From 580f33d8922f14f597f29f6f97be9d074d2fd147 Mon Sep 17 00:00:00 2001 From: MrLuje Date: Sun, 17 Dec 2023 18:12:32 +0000 Subject: [PATCH 01/22] add FSharpLint.Client project --- FSharpLint.sln | 15 + paket.dependencies | 2 + paket.lock | 55 +++- src/FSharpLint.Client/Contracts.fs | 31 +++ src/FSharpLint.Client/Contracts.fsi | 28 ++ .../FSharpLint.Client.fsproj | 26 ++ .../FSharpLintToolLocator.fs | 249 +++++++++++++++++ .../FSharpLintToolLocator.fsi | 7 + src/FSharpLint.Client/LSPFSharpLintService.fs | 260 ++++++++++++++++++ .../LSPFSharpLintService.fsi | 6 + .../LSPFSharpLintServiceTypes.fs | 58 ++++ .../LSPFSharpLintServiceTypes.fsi | 50 ++++ src/FSharpLint.Client/paket.references | 3 + src/FSharpLint.Console/Daemon.fs | 35 +++ .../FSharpLint.Console.fsproj | 5 +- src/FSharpLint.Console/Program.fs | 13 +- src/FSharpLint.Console/Version.fs | 8 + src/FSharpLint.Console/paket.references | 3 +- 18 files changed, 846 insertions(+), 8 deletions(-) create mode 100644 src/FSharpLint.Client/Contracts.fs create mode 100644 src/FSharpLint.Client/Contracts.fsi create mode 100644 src/FSharpLint.Client/FSharpLint.Client.fsproj create mode 100644 src/FSharpLint.Client/FSharpLintToolLocator.fs create mode 100644 src/FSharpLint.Client/FSharpLintToolLocator.fsi create mode 100644 src/FSharpLint.Client/LSPFSharpLintService.fs create mode 100644 src/FSharpLint.Client/LSPFSharpLintService.fsi create mode 100644 src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs create mode 100644 src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi create mode 100644 src/FSharpLint.Client/paket.references create mode 100644 src/FSharpLint.Console/Daemon.fs create mode 100644 src/FSharpLint.Console/Version.fs diff --git a/FSharpLint.sln b/FSharpLint.sln index 95809fec0..f578f6676 100644 --- a/FSharpLint.sln +++ b/FSharpLint.sln @@ -112,6 +112,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "rules", "rules", "{AEBB56D7 docs\content\how-tos\rules\FL0082.md = docs\content\how-tos\rules\FL0082.md EndProjectSection EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FSharpLint.Client", "src\FSharpLint.Client\FSharpLint.Client.fsproj", "{0452CA18-2599-4D8B-8A48-01A8B78F3984}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -194,6 +196,18 @@ Global {B4A92AC6-F74A-4709-B2F7-6C5BABBFDEB0}.Release|x64.Build.0 = Release|Any CPU {B4A92AC6-F74A-4709-B2F7-6C5BABBFDEB0}.Release|x86.ActiveCfg = Release|Any CPU {B4A92AC6-F74A-4709-B2F7-6C5BABBFDEB0}.Release|x86.Build.0 = Release|Any CPU + {0452CA18-2599-4D8B-8A48-01A8B78F3984}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0452CA18-2599-4D8B-8A48-01A8B78F3984}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0452CA18-2599-4D8B-8A48-01A8B78F3984}.Debug|x64.ActiveCfg = Debug|Any CPU + {0452CA18-2599-4D8B-8A48-01A8B78F3984}.Debug|x64.Build.0 = Debug|Any CPU + {0452CA18-2599-4D8B-8A48-01A8B78F3984}.Debug|x86.ActiveCfg = Debug|Any CPU + {0452CA18-2599-4D8B-8A48-01A8B78F3984}.Debug|x86.Build.0 = Debug|Any CPU + {0452CA18-2599-4D8B-8A48-01A8B78F3984}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0452CA18-2599-4D8B-8A48-01A8B78F3984}.Release|Any CPU.Build.0 = Release|Any CPU + {0452CA18-2599-4D8B-8A48-01A8B78F3984}.Release|x64.ActiveCfg = Release|Any CPU + {0452CA18-2599-4D8B-8A48-01A8B78F3984}.Release|x64.Build.0 = Release|Any CPU + {0452CA18-2599-4D8B-8A48-01A8B78F3984}.Release|x86.ActiveCfg = Release|Any CPU + {0452CA18-2599-4D8B-8A48-01A8B78F3984}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -207,6 +221,7 @@ Global {B4A92AC6-F74A-4709-B2F7-6C5BABBFDEB0} = {1CD44876-BCDC-4C93-9DC2-C45244BD62AE} {E1E03FFE-30DF-4522-83DA-9089147B431E} = {270E691D-ECA1-4BC5-B851-C5431A64E9FA} {AEBB56D7-30B4-40D7-B065-54B8BE960298} = {E1E03FFE-30DF-4522-83DA-9089147B431E} + {0452CA18-2599-4D8B-8A48-01A8B78F3984} = {40C2798B-7078-4D4F-BD37-195240CB827B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B54B4B7D-F019-48A3-BB5B-635B68FE41C3} diff --git a/paket.dependencies b/paket.dependencies index b453efcaf..9fa568768 100644 --- a/paket.dependencies +++ b/paket.dependencies @@ -19,6 +19,8 @@ nuget NUnit3TestAdapter nuget Microsoft.NET.Test.Sdk 17.7.2 nuget Newtonsoft.Json nuget Microsoft.Build.Locator +nuget SemanticVersioning 2.0.2 +nuget StreamJsonRpc ~> 2.8.28 # don't expose as a package reference nuget Microsoft.SourceLink.GitHub copy_local: true diff --git a/paket.lock b/paket.lock index 5724dacc5..437906ce4 100644 --- a/paket.lock +++ b/paket.lock @@ -80,7 +80,17 @@ NUGET Ionide.ProjInfo.Sln (>= 0.58) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0)) Newtonsoft.Json (>= 13.0.1) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0)) Ionide.ProjInfo.Sln (0.58) - Microsoft.Bcl.AsyncInterfaces (8.0) - restriction: || (&& (== net6.0) (>= net462)) (&& (== net6.0) (< netstandard2.1)) (== netstandard2.0) + MessagePack (2.5.140) + MessagePack.Annotations (>= 2.5.140) + Microsoft.Bcl.AsyncInterfaces (>= 6.0) - restriction: == netstandard2.0 + Microsoft.NET.StringTools (>= 17.6.3) + System.Collections.Immutable (>= 6.0) - restriction: == netstandard2.0 + System.Reflection.Emit (>= 4.7) - restriction: == netstandard2.0 + System.Reflection.Emit.Lightweight (>= 4.7) - restriction: == netstandard2.0 + System.Runtime.CompilerServices.Unsafe (>= 6.0) + System.Threading.Tasks.Extensions (>= 4.5.4) - restriction: == netstandard2.0 + MessagePack.Annotations (2.5.140) + Microsoft.Bcl.AsyncInterfaces (8.0) System.Threading.Tasks.Extensions (>= 4.5.4) - restriction: || (&& (== net6.0) (>= net462)) (&& (== net6.0) (< netstandard2.1)) (== netstandard2.0) Microsoft.Build (16.11) - copy_local: false Microsoft.Build.Framework (>= 16.11) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net5.0)) @@ -188,6 +198,15 @@ NUGET Microsoft.TestPlatform.TestHost (17.8) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1)) Microsoft.TestPlatform.ObjectModel (>= 17.8) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1)) Newtonsoft.Json (>= 13.0.1) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1)) + Microsoft.VisualStudio.Threading (17.8.14) + Microsoft.Bcl.AsyncInterfaces (>= 7.0) + Microsoft.VisualStudio.Threading.Analyzers (>= 17.8.14) + Microsoft.VisualStudio.Validation (>= 17.8.8) + Microsoft.Win32.Registry (>= 5.0) + System.Runtime.CompilerServices.Unsafe (>= 6.0) - restriction: || (&& (== net6.0) (>= net472)) (== netstandard2.0) + System.Threading.Tasks.Extensions (>= 4.5.4) + Microsoft.VisualStudio.Threading.Analyzers (17.8.14) + Microsoft.VisualStudio.Validation (17.8.8) Microsoft.Win32.Primitives (4.3) Microsoft.NETCore.Platforms (>= 1.1) Microsoft.NETCore.Targets (>= 1.1) @@ -197,6 +216,12 @@ NUGET System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (< netcoreapp2.1)) (&& (== net6.0) (>= uap10.1)) (== netstandard2.0) System.Security.AccessControl (>= 5.0) System.Security.Principal.Windows (>= 5.0) + Nerdbank.Streams (2.10.72) + Microsoft.Bcl.AsyncInterfaces (>= 7.0) + Microsoft.VisualStudio.Threading (>= 17.6.40) + Microsoft.VisualStudio.Validation (>= 17.6.11) + System.IO.Pipelines (>= 7.0) + System.Runtime.CompilerServices.Unsafe (>= 6.0) NETStandard.Library (2.0.3) Microsoft.NETCore.Platforms (>= 1.1) Newtonsoft.Json (13.0.3) @@ -249,7 +274,22 @@ NUGET runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl (4.3.3) runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl (4.3.3) runtime.ubuntu.18.04-x64.runtime.native.System.Security.Cryptography.OpenSsl (4.3.3) - SemanticVersioning (2.0.2) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0)) + SemanticVersioning (2.0.2) + StreamJsonRpc (2.8.28) + MessagePack (>= 2.2.85) + Microsoft.Bcl.AsyncInterfaces (>= 5.0) + Microsoft.VisualStudio.Threading (>= 16.9.60) + Nerdbank.Streams (>= 2.6.81) + Newtonsoft.Json (>= 12.0.2) + System.Collections.Immutable (>= 5.0) + System.Diagnostics.DiagnosticSource (>= 5.0.1) + System.IO.Pipelines (>= 5.0.1) + System.Memory (>= 4.5.4) + System.Net.Http (>= 4.3.4) + System.Net.WebSockets (>= 4.3) + System.Reflection.Emit (>= 4.7) + System.Threading.Tasks.Dataflow (>= 5.0) + System.Threading.Tasks.Extensions (>= 4.5.4) System.Buffers (4.5.1) System.CodeDom (8.0) - copy_local: false System.Collections (4.3) @@ -352,6 +392,10 @@ NUGET System.Threading.Tasks (>= 4.3) System.IO.FileSystem.Primitives (4.3) System.Runtime (>= 4.3) + System.IO.Pipelines (8.0) + System.Buffers (>= 4.5.1) - restriction: || (&& (== net6.0) (>= net462)) (== netstandard2.0) + System.Memory (>= 4.5.5) - restriction: || (&& (== net6.0) (>= net462)) (== netstandard2.0) + System.Threading.Tasks.Extensions (>= 4.5.4) - restriction: || (&& (== net6.0) (>= net462)) (== netstandard2.0) System.Linq (4.3) System.Collections (>= 4.3) System.Diagnostics.Debug (>= 4.3) @@ -471,6 +515,11 @@ NUGET System.Resources.ResourceManager (>= 4.3) System.Runtime (>= 4.3) System.Runtime.Extensions (>= 4.3) + System.Net.WebSockets (4.3) + Microsoft.Win32.Primitives (>= 4.3) + System.Resources.ResourceManager (>= 4.3) + System.Runtime (>= 4.3) + System.Threading.Tasks (>= 4.3) System.Numerics.Vectors (4.5) - restriction: == netstandard2.0 System.ObjectModel (4.3) System.Collections (>= 4.3) @@ -674,7 +723,7 @@ NUGET Microsoft.NETCore.Targets (>= 1.1) System.Runtime (>= 4.3) System.Threading.Tasks.Dataflow (8.0) - copy_local: false - System.Threading.Tasks.Extensions (4.5.4) - restriction: || (&& (== net6.0) (>= net472)) (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (>= uap10.1)) (== netstandard2.0) + System.Threading.Tasks.Extensions (4.5.4) System.Runtime.CompilerServices.Unsafe (>= 4.5.3) - restriction: || (&& (== net6.0) (>= net461)) (&& (== net6.0) (< netcoreapp2.1)) (&& (== net6.0) (< netstandard1.0)) (&& (== net6.0) (< netstandard2.0)) (&& (== net6.0) (>= wp8)) (== netstandard2.0) System.Threading.Tasks.Parallel (4.3) System.Collections.Concurrent (>= 4.3) diff --git a/src/FSharpLint.Client/Contracts.fs b/src/FSharpLint.Client/Contracts.fs new file mode 100644 index 000000000..b45a021f3 --- /dev/null +++ b/src/FSharpLint.Client/Contracts.fs @@ -0,0 +1,31 @@ +module FSharpLint.Client.Contracts + +open System +open System.Threading +open System.Threading.Tasks + +[] +module Methods = + [] + let Version = "fsharplint/version" + +type VersionRequest = + { + FilePath: string + } + +type FSharpLintResult = + | Content of string + +type FSharpLintResponse = { + Code: int + FilePath: string + Result : FSharpLintResult +} + +type FSharpLintService = + interface + inherit IDisposable + + abstract member VersionAsync: VersionRequest * ?cancellationToken: CancellationToken -> Task + end diff --git a/src/FSharpLint.Client/Contracts.fsi b/src/FSharpLint.Client/Contracts.fsi new file mode 100644 index 000000000..327947d50 --- /dev/null +++ b/src/FSharpLint.Client/Contracts.fsi @@ -0,0 +1,28 @@ +module FSharpLint.Client.Contracts + +open System.Threading +open System.Threading.Tasks + +module Methods = + + [] + val Version: string = "fsharplint/version" + +type VersionRequest = + { + FilePath: string + } + +type FSharpLintResult = + | Content of string + +type FSharpLintResponse = { + Code: int + FilePath: string + Result : FSharpLintResult +} + +type FSharpLintService = + inherit System.IDisposable + + abstract VersionAsync: VersionRequest * ?cancellationToken: CancellationToken -> Task diff --git a/src/FSharpLint.Client/FSharpLint.Client.fsproj b/src/FSharpLint.Client/FSharpLint.Client.fsproj new file mode 100644 index 000000000..982fbddd1 --- /dev/null +++ b/src/FSharpLint.Client/FSharpLint.Client.fsproj @@ -0,0 +1,26 @@ + + + net6.0 + true + true + FSharpLint.Client + false + FSharpLint.Client + Companion library to format using FSharpLint tool. + F#;fsharp;lint;FSharpLint;fslint;api + + + + + + + + + + + + + + + + diff --git a/src/FSharpLint.Client/FSharpLintToolLocator.fs b/src/FSharpLint.Client/FSharpLintToolLocator.fs new file mode 100644 index 000000000..e94bbc7b3 --- /dev/null +++ b/src/FSharpLint.Client/FSharpLintToolLocator.fs @@ -0,0 +1,249 @@ +module FSharpLint.Client.FSharpLintToolLocator + +open System +open System.ComponentModel +open System.Diagnostics +open System.IO +open System.Text.RegularExpressions +open System.Runtime.InteropServices +open StreamJsonRpc +open FSharpLint.Client.LSPFSharpLintServiceTypes + +let private supportedRange = SemanticVersioning.Range(">=v0.21.3") //TODO: proper version + +let private (|CompatibleVersion|_|) (version: string) = + match SemanticVersioning.Version.TryParse version with + | true, parsedVersion -> + if supportedRange.IsSatisfied(parsedVersion, includePrerelease = true) then + Some version + else + None + | _ -> None +let [] fsharpLintToolName = "dotnet-fsharplint" + +let private (|CompatibleToolName|_|) toolName = + if toolName = fsharpLintToolName then + Some toolName + else + None + +let private readOutputStreamAsLines (outputStream: StreamReader) : string list = + let rec readLines (outputStream: StreamReader) (continuation: string list -> string list) = + let nextLine = outputStream.ReadLine() + + if isNull nextLine then + continuation [] + else + readLines outputStream (fun lines -> nextLine :: lines |> continuation) + + readLines outputStream id + +let private startProcess (ps: ProcessStartInfo) : Result = + try + Ok(Process.Start ps) + with + | :? Win32Exception as win32ex -> + let pathEnv = Environment.GetEnvironmentVariable "PATH" + + Error( + ProcessStartError.ExecutableFileNotFound( + ps.FileName, + ps.Arguments, + ps.WorkingDirectory, + pathEnv, + win32ex.Message + ) + ) + | ex -> Error(ProcessStartError.UnExpectedException(ps.FileName, ps.Arguments, ex.Message)) + +let private runToolListCmd (Folder workingDir: Folder) (globalFlag: bool) : Result = + let ps = ProcessStartInfo("dotnet") + ps.WorkingDirectory <- workingDir + + if ps.EnvironmentVariables.ContainsKey "DOTNET_CLI_UI_LANGUAGE" then + ps.EnvironmentVariables.["DOTNET_CLI_UI_LANGUAGE"] <- "en-us" + else + ps.EnvironmentVariables.Add("DOTNET_CLI_UI_LANGUAGE", "en-us") + + ps.CreateNoWindow <- true + ps.Arguments <- if globalFlag then "tool list -g" else "tool list" + ps.RedirectStandardOutput <- true + ps.RedirectStandardError <- true + ps.UseShellExecute <- false + + match startProcess ps with + | Ok p -> + p.WaitForExit() + let exitCode = p.ExitCode + + if exitCode = 0 then + let output = readOutputStreamAsLines p.StandardOutput + Ok output + else + let error = p.StandardError.ReadToEnd() + Error(DotNetToolListError.ExitCodeNonZero(ps.FileName, ps.Arguments, exitCode, error)) + | Error err -> Error(DotNetToolListError.ProcessStartError err) + +let private (|CompatibleTool|_|) lines = + let (|HeaderLine|_|) line = + if Regex.IsMatch(line, @"^Package\sId\s+Version.+$") then + Some() + else + None + + let (|Dashes|_|) line = + if String.forall ((=) '-') line then Some() else None + + let (|Tools|_|) lines = + let tools = + lines + |> List.choose (fun (line: string) -> + let parts = line.Split([| ' ' |], StringSplitOptions.RemoveEmptyEntries) + + if parts.Length > 2 then + Some(parts.[0], parts.[1]) + else + None) + + if List.isEmpty tools then None else Some tools + + match lines with + | HeaderLine :: Dashes :: Tools tools -> + let tool = + List.tryFind + (fun (packageId, version) -> + match packageId, version with + | CompatibleToolName _, CompatibleVersion _ -> true + | _ -> false) + tools + + Option.map (snd >> FSharpLintVersion) tool + | _ -> None + +let private isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + +// Find an executable fsharplint file on the PATH +let private fsharpLintVersionOnPath () : (FSharpLintExecutableFile * FSharpLintVersion) option = + let fsharpLintExecutableOnPathOpt = + match Option.ofObj (Environment.GetEnvironmentVariable("PATH")) with + | Some s -> s.Split([| if isWindows then ';' else ':' |], StringSplitOptions.RemoveEmptyEntries) + | None -> Array.empty + |> Seq.choose (fun folder -> + if isWindows then + let fsharpLintExe = Path.Combine(folder, $"{fsharpLintToolName}.exe") + if File.Exists fsharpLintExe then Some fsharpLintExe + else None + else + let fsharpLint = Path.Combine(folder, fsharpLintToolName) + if File.Exists fsharpLint then Some fsharpLint + else None) + |> Seq.tryHead + + fsharpLintExecutableOnPathOpt + |> Option.bind (fun fsharpLintExecutablePath -> + let processStart = ProcessStartInfo(fsharpLintExecutablePath) + processStart.Arguments <- "--version" + processStart.RedirectStandardOutput <- true + processStart.CreateNoWindow <- true + processStart.RedirectStandardOutput <- true + processStart.RedirectStandardError <- true + processStart.UseShellExecute <- false + + match startProcess processStart with + | Ok p -> + p.WaitForExit() + let stdOut = p.StandardOutput.ReadToEnd() + + stdOut + |> Option.ofObj + |> Option.bind (fun s -> + if s.Contains("Current version: ", StringComparison.CurrentCultureIgnoreCase) then + let version = s.ToLowerInvariant().Replace("current version: ", String.Empty).Trim() + Some (FSharpLintExecutableFile(fsharpLintExecutablePath), FSharpLintVersion(version)) + else + None) + | Error(ProcessStartError.ExecutableFileNotFound _) + | Error(ProcessStartError.UnExpectedException _) -> None) + +let findFSharpLintTool (workingDir: Folder) : Result = + // First try and find a local tool for the folder. + // Next see if there is a global tool. + // Lastly check if an executable is present on the PATH. + let localToolsListResult = runToolListCmd workingDir false + + match localToolsListResult with + | Ok(CompatibleTool version) -> Ok(FSharpLintToolFound(version, FSharpLintToolStartInfo.LocalTool workingDir)) + | Error err -> Error(FSharpLintToolError.DotNetListError err) + | Ok _localToolListResult -> + let globalToolsListResult = runToolListCmd workingDir true + + match globalToolsListResult with + | Ok(CompatibleTool version) -> Ok(FSharpLintToolFound(version, FSharpLintToolStartInfo.GlobalTool)) + | Error err -> Error(FSharpLintToolError.DotNetListError err) + | Ok _nonCompatibleGlobalVersion -> + let onPathVersion = fsharpLintVersionOnPath () + + match onPathVersion with + | Some(executableFile, FSharpLintVersion(CompatibleVersion version)) -> + Ok(FSharpLintToolFound((FSharpLintVersion(version)), FSharpLintToolStartInfo.ToolOnPath executableFile)) + | _ -> Error FSharpLintToolError.NoCompatibleVersionFound + +let createFor (startInfo: FSharpLintToolStartInfo) : Result = + let processStart = + match startInfo with + | FSharpLintToolStartInfo.LocalTool(Folder workingDirectory) -> + let ps = ProcessStartInfo("dotnet") + ps.WorkingDirectory <- workingDirectory + ps.Arguments <- $"{fsharpLintToolName} --daemon" + ps + | FSharpLintToolStartInfo.GlobalTool -> + let userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + + let fsharpLintExecutable = + let fileName = if isWindows then $"{fsharpLintToolName}.exe" else fsharpLintToolName + Path.Combine(userProfile, ".dotnet", "tools", fileName) + + let ps = ProcessStartInfo(fsharpLintExecutable) + ps.Arguments <- "--daemon" + ps + | FSharpLintToolStartInfo.ToolOnPath(FSharpLintExecutableFile executableFile) -> + let ps = ProcessStartInfo(executableFile) + ps.Arguments <- "--daemon" + ps + + processStart.UseShellExecute <- false + processStart.RedirectStandardInput <- true + processStart.RedirectStandardOutput <- true + processStart.RedirectStandardError <- true + processStart.CreateNoWindow <- true + + match startProcess processStart with + | Ok daemonProcess -> + let handler = new HeaderDelimitedMessageHandler( + daemonProcess.StandardInput.BaseStream, + daemonProcess.StandardOutput.BaseStream) + let client = new JsonRpc(handler) + + do client.StartListening() + + try + // Get the version first as a sanity check that connection is possible + let _version = + client.InvokeAsync(FSharpLint.Client.Contracts.Methods.Version) + |> Async.AwaitTask + |> Async.RunSynchronously + + Ok + { RpcClient = client + Process = daemonProcess + StartInfo = startInfo } + with ex -> + let error = + if daemonProcess.HasExited then + let stdErr = daemonProcess.StandardError.ReadToEnd() + $"Daemon std error: {stdErr}.\nJsonRpc exception:{ex.Message}" + else + ex.Message + + Error(ProcessStartError.UnExpectedException(processStart.FileName, processStart.Arguments, error)) + | Error err -> Error err diff --git a/src/FSharpLint.Client/FSharpLintToolLocator.fsi b/src/FSharpLint.Client/FSharpLintToolLocator.fsi new file mode 100644 index 000000000..077eff316 --- /dev/null +++ b/src/FSharpLint.Client/FSharpLintToolLocator.fsi @@ -0,0 +1,7 @@ +module FSharpLint.Client.FSharpLintToolLocator + +open FSharpLint.Client.LSPFSharpLintServiceTypes + +val findFSharpLintTool: workingDir: Folder -> Result + +val createFor: startInfo: FSharpLintToolStartInfo -> Result diff --git a/src/FSharpLint.Client/LSPFSharpLintService.fs b/src/FSharpLint.Client/LSPFSharpLintService.fs new file mode 100644 index 000000000..1676592f3 --- /dev/null +++ b/src/FSharpLint.Client/LSPFSharpLintService.fs @@ -0,0 +1,260 @@ +module FSharpLint.Client.LSPFSharpLintService + +open System +open System.IO +open System.Threading +open System.Threading.Tasks +open StreamJsonRpc +open FSharpLint.Client.Contracts +open FSharpLint.Client.LSPFSharpLintServiceTypes +open FSharpLint.Client.FSharpLintToolLocator + +type ServiceState = + { Daemons: Map + FolderToVersion: Map } + + static member Empty: ServiceState = + { Daemons = Map.empty + FolderToVersion = Map.empty } + +[] +type GetDaemonError = + | DotNetToolListError of error: DotNetToolListError + | FSharpLintProcessStart of error: ProcessStartError + | InCompatibleVersionFound + | CompatibleVersionIsKnownButNoDaemonIsRunning of version: FSharpLintVersion + +type Msg = + | GetDaemon of folder: Folder * replyChannel: AsyncReplyChannel> + | Reset of AsyncReplyChannel + +let private createAgent (ct: CancellationToken) = + MailboxProcessor.Start( + (fun inbox -> + let rec messageLoop (state: ServiceState) = + async { + let! msg = inbox.Receive() + + let nextState = + match msg with + | GetDaemon(folder, replyChannel) -> + // get the version for that folder + // look in the cache first + let versionFromCache = Map.tryFind folder state.FolderToVersion + + match versionFromCache with + | Some version -> + let daemon = Map.tryFind version state.Daemons + + match daemon with + | Some daemon -> + // We have a daemon for the required version in the cache, check if we can still use it. + if daemon.Process.HasExited then + // weird situation where the process has crashed. + // Trying to reboot + (daemon :> IDisposable).Dispose() + + let newDaemonResult = createFor daemon.StartInfo + + match newDaemonResult with + | Ok newDaemon -> + replyChannel.Reply(Ok newDaemon.RpcClient) + + { FolderToVersion = Map.add folder version state.FolderToVersion + Daemons = Map.add version newDaemon state.Daemons } + | Error pse -> + replyChannel.Reply(Error(GetDaemonError.FSharpLintProcessStart pse)) + state + else + // return running client + replyChannel.Reply(Ok daemon.RpcClient) + + { state with + FolderToVersion = Map.add folder version state.FolderToVersion } + | None -> + // This is a strange situation, we know what version is linked to that folder but there is no daemon + // The moment a version is added, is also the moment a daemon is re-used or created + replyChannel.Reply( + Error(GetDaemonError.CompatibleVersionIsKnownButNoDaemonIsRunning version) + ) + + state + | None -> + // Try and find a version of fsharplint daemon for our current folder + let fsharpLintToolResult: Result = + findFSharpLintTool folder + + match fsharpLintToolResult with + | Ok(FSharpLintToolFound(version, startInfo)) -> + let createDaemonResult = createFor startInfo + + match createDaemonResult with + | Ok daemon -> + replyChannel.Reply(Ok daemon.RpcClient) + + { Daemons = Map.add version daemon state.Daemons + FolderToVersion = Map.add folder version state.FolderToVersion } + | Error pse -> + replyChannel.Reply(Error(GetDaemonError.FSharpLintProcessStart pse)) + state + | Error FSharpLintToolError.NoCompatibleVersionFound -> + replyChannel.Reply(Error GetDaemonError.InCompatibleVersionFound) + state + | Error(FSharpLintToolError.DotNetListError dotNetToolListError) -> + replyChannel.Reply(Error(GetDaemonError.DotNetToolListError dotNetToolListError)) + state + | Reset replyChannel -> + Map.toList state.Daemons + |> List.iter (fun (_, daemon) -> (daemon :> IDisposable).Dispose()) + + replyChannel.Reply() + ServiceState.Empty + + return! messageLoop nextState + } + + messageLoop ServiceState.Empty), + cancellationToken = ct + ) + +type FSharpLintServiceError = + | DaemonNotFound of GetDaemonError + | FileDoesNotExist + | FilePathIsNotAbsolute + | CancellationWasRequested + +let isPathAbsolute (path: string) : bool = + if + String.IsNullOrWhiteSpace path + || path.IndexOfAny(Path.GetInvalidPathChars()) <> -1 + || not (Path.IsPathRooted path) + then + false + else + let pathRoot = Path.GetPathRoot path + // Accepts X:\ and \\UNC\PATH, rejects empty string, \ and X:, but accepts / to support Linux + if pathRoot.Length <= 2 && pathRoot <> "/" then + false + else if pathRoot.[0] <> '\\' || pathRoot.[1] <> '\\' then + true + else + pathRoot.Trim('\\').IndexOf('\\') <> -1 // A UNC server name without a share name (e.g "\\NAME" or "\\NAME\") is invalid + +let private isCancellationRequested (requested: bool) : Result = + if requested then + Error FSharpLintServiceError.CancellationWasRequested + else + Ok() + +let private getFolderFor filePath (): Result = + let handleFile filePath = + if not (isPathAbsolute filePath) then + Error FSharpLintServiceError.FilePathIsNotAbsolute + elif not (File.Exists filePath) then + Error FSharpLintServiceError.FileDoesNotExist + else + Path.GetDirectoryName filePath |> Folder |> Ok + + handleFile filePath + +let private getDaemon (agent: MailboxProcessor) (folder: Folder) : Result = + let daemon = agent.PostAndReply(fun replyChannel -> GetDaemon(folder, replyChannel)) + + match daemon with + | Ok daemon -> Ok daemon + | Error gde -> Error(FSharpLintServiceError.DaemonNotFound gde) + +let private fileNotFoundResponse filePath : Task = + { Code = int FSharpLintResponseCode.FileNotFound + FilePath = filePath + Result = Content $"File \"%s{filePath}\" does not exist." + } + |> Task.FromResult + +let private fileNotAbsoluteResponse filePath : Task = + { Code = int FSharpLintResponseCode.FilePathIsNotAbsolute + FilePath = filePath + Result = Content $"\"%s{filePath}\" is not an absolute file path. Relative paths are not supported." + } + |> Task.FromResult + +let private daemonNotFoundResponse filePath (error: GetDaemonError) : Task = + let content, code = + match error with + | GetDaemonError.DotNetToolListError(DotNetToolListError.ProcessStartError(ProcessStartError.ExecutableFileNotFound(executableFile, + arguments, + workingDirectory, + pathEnvironmentVariable, + error))) + | GetDaemonError.FSharpLintProcessStart(ProcessStartError.ExecutableFileNotFound(executableFile, + arguments, + workingDirectory, + pathEnvironmentVariable, + error)) -> + $"FSharpLint.Client tried to run `%s{executableFile} %s{arguments}` inside working directory \"{workingDirectory}\" but could not find \"%s{executableFile}\" on the PATH (%s{pathEnvironmentVariable}). Error: %s{error}", + FSharpLintResponseCode.DaemonCreationFailed + | GetDaemonError.DotNetToolListError(DotNetToolListError.ProcessStartError(ProcessStartError.UnExpectedException(executableFile, + arguments, + error))) + | GetDaemonError.FSharpLintProcessStart(ProcessStartError.UnExpectedException(executableFile, arguments, error)) -> + $"FSharpLint.Client tried to run `%s{executableFile} %s{arguments}` but failed with \"%s{error}\"", + FSharpLintResponseCode.DaemonCreationFailed + | GetDaemonError.DotNetToolListError(DotNetToolListError.ExitCodeNonZero(executableFile, + arguments, + exitCode, + error)) -> + $"FSharpLint.Client tried to run `%s{executableFile} %s{arguments}` but exited with code {exitCode} {error}", + FSharpLintResponseCode.DaemonCreationFailed + | GetDaemonError.InCompatibleVersionFound -> + "FSharpLint.Client did not found a compatible dotnet tool version to launch as daemon process", + FSharpLintResponseCode.ToolNotFound + | GetDaemonError.CompatibleVersionIsKnownButNoDaemonIsRunning(FSharpLintVersion version) -> + $"FSharpLint.Client found a compatible version `%s{version}` but no daemon could be launched.", + FSharpLintResponseCode.DaemonCreationFailed + + { Code = int code + FilePath = filePath + Result = Content content + } + |> Task.FromResult + +let private cancellationWasRequestedResponse filePath : Task = + { Code = int FSharpLintResponseCode.CancellationWasRequested + FilePath = filePath + Result = Content "FSharpLintService is being or has been disposed." + } + |> Task.FromResult + +let mapResultToResponse (filePath: string) (result: Result, FSharpLintServiceError>) = + match result with + | Ok t -> t + | Error FSharpLintServiceError.FileDoesNotExist -> fileNotFoundResponse filePath + | Error FSharpLintServiceError.FilePathIsNotAbsolute -> fileNotAbsoluteResponse filePath + | Error(FSharpLintServiceError.DaemonNotFound e) -> daemonNotFoundResponse filePath e + | Error FSharpLintServiceError.CancellationWasRequested -> cancellationWasRequestedResponse filePath + +type LSPFSharpLintService() = + let cts = new CancellationTokenSource() + let agent = createAgent cts.Token + + interface FSharpLintService with + member this.Dispose() = + if not cts.IsCancellationRequested then + let _ = agent.PostAndReply Reset + cts.Cancel() + + member _.VersionAsync(versionRequest: VersionRequest, ?cancellationToken: CancellationToken) : Task = + isCancellationRequested cts.IsCancellationRequested + |> Result.bind (getFolderFor (versionRequest.FilePath)) + |> Result.bind (getDaemon agent) + |> Result.map (fun client -> + client + .InvokeWithCancellationAsync( + Methods.Version, + cancellationToken = Option.defaultValue cts.Token cancellationToken + ) + .ContinueWith(fun (t: Task) -> + { Code = int FSharpLintResponseCode.Version + Result = Content t.Result + FilePath = versionRequest.FilePath })) + |> mapResultToResponse versionRequest.FilePath diff --git a/src/FSharpLint.Client/LSPFSharpLintService.fsi b/src/FSharpLint.Client/LSPFSharpLintService.fsi new file mode 100644 index 000000000..70cfcd4f5 --- /dev/null +++ b/src/FSharpLint.Client/LSPFSharpLintService.fsi @@ -0,0 +1,6 @@ +module FSharpLint.Client.LSPFSharpLintService + +type LSPFSharpLintService = + interface Contracts.FSharpLintService + + new: unit -> LSPFSharpLintService diff --git a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs new file mode 100644 index 000000000..59885a0cd --- /dev/null +++ b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs @@ -0,0 +1,58 @@ +module FSharpLint.Client.LSPFSharpLintServiceTypes + +open System +open System.Diagnostics +open StreamJsonRpc + +type FSharpLintResponseCode = + | ToolNotFound = 1 + | FileNotFound = 2 + | FilePathIsNotAbsolute = 3 + | CancellationWasRequested = 4 + | DaemonCreationFailed = 5 + | Version = 6 + +type FSharpLintVersion = FSharpLintVersion of string +type FSharpLintExecutableFile = FSharpLintExecutableFile of string +type Folder = Folder of path: string + +[] +type FSharpLintToolStartInfo = + | LocalTool of workingDirectory: Folder + | GlobalTool + | ToolOnPath of executableFile: FSharpLintExecutableFile + +type RunningFSharpLintTool = + { Process: Process + RpcClient: JsonRpc + StartInfo: FSharpLintToolStartInfo } + + interface IDisposable with + member this.Dispose() : unit = + if not this.Process.HasExited then + this.Process.Kill() + + this.Process.Dispose() + this.RpcClient.Dispose() + +[] +type ProcessStartError = + | ExecutableFileNotFound of + executableFile: string * + arguments: string * + workingDirectory: string * + pathEnvironmentVariable: string * + error: string + | UnExpectedException of executableFile: string * arguments: string * error: string + +[] +type DotNetToolListError = + | ProcessStartError of ProcessStartError + | ExitCodeNonZero of executableFile: string * arguments: string * exitCode: int * error: string + +type FSharpLintToolFound = FSharpLintToolFound of version: FSharpLintVersion * startInfo: FSharpLintToolStartInfo + +[] +type FSharpLintToolError = + | NoCompatibleVersionFound + | DotNetListError of DotNetToolListError diff --git a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi new file mode 100644 index 000000000..52e644ccf --- /dev/null +++ b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi @@ -0,0 +1,50 @@ +module FSharpLint.Client.LSPFSharpLintServiceTypes + +type FSharpLintResponseCode = + | ToolNotFound = 1 + | FileNotFound = 2 + | FilePathIsNotAbsolute = 3 + | CancellationWasRequested = 4 + | DaemonCreationFailed = 5 + | Version = 6 + +type FSharpLintVersion = FSharpLintVersion of string + +type FSharpLintExecutableFile = FSharpLintExecutableFile of string + +type Folder = Folder of path: string + +[] +type FSharpLintToolStartInfo = + | LocalTool of workingDirectory: Folder + | GlobalTool + | ToolOnPath of executableFile: FSharpLintExecutableFile + +type RunningFSharpLintTool = + { Process: System.Diagnostics.Process + RpcClient: StreamJsonRpc.JsonRpc + StartInfo: FSharpLintToolStartInfo } + + interface System.IDisposable + +[] +type ProcessStartError = + | ExecutableFileNotFound of + executableFile: string * + arguments: string * + workingDirectory: string * + pathEnvironmentVariable: string * + error: string + | UnExpectedException of executableFile: string * arguments: string * error: string + +[] +type DotNetToolListError = + | ProcessStartError of ProcessStartError + | ExitCodeNonZero of executableFile: string * arguments: string * exitCode: int * error: string + +type FSharpLintToolFound = FSharpLintToolFound of version: FSharpLintVersion * startInfo: FSharpLintToolStartInfo + +[] +type FSharpLintToolError = + | NoCompatibleVersionFound + | DotNetListError of DotNetToolListError diff --git a/src/FSharpLint.Client/paket.references b/src/FSharpLint.Client/paket.references new file mode 100644 index 000000000..d3e94d317 --- /dev/null +++ b/src/FSharpLint.Client/paket.references @@ -0,0 +1,3 @@ +Newtonsoft.Json +SemanticVersioning +StreamJsonRpc diff --git a/src/FSharpLint.Console/Daemon.fs b/src/FSharpLint.Console/Daemon.fs new file mode 100644 index 000000000..186c30212 --- /dev/null +++ b/src/FSharpLint.Console/Daemon.fs @@ -0,0 +1,35 @@ +module FSharpLint.Console.Daemon + +open System +open System.Diagnostics +open System.IO +open System.Threading +open StreamJsonRpc +open FSharpLint.Client.Contracts +open FSharp.Core + +type FSharpLintDaemon(sender: Stream, reader: Stream) as this = + let rpc: JsonRpc = JsonRpc.Attach(sender, reader, this) + let traceListener = new DefaultTraceListener() + + do + // hook up request/response logging for debugging + rpc.TraceSource <- TraceSource(typeof.Name, SourceLevels.Verbose) + rpc.TraceSource.Listeners.Add traceListener |> ignore + + let disconnectEvent = new ManualResetEvent(false) + + let exit () = disconnectEvent.Set() |> ignore + + do rpc.Disconnected.Add(fun _ -> exit ()) + + interface IDisposable with + member this.Dispose() = + traceListener.Dispose() + disconnectEvent.Dispose() + + /// returns a hot task that resolves when the stream has terminated + member this.WaitForClose = rpc.Completion + + [] + member _.Version() : string = FSharpLint.Console.Version.get () diff --git a/src/FSharpLint.Console/FSharpLint.Console.fsproj b/src/FSharpLint.Console/FSharpLint.Console.fsproj index 2a9d92b88..488a3574c 100644 --- a/src/FSharpLint.Console/FSharpLint.Console.fsproj +++ b/src/FSharpLint.Console/FSharpLint.Console.fsproj @@ -1,4 +1,4 @@ - + Exe @@ -17,11 +17,14 @@ + + + diff --git a/src/FSharpLint.Console/Program.fs b/src/FSharpLint.Console/Program.fs index 73d43cd43..7bb524799 100644 --- a/src/FSharpLint.Console/Program.fs +++ b/src/FSharpLint.Console/Program.fs @@ -6,6 +6,7 @@ open System.IO open FSharpLint.Framework open FSharpLint.Application open System.Reflection +open Daemon /// Output format the linter will use. type private OutputFormat = @@ -25,6 +26,7 @@ type private ToolArgs = | [] Format of OutputFormat | [] Lint of ParseResults | Version + | Daemon with interface IArgParserTemplate with member this.Usage = @@ -32,6 +34,7 @@ with | Format _ -> "Output format of the linter." | Lint _ -> "Runs FSharpLint against a file or a collection of files." | Version -> "Prints current version." + | Daemon -> "Daemon mode, launches an LSP-like server to can be used by editor tooling." // TODO: investigate erroneous warning on this type definition // fsharplint:disable UnionDefinitionIndentation @@ -84,12 +87,16 @@ let private start (arguments:ParseResults) (toolsPath:Ionide.ProjInfo. | None -> Output.StandardOutput() :> Output.IOutput if arguments.Contains ToolArgs.Version then - let version = - Assembly.GetExecutingAssembly().GetCustomAttributes false - |> Seq.pick (function | :? AssemblyInformationalVersionAttribute as aiva -> Some aiva.InformationalVersion | _ -> None) + let version = FSharpLint.Console.Version.get () sprintf "Current version: %s" version |> output.WriteInfo () + if arguments.Contains ToolArgs.Daemon then + let daemon = new FSharpLintDaemon(Console.OpenStandardOutput(), Console.OpenStandardInput()) + AppDomain.CurrentDomain.ProcessExit.Add(fun _ -> (daemon :> IDisposable).Dispose()) + + daemon.WaitForClose.GetAwaiter().GetResult() + let handleError (str:string) = output.WriteError str exitCode <- -1 diff --git a/src/FSharpLint.Console/Version.fs b/src/FSharpLint.Console/Version.fs new file mode 100644 index 000000000..0019a2265 --- /dev/null +++ b/src/FSharpLint.Console/Version.fs @@ -0,0 +1,8 @@ +[] +module FSharpLint.Console.Version + +open System.Reflection + +let get () = + Assembly.GetExecutingAssembly().GetCustomAttributes false + |> Seq.pick (function | :? AssemblyInformationalVersionAttribute as aiva -> Some aiva.InformationalVersion | _ -> None) diff --git a/src/FSharpLint.Console/paket.references b/src/FSharpLint.Console/paket.references index fc097e42e..1f9b1e2d2 100644 --- a/src/FSharpLint.Console/paket.references +++ b/src/FSharpLint.Console/paket.references @@ -1,4 +1,5 @@ Argu FSharp.Compiler.Service FSharp.Core -Microsoft.SourceLink.GitHub \ No newline at end of file +Microsoft.SourceLink.GitHub +StreamJsonRpc From 544236410e29042cb938f84d999783c9f32b8dfa Mon Sep 17 00:00:00 2001 From: MrLuje Date: Sun, 17 Dec 2023 19:14:38 +0000 Subject: [PATCH 02/22] add client tests --- FSharpLint.sln | 15 ++++ .../FSharpLint.Client.Tests.fsproj | 13 ++++ tests/FSharpLint.Client.Tests/TestClient.fs | 74 +++++++++++++++++++ .../FSharpLint.Client.Tests/paket.references | 4 + 4 files changed, 106 insertions(+) create mode 100644 tests/FSharpLint.Client.Tests/FSharpLint.Client.Tests.fsproj create mode 100644 tests/FSharpLint.Client.Tests/TestClient.fs create mode 100644 tests/FSharpLint.Client.Tests/paket.references diff --git a/FSharpLint.sln b/FSharpLint.sln index f578f6676..1d87d0c3e 100644 --- a/FSharpLint.sln +++ b/FSharpLint.sln @@ -114,6 +114,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "rules", "rules", "{AEBB56D7 EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FSharpLint.Client", "src\FSharpLint.Client\FSharpLint.Client.fsproj", "{0452CA18-2599-4D8B-8A48-01A8B78F3984}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FSharpLint.Client.Tests", "tests\FSharpLint.Client.Tests\FSharpLint.Client.Tests.fsproj", "{72A7ED5D-8279-4375-B5EA-EFF9C33DD280}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -208,6 +210,18 @@ Global {0452CA18-2599-4D8B-8A48-01A8B78F3984}.Release|x64.Build.0 = Release|Any CPU {0452CA18-2599-4D8B-8A48-01A8B78F3984}.Release|x86.ActiveCfg = Release|Any CPU {0452CA18-2599-4D8B-8A48-01A8B78F3984}.Release|x86.Build.0 = Release|Any CPU + {72A7ED5D-8279-4375-B5EA-EFF9C33DD280}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72A7ED5D-8279-4375-B5EA-EFF9C33DD280}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72A7ED5D-8279-4375-B5EA-EFF9C33DD280}.Debug|x64.ActiveCfg = Debug|Any CPU + {72A7ED5D-8279-4375-B5EA-EFF9C33DD280}.Debug|x64.Build.0 = Debug|Any CPU + {72A7ED5D-8279-4375-B5EA-EFF9C33DD280}.Debug|x86.ActiveCfg = Debug|Any CPU + {72A7ED5D-8279-4375-B5EA-EFF9C33DD280}.Debug|x86.Build.0 = Debug|Any CPU + {72A7ED5D-8279-4375-B5EA-EFF9C33DD280}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72A7ED5D-8279-4375-B5EA-EFF9C33DD280}.Release|Any CPU.Build.0 = Release|Any CPU + {72A7ED5D-8279-4375-B5EA-EFF9C33DD280}.Release|x64.ActiveCfg = Release|Any CPU + {72A7ED5D-8279-4375-B5EA-EFF9C33DD280}.Release|x64.Build.0 = Release|Any CPU + {72A7ED5D-8279-4375-B5EA-EFF9C33DD280}.Release|x86.ActiveCfg = Release|Any CPU + {72A7ED5D-8279-4375-B5EA-EFF9C33DD280}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -222,6 +236,7 @@ Global {E1E03FFE-30DF-4522-83DA-9089147B431E} = {270E691D-ECA1-4BC5-B851-C5431A64E9FA} {AEBB56D7-30B4-40D7-B065-54B8BE960298} = {E1E03FFE-30DF-4522-83DA-9089147B431E} {0452CA18-2599-4D8B-8A48-01A8B78F3984} = {40C2798B-7078-4D4F-BD37-195240CB827B} + {72A7ED5D-8279-4375-B5EA-EFF9C33DD280} = {1CD44876-BCDC-4C93-9DC2-C45244BD62AE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B54B4B7D-F019-48A3-BB5B-635B68FE41C3} diff --git a/tests/FSharpLint.Client.Tests/FSharpLint.Client.Tests.fsproj b/tests/FSharpLint.Client.Tests/FSharpLint.Client.Tests.fsproj new file mode 100644 index 000000000..2a31c83f5 --- /dev/null +++ b/tests/FSharpLint.Client.Tests/FSharpLint.Client.Tests.fsproj @@ -0,0 +1,13 @@ + + + net6.0 + false + + + + + + + + + \ No newline at end of file diff --git a/tests/FSharpLint.Client.Tests/TestClient.fs b/tests/FSharpLint.Client.Tests/TestClient.fs new file mode 100644 index 000000000..65838f4c0 --- /dev/null +++ b/tests/FSharpLint.Client.Tests/TestClient.fs @@ -0,0 +1,74 @@ +module FSharpLint.Client.Tests + +open NUnit.Framework +open System.IO +open System +open Contracts +open LSPFSharpLintService +open LSPFSharpLintServiceTypes + +let () x y = Path.Combine(x, y) + +let basePath = TestContext.CurrentContext.TestDirectory ".." ".." ".." ".." ".." +let fsharpLintConsoleDll = basePath "src" "FSharpLint.Console" "bin" "Release" "net6.0" "dotnet-fsharplint.dll" +let fsharpConsoleOutputDir = Path.GetFullPath (Path.GetDirectoryName(fsharpLintConsoleDll)) + +let runVersionCall filePath (service: FSharpLintService) = + async { + let request = + { + FilePath = filePath + } + let! version = service.VersionAsync(request) |> Async.AwaitTask + return version + } + |> Async.RunSynchronously + +// ensure current FSharpLint.Console output is in PATH so it can use its daemon if needed +let ensureDaemonPath wantBuiltDaemon = + let path = Environment.GetEnvironmentVariable("PATH") + if wantBuiltDaemon then + if not <| path.Contains(fsharpConsoleOutputDir, StringComparison.InvariantCultureIgnoreCase) then + Environment.SetEnvironmentVariable("PATH", $"{fsharpConsoleOutputDir}:{path})") + else if path.Contains(fsharpConsoleOutputDir, StringComparison.InvariantCultureIgnoreCase) then + Assert.Inconclusive() + +[] +let TestDaemonNotFound() = + ensureDaemonPath false + + let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" + let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService + let versionResponse = runVersionCall testHintsFile fsharpLintService + + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.ToolNotFound, versionResponse.Code) + +[] +let TestDaemonVersion() = + ensureDaemonPath true + + let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" + let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService + let versionResponse = runVersionCall testHintsFile fsharpLintService + + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.Version, versionResponse.Code) + +[] +let TestFilePathShouldBeAbsolute() = + ensureDaemonPath true + + let testHintsFile = ".." "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" + let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService + let versionResponse = runVersionCall testHintsFile fsharpLintService + + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.FilePathIsNotAbsolute, versionResponse.Code) + +[] +let TestFileShouldExists() = + ensureDaemonPath true + + let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHintsOOOPS.fs" + let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService + let versionResponse = runVersionCall testHintsFile fsharpLintService + + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.FileNotFound, versionResponse.Code) diff --git a/tests/FSharpLint.Client.Tests/paket.references b/tests/FSharpLint.Client.Tests/paket.references new file mode 100644 index 000000000..b7dc60e22 --- /dev/null +++ b/tests/FSharpLint.Client.Tests/paket.references @@ -0,0 +1,4 @@ +nunit +NUnit3TestAdapter +FSharp.Core +Microsoft.NET.Test.Sdk \ No newline at end of file From 35ebe5404fda4139f6b712bb0dedaef67508a984 Mon Sep 17 00:00:00 2001 From: MrLuje Date: Sun, 17 Dec 2023 20:34:04 +0000 Subject: [PATCH 03/22] cleanup TestDaemonVersion --- tests/FSharpLint.Client.Tests/TestClient.fs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/FSharpLint.Client.Tests/TestClient.fs b/tests/FSharpLint.Client.Tests/TestClient.fs index 65838f4c0..d2c25e709 100644 --- a/tests/FSharpLint.Client.Tests/TestClient.fs +++ b/tests/FSharpLint.Client.Tests/TestClient.fs @@ -50,7 +50,11 @@ let TestDaemonVersion() = let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService let versionResponse = runVersionCall testHintsFile fsharpLintService - + + match versionResponse.Result with + | Content result -> Assert.IsFalse (String.IsNullOrWhiteSpace result) + // | _ -> Assert.Fail("Response should be a version number") + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.Version, versionResponse.Code) [] From de5f1489f8a7e77bb84d73365c45c9621130ccb4 Mon Sep 17 00:00:00 2001 From: MrLuje Date: Mon, 18 Dec 2023 17:18:35 +0000 Subject: [PATCH 04/22] update README.md --- src/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/src/README.md b/src/README.md index 611b1b490..3fe4c5099 100644 --- a/src/README.md +++ b/src/README.md @@ -4,3 +4,4 @@ Project Name | Description ------------ | -------- **`FSharpLint.Core`** | Linter library, generates an assembly which can run the linter, to be used by any application which wants to lint an F# file or project. [Available on nuget](https://www.nuget.org/packages/FSharpLint.Core/). **`FSharpLint.Console`** | Basic console application to run the linter. +**`FSharpLint.Client`** | Linter client that connects to ambiant FSharpLint.Console installations through JsonRPC, to be used by application which wants to lint F# files without referencing FSharp.Compiler.Service. [Available on nuget](https://www.nuget.org/packages/FSharpLint.Client/). From 2daa08f1d7e48a4a799260e136797490eae242af Mon Sep 17 00:00:00 2001 From: MrLuje Date: Sat, 23 Dec 2023 22:15:50 +0000 Subject: [PATCH 05/22] remove reference from FSharpLint.Client to FSharpLint.Core (which includes FCS) --- src/FSharpLint.Client/FSharpLint.Client.fsproj | 3 --- src/FSharpLint.Client/paket.references | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/FSharpLint.Client/FSharpLint.Client.fsproj b/src/FSharpLint.Client/FSharpLint.Client.fsproj index 982fbddd1..88a02a6b3 100644 --- a/src/FSharpLint.Client/FSharpLint.Client.fsproj +++ b/src/FSharpLint.Client/FSharpLint.Client.fsproj @@ -19,8 +19,5 @@ - - - diff --git a/src/FSharpLint.Client/paket.references b/src/FSharpLint.Client/paket.references index d3e94d317..bd8a77eb9 100644 --- a/src/FSharpLint.Client/paket.references +++ b/src/FSharpLint.Client/paket.references @@ -1,3 +1,4 @@ +FSharp.Core Newtonsoft.Json SemanticVersioning StreamJsonRpc From bd44b48d31945dddf79cb70faf6263058231942d Mon Sep 17 00:00:00 2001 From: MrLuje Date: Mon, 25 Dec 2023 19:52:33 +0000 Subject: [PATCH 06/22] test: ensure FCS is not referenced by FSharpLint.Client --- .../FSharpLint.Client.Tests.fsproj | 1 + tests/FSharpLint.Client.Tests/ReferenceTests.fs | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 tests/FSharpLint.Client.Tests/ReferenceTests.fs diff --git a/tests/FSharpLint.Client.Tests/FSharpLint.Client.Tests.fsproj b/tests/FSharpLint.Client.Tests/FSharpLint.Client.Tests.fsproj index 2a31c83f5..62a9ecab6 100644 --- a/tests/FSharpLint.Client.Tests/FSharpLint.Client.Tests.fsproj +++ b/tests/FSharpLint.Client.Tests/FSharpLint.Client.Tests.fsproj @@ -4,6 +4,7 @@ false + diff --git a/tests/FSharpLint.Client.Tests/ReferenceTests.fs b/tests/FSharpLint.Client.Tests/ReferenceTests.fs new file mode 100644 index 000000000..a4ad9d9b4 --- /dev/null +++ b/tests/FSharpLint.Client.Tests/ReferenceTests.fs @@ -0,0 +1,14 @@ +module FSharpLint.Client.ReferenceTests + +open NUnit.Framework +open System.IO +open System + +[] +let ``FSharpLint.Client should not reference FSharpLint.Core``() = + try + System.Activator.CreateInstanceFrom("FSharp.Compiler.Service.dll", "FSharp.Compiler.CodeAnalysis.FSharpCheckFileResults") + |> ignore + with + | :? FileNotFoundException as e -> () // dll is missing, what we want + | :? MissingMethodException as e -> Assert.Fail() // ctor is missing, dll was found From d3b16fb218f99deee9cc79c40f8f497b8dfc469f Mon Sep 17 00:00:00 2001 From: MrLuje Date: Tue, 26 Dec 2023 16:28:30 +0000 Subject: [PATCH 07/22] add FSHARPLINT_SEARCH_PATH_OVERRIDE env var to override search location --- .../FSharpLintToolLocator.fs | 11 ++- tests/FSharpLint.Client.Tests/TestClient.fs | 85 +++++++++++-------- 2 files changed, 59 insertions(+), 37 deletions(-) diff --git a/src/FSharpLint.Client/FSharpLintToolLocator.fs b/src/FSharpLint.Client/FSharpLintToolLocator.fs index e94bbc7b3..9aa33a313 100644 --- a/src/FSharpLint.Client/FSharpLintToolLocator.fs +++ b/src/FSharpLint.Client/FSharpLintToolLocator.fs @@ -65,8 +65,13 @@ let private runToolListCmd (Folder workingDir: Folder) (globalFlag: bool) : Resu else ps.EnvironmentVariables.Add("DOTNET_CLI_UI_LANGUAGE", "en-us") + let toolArguments = + Option.ofObj (Environment.GetEnvironmentVariable "FSHARPLINT_SEARCH_PATH_OVERRIDE") + |> Option.map(fun env -> $" --tool-path %s{env}") + |> Option.defaultValue (if globalFlag then "-g" else String.Empty) + ps.CreateNoWindow <- true - ps.Arguments <- if globalFlag then "tool list -g" else "tool list" + ps.Arguments <- $"tool list %s{toolArguments}" ps.RedirectStandardOutput <- true ps.RedirectStandardError <- true ps.UseShellExecute <- false @@ -125,7 +130,9 @@ let private isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) // Find an executable fsharplint file on the PATH let private fsharpLintVersionOnPath () : (FSharpLintExecutableFile * FSharpLintVersion) option = let fsharpLintExecutableOnPathOpt = - match Option.ofObj (Environment.GetEnvironmentVariable("PATH")) with + Option.ofObj (Environment.GetEnvironmentVariable("FSHARPLINT_SEARCH_PATH_OVERRIDE")) + |> Option.orElse (Option.ofObj (Environment.GetEnvironmentVariable("PATH"))) + |> function | Some s -> s.Split([| if isWindows then ';' else ':' |], StringSplitOptions.RemoveEmptyEntries) | None -> Array.empty |> Seq.choose (fun folder -> diff --git a/tests/FSharpLint.Client.Tests/TestClient.fs b/tests/FSharpLint.Client.Tests/TestClient.fs index d2c25e709..597a39c80 100644 --- a/tests/FSharpLint.Client.Tests/TestClient.fs +++ b/tests/FSharpLint.Client.Tests/TestClient.fs @@ -13,6 +13,30 @@ let basePath = TestContext.CurrentContext.TestDirectory ".." ".." ". let fsharpLintConsoleDll = basePath "src" "FSharpLint.Console" "bin" "Release" "net6.0" "dotnet-fsharplint.dll" let fsharpConsoleOutputDir = Path.GetFullPath (Path.GetDirectoryName(fsharpLintConsoleDll)) +[] +type ToolStatus = | Available | NotAvailable +type ToolLocationOverride(toolStatus: ToolStatus) = + let tempFolder = Path.GetTempFileName() + + do match toolStatus with + | ToolStatus.Available -> Environment.SetEnvironmentVariable("FSHARPLINT_SEARCH_PATH_OVERRIDE", fsharpConsoleOutputDir) + | ToolStatus.NotAvailable -> + let path = Environment.GetEnvironmentVariable("PATH") + // ensure bin dir is not in path + if path.Contains(fsharpConsoleOutputDir, StringComparison.InvariantCultureIgnoreCase) then + Assert.Inconclusive() + + File.Delete(tempFolder) + Directory.CreateDirectory(tempFolder) |> ignore + + // set search path to an empty dir + Environment.SetEnvironmentVariable("FSHARPLINT_SEARCH_PATH_OVERRIDE", tempFolder) + + interface IDisposable with + member this.Dispose() = + if File.Exists tempFolder then + File.Delete tempFolder + let runVersionCall filePath (service: FSharpLintService) = async { let request = @@ -24,55 +48,46 @@ let runVersionCall filePath (service: FSharpLintService) = } |> Async.RunSynchronously -// ensure current FSharpLint.Console output is in PATH so it can use its daemon if needed -let ensureDaemonPath wantBuiltDaemon = - let path = Environment.GetEnvironmentVariable("PATH") - if wantBuiltDaemon then - if not <| path.Contains(fsharpConsoleOutputDir, StringComparison.InvariantCultureIgnoreCase) then - Environment.SetEnvironmentVariable("PATH", $"{fsharpConsoleOutputDir}:{path})") - else if path.Contains(fsharpConsoleOutputDir, StringComparison.InvariantCultureIgnoreCase) then - Assert.Inconclusive() - [] let TestDaemonNotFound() = - ensureDaemonPath false - - let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" - let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService - let versionResponse = runVersionCall testHintsFile fsharpLintService + using (new ToolLocationOverride(ToolStatus.NotAvailable)) <| fun _ -> - Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.ToolNotFound, versionResponse.Code) + let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" + let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService + let versionResponse = runVersionCall testHintsFile fsharpLintService + + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.ToolNotFound, versionResponse.Code) [] let TestDaemonVersion() = - ensureDaemonPath true + using (new ToolLocationOverride(ToolStatus.Available)) <| fun _ -> - let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" - let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService - let versionResponse = runVersionCall testHintsFile fsharpLintService + let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" + let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService + let versionResponse = runVersionCall testHintsFile fsharpLintService - match versionResponse.Result with - | Content result -> Assert.IsFalse (String.IsNullOrWhiteSpace result) - // | _ -> Assert.Fail("Response should be a version number") + match versionResponse.Result with + | Content result -> Assert.IsFalse (String.IsNullOrWhiteSpace result) + // | _ -> Assert.Fail("Response should be a version number") - Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.Version, versionResponse.Code) + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.Version, versionResponse.Code) [] let TestFilePathShouldBeAbsolute() = - ensureDaemonPath true + using (new ToolLocationOverride(ToolStatus.Available)) <| fun _ -> - let testHintsFile = ".." "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" - let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService - let versionResponse = runVersionCall testHintsFile fsharpLintService - - Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.FilePathIsNotAbsolute, versionResponse.Code) + let testHintsFile = ".." "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" + let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService + let versionResponse = runVersionCall testHintsFile fsharpLintService + + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.FilePathIsNotAbsolute, versionResponse.Code) [] let TestFileShouldExists() = - ensureDaemonPath true + using (new ToolLocationOverride(ToolStatus.Available)) <| fun _ -> - let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHintsOOOPS.fs" - let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService - let versionResponse = runVersionCall testHintsFile fsharpLintService - - Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.FileNotFound, versionResponse.Code) + let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHintsOOOPS.fs" + let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService + let versionResponse = runVersionCall testHintsFile fsharpLintService + + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.FileNotFound, versionResponse.Code) From 0a1eaf92b0b68ce55c478faacd2a379d4b22caca Mon Sep 17 00:00:00 2001 From: MrLuje Date: Fri, 29 Dec 2023 13:18:47 +0000 Subject: [PATCH 08/22] PR feedback: -g -> --global --- src/FSharpLint.Client/FSharpLintToolLocator.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FSharpLint.Client/FSharpLintToolLocator.fs b/src/FSharpLint.Client/FSharpLintToolLocator.fs index 9aa33a313..70c3c9410 100644 --- a/src/FSharpLint.Client/FSharpLintToolLocator.fs +++ b/src/FSharpLint.Client/FSharpLintToolLocator.fs @@ -68,7 +68,7 @@ let private runToolListCmd (Folder workingDir: Folder) (globalFlag: bool) : Resu let toolArguments = Option.ofObj (Environment.GetEnvironmentVariable "FSHARPLINT_SEARCH_PATH_OVERRIDE") |> Option.map(fun env -> $" --tool-path %s{env}") - |> Option.defaultValue (if globalFlag then "-g" else String.Empty) + |> Option.defaultValue (if globalFlag then "--global" else String.Empty) ps.CreateNoWindow <- true ps.Arguments <- $"tool list %s{toolArguments}" From bc88d44a6f50135020f9a8b69d26831aa3d68368 Mon Sep 17 00:00:00 2001 From: MrLuje Date: Fri, 29 Dec 2023 13:19:11 +0000 Subject: [PATCH 09/22] PR feedback: simplify DOTNET_CLI_UI_LANGUAGE env var usage --- src/FSharpLint.Client/FSharpLintToolLocator.fs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/FSharpLint.Client/FSharpLintToolLocator.fs b/src/FSharpLint.Client/FSharpLintToolLocator.fs index 70c3c9410..697df17a2 100644 --- a/src/FSharpLint.Client/FSharpLintToolLocator.fs +++ b/src/FSharpLint.Client/FSharpLintToolLocator.fs @@ -59,11 +59,7 @@ let private startProcess (ps: ProcessStartInfo) : Result = let ps = ProcessStartInfo("dotnet") ps.WorkingDirectory <- workingDir - - if ps.EnvironmentVariables.ContainsKey "DOTNET_CLI_UI_LANGUAGE" then - ps.EnvironmentVariables.["DOTNET_CLI_UI_LANGUAGE"] <- "en-us" - else - ps.EnvironmentVariables.Add("DOTNET_CLI_UI_LANGUAGE", "en-us") + ps.EnvironmentVariables.["DOTNET_CLI_UI_LANGUAGE"] <- "en-us" //ensure we have predictible output for parsing let toolArguments = Option.ofObj (Environment.GetEnvironmentVariable "FSHARPLINT_SEARCH_PATH_OVERRIDE") From 998a46a31cff062988fb8f08101e38247436e813 Mon Sep 17 00:00:00 2001 From: MrLuje Date: Sat, 30 Dec 2023 14:17:44 +0000 Subject: [PATCH 10/22] PR feedback: clearer FSharpLintResponseCode --- src/FSharpLint.Client/LSPFSharpLintService.fs | 18 +++++++++--------- .../LSPFSharpLintServiceTypes.fs | 12 ++++++------ .../LSPFSharpLintServiceTypes.fsi | 12 ++++++------ tests/FSharpLint.Client.Tests/TestClient.fs | 8 ++++---- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/FSharpLint.Client/LSPFSharpLintService.fs b/src/FSharpLint.Client/LSPFSharpLintService.fs index 1676592f3..7d058eb13 100644 --- a/src/FSharpLint.Client/LSPFSharpLintService.fs +++ b/src/FSharpLint.Client/LSPFSharpLintService.fs @@ -165,14 +165,14 @@ let private getDaemon (agent: MailboxProcessor) (folder: Folder) : Result Error(FSharpLintServiceError.DaemonNotFound gde) let private fileNotFoundResponse filePath : Task = - { Code = int FSharpLintResponseCode.FileNotFound + { Code = int FSharpLintResponseCode.ErrFileNotFound FilePath = filePath Result = Content $"File \"%s{filePath}\" does not exist." } |> Task.FromResult let private fileNotAbsoluteResponse filePath : Task = - { Code = int FSharpLintResponseCode.FilePathIsNotAbsolute + { Code = int FSharpLintResponseCode.ErrFilePathIsNotAbsolute FilePath = filePath Result = Content $"\"%s{filePath}\" is not an absolute file path. Relative paths are not supported." } @@ -192,25 +192,25 @@ let private daemonNotFoundResponse filePath (error: GetDaemonError) : Task $"FSharpLint.Client tried to run `%s{executableFile} %s{arguments}` inside working directory \"{workingDirectory}\" but could not find \"%s{executableFile}\" on the PATH (%s{pathEnvironmentVariable}). Error: %s{error}", - FSharpLintResponseCode.DaemonCreationFailed + FSharpLintResponseCode.ErrDaemonCreationFailed | GetDaemonError.DotNetToolListError(DotNetToolListError.ProcessStartError(ProcessStartError.UnExpectedException(executableFile, arguments, error))) | GetDaemonError.FSharpLintProcessStart(ProcessStartError.UnExpectedException(executableFile, arguments, error)) -> $"FSharpLint.Client tried to run `%s{executableFile} %s{arguments}` but failed with \"%s{error}\"", - FSharpLintResponseCode.DaemonCreationFailed + FSharpLintResponseCode.ErrDaemonCreationFailed | GetDaemonError.DotNetToolListError(DotNetToolListError.ExitCodeNonZero(executableFile, arguments, exitCode, error)) -> $"FSharpLint.Client tried to run `%s{executableFile} %s{arguments}` but exited with code {exitCode} {error}", - FSharpLintResponseCode.DaemonCreationFailed + FSharpLintResponseCode.ErrDaemonCreationFailed | GetDaemonError.InCompatibleVersionFound -> "FSharpLint.Client did not found a compatible dotnet tool version to launch as daemon process", - FSharpLintResponseCode.ToolNotFound + FSharpLintResponseCode.ErrToolNotFound | GetDaemonError.CompatibleVersionIsKnownButNoDaemonIsRunning(FSharpLintVersion version) -> $"FSharpLint.Client found a compatible version `%s{version}` but no daemon could be launched.", - FSharpLintResponseCode.DaemonCreationFailed + FSharpLintResponseCode.ErrDaemonCreationFailed { Code = int code FilePath = filePath @@ -219,7 +219,7 @@ let private daemonNotFoundResponse filePath (error: GetDaemonError) : Task Task.FromResult let private cancellationWasRequestedResponse filePath : Task = - { Code = int FSharpLintResponseCode.CancellationWasRequested + { Code = int FSharpLintResponseCode.ErrCancellationWasRequested FilePath = filePath Result = Content "FSharpLintService is being or has been disposed." } @@ -254,7 +254,7 @@ type LSPFSharpLintService() = cancellationToken = Option.defaultValue cts.Token cancellationToken ) .ContinueWith(fun (t: Task) -> - { Code = int FSharpLintResponseCode.Version + { Code = int FSharpLintResponseCode.OkCurrentDaemonVersion Result = Content t.Result FilePath = versionRequest.FilePath })) |> mapResultToResponse versionRequest.FilePath diff --git a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs index 59885a0cd..9b6b15dd9 100644 --- a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs +++ b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs @@ -5,12 +5,12 @@ open System.Diagnostics open StreamJsonRpc type FSharpLintResponseCode = - | ToolNotFound = 1 - | FileNotFound = 2 - | FilePathIsNotAbsolute = 3 - | CancellationWasRequested = 4 - | DaemonCreationFailed = 5 - | Version = 6 + | ErrToolNotFound = -5 + | ErrFileNotFound = -4 + | ErrFilePathIsNotAbsolute = -3 + | ErrCancellationWasRequested = -2 + | ErrDaemonCreationFailed = -1 + | OkCurrentDaemonVersion = 0 type FSharpLintVersion = FSharpLintVersion of string type FSharpLintExecutableFile = FSharpLintExecutableFile of string diff --git a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi index 52e644ccf..1b552bfa8 100644 --- a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi +++ b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi @@ -1,12 +1,12 @@ module FSharpLint.Client.LSPFSharpLintServiceTypes type FSharpLintResponseCode = - | ToolNotFound = 1 - | FileNotFound = 2 - | FilePathIsNotAbsolute = 3 - | CancellationWasRequested = 4 - | DaemonCreationFailed = 5 - | Version = 6 + | ErrToolNotFound = -5 + | ErrFileNotFound = -4 + | ErrFilePathIsNotAbsolute = -3 + | ErrCancellationWasRequested = -2 + | ErrDaemonCreationFailed = -1 + | OkCurrentDaemonVersion = 0 type FSharpLintVersion = FSharpLintVersion of string diff --git a/tests/FSharpLint.Client.Tests/TestClient.fs b/tests/FSharpLint.Client.Tests/TestClient.fs index 597a39c80..8d4c3df95 100644 --- a/tests/FSharpLint.Client.Tests/TestClient.fs +++ b/tests/FSharpLint.Client.Tests/TestClient.fs @@ -56,7 +56,7 @@ let TestDaemonNotFound() = let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService let versionResponse = runVersionCall testHintsFile fsharpLintService - Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.ToolNotFound, versionResponse.Code) + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.ErrToolNotFound, versionResponse.Code) [] let TestDaemonVersion() = @@ -70,7 +70,7 @@ let TestDaemonVersion() = | Content result -> Assert.IsFalse (String.IsNullOrWhiteSpace result) // | _ -> Assert.Fail("Response should be a version number") - Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.Version, versionResponse.Code) + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.OkCurrentDaemonVersion, versionResponse.Code) [] let TestFilePathShouldBeAbsolute() = @@ -80,7 +80,7 @@ let TestFilePathShouldBeAbsolute() = let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService let versionResponse = runVersionCall testHintsFile fsharpLintService - Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.FilePathIsNotAbsolute, versionResponse.Code) + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.ErrFilePathIsNotAbsolute, versionResponse.Code) [] let TestFileShouldExists() = @@ -90,4 +90,4 @@ let TestFileShouldExists() = let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService let versionResponse = runVersionCall testHintsFile fsharpLintService - Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.FileNotFound, versionResponse.Code) + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.ErrFileNotFound, versionResponse.Code) From 8a9028f86b2a1753c3500f81f7eda329a5ad08b3 Mon Sep 17 00:00:00 2001 From: MrLuje Date: Sat, 30 Dec 2023 16:05:08 +0000 Subject: [PATCH 11/22] PR feedback: Folder check --- src/FSharpLint.Client/FSharpLintToolLocator.fs | 8 ++++---- src/FSharpLint.Client/LSPFSharpLintService.fs | 7 +++---- src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs | 14 +++++++++++++- .../LSPFSharpLintServiceTypes.fsi | 5 ++++- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/FSharpLint.Client/FSharpLintToolLocator.fs b/src/FSharpLint.Client/FSharpLintToolLocator.fs index 697df17a2..aa55312e1 100644 --- a/src/FSharpLint.Client/FSharpLintToolLocator.fs +++ b/src/FSharpLint.Client/FSharpLintToolLocator.fs @@ -56,9 +56,9 @@ let private startProcess (ps: ProcessStartInfo) : Result Error(ProcessStartError.UnExpectedException(ps.FileName, ps.Arguments, ex.Message)) -let private runToolListCmd (Folder workingDir: Folder) (globalFlag: bool) : Result = +let private runToolListCmd (workingDir: Folder) (globalFlag: bool) : Result = let ps = ProcessStartInfo("dotnet") - ps.WorkingDirectory <- workingDir + ps.WorkingDirectory <- Folder.unwrap workingDir ps.EnvironmentVariables.["DOTNET_CLI_UI_LANGUAGE"] <- "en-us" //ensure we have predictible output for parsing let toolArguments = @@ -194,9 +194,9 @@ let findFSharpLintTool (workingDir: Folder) : Result = let processStart = match startInfo with - | FSharpLintToolStartInfo.LocalTool(Folder workingDirectory) -> + | FSharpLintToolStartInfo.LocalTool(workingDirectory: Folder) -> let ps = ProcessStartInfo("dotnet") - ps.WorkingDirectory <- workingDirectory + ps.WorkingDirectory <- Folder.unwrap workingDirectory ps.Arguments <- $"{fsharpLintToolName} --daemon" ps | FSharpLintToolStartInfo.GlobalTool -> diff --git a/src/FSharpLint.Client/LSPFSharpLintService.fs b/src/FSharpLint.Client/LSPFSharpLintService.fs index 7d058eb13..2a5f8fbe7 100644 --- a/src/FSharpLint.Client/LSPFSharpLintService.fs +++ b/src/FSharpLint.Client/LSPFSharpLintService.fs @@ -150,10 +150,9 @@ let private getFolderFor filePath (): Result = let handleFile filePath = if not (isPathAbsolute filePath) then Error FSharpLintServiceError.FilePathIsNotAbsolute - elif not (File.Exists filePath) then - Error FSharpLintServiceError.FileDoesNotExist - else - Path.GetDirectoryName filePath |> Folder |> Ok + else match Folder.from filePath with + | None -> Error FSharpLintServiceError.FileDoesNotExist + | Some folder -> Ok folder handleFile filePath diff --git a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs index 9b6b15dd9..20fa4c775 100644 --- a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs +++ b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs @@ -2,6 +2,7 @@ open System open System.Diagnostics +open System.IO open StreamJsonRpc type FSharpLintResponseCode = @@ -14,7 +15,18 @@ type FSharpLintResponseCode = type FSharpLintVersion = FSharpLintVersion of string type FSharpLintExecutableFile = FSharpLintExecutableFile of string -type Folder = Folder of path: string +type Folder = private Folder of string +with + static member from (filePath: string) = + if File.Exists(filePath) then + let folder = Path.GetFullPath(filePath) |> Path.GetDirectoryName + if DirectoryInfo(folder).Exists then + folder |> Folder |> Some + else + None + else + None + static member unwrap(Folder f) = f [] type FSharpLintToolStartInfo = diff --git a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi index 1b552bfa8..18ed35e71 100644 --- a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi +++ b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi @@ -12,7 +12,10 @@ type FSharpLintVersion = FSharpLintVersion of string type FSharpLintExecutableFile = FSharpLintExecutableFile of string -type Folder = Folder of path: string +type Folder = private Folder of string +with + static member from: string -> Folder option + static member unwrap: Folder -> string [] type FSharpLintToolStartInfo = From 4cff9e77fc8a94583224a900c859a8b7c315f004 Mon Sep 17 00:00:00 2001 From: MrLuje Date: Sun, 31 Dec 2023 09:42:27 +0000 Subject: [PATCH 12/22] PR feedback: UnexpectedException --- src/FSharpLint.Client/FSharpLintToolLocator.fs | 6 +++--- src/FSharpLint.Client/LSPFSharpLintService.fs | 4 ++-- src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs | 2 +- src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/FSharpLint.Client/FSharpLintToolLocator.fs b/src/FSharpLint.Client/FSharpLintToolLocator.fs index aa55312e1..a6242dcd3 100644 --- a/src/FSharpLint.Client/FSharpLintToolLocator.fs +++ b/src/FSharpLint.Client/FSharpLintToolLocator.fs @@ -54,7 +54,7 @@ let private startProcess (ps: ProcessStartInfo) : Result Error(ProcessStartError.UnExpectedException(ps.FileName, ps.Arguments, ex.Message)) + | ex -> Error(ProcessStartError.UnexpectedException(ps.FileName, ps.Arguments, ex.Message)) let private runToolListCmd (workingDir: Folder) (globalFlag: bool) : Result = let ps = ProcessStartInfo("dotnet") @@ -166,7 +166,7 @@ let private fsharpLintVersionOnPath () : (FSharpLintExecutableFile * FSharpLintV else None) | Error(ProcessStartError.ExecutableFileNotFound _) - | Error(ProcessStartError.UnExpectedException _) -> None) + | Error(ProcessStartError.UnexpectedException _) -> None) let findFSharpLintTool (workingDir: Folder) : Result = // First try and find a local tool for the folder. @@ -248,5 +248,5 @@ let createFor (startInfo: FSharpLintToolStartInfo) : Result Error err diff --git a/src/FSharpLint.Client/LSPFSharpLintService.fs b/src/FSharpLint.Client/LSPFSharpLintService.fs index 2a5f8fbe7..5b5df5b42 100644 --- a/src/FSharpLint.Client/LSPFSharpLintService.fs +++ b/src/FSharpLint.Client/LSPFSharpLintService.fs @@ -192,10 +192,10 @@ let private daemonNotFoundResponse filePath (error: GetDaemonError) : Task $"FSharpLint.Client tried to run `%s{executableFile} %s{arguments}` inside working directory \"{workingDirectory}\" but could not find \"%s{executableFile}\" on the PATH (%s{pathEnvironmentVariable}). Error: %s{error}", FSharpLintResponseCode.ErrDaemonCreationFailed - | GetDaemonError.DotNetToolListError(DotNetToolListError.ProcessStartError(ProcessStartError.UnExpectedException(executableFile, + | GetDaemonError.DotNetToolListError(DotNetToolListError.ProcessStartError(ProcessStartError.UnexpectedException(executableFile, arguments, error))) - | GetDaemonError.FSharpLintProcessStart(ProcessStartError.UnExpectedException(executableFile, arguments, error)) -> + | GetDaemonError.FSharpLintProcessStart(ProcessStartError.UnexpectedException(executableFile, arguments, error)) -> $"FSharpLint.Client tried to run `%s{executableFile} %s{arguments}` but failed with \"%s{error}\"", FSharpLintResponseCode.ErrDaemonCreationFailed | GetDaemonError.DotNetToolListError(DotNetToolListError.ExitCodeNonZero(executableFile, diff --git a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs index 20fa4c775..a2a504cba 100644 --- a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs +++ b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs @@ -55,7 +55,7 @@ type ProcessStartError = workingDirectory: string * pathEnvironmentVariable: string * error: string - | UnExpectedException of executableFile: string * arguments: string * error: string + | UnexpectedException of executableFile: string * arguments: string * error: string [] type DotNetToolListError = diff --git a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi index 18ed35e71..9a1d13f57 100644 --- a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi +++ b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi @@ -38,7 +38,7 @@ type ProcessStartError = workingDirectory: string * pathEnvironmentVariable: string * error: string - | UnExpectedException of executableFile: string * arguments: string * error: string + | UnexpectedException of executableFile: string * arguments: string * error: string [] type DotNetToolListError = From 5b4ce385d7a68dd85088c4a8c2ab565feb767550 Mon Sep 17 00:00:00 2001 From: MrLuje Date: Sun, 31 Dec 2023 10:09:31 +0000 Subject: [PATCH 13/22] PR feedback: comment about Path.GetFullPath --- src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs index a2a504cba..83d365823 100644 --- a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs +++ b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs @@ -1,4 +1,4 @@ -module FSharpLint.Client.LSPFSharpLintServiceTypes +module FSharpLint.Client.LSPFSharpLintServiceTypes open System open System.Diagnostics @@ -19,7 +19,9 @@ type Folder = private Folder of string with static member from (filePath: string) = if File.Exists(filePath) then - let folder = Path.GetFullPath(filePath) |> Path.GetDirectoryName + let folder = + Path.GetFullPath(filePath) // to resolves path like /foo/bar/../baz + |> Path.GetDirectoryName if DirectoryInfo(folder).Exists then folder |> Folder |> Some else From b61aaffe9f9d4cf3da11e0653befbea0b270479f Mon Sep 17 00:00:00 2001 From: MrLuje Date: Tue, 2 Jan 2024 14:57:05 +0000 Subject: [PATCH 14/22] PR feedback: Path.GetFullPath readability --- src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs index 83d365823..399a1768c 100644 --- a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs +++ b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs @@ -19,9 +19,8 @@ type Folder = private Folder of string with static member from (filePath: string) = if File.Exists(filePath) then - let folder = - Path.GetFullPath(filePath) // to resolves path like /foo/bar/../baz - |> Path.GetDirectoryName + // Path.GetFullPath to resolve path like /foo/bar/../baz + let folder = ((filePath |> Path.GetFullPath |> FileInfo).Directory).FullName if DirectoryInfo(folder).Exists then folder |> Folder |> Some else From 8db5dc6b1ab9fa74b55dfd02bf27fd4bd026fd18 Mon Sep 17 00:00:00 2001 From: MrLuje Date: Sat, 6 Jan 2024 17:14:38 +0000 Subject: [PATCH 15/22] PR feedback: reuse DirectoryInfo instance --- src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs index 399a1768c..e308ef022 100644 --- a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs +++ b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs @@ -20,9 +20,9 @@ with static member from (filePath: string) = if File.Exists(filePath) then // Path.GetFullPath to resolve path like /foo/bar/../baz - let folder = ((filePath |> Path.GetFullPath |> FileInfo).Directory).FullName - if DirectoryInfo(folder).Exists then - folder |> Folder |> Some + let folder = ((filePath |> Path.GetFullPath |> FileInfo).Directory) + if folder.Exists then + folder.FullName |> Folder |> Some else None else From 66da7f26d222f0fac41459363c89245095fb7a24 Mon Sep 17 00:00:00 2001 From: MrLuje Date: Tue, 2 Jan 2024 16:07:24 +0000 Subject: [PATCH 16/22] WIP lint --- src/FSharpLint.Client/Contracts.fs | 66 +++++++++++++++++++ src/FSharpLint.Client/Contracts.fsi | 63 ++++++++++++++++++ src/FSharpLint.Client/LSPFSharpLintService.fs | 23 +++++++ .../LSPFSharpLintServiceTypes.fs | 2 + .../LSPFSharpLintServiceTypes.fsi | 2 + src/FSharpLint.Console/Daemon.fs | 44 ++++++++++++- tests/FSharpLint.Client.Tests/TestClient.fs | 36 ++++++++-- 7 files changed, 230 insertions(+), 6 deletions(-) diff --git a/src/FSharpLint.Client/Contracts.fs b/src/FSharpLint.Client/Contracts.fs index b45a021f3..3ff48188d 100644 --- a/src/FSharpLint.Client/Contracts.fs +++ b/src/FSharpLint.Client/Contracts.fs @@ -9,13 +9,78 @@ module Methods = [] let Version = "fsharplint/version" + [] + let LintFile = "fsharplint/lintfile" + type VersionRequest = { FilePath: string } +type LintFileRequest = + { + FilePath: string + LintConfigPath: string option + } + +type ClientRange = + class + val StartLine: int + val StartColumn: int + val EndLine: int + val EndColumn: int + new(startLine: int, startColumn: int, endLine: int, endColumn: int) = + { StartLine = startLine + StartColumn = startColumn + EndLine = endLine + EndColumn = endColumn } + end + +type ClientSuggestedFix = { + /// Text to be replaced. + FromText:string + + /// Location of the text to be replaced. + FromRange:ClientRange + + /// Text to replace the `FromText`, i.e. the fix. + ToText:string +} + +[] +type ClientWarningDetails = { + /// Location of the code that prompted the suggestion. + Range:ClientRange + + /// Suggestion message to describe the possible problem to the user. + Message:string + + /// Information to provide an automated fix. + SuggestedFix:ClientSuggestedFix option +} + +/// A lint "warning", sources the location of the warning with a suggestion on how it may be fixed. +[] +type ClientLintWarning = { + /// Unique identifier for the rule that caused the warning. + RuleIdentifier:string + + /// Unique name for the rule that caused the warning. + RuleName:string + + /// Path to the file where the error occurs. + FilePath:string + + /// Text that caused the error (the `Range` of the content of `FileName`). + ErrorText:string + + /// Details for the warning. + Details:ClientWarningDetails +} + type FSharpLintResult = | Content of string + | LintResult of ClientLintWarning list type FSharpLintResponse = { Code: int @@ -28,4 +93,5 @@ type FSharpLintService = inherit IDisposable abstract member VersionAsync: VersionRequest * ?cancellationToken: CancellationToken -> Task + abstract member LintFileAsync: LintFileRequest * ?cancellationToken: CancellationToken -> Task end diff --git a/src/FSharpLint.Client/Contracts.fsi b/src/FSharpLint.Client/Contracts.fsi index 327947d50..ea7b728eb 100644 --- a/src/FSharpLint.Client/Contracts.fsi +++ b/src/FSharpLint.Client/Contracts.fsi @@ -7,14 +7,76 @@ module Methods = [] val Version: string = "fsharplint/version" + + [] + val LintFile: string = "fsharplint/lintfile" type VersionRequest = { FilePath: string } +type LintFileRequest = + { + FilePath: string + LintConfigPath: string option + } + + +type ClientRange = + class + new: startLine: int * startColumn: int * endLine: int * endColumn: int -> ClientRange + val StartLine: int + val StartColumn: int + val EndLine: int + val EndColumn: int + end + +type ClientSuggestedFix = { + /// Text to be replaced. + FromText:string + + /// Location of the text to be replaced. + FromRange:ClientRange + + /// Text to replace the `FromText`, i.e. the fix. + ToText:string +} + +[] +type ClientWarningDetails = { + /// Location of the code that prompted the suggestion. + Range:ClientRange + + /// Suggestion message to describe the possible problem to the user. + Message:string + + /// Information to provide an automated fix. + SuggestedFix:ClientSuggestedFix option +} + +/// A lint "warning", sources the location of the warning with a suggestion on how it may be fixed. +[] +type ClientLintWarning = { + /// Unique identifier for the rule that caused the warning. + RuleIdentifier:string + + /// Unique name for the rule that caused the warning. + RuleName:string + + /// Path to the file where the error occurs. + FilePath:string + + /// Text that caused the error (the `Range` of the content of `FileName`). + ErrorText:string + + /// Details for the warning. + Details:ClientWarningDetails +} + type FSharpLintResult = | Content of string + | LintResult of ClientLintWarning list type FSharpLintResponse = { Code: int @@ -26,3 +88,4 @@ type FSharpLintService = inherit System.IDisposable abstract VersionAsync: VersionRequest * ?cancellationToken: CancellationToken -> Task + abstract LintFileAsync: LintFileRequest * ?cancellationToken: CancellationToken -> Task diff --git a/src/FSharpLint.Client/LSPFSharpLintService.fs b/src/FSharpLint.Client/LSPFSharpLintService.fs index 5b5df5b42..16ce1497e 100644 --- a/src/FSharpLint.Client/LSPFSharpLintService.fs +++ b/src/FSharpLint.Client/LSPFSharpLintService.fs @@ -257,3 +257,26 @@ type LSPFSharpLintService() = Result = Content t.Result FilePath = versionRequest.FilePath })) |> mapResultToResponse versionRequest.FilePath + + member _.LintFileAsync(lintFileRequest: LintFileRequest, ?cancellationToken: CancellationToken) : Task = + isCancellationRequested cts.IsCancellationRequested + |> Result.bind (getFolderFor (lintFileRequest.FilePath)) + |> Result.bind (getDaemon agent) + |> Result.map (fun client -> + client + .InvokeWithCancellationAsync>( + Methods.LintFile, + arguments = [| lintFileRequest |], + cancellationToken = Option.defaultValue cts.Token cancellationToken + ) + .ContinueWith(fun (t: Task>) -> + match t.Result with + | Ok lintResult -> + { Code = int FSharpLintResponseCode.OkLint + FilePath = lintFileRequest.FilePath + Result = LintResult lintResult } + | Error msg -> + { Code = int FSharpLintResponseCode.OkLintError + FilePath = lintFileRequest.FilePath + Result = Content msg })) + |> mapResultToResponse lintFileRequest.FilePath diff --git a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs index e308ef022..e19954808 100644 --- a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs +++ b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs @@ -12,6 +12,8 @@ type FSharpLintResponseCode = | ErrCancellationWasRequested = -2 | ErrDaemonCreationFailed = -1 | OkCurrentDaemonVersion = 0 + | OkLint = 1 + | OkLintError = 2 type FSharpLintVersion = FSharpLintVersion of string type FSharpLintExecutableFile = FSharpLintExecutableFile of string diff --git a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi index 9a1d13f57..9673e0716 100644 --- a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi +++ b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi @@ -7,6 +7,8 @@ type FSharpLintResponseCode = | ErrCancellationWasRequested = -2 | ErrDaemonCreationFailed = -1 | OkCurrentDaemonVersion = 0 + | OkLint = 1 + | OkLintError = 2 type FSharpLintVersion = FSharpLintVersion of string diff --git a/src/FSharpLint.Console/Daemon.fs b/src/FSharpLint.Console/Daemon.fs index 186c30212..0b8c77712 100644 --- a/src/FSharpLint.Console/Daemon.fs +++ b/src/FSharpLint.Console/Daemon.fs @@ -5,8 +5,30 @@ open System.Diagnostics open System.IO open System.Threading open StreamJsonRpc -open FSharpLint.Client.Contracts open FSharp.Core +open FSharpLint.Application +open Newtonsoft.Json +open FSharpLint.Client.Contracts + +let private toClientLintWarning (lintWarning: FSharpLint.Framework.Suggestion.LintWarning): ClientLintWarning = + { + ErrorText = lintWarning.ErrorText + FilePath = lintWarning.FilePath + RuleIdentifier = lintWarning.RuleIdentifier + RuleName = lintWarning.RuleName + Details = { + Range = ClientRange(lintWarning.Details.Range.StartLine, lintWarning.Details.Range.StartColumn, lintWarning.Details.Range.EndLine, lintWarning.Details.Range.EndColumn) + Message = lintWarning.Details.Message + SuggestedFix = + lintWarning.Details.SuggestedFix + |> Option.bind(fun fix -> fix.Value) + |> Option.map(fun fix -> { + FromRange = ClientRange(fix.FromRange.StartLine, fix.FromRange.StartColumn, fix.FromRange.EndLine, fix.FromRange.EndColumn) + FromText = fix.FromText + ToText = fix.ToText + }) + } + } type FSharpLintDaemon(sender: Stream, reader: Stream) as this = let rpc: JsonRpc = JsonRpc.Attach(sender, reader, this) @@ -33,3 +55,23 @@ type FSharpLintDaemon(sender: Stream, reader: Stream) as this = [] member _.Version() : string = FSharpLint.Console.Version.get () + + [] + member _.LintFile(request: LintFileRequest) : Result = + let lintConfig = + match request.LintConfigPath with + | Some path -> + { CancellationToken = None + ReceivedWarning = None + Configuration = FromFile path + ReportLinterProgress = None } + | None -> Lint.OptionalLintParameters.Default + + let lintResult = Lint.lintFile lintConfig (request.FilePath) + match lintResult with + | LintResult.Success warnings -> + let result = warnings |> List.map toClientLintWarning + Debug.Assert (JsonConvert.SerializeObject result <> "") + + Ok result + | LintResult.Failure failure -> Error failure.Description diff --git a/tests/FSharpLint.Client.Tests/TestClient.fs b/tests/FSharpLint.Client.Tests/TestClient.fs index 8d4c3df95..72ead1b3f 100644 --- a/tests/FSharpLint.Client.Tests/TestClient.fs +++ b/tests/FSharpLint.Client.Tests/TestClient.fs @@ -48,8 +48,20 @@ let runVersionCall filePath (service: FSharpLintService) = } |> Async.RunSynchronously +let runLintFileCall filePath (service: FSharpLintService) = + async { + let request = + { + FilePath = filePath + LintConfigPath = None + } + let! lintResult = service.LintFileAsync(request) |> Async.AwaitTask + return lintResult + } + |> Async.RunSynchronously + [] -let TestDaemonNotFound() = +let ``Daemon cannot be found``() = using (new ToolLocationOverride(ToolStatus.NotAvailable)) <| fun _ -> let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" @@ -59,7 +71,7 @@ let TestDaemonNotFound() = Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.ErrToolNotFound, versionResponse.Code) [] -let TestDaemonVersion() = +let ``Daemon answer with its version number``() = using (new ToolLocationOverride(ToolStatus.Available)) <| fun _ -> let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" @@ -68,12 +80,12 @@ let TestDaemonVersion() = match versionResponse.Result with | Content result -> Assert.IsFalse (String.IsNullOrWhiteSpace result) - // | _ -> Assert.Fail("Response should be a version number") + | _ -> Assert.Fail("Response should be a version number") Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.OkCurrentDaemonVersion, versionResponse.Code) [] -let TestFilePathShouldBeAbsolute() = +let ``Daemon cannot work with relative path``() = using (new ToolLocationOverride(ToolStatus.Available)) <| fun _ -> let testHintsFile = ".." "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" @@ -83,7 +95,7 @@ let TestFilePathShouldBeAbsolute() = Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.ErrFilePathIsNotAbsolute, versionResponse.Code) [] -let TestFileShouldExists() = +let ``Daemon cannot work with non-existing file``() = using (new ToolLocationOverride(ToolStatus.Available)) <| fun _ -> let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHintsOOOPS.fs" @@ -91,3 +103,17 @@ let TestFileShouldExists() = let versionResponse = runVersionCall testHintsFile fsharpLintService Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.ErrFileNotFound, versionResponse.Code) + +[] +let ``Daemon can lint a file with success``() = + using (new ToolLocationOverride(ToolStatus.Available)) <| fun _ -> + + let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" + let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService + let versionResponse = runLintFileCall testHintsFile fsharpLintService + + match versionResponse.Result with + | Content result -> Assert.Fail("Should be a lint result") + | LintResult warnings -> + Assert.IsNotEmpty warnings + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.OkLint, versionResponse.Code) From 8717a066a130367a26d27c0d49009d8fdd6da6d9 Mon Sep 17 00:00:00 2001 From: MrLuje Date: Tue, 2 Jan 2024 16:07:24 +0000 Subject: [PATCH 17/22] more tests --- tests/FSharpLint.Client.Tests/TestClient.fs | 14 ++++++++++++++ tests/FSharpLint.Client.Tests/UnparsableFile.fs | 6 ++++++ 2 files changed, 20 insertions(+) create mode 100644 tests/FSharpLint.Client.Tests/UnparsableFile.fs diff --git a/tests/FSharpLint.Client.Tests/TestClient.fs b/tests/FSharpLint.Client.Tests/TestClient.fs index 72ead1b3f..b665ede5a 100644 --- a/tests/FSharpLint.Client.Tests/TestClient.fs +++ b/tests/FSharpLint.Client.Tests/TestClient.fs @@ -117,3 +117,17 @@ let ``Daemon can lint a file with success``() = | LintResult warnings -> Assert.IsNotEmpty warnings Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.OkLint, versionResponse.Code) + +[] +let ``LintError if Daemon lint an unparsable file``() = + using (new ToolLocationOverride(ToolStatus.Available)) <| fun _ -> + + let testHintsFile = basePath "tests" "FSharpLint.Client.Tests" "UnparsableFile.fs" + let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService + let versionResponse = runLintFileCall testHintsFile fsharpLintService + + match versionResponse.Result with + | Content result -> Assert.Fail("Should be a lint result") + | LintResult warnings -> + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.OkLintError, versionResponse.Code) + Assert.IsEmpty warnings diff --git a/tests/FSharpLint.Client.Tests/UnparsableFile.fs b/tests/FSharpLint.Client.Tests/UnparsableFile.fs new file mode 100644 index 000000000..57351c2a3 --- /dev/null +++ b/tests/FSharpLint.Client.Tests/UnparsableFile.fs @@ -0,0 +1,6 @@ +module Test + +# Hello word + +## I'm a markdown (probably ?) +let test = System.IO.Fi le From 54fb34f40b59cfa18182c4b75b06caee2f324a8a Mon Sep 17 00:00:00 2001 From: MrLuje Date: Tue, 2 Jan 2024 16:07:24 +0000 Subject: [PATCH 18/22] ignore LintError test --- tests/FSharpLint.Client.Tests/TestClient.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/FSharpLint.Client.Tests/TestClient.fs b/tests/FSharpLint.Client.Tests/TestClient.fs index b665ede5a..10b317a64 100644 --- a/tests/FSharpLint.Client.Tests/TestClient.fs +++ b/tests/FSharpLint.Client.Tests/TestClient.fs @@ -118,7 +118,7 @@ let ``Daemon can lint a file with success``() = Assert.IsNotEmpty warnings Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.OkLint, versionResponse.Code) -[] +[] let ``LintError if Daemon lint an unparsable file``() = using (new ToolLocationOverride(ToolStatus.Available)) <| fun _ -> From 0b4f9b09d5e60bdea9a514eb34b7a49d2874e0f5 Mon Sep 17 00:00:00 2001 From: MrLuje Date: Tue, 2 Jan 2024 16:07:24 +0000 Subject: [PATCH 19/22] test: more Lint call properties --- tests/FSharpLint.Client.Tests/TestClient.fs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/FSharpLint.Client.Tests/TestClient.fs b/tests/FSharpLint.Client.Tests/TestClient.fs index 10b317a64..768d94de7 100644 --- a/tests/FSharpLint.Client.Tests/TestClient.fs +++ b/tests/FSharpLint.Client.Tests/TestClient.fs @@ -116,6 +116,21 @@ let ``Daemon can lint a file with success``() = | Content result -> Assert.Fail("Should be a lint result") | LintResult warnings -> Assert.IsNotEmpty warnings + + warnings + |> List.iter(fun warning -> + Assert.IsNotEmpty warning.RuleName + Assert.IsTrue (warning.RuleIdentifier.StartsWith("FL")) + Assert.IsNotEmpty warning.ErrorText + Assert.IsNotEmpty warning.FilePath + + Assert.IsNotEmpty warning.Details.Message + Assert.Positive warning.Details.Range.StartLine + Assert.Positive warning.Details.Range.StartColumn + Assert.Positive warning.Details.Range.EndLine + Assert.Positive warning.Details.Range.EndColumn + Assert.IsTrue <| Option.isSome warning.Details.SuggestedFix) + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.OkLint, versionResponse.Code) [] From 20de74218ac084c50ffef0cf5de69e128f0b6f6c Mon Sep 17 00:00:00 2001 From: MrLuje Date: Tue, 2 Jan 2024 16:07:24 +0000 Subject: [PATCH 20/22] WIP test --- tests/FSharpLint.Client.Tests/TestClient.fs | 32 +++++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/tests/FSharpLint.Client.Tests/TestClient.fs b/tests/FSharpLint.Client.Tests/TestClient.fs index 768d94de7..8ab253586 100644 --- a/tests/FSharpLint.Client.Tests/TestClient.fs +++ b/tests/FSharpLint.Client.Tests/TestClient.fs @@ -6,6 +6,8 @@ open System open Contracts open LSPFSharpLintService open LSPFSharpLintServiceTypes +open NUnit.Framework +open StreamJsonRpc let () x y = Path.Combine(x, y) @@ -15,15 +17,15 @@ let fsharpConsoleOutputDir = Path.GetFullPath (Path.GetDirectoryName(fsharpLintC [] type ToolStatus = | Available | NotAvailable -type ToolLocationOverride(toolStatus: ToolStatus) = +type ToolLocationOverride(toolStatus: ToolStatus, consoleDllPath: string) = let tempFolder = Path.GetTempFileName() do match toolStatus with - | ToolStatus.Available -> Environment.SetEnvironmentVariable("FSHARPLINT_SEARCH_PATH_OVERRIDE", fsharpConsoleOutputDir) + | ToolStatus.Available -> Environment.SetEnvironmentVariable("FSHARPLINT_SEARCH_PATH_OVERRIDE", consoleDllPath) | ToolStatus.NotAvailable -> let path = Environment.GetEnvironmentVariable("PATH") // ensure bin dir is not in path - if path.Contains(fsharpConsoleOutputDir, StringComparison.InvariantCultureIgnoreCase) then + if path.Contains(consoleDllPath, StringComparison.InvariantCultureIgnoreCase) then Assert.Inconclusive() File.Delete(tempFolder) @@ -37,6 +39,8 @@ type ToolLocationOverride(toolStatus: ToolStatus) = if File.Exists tempFolder then File.Delete tempFolder + new (toolStatus: ToolStatus) = new ToolLocationOverride(toolStatus, fsharpConsoleOutputDir) + let runVersionCall filePath (service: FSharpLintService) = async { let request = @@ -84,6 +88,20 @@ let ``Daemon answer with its version number``() = Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.OkCurrentDaemonVersion, versionResponse.Code) +[] +let ``[1] Daemon answer with its version number``() = + using (new ToolLocationOverride(ToolStatus.Available, "/home/vince/src/github/mrluje/FSharpLint.worktrees/rw/make_it_all_6/api_layer_version_net6.0")) <| fun _ -> + + let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" + let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService + let versionResponse = runVersionCall testHintsFile fsharpLintService + + match versionResponse.Result with + | Content result -> Assert.IsFalse (String.IsNullOrWhiteSpace result) + | _ -> Assert.Fail("Response should be a version number") + + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.OkCurrentDaemonVersion, versionResponse.Code) + [] let ``Daemon cannot work with relative path``() = using (new ToolLocationOverride(ToolStatus.Available)) <| fun _ -> @@ -133,6 +151,14 @@ let ``Daemon can lint a file with success``() = Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.OkLint, versionResponse.Code) +[] +let ``[1] Daemon doesn't know LintFile method``() = + using (new ToolLocationOverride(ToolStatus.Available, "/home/vince/src/github/mrluje/FSharpLint.worktrees/rw/make_it_all_6/api_layer_version_net6.0")) <| fun _ -> + + let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" + let fsharpLintService: FSharpLintService = new LSPFSharpLintService() :> FSharpLintService + Assert.Throws(fun () -> runLintFileCall testHintsFile fsharpLintService |> ignore) + [] let ``LintError if Daemon lint an unparsable file``() = using (new ToolLocationOverride(ToolStatus.Available)) <| fun _ -> From 319f84e2185e18d73d0f06ebbce5c86964bdc906 Mon Sep 17 00:00:00 2001 From: MrLuje Date: Wed, 3 Jan 2024 17:12:24 +0000 Subject: [PATCH 21/22] fix cancellationtoken --- src/FSharpLint.Console/Daemon.fs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/FSharpLint.Console/Daemon.fs b/src/FSharpLint.Console/Daemon.fs index 0b8c77712..60655e750 100644 --- a/src/FSharpLint.Console/Daemon.fs +++ b/src/FSharpLint.Console/Daemon.fs @@ -57,11 +57,11 @@ type FSharpLintDaemon(sender: Stream, reader: Stream) as this = member _.Version() : string = FSharpLint.Console.Version.get () [] - member _.LintFile(request: LintFileRequest) : Result = + member _.LintFile(request: LintFileRequest, cancellationToken: CancellationToken option) : Result = let lintConfig = match request.LintConfigPath with | Some path -> - { CancellationToken = None + { CancellationToken = cancellationToken ReceivedWarning = None Configuration = FromFile path ReportLinterProgress = None } From f671d459e20112aa9be898ae2e64fe2b54b4a48f Mon Sep 17 00:00:00 2001 From: MrLuje Date: Sun, 28 Jan 2024 16:20:16 +0000 Subject: [PATCH 22/22] fix: daemon cache ensure we can reuse an already found daemon version from another folder --- src/FSharpLint.Client/LSPFSharpLintService.fs | 83 ++++++++++--------- 1 file changed, 44 insertions(+), 39 deletions(-) diff --git a/src/FSharpLint.Client/LSPFSharpLintService.fs b/src/FSharpLint.Client/LSPFSharpLintService.fs index 16ce1497e..e8034d89d 100644 --- a/src/FSharpLint.Client/LSPFSharpLintService.fs +++ b/src/FSharpLint.Client/LSPFSharpLintService.fs @@ -29,6 +29,35 @@ type Msg = | Reset of AsyncReplyChannel let private createAgent (ct: CancellationToken) = + let tryGetVersionFromCache state version folder (replyChannel: AsyncReplyChannel>) = + let daemon = Map.tryFind version state.Daemons + + match daemon with + | Some daemon -> + // We have a daemon for the required version in the cache, check if we can still use it. + if daemon.Process.HasExited then + // weird situation where the process has crashed. + // Trying to reboot + (daemon :> IDisposable).Dispose() + + let newDaemonResult = createFor daemon.StartInfo + + match newDaemonResult with + | Ok newDaemon -> + replyChannel.Reply(Ok newDaemon.RpcClient) + + Some { FolderToVersion = Map.add folder version state.FolderToVersion + Daemons = Map.add version newDaemon state.Daemons } + | Error pse -> + replyChannel.Reply(Error(GetDaemonError.FSharpLintProcessStart pse)) + Some state + else + // return running client + replyChannel.Reply(Ok daemon.RpcClient) + + Some { state with FolderToVersion = Map.add folder version state.FolderToVersion } + | None -> None + MailboxProcessor.Start( (fun inbox -> let rec messageLoop (state: ServiceState) = @@ -41,44 +70,17 @@ let private createAgent (ct: CancellationToken) = // get the version for that folder // look in the cache first let versionFromCache = Map.tryFind folder state.FolderToVersion - match versionFromCache with | Some version -> - let daemon = Map.tryFind version state.Daemons - - match daemon with - | Some daemon -> - // We have a daemon for the required version in the cache, check if we can still use it. - if daemon.Process.HasExited then - // weird situation where the process has crashed. - // Trying to reboot - (daemon :> IDisposable).Dispose() - - let newDaemonResult = createFor daemon.StartInfo - - match newDaemonResult with - | Ok newDaemon -> - replyChannel.Reply(Ok newDaemon.RpcClient) - - { FolderToVersion = Map.add folder version state.FolderToVersion - Daemons = Map.add version newDaemon state.Daemons } - | Error pse -> - replyChannel.Reply(Error(GetDaemonError.FSharpLintProcessStart pse)) - state - else - // return running client - replyChannel.Reply(Ok daemon.RpcClient) - - { state with - FolderToVersion = Map.add folder version state.FolderToVersion } - | None -> + tryGetVersionFromCache state version folder replyChannel + |> Option.defaultWith(fun () -> // This is a strange situation, we know what version is linked to that folder but there is no daemon // The moment a version is added, is also the moment a daemon is re-used or created replyChannel.Reply( Error(GetDaemonError.CompatibleVersionIsKnownButNoDaemonIsRunning version) ) - state + state) | None -> // Try and find a version of fsharplint daemon for our current folder let fsharpLintToolResult: Result = @@ -86,17 +88,20 @@ let private createAgent (ct: CancellationToken) = match fsharpLintToolResult with | Ok(FSharpLintToolFound(version, startInfo)) -> - let createDaemonResult = createFor startInfo + tryGetVersionFromCache state version folder replyChannel + |> Option.defaultWith(fun () -> + let createDaemonResult = createFor startInfo - match createDaemonResult with - | Ok daemon -> - replyChannel.Reply(Ok daemon.RpcClient) + match createDaemonResult with + | Ok daemon -> + replyChannel.Reply(Ok daemon.RpcClient) - { Daemons = Map.add version daemon state.Daemons - FolderToVersion = Map.add folder version state.FolderToVersion } - | Error pse -> - replyChannel.Reply(Error(GetDaemonError.FSharpLintProcessStart pse)) - state + { Daemons = Map.add version daemon state.Daemons + FolderToVersion = Map.add folder version state.FolderToVersion } + | Error pse -> + replyChannel.Reply(Error(GetDaemonError.FSharpLintProcessStart pse)) + state + ) | Error FSharpLintToolError.NoCompatibleVersionFound -> replyChannel.Reply(Error GetDaemonError.InCompatibleVersionFound) state