Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 2 deletions coverage-thresholds.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"default_threshold": 95,
"projects": {
"sharplsp": {
"threshold": 95.8,
"threshold": 96.25,
"language": "rust"
},
"sharplsp-sidecar-common": {
Expand All @@ -20,7 +20,7 @@
"language": "dotnet"
},
"vscode-extension": {
"threshold": 96.96,
"threshold": 97.4,
"language": "typescript"
},
"sharplsp-zed": {
Expand Down
1 change: 1 addition & 0 deletions docs/specs/SCRIPTING-FILEBASED-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ Two file-based apps in one directory are two independent compilations. The sidec

- A file with no supported document kind returns a `Result` failure, never an empty synthetic workspace.
- A directory with no solution or project is valid: `OpenCoreAsync` records a project-less root and returns success, deferring workspace creation to the first document update. Each loose file becomes an independent ad-hoc project per [SCRIPT-ANTIPATTERN]. `IsLoaded` remains false until a document arrives.
- The F# sidecar opens a directory or selection with no F# project as an empty workspace and returns success. A script that no loaded project compiles is never project-owned ([SCRIPT-DETECT]): hover, completion, navigation and the other semantic queries check it in its own context from its own directives ([SCRIPT-FSX-OPTIONS]), whether no workspace loaded or a project beside it does not list it (#497).
- Multiple candidate solutions are ambiguity, not project absence. `SolutionLoader.FindAmbiguousSolutions` MUST return an error naming every candidate and the `csharp.solution_path` resolution setting from [SHARPLSP-ARCHITECTURE-PROJECTS-SOLUTION-PATH], never enter project-less mode.
- Any I/O during closure expansion is wrapped; a failure to read one included file degrades that file only and is reported as a diagnostic, leaving the rest of the closure loaded.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2333,17 +2333,18 @@ let ``WS2 getDocumentHighlights returns empty for a file that cannot be read`` (
}

[<Fact>]
let ``WS2 loadProject reports an error for a directory with no fsproj`` () = task {
// discoverFsprojFiles finds zero .fsproj under a directory, so
// loadFirstProject returns the "No .fsproj found" error branch.
let ``WS2 loadProject opens an empty workspace for a directory with no fsproj`` () = task {
// discoverFsprojFiles finds zero .fsproj under a directory: a valid project-less
// workspace whose scripts answer from their own context. [SCRIPT-DEGRADE]
let dir = NativePaths.Temp($"sharplsp-empty2-{Guid.NewGuid():N}")
Directory.CreateDirectory(dir) |> ignore
let state = FSharpWorkspace.create ()
try
let! result = FSharpWorkspaceLoading.loadProject state dir
match result with
| Error msg -> Assert.Contains("No .fsproj", msg)
| Ok () -> Assert.Fail("expected an error for a project-less directory")
Assert.True(Result.isOk result, $"a project-less directory is a valid workspace, got {result}")
Assert.False(state.IsLoaded, "no project is loaded")
Assert.True(state.LastLoadWasEmpty, "the load is recorded as empty")
Assert.Empty(state.Projects)
finally
cleanup dir
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,15 +140,17 @@ let ``loadProject discovers the fsproj through an explicit slnx`` () =
}

[<Fact>]
let ``loadProject on a solution with no fsproj reports an error`` () =
let ``loadProject on a solution with no fsproj opens an empty workspace`` () =
task {
let dir = NativePaths.Temp($"slsp-emptysln-{System.Guid.NewGuid():N}")
Directory.CreateDirectory(dir) |> ignore
let slnx = NativePaths.Resolve(dir, "Empty.slnx")
File.WriteAllText(slnx, "<Solution>\n</Solution>\n")
let ws = FSharpWorkspace.create ()
let! result = FSharpWorkspaceLoading.loadProject ws slnx
Assert.True(match result with | Error _ -> true | Ok _ -> false)
Assert.True(Result.isOk result, $"a selection with no F# project clears the workspace, got {result}")
Assert.False(ws.IsLoaded, "no project is loaded")
Assert.True(ws.LastLoadWasEmpty, "the selection is recorded as empty")
try Directory.Delete(dir, true) with _ -> ()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ let ``cracking a real fsproj carries OtherFlags to FCS and drops unexpanded prop

try
let fsproj = writeProject dir "CrackedA" FLAGS_WITHOUT_OUTPUT [||]
let flags = FSharpWorkspace.parseFsprojOtherFlags fsproj
let flags = FSharpProjectLoading.parseFsprojOtherFlags fsproj

Assert.Equal<string array>([| "--nowarn:52"; "--warnon:1182" |], flags)
Assert.DoesNotContain(flags, fun flag -> flag.StartsWith("$(", StringComparison.Ordinal))
Expand Down Expand Up @@ -119,7 +119,7 @@ let ``compile items become the FCS source list in declaration order`` () = task

[<Fact>]
let ``framework reference args pin the runtime instead of the desktop profile`` () =
let args = FSharpWorkspace.frameworkReferenceArgs ()
let args = FSharpProjectLoading.frameworkReferenceArgs ()

Assert.Contains("--noframework", args)
Assert.Contains("--targetprofile:netcore", args)
Expand Down Expand Up @@ -244,7 +244,7 @@ let private assertEditableInputs state root project name = task {
let reference = NativePaths.Resolve(root, "Neighbour", "Neighbour.csproj")
let! evaluated = FSharpWorkspace.compileSources state project CancellationToken.None
assertExactOrRefused project source evaluated
Assert.Equal<string array>([| "--nowarn:52"; "--warnon:1182" |], FSharpWorkspace.parseFsprojOtherFlags project)
Assert.Equal<string array>([| "--nowarn:52"; "--warnon:1182" |], FSharpProjectLoading.parseFsprojOtherFlags project)
Assert.Equal(name, FSharpWorkspace.parseFsprojAssemblyName project)
Assert.False(FSharpDesignTime.declaresTargetFrameworks project)
Assert.Equal<string array>([| reference |], ProjectReferences.ReadReferencedProjects(project) |> Seq.toArray)
Expand Down
54 changes: 54 additions & 0 deletions src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpScriptTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module SharpLsp.Sidecar.FSharp.Tests.FSharpScriptTests

open System
open System.IO
open System.Threading
open Xunit
open SharpLsp.Sidecar.FSharp
open SharpLsp.Sidecar.Common
Expand Down Expand Up @@ -87,3 +88,56 @@ let ``fsx defines INTERACTIVE and EDITING but not COMPILED`` () =
Assert.DoesNotContain("--define:COMPILED", otherOptions)
Directory.Delete(dir, true)
}

/// The markdown a hover at `line`, `character` of `file` shows; empty when none.
let private hoverText ws file line character =
task {
let! hover = FSharpWorkspace.getHover ws file line character
return hover |> Option.map (fun (markdown, _, _, _, _) -> markdown) |> Option.defaultValue ""
}

/// A folder holding only a script is a valid workspace, and the script answers from
/// its own context: an `.fsx` is never project-owned. Implements [SCRIPT-DEGRADE],
/// [SCRIPT-DETECT] and [SCRIPT-FSX-OPTIONS] (GitHub #497).
[<Fact>]
let ``a script in a folder with no project answers hover and completion`` () =
task {
let dir = tempDir ()
let script = write dir "build.fsx" "let square x = x * x\nlet nine = square 3\n"
let ws = FSharpWorkspace.create ()

let! opened = FSharpWorkspaceLoading.loadWorkspaceWithCancellation ws dir false CancellationToken.None
Assert.True(Result.isOk opened, $"a folder with no project is a valid workspace, got {opened}")

let! hover = hoverText ws script 1 13
Assert.Contains("square", hover)
Assert.Contains("int -> int", hover)
let! completions = FSharpCompletion.getCompletions ws script 1 13
Assert.Contains("square", completions |> List.map _.Label)
Directory.Delete(dir, true)
}

/// A project in the folder compiles only its own files: a script beside it that it
/// does not list answers from the script's own context, never the project's
/// (GitHub #497).
[<Fact>]
let ``a script beside a project that does not compile it answers from its own context`` () =
task {
let dir = tempDir ()
let project = "<Project Sdk=\"Microsoft.NET.Sdk\"><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup><ItemGroup><Compile Include=\"Library.fs\" /></ItemGroup></Project>"
write dir "App.fsproj" project |> ignore
let library = write dir "Library.fs" "module Library\nlet answer = 42\n"
let script = write dir "publish.fsx" "let triple x = x * 3\nlet nine = triple 3\n"
let ws = FSharpWorkspace.create ()

let! opened = FSharpWorkspaceLoading.loadWorkspaceWithCancellation ws dir true CancellationToken.None
Assert.True(Result.isOk opened, $"the project loads, got {opened}")
Assert.True(ws.IsLoaded, "the project is the workspace")

let! scriptHover = hoverText ws script 1 13
Assert.Contains("triple", scriptHover)
Assert.Contains("int -> int", scriptHover)
let! libraryHover = hoverText ws library 1 5
Assert.Contains("answer", libraryHover)
Directory.Delete(dir, true)
}
Original file line number Diff line number Diff line change
Expand Up @@ -1214,15 +1214,15 @@ let ``workspace returns None for all queries when not loaded`` () = task {
}

[<Fact>]
let ``loadProject with missing fsproj returns error`` () = task {
let ``loadProject on a directory with no fsproj opens an empty workspace`` () = task {
let st = FSharpWorkspace.create ()
let empty = NativePaths.Temp($"sharplsp-empty-{Guid.NewGuid():N}")
Directory.CreateDirectory(empty) |> ignore
try
let! r = FSharpWorkspaceLoading.loadProject st empty
match r with
| Error msg -> Assert.Contains("No .fsproj", msg)
| Ok () -> Assert.Fail("Should fail for missing .fsproj")
Assert.True(Result.isOk r, $"a directory with no .fsproj is a valid workspace, got {r}")
Assert.False(st.IsLoaded, "no project is loaded")
Assert.True(st.ProjectOptions.IsNone, "and no project's options stand in for one")
finally
try Directory.Delete(empty, true) with _ -> ()
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,9 @@ let private scopeChanged previous (current: FSharpProjectOptions) =
/// [HOVER-FSHARP-OVERLAY]
let private scriptOptions (state: FSharpWorkspace.FSharpWorkspaceState) (root, persist, previous) ct =
task {
let! input = FSharpWorkspaceRuntime.tryReadSourceAsync state.Overlays root ct
let source = input |> Option.defaultValue System.String.Empty
let! options, diagnostics = FSharpProjectLoading.scriptOptions state.Checker root source ct
let! options, diagnostics, sourceLength = FSharpProjectLoading.scriptOptionsFromEditor state.Checker state.Overlays root ct
Serilog.Log.Debug("Script compiler inputs resolved: {SourceLength}, {DiagnosticCount}, {SourceFileCount}",
source.Length, diagnostics.Length, options.SourceFiles.Length)
sourceLength, diagnostics.Length, options.SourceFiles.Length)
let changed = persist && scopeChanged previous options
if persist then state.ProjectOptions <- Some options
return options, diagnostics, changed
Expand Down
11 changes: 11 additions & 0 deletions src/sidecars/SharpLsp.Sidecar.FSharp/FSharpProjectLoading.fs
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,14 @@ let scriptOptions (checker: FSharpChecker) (scriptPath: string) (source: string)
useFsiAuxLib = true, useSdkRefs = true, assumeDotNetFramework = false)
return! Async.StartAsTask(computation, cancellationToken = ct)
}

/// The options of a script from the text the editor holds, else the text on disk,
/// with the resolution diagnostics and the length of the text resolved.
/// [SCRIPT-FSX-OPTIONS] [HOVER-FSHARP-OVERLAY]
let scriptOptionsFromEditor (checker: FSharpChecker) overlays (scriptPath: string) (ct: CancellationToken) =
task {
let! input = FSharpWorkspaceRuntime.tryReadSourceAsync overlays scriptPath ct
let source = input |> Option.defaultValue String.Empty
let! options, diagnostics = scriptOptions checker scriptPath source ct
return options, diagnostics, source.Length
}
30 changes: 22 additions & 8 deletions src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspace.fs
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,25 @@ let private interpretAnswer
Log.Debug("[F# Check] aborted; parse diagnostics: {@Diagnostics}", parseResults.Diagnostics)
None

/// A script no loaded project or script compiles: it is never project-owned, so it
/// checks in its own context, with or without a workspace. [SCRIPT-DETECT] (GitHub #497)
let internal isLooseScript (state: FSharpWorkspaceState) (filePath: string) =
FSharpProjectLoading.isScriptPath filePath
&& not (optionsFor state filePath |> Option.exists (fun options -> (FSharpWorkspaceRuntime.tryProjectSourcePath options filePath).IsSome))

/// Check a loose script under the options its own directives resolve. [SCRIPT-FSX-OPTIONS]
let private checkLooseScript (state: FSharpWorkspaceState) (filePath: string) ct =
task {
let! options, _, _ = FSharpProjectLoading.scriptOptionsFromEditor state.Checker state.Overlays filePath ct
let! parseResults, checkAnswer, source = parseAndCheckOnceWithCancellation state filePath options ct
return interpretAnswer parseResults checkAnswer source
}

let internal checkFileWithParseWithCancellation (state: FSharpWorkspaceState) (filePath: string) ct =
task {
if not state.IsLoaded then
if isLooseScript state filePath then
return! checkLooseScript state filePath ct
elif not state.IsLoaded then
return None
else
let projectPath = projectFilePath state filePath
Expand All @@ -322,7 +338,11 @@ let internal checkFileWithParse state filePath =

let internal checkFileWithSource state filePath source =
task {
if not state.IsLoaded then
if isLooseScript state filePath then
let! options, _ = FSharpProjectLoading.scriptOptions state.Checker filePath source CancellationToken.None
let! parseResults, checkAnswer = state.Checker.ParseAndCheckFileInProject(filePath, 0, SourceText.ofString source, options)
return interpretAnswer parseResults checkAnswer source
elif not state.IsLoaded then
return None
else
let projectPath = projectFilePath state filePath
Expand All @@ -347,15 +367,9 @@ let internal checkFileWithCancellation (state: FSharpWorkspaceState) (filePath:
let internal checkFile state filePath =
checkFileWithCancellation state filePath CancellationToken.None

let internal parseFsprojOtherFlags path =
FSharpProjectLoading.parseFsprojOtherFlags path

let internal parseFsprojAssemblyName path =
FSharpProjectLoading.parseFsprojAssemblyName path

let internal frameworkReferenceArgs () =
FSharpProjectLoading.frameworkReferenceArgs ()

/// Forget `entry` once it fails or is cancelled, so a later request retries; the
/// value-matched removal never discards an evaluation a reload stored since. It runs
/// as the evaluation completes, before its creator resumes, so a caller that saw the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ let private clearWorkspace (state: FSharpWorkspaceState) =
let private loadProjects (state: FSharpWorkspaceState) (fsprojFiles: string array) autoRestore ct =
task {
if fsprojFiles.Length = 0 then
// A folder with no F# project is a valid workspace: each script in it
// answers from its own context. [SCRIPT-DEGRADE] [SCRIPT-DETECT]
clearWorkspace state
return Error "No .fsproj found"
Log.Debug("F# workspace has no F# projects")
return Ok()
else
try
state.CompileSources.Clear()
Expand Down
Loading