diff --git a/src/fsharp/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.DesignTime.proj b/src/fsharp/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.DesignTime.proj new file mode 100644 index 000000000..d02ae419b --- /dev/null +++ b/src/fsharp/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.DesignTime.proj @@ -0,0 +1,49 @@ + + + + + + + $(MSBuildThisFileDirectory)FSharp.Compiler.LanguageServer.DesignTime.targets + + + + + + + + + + + + + + diff --git a/src/fsharp/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.DesignTime.targets b/src/fsharp/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.DesignTime.targets new file mode 100644 index 000000000..ea8f3e286 --- /dev/null +++ b/src/fsharp/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.DesignTime.targets @@ -0,0 +1,52 @@ + + + + + true + false + true + true + false + false + false + true + false + true + false + + + + + + + _ComputeTargetFrameworkItems + _PopulateTargetFrameworks + + + + + <_TargetFramework Include="$(TargetFramework)" /> + + + + + + + + + + + + + + diff --git a/src/fsharp/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj b/src/fsharp/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj index fd6e517e5..0bb089914 100644 --- a/src/fsharp/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj +++ b/src/fsharp/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj @@ -23,6 +23,15 @@ + + + + + + + + + diff --git a/src/fsharp/FSharp.Compiler.LanguageServer/LspExternalAccess.fs b/src/fsharp/FSharp.Compiler.LanguageServer/LspExternalAccess.fs index e6fa760d1..48e4b0b40 100644 --- a/src/fsharp/FSharp.Compiler.LanguageServer/LspExternalAccess.fs +++ b/src/fsharp/FSharp.Compiler.LanguageServer/LspExternalAccess.fs @@ -9,10 +9,18 @@ module FunctionNames = [] let OptionsSet = "options/set" + [] + let TextDocumentPublishDiagnostics = "textDocument/publishDiagnostics" + type Options = - { usePreviewTextHover: bool } + { usePreviewTextHover: bool + usePreviewDiagnostics: bool } static member Default() = - { usePreviewTextHover = false } + { usePreviewTextHover = false + usePreviewDiagnostics = false } + static member AllOn() = + { usePreviewTextHover = true + usePreviewDiagnostics = true } module Extensions = type JsonRpc with diff --git a/src/fsharp/FSharp.Compiler.LanguageServer/LspTypes.fs b/src/fsharp/FSharp.Compiler.LanguageServer/LspTypes.fs index 97479eef2..264e526fc 100644 --- a/src/fsharp/FSharp.Compiler.LanguageServer/LspTypes.fs +++ b/src/fsharp/FSharp.Compiler.LanguageServer/LspTypes.fs @@ -5,8 +5,9 @@ namespace FSharp.Compiler.LanguageServer open Newtonsoft.Json.Linq open Newtonsoft.Json -// Interfaces as defined at https://microsoft.github.io/language-server-protocol/specification. The properties on -// these types are camlCased to match the underlying JSON properties to avoid attributes on every field: +// Interfaces as defined at https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/. +// The properties on these types are camlCased to match the underlying JSON properties to avoid attributes on every +// field: // [] /// Represents a zero-based line and column of a text document. @@ -32,7 +33,7 @@ type Diagnostic = { range: Range severity: int option code: string - source: string option // "F#" + source: string option message: string relatedInformation: DiagnosticRelatedInformation[] option } static member Error = 1 @@ -46,7 +47,7 @@ type PublishDiagnosticsParams = type ClientCapabilities = { workspace: JToken option // TODO: WorkspaceClientCapabilities - textDocument: JToken option // TODO: TextDocumentCapabilities + textDocument: JToken option // TODO: TextDocumentClientCapabilities, publishDiagnostics: { relatedInformation: bool option } experimental: JToken option supportsVisualStudioExtensions: bool option } diff --git a/src/fsharp/FSharp.Compiler.LanguageServer/Methods.fs b/src/fsharp/FSharp.Compiler.LanguageServer/Methods.fs index d1e614cb2..453b7f822 100644 --- a/src/fsharp/FSharp.Compiler.LanguageServer/Methods.fs +++ b/src/fsharp/FSharp.Compiler.LanguageServer/Methods.fs @@ -8,8 +8,10 @@ open System.Threading open Newtonsoft.Json.Linq open StreamJsonRpc -// https://microsoft.github.io/language-server-protocol/specification -type Methods(state: State) = +// https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/ +type Methods() = + + let state = State() /// Helper to run Async<'T> with a CancellationToken. let runAsync (cancellationToken: CancellationToken) (computation: Async<'T>) = Async.StartAsTask(computation, cancellationToken=cancellationToken) @@ -29,8 +31,10 @@ type Methods(state: State) = [] initializationOptions: JToken, capabilities: ClientCapabilities, [] trace: string, - [] workspaceFolders: WorkspaceFolder[] + [] workspaceFolders: WorkspaceFolder[], + [] cancellationToken: CancellationToken ) = + state.Initialize rootPath rootUri (fun projectOptions -> TextDocument.PublishDiagnostics(state, projectOptions) |> Async.Start) { InitializeResult.capabilities = ServerCapabilities.DefaultCapabilities() } [] @@ -63,5 +67,6 @@ type Methods(state: State) = ( options: Options ) = - sprintf "got options %A" options |> Console.Error.WriteLine + eprintfn "got options %A" options state.Options <- options + state.InvalidateAllProjects() diff --git a/src/fsharp/FSharp.Compiler.LanguageServer/Server.fs b/src/fsharp/FSharp.Compiler.LanguageServer/Server.fs index 071ad6b22..28d5e49a5 100644 --- a/src/fsharp/FSharp.Compiler.LanguageServer/Server.fs +++ b/src/fsharp/FSharp.Compiler.LanguageServer/Server.fs @@ -12,17 +12,17 @@ type Server(sendingStream: Stream, receivingStream: Stream) = let converter = JsonOptionConverter() // special handler to convert between `Option<'T>` and `obj/null`. do formatter.JsonSerializer.Converters.Add(converter) let handler = new HeaderDelimitedMessageHandler(sendingStream, receivingStream, formatter) - let state = State() - let methods = Methods(state) + let methods = Methods() let rpc = new JsonRpc(handler, methods) + do methods.State.JsonRpc <- Some rpc member __.StartListening() = rpc.StartListening() member __.WaitForExitAsync() = async { - do! Async.AwaitEvent (state.Shutdown) - do! Async.AwaitEvent (state.Exit) + do! Async.AwaitEvent (methods.State.Shutdown) + do! Async.AwaitEvent (methods.State.Exit) } interface IDisposable with diff --git a/src/fsharp/FSharp.Compiler.LanguageServer/State.fs b/src/fsharp/FSharp.Compiler.LanguageServer/State.fs index 5ca2d3f84..0812bb9a7 100644 --- a/src/fsharp/FSharp.Compiler.LanguageServer/State.fs +++ b/src/fsharp/FSharp.Compiler.LanguageServer/State.fs @@ -2,11 +2,209 @@ namespace FSharp.Compiler.LanguageServer +open System +open System.Collections.Concurrent +open System.Collections.Generic +open System.Diagnostics +open System.IO +open System.Text.RegularExpressions +open FSharp.Compiler.SourceCodeServices +open StreamJsonRpc + +module internal Solution = + // easy unit testing + let getProjectPaths (solutionContent: string) (solutionDir: string) = + // This looks scary, but is much more lightweight than carrying along MSBuild just to have it parse the solution file. + // + // A valid line in .sln looks like: + // Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ConsoleApp2", "ConsoleApp2\ConsoleApp2.fsproj", "{60A4BE67-7E03-4200-AD38-B0E5E8E049C1}" + // and we're hoping to extract this: ------------------------------------^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + // + // therefore: + // ^Project text 'Project' at the start of the line + // .* any number of characters + // \"" double quote character (it's doubled up to escape from the raw string literal here) + // ( start of capture group + // [^\""] not a quote + // * many of those + // \.fsproj literal string ".fsproj" + // ) end of capture group + // \"" double quote + let pattern = Regex(@"^Project.*\""([^\""]*\.fsproj)\""") + let lines = solutionContent.Split('\n') + let relativeProjects = + lines + |> Array.map pattern.Match + |> Array.filter (fun m -> m.Success) + |> Array.map (fun m -> m.Groups.[1].Value) + // .sln files by convention uses backslashes, which might not be appropriate at runtime + |> Array.map (fun p -> p.Replace('\\', Path.DirectorySeparatorChar)) + let projects = + relativeProjects + |> Array.map (fun p -> if Path.IsPathRooted(p) then p else Path.Combine(solutionDir, p)) + projects + type State() = + let checker = FSharpChecker.Create() + + let sourceFileToProjectMap = ConcurrentDictionary() + let shutdownEvent = new Event<_>() let exitEvent = new Event<_>() let cancelEvent = new Event<_>() + let projectInvalidatedEvent = new Event<_>() + + let fileChanged (args: FileSystemEventArgs) = + match sourceFileToProjectMap.TryGetValue args.FullPath with + | true, projectOptions -> projectInvalidatedEvent.Trigger(projectOptions) + | false, _ -> () + let fileRenamed (args: RenamedEventArgs) = + match sourceFileToProjectMap.TryGetValue args.FullPath with + | true, projectOptions -> projectInvalidatedEvent.Trigger(projectOptions) + | false, _ -> () + let fileWatcher = new FileSystemWatcher() + do fileWatcher.IncludeSubdirectories <- true + do fileWatcher.Changed.Add(fileChanged) + do fileWatcher.Created.Add(fileChanged) + do fileWatcher.Deleted.Add(fileChanged) + do fileWatcher.Renamed.Add(fileRenamed) + + let execProcess (name: string) (args: string) = + let startInfo = ProcessStartInfo(name, args) + eprintfn "executing: %s %s" name args + startInfo.CreateNoWindow <- true + startInfo.RedirectStandardOutput <- true + startInfo.UseShellExecute <- false + let lines = List() + use proc = new Process() + proc.StartInfo <- startInfo + proc.OutputDataReceived.Add(fun args -> lines.Add(args.Data)) + proc.Start() |> ignore + proc.BeginOutputReadLine() + proc.WaitForExit() + lines.ToArray() + + let linesWithPrefixClean (prefix: string) (lines: string[]) = + lines + |> Array.filter (isNull >> not) + |> Array.map (fun line -> line.TrimStart(' ')) + |> Array.filter (fun line -> line.StartsWith(prefix)) + |> Array.map (fun line -> line.Substring(prefix.Length)) + + let getProjectOptions (rootDir: string) = + if isNull rootDir then [||] + else + fileWatcher.Path <- rootDir + fileWatcher.EnableRaisingEvents <- true + + /// This function is meant to be temporary. Until we figure out what a language server for a project + /// system looks like, we have to guess based on the files we find in the root. + let getProjectOptions (projectPath: string) = + let projectDir = Path.GetDirectoryName(projectPath) + let normalizePath (path: string) = + if Path.IsPathRooted(path) then path + else Path.Combine(projectDir, path) + + // To avoid essentially re-creating a copy of MSBuild alongside this tool, we instead fake a design- + // time build with this project. The output of building this helper project is text that's easily + // parsable. See the helper project for more information. + let reporterProject = Path.Combine(Path.GetDirectoryName(typeof.Assembly.Location), "FSharp.Compiler.LanguageServer.DesignTime.proj") + let detectedTfmSentinel = "DetectedTargetFramework=" + let detectedCommandLineArgSentinel = "DetectedCommandLineArg=" + + let execTfmReporter = + sprintf "build \"%s\" \"/p:ProjectFile=%s\"" reporterProject projectPath + |> execProcess "dotnet" + + let execArgReporter (tfm: string) = + sprintf "build \"%s\" \"/p:ProjectFile=%s\" \"/p:TargetFramework=%s\"" reporterProject projectPath tfm + |> execProcess "dotnet" + + // find the target frameworks + let targetFrameworks = + execTfmReporter + |> linesWithPrefixClean detectedTfmSentinel + + let getArgs (tfm: string) = + execArgReporter tfm + |> linesWithPrefixClean detectedCommandLineArgSentinel + + let tfmAndArgs = + targetFrameworks + |> Array.map (fun tfm -> tfm, getArgs tfm) + + let separateArgs (args: string[]) = + args + |> Array.partition (fun a -> a.StartsWith("-")) + |> (fun (options, files) -> + let normalizedFiles = files |> Array.map normalizePath + options, normalizedFiles) + + // TODO: for now we're only concerned with the first TFM + let _tfm, args = Array.head tfmAndArgs + + let otherOptions, sourceFiles = separateArgs args + + let projectOptions: FSharpProjectOptions = + { ProjectFileName = projectPath + ProjectId = None + SourceFiles = sourceFiles + OtherOptions = otherOptions + ReferencedProjects = [||] // TODO: populate from @(ProjectReference) + IsIncompleteTypeCheckEnvironment = false + UseScriptResolutionRules = false + LoadTime = DateTime.Now + UnresolvedReferences = None + OriginalLoadReferences = [] + ExtraProjectInfo = None + Stamp = None } + projectOptions + let topLevelProjects = Directory.GetFiles(rootDir, "*.fsproj") + let watchableProjectPaths = + match topLevelProjects with + | [||] -> + match Directory.GetFiles(rootDir, "*.sln") with + // TODO: what to do with multiple .sln or a combo of .sln/.fsproj? + | [| singleSolution |] -> + let content = File.ReadAllText(singleSolution) + let solutionDir = Path.GetDirectoryName(singleSolution) + Solution.getProjectPaths content solutionDir + | _ -> [||] + | _ -> topLevelProjects + let watchableProjectOptions = + watchableProjectPaths + |> Array.map getProjectOptions + + // associate source files with project options + let watchFile file projectOptions = + sourceFileToProjectMap.AddOrUpdate(file, projectOptions, fun _ _ -> projectOptions) + + for projectOptions in watchableProjectOptions do + // watch .fsproj + watchFile projectOptions.ProjectFileName projectOptions |> ignore + // TODO: watch .deps.json + for sourceFile in projectOptions.SourceFiles do + let sourceFileFullPath = + if Path.IsPathRooted(sourceFile) then sourceFile + else + let projectDir = Path.GetDirectoryName(projectOptions.ProjectFileName) + Path.Combine(projectDir, sourceFile) + watchFile sourceFileFullPath projectOptions |> ignore + + watchableProjectOptions + + member __.Checker = checker + + /// Initialize the LSP at the specified location. According to the spec, `rootUri` is to be preferred over `rootPath`. + member __.Initialize (rootPath: string) (rootUri: DocumentUri) (computeDiagnostics: FSharpProjectOptions -> unit) = + let rootDir = + if not (isNull rootUri) then Uri(rootUri).LocalPath + else rootPath + let projectOptions = getProjectOptions rootDir + projectInvalidatedEvent.Publish.Add computeDiagnostics // compute diagnostics on project invalidation + for projectOption in projectOptions do + computeDiagnostics projectOption // compute initial set of diagnostics [] member __.Shutdown = shutdownEvent.Publish @@ -17,10 +215,19 @@ type State() = [] member __.Cancel = cancelEvent.Publish + [] + member __.ProjectInvalidated = projectInvalidatedEvent.Publish + member __.DoShutdown() = shutdownEvent.Trigger() member __.DoExit() = exitEvent.Trigger() member __.DoCancel() = cancelEvent.Trigger() + member __.InvalidateAllProjects() = + for projectOptions in sourceFileToProjectMap.Values do + projectInvalidatedEvent.Trigger(projectOptions) + member val Options = Options.Default() with get, set + + member val JsonRpc: JsonRpc option = None with get, set diff --git a/src/fsharp/FSharp.Compiler.LanguageServer/TextDocument.fs b/src/fsharp/FSharp.Compiler.LanguageServer/TextDocument.fs index 0c7379650..489b55ebc 100644 --- a/src/fsharp/FSharp.Compiler.LanguageServer/TextDocument.fs +++ b/src/fsharp/FSharp.Compiler.LanguageServer/TextDocument.fs @@ -2,13 +2,15 @@ namespace FSharp.Compiler.LanguageServer -open System +open System.Threading module TextDocument = + let mutable publishDiagnosticsCancellationTokenSource = new CancellationTokenSource() + let Hover (state: State) (textDocument: TextDocumentIdentifier) (position: Position) = async { - Console.Error.WriteLine("hover at " + position.line.ToString() + "," + position.character.ToString()) + eprintfn "hover at %d, %d" position.line position.character if not state.Options.usePreviewTextHover then return None else let startCol, endCol = @@ -21,10 +23,53 @@ module TextDocument = } } - let PublishDiagnostics(state: State) = + let PublishDiagnostics(state: State, projectOptions: FSharp.Compiler.SourceCodeServices.FSharpProjectOptions) = + // TODO: honor TextDocumentClientCapabilities.publishDiagnostics.relatedInformation + // cancel any existing request to publish diagnostics + publishDiagnosticsCancellationTokenSource.Cancel() + publishDiagnosticsCancellationTokenSource <- new CancellationTokenSource() async { - return { - PublishDiagnosticsParams.uri = "" - diagnostics = [||] - } + if not state.Options.usePreviewDiagnostics then return () + else + eprintfn "starting diagnostics computation" + match state.JsonRpc with + | None -> eprintfn "state.JsonRpc was null; should not be?" + | Some jsonRpc -> + let! results = state.Checker.ParseAndCheckProject projectOptions + let diagnostics = results.Errors + let diagnosticsPerFile = + diagnostics + |> Array.fold (fun state t -> + let existing = Map.tryFind t.FileName state |> Option.defaultValue [] + Map.add t.FileName (t :: existing) state) Map.empty + for sourceFile in projectOptions.SourceFiles do + let diagnostics = + Map.tryFind sourceFile diagnosticsPerFile + |> Option.defaultValue [] + |> List.map (fun d -> + // F# errors count lines starting at 1, but LSP starts at 0 + let range: Range = + { start = { line = d.StartLineAlternate - 1; character = d.StartColumn } + ``end`` = { line = d.EndLineAlternate - 1; character = d.EndColumn } } + let severity = + match d.Severity with + | FSharp.Compiler.SourceCodeServices.FSharpErrorSeverity.Warning -> Diagnostic.Warning + | FSharp.Compiler.SourceCodeServices.FSharpErrorSeverity.Error -> Diagnostic.Error + let res: Diagnostic = + { range = range + severity = Some severity + code = "FS" + d.ErrorNumber.ToString("0000") + source = Some d.FileName + message = d.Message + relatedInformation = None } + res) + |> List.toArray + let args: PublishDiagnosticsParams = + { uri = System.Uri(sourceFile).AbsoluteUri + diagnostics = diagnostics } + + // fire each notification separately + jsonRpc.NotifyAsync(TextDocumentPublishDiagnostics, args) |> Async.AwaitTask |> Async.Start } + |> (fun computation -> Async.StartAsTask(computation, cancellationToken=publishDiagnosticsCancellationTokenSource.Token)) + |> Async.AwaitTask diff --git a/tests/FSharp.Compiler.LanguageServer.UnitTests/DiagnosticsTests.fs b/tests/FSharp.Compiler.LanguageServer.UnitTests/DiagnosticsTests.fs new file mode 100644 index 000000000..aaadff258 --- /dev/null +++ b/tests/FSharp.Compiler.LanguageServer.UnitTests/DiagnosticsTests.fs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace FSharp.Compiler.LanguageServer.UnitTests + +open System +open System.IO +open System.Linq +open System.Threading.Tasks +open FSharp.Compiler.LanguageServer +open Nerdbank.Streams +open NUnit.Framework + +[] +type DiagnosticsTests() = + + let createTestableProject (tfm: string) (sourceFiles: (string * string) list) = + let testDir = new TemporaryDirectory() + let directoryBuildText = "" + File.WriteAllText(Path.Combine(testDir.Directory, "Directory.Build.props"), directoryBuildText) + File.WriteAllText(Path.Combine(testDir.Directory, "Directory.Build.targets"), directoryBuildText) + for name, contents in sourceFiles do + File.WriteAllText(Path.Combine(testDir.Directory, name), contents) + let compileItems = + sourceFiles + |> List.map fst + |> List.map (sprintf " ") + |> List.fold (fun content line -> content + "\n" + line) "" + let replacements = + [ "{{COMPILE}}", compileItems + "{{TARGETFRAMEWORK}}", tfm ] + let projectTemplate = + @" + + + Library + {{TARGETFRAMEWORK}} + + +{{COMPILE}} + +" + let projectFile = + replacements + |> List.fold (fun (content: string) (find, replace) -> content.Replace(find, replace)) projectTemplate + File.WriteAllText(Path.Combine(testDir.Directory, "test.fsproj"), projectFile) + testDir + + let createRpcClient (tempDir: TemporaryDirectory) = + let clientStream, serverStream = FullDuplexStream.CreatePair().ToTuple() + let server = new Server(serverStream, serverStream) + server.StartListening() + let client = new TestClient(tempDir, clientStream, clientStream, server) + client + + let createClientTest (tfm: string) (sourceFiles: (string * string) list) = + let testDir = createTestableProject tfm sourceFiles + let client = createRpcClient testDir + client + + let getDiagnostics (content: string) = + async { + use client = createClientTest "netstandard2.0" [ "lib.fs", content ] + let! diagnostics = client.WaitForDiagnosticsAsync client.Initialize ["lib.fs"] + return diagnostics.["lib.fs"] + } + + [] + member __.``No diagnostics for correct code``() = + async { + let! diagnostics = getDiagnostics @" +namespace Test + +module Numbers = + let one: int = 1 +" + Assert.AreEqual(0, diagnostics.Length) + } |> Async.StartAsTask :> Task + + [] + member __.``Diagnostics for incorrect code``() = + async { + let! diagnostics = getDiagnostics @" +namespace Test + +module Numbers = + let one: int = false +" + let diag = diagnostics.Single() + Assert.AreEqual("FS0001", diag.code) + Assert.AreEqual(Some 1, diag.severity) + Assert.AreEqual(4, diag.range.start.line) + Assert.AreEqual(19, diag.range.start.character) + Assert.AreEqual(4, diag.range.``end``.line) + Assert.AreEqual(24, diag.range.``end``.character) + Assert.AreEqual("This expression was expected to have type\n 'int' \nbut here has type\n 'bool'", diag.message.Trim()) + Assert.IsTrue(diag.source.Value.EndsWith("lib.fs")) + } |> Async.StartAsTask :> Task + + [] + member __.``Diagnostics added for updated incorrect code``() = + async { + let correct = @" +namespace Test + +module Numbers = + let one: int = 1 +" + let incorrect = @" +namespace Test + +module Numbers = + let one: int = false +" + + // verify initial state + use client = createClientTest "netstandard2.0" [ "lib.fs", correct ] + let! diagnostics = client.WaitForDiagnosticsAsync client.Initialize ["lib.fs"] + Assert.AreEqual(0, diagnostics.["lib.fs"].Length) + + // touch file with incorrect data + let touch () = File.WriteAllText(Path.Combine(client.RootPath, "lib.fs"), incorrect) + let! diagnostics = client.WaitForDiagnostics touch ["lib.fs"] + let diag = diagnostics.["lib.fs"].Single() + Assert.AreEqual("FS0001", diag.code) + } |> Async.StartAsTask :> Task + + [] + member __.``Diagnostics removed for updated correct code``() = + async { + let incorrect = @" +namespace Test + +module Numbers = + let one: int = false +" + let correct = @" +namespace Test + +module Numbers = + let one: int = 1 +" + + // verify initial state + use client = createClientTest "netstandard2.0" [ "lib.fs", incorrect ] + let! diagnostics = client.WaitForDiagnosticsAsync client.Initialize ["lib.fs"] + let diag = diagnostics.["lib.fs"].Single() + Assert.AreEqual("FS0001", diag.code) + + // touch file with correct data + let touch () = File.WriteAllText(Path.Combine(client.RootPath, "lib.fs"), correct) + let! diagnostics = client.WaitForDiagnostics touch ["lib.fs"] + let libActualContents = File.ReadAllText(Path.Combine(client.RootPath, "lib.fs")) + Assert.AreEqual(0, diagnostics.["lib.fs"].Length, "Actual on-disk contents of lib.fs:\n" + libActualContents) + } |> Async.StartAsTask :> Task diff --git a/tests/FSharp.Compiler.LanguageServer.UnitTests/FSharp.Compiler.LanguageServer.UnitTests.fsproj b/tests/FSharp.Compiler.LanguageServer.UnitTests/FSharp.Compiler.LanguageServer.UnitTests.fsproj index c457b9e56..4e061f557 100644 --- a/tests/FSharp.Compiler.LanguageServer.UnitTests/FSharp.Compiler.LanguageServer.UnitTests.fsproj +++ b/tests/FSharp.Compiler.LanguageServer.UnitTests/FSharp.Compiler.LanguageServer.UnitTests.fsproj @@ -11,8 +11,12 @@ + + + + diff --git a/tests/FSharp.Compiler.LanguageServer.UnitTests/MiscTests.fs b/tests/FSharp.Compiler.LanguageServer.UnitTests/MiscTests.fs new file mode 100644 index 000000000..02104be2e --- /dev/null +++ b/tests/FSharp.Compiler.LanguageServer.UnitTests/MiscTests.fs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace FSharp.Compiler.LanguageServer.UnitTests + +open System.IO +open FSharp.Compiler.LanguageServer +open NUnit.Framework + +[] +type MiscTests() = + + [] + member __.``Find F# projects in a .sln file``() = + let slnContent = @" +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29201.188 +MinimumVisualStudioVersion = 10.0.40219.1 +Project(""{F2A71F9B-5D33-465A-A702-920D77279786}"") = ""ConsoleApp1"", ""ConsoleApp1\ConsoleApp1.fsproj"", ""{60A4BE67-7E03-4200-AD38-B0E5E8E049C1}"" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {60A4BE67-7E03-4200-AD38-B0E5E8E049C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {60A4BE67-7E03-4200-AD38-B0E5E8E049C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {60A4BE67-7E03-4200-AD38-B0E5E8E049C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {60A4BE67-7E03-4200-AD38-B0E5E8E049C1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {80902CFC-54E6-4485-AC17-4516930C8B2B} + EndGlobalSection +EndGlobal +" + let testDir = @"C:\Dir\With\Solution" // don't care about the potentially improper directory separators here, it's really just a dumb string + let foundProjects = Solution.getProjectPaths slnContent testDir + let expected = Path.Combine(testDir, "ConsoleApp1", "ConsoleApp1.fsproj") // proper directory separator characters will be used at runtime + Assert.AreEqual([| expected |], foundProjects) diff --git a/tests/FSharp.Compiler.LanguageServer.UnitTests/ProtocolTests.fs b/tests/FSharp.Compiler.LanguageServer.UnitTests/ProtocolTests.fs index 4d0a789b9..e9de31049 100644 --- a/tests/FSharp.Compiler.LanguageServer.UnitTests/ProtocolTests.fs +++ b/tests/FSharp.Compiler.LanguageServer.UnitTests/ProtocolTests.fs @@ -31,21 +31,17 @@ type ProtocolTests() = client.StartListening() // initialize - let capabilities = - { ClientCapabilities.workspace = None + let capabilities: ClientCapabilities = + { workspace = None textDocument = None experimental = None supportsVisualStudioExtensions = None } let! result = - client.InvokeAsync( - "initialize", // method - 0, // processId - "rootPath", - "rootUri", - null, // initializationOptions - capabilities, // client capabilities - "none") // trace - |> Async.AwaitTask + client.InvokeWithParameterObjectAsync( + "initialize", + {| processId = Process.GetCurrentProcess().Id + capabilities = capabilities |} + ) |> Async.AwaitTask Assert.True(result.capabilities.hoverProvider) do! client.NotifyAsync("initialized") |> Async.AwaitTask diff --git a/tests/FSharp.Compiler.LanguageServer.UnitTests/TemporaryDirectory.fs b/tests/FSharp.Compiler.LanguageServer.UnitTests/TemporaryDirectory.fs new file mode 100644 index 000000000..ef3953d78 --- /dev/null +++ b/tests/FSharp.Compiler.LanguageServer.UnitTests/TemporaryDirectory.fs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace FSharp.Compiler.LanguageServer.UnitTests + +open System +open System.IO + +type TemporaryDirectory() = + + let directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()) + do Directory.CreateDirectory(directory) |> ignore + + member __.Directory = directory + + interface IDisposable with + member __.Dispose() = + try + Directory.Delete(directory, true) + with + | _ -> () diff --git a/tests/FSharp.Compiler.LanguageServer.UnitTests/TestClient.fs b/tests/FSharp.Compiler.LanguageServer.UnitTests/TestClient.fs new file mode 100644 index 000000000..0baaaf40e --- /dev/null +++ b/tests/FSharp.Compiler.LanguageServer.UnitTests/TestClient.fs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace FSharp.Compiler.LanguageServer.UnitTests + +open System +open System.Collections.Generic +open System.Diagnostics +open System.IO +open System.Threading +open FSharp.Compiler.LanguageServer +open Newtonsoft.Json.Linq +open StreamJsonRpc + +type TestClient(tempDir: TemporaryDirectory, sendingStream: Stream, receivingStream: Stream, server: Server) = + + let rootPath = tempDir.Directory + let rootPath = if rootPath.EndsWith(Path.DirectorySeparatorChar.ToString()) then rootPath else rootPath + Path.DirectorySeparatorChar.ToString() + let diagnosticsEvent = Event<_>() + + let formatter = JsonMessageFormatter() + let converter = JsonOptionConverter() // special handler to convert between `Option<'T>` and `obj/null`. + do formatter.JsonSerializer.Converters.Add(converter) + let handler = new HeaderDelimitedMessageHandler(sendingStream, receivingStream, formatter) + let client = new JsonRpc(handler) + let handler (functionName: string) (args: JToken): JToken = + match functionName with + | TextDocumentPublishDiagnostics -> + let args = args.ToObject(formatter.JsonSerializer) + let fullPath = Uri(args.uri).LocalPath + let shortPath = if fullPath.StartsWith(rootPath) then fullPath.Substring(rootPath.Length) else fullPath + diagnosticsEvent.Trigger((shortPath, args.diagnostics)) + null + | _ -> null + let addHandler (name: string) = + client.AddLocalRpcMethod(name, new Func(handler name)) + do addHandler TextDocumentPublishDiagnostics + do client.StartListening() + + member __.RootPath = rootPath + + member __.Server = server + + [] + member __.PublishDiagnostics = diagnosticsEvent.Publish + + member __.Initialize () = + async { + do! client.NotifyWithParameterObjectAsync(OptionsSet, {| options = Options.AllOn() |}) |> Async.AwaitTask + let capabilities: ClientCapabilities = + { workspace = None + textDocument = None + experimental = None + supportsVisualStudioExtensions = None } + let! _result = + client.InvokeWithParameterObjectAsync( + "initialize", // method + {| processId = Process.GetCurrentProcess().Id + rootPath = rootPath + capabilities = capabilities |} + ) |> Async.AwaitTask + return () + } + + member this.WaitForDiagnostics (triggerAction: unit -> unit) (fileNames: string list) = + async { + // prepare file diagnostic triggers + let diagnosticTriggers = Dictionary() + fileNames |> List.iter (fun f -> diagnosticTriggers.[f] <- new ManualResetEvent(false)) + + // prepare callback handler + let diagnosticsMap = Dictionary() + let handler (fileName: string, diagnostics: Diagnostic[]) = + diagnosticsMap.[fileName] <- diagnostics + // auto-generated files (e.g., AssemblyInfo.fs) won't be in the trigger map + if diagnosticTriggers.ContainsKey(fileName) then + diagnosticTriggers.[fileName].Set() |> ignore + + // subscribe to the event + let wrappedHandler = new Handler(fun _sender args -> handler args) + this.PublishDiagnostics.AddHandler(wrappedHandler) + triggerAction () + + // wait for all triggers to hit + let! results = + diagnosticTriggers + |> Seq.map (fun entry -> + async { + let! result = Async.AwaitWaitHandle(entry.Value, millisecondsTimeout = int (TimeSpan.FromSeconds(10.0).TotalMilliseconds)) + return if result then None + else + let filePath = Path.Combine(rootPath, entry.Key) + let actualContents = File.ReadAllText(filePath) + Some <| sprintf "No diagnostics received for file '%s'. Contents:\n%s\n" entry.Key actualContents + }) + |> Async.Parallel + let results = results |> Array.choose (fun x -> x) + if results.Length > 0 then + let combinedErrors = String.Join("-----\n", results) + let allDiagnosticsEvents = + diagnosticsMap + |> Seq.map (fun entry -> + sprintf "File '%s' reported %d diagnostics." entry.Key entry.Value.Length) + |> (fun s -> String.Join("\n", s)) + failwith <| sprintf "Error waiting for diagnostics:\n%s\n-----\n%s" combinedErrors allDiagnosticsEvents + + // clean up event + this.PublishDiagnostics.RemoveHandler(wrappedHandler) + + // done + return diagnosticsMap + } + + member this.WaitForDiagnosticsAsync (triggerAction: unit -> Async) (fileNames: string list) = + this.WaitForDiagnostics (fun () -> triggerAction () |> Async.RunSynchronously) fileNames + + interface IDisposable with + member __.Dispose() = + try + (tempDir :> IDisposable).Dispose() + with + | _ -> () diff --git a/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs b/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs index 273e0bad4..0d92ce047 100644 --- a/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs +++ b/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs @@ -33,7 +33,10 @@ type internal FSharpDocumentDiagnosticAnalyzer [] () = let getProjectInfoManager(document: Document) = document.Project.Solution.Workspace.Services.GetService().FSharpProjectOptionsManager - + + let getSettings(document: Document) = + document.Project.Solution.Workspace.Services.GetService() + static let errorInfoEqualityComparer = { new IEqualityComparer with member __.Equals (x, y) = @@ -110,6 +113,10 @@ type internal FSharpDocumentDiagnosticAnalyzer [] () = interface IFSharpDocumentDiagnosticAnalyzer with member this.AnalyzeSyntaxAsync(document: Document, cancellationToken: CancellationToken): Task> = + // if using LSP, just bail early + let settings = getSettings document + if settings.Advanced.UsePreviewDiagnostics then Task.FromResult(ImmutableArray.Empty) + else let projectInfoManager = getProjectInfoManager document asyncMaybe { let! parsingOptions, projectOptions = projectInfoManager.TryGetOptionsForEditingDocumentOrProject(document, cancellationToken) @@ -123,6 +130,10 @@ type internal FSharpDocumentDiagnosticAnalyzer [] () = |> RoslynHelpers.StartAsyncAsTask cancellationToken member this.AnalyzeSemanticsAsync(document: Document, cancellationToken: CancellationToken): Task> = + // if using LSP, just bail early + let settings = getSettings document + if settings.Advanced.UsePreviewDiagnostics then Task.FromResult(ImmutableArray.Empty) + else let projectInfoManager = getProjectInfoManager document asyncMaybe { let! parsingOptions, _, projectOptions = projectInfoManager.TryGetOptionsForDocumentOrProject(document, cancellationToken) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FSharpLanguageClient.fs b/vsintegration/src/FSharp.Editor/LanguageService/FSharpLanguageClient.fs index 176ba2cc5..7a987d665 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/FSharpLanguageClient.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/FSharpLanguageClient.fs @@ -34,7 +34,8 @@ type FSharpContentDefinition() = type internal FSharpLanguageClient [] ( - lspService: LspService + lspService: LspService, + settings: EditorOptions ) = inherit LanguageClient() override __.Name = "F# Language Service" @@ -63,4 +64,7 @@ type internal FSharpLanguageClient member __.CustomMessageTarget = null member __.MiddleLayer = null member __.AttachForCustomMessageAsync(rpc: JsonRpc) = - lspService.SetJsonRpc(rpc) |> Async.StartAsTask :> Task + async { + do! lspService.SetJsonRpc(rpc) + do! lspService.SetOptions(settings.Advanced.AsLspOptions()) + } |> Async.StartAsTask :> Task diff --git a/vsintegration/src/FSharp.Editor/Options/EditorOptions.fs b/vsintegration/src/FSharp.Editor/Options/EditorOptions.fs index 3d71e2522..af88f02d1 100644 --- a/vsintegration/src/FSharp.Editor/Options/EditorOptions.fs +++ b/vsintegration/src/FSharp.Editor/Options/EditorOptions.fs @@ -93,11 +93,16 @@ type CodeLensOptions = type AdvancedOptions = { IsBlockStructureEnabled: bool IsOutliningEnabled: bool - UsePreviewTextHover: bool } + UsePreviewTextHover: bool + UsePreviewDiagnostics: bool } static member Default = { IsBlockStructureEnabled = true IsOutliningEnabled = true - UsePreviewTextHover = false } + UsePreviewTextHover = false + UsePreviewDiagnostics = false } + member this.AsLspOptions(): Options = + { usePreviewTextHover = this.UsePreviewTextHover + usePreviewDiagnostics = this.UsePreviewDiagnostics } [] type FormattingOptions = @@ -203,8 +208,7 @@ module internal OptionsUI = async { let lspService = this.GetService() let settings = this.GetService() - let options = - { Options.usePreviewTextHover = settings.Advanced.UsePreviewTextHover } + let options = settings.Advanced.AsLspOptions() do! lspService.SetOptions options } |> Async.Start diff --git a/vsintegration/src/FSharp.UIResources/AdvancedOptionsControl.xaml b/vsintegration/src/FSharp.UIResources/AdvancedOptionsControl.xaml index f8dd82f7d..57ca75df7 100644 --- a/vsintegration/src/FSharp.UIResources/AdvancedOptionsControl.xaml +++ b/vsintegration/src/FSharp.UIResources/AdvancedOptionsControl.xaml @@ -27,8 +27,12 @@ diff --git a/vsintegration/src/FSharp.UIResources/Strings.Designer.cs b/vsintegration/src/FSharp.UIResources/Strings.Designer.cs index 661fffa56..c03ff05d7 100644 --- a/vsintegration/src/FSharp.UIResources/Strings.Designer.cs +++ b/vsintegration/src/FSharp.UIResources/Strings.Designer.cs @@ -150,6 +150,15 @@ namespace Microsoft.VisualStudio.FSharp.UIResources { } } + /// + /// Looks up a localized string similar to Diagnostics. + /// + public static string Diagnostics { + get { + return ResourceManager.GetString("Diagnostics", resourceCulture); + } + } + /// /// Looks up a localized string similar to D_ot underline. /// diff --git a/vsintegration/src/FSharp.UIResources/Strings.resx b/vsintegration/src/FSharp.UIResources/Strings.resx index f4066cf34..037596b71 100644 --- a/vsintegration/src/FSharp.UIResources/Strings.resx +++ b/vsintegration/src/FSharp.UIResources/Strings.resx @@ -237,4 +237,7 @@ Text hover + + Diagnostics + \ No newline at end of file diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.cs.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.cs.xlf index 973cdbd6e..0d2794979 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.cs.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.cs.xlf @@ -42,6 +42,11 @@ Seznamy dokončení + + Diagnostics + Diagnostics + + Performance Výkon diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.de.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.de.xlf index c2defd7c4..fc1d36391 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.de.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.de.xlf @@ -42,6 +42,11 @@ Vervollständigungslisten + + Diagnostics + Diagnostics + + Performance Leistung diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.es.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.es.xlf index 2b0507a43..24649ecad 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.es.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.es.xlf @@ -42,6 +42,11 @@ Listas de finalización + + Diagnostics + Diagnostics + + Performance Rendimiento diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.fr.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.fr.xlf index 423d9c97c..d0f77ba36 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.fr.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.fr.xlf @@ -42,6 +42,11 @@ Listes de saisie semi-automatique + + Diagnostics + Diagnostics + + Performance Performances diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.it.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.it.xlf index a577a1e31..a5555f973 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.it.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.it.xlf @@ -42,6 +42,11 @@ Elenchi di completamento + + Diagnostics + Diagnostics + + Performance Prestazioni diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.ja.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.ja.xlf index de08a5885..847e29436 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.ja.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.ja.xlf @@ -42,6 +42,11 @@ 入力候補一覧 + + Diagnostics + Diagnostics + + Performance パフォーマンス diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.ko.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.ko.xlf index 0651ce96f..55927919d 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.ko.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.ko.xlf @@ -42,6 +42,11 @@ 완성 목록 + + Diagnostics + Diagnostics + + Performance 성능 diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.pl.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.pl.xlf index 1c6085f85..db4a13948 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.pl.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.pl.xlf @@ -42,6 +42,11 @@ Listy uzupełniania + + Diagnostics + Diagnostics + + Performance Wydajność diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.pt-BR.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.pt-BR.xlf index 2fca2b08f..5385f6147 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.pt-BR.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.pt-BR.xlf @@ -42,6 +42,11 @@ Listas de Conclusão + + Diagnostics + Diagnostics + + Performance Desempenho diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.ru.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.ru.xlf index de8534643..ba6871df2 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.ru.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.ru.xlf @@ -42,6 +42,11 @@ Списки завершения + + Diagnostics + Diagnostics + + Performance Производительность diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.tr.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.tr.xlf index dd44a1a1b..9caf1fa8b 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.tr.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.tr.xlf @@ -42,6 +42,11 @@ Tamamlama Listeleri + + Diagnostics + Diagnostics + + Performance Performans diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hans.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hans.xlf index ba5c37bf7..cff797b88 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hans.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hans.xlf @@ -42,6 +42,11 @@ 完成列表 + + Diagnostics + Diagnostics + + Performance 性能 diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hant.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hant.xlf index db714fc7b..a3267c876 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hant.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hant.xlf @@ -42,6 +42,11 @@ 完成清單 + + Diagnostics + Diagnostics + + Performance 效能