diff --git a/coverage-thresholds.json b/coverage-thresholds.json index da259a2b..aafdb382 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -4,7 +4,7 @@ "default_threshold": 95, "projects": { "sharplsp": { - "threshold": 95.8, + "threshold": 96.25, "language": "rust" }, "sharplsp-sidecar-common": { @@ -20,7 +20,7 @@ "language": "dotnet" }, "vscode-extension": { - "threshold": 96.96, + "threshold": 97.4, "language": "typescript" }, "sharplsp-zed": { diff --git a/docs/specs/SCRIPTING-FILEBASED-SPEC.md b/docs/specs/SCRIPTING-FILEBASED-SPEC.md index 28758f88..ac4b667f 100644 --- a/docs/specs/SCRIPTING-FILEBASED-SPEC.md +++ b/docs/specs/SCRIPTING-FILEBASED-SPEC.md @@ -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. diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpCoverageTests.fs b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpCoverageTests.fs index 891c076a..63ba6b0b 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpCoverageTests.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpCoverageTests.fs @@ -2333,17 +2333,18 @@ let ``WS2 getDocumentHighlights returns empty for a file that cannot be read`` ( } [] -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 } diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpExtraSuccessCoverageTests.fs b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpExtraSuccessCoverageTests.fs index e37f7c12..3fe05742 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpExtraSuccessCoverageTests.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpExtraSuccessCoverageTests.fs @@ -140,7 +140,7 @@ let ``loadProject discovers the fsproj through an explicit slnx`` () = } [] -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 @@ -148,7 +148,9 @@ let ``loadProject on a solution with no fsproj reports an error`` () = File.WriteAllText(slnx, "\n\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 _ -> () } diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpProjectCrackingTests.fs b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpProjectCrackingTests.fs index fc4e6cf7..1f88ca17 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpProjectCrackingTests.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpProjectCrackingTests.fs @@ -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([| "--nowarn:52"; "--warnon:1182" |], flags) Assert.DoesNotContain(flags, fun flag -> flag.StartsWith("$(", StringComparison.Ordinal)) @@ -119,7 +119,7 @@ let ``compile items become the FCS source list in declaration order`` () = task [] 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) @@ -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([| "--nowarn:52"; "--warnon:1182" |], FSharpWorkspace.parseFsprojOtherFlags project) + Assert.Equal([| "--nowarn:52"; "--warnon:1182" |], FSharpProjectLoading.parseFsprojOtherFlags project) Assert.Equal(name, FSharpWorkspace.parseFsprojAssemblyName project) Assert.False(FSharpDesignTime.declaresTargetFrameworks project) Assert.Equal([| reference |], ProjectReferences.ReadReferencedProjects(project) |> Seq.toArray) diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpScriptTests.fs b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpScriptTests.fs index bedf48ea..b1c11e67 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpScriptTests.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpScriptTests.fs @@ -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 @@ -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). +[] +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). +[] +let ``a script beside a project that does not compile it answers from its own context`` () = + task { + let dir = tempDir () + let project = "net10.0" + 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) + } diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SidecarEndToEndTests.fs b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SidecarEndToEndTests.fs index a19c2614..33b33389 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SidecarEndToEndTests.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SidecarEndToEndTests.fs @@ -1214,15 +1214,15 @@ let ``workspace returns None for all queries when not loaded`` () = task { } [] -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 _ -> () } diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpDiagnosticQueries.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpDiagnosticQueries.fs index 45add807..3ee0eced 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpDiagnosticQueries.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpDiagnosticQueries.fs @@ -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 diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpProjectLoading.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpProjectLoading.fs index 28007776..d0f46eda 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpProjectLoading.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpProjectLoading.fs @@ -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 + } diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspace.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspace.fs index ca09ea01..f36c81d3 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspace.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspace.fs @@ -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 @@ -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 @@ -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 diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspaceLoading.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspaceLoading.fs index a954b635..b9fa3f36 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspaceLoading.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspaceLoading.fs @@ -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()