Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
}

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>
254 changes: 254 additions & 0 deletions src/FSharpLint.Client/FSharpLintToolLocator.fs
Original file line number Diff line number Diff line change
@@ -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 [<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 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<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 ->
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