diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 24c4f1bca..d545b57fe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -61,7 +61,13 @@ jobs: name: Build on ${{matrix.os}} for ${{ matrix.label }} 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 e9f29fcfc..23483d6a7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -52,7 +52,7 @@ "args": [ "--debug", "--filter", - "FSAC.lsp.${input:loader}.${input:lsp-server}.${input:testName}" + "FSAC.lsp.${input:loader}.${input:testName}" ] } ], @@ -77,21 +77,10 @@ "default": "WorkspaceLoader", "type": "pickString" }, - - { - "id": "lsp-server", - "description": "The lsp serrver", - "options": [ - "FSharpLspServer", - "AdaptiveLspServer" - ], - "default": "AdaptiveLspServer", - "type": "pickString" - }, { "id": "testName", "description": "the name of the test as provided to `testCase`", "type": "promptString" } ] -} +} \ No newline at end of file diff --git a/global.json b/global.json index 31d071663..7dd854d23 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { "version": "7.0.400", - "allowPrerelease": true + "rollForward": "major" } } \ No newline at end of file diff --git a/src/FsAutoComplete.Core/Commands.fs b/src/FsAutoComplete.Core/Commands.fs index 7b1e34125..a34972c5b 100644 --- a/src/FsAutoComplete.Core/Commands.fs +++ b/src/FsAutoComplete.Core/Commands.fs @@ -83,6 +83,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 81df0fe33..50e6a8535 100644 --- a/src/FsAutoComplete.Core/FsAutoComplete.Core.fsproj +++ b/src/FsAutoComplete.Core/FsAutoComplete.Core.fsproj @@ -4,6 +4,7 @@ net6.0;net7.0 net6.0;net7.0;net8.0 false + preview @@ -58,6 +59,7 @@ + diff --git a/src/FsAutoComplete.Core/NestedLanguages.fs b/src/FsAutoComplete.Core/NestedLanguages.fs new file mode 100644 index 000000000..064df7301 --- /dev/null +++ b/src/FsAutoComplete.Core/NestedLanguages.fs @@ -0,0 +1,318 @@ +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 } + + +/// 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(range = range) when index = 0 -> + [| + // leading tokens adjustment + // GAP: we don't know how many interpolation $ or " there are, so we are guessing + 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 -> () + + // 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 17c923bcc..9a28adcea 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 f5d829512..2d1e187b2 100644 --- a/src/FsAutoComplete/LspServers/AdaptiveServerState.fs +++ b/src/FsAutoComplete/LspServers/AdaptiveServerState.fs @@ -392,19 +392,26 @@ type AdaptiveState(lspClient: FSharpLspClient, sourceTextFactory: ISourceTextFac 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) = @@ -594,6 +601,19 @@ type AdaptiveState(lspClient: FSharpLspClient, sourceTextFactory: ISourceTextFac { 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 cbd1e2fcb..a5d2df915 100644 --- a/src/FsAutoComplete/LspServers/FSharpLspClient.fs +++ b/src/FsAutoComplete/LspServers/FSharpLspClient.fs @@ -2,7 +2,6 @@ namespace FsAutoComplete.Lsp open Ionide.LanguageServerProtocol -open Ionide.LanguageServerProtocol.Types.LspResult open Ionide.LanguageServerProtocol.Server open Ionide.LanguageServerProtocol.Types open FsAutoComplete.LspHelpers @@ -12,6 +11,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) = @@ -62,6 +69,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/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs new file mode 100644 index 000000000..42ba6afae --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs @@ -0,0 +1,206 @@ +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.[r.Start.Line] + let endLine = lines.[r.End.Line] + + if r.Start.Line = r.End.Line then + startLine.Substring(r.Start.Character, r.End.Character - r.Start.Character) + else + let start = startLine.Substring(r.Start.Character) + let ``end`` = endLine.Substring(0, r.End.Character) + + let middle = + lines.[r.Start.Line + 1 .. r.End.Line - 1] |> 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" + [ ptestList + "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", [| (1, 38), (1, 58) |]) |] + server + + hasLanguages + "F#-defined type" + """ + type Foo() = + member x.Boo([] uriString: string) = () + let f = new Foo() + let u = f.Boo("https://google.com") + """ + [| ("uri", [| (4, 26), (4, 46) |]) |] + 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", [| (2, 26), (2, 34); (2, 42), (2, 46) |]) |] + 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", [| (2, 25), (2, 43) |]) |] + server + + hasLanguages + "verbatim string value" + """ + let boo ([] uriString: string) = () + let u = boo @"https://google.com" + """ + [| ("uri", [| (2, 26), (2, 44) |]) |] + server + + hasLanguages + "triple-quote string value" + """ + let boo ([] uriString: string) = () + let u = boo "https://google.com" + """ + [| ("uri", [| (2, 25), (2, 43) |]) |] + server + + hasLanguages + "simple interpolated string" + """ + let uri ([]s: string) = () + let u = uri $"https://{true}.com" + """ + [| ("uri", [| (2, 26), (2, 34); (2, 40), (2, 44) |]) |] + 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", [| (3, 34), (3, 50) |]); ("sql", [| (4, 31), (4, 79) |]) |] + server ]) ] ] diff --git a/test/FsAutoComplete.Tests.Lsp/Program.fs b/test/FsAutoComplete.Tests.Lsp/Program.fs index 9e09c51e9..8e1b23b91 100644 --- a/test/FsAutoComplete.Tests.Lsp/Program.fs +++ b/test/FsAutoComplete.Tests.Lsp/Program.fs @@ -37,7 +37,6 @@ let loaders = // "MSBuild Project Graph WorkspaceLoader", (fun toolpath -> WorkspaceLoaderViaProjectGraph.Create(toolpath, FsAutoComplete.Core.ProjectLoader.globalProperties)) ] - let adaptiveLspServerFactory toolsPath workspaceLoaderFactory sourceTextFactory = Helpers.createAdaptiveServer (fun () -> workspaceLoaderFactory toolsPath) sourceTextFactory @@ -53,10 +52,8 @@ let lspTests = testList $"{loaderName}" - [ - Templates.tests () - let createServer () = - adaptiveLspServerFactory toolsPath workspaceLoaderFactory sourceTextFactory + [ Templates.tests () + let createServer () = adaptiveLspServerFactory toolsPath workspaceLoaderFactory sourceTextFactory initTests createServer closeTests createServer @@ -91,10 +88,8 @@ let lspTests = 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 @@ -103,14 +98,15 @@ let lspTests = UnusedDeclarationsTests.tests createServer EmptyFileTests.tests createServer CallHierarchy.tests createServer - ] ] + NestedLanguageTests.tests 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" [ generalTests; lspTests ] @@ -221,7 +217,7 @@ let main args = let cts = new CancellationTokenSource(testTimeout) let args = - [ CLIArguments.Printer(Expecto.Impl.TestPrinters.summaryWithLocationPrinter defaultConfig.printer) + [ //CLIArguments.Printer(Expecto.Impl.TestPrinters.summaryWithLocationPrinter defaultConfig.printer) CLIArguments.Verbosity logLevel // CLIArguments.Parallel ] diff --git a/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs b/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs index 79048a600..0f13d2d99 100644 --- a/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs +++ b/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs @@ -28,22 +28,19 @@ type CachedServer = Async type Document = { Server: Server - FilePath : string + FilePath: string Uri: DocumentUri mutable Version: int } + member doc.TextDocumentIdentifier: TextDocumentIdentifier = { Uri = doc.Uri } member doc.VersionedTextDocumentIdentifier: VersionedTextDocumentIdentifier = - { Uri = doc.Uri - Version = doc.Version } + { Uri = doc.Uri; Version = doc.Version } member x.Diagnostics = - x.Server.Events - |> fileDiagnosticsForUri x.TextDocumentIdentifier.Uri + x.Server.Events |> fileDiagnosticsForUri x.TextDocumentIdentifier.Uri - member x.CompilerDiagnostics = - x.Diagnostics - |> diagnosticsFromSource "F# Compiler" + member x.CompilerDiagnostics = x.Diagnostics |> diagnosticsFromSource "F# Compiler" interface IDisposable with override doc.Dispose() : unit = @@ -65,7 +62,7 @@ module Server = for file in System.IO.Directory.EnumerateFiles(path, "*.fsproj", SearchOption.AllDirectories) do do! file |> Path.GetDirectoryName |> dotnetRestore - let (server : IFSharpLspServer, events : IObservable<_>) = createServer () + let (server: IFSharpLspServer, events: IObservable<_>) = createServer () events |> Observable.add logEvent let p: InitializeParams = @@ -88,7 +85,8 @@ module Server = match! server.Initialize p with | Ok _ -> - do! server.Initialized (InitializedParams()) + do! server.Initialized(InitializedParams()) + return { RootPath = path Server = server @@ -131,9 +129,7 @@ module Server = async { let! server = server - let doc = - server - |> createDocument String.Empty (server |> nextUntitledDocUri) + let doc = server |> createDocument String.Empty (server |> nextUntitledDocUri) let! diags = doc |> Document.openWith initialText @@ -161,17 +157,13 @@ module Server = server |> createDocument fullPath - ( - fullPath - // normalize path is necessary: otherwise might be different lower/upper cases in uri for tests and LSP server: - // on windows `E:\...`: `file:///E%3A/...` (before normalize) vs. `file:///e%3A/..` (after normalize) - |> normalizePath - |> Path.LocalPathToUri - ) - - let! diags = - doc - |> Document.openWith (File.ReadAllText fullPath) + (fullPath + // normalize path is necessary: otherwise might be different lower/upper cases in uri for tests and LSP server: + // on windows `E:\...`: `file:///E%3A/...` (before normalize) vs. `file:///e%3A/..` (after normalize) + |> normalizePath + |> Path.LocalPathToUri) + + let! diags = doc |> Document.openWith (File.ReadAllText fullPath) return (doc, diags) } @@ -197,9 +189,7 @@ module Server = // To avoid hitting the typechecker cache, we need to update the file's timestamp IO.File.SetLastWriteTimeUtc(fullPath, DateTime.UtcNow) - let doc = - server - |> createDocument fullPath (Path.FilePathToUri fullPath) + let doc = server |> createDocument fullPath (Path.FilePathToUri fullPath) let! diags = doc |> Document.openWith initialText @@ -210,12 +200,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 : Helpers.ClientEvents -> System.IObservable<'t> = + Observable.choose (fun (typ', _o) -> if typ' = eventName then Some(unbox _o) else None) /// `textDocument/publishDiagnostics` /// @@ -225,11 +211,7 @@ module Document = let diagnosticsStream (doc: Document) = doc.Server.Events |> typedEvents "textDocument/publishDiagnostics" - |> Observable.choose (fun n -> - if n.Uri = doc.Uri then - Some n.Diagnostics - else - None) + |> Observable.choose (fun n -> if n.Uri = doc.Uri then Some n.Diagnostics else None) /// `fsharp/documentAnalyzed` let analyzedStream (doc: Document) = @@ -241,21 +223,19 @@ module Document = /// in ms let private waitForLateDiagnosticsDelay = let envVar = "FSAC_WaitForLateDiagnosticsDelay" + System.Environment.GetEnvironmentVariable envVar |> Option.ofObj |> Option.map (fun d -> match System.Int32.TryParse d with | (true, d) -> d - | (false, _) -> - failwith $"Environment Variable '%s{envVar}' exists, but is not a correct int number ('%s{d}')" - ) + | (false, _) -> failwith $"Environment Variable '%s{envVar}' exists, but is not a correct int number ('%s{d}')") |> Option.orElseWith (fun _ -> - // set in Github Actions: https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables - match System.Environment.GetEnvironmentVariable "CI" with - | null -> None - | _ -> Some 25 - ) - |> Option.defaultValue 7 // testing locally + // set in Github Actions: https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables + match System.Environment.GetEnvironmentVariable "CI" with + | null -> None + | _ -> Some 25) + |> Option.defaultValue 7 // testing locally /// Waits (if necessary) and gets latest diagnostics. /// @@ -298,6 +278,7 @@ module Document = >> Log.addContext "uri" doc.Uri >> Log.addContext "version" doc.Version ) + let tcs = TaskCompletionSource<_>() use _ = @@ -313,7 +294,7 @@ module Document = ) |> Observable.bufferSpan (timeout) // |> Observable.timeoutSpan timeout - |> Observable.subscribe(fun x -> tcs.SetResult x) + |> Observable.subscribe (fun x -> tcs.SetResult x) let! result = tcs.Task |> Async.AwaitTask @@ -326,7 +307,7 @@ module Document = System.Threading.Interlocked.Increment(&doc.Version) /// Note: Mutates passed `doc` - let private incrVersionedTextDocumentIdentifier (doc: Document): VersionedTextDocumentIdentifier = + let private incrVersionedTextDocumentIdentifier (doc: Document) : VersionedTextDocumentIdentifier = { Uri = doc.Uri Version = incrVersion doc } @@ -343,8 +324,8 @@ module Document = try return! doc |> waitForLatestDiagnostics Helpers.defaultTimeout - with - | :? TimeoutException -> return failwith $"Timeout waiting for latest diagnostics for {doc.Uri}" + with :? TimeoutException -> + return failwith $"Timeout waiting for latest diagnostics for {doc.Uri}" } let close (doc: Document) = @@ -371,12 +352,11 @@ module Document = return! doc |> waitForLatestDiagnostics Helpers.defaultTimeout } - let saveText (text : string) (doc : Document) = + let saveText (text: string) (doc: Document) = async { - let p : DidSaveTextDocumentParams = { - Text = Some text - TextDocument = doc.TextDocumentIdentifier - } + let p: DidSaveTextDocumentParams = + { Text = Some text + TextDocument = doc.TextDocumentIdentifier } // Simulate the file being written to disk so we don't hit the typechecker cache IO.File.SetLastWriteTimeUtc(doc.FilePath, DateTime.UtcNow) do! doc.Server.Server.TextDocumentDidSave p @@ -387,8 +367,7 @@ module Document = let private assertOk result = Expect.isOk result "Expected success" - result - |> Result.defaultWith (fun _ -> failtest "not reachable") + result |> Result.defaultWith (fun _ -> failtest "not reachable") let private assertSome opt = Expect.isSome opt "Expected to have Some" @@ -401,21 +380,27 @@ module Document = let ps: CodeActionParams = { TextDocument = doc.TextDocumentIdentifier Range = range - Context = { Diagnostics = diagnostics; Only = None; TriggerKind = None } } + Context = + { Diagnostics = diagnostics + Only = None + TriggerKind = None } } let! res = doc.Server.Server.TextDocumentCodeAction ps return res |> assertOk } - let inlayHintsAt range (doc: Document) = async { - let ps: InlayHintParams = { - Range = range - TextDocument = doc.TextDocumentIdentifier + let inlayHintsAt range (doc: Document) = + async { + let ps: InlayHintParams = + { Range = range + TextDocument = doc.TextDocumentIdentifier } + + let! res = doc.Server.Server.TextDocumentInlayHint ps + return res |> assertOk |> assertSome + } + + let resolveInlayHint inlayHint (doc: Document) = + async { + let! res = doc.Server.Server.InlayHintResolve inlayHint + return res |> assertOk } - let! res = doc.Server.Server.TextDocumentInlayHint ps - return res |> assertOk |> assertSome - } - let resolveInlayHint inlayHint (doc: Document) = async { - let! res = doc.Server.Server.InlayHintResolve inlayHint - return res |> assertOk - } 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