Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
67625f0
add FSharpLint.Client project
MrLuje Dec 17, 2023
86288de
add client tests
MrLuje Dec 17, 2023
d09fd32
cleanup TestDaemonVersion
MrLuje Dec 17, 2023
51adb1d
update README.md
MrLuje Dec 18, 2023
99a252a
remove reference from FSharpLint.Client to FSharpLint.Core (which inc…
MrLuje Dec 23, 2023
a7de738
test: ensure FCS is not referenced by FSharpLint.Client
MrLuje Dec 25, 2023
839ee03
add FSHARPLINT_SEARCH_PATH_OVERRIDE env var to override search location
MrLuje Dec 26, 2023
8d4250f
PR feedback: -g -> --global
MrLuje Dec 29, 2023
1a2c2e3
PR feedback: simplify DOTNET_CLI_UI_LANGUAGE env var usage
MrLuje Dec 29, 2023
c7670d9
PR feedback: clearer FSharpLintResponseCode
MrLuje Dec 30, 2023
8cf9426
PR feedback: Folder check
MrLuje Dec 30, 2023
261c643
PR feedback: UnexpectedException
MrLuje Dec 31, 2023
49a5332
PR feedback: comment about Path.GetFullPath
MrLuje Dec 31, 2023
ee4f419
PR feedback: Path.GetFullPath readability
MrLuje Jan 2, 2024
2653e11
PR feedback: reuse DirectoryInfo instance
MrLuje Jan 6, 2024
1cc9edb
Сlient: introduce File type
webwarrior-ws Jan 22, 2024
776f736
FL0084
MrLuje Feb 2, 2024
24963dc
FL0043
MrLuje Feb 2, 2024
a5dba0f
FL0055
MrLuje Feb 2, 2024
bd88d1a
fix more rules
MrLuje Feb 3, 2024
363292d
PR feedback: Folder FromFile/FromFolder
MrLuje Feb 15, 2024
9d5c57c
WIP: remove disable-next-line RedundantNewKeyword
Mersho Feb 6, 2024
4a98681
fix more rules
MrLuje Dec 11, 2025
a2378c6
fix: daemon cache
MrLuje Jan 28, 2024
a3429a0
FL0022
MrLuje Dec 30, 2025
80b3462
PR feedbacks
MrLuje Dec 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<PackageVersion Include="NUnit" Version="3.14.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="5.0.0" />
<PackageVersion Include="SemanticVersioning" Version="2.0.2" />
<PackageVersion Include="StreamJsonRpc" Version="2.8.28" />
<PackageVersion Include="System.Reactive" Version="6.0.1" />
<PackageVersion Include="System.Text.Json" Version="9.0.0" />
</ItemGroup>
</Project>
</Project>
2 changes: 2 additions & 0 deletions FSharpLint.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,12 @@
</Folder>
<Folder Name="/src/">
<Project Path="src/FSharpLint.Console/FSharpLint.Console.fsproj" />
<Project Path="src/FSharpLint.Client/FSharpLint.Client.fsproj" />
<Project Path="src/FSharpLint.Core/FSharpLint.Core.fsproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/FSharpLint.Benchmarks/FSharpLint.Benchmarks.fsproj" />
<Project Path="tests/FSharpLint.Client.Tests/FSharpLint.Client.Tests.fsproj" />
<Project Path="tests/FSharpLint.Console.Tests/FSharpLint.Console.Tests.fsproj" />
<Project Path="tests/FSharpLint.Core.Tests/FSharpLint.Core.Tests.fsproj" />
<Project Path="tests/FSharpLint.FunctionalTest/FSharpLint.FunctionalTest.fsproj" />
Expand Down
31 changes: 31 additions & 0 deletions src/FSharpLint.Client/Contracts.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
module FSharpLint.Client.Contracts

open System
open System.Threading
open System.Threading.Tasks

[<RequireQualifiedAccess>]
module Methods =
[<Literal>]
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<FSharpLintResponse>
end
28 changes: 28 additions & 0 deletions src/FSharpLint.Client/Contracts.fsi
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
module FSharpLint.Client.Contracts

open System.Threading
open System.Threading.Tasks

module Methods =

[<Literal>]
val Version: string = "fsharplint/version"

type VersionRequest =
{
FilePath: string
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MrLuje the consistency-chaser in me is wanting to suggest that we create here a similar type to Folder, e.g. File which uses FileInfo underneath (instead of DirectoryInfo)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm all for consistency but IMHO there is less value for this one :

  • VersionRequest is one of the API entrypoint (whereas Folder was only used internally) and it may be less intuitive to use
  • File check is already covered by ErrFileNotFound, so we may want to change it if we go with the FileInfo way

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it has less value, but I'm just allergic to the PrimitiveObsession anti-pattern (since I came to know the type FileInfo, seeing a string for file paths makes my eyes bleed haha). I can make the change myself, I'll push to your branch yeah?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@knocte sure, go for it :)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MrLuje sure, I'll do it this week. BTW can you rebase the PR? looks like there is some conflict

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@knocte done

}

type FSharpLintResult =
| Content of string

type FSharpLintResponse = {
Code: int
FilePath: string
Result : FSharpLintResult
}

type IFSharpLintService =
inherit System.IDisposable

abstract VersionAsync: VersionRequest * ?cancellationToken: CancellationToken -> Task<FSharpLintResponse>
36 changes: 36 additions & 0 deletions src/FSharpLint.Client/FSharpLint.Client.fsproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net9.0;net8.0</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<IsPackable>true</IsPackable>
<RootNamespace>FSharpLint.Client</RootNamespace>
<EnableDefaultItems>false</EnableDefaultItems>
<Title>FSharpLint.Client</Title>
<Description>Companion library to format using FSharpLint tool.</Description>
<PackageTags>F#;fsharp;lint;FSharpLint;fslint;api</PackageTags>
</PropertyGroup>

<ItemGroup>
<Compile Include="Contracts.fsi" />
<Compile Include="Contracts.fs" />
<Compile Include="LSPFSharpLintServiceTypes.fsi" />
<Compile Include="LSPFSharpLintServiceTypes.fs" />
<Compile Include="FSharpLintToolLocator.fsi" />
<Compile Include="FSharpLintToolLocator.fs" />
<Compile Include="LSPFSharpLintService.fsi" />
<Compile Include="LSPFSharpLintService.fs" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\FSharpLint.Core\FSharpLint.Core.fsproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="FSharp.Core" />
<PackageReference Include="SemanticVersioning" />
<PackageReference Include="StreamJsonRpc" />
<PackageReference Include="System.Text.Json" />
</ItemGroup>

</Project>
256 changes: 256 additions & 0 deletions src/FSharpLint.Client/FSharpLintToolLocator.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
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 [<Literal>] 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<Process, ProcessStartError> =
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<string list, DotNetToolListError> =
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)

let ps = ProcessStartInfo(
"dotnet",
Arguments = $"tool list %s{toolArguments}",
WorkingDirectory = Folder.Unwrap workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
)
ps.EnvironmentVariables.["DOTNET_CLI_UI_LANGUAGE"] <- "en-us" //ensure we have predictible output for parsing

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([| Path.PathSeparator |], StringSplitOptions.RemoveEmptyEntries)
| None -> Array.empty
|> Seq.choose (fun folder ->
let fsharpLint =
if isWindows then Path.Combine(folder, $"{FSharpLintToolName}.exe")
else Path.Combine(folder, FSharpLintToolName)
if File.Exists fsharpLint then Some fsharpLint
else None)
|> Seq.tryHead
|> Option.bind File.From

let extractFsharpLintVersion 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

fsharpLintExecutableOnPathOpt
|> Option.bind extractFsharpLintVersion

let findFSharpLintTool (workingDir: Folder) : Result<FSharpLintToolFound, FSharpLintToolError> =
// 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<RunningFSharpLintTool, ProcessStartError> =
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<string>(FSharpLint.Client.Contracts.Methods.Version)
|> Async.AwaitTask
|> Async.RunSynchronously

Ok
{ RpcClient = client
Process = daemonProcess
StartInfo = startInfo }
with ex ->
Console.Error.WriteLine(ex.ToString())

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
7 changes: 7 additions & 0 deletions src/FSharpLint.Client/FSharpLintToolLocator.fsi
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module FSharpLint.Client.FSharpLintToolLocator

open FSharpLint.Client.LSPFSharpLintServiceTypes

val findFSharpLintTool: workingDir: Folder -> Result<FSharpLintToolFound, FSharpLintToolError>

val createFor: startInfo: FSharpLintToolStartInfo -> Result<RunningFSharpLintTool, ProcessStartError>
Loading
Loading