diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 909cfa5c7..612e0c3c9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,7 +57,13 @@ jobs: name: Build on ${{matrix.os}} for ${{ matrix.label }} ${{ matrix.workspace-loader }} ${{ matrix.use-transparent-compiler }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + if: ${{ github.event_name == 'pull_request' }} + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/checkout@v4 + if: ${{ github.event_name == 'push' }} # setup .NET per the repo global.json - name: Setup .NET diff --git a/.vscode/launch.json b/.vscode/launch.json index 58e99a0d4..114bf6b95 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,10 @@ "type": "coreclr", "request": "launch", "program": "${workspaceFolder}/src/FsAutoComplete/bin/Debug/net8.0/fsautocomplete.dll", - "args": ["--mode", "lsp"], + "args": [ + "--mode", + "lsp" + ], "cwd": "${workspaceFolder}", "console": "internalConsole", "stopAtEntry": false, @@ -47,7 +50,11 @@ } }, "enableStepFiltering": false, - "args": ["--debug", "--filter", "FSAC.lsp.${input:loader}.${input:compiler}.${input:testName}"], + "args": [ + "--debug", + "--filter", + "FSAC.lsp.${input:loader}.${input:compiler}.${input:testName}" + ], "preLaunchTask": "build debug" }, { @@ -69,7 +76,11 @@ } }, "enableStepFiltering": false, - "args": ["--debug", "--filter", "FSAC.general.${input:testName}"], + "args": [ + "--debug", + "--filter", + "FSAC.general.${input:testName}" + ], "preLaunchTask": "build debug" } ], @@ -77,21 +88,30 @@ { "id": "tfm", "description": "The TFM of the test to run", - "options": ["net8.0", "net9.0"], + "options": [ + "net8.0", + "net9.0" + ], "default": "net8.0", "type": "pickString" }, { "id": "loader", "description": "The loader to use for the test", - "options": ["Ionide WorkspaceLoader", "MSBuild Project Graph WorkspaceLoader"], + "options": [ + "Ionide WorkspaceLoader", + "MSBuild Project Graph WorkspaceLoader" + ], "default": "WorkspaceLoader", "type": "pickString" }, { "id": "compiler", "description": "The compiler to use", - "options": ["BackgroundCompiler", "TransparentCompiler"], + "options": [ + "BackgroundCompiler", + "TransparentCompiler" + ], "default": "BackgroundCompiler", "type": "pickString" }, @@ -101,4 +121,4 @@ "type": "promptString" } ] -} +} \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 33d31a741..203521a26 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,6 +14,7 @@ $(WarnOn);3390 $(NoWarn);NU1701 + $(NoWarn);NU1510 true $(MSBuildThisFileDirectory)CHANGELOG.md diff --git a/global.json b/global.json index 689afa9ed..c08f7e6f5 100644 --- a/global.json +++ b/global.json @@ -4,4 +4,4 @@ "rollForward": "latestMajor", "allowPrerelease": true } -} +} \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 00d2be6f3..a61f3638a 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -10,8 +10,8 @@ $(NoWarn);NU5104 $(NoWarn);FS0075 - - $(NoWarn);FS3391 + + $(NoWarn);FS3391;NU1510 $(OtherFlags) --test:GraphBasedChecking --test:DumpCheckingGraph diff --git a/src/FsAutoComplete.Core/Commands.fs b/src/FsAutoComplete.Core/Commands.fs index b31c157d7..53817e0fe 100644 --- a/src/FsAutoComplete.Core/Commands.fs +++ b/src/FsAutoComplete.Core/Commands.fs @@ -84,6 +84,10 @@ type NotificationEvent = | Canceled of errorMessage: string | FileParsed of string | TestDetected of file: string * tests: TestAdapter.TestAdapterEntry[] + | NestedLanguagesFound of + file: string * + version: int * + nestedLanguages: NestedLanguages.NestedLanguageDocument array module Commands = open System.Collections.Concurrent diff --git a/src/FsAutoComplete.Core/FsAutoComplete.Core.fsproj b/src/FsAutoComplete.Core/FsAutoComplete.Core.fsproj index 04812cf9f..c884bd2e9 100644 --- a/src/FsAutoComplete.Core/FsAutoComplete.Core.fsproj +++ b/src/FsAutoComplete.Core/FsAutoComplete.Core.fsproj @@ -4,6 +4,7 @@ net8.0;net9.0 false $(NoWarn);FS0057 + preview @@ -61,6 +62,7 @@ + diff --git a/src/FsAutoComplete.Core/NestedLanguages.fs b/src/FsAutoComplete.Core/NestedLanguages.fs new file mode 100644 index 000000000..7ac9166f3 --- /dev/null +++ b/src/FsAutoComplete.Core/NestedLanguages.fs @@ -0,0 +1,336 @@ +module FsAutoComplete.NestedLanguages + +open FsAutoComplete.Logging +open FsToolkit.ErrorHandling +open FSharp.Compiler.Syntax +open FSharp.Compiler.Text +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Symbols + +#nowarn "57" // from-end slicing + +let logger = LogProvider.getLoggerByName "NestedLanguages" + +type private StringParameter = + { methodIdent: LongIdent + parameterRange: Range + rangesToRemove: Range array + parameterPosition: int } + +let private pattern = + System.Text.RegularExpressions.Regex( + @"(?%)(?[0\+\-]?)(?\d*)(\.(?\d+))?(?[bscdiuxXoBeEfFgGMOAat%])", + System.Text.RegularExpressions.RegexOptions.Compiled + ) + +let private isFormatSpecifier (text: string) = pattern.Match(text) + +/// for virtual documents based on interpolated strings we need to remove two kinds of trivia from the overall string portions. +/// * for interpolation expressions we need to remove the entire range of the expression - this will be invisible to the virtual document since it is F# code. +/// * for string literals, we need to remove the prefix/suffix tokens (quotes, interpolation brackets, format specifiers, etc) so that the only content visible +/// to the virtual document is the actual string content. +/// +/// FEATURE GAP: we don't know in the AST the locations of the string trivia, so we can't support format specifiers or variable-length +/// interpolation start/end tokens. +let private discoverRangesToRemoveForInterpolatedString + (stringKind: SynStringKind) + (parts: SynInterpolatedStringPart[]) + = + parts + |> Array.indexed + |> Array.collect (fun (index, part) -> + match part with + | SynInterpolatedStringPart.FillExpr(fillExpr = e) -> [| e.Range |] + // for the first part we have whatever 'leading' element on the left and a trailing interpolation piece (which can include a format specifier) on the right + | SynInterpolatedStringPart.String(value = value; range = range) when index = 0 -> + [| + // leading tokens adjustment + // GAP: we don't know how many interpolation $ or " there are, so we are guessing + let startRange = + match stringKind with + | SynStringKind.Regular -> + // 'regular' means $" leading identifier + range.WithEnd(range.Start.WithColumn(range.StartColumn + 2)) + | SynStringKind.TripleQuote -> + // 'triple quote' means $""" leading identifier + range.WithEnd(range.Start.WithColumn(range.StartColumn + 4)) + // there's no such thing as a verbatim interpolated string + | SynStringKind.Verbatim -> range + + + // GAP: we don't know if there's a format specifier at the front: %[flags][width][.precision][type] + // flags are 0,+,-, width is an integer, precision is `.` followed by an integer, type is one of the following: b, s, c, d, i, u, x, X, o, B, e, E, f, F, g, G, M, O, A, a, t, % + let adjustedRangeForFormatSpecifier = + let formatmatch = isFormatSpecifier value + + if formatmatch.Success then + startRange.WithEnd(startRange.End.WithColumn(startRange.StartColumn + formatmatch.Index - 1)) + else + startRange + + adjustedRangeForFormatSpecifier + + // trailing token adjustment- only an opening bracket { + // GAP: this is the feature gap - we don't know about format specifiers + range.WithStart(range.End.WithColumn(range.EndColumn - 1)) + + |] + // for the last part we have a single-character interpolation bracket on the left and the 'trailing' string elements on the right + | SynInterpolatedStringPart.String(range = range) when index = parts.Length - 1 -> + [| + // leading token adjustment - only a closing bracket } + range.WithEnd(range.Start.WithColumn(range.StartColumn + 1)) + + // trailing tokens adjustment + // GAP: we don't know how many """ to adjust for triple-quote interpolated string endings + match stringKind with + | SynStringKind.Regular -> + // 'regular' means trailing identifier " + range.WithStart(range.End.WithColumn(range.EndColumn - 1)) + | SynStringKind.TripleQuote -> + // 'triple quote' means trailing identifier """ + range.WithStart(range.End.WithColumn(range.EndColumn - 3)) + // no such thing as verbatim interpolated strings + | SynStringKind.Verbatim -> () |] + // for all other parts we have a single-character interpolation bracket on the left and a trailing interpolation piece (which can include a format specifier) on the right + | SynInterpolatedStringPart.String(range = range) -> + [| + // leading token adjustment - only a closing bracket } + range.WithEnd(range.Start.WithColumn(range.StartColumn + 1)) + // trailing token adjustment- only an opening bracket { + // GAP: this is the feature gap - we don't know about format specifiers here + range.WithStart(range.End.WithColumn(range.EndColumn - 1)) |]) + +let private (|Ident|_|) (e: SynExpr) = + match e with + | SynExpr.Ident(ident) -> Some([ ident ]) + | SynExpr.LongIdent(longDotId = SynLongIdent(id = ident)) -> Some ident + | _ -> None + +/// in order for nested documents to be recognized as their document types, the string quotes (and other tokens) need to be removed +/// from the actual string content. +let private removeStringTokensFromStringRange (kind: SynStringKind) (range: Range) : Range array = + match kind with + | SynStringKind.Regular -> + // we need to trim the double-quote off of the start and end + [| Range.mkRange range.FileName range.Start (range.Start.WithColumn(range.StartColumn + 1)) + Range.mkRange range.FileName (range.End.WithColumn(range.EndColumn - 1)) range.End |] + | SynStringKind.Verbatim -> + // we need to trim the @+double-quote off of the start and double-quote off the end + [| Range.mkRange range.FileName range.Start (range.Start.WithColumn(range.StartColumn + 2)) + Range.mkRange range.FileName (range.End.WithColumn(range.EndColumn - 1)) range.End |] + | SynStringKind.TripleQuote -> + // we need to trim the @+double-quote off of the start and double-quote off the end + [| Range.mkRange range.FileName range.Start (range.Start.WithColumn(range.StartColumn + 2)) + Range.mkRange range.FileName (range.End.WithColumn(range.EndColumn - 1)) range.End |] + +let rec private (|IsApplicationWithStringParameters|_|) (e: SynExpr) : StringParameter array option = + match e with + // lines inside a binding + // let doThing () = + // c.M("
") + // c.M($"
{1 + 1}") + // "
" |> c.M + // $"
{1 + 1}" |> c.M + | SynExpr.Sequential(expr1 = e1; expr2 = e2) -> + let e1Parameters = + match e1 with + | IsApplicationWithStringParameters(stringParameter) when not (Array.isEmpty stringParameter) -> + ValueSome stringParameter + | _ -> ValueNone + + let e2Parameters = + match e2 with + | IsApplicationWithStringParameters(stringParameter) when not (Array.isEmpty stringParameter) -> + ValueSome stringParameter + | _ -> ValueNone + + match e1Parameters, e2Parameters with + | ValueNone, ValueNone -> None + | ValueSome e1Parameters, ValueNone -> Some e1Parameters + | ValueNone, ValueSome e2Parameters -> Some e2Parameters + | ValueSome e1Parameters, ValueSome e2Parameters -> Some(Array.append e1Parameters e2Parameters) + + // method call with string parameter - c.M("
") + | SynExpr.App( + funcExpr = Ident(ident); argExpr = SynExpr.Paren(expr = SynExpr.Const(SynConst.String(_text, kind, range), _))) + // method call with string parameter - c.M "
" + | SynExpr.App(funcExpr = Ident(ident); argExpr = SynExpr.Const(SynConst.String(_text, kind, range), _)) -> + Some( + [| { methodIdent = ident + parameterRange = range + rangesToRemove = removeStringTokensFromStringRange kind range + parameterPosition = 0 } |] + ) + // method call with interpolated string parameter - c.M $"
{1 + 1}" + | SynExpr.App( + funcExpr = Ident(ident) + argExpr = SynExpr.Paren( + expr = SynExpr.InterpolatedString(contents = parts; synStringKind = stringKind; range = range))) + // method call with interpolated string parameter - c.M($"
{1 + 1}") + | SynExpr.App( + funcExpr = Ident(ident) + argExpr = SynExpr.InterpolatedString(contents = parts; synStringKind = stringKind; range = range)) -> + let rangesToRemove = + discoverRangesToRemoveForInterpolatedString stringKind (Array.ofList parts) + + Some( + [| { methodIdent = ident + parameterRange = range + rangesToRemove = rangesToRemove + parameterPosition = 0 } |] + ) + // piped method call with string parameter - "
" |> c.M + // piped method call with interpolated parameter - $"
{1 + 1}" |> c.M + // method call with multiple string or interpolated string parameters (this also covers the case when not all parameters of the member are strings) + // c.M("
", true) and/or c.M(true, "
") + // piped method call with multiple string or interpolated string parameters (this also covers the case when not all parameters of the member are strings) + // let binding that is a string value that has the StringSyntax attribute on it - [] let html = "
" + // all of the above but with literals + | _ -> None + +/// +type private StringParameterFinder() = + inherit SyntaxCollectorBase() + + let languages = ResizeArray() + + override _.WalkBinding(binding) = + match binding with + | SynBinding(expr = IsApplicationWithStringParameters(stringParameters)) -> languages.AddRange stringParameters + | _ -> () + + + override _.WalkSynModuleDecl(decl) = + match decl with + | SynModuleDecl.Expr(expr = IsApplicationWithStringParameters(stringParameters)) -> + languages.AddRange stringParameters + | _ -> () + + member _.NestedLanguages = languages.ToArray() + + +let private findParametersForParseTree (p: ParsedInput) = + let walker = StringParameterFinder() + walkAst walker p + walker.NestedLanguages + +let private (|IsStringSyntax|_|) (a: FSharpAttribute) = + match a.AttributeType.FullName with + | "System.Diagnostics.CodeAnalysis.StringSyntaxAttribute" -> + match a.ConstructorArguments |> Seq.tryHead with + | Some(_ty, languageValue) -> Some(languageValue :?> string) + | _ -> None + | _ -> None + +type NestedLanguageDocument = + { Language: string + Ranges: Range array } + +let rangeMinusRanges (totalRange: Range) (rangesToRemove: Range array) : Range array = + match rangesToRemove with + | [||] -> [| totalRange |] + | _ -> + let mutable returnVal = ResizeArray() + let mutable currentStart = totalRange.Start + + for r in rangesToRemove do + if currentStart = r.Start then + // no gaps, so just advance the current pointer + currentStart <- r.End + else + returnVal.Add(Range.mkRange totalRange.FileName currentStart r.Start) + currentStart <- r.End + + // only need to add the final range if there is a gap between where we are and the end of the string + if currentStart <> totalRange.End then + returnVal.Add(Range.mkRange totalRange.FileName currentStart totalRange.End) + + returnVal.ToArray() + +let private parametersThatAreStringSyntax + (parameters: StringParameter array, checkResults: FSharpCheckFileResults, text: VolatileFile) + : NestedLanguageDocument array Async = + async { + let returnVal = ResizeArray() + + for p in parameters do + logger.info ( + Log.setMessageI + $"Checking parameter: {p.parameterRange.ToString():range} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" + ) + + let lastPart = p.methodIdent[^0] + let endOfFinalTextToken = lastPart.idRange.End + + match text.Source.GetLine(endOfFinalTextToken) with + | None -> () + | Some lineText -> + + match + checkResults.GetSymbolUseAtLocation( + endOfFinalTextToken.Line, + endOfFinalTextToken.Column, + lineText, + p.methodIdent |> List.map (fun x -> x.idText) + ) + with + | None -> () + | Some usage -> + logger.info ( + Log.setMessageI + $"Found symbol use: {usage.Symbol.ToString():symbol} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" + ) + + let sym = usage.Symbol + // todo: keep MRU map of symbols to parameters and MRU of parameters to StringSyntax status + + match sym with + | :? FSharpMemberOrFunctionOrValue as mfv -> + let allParameters = mfv.CurriedParameterGroups |> Seq.collect id |> Seq.toArray + let fsharpP = allParameters[p.parameterPosition] + + logger.info ( + Log.setMessageI + $"Found parameter: {fsharpP.ToString():symbol} with {fsharpP.Attributes.Count:attributeCount} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" + ) + + match fsharpP.Attributes |> Seq.tryPick (|IsStringSyntax|_|) with + | Some language -> + returnVal.Add + { Language = language + Ranges = rangeMinusRanges p.parameterRange p.rangesToRemove } + | None -> () + | _ -> () + + return returnVal.ToArray() + } + +/// to find all of the nested language highlights, we're going to do the following: +/// * find all of the interpolated strings or string literals in the file that are in parameter-application positions +/// * get the method calls happening at those positions to check if that method has the StringSyntaxAttribute +/// * if so, return a) the language in the StringSyntaxAttribute, and b) the range of the interpolated string +let findNestedLanguages (tyRes: ParseAndCheckResults, text: VolatileFile) : NestedLanguageDocument array Async = + async { + let potentialParameters = findParametersForParseTree tyRes.GetAST + + logger.info ( + Log.setMessageI + $"Found {potentialParameters.Length:stringParams} potential parameters in {text.FileName:filename}@{text.Version:version}" + ) + + for p in potentialParameters do + logger.info ( + Log.setMessageI + $"Potential parameter: {p.parameterRange.ToString():range} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" + ) + + let! actualStringSyntaxParameters = parametersThatAreStringSyntax (potentialParameters, tyRes.GetCheckResults, text) + + logger.info ( + Log.setMessageI + $"Found {actualStringSyntaxParameters.Length:stringParams} actual parameters in {text.FileName:filename}@{text.Version:version}" + ) + + return actualStringSyntaxParameters + } diff --git a/src/FsAutoComplete.Core/UntypedAstUtils.fs b/src/FsAutoComplete.Core/UntypedAstUtils.fs index d06bc6af3..7722b0726 100644 --- a/src/FsAutoComplete.Core/UntypedAstUtils.fs +++ b/src/FsAutoComplete.Core/UntypedAstUtils.fs @@ -26,11 +26,12 @@ module Syntax = loop [] pats + [] type SyntaxCollectorBase() = abstract WalkSynModuleOrNamespace: SynModuleOrNamespace -> unit default _.WalkSynModuleOrNamespace _ = () abstract WalkAttribute: SynAttribute -> unit - default _.WalkAttribute _ = () + default _.WalkAttribute(_: SynAttribute) = () abstract WalkSynModuleDecl: SynModuleDecl -> unit default _.WalkSynModuleDecl _ = () abstract WalkExpr: SynExpr -> unit @@ -59,8 +60,10 @@ module Syntax = default _.WalkClause _ = () abstract WalkInterpolatedStringPart: SynInterpolatedStringPart -> unit default _.WalkInterpolatedStringPart _ = () + abstract WalkMeasure: SynMeasure -> unit - default _.WalkMeasure _ = () + default _.WalkMeasure(_: SynMeasure) = () + abstract WalkComponentInfo: SynComponentInfo -> unit default _.WalkComponentInfo _ = () abstract WalkTypeDefnSigRepr: SynTypeDefnSigRepr -> unit diff --git a/src/FsAutoComplete.Core/UntypedAstUtils.fsi b/src/FsAutoComplete.Core/UntypedAstUtils.fsi index d99122114..40cab0b89 100644 --- a/src/FsAutoComplete.Core/UntypedAstUtils.fsi +++ b/src/FsAutoComplete.Core/UntypedAstUtils.fsi @@ -3,36 +3,65 @@ namespace FSharp.Compiler module Syntax = open FSharp.Compiler.Syntax + [] type SyntaxCollectorBase = new: unit -> SyntaxCollectorBase abstract WalkSynModuleOrNamespace: SynModuleOrNamespace -> unit + default WalkSynModuleOrNamespace: SynModuleOrNamespace -> unit abstract WalkAttribute: SynAttribute -> unit + default WalkAttribute: SynAttribute -> unit abstract WalkSynModuleDecl: SynModuleDecl -> unit + default WalkSynModuleDecl: SynModuleDecl -> unit abstract WalkExpr: SynExpr -> unit + default WalkExpr: SynExpr -> unit abstract WalkTypar: SynTypar -> unit + default WalkTypar: SynTypar -> unit abstract WalkTyparDecl: SynTyparDecl -> unit + default WalkTyparDecl: SynTyparDecl -> unit abstract WalkTypeConstraint: SynTypeConstraint -> unit + default WalkTypeConstraint: SynTypeConstraint -> unit abstract WalkType: SynType -> unit + default WalkType: SynType -> unit abstract WalkMemberSig: SynMemberSig -> unit + default WalkMemberSig: SynMemberSig -> unit abstract WalkPat: SynPat -> unit + default WalkPat: SynPat -> unit abstract WalkValTyparDecls: SynValTyparDecls -> unit + default WalkValTyparDecls: SynValTyparDecls -> unit abstract WalkBinding: SynBinding -> unit + default WalkBinding: SynBinding -> unit abstract WalkSimplePat: SynSimplePat -> unit + default WalkSimplePat: SynSimplePat -> unit abstract WalkInterfaceImpl: SynInterfaceImpl -> unit + default WalkInterfaceImpl: SynInterfaceImpl -> unit abstract WalkClause: SynMatchClause -> unit + default WalkClause: SynMatchClause -> unit abstract WalkInterpolatedStringPart: SynInterpolatedStringPart -> unit + default WalkInterpolatedStringPart: SynInterpolatedStringPart -> unit abstract WalkMeasure: SynMeasure -> unit + default WalkMeasure: SynMeasure -> unit abstract WalkComponentInfo: SynComponentInfo -> unit + default WalkComponentInfo: SynComponentInfo -> unit abstract WalkTypeDefnSigRepr: SynTypeDefnSigRepr -> unit + default WalkTypeDefnSigRepr: SynTypeDefnSigRepr -> unit abstract WalkUnionCaseType: SynUnionCaseKind -> unit + default WalkUnionCaseType: SynUnionCaseKind -> unit abstract WalkEnumCase: SynEnumCase -> unit + default WalkEnumCase: SynEnumCase -> unit abstract WalkField: SynField -> unit + default WalkField: SynField -> unit abstract WalkTypeDefnSimple: SynTypeDefnSimpleRepr -> unit + default WalkTypeDefnSimple: SynTypeDefnSimpleRepr -> unit abstract WalkValSig: SynValSig -> unit + default WalkValSig: SynValSig -> unit abstract WalkMember: SynMemberDefn -> unit + default WalkMember: SynMemberDefn -> unit abstract WalkUnionCase: SynUnionCase -> unit + default WalkUnionCase: SynUnionCase -> unit abstract WalkTypeDefnRepr: SynTypeDefnRepr -> unit + default WalkTypeDefnRepr: SynTypeDefnRepr -> unit abstract WalkTypeDefn: SynTypeDefn -> unit + default WalkTypeDefn: SynTypeDefn -> unit val walkAst: walker: SyntaxCollectorBase -> input: ParsedInput -> unit diff --git a/src/FsAutoComplete/LspServers/AdaptiveServerState.fs b/src/FsAutoComplete/LspServers/AdaptiveServerState.fs index c521a4eb7..c5f5dc67c 100644 --- a/src/FsAutoComplete/LspServers/AdaptiveServerState.fs +++ b/src/FsAutoComplete/LspServers/AdaptiveServerState.fs @@ -563,19 +563,26 @@ type AdaptiveState Loggers.analyzers.error (Log.setMessageI $"Run failed for {file:file}" >> Log.addExn ex) } + let checkForNestedLanguages _config parseAndCheckResults (volatileFile: VolatileFile) = + async { + let! languages = NestedLanguages.findNestedLanguages (parseAndCheckResults, volatileFile) + + notifications.Trigger( + NotificationEvent.NestedLanguagesFound(volatileFile.FileName, volatileFile.Version, languages), + CancellationToken.None + ) + } + do disposables.Add <| fileChecked.Publish.Subscribe(fun (parseAndCheck, volatileFile, ct) -> if volatileFile.Source.Length = 0 then () // Don't analyze and error on an empty file else - async { - let config = config |> AVal.force - do! builtInCompilerAnalyzers config volatileFile parseAndCheck - do! runAnalyzers config parseAndCheck volatileFile - - } - |> Async.StartWithCT ct) + let config = config |> AVal.force + Async.Start(builtInCompilerAnalyzers config volatileFile parseAndCheck, ct) + Async.Start(runAnalyzers config parseAndCheck volatileFile, ct) + Async.Start(checkForNestedLanguages config parseAndCheck volatileFile, ct)) let handleCommandEvents (n: NotificationEvent, ct: CancellationToken) = @@ -784,6 +791,19 @@ type AdaptiveState { File = Path.LocalPathToUri file Tests = tests |> Array.map map } |> lspClient.NotifyTestDetected + | NotificationEvent.NestedLanguagesFound(file, version, nestedLanguages) -> + let uri = Path.LocalPathToUri file + + do! + lspClient.NotifyNestedLanguages( + { TextDocument = { Version = version; Uri = uri } + NestedLanguages = + nestedLanguages + |> Array.map (fun n -> + { Language = n.Language + Ranges = n.Ranges |> Array.map fcsRangeToLsp }) } + ) + with ex -> logger.error ( Log.setMessage "Exception while handling command event {evt}: {ex}" diff --git a/src/FsAutoComplete/LspServers/FSharpLspClient.fs b/src/FsAutoComplete/LspServers/FSharpLspClient.fs index 5ab36cd5c..4c41d6f7e 100644 --- a/src/FsAutoComplete/LspServers/FSharpLspClient.fs +++ b/src/FsAutoComplete/LspServers/FSharpLspClient.fs @@ -3,7 +3,6 @@ namespace FsAutoComplete.Lsp open FsAutoComplete open Ionide.LanguageServerProtocol -open Ionide.LanguageServerProtocol.Types.LspResult open Ionide.LanguageServerProtocol.Server open Ionide.LanguageServerProtocol.Types open FsAutoComplete.LspHelpers @@ -13,6 +12,14 @@ open FsAutoComplete.Utils open System.Threading open IcedTasks +type NestedLanguage = + { Language: string + Ranges: Types.Range[] } + +type TextDocumentNestedLanguages = + { TextDocument: VersionedTextDocumentIdentifier + NestedLanguages: NestedLanguage[] } + type FSharpLspClient(sendServerNotification: ClientNotificationSender, sendServerRequest: ClientRequestSender) = @@ -64,6 +71,10 @@ type FSharpLspClient(sendServerNotification: ClientNotificationSender, sendServe member __.NotifyTestDetected(p: TestDetectedNotification) = sendServerNotification "fsharp/testDetected" (box p) |> Async.Ignore + member _.NotifyNestedLanguages(p: TextDocumentNestedLanguages) = + sendServerNotification "fsharp/textDocument/nestedLanguages" (box p) + |> Async.Ignore + member x.CodeLensRefresh() = match x.ClientCapabilities with | Some { Workspace = Some { CodeLens = Some { RefreshSupport = Some true } } } -> diff --git a/src/FsAutoComplete/LspServers/FSharpLspClient.fsi b/src/FsAutoComplete/LspServers/FSharpLspClient.fsi index 4fbadc76e..0d69ca6bb 100644 --- a/src/FsAutoComplete/LspServers/FSharpLspClient.fsi +++ b/src/FsAutoComplete/LspServers/FSharpLspClient.fsi @@ -8,6 +8,14 @@ open System open System.Threading open IcedTasks +type NestedLanguage = + { Language: string + Ranges: Types.Range[] } + +type TextDocumentNestedLanguages = + { TextDocument: VersionedTextDocumentIdentifier + NestedLanguages: NestedLanguage[] } + type FSharpLspClient = new: sendServerNotification: ClientNotificationSender * sendServerRequest: ClientRequestSender -> FSharpLspClient inherit LspClient @@ -32,9 +40,11 @@ type FSharpLspClient = member NotifyDocumentAnalyzed: p: DocumentAnalyzedNotification -> Async member NotifyTestDetected: p: TestDetectedNotification -> Async member CodeLensRefresh: unit -> Async + member NotifyNestedLanguages: p: TextDocumentNestedLanguages -> Async override WorkDoneProgressCreate: ProgressToken -> AsyncLspResult override Progress: ProgressToken * 'Progress -> Async + /// /// Represents a progress report that can be used to report progress to the client. /// diff --git a/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs new file mode 100644 index 000000000..e8b73772d --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs @@ -0,0 +1,207 @@ +module FsAutoComplete.Tests.NestedLanguageTests + +open Expecto +open Utils.ServerTests +open Helpers +open Utils.Server +open System +open Ionide.LanguageServerProtocol.Types + +type Document with + + member x.NestedLanguages = + x.Server.Events + |> Document.typedEvents ("fsharp/textDocument/nestedLanguages") + |> Observable.filter (fun n -> n.TextDocument = x.VersionedTextDocumentIdentifier) + +let private getDocumentText (lines: string[]) (ranges: Range array) : string = + ranges + |> Array.map (fun r -> + let startLine = lines.[int r.Start.Line] + let endLine = lines.[int r.End.Line] + + if r.Start.Line = r.End.Line then + startLine.Substring(int r.Start.Character, int (r.End.Character - r.Start.Character)) + else + let start = startLine.Substring(int r.Start.Character) + let ``end`` = endLine.Substring(0, int r.End.Character) + + let middle = + lines.[int (r.Start.Line + 1u) .. int (r.End.Line - 1u)] + |> Array.map (fun l -> l.Trim()) + + let middle = String.Join(" ", middle) + start + middle + ``end``) + |> String.concat "\n" + + + +let private contentErrorMessage + (actual: FsAutoComplete.Lsp.NestedLanguage array) + (expected: FsAutoComplete.Lsp.NestedLanguage array) + (sourceText: string) + = + let builder = System.Text.StringBuilder() + let lines = sourceText.Split([| '\n'; '\r' |], StringSplitOptions.None) + + builder.AppendLine "Expected nested documents to be equivalent, but found differences" + |> ignore + + if actual.Length <> expected.Length then + builder.AppendLine $"Expected %d{expected.Length} nested languages, but found %d{actual.Length}" + |> ignore + else + for (index, (expected, actual)) in Array.zip expected actual |> Array.indexed do + if expected.Language <> actual.Language then + builder.AppendLine + $"Expected document #${index}'s language to be %s{expected.Language}, but was %s{actual.Language}" + |> ignore + + let expectedText = getDocumentText lines expected.Ranges + let actualText = getDocumentText lines actual.Ranges + + builder.AppendLine $"Expected document #{index} to be \n\t%s{expectedText}\nbut was\n\t%s{actualText}" + |> ignore + + builder.ToString() + +let hasLanguages name source expectedLanguages server = + testAsync name { + let! (doc, diags) = server |> Server.createUntitledDocument source + Expect.isEmpty diags "no diagnostics" + let! nestedLanguages = doc.NestedLanguages |> Async.AwaitObservable + + let mappedExpectedLanguages: FsAutoComplete.Lsp.NestedLanguage array = + expectedLanguages + |> Array.map (fun (l, rs) -> + { Language = l + Ranges = + rs + |> Array.map (fun ((sl, sc), (el, ec)) -> + { Start = { Line = sl; Character = sc } + End = { Line = el; Character = ec } }) }) + + Expect.equal + nestedLanguages.NestedLanguages + mappedExpectedLanguages + (contentErrorMessage nestedLanguages.NestedLanguages mappedExpectedLanguages source) + } + +let tests state = + testList + "nested languages" + [ testList + "unsupported scenarios" + // pending because class members don't return attributes in the FCS Parameter API + [ serverTestList "class member" state defaultConfigDto None (fun server -> + [ hasLanguages + "BCL type" + """ + let b = System.UriBuilder("https://google.com") + """ + [| ("uri", [| (1u, 38u), (1u, 58u) |]) |] + server + + hasLanguages + "F#-defined type" + """ + type Foo() = + member x.Boo([] uriString: string) = () + let f = new Foo() + let u = f.Boo("https://google.com") + """ + [| ("uri", [| (4u, 26u), (4u, 46u) |]) |] + server ]) + serverTestList "functions" state defaultConfigDto None (fun server -> + [ hasLanguages + "interpolated string with format specifier" + """ + let uri ([]s: string) = () + let u = uri $"https://%b{true}.com" + """ + [| ("uri", [| (2u, 26u), (2u, 34u); (2u, 42u), (2u, 46u) |]) |] + server + + + // commented out because I can't figure out how to get the new string interpolation working + // hasLanguages + // "more than triple-quoted interpolated string with format specifier" + // """ + // let uri ([]s: string) = () + // let u = uri $$""""https://%b{{true}}.com"""" + // """ + // [| ("uri", [| (2, 24), (2, 35); (2, 39), (2, 45) |]) |] + // server + ]) ] + testList + "FSharp Code" + [ serverTestList "let bound function member" state defaultConfigDto None (fun server -> + [ hasLanguages + "normal string value" + """ + let boo ([] uriString: string) = () + let u = boo "https://google.com" + """ + // note for reader - 24 is the start quote, 44 is the end quote, so we want a doc including 25-43 + [| ("uri", [| (2u, 25u), (2u, 43u) |]) |] + server + + hasLanguages + "verbatim string value" + """ + let boo ([] uriString: string) = () + let u = boo @"https://google.com" + """ + [| ("uri", [| (2u, 26u), (2u, 44u) |]) |] + server + + hasLanguages + "triple-quote string value" + """ + let boo ([] uriString: string) = () + let u = boo "https://google.com" + """ + [| ("uri", [| (2u, 25u), (2u, 43u) |]) |] + server + + hasLanguages + "simple interpolated string" + """ + let uri ([]s: string) = () + let u = uri $"https://{true}.com" + """ + [| ("uri", [| (2u, 26u), (2u, 34u); (2u, 40u), (2u, 44u) |]) |] + server + + // commented out because I can't figure out how to get the new string interpolation working + // hasLanguages + // "triple-quote interpolated string" + // """ + // let uri ([]s: string) = () + // let u = uri $\"\"\"https://{true}.com"\"\\" + // """ + // [| ("uri", [| (2, 24), (2, 35); (2, 39), (2, 45) |]) |] + // server + + + + // commented out because I can't figure out how to get the new string interpolation working + // hasLanguages + // "triple-quoted interpolated string with format specifier" + // """ + // let uri ([]s: string) = () + // let u = uri $"https://%b{true}.com" + // """ + // [| ("uri", [| (2, 24), (2, 35); (2, 39), (2, 45) |]) |] + // server + + hasLanguages + "multiple languages in the same document" + """ + let html ([]s: string) = () + let sql ([]s: string) = () + let myWebPage = html "wow" + let myQuery = sql "select * from accounts where net_worth > 1000000" + """ + [| ("html", [| (3u, 34u), (3u, 50u) |]); ("sql", [| (4u, 31u), (4u, 79u) |]) |] + server ]) ] ] diff --git a/test/FsAutoComplete.Tests.Lsp/Program.fs b/test/FsAutoComplete.Tests.Lsp/Program.fs index e85198116..4b9fa2b9e 100644 --- a/test/FsAutoComplete.Tests.Lsp/Program.fs +++ b/test/FsAutoComplete.Tests.Lsp/Program.fs @@ -53,7 +53,7 @@ let loaders = let adaptiveLspServerFactory toolsPath workspaceLoaderFactory sourceTextFactory = - Helpers.createAdaptiveServer (fun () -> workspaceLoaderFactory toolsPath) sourceTextFactory + Helpers.createAdaptiveServer (fun () -> workspaceLoaderFactory toolsPath) sourceTextFactory true let sourceTextFactory: ISourceTextFactory = RoslynSourceTextFactory() @@ -81,69 +81,67 @@ let lspTests = testList $"{loaderName}" [ - for (compilerName, useTransparentCompiler) in compilers do - testList - $"{compilerName}" - [ - Templates.tests () - let createServer () = - adaptiveLspServerFactory toolsPath workspaceLoaderFactory sourceTextFactory useTransparentCompiler - - initTests createServer - closeTests createServer - - Utils.Tests.Server.tests createServer - Utils.Tests.CursorbasedTests.tests createServer - - CodeLens.tests createServer - documentSymbolTest createServer - workspaceSymbolTest createServer - Completion.autocompleteTest createServer - Completion.autoOpenTests createServer - Completion.fullNameExternalAutocompleteTest createServer - foldingTests createServer - tooltipTests createServer - Highlighting.tests createServer - scriptPreviewTests createServer - scriptEvictionTests createServer - scriptProjectOptionsCacheTests createServer - dependencyManagerTests createServer - interactiveDirectivesUnitTests - - // commented out because FSDN is down - //fsdnTest createServer - - //linterTests createServer - uriTests - formattingTests createServer - analyzerTests createServer - signatureTests createServer - SignatureHelp.tests createServer - InlineHints.tests createServer - CodeFixTests.Tests.tests sourceTextFactory createServer - Completion.tests createServer - GoTo.tests createServer - - FindReferences.tests createServer - Rename.tests createServer - - InfoPanelTests.docFormattingTest createServer - DetectUnitTests.tests createServer - XmlDocumentationGeneration.tests createServer - InlayHintTests.tests createServer - DependentFileChecking.tests createServer - UnusedDeclarationsTests.tests createServer - EmptyFileTests.tests createServer - CallHierarchy.tests createServer - diagnosticsTest createServer - ] ] ] + Templates.tests () + let createServer () = + adaptiveLspServerFactory toolsPath workspaceLoaderFactory sourceTextFactory + + initTests createServer + closeTests createServer + + Utils.Tests.Server.tests createServer + Utils.Tests.CursorbasedTests.tests createServer + + CodeLens.tests createServer + documentSymbolTest createServer + workspaceSymbolTest createServer + Completion.autocompleteTest createServer + Completion.autoOpenTests createServer + Completion.fullNameExternalAutocompleteTest createServer + foldingTests createServer + tooltipTests createServer + Highlighting.tests createServer + scriptPreviewTests createServer + scriptEvictionTests createServer + scriptProjectOptionsCacheTests createServer + dependencyManagerTests createServer + interactiveDirectivesUnitTests + + // commented out because FSDN is down + //fsdnTest createServer + + //linterTests createServer + uriTests + formattingTests createServer + analyzerTests createServer + signatureTests createServer + SignatureHelp.tests createServer + InlineHints.tests createServer + CodeFixTests.Tests.tests sourceTextFactory createServer + Completion.tests createServer + GoTo.tests createServer + + FindReferences.tests createServer + Rename.tests createServer + + InfoPanelTests.docFormattingTest createServer + DetectUnitTests.tests createServer + XmlDocumentationGeneration.tests createServer + InlayHintTests.tests createServer + DependentFileChecking.tests createServer + UnusedDeclarationsTests.tests createServer + EmptyFileTests.tests createServer + CallHierarchy.tests createServer + NestedLanguageTests.tests createServer + diagnosticsTest createServer + ] ] /// Tests that do not require a LSP server -let generalTests = testList "general" [ - testList (nameof (Utils)) [ Utils.Tests.Utils.tests; Utils.Tests.TextEdit.tests ] - InlayHintTests.explicitTypeInfoTests sourceTextFactory - FindReferences.tryFixupRangeTests sourceTextFactory -] +let generalTests = + testList + "general" + [ testList (nameof (Utils)) [ Utils.Tests.Utils.tests; Utils.Tests.TextEdit.tests ] + InlayHintTests.explicitTypeInfoTests sourceTextFactory + FindReferences.tryFixupRangeTests sourceTextFactory ] [] let tests = testList "FSAC" [ diff --git a/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs b/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs index 065617682..62d6e77d1 100644 --- a/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs +++ b/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs @@ -85,8 +85,7 @@ module Server = match! server.Initialize p with | Ok _ -> - do! server.Initialized() - + do! server.Initialized () return { RootPath = path Server = server @@ -200,8 +199,8 @@ module Document = open System.Reactive.Linq open System.Threading.Tasks - let private typedEvents<'t> typ : _ -> System.IObservable<'t> = - Observable.choose (fun (typ', _o) -> if typ' = typ then Some(unbox _o) else None) + let typedEvents<'t> eventName : _ -> System.IObservable<'t> = + Observable.choose (fun (typ', _o) -> if typ' = eventName then Some(unbox _o) else None) /// `textDocument/publishDiagnostics` /// diff --git a/test/FsAutoComplete.Tests.Lsp/Utils/Server.fsi b/test/FsAutoComplete.Tests.Lsp/Utils/Server.fsi index e31695c57..6c788e9ef 100644 --- a/test/FsAutoComplete.Tests.Lsp/Utils/Server.fsi +++ b/test/FsAutoComplete.Tests.Lsp/Utils/Server.fsi @@ -16,111 +16,113 @@ open Utils open Ionide.ProjInfo.Logging type Server = - { RootPath: string option - Server: IFSharpLspServer - Events: ClientEvents - mutable UntitledCounter: int } + { RootPath: string option + Server: IFSharpLspServer + Events: ClientEvents + mutable UntitledCounter: int } /// `Server` cached with `Async.Cache` type CachedServer = Async type Document = - { Server: Server - FilePath: string - Uri: DocumentUri - mutable Version: int } + { Server: Server + FilePath: string + Uri: DocumentUri + mutable Version: int } - member TextDocumentIdentifier: TextDocumentIdentifier - member VersionedTextDocumentIdentifier: VersionedTextDocumentIdentifier - member Diagnostics: IObservable - member CompilerDiagnostics: IObservable - interface IDisposable + member TextDocumentIdentifier: TextDocumentIdentifier + member VersionedTextDocumentIdentifier: VersionedTextDocumentIdentifier + member Diagnostics: IObservable + member CompilerDiagnostics: IObservable + interface IDisposable module Server = - val create: - path: string option -> - config: FSharpConfigDto -> - createServer: (unit -> IFSharpLspServer * IObservable) -> - CachedServer + val create: + path: string option -> + config: FSharpConfigDto -> + createServer: (unit -> IFSharpLspServer * IObservable) -> + CachedServer - val shutdown: server: CachedServer -> Async - val createUntitledDocument: initialText: string -> server: CachedServer -> Async - /// `path` can be absolute or relative. - /// For relative path `server.RootPath` must be specified! - /// - /// Note: When `path` is relative: relative to `server.RootPath`! - val openDocument: path: string -> server: CachedServer -> Async + val shutdown: server: CachedServer -> Async + val createUntitledDocument: initialText: string -> server: CachedServer -> Async + /// `path` can be absolute or relative. + /// For relative path `server.RootPath` must be specified! + /// + /// Note: When `path` is relative: relative to `server.RootPath`! + val openDocument: path: string -> server: CachedServer -> Async - /// Like `Server.openDocument`, but instead of reading source text from `path`, - /// this here instead uses `initialText` (which can be different from content of `path`!). - /// - /// This way an existing file with different text can be faked. - /// Logically equal to `Server.openDocument`, and later changing its text via `Document.changeTextTo`. - /// But this here doesn't have to parse and check everything twice (once for open, once for changed) - /// and is WAY faster than `Server.openDocument` followed by `Document.changeTextTo` when involving multiple documents. - /// (For example with CodeFix tests using `fsi` file and corresponding `fs` file) - val openDocumentWithText: - path: string -> initialText: string -> server: CachedServer -> Async + /// Like `Server.openDocument`, but instead of reading source text from `path`, + /// this here instead uses `initialText` (which can be different from content of `path`!). + /// + /// This way an existing file with different text can be faked. + /// Logically equal to `Server.openDocument`, and later changing its text via `Document.changeTextTo`. + /// But this here doesn't have to parse and check everything twice (once for open, once for changed) + /// and is WAY faster than `Server.openDocument` followed by `Document.changeTextTo` when involving multiple documents. + /// (For example with CodeFix tests using `fsi` file and corresponding `fs` file) + val openDocumentWithText: + path: string -> initialText: string -> server: CachedServer -> Async module Document = - open System.Reactive.Linq - open System.Threading.Tasks + open System.Reactive.Linq + open System.Threading.Tasks - /// `textDocument/publishDiagnostics` - /// - /// Note: for each analyzing round there are might be multiple `publishDiagnostics` events (F# compiler, for each built-in Analyzer, for Custom Analyzers) - /// - /// Note: Because source `doc.Server.Events` is `ReplaySubject`, subscribing to Stream returns ALL past diagnostics too! - val diagnosticsStream: doc: Document -> IObservable - /// `fsharp/documentAnalyzed` - val analyzedStream: doc: Document -> IObservable - /// in ms - /// Waits (if necessary) and gets latest diagnostics. - /// - /// To detect newest diags: - /// * Waits for `fsharp/documentAnalyzed` for passed `doc` and its `doc.Version`. - /// * Then waits a but more for potential late diags. - /// * Then returns latest diagnostics. - /// - /// - /// ### Explanation: Get latest & correct diagnostics - /// Diagnostics aren't collected and then sent once, but instead sent after each parsing/analyzing step. - /// -> There are multiple `textDocument/publishDiagnostics` sent for each parsing/analyzing round: - /// * one when file parsed by F# compiler - /// * one for each built-in (enabled) Analyzers (in `src\FsAutoComplete\FsAutoComplete.Lsp.fs` > `FsAutoComplete.Lsp.FSharpLspServer.analyzeFile`), - /// * for linter (currently disabled) - /// * for custom analyzers - /// - /// -> To receive ALL diagnostics: use Diagnostics of last `textDocument/publishDiagnostics` event. - /// - /// Issue: What is the last `publishDiagnostics`? Might already be here or arrive in future. - /// -> `fsharp/documentAnalyzed` was introduced. Notification when a doc was completely analyzed - /// -> wait for `documentAnalyzed` - /// - /// But issue: last `publishDiagnostics` might be received AFTER `documentAnalyzed` (because of async notifications & sending) - /// -> after receiving `documentAnalyzed` wait a bit for late `publishDiagnostics` - /// - /// But issue: Wait for how long? Too long: extends test execution time. Too short: Might miss diags. - /// -> unresolved. Current wait based on testing on modern_ish PC. Seems to work on CI too. - /// - /// - /// *Inconvenience*: Only newest diags can be retrieved this way. Diags for older file versions cannot be extracted reliably: - /// `doc.Server.Events` is a `ReplaySubject` -> returns ALL previous events on new subscription - /// -> All past `documentAnalyzed` events and their diags are all received at once - /// -> waiting a bit after a version-specific `documentAnalyzed` always returns latest diags. - val waitForLatestDiagnostics: timeout: TimeSpan -> doc: Document -> Async - val openWith: initialText: string -> doc: Document -> Async - val close: doc: Document -> Async - /// - /// Fire a textDocument/didChange request for the specified document with the given text - /// as the entire new text of the document, then wait for diagnostics for the document. - /// - val changeTextTo: text: string -> doc: Document -> Async - val saveText: text: string -> doc: Document -> Async + val typedEvents: eventName: string -> (ClientEvents -> IObservable<'t>) - /// Note: diagnostics aren't filtered to match passed range in here - val codeActionAt: - diagnostics: Diagnostic[] -> range: Range -> doc: Document -> Async + /// `textDocument/publishDiagnostics` + /// + /// Note: for each analyzing round there are might be multiple `publishDiagnostics` events (F# compiler, for each built-in Analyzer, for Custom Analyzers) + /// + /// Note: Because source `doc.Server.Events` is `ReplaySubject`, subscribing to Stream returns ALL past diagnostics too! + val diagnosticsStream: doc: Document -> IObservable + /// `fsharp/documentAnalyzed` + val analyzedStream: doc: Document -> IObservable + /// in ms + /// Waits (if necessary) and gets latest diagnostics. + /// + /// To detect newest diags: + /// * Waits for `fsharp/documentAnalyzed` for passed `doc` and its `doc.Version`. + /// * Then waits a but more for potential late diags. + /// * Then returns latest diagnostics. + /// + /// + /// ### Explanation: Get latest & correct diagnostics + /// Diagnostics aren't collected and then sent once, but instead sent after each parsing/analyzing step. + /// -> There are multiple `textDocument/publishDiagnostics` sent for each parsing/analyzing round: + /// * one when file parsed by F# compiler + /// * one for each built-in (enabled) Analyzers (in `src\FsAutoComplete\FsAutoComplete.Lsp.fs` > `FsAutoComplete.Lsp.FSharpLspServer.analyzeFile`), + /// * for linter (currently disabled) + /// * for custom analyzers + /// + /// -> To receive ALL diagnostics: use Diagnostics of last `textDocument/publishDiagnostics` event. + /// + /// Issue: What is the last `publishDiagnostics`? Might already be here or arrive in future. + /// -> `fsharp/documentAnalyzed` was introduced. Notification when a doc was completely analyzed + /// -> wait for `documentAnalyzed` + /// + /// But issue: last `publishDiagnostics` might be received AFTER `documentAnalyzed` (because of async notifications & sending) + /// -> after receiving `documentAnalyzed` wait a bit for late `publishDiagnostics` + /// + /// But issue: Wait for how long? Too long: extends test execution time. Too short: Might miss diags. + /// -> unresolved. Current wait based on testing on modern_ish PC. Seems to work on CI too. + /// + /// + /// *Inconvenience*: Only newest diags can be retrieved this way. Diags for older file versions cannot be extracted reliably: + /// `doc.Server.Events` is a `ReplaySubject` -> returns ALL previous events on new subscription + /// -> All past `documentAnalyzed` events and their diags are all received at once + /// -> waiting a bit after a version-specific `documentAnalyzed` always returns latest diags. + val waitForLatestDiagnostics: timeout: TimeSpan -> doc: Document -> Async + val openWith: initialText: string -> doc: Document -> Async + val close: doc: Document -> Async + /// + /// Fire a textDocument/didChange request for the specified document with the given text + /// as the entire new text of the document, then wait for diagnostics for the document. + /// + val changeTextTo: text: string -> doc: Document -> Async + val saveText: text: string -> doc: Document -> Async - val inlayHintsAt: range: Range -> doc: Document -> Async - val resolveInlayHint: inlayHint: InlayHint -> doc: Document -> Async + /// Note: diagnostics aren't filtered to match passed range in here + val codeActionAt: + diagnostics: Diagnostic[] -> range: Range -> doc: Document -> Async + + val inlayHintsAt: range: Range -> doc: Document -> Async + val resolveInlayHint: inlayHint: InlayHint -> doc: Document -> Async