diff --git a/Directory.Packages.props b/Directory.Packages.props index efe44081a..ea1ebaba3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,7 +23,9 @@ + + - \ No newline at end of file + diff --git a/FSharpLint.slnx b/FSharpLint.slnx index 2d3d31087..dbc22d421 100644 --- a/FSharpLint.slnx +++ b/FSharpLint.slnx @@ -97,10 +97,12 @@ + + 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..128f1ad5a --- /dev/null +++ b/src/FSharpLint.Client/FSharpLint.Client.fsproj @@ -0,0 +1,36 @@ + + + + net9.0;net8.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..5e5b496ec --- /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 List.Empty + 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 proc -> + proc.WaitForExit() + let exitCode = proc.ExitCode + + if exitCode = 0 then + let output = readOutputStreamAsLines proc.StandardOutput + Ok output + else + let error = proc.StandardError.ReadToEnd() + Error(DotNetToolListError.ExitCodeNonZero(ps.FileName, ps.Arguments, exitCode, error)) + | Error err -> Error(DotNetToolListError.ProcessStartError err) + +let private (|CompatibleTool|_|) lines = + let (|HeaderLine|_|) (line: String) = + 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 path -> path.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 proc -> + proc.WaitForExit() + let stdOut = proc.StandardOutput.ReadToEnd() + + stdOut + |> Option.ofObj + |> Option.bind (fun stdOut -> + if stdOut.Contains("Current version: ", StringComparison.CurrentCultureIgnoreCase) then + let version = stdOut.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..50ce1adb2 --- /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 version -> version + | Error FSharpLintServiceError.FileDoesNotExist -> fileNotFoundResponse filePath + | Error FSharpLintServiceError.FilePathIsNotAbsolute -> fileNotAbsoluteResponse filePath + | Error(FSharpLintServiceError.DaemonNotFound err) -> daemonNotFoundResponse filePath err + | 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 (task: Task) -> + { Code = int FSharpLintResponseCode.OkCurrentDaemonVersion + Result = Content task.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..adb2536a5 --- /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 file) = file + +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 folder) = folder + +[] +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.Console/Daemon.fs b/src/FSharpLint.Console/Daemon.fs new file mode 100644 index 000000000..b977eac0b --- /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 03c445115..a725ce9c3 100644 --- a/src/FSharpLint.Console/FSharpLint.Console.fsproj +++ b/src/FSharpLint.Console/FSharpLint.Console.fsproj @@ -1,4 +1,4 @@ - + net9.0;net8.0 @@ -27,6 +27,8 @@ + + @@ -37,6 +39,7 @@ + diff --git a/src/FSharpLint.Console/Program.fs b/src/FSharpLint.Console/Program.fs index b5e59f28a..3c74b7c99 100644 --- a/src/FSharpLint.Console/Program.fs +++ b/src/FSharpLint.Console/Program.fs @@ -6,6 +6,7 @@ open System.IO open System.Reflection open FSharpLint.Framework open FSharpLint.Application +open Daemon /// Output format the linter will use. type private OutputFormat = @@ -26,6 +27,7 @@ type private ToolArgs = | [] Format of OutputFormat | [] Lint of ParseResults | Version + | Daemon with interface IArgParserTemplate with member this.Usage = @@ -33,6 +35,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 @@ -55,9 +58,9 @@ with let internal expandWildcard (pattern:string) = let isFSharpFile (filePath:string) = filePath.EndsWith ".fs" || filePath.EndsWith ".fsx" - + let normalizedPattern = pattern.Replace('\\', '/') - + let directory, searchPattern, searchOption = match normalizedPattern.IndexOf "**/" with | -1 -> @@ -77,7 +80,7 @@ let internal expandWildcard (pattern:string) = let dir = normalizedPattern.Substring(0, doubleStarIndex).TrimEnd '/' let pat = normalizedPattern.Substring(doubleStarIndex + 3) (dir, pat, SearchOption.AllDirectories) - + let fullDirectory = Path.GetFullPath directory if Directory.Exists fullDirectory then Directory.GetFiles(fullDirectory, searchPattern, searchOption) @@ -124,12 +127,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 () output.WriteInfo $"Current version: {version}" Environment.Exit 0 + 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/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..ef82c8172 --- /dev/null +++ b/tests/FSharpLint.Client.Tests/FSharpLint.Client.Tests.fsproj @@ -0,0 +1,24 @@ + + + + net9.0 + false + + + + + + + + + + + + + + + + + + + diff --git a/tests/FSharpLint.Client.Tests/ReferenceTests.fs b/tests/FSharpLint.Client.Tests/ReferenceTests.fs new file mode 100644 index 000000000..f5e400e2a --- /dev/null +++ b/tests/FSharpLint.Client.Tests/ReferenceTests.fs @@ -0,0 +1,15 @@ +module FSharpLint.Client.ReferenceTests + +open NUnit.Framework +open System.IO +open System +open System.Runtime.Remoting + +[] +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..89753d386 --- /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 () path1 path2 = Path.Combine(path1, path2) + +let basePath = TestContext.CurrentContext.TestDirectory ".." ".." ".." ".." ".." +let fsharpLintConsoleDll = basePath "src" "FSharpLint.Console" "bin" "Release" "net9.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)