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/.vscode/tasks.json b/.vscode/tasks.json index 0129f7fe1..336d459dc 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,19 +1,31 @@ { - // See https://go.microsoft.com/fwlink/?LinkId=733558 - // for the documentation about the tasks.json format - "version": "2.0.0", - "tasks": [ - { - "label": "format codebase", - "command": "dotnet", - "args": [ - "build", - "-t", - "Format" - ], - "detail": "Format all source code using Fantomas", - "type": "shell", - "problemMatcher": [] - } - ] -} + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "format codebase", + "command": "dotnet", + "args": [ + "build", + "-t", + "Format" + ], + "detail": "Format all source code using Fantomas", + "type": "shell", + "problemMatcher": [] + }, + { + "type": "msbuild", + "problemMatcher": [ + "$msCompile" + ], + "group": { + "kind": "build", + "isDefault": true + }, + "label": "Build: FsAutoComplete.fsproj", + "detail": "Build the FsAutoComplete.fsproj project using dotnet build" + } + ] +} \ No newline at end of file diff --git a/global.json b/global.json deleted file mode 100644 index 31d071663..000000000 --- a/global.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "sdk": { - "version": "7.0.400", - "allowPrerelease": true - } -} \ No newline at end of file diff --git a/paket.lock b/paket.lock index 76a1e90db..f729a645b 100644 --- a/paket.lock +++ b/paket.lock @@ -133,7 +133,7 @@ NUGET System.Reflection.Metadata (>= 5.0) Ionide.Analyzers (0.7) Ionide.KeepAChangelog.Tasks (0.1.8) - copy_local: true - Ionide.LanguageServerProtocol (0.4.20) + Ionide.LanguageServerProtocol (0.4.22) FSharp.Core (>= 6.0) Newtonsoft.Json (>= 13.0.1) StreamJsonRpc (>= 2.16.36) diff --git a/src/FsAutoComplete.Core/KeywordList.fs b/src/FsAutoComplete.Core/KeywordList.fs index a86a18129..8f38cf995 100644 --- a/src/FsAutoComplete.Core/KeywordList.fs +++ b/src/FsAutoComplete.Core/KeywordList.fs @@ -6,6 +6,10 @@ open FSharp.Compiler.Tokenization open FSharp.Compiler.EditorServices open FSharp.Compiler.Symbols +// 44 is the 'This construct is deprecated' error - we've addressed these by moving to TextEdit for the completionItems here, +// but the helper function for the CompletionItem record has to init the field to None, so it's still being counted as used. +#nowarn "44" + module KeywordList = let keywordDescriptions = FSharpKeywords.KeywordsWithDescription |> dict @@ -39,7 +43,13 @@ module KeywordList = "line", "Indicates the original source code line" ] |> dict - let hashSymbolCompletionItems = + let private textEdit text pos : U2 = + U2.First( + { Range = { Start = pos; End = pos } + NewText = text } + ) + + let hashSymbolCompletionItems pos = hashDirectives |> Seq.map (fun kv -> let label = "#" + kv.Key @@ -47,7 +57,7 @@ module KeywordList = { CompletionItem.Create(kv.Key) with Data = Some(Newtonsoft.Json.Linq.JValue(label)) Kind = Some CompletionItemKind.Keyword - InsertText = Some kv.Key + TextEdit = Some(textEdit kv.Value pos) FilterText = Some kv.Key SortText = Some kv.Key Documentation = Some(Documentation.String kv.Value) @@ -57,13 +67,13 @@ module KeywordList = let allKeywords: string list = keywordDescriptions |> Seq.map ((|KeyValue|) >> fst) |> Seq.toList - let keywordCompletionItems = + let keywordCompletionItems pos = allKeywords |> List.mapi (fun id k -> { CompletionItem.Create(k) with Data = Some(Newtonsoft.Json.Linq.JValue(k)) Kind = Some CompletionItemKind.Keyword - InsertText = Some k + TextEdit = Some(textEdit k pos) SortText = Some(sprintf "1000000%d" id) FilterText = Some k Label = k }) diff --git a/src/FsAutoComplete.Logging/FsAutoComplete.Logging.fsproj b/src/FsAutoComplete.Logging/FsAutoComplete.Logging.fsproj index d5b5f14bc..e74f100ec 100644 --- a/src/FsAutoComplete.Logging/FsAutoComplete.Logging.fsproj +++ b/src/FsAutoComplete.Logging/FsAutoComplete.Logging.fsproj @@ -11,4 +11,8 @@ + + + + diff --git a/src/FsAutoComplete/LspHelpers.fs b/src/FsAutoComplete/LspHelpers.fs index c72b6b6de..0407855a2 100644 --- a/src/FsAutoComplete/LspHelpers.fs +++ b/src/FsAutoComplete/LspHelpers.fs @@ -18,6 +18,7 @@ module FcsRange = FSharp.Compiler.Text.Range type FcsPos = FSharp.Compiler.Text.Position module FcsPos = FSharp.Compiler.Text.Position +#nowarn "44" // We're not using the Deprecated member of DocumentSymbol here, but it's still required to be _set_. module FcsPos = let subtractColumn (pos: FcsPos) (column: int) = FcsPos.mkPos pos.Line (pos.Column - column) @@ -115,13 +116,13 @@ module Conversions = Data = None CodeDescription = Some { Href = Some(Uri(urlForCompilerCode error.ErrorNumber)) } } - let getSymbolInformations + let getWorkspaceSymbols (uri: DocumentUri) (glyphToSymbolKind: FSharpGlyph -> SymbolKind option) (topLevel: NavigationTopLevelDeclaration) - (symbolFilter: SymbolInformation -> bool) - : SymbolInformation[] = - let inner (container: string option) (decl: NavigationItem) : SymbolInformation option = + (symbolFilter: WorkspaceSymbol -> bool) + : WorkspaceSymbol[] = + let inner (container: string option) (decl: NavigationItem) : WorkspaceSymbol option = // We should nearly always have a kind, if the client doesn't send weird capabilities, // if we don't why not assume module... let kind = defaultArg (glyphToSymbolKind decl.Glyph) SymbolKind.Module @@ -130,20 +131,48 @@ module Conversions = { Uri = uri Range = fcsRangeToLsp decl.Range } - let sym: SymbolInformation = + let sym: WorkspaceSymbol = { Name = decl.LogicalName - Kind = kind - Location = location ContainerName = container + Location = U2.First location + Kind = kind Tags = None - Deprecated = None } + Data = None } if symbolFilter sym then Some sym else None [| yield! inner None topLevel.Declaration |> Option.toArray yield! topLevel.Nested |> Array.choose (inner (Some topLevel.Declaration.LogicalName)) |] - let applyQuery (query: string) (info: SymbolInformation) = + let getDocumentSymbol + (glyphToSymbolKind: FSharpGlyph -> SymbolKind option) + (topLevelItem: NavigationTopLevelDeclaration) + : DocumentSymbol = + let makeChildDocumentSymbol (item: NavigationItem) : DocumentSymbol = + { Name = item.LogicalName + Detail = None + Kind = defaultArg (glyphToSymbolKind item.Glyph) SymbolKind.Module + Deprecated = None + Range = fcsRangeToLsp item.Range + SelectionRange = fcsRangeToLsp item.Range + Children = None + Tags = None } + + { Name = topLevelItem.Declaration.LogicalName + // TODO: what Detail actually influences + Detail = None + Kind = defaultArg (glyphToSymbolKind topLevelItem.Declaration.Glyph) SymbolKind.Module + Deprecated = None + // TODO: it would be good if we could get just the 'interesting' part of the Declaration.Range here for the SelectionRange + Range = fcsRangeToLsp topLevelItem.Declaration.Range + SelectionRange = fcsRangeToLsp topLevelItem.Declaration.Range + Children = + match topLevelItem.Nested with + | [||] -> None + | children -> Some(Array.map makeChildDocumentSymbol children) + Tags = None } + + let applyQuery (query: string) (info: WorkspaceSymbol) = match query.Split([| '.' |], StringSplitOptions.RemoveEmptyEntries) with | [||] -> false | [| fullName |] -> info.Name.StartsWith(fullName, StringComparison.Ordinal) diff --git a/src/FsAutoComplete/LspHelpers.fsi b/src/FsAutoComplete/LspHelpers.fsi index e6bd689ab..58625c705 100644 --- a/src/FsAutoComplete/LspHelpers.fsi +++ b/src/FsAutoComplete/LspHelpers.fsi @@ -57,14 +57,19 @@ module Conversions = val urlForCompilerCode: number: int -> string val fcsErrorToDiagnostic: error: FSharpDiagnostic -> Diagnostic - val getSymbolInformations: + val getWorkspaceSymbols: uri: DocumentUri -> glyphToSymbolKind: (FSharpGlyph -> SymbolKind option) -> topLevel: NavigationTopLevelDeclaration -> - symbolFilter: (SymbolInformation -> bool) -> - SymbolInformation array + symbolFilter: (WorkspaceSymbol -> bool) -> + WorkspaceSymbol array - val applyQuery: query: string -> info: SymbolInformation -> bool + val getDocumentSymbol: + glyphToSymbolKind: (FSharpGlyph -> SymbolKind option) -> + topLevelItem: NavigationTopLevelDeclaration -> + DocumentSymbol + + val applyQuery: query: string -> info: WorkspaceSymbol -> bool val getCodeLensInformation: uri: DocumentUri -> typ: string -> topLevel: NavigationTopLevelDeclaration -> CodeLens array diff --git a/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs b/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs index 130161c96..2646e53a9 100644 --- a/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs +++ b/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs @@ -41,6 +41,8 @@ open System.Threading.Tasks open FsAutoComplete.FCSPatches open Helpers +#nowarn "44" // we create CompletionItems here that have two deprecated properties. Since records always have to set each property.... + type AdaptiveFSharpLspServer (workspaceLoader: IWorkspaceLoader, lspClient: FSharpLspClient, sourceTextFactory: ISourceTextFactory) = @@ -305,15 +307,22 @@ type AdaptiveFSharpLspServer | Some false -> None | None -> None - let actualRootPath = - match p.RootUri with - | Some rootUri -> Some(Path.FileUriToLocalPath rootUri) - | None -> p.RootPath + let workspaceRoot = + match p.WorkspaceFolders with + | None + | Some [||] -> + failwith + "Unable to start LSP server - no workspacePaths are available and we do not support the deprecated RootPath and RootUri options." + | Some folders -> Some(Path.FileUriToLocalPath folders[0].Uri) let projs = - match p.RootPath, c.AutomaticWorkspaceInit with + match workspaceRoot, c.AutomaticWorkspaceInit with + // if no workspace root available, or one is available but we don't want to automatically + // initialize the workspace, then we don't have any projects to initialize. + // in this case we need the client to call WorkspacePeek/WorkspaceLoad explicitly | None, _ - | _, false -> state.WorkspacePaths + | _, false -> WorkspaceChosen.NotChosen + // otherwise, try to infer the workspace from the automatic workspace information | Some actualRootPath, true -> let peeks = WorkspacePeek.peek @@ -342,7 +351,7 @@ type AdaptiveFSharpLspServer |> WorkspaceChosen.Projs transact (fun () -> - state.RootPath <- actualRootPath + state.RootPath <- workspaceRoot state.ClientCapabilities <- p.Capabilities lspClient.ClientCapabilities <- p.Capabilities @@ -545,7 +554,7 @@ type AdaptiveFSharpLspServer if lineStr.StartsWith("#", StringComparison.Ordinal) then let completionList = { IsIncomplete = false - Items = KeywordList.hashSymbolCompletionItems + Items = KeywordList.hashSymbolCompletionItems p.Position ItemDefaults = None } @@ -624,7 +633,13 @@ type AdaptiveFSharpLspServer | Some no when config.FullNameExternalAutocomplete -> sprintf "%s.%s" no d.NameInCode | _ -> d.NameInCode - let createCompletionItem (config: FSharpConfig) (id: int) (d: DeclarationListItem) = + let textEdit text pos : U2 = + U2.First( + { Range = { Start = pos; End = pos } + NewText = text } + ) + + let createCompletionItem (config: FSharpConfig) insertPos (id: int) (d: DeclarationListItem) = let code = getCodeToInsert d /// The `label` for completion "System.Math.Ceiling" will be displayed as "Ceiling (System.Math)". This is to bias the viewer towards the member name, @@ -641,7 +656,7 @@ type AdaptiveFSharpLspServer { CompletionItem.Create(d.NameInList) with Data = Some(JValue(d.FullName)) Kind = (state.GlyphToCompletionKind) d.Glyph - InsertText = Some code + TextEdit = Some(textEdit code insertPos) SortText = Some(sprintf "%06d" id) FilterText = Some filterText Label = label } @@ -668,13 +683,13 @@ type AdaptiveFSharpLspServer let includeKeywords = config.KeywordsAutocomplete && shouldKeywords - let items = decls |> Array.mapi (createCompletionItem config) + let items = decls |> Array.mapi (createCompletionItem config p.Position) let its = if not includeKeywords then items else - Array.append items KeywordList.keywordCompletionItems + Array.append items (KeywordList.keywordCompletionItems p.Position) let completionList = { IsIncomplete = false @@ -899,24 +914,26 @@ type AdaptiveFSharpLspServer else TipFormatter.FormatCommentStyle.Legacy + let md text : MarkupContent = + { Kind = MarkupKind.Markdown + Value = text } + match TipFormatter.tryFormatTipEnhanced tooltipResult.ToolTipText formatCommentStyle with | TipFormatter.TipFormatterResult.Success tooltipInfo -> // Display the signature as a code block + + let fsharpCode s = "```fsharp\n" + s + "\n```" + let signature = - tooltipResult.Signature - |> TipFormatter.prepareSignature - |> (fun content -> MarkedString.WithLanguage { Language = "fsharp"; Value = content }) + tooltipResult.Signature |> TipFormatter.prepareSignature |> fsharpCode // Display each footer line as a separate line - let footerLines = - tooltipResult.Footer - |> TipFormatter.prepareFooterLines - |> Array.map MarkedString.String + let footerLines = tooltipResult.Footer |> TipFormatter.prepareFooterLines let contents = [| signature - MarkedString.String tooltipInfo.DocComment + tooltipInfo.DocComment match tooltipResult.SymbolInfo with | TryGetToolTipEnhancedResult.Keyword _ -> () | TryGetToolTipEnhancedResult.Symbol symbolInfo -> @@ -924,20 +941,19 @@ type AdaptiveFSharpLspServer tooltipInfo.HasTruncatedExamples symbolInfo.XmlDocSig symbolInfo.Assembly - |> MarkedString.String yield! footerLines |] let response = - { Contents = MarkedStrings contents + { Contents = contents |> String.join Environment.NewLine |> md |> MarkupContent Range = None } return (Some response) | TipFormatter.TipFormatterResult.Error error -> - let contents = [| MarkedString.String ""; MarkedString.String error |] + let contents = md $"\n {error}" let response = - { Contents = MarkedStrings contents + { Contents = MarkupContent contents Range = None } return (Some response) @@ -1250,6 +1266,7 @@ type AdaptiveFSharpLspServer return! returnException e } + /// This is mostly used to power the per-document @-based searching in VSCode. Open the Command Palette, type a query starting with @SOME_MEMBER_NAME, and quickly go to that member in the current file override __.TextDocumentDocumentSymbol(p: DocumentSymbolParams) = asyncResult { let tags = [ "DocumentSymbolParams", box p ] @@ -1267,11 +1284,10 @@ type AdaptiveFSharpLspServer | Some decls -> return decls - |> Array.collect (fun top -> - getSymbolInformations p.TextDocument.Uri state.GlyphToSymbolKind top (fun _s -> true)) - |> U2.First + |> Array.map (getDocumentSymbol state.GlyphToSymbolKind) + |> U2.Second |> Some - | None -> return! LspResult.internalError $"No declarations for {fn}" + | None -> return None with e -> trace |> Tracing.recordException e @@ -1285,6 +1301,7 @@ type AdaptiveFSharpLspServer } + /// This is used to power the "Go to symbol in workspace" feature in VSCode. Open the Command Palette, type a query starting with #SOME_MEMBER_NAME, and quickly go to that member in the entire workspace override __.WorkspaceSymbol(symbolRequest: WorkspaceSymbolParams) = asyncResult { let tags = [ "WorkspaceSymbolParams", box symbolRequest ] @@ -1306,12 +1323,9 @@ type AdaptiveFSharpLspServer let uri = Path.LocalPathToUri p ns - |> Array.collect (fun n -> - getSymbolInformations uri glyphToSymbolKind n (applyQuery symbolRequest.Query))) - |> U2.First - |> Some + |> Array.collect (fun n -> getWorkspaceSymbols uri glyphToSymbolKind n (applyQuery symbolRequest.Query))) - return res + return Some(U2.Second res) with e -> trace |> Tracing.recordException e @@ -3062,16 +3076,16 @@ type AdaptiveFSharpLspServer override x.Dispose() = disposables.Dispose() - member this.WorkDoneProgressCancel(token: ProgressToken) : Async = + member this.WorkDoneProgressCancel(progressParams: WorkDoneProgressCancelParams) : Async = async { - let tags = [ "ProgressToken", box token ] + let tags = [ "ProgressToken", box progressParams.token ] use trace = fsacActivitySource.StartActivityForType(thisType, tags = tags) try logger.info ( Log.setMessage "WorkDoneProgressCancel Request: {params}" - >> Log.addContextDestructured "params" token + >> Log.addContextDestructured "params" progressParams.token ) with e -> @@ -3079,7 +3093,7 @@ type AdaptiveFSharpLspServer logger.error ( Log.setMessage "WorkDoneProgressCancel Request Errored {p}" - >> Log.addContextDestructured "token" token + >> Log.addContextDestructured "token" progressParams.token >> Log.addExn e ) @@ -3147,8 +3161,6 @@ module AdaptiveFSharpLspServer = } - - let startCore toolsPath workspaceLoaderFactory sourceTextFactory = use input = Console.OpenStandardInput() use output = Console.OpenStandardOutput() diff --git a/test/FsAutoComplete.Tests.Lsp/CompletionTests.fs b/test/FsAutoComplete.Tests.Lsp/CompletionTests.fs index b519a34ac..b95006260 100644 --- a/test/FsAutoComplete.Tests.Lsp/CompletionTests.fs +++ b/test/FsAutoComplete.Tests.Lsp/CompletionTests.fs @@ -10,6 +10,8 @@ open FsAutoComplete.Lsp open FsToolkit.ErrorHandling open Helpers.Expecto.ShadowedTimeouts +#nowarn "44" //we're testing so need to be able to use deprecated fields + let tests state = let server = async { @@ -781,7 +783,9 @@ let autoOpenTests state = let (|ContainsOpenAction|_|) (codeActions: CodeAction[]) = codeActions - |> Array.tryFind (fun ca -> ca.Kind = Some "quickfix" && ca.Title.StartsWith("open ", StringComparison.Ordinal)) + |> Array.tryFind (fun ca -> + ca.Kind = Some "quickfix" + && ca.Title.StartsWith("open ", StringComparison.Ordinal)) match! server.TextDocumentCodeAction p with | Error e -> return failtestf "Quick fix Request failed: %A" e diff --git a/test/FsAutoComplete.Tests.Lsp/CoreTests.fs b/test/FsAutoComplete.Tests.Lsp/CoreTests.fs index fbc24265a..def90140b 100644 --- a/test/FsAutoComplete.Tests.Lsp/CoreTests.fs +++ b/test/FsAutoComplete.Tests.Lsp/CoreTests.fs @@ -24,19 +24,20 @@ open FSharpx.Control open Utils.Tests open Helpers.Expecto.ShadowedTimeouts +#nowarn "44" //we're testing so need to be able to use deprecated fields + ///Test for initialization of the server let initTests createServer = testCaseAsync "InitTest" (async { - let tempDir = - Path.Combine(Path.GetTempPath(), "FsAutoComplete.Tests", Guid.NewGuid().ToString()) + use tempDir = DisposableDirectory.Create() let (server: IFSharpLspServer, _event) = createServer () let p: InitializeParams = { ProcessId = Some 1 - RootPath = Some __SOURCE_DIRECTORY__ + RootPath = None Locale = None RootUri = None InitializationOptions = Some(Server.serialize defaultConfigDto) @@ -47,9 +48,9 @@ let initTests createServer = Version = Some "0.0.0" } WorkspaceFolders = Some - [| { Uri = Path.FilePathToUri tempDir + [| { Uri = Path.FilePathToUri tempDir.DirectoryInfo.FullName Name = "Test Folder" } |] - trace = None } + trace = Some "verbose" } let! result = server.Initialize p @@ -112,7 +113,7 @@ let initTests createServer = "Workspace Symbol Provider" Expect.equal res.Capabilities.FoldingRangeProvider (Some true) "Folding Range Provider active" - | Result.Error _e -> failtest "Initialization failed" + | Result.Error e -> failtest e.Message }) ///Tests for getting document symbols @@ -140,14 +141,26 @@ let documentSymbolTest state = match res with | Result.Error e -> failtestf "Request failed: %A" e | Result.Ok None -> failtest "Request none" - | Result.Ok(Some(U2.First res)) -> - Expect.equal res.Length 15 "Document Symbol has all symbols" + | Result.Ok(Some(U2.First _)) -> raise (NotImplementedException("DocumentSymbol isn't used in FSAC yet")) + + | Result.Ok(Some(U2.Second res)) -> + + // have to unroll the document symbols since they are properly heirarchical now + let all (s: DocumentSymbol) = + [| yield s + yield! + (match s.Children with + | Some c -> c + | None -> [||]) |] + + let allSymbols = res |> Array.collect all + + Expect.equal allSymbols.Length 15 "Document Symbol has all symbols" Expect.exists - res + allSymbols (fun n -> n.Name = "MyDateTime" && n.Kind = SymbolKind.Class) "Document symbol contains given symbol" - | Result.Ok(Some(U2.Second _res)) -> raise (NotImplementedException("DocumentSymbol isn't used in FSAC yet")) }) ] let foldingTests state = diff --git a/test/FsAutoComplete.Tests.Lsp/EmptyFileTests.fs b/test/FsAutoComplete.Tests.Lsp/EmptyFileTests.fs index e5095e16e..6c1b9a628 100644 --- a/test/FsAutoComplete.Tests.Lsp/EmptyFileTests.fs +++ b/test/FsAutoComplete.Tests.Lsp/EmptyFileTests.fs @@ -10,8 +10,10 @@ open FsToolkit.ErrorHandling open Utils.Server open Helpers.Expecto.ShadowedTimeouts +#nowarn "44" //we're testing so need to be able to use deprecated fields + let tests state = - let createServer() = + let createServer () = async { let path = Path.Combine(__SOURCE_DIRECTORY__, "TestCases", "EmptyFileTests") @@ -23,87 +25,94 @@ let tests state = return server, events, scriptPath } |> Async.Cache - let server1 = createServer() - let server2 = createServer() + + let server1 = createServer () + let server2 = createServer () testList "empty file features" [ testList "tests" - [ - testCaseAsync - "no parsing/checking errors" - (async { - let! server, events, scriptPath = server1 - do! server.TextDocumentDidOpen { TextDocument = loadDocument scriptPath } - - match! waitForParseResultsForFile "EmptyFile.fsx" events with - | Ok _ -> () // all good, no parsing/checking errors - | Core.Result.Error errors -> failwithf "Errors while parsing script %s: %A" scriptPath errors - }) - - testCaseAsync - "auto completion does not throw and is empty" - (async { - let! server, _, path = server1 - do! server.TextDocumentDidOpen { TextDocument = loadDocument path } - - let completionParams: CompletionParams = - { TextDocument = { Uri = Path.FilePathToUri path } - Position = { Line = 0; Character = 0 } - Context = - Some - { triggerKind = CompletionTriggerKind.Invoked - triggerCharacter = None } } - - match! server.TextDocumentCompletion completionParams with - | Ok (Some _) -> failtest "An empty file has empty completions" - | Ok None -> () - | Error e -> failtestf "Got an error while retrieving completions: %A" e - }) - testCaseAsync - "type 'c' for checking error and autocompletion starts with 'async'" - (async { - let! server, events, scriptPath = server2 - do! server.TextDocumentDidOpen { TextDocument = loadDocument scriptPath } - - do! server.TextDocumentDidChange { - TextDocument = { Uri = Path.FilePathToUri scriptPath; Version = 1 } - ContentChanges = [| { - Range = Some { Start = { Line = 0; Character = 0 }; End = { Line = 0; Character = 0 } } - RangeLength = Some 0 - Text = "c" - } |] - } - - let! completions = - server.TextDocumentCompletion { - TextDocument = { Uri = Path.FilePathToUri scriptPath } - Position = { Line = 0; Character = 1 } - Context = - Some - { triggerKind = CompletionTriggerKind.Invoked - triggerCharacter = None } - } |> Async.StartChild - - let! compilerResults = waitForCompilerDiagnosticsForFile "EmptyFile.fsx" events |> Async.StartChild - - match! compilerResults with - | Ok () -> failtest "should get an F# compiler checking error from a 'c' by itself" - | Core.Result.Error errors -> - Expect.hasLength errors 1 "should have only an error FS0039: identifier not defined" - Expect.exists errors (fun error -> error.Code = Some "39") "should have an error FS0039: identifier not defined" - - match! completions with - | Ok (Some completions) -> - Expect.isGreaterThan - completions.Items.Length - 30 - "should have a complete completion list all containing c" - - let firstItem = completions.Items.[0] - Expect.equal firstItem.Label "async" "first member should be async" - | Ok None -> failtest "Should have gotten some completion items" - | Error e -> failtestf "Got an error while retrieving completions: %A" e - }) - ]] + [ testCaseAsync + "no parsing/checking errors" + (async { + let! server, events, scriptPath = server1 + do! server.TextDocumentDidOpen { TextDocument = loadDocument scriptPath } + + match! waitForParseResultsForFile "EmptyFile.fsx" events with + | Ok _ -> () // all good, no parsing/checking errors + | Core.Result.Error errors -> failwithf "Errors while parsing script %s: %A" scriptPath errors + }) + + testCaseAsync + "auto completion does not throw and is empty" + (async { + let! server, _, path = server1 + do! server.TextDocumentDidOpen { TextDocument = loadDocument path } + + let completionParams: CompletionParams = + { TextDocument = { Uri = Path.FilePathToUri path } + Position = { Line = 0; Character = 0 } + Context = + Some + { triggerKind = CompletionTriggerKind.Invoked + triggerCharacter = None } } + + match! server.TextDocumentCompletion completionParams with + | Ok(Some _) -> failtest "An empty file has empty completions" + | Ok None -> () + | Error e -> failtestf "Got an error while retrieving completions: %A" e + }) + testCaseAsync + "type 'c' for checking error and autocompletion starts with 'async'" + (async { + let! server, events, scriptPath = server2 + do! server.TextDocumentDidOpen { TextDocument = loadDocument scriptPath } + + do! + server.TextDocumentDidChange + { TextDocument = + { Uri = Path.FilePathToUri scriptPath + Version = 1 } + ContentChanges = + [| { Range = + Some + { Start = { Line = 0; Character = 0 } + End = { Line = 0; Character = 0 } } + RangeLength = Some 0 + Text = "c" } |] } + + let! completions = + server.TextDocumentCompletion + { TextDocument = { Uri = Path.FilePathToUri scriptPath } + Position = { Line = 0; Character = 1 } + Context = + Some + { triggerKind = CompletionTriggerKind.Invoked + triggerCharacter = None } } + |> Async.StartChild + + let! compilerResults = waitForCompilerDiagnosticsForFile "EmptyFile.fsx" events |> Async.StartChild + + match! compilerResults with + | Ok() -> failtest "should get an F# compiler checking error from a 'c' by itself" + | Core.Result.Error errors -> + Expect.hasLength errors 1 "should have only an error FS0039: identifier not defined" + + Expect.exists + errors + (fun error -> error.Code = Some "39") + "should have an error FS0039: identifier not defined" + + match! completions with + | Ok(Some completions) -> + Expect.isGreaterThan + completions.Items.Length + 30 + "should have a complete completion list all containing c" + + let firstItem = completions.Items.[0] + Expect.equal firstItem.Label "async" "first member should be async" + | Ok None -> failtest "Should have gotten some completion items" + | Error e -> failtestf "Got an error while retrieving completions: %A" e + }) ] ] diff --git a/test/FsAutoComplete.Tests.Lsp/Helpers.fs b/test/FsAutoComplete.Tests.Lsp/Helpers.fs index 2b0ee4c6b..f8461820a 100644 --- a/test/FsAutoComplete.Tests.Lsp/Helpers.fs +++ b/test/FsAutoComplete.Tests.Lsp/Helpers.fs @@ -12,6 +12,8 @@ open FSharp.Control.Reactive open System.Threading open FSharp.UMX +#nowarn "44" //we're testing so need to be able to use deprecated fields + module Expecto = open System.Threading.Tasks @@ -529,18 +531,19 @@ let dotnetToolRestore dir = let serverInitialize path (config: FSharpConfigDto) createServer = async { - dotnetCleanup path - for file in System.IO.Directory.EnumerateFiles(path, "*.fsproj", SearchOption.AllDirectories) do - do! file |> Path.GetDirectoryName |> dotnetRestore + let d = DisposableDirectory.From path + + for file in d.DirectoryInfo.GetFiles("*.fsproj", SearchOption.AllDirectories) do + do! file.DirectoryName |> dotnetRestore let (server: IFSharpLspServer), clientNotifications = createServer () clientNotifications |> Observable.add logEvent let p: InitializeParams = { ProcessId = Some 1 - RootPath = Some path - RootUri = Some(sprintf "file://%s" path) + RootPath = None + RootUri = None InitializationOptions = Some(Server.serialize config) Capabilities = Some clientCaps ClientInfo = @@ -549,9 +552,9 @@ let serverInitialize path (config: FSharpConfigDto) createServer = Version = Some "0.0.0" } WorkspaceFolders = Some - [| { Uri = Path.FilePathToUri path + [| { Uri = Path.FilePathToUri d.DirectoryInfo.FullName Name = "Test Folder" } |] - trace = None + trace = Some "verbose" Locale = None } let! result = server.Initialize p diff --git a/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs b/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs index 79048a600..671e9c5f4 100644 --- a/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs +++ b/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs @@ -15,10 +15,12 @@ open Expecto open Utils open Ionide.ProjInfo.Logging +#nowarn "44" //we're testing so need to be able to use deprecated fields + let private logger = LogProvider.getLoggerByName "Utils.Server" type Server = - { RootPath: string option + { RootPath: string Server: IFSharpLspServer Events: ClientEvents mutable UntitledCounter: int } @@ -28,26 +30,22 @@ 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 = - doc |> Document.close |> Async.RunSynchronously + override doc.Dispose() : unit = doc |> Document.close |> Async.RunSynchronously module Server = let private initialize path (config: FSharpConfigDto) createServer = @@ -57,22 +55,24 @@ module Server = >> Log.addContextDestructured "path" path ) - match path with - | None -> () - | Some path -> - dotnetCleanup path + let path = + match path with + | Some p -> p + | None -> DisposableDirectory.Create().DirectoryInfo.FullName - for file in System.IO.Directory.EnumerateFiles(path, "*.fsproj", SearchOption.AllDirectories) do - do! file |> Path.GetDirectoryName |> dotnetRestore + dotnetCleanup path - let (server : IFSharpLspServer, events : IObservable<_>) = createServer () + for file in System.IO.Directory.EnumerateFiles(path, "*.fsproj", SearchOption.AllDirectories) do + do! file |> Path.GetDirectoryName |> dotnetRestore + + let (server: IFSharpLspServer, events: IObservable<_>) = createServer () events |> Observable.add logEvent let p: InitializeParams = { ProcessId = Some 1 - RootPath = path + RootPath = None Locale = None - RootUri = path |> Option.map (sprintf "file://%s") + RootUri = None InitializationOptions = Some(Server.serialize config) Capabilities = Some clientCaps ClientInfo = @@ -80,15 +80,15 @@ module Server = { Name = "FSAC Tests" Version = Some "0.0.0" } WorkspaceFolders = - path - |> Option.map (fun p -> - [| { Uri = Path.FilePathToUri p - Name = "Test Folder" } |]) + Some + [| { Uri = Path.FilePathToUri path + Name = "Test Folder" } |] trace = None } match! server.Initialize p with | Ok _ -> - do! server.Initialized (InitializedParams()) + do! server.Initialized(InitializedParams()) + return { RootPath = path Server = server @@ -101,8 +101,7 @@ module Server = async { let! server = initialize path config createServer - if path |> Option.isSome then - do! waitForWorkspaceFinishedParsing server.Events + do! waitForWorkspaceFinishedParsing server.Events return server } @@ -131,9 +130,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 @@ -154,24 +151,19 @@ module Server = if Path.IsPathRooted path then path else - Expect.isSome server.RootPath "relative path is only possible when `server.RootPath` is specified!" - Path.Combine(server.RootPath.Value, path) + Path.Combine(server.RootPath, path) let doc = 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) } @@ -187,19 +179,16 @@ module Server = let openDocumentWithText path (initialText: string) (server: CachedServer) = async { let! server = server - assert (server.RootPath |> Option.isSome) let fullPath = - Path.Combine(server.RootPath.Value, path) + Path.Combine(server.RootPath, path) |> Utils.normalizePath |> FSharp.UMX.UMX.untag // 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 @@ -211,11 +200,7 @@ module Document = 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) + Observable.choose (fun (typ', _o) -> if typ' = typ then Some(unbox _o) else None) /// `textDocument/publishDiagnostics` /// @@ -225,11 +210,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 +222,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 +277,7 @@ module Document = >> Log.addContext "uri" doc.Uri >> Log.addContext "version" doc.Version ) + let tcs = TaskCompletionSource<_>() use _ = @@ -313,7 +293,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 @@ -322,11 +302,10 @@ module Document = /// Note: Mutates passed `doc` - let private incrVersion (doc: Document) = - System.Threading.Interlocked.Increment(&doc.Version) + let private incrVersion (doc: 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 +322,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 +350,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 +365,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 +378,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..1010ab815 100644 --- a/test/FsAutoComplete.Tests.Lsp/Utils/Server.fsi +++ b/test/FsAutoComplete.Tests.Lsp/Utils/Server.fsi @@ -16,111 +16,114 @@ open Utils open Ionide.ProjInfo.Logging type Server = - { RootPath: string option - Server: IFSharpLspServer - Events: ClientEvents - mutable UntitledCounter: int } + { RootPath: string + 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 + /// if not specified, a temp directory will be created to anchor the LSP + /// + /// + 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 + /// `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 - /// Note: diagnostics aren't filtered to match passed range in here - val codeActionAt: - diagnostics: Diagnostic[] -> range: Range -> 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 + val inlayHintsAt: range: Range -> doc: Document -> Async + val resolveInlayHint: inlayHint: InlayHint -> doc: Document -> Async diff --git a/test/FsAutoComplete.Tests.Lsp/Utils/ServerTests.fs b/test/FsAutoComplete.Tests.Lsp/Utils/ServerTests.fs index 4633104ec..7affc9ffa 100644 --- a/test/FsAutoComplete.Tests.Lsp/Utils/ServerTests.fs +++ b/test/FsAutoComplete.Tests.Lsp/Utils/ServerTests.fs @@ -23,41 +23,25 @@ let cleanableTestList (cleanup: Async<'a> -> Async) (tests: Async<'a> -> Test list) = - let value = - if cacheValue then - initialize |> Async.Cache - else - initialize + let value = if cacheValue then initialize |> Async.Cache else initialize let tests = tests value - testSequenced <| runner name [ - yield! tests + testSequenced + <| runner + name + [ yield! tests - if not (tests |> List.isEmpty) then - testCaseAsync "cleanup" (cleanup value) - ] + if not (tests |> List.isEmpty) then + testCaseAsync "cleanup" (cleanup value) ] -let private serverTestList' - runner - name - createServer - config - path - tests - = +let private serverTestList' runner name createServer config path tests = // path must be "absolutely normalized". `..` (parent) isn't valid -> Uri in FSAC and uri in doc are otherwise different, which leads to infinte waiting or timeouts. let path = path |> Option.map (System.IO.Path.GetFullPath) let init = Server.create path config createServer let cleanup = Server.shutdown - cleanableTestList - runner - name - init - false - cleanup - tests + cleanableTestList runner name init false cleanup tests /// ## Example /// ```fsharp @@ -81,20 +65,11 @@ let private documentTestList' (getDocument: CachedServer -> Async) tests = - let doc = - server - |> getDocument - |> Async.Cache + let doc = server |> getDocument |> Async.Cache let init = doc let cleanup = Async.map fst >> Async.bind Document.close - cleanableTestList - runner - name - init - false - cleanup - tests + cleanableTestList runner name init false cleanup tests /// Note: Not intended for changing document: always same (initial) diags /// diff --git a/test/FsAutoComplete.Tests.Lsp/Utils/ServerTests.fsi b/test/FsAutoComplete.Tests.Lsp/Utils/ServerTests.fsi index 683d54abd..b71805525 100644 --- a/test/FsAutoComplete.Tests.Lsp/Utils/ServerTests.fsi +++ b/test/FsAutoComplete.Tests.Lsp/Utils/ServerTests.fsi @@ -16,13 +16,13 @@ open Ionide.LanguageServerProtocol.Types /// Use `false` when `initialize` returns an already cached value, otherwise `false`. /// Then in here `Async.Cache` is used to cache value. val cleanableTestList: - runner: (string -> Test list -> Test) -> - name: string -> - initialize: Async<'a> -> - cacheValue: bool -> - cleanup: (Async<'a> -> Async) -> - tests: (Async<'a> -> Test list) -> - Test + runner: (string -> Test list -> Test) -> + name: string -> + initialize: Async<'a> -> + cacheValue: bool -> + cleanup: (Async<'a> -> Async) -> + tests: (Async<'a> -> Test list) -> + Test /// ## Example /// ```fsharp @@ -35,9 +35,29 @@ val cleanableTestList: /// } /// ]) /// ``` -val serverTestList: (string -> (unit -> FsAutoComplete.Lsp.IFSharpLspServer * System.IObservable) -> FsAutoComplete.LspHelpers.FSharpConfigDto -> option -> (Async -> list) -> Test) -val fserverTestList: (string -> (unit -> FsAutoComplete.Lsp.IFSharpLspServer * System.IObservable) -> FsAutoComplete.LspHelpers.FSharpConfigDto -> option -> (Async -> list) -> Test) -val pserverTestList: (string -> (unit -> FsAutoComplete.Lsp.IFSharpLspServer * System.IObservable) -> FsAutoComplete.LspHelpers.FSharpConfigDto -> option -> (Async -> list) -> Test) +val serverTestList: + (string + -> (unit -> FsAutoComplete.Lsp.IFSharpLspServer * System.IObservable) + -> FsAutoComplete.LspHelpers.FSharpConfigDto + -> option + -> (Async -> list) + -> Test) + +val fserverTestList: + (string + -> (unit -> FsAutoComplete.Lsp.IFSharpLspServer * System.IObservable) + -> FsAutoComplete.LspHelpers.FSharpConfigDto + -> option + -> (Async -> list) + -> Test) + +val pserverTestList: + (string + -> (unit -> FsAutoComplete.Lsp.IFSharpLspServer * System.IObservable) + -> FsAutoComplete.LspHelpers.FSharpConfigDto + -> option + -> (Async -> list) + -> Test) /// Note: Not intended for changing document: always same (initial) diags @@ -57,6 +77,23 @@ val pserverTestList: (string -> (unit -> FsAutoComplete.Lsp.IFSharpLspServer * S /// ]) /// ]) /// ``` -val documentTestList: (string -> CachedServer -> (CachedServer -> Async) -> (Async -> list) -> Test) -val fdocumentTestList: (string -> CachedServer -> (CachedServer -> Async) -> (Async -> list) -> Test) -val pdocumentTestList: (string -> CachedServer -> (CachedServer -> Async) -> (Async -> list) -> Test) +val documentTestList: + (string + -> CachedServer + -> (CachedServer -> Async) + -> (Async -> list) + -> Test) + +val fdocumentTestList: + (string + -> CachedServer + -> (CachedServer -> Async) + -> (Async -> list) + -> Test) + +val pdocumentTestList: + (string + -> CachedServer + -> (CachedServer -> Async) + -> (Async -> list) + -> Test)