diff --git a/src/FsAutoComplete.Core/Commands.fs b/src/FsAutoComplete.Core/Commands.fs index 5576b809c..f5fe1b39a 100644 --- a/src/FsAutoComplete.Core/Commands.fs +++ b/src/FsAutoComplete.Core/Commands.fs @@ -92,7 +92,7 @@ type NotificationEvent = | NestedLanguagesFound of file: string * version: int * - nestedLanguages: {| language: string; range: Range |}[] + 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 f0502b4e1..0d471a6d1 100644 --- a/src/FsAutoComplete.Core/FsAutoComplete.Core.fsproj +++ b/src/FsAutoComplete.Core/FsAutoComplete.Core.fsproj @@ -3,6 +3,7 @@ net6.0 net6.0;net7.0 false + preview diff --git a/src/FsAutoComplete.Core/NestedLanguages.fs b/src/FsAutoComplete.Core/NestedLanguages.fs index 606c52e39..c47e6fb54 100644 --- a/src/FsAutoComplete.Core/NestedLanguages.fs +++ b/src/FsAutoComplete.Core/NestedLanguages.fs @@ -2,18 +2,189 @@ module FsAutoComplete.NestedLanguages open FsToolkit.ErrorHandling open FSharp.Compiler.Syntax -open FSharp.Compiler.Syntax.SyntaxTraversal open FSharp.Compiler.Text +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Symbols + +#nowarn "57" // from-end slicing type private StringParameter = { methodIdent: LongIdent - parameterRange: Range } + parameterRange: Range + rangesToRemove: Range[] + parameterPosition: int } + +let discoverRangesToRemoveForInterpolatedString (list: SynInterpolatedStringPart list) = + list + |> List.choose (function + | SynInterpolatedStringPart.FillExpr(fillExpr = e) -> Some e.Range + | _ -> None) + |> List.toArray + +let private (|Ident|_|) (e: SynExpr) = + match e with + | SynExpr.LongIdent(longDotId = SynLongIdent(id = ident)) -> Some ident + | _ -> None + +let rec private (|IsApplicationWithStringParameters|_|) (e: SynExpr) : 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) -> + [| match e1 with + | IsApplicationWithStringParameters(stringParameter) -> yield! stringParameter + | _ -> () + + match e2 with + | IsApplicationWithStringParameters(stringParameter) -> yield! stringParameter + | _ -> () |] + // TODO: check if the array would be empty and return none + |> Some + + // 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 = [||] + parameterPosition = 0 } |] + ) + // method call with interpolated string parameter - c.M $"
{1 + 1}" + | SynExpr.App( + funcExpr = SynExpr.LongIdent(longDotId = SynLongIdent(id = ident)) + argExpr = SynExpr.Paren(expr = SynExpr.InterpolatedString(contents = parts; range = range))) + // method call with interpolated string parameter - c.M($"
{1 + 1}") + | SynExpr.App( + funcExpr = SynExpr.LongIdent(longDotId = SynLongIdent(id = ident)) + argExpr = SynExpr.InterpolatedString(contents = parts; range = range)) -> + let rangesToRemove = discoverRangesToRemoveForInterpolatedString 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 SyntaxVisitorBase() + inherit SyntaxCollectorBase() + + let languages = ResizeArray() + + override _.WalkBinding(SynBinding(expr = expr)) = + match expr with + | 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[] } + +let rangeMinusRanges (totalRange: Range) (rangesToRemove: Range[]) : Range[] = + match rangesToRemove with + | [||] -> [| totalRange |] + | _ -> + let mutable returnVal = ResizeArray() + let mutable currentStart = totalRange.Start + + for r in rangesToRemove do + returnVal.Add(Range.mkRange totalRange.FileName currentStart r.Start) + currentStart <- r.End + + returnVal.Add(Range.mkRange totalRange.FileName currentStart totalRange.End) + returnVal.ToArray() + +let private parametersThatAreStringSyntax + ( + parameters: StringParameter[], + checkResults: FSharpCheckFileResults, + text: IFSACSourceText + ) : Async = + async { + let returnVal = ResizeArray() + + for p in parameters do + let precedingParts, lastPart = p.methodIdent.[0..^1], p.methodIdent[^0] + let endOfFinalTextToken = lastPart.idRange.End + + match text.GetLine(endOfFinalTextToken) with + | None -> () + | Some lineText -> + + match + checkResults.GetSymbolUseAtLocation( + endOfFinalTextToken.Line, + endOfFinalTextToken.Column, + lineText, + precedingParts |> List.map (fun i -> i.idText) + ) + with + | None -> () + | Some usage -> + + 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 |> Seq.item p.parameterPosition + + 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) = async { return [||] } +let findNestedLanguages (tyRes: ParseAndCheckResults, text: IFSACSourceText) : NestedLanguageDocument[] Async = + async { + // get all string constants + let potentialParameters = findParametersForParseTree tyRes.GetAST + let! actualStringSyntaxParameters = parametersThatAreStringSyntax (potentialParameters, tyRes.GetCheckResults, text) + return actualStringSyntaxParameters + } diff --git a/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs b/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs index 5c2d8d611..23bd7d9e2 100644 --- a/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs +++ b/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs @@ -346,7 +346,7 @@ type AdaptiveFSharpLspServer use progress = new ServerProgressReport(lspClient) do! progress.Begin($"Checking simplifing of names {fileName}...", message = filePathUntag) - let! nestedLanguages = NestedLanguages.findNestedLanguages tyRes + let! nestedLanguages = NestedLanguages.findNestedLanguages (tyRes, source) let! ct = Async.CancellationToken notifications.Trigger(NotificationEvent.NestedLanguagesFound(filePath, version, nestedLanguages), ct) with e -> @@ -371,7 +371,9 @@ type AdaptiveFSharpLspServer config.SimplifyNameAnalyzer && isNotExcluded config.SimplifyNameAnalyzerExclusions then - checkSimplifiedNames ] + checkSimplifiedNames + // todo: add config flag for nested languages + findNestedLanguages ] async { do! analyzers |> Async.parallel75 |> Async.Ignore @@ -631,14 +633,14 @@ type AdaptiveFSharpLspServer | NotificationEvent.NestedLanguagesFound(file, version, nestedLanguages) -> do! lspClient.NotifyNestedLanguages( - {| nestedLanguages = + { NestedLanguages = nestedLanguages |> Array.map (fun l -> - {| language = l.language - range = fcsRangeToLsp l.range |}) - textDocument = + { Language = l.Language + Ranges = l.Ranges |> Array.map fcsRangeToLsp }) + TextDocument = { Uri = Path.LocalPathToUri file - Version = version } |} + Version = version } } ) with ex -> logger.error ( diff --git a/src/FsAutoComplete/LspServers/FSharpLspClient.fs b/src/FsAutoComplete/LspServers/FSharpLspClient.fs index 29a89991f..223622a79 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) = @@ -72,13 +79,7 @@ type FSharpLspClient(sendServerNotification: ClientNotificationSender, sendServe member __.NotifyTestDetected(p: TestDetectedNotification) = sendServerNotification "fsharp/testDetected" (box p) |> Async.Ignore - member _.NotifyNestedLanguages - (p: - {| textDocument: VersionedTextDocumentIdentifier - nestedLanguages: - {| language: string - range: Ionide.LanguageServerProtocol.Types.Range |}[] |}) - = + member _.NotifyNestedLanguages(p: TextDocumentNestedLanguages) = sendServerNotification "fsharp/textDocument/nestedLanguages" (box p) |> Async.Ignore diff --git a/src/FsAutoComplete/LspServers/FsAutoComplete.Lsp.fs b/src/FsAutoComplete/LspServers/FsAutoComplete.Lsp.fs index 4d1f15ff5..8e6c03e84 100644 --- a/src/FsAutoComplete/LspServers/FsAutoComplete.Lsp.fs +++ b/src/FsAutoComplete/LspServers/FsAutoComplete.Lsp.fs @@ -386,14 +386,14 @@ type FSharpLspServer(state: State, lspClient: FSharpLspClient, sourceTextFactory | NotificationEvent.NestedLanguagesFound(file, version, nestedLanguages) -> lspClient.NotifyNestedLanguages( - {| nestedLanguages = + { NestedLanguages = nestedLanguages |> Array.map (fun l -> - {| language = l.language - range = fcsRangeToLsp l.range |}) - textDocument = + { Language = l.Language + Ranges = l.Ranges |> Array.map fcsRangeToLsp }) + TextDocument = { Uri = Path.LocalPathToUri file - Version = version } |} + Version = version } } ) |> Async.Start with ex -> diff --git a/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs new file mode 100644 index 000000000..8f56f33f4 --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs @@ -0,0 +1,45 @@ +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 hasLanguages name source expectedLanguages server = + testAsync name { + let! (doc, _) = server |> Server.createUntitledDocument source + 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 "languages" + } + +let tests state = + testList + "nested languages" + [ serverTestList "class member" state defaultConfigDto None (fun server -> + [ hasLanguages + "with single string parameter" + """ + let b = System.UriBuilder("https://google.com") + """ + [| ("uri", [| (1, 38), (1, 58) |]) |] + server ]) ] diff --git a/test/FsAutoComplete.Tests.Lsp/Program.fs b/test/FsAutoComplete.Tests.Lsp/Program.fs index 52d053492..015a480b8 100644 --- a/test/FsAutoComplete.Tests.Lsp/Program.fs +++ b/test/FsAutoComplete.Tests.Lsp/Program.fs @@ -32,10 +32,10 @@ let testTimeout = Environment.SetEnvironmentVariable("FSAC_WORKSPACELOAD_DELAY", "250") let loaders = - [ - "Ionide WorkspaceLoader", (fun toolpath -> WorkspaceLoader.Create(toolpath, FsAutoComplete.Core.ProjectLoader.globalProperties)) + [ "Ionide WorkspaceLoader", + (fun toolpath -> WorkspaceLoader.Create(toolpath, FsAutoComplete.Core.ProjectLoader.globalProperties)) // "MSBuild Project Graph WorkspaceLoader", (fun toolpath -> WorkspaceLoaderViaProjectGraph.Create(toolpath, FsAutoComplete.Core.ProjectLoader.globalProperties)) - ] + ] let fsharpLspServerFactory toolsPath workspaceLoaderFactory = let testRunDir = @@ -53,13 +53,11 @@ let adaptiveLspServerFactory toolsPath workspaceLoaderFactory sourceTextFactory let lspServers = [ // "FSharpLspServer", fsharpLspServerFactory - "AdaptiveLspServer", adaptiveLspServerFactory - ] + "AdaptiveLspServer", adaptiveLspServerFactory ] -let sourceTextFactories: (string * ISourceTextFactory) list = [ - "NamedText", NamedTextFactory() - "RoslynSourceText", RoslynSourceTextFactory() -] +let sourceTextFactories: (string * ISourceTextFactory) list = + [ "NamedText", NamedTextFactory() + "RoslynSourceText", RoslynSourceTextFactory() ] let mutable toolsPath = Ionide.ProjInfo.Init.init (System.IO.DirectoryInfo Environment.CurrentDirectory) None @@ -73,8 +71,7 @@ let lspTests = testList $"{loaderName}.{lspName}.{sourceTextName}" - [ - Templates.tests () + [ Templates.tests () let createServer () = lspFactory toolsPath workspaceLoaderFactory sourceTextFactory @@ -120,24 +117,19 @@ let lspTests = DependentFileChecking.tests createServer UnusedDeclarationsTests.tests createServer EmptyFileTests.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 ] - for (name, factory) in sourceTextFactories do - InlayHintTests.explicitTypeInfoTests (name, factory) - FindReferences.tryFixupRangeTests (name, factory) -] +let generalTests = + testList + "general" + [ testList (nameof (Utils)) [ Utils.Tests.Utils.tests; Utils.Tests.TextEdit.tests ] + for (name, factory) in sourceTextFactories do + InlayHintTests.explicitTypeInfoTests (name, factory) + FindReferences.tryFixupRangeTests (name, factory) ] [] -let tests = - testList - "FSAC" - [ - generalTests - lspTests - ] +let tests = testList "FSAC" [ generalTests; lspTests ] [] @@ -154,7 +146,7 @@ let main args = |> Array.tryFind (fun arg -> arg.StartsWith logMarker) |> Option.map (fun log -> log.Substring(logMarker.Length)) with - | Some ("warn" | "warning") -> Logging.LogLevel.Warn + | Some("warn" | "warning") -> Logging.LogLevel.Warn | Some "error" -> Logging.LogLevel.Error | Some "fatal" -> Logging.LogLevel.Fatal | Some "info" -> Logging.LogLevel.Info @@ -162,9 +154,7 @@ let main args = | Some "debug" -> Logging.LogLevel.Debug | _ -> Logging.LogLevel.Warn - let args = - args - |> Array.filter (fun arg -> not <| arg.StartsWith logMarker) + let args = args |> Array.filter (fun arg -> not <| arg.StartsWith logMarker) logLevel, args @@ -216,10 +206,8 @@ let main args = .Filter.ByExcluding(Matching.FromSource("FileSystem")) .Filter.ByExcluding(sourcesToExclude) - .Destructure - .FSharpTypes() - .Destructure - .ByTransforming(fun r -> + .Destructure.FSharpTypes() + .Destructure.ByTransforming(fun r -> box {| FileName = r.FileName Start = r.Start @@ -227,8 +215,7 @@ let main args = .Destructure.ByTransforming(fun r -> box {| Line = r.Line; Column = r.Column |}) .Destructure.ByTransforming(fun tok -> tok.ToString() |> box) .Destructure.ByTransforming(fun di -> box di.FullName) - .WriteTo - .Async(fun c -> + .WriteTo.Async(fun c -> c.Console( outputTemplate = outputTemplate, standardErrorFromLevel = Nullable<_>(LogEventLevel.Verbose), @@ -250,9 +237,7 @@ let main args = // failOnFocusedTests = true printer = Expecto.Impl.TestPrinters.summaryWithLocationPrinter defaultConfig.printer verbosity = logLevel - // runInParallel = false - } + // runInParallel = false + } runTestsWithArgsAndCancel cts.Token config fixedUpArgs tests - - 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