diff --git a/FSharpLint.sln b/FSharpLint.sln index 95809fec0..1d87d0c3e 100644 --- a/FSharpLint.sln +++ b/FSharpLint.sln @@ -112,6 +112,10 @@ 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 +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 @@ -194,6 +198,30 @@ 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 + {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 @@ -207,6 +235,8 @@ 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} + {72A7ED5D-8279-4375-B5EA-EFF9C33DD280} = {1CD44876-BCDC-4C93-9DC2-C45244BD62AE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B54B4B7D-F019-48A3-BB5B-635B68FE41C3} diff --git a/paket.dependencies b/paket.dependencies index 2fd7e9b44..edbf91b17 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 5377de1a0..79ed9c675 100644 --- a/paket.lock +++ b/paket.lock @@ -80,7 +80,17 @@ NUGET Ionide.ProjInfo.Sln (>= 0.61.3) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0)) Newtonsoft.Json (>= 13.0.1) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0)) Ionide.ProjInfo.Sln (0.61.3) - 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) @@ -569,7 +618,6 @@ NUGET System.Security.Cryptography.Primitives (>= 4.3) System.Text.Encoding (>= 4.3) System.Security.Cryptography.Cng (5.0) - copy_local: false - System.Formats.Asn1 (>= 5.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.0)) System.Security.Cryptography.Csp (4.3) Microsoft.NETCore.Platforms (>= 1.1) System.IO (>= 4.3) @@ -675,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..dba1457ab --- /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 IFSharpLintService = + 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..9c7cc2174 --- /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 IFSharpLintService = + 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..88a02a6b3 --- /dev/null +++ b/src/FSharpLint.Client/FSharpLint.Client.fsproj @@ -0,0 +1,23 @@ + + + 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..9a5b85447 --- /dev/null +++ b/src/FSharpLint.Client/FSharpLintToolLocator.fs @@ -0,0 +1,254 @@ +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 (workingDir: Folder) (globalFlag: bool) : Result = + let ps = ProcessStartInfo("dotnet") + ps.WorkingDirectory <- Folder.Unwrap workingDir + 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") + |> Option.map(fun env -> $" --tool-path %s{env}") + |> Option.defaultValue (if globalFlag then "--global" else String.Empty) + + ps.CreateNoWindow <- true + ps.Arguments <- $"tool list %s{toolArguments}" + 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 = + 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 -> + 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 + |> Option.bind File.From + + fsharpLintExecutableOnPathOpt + |> Option.bind (fun fsharpLintExecutablePath -> + let processStart = ProcessStartInfo( + FileName = File.Unwrap fsharpLintExecutablePath, + Arguments = "--version", + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + 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(workingDirectory: Folder) -> + ProcessStartInfo( + FileName = "dotnet", + WorkingDirectory = Folder.Unwrap workingDirectory, + Arguments = $"{FSharpLintToolName} --daemon") + | 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) + + ProcessStartInfo( + FileName = fsharpLintExecutable, + Arguments = "--daemon") + | FSharpLintToolStartInfo.ToolOnPath(FSharpLintExecutableFile executableFile) -> + ProcessStartInfo( + FileName = File.Unwrap executableFile, + Arguments = "--daemon") + + 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..85da8cc6d --- /dev/null +++ b/src/FSharpLint.Client/LSPFSharpLintService.fs @@ -0,0 +1,259 @@ +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 + else match Folder.FromFile filePath with + | None -> Error FSharpLintServiceError.FileDoesNotExist + | Some folder -> Ok folder + + 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.ErrFileNotFound + FilePath = filePath + Result = Content $"File \"%s{filePath}\" does not exist." + } + |> Task.FromResult + +let private fileNotAbsoluteResponse filePath : Task = + { Code = int FSharpLintResponseCode.ErrFilePathIsNotAbsolute + 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.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.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.ErrDaemonCreationFailed + | GetDaemonError.InCompatibleVersionFound -> + "FSharpLint.Client did not found a compatible dotnet tool version to launch as daemon process", + FSharpLintResponseCode.ErrToolNotFound + | GetDaemonError.CompatibleVersionIsKnownButNoDaemonIsRunning(FSharpLintVersion version) -> + $"FSharpLint.Client found a compatible version `%s{version}` but no daemon could be launched.", + FSharpLintResponseCode.ErrDaemonCreationFailed + + { Code = int code + FilePath = filePath + Result = Content content + } + |> Task.FromResult + +let private cancellationWasRequestedResponse filePath : Task = + { Code = int FSharpLintResponseCode.ErrCancellationWasRequested + 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 IFSharpLintService with + member this.Dispose() = + if not cts.IsCancellationRequested then + agent.PostAndReply Reset |> ignore + 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.OkCurrentDaemonVersion + 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..3d8bf3ac5 --- /dev/null +++ b/src/FSharpLint.Client/LSPFSharpLintService.fsi @@ -0,0 +1,6 @@ +module FSharpLint.Client.LSPFSharpLintService + +type LSPFSharpLintService = + interface Contracts.IFSharpLintService + + new: unit -> LSPFSharpLintService diff --git a/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs new file mode 100644 index 000000000..8122618ec --- /dev/null +++ b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fs @@ -0,0 +1,86 @@ +module FSharpLint.Client.LSPFSharpLintServiceTypes + +open System +open System.Diagnostics +open System.IO +open StreamJsonRpc + +type FSharpLintResponseCode = + | ErrToolNotFound = -5 + | ErrFileNotFound = -4 + | ErrFilePathIsNotAbsolute = -3 + | ErrCancellationWasRequested = -2 + | ErrDaemonCreationFailed = -1 + | OkCurrentDaemonVersion = 0 + +type File = private File of string +with + static member From (filePath: string) = + if File.Exists(filePath) then + filePath |> File |> Some + else + None + + static member Unwrap(File f) = f + +type FSharpLintVersion = FSharpLintVersion of string +type FSharpLintExecutableFile = FSharpLintExecutableFile of File +type Folder = private Folder of string +with + static member FromFile (filePath: string) = + if File.Exists(filePath) then + let folder = (FileInfo filePath).Directory + if folder.Exists then + folder.FullName |> Folder |> Some + else + None + else + None + static member FromFolder (folderPath: string) = + if Directory.Exists(folderPath) then + let folder = DirectoryInfo folderPath + folder.FullName |> Folder |> Some + else + None + static member Unwrap(Folder f) = f + +[] +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..677c7c2ff --- /dev/null +++ b/src/FSharpLint.Client/LSPFSharpLintServiceTypes.fsi @@ -0,0 +1,59 @@ +module FSharpLint.Client.LSPFSharpLintServiceTypes + +type FSharpLintResponseCode = + | ErrToolNotFound = -5 + | ErrFileNotFound = -4 + | ErrFilePathIsNotAbsolute = -3 + | ErrCancellationWasRequested = -2 + | ErrDaemonCreationFailed = -1 + | OkCurrentDaemonVersion = 0 + +type File = private File of string +with + static member From: string -> File option + static member Unwrap: File -> string + +type FSharpLintVersion = FSharpLintVersion of string + +type FSharpLintExecutableFile = FSharpLintExecutableFile of File + +type Folder = private Folder of string +with + static member FromFile: string -> Folder option + static member FromFolder: string -> Folder option + static member Unwrap: Folder -> 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..bd8a77eb9 --- /dev/null +++ b/src/FSharpLint.Client/paket.references @@ -0,0 +1,4 @@ +FSharp.Core +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 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/). 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..62a9ecab6 --- /dev/null +++ b/tests/FSharpLint.Client.Tests/FSharpLint.Client.Tests.fsproj @@ -0,0 +1,14 @@ + + + net6.0 + false + + + + + + + + + + \ No newline at end of file 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 diff --git a/tests/FSharpLint.Client.Tests/TestClient.fs b/tests/FSharpLint.Client.Tests/TestClient.fs new file mode 100644 index 000000000..18d9db26a --- /dev/null +++ b/tests/FSharpLint.Client.Tests/TestClient.fs @@ -0,0 +1,93 @@ +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)) + +[] +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: IFSharpLintService) = + async { + let request = + { + FilePath = filePath + } + let! version = service.VersionAsync(request) |> Async.AwaitTask + return version + } + |> Async.RunSynchronously + +[] +let TestDaemonNotFound() = + using (new ToolLocationOverride(ToolStatus.NotAvailable)) <| fun _ -> + + let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" + let fsharpLintService: IFSharpLintService = new LSPFSharpLintService() :> IFSharpLintService + let versionResponse = runVersionCall testHintsFile fsharpLintService + + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.ErrToolNotFound, versionResponse.Code) + +[] +let TestDaemonVersion() = + using (new ToolLocationOverride(ToolStatus.Available)) <| fun _ -> + + let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" + let fsharpLintService: IFSharpLintService = new LSPFSharpLintService() :> IFSharpLintService + 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 TestFilePathShouldBeAbsolute() = + using (new ToolLocationOverride(ToolStatus.Available)) <| fun _ -> + + let testHintsFile = ".." "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHints.fs" + let fsharpLintService: IFSharpLintService = new LSPFSharpLintService() :> IFSharpLintService + let versionResponse = runVersionCall testHintsFile fsharpLintService + + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.ErrFilePathIsNotAbsolute, versionResponse.Code) + +[] +let TestFileShouldExists() = + using (new ToolLocationOverride(ToolStatus.Available)) <| fun _ -> + + let testHintsFile = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" "TestHintsOOOPS.fs" + let fsharpLintService: IFSharpLintService = new LSPFSharpLintService() :> IFSharpLintService + let versionResponse = runVersionCall testHintsFile fsharpLintService + + Assert.AreEqual(LanguagePrimitives.EnumToValue FSharpLintResponseCode.ErrFileNotFound, 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