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)