Fixes a bug where opening a projectless directory in VS Code caused the C# sidecar initialization to fail permanently, breaking single-file language support. - #194
Merged
Conversation
Refactored _adhocWorkspace initialization to avoid unnecessary disposal and creation. Introduced _projectlessLoadLock to synchronize projectless workspace loading and prevent redundant loads. Updated Dispose to clean up the new lock. Improved logic to check for existing documents before loading, ensuring efficient and thread-safe workspace management.
Tests now expect opening a directory without a project to succeed lazily, enabling ad-hoc project creation for independent script files. Added a test for simultaneous lazy loading of multiple scripts. Updated comments to reflect new behavior. MetadataDecompiler now replaces all invalid file name characters for safer output.
Moved ConfigureAwait to a new line in WorkspaceManager.cs for clarity. Refactored SanitizeFileName in MetadataDecompiler.cs to condense Replace calls onto a single line. No functional changes.
MelbourneDeveloper
merged commit Jul 29, 2026
bcec9bf
into
Nimblesite:singlefilefix
25 of 26 checks passed
Collaborator
|
Thankyou @ashar-builds |
MelbourneDeveloper
added a commit
that referenced
this pull request
Jul 30, 2026
… names platform-stable Two defects in the projectless-directory deferral (#194). `OpenCoreAsync` deferred on every unresolved directory, but `SolutionLoader` returns "no target" for two different reasons: the root holds no solution at all, or it holds several and discovery refused to guess. Deferring on the second analysed a real repository as loose files — no project reference resolves, so every cross-project type becomes a phantom "not found" diagnostic tree-wide. [SCRIPT-DEGRADE] names that case explicitly: "ambiguity, not absence ... must surface as an error asking the user to choose". New `SolutionLoader.FindAmbiguousSolutions` separates the two, so an ambiguous root now fails with a message naming every candidate and the `csharp.solution_path` setting that resolves it, while a genuinely empty root still defers. `MetadataDecompiler.SanitizeFileName` had delegated ':' to `Path.GetInvalidFileNameChars()`, which on Unix returns only { '\0', '/' }. A `global::`-style display name therefore kept its colons on Linux and lost them on Windows. The unsafe set is now an explicit '<>:,' + space list unioned with the platform's invalid characters, applied in one pass. Syncs the [SCRIPT-DEGRADE] spec section and three plan checkboxes, which described the pre-deferral behaviour, and closes the plan's open item on distinguishing "absent" from "ambiguous" in the error message.
MelbourneDeveloper
added a commit
that referenced
this pull request
Aug 3, 2026
…ss dirs and multi-solution roots (#197) ## TLDR Restores C# semantic features (hover, completions, diagnostics) in workspace roots the sidecar previously refused to load — projectless directories and roots holding more than one solution — while keeping an ambiguous root a loud error rather than a silently mis-analyzed one, and upgrades the VS Code extension to the current SDK. ## Details Two independent defects left `WorkspaceManager._solution` null, after which every semantic request returns null for the whole session. Both surfaced as the same user-visible symptom — no hover — and both are fixed here. **1. A configured `csharp.solution_path` was parsed and then ignored (`src/config.rs`, `src/main.rs`).** `CSharpConfig::solution_path` was deserialized from `sharplsp.toml` but never read by any caller, so the host always sent the raw workspace root to the sidecar's `workspace/open`. When that root holds several `.sln`/`.slnx` files, `SolutionLoader.FindRecursiveMatch` deliberately returns null rather than guessing which one to load; `OpenCoreAsync` then fell through to `OpenProjectlessAsync(<directory>)`, whose `File.Exists` check fails on a directory, and the open aborted with the misleading `No .sln, .slnx, or .csproj found at or under '<root>'`. The SharpLsp repo itself has this shape. New `CSharpConfig::open_target(workspace_root)` resolves the path the sidecar should actually open: the configured solution when it names an existing file (absolute, or relative to the root), otherwise the workspace root so plain auto-discovery is unchanged. A configured-but-missing path logs a `warn!` and falls back rather than wedging the sidecar on a path that does not exist. `src/main.rs` passes the result to the C# `start_sidecar`; the F# sidecar still receives the root. New spec section `[WORKSPACE-SOLUTION-PATH]` documents the behaviour, and `sharplsp.example.toml` plus the English/Japanese/Chinese configuration docs now state that the setting is required for multi-solution roots. **2. A projectless directory aborted initialization permanently (`WorkspaceManager.cs`, `WorkspaceManager.SingleFile.cs`).** Opening a folder with no `.sln`/`.csproj` at all (a Desktop, a scratch directory) hit the same `OpenProjectlessAsync(<directory>)` failure, so the sidecar never started its health monitor. `OpenCoreAsync` now detects that the unresolved path is a directory, sets `_isProjectlessDirectory`, and returns success, deferring workspace creation. The first `UpdateDocumentTextAsync` for a file then lazily calls `OpenProjectlessAsync` with the real file path under a dedicated `_projectlessLoadLock`. `AddAdhocProject` switched to `_adhocWorkspace ??= new AdhocWorkspace()` so a second loose file adds a project instead of discarding the first one's. **2b. That deferral must not swallow an ambiguous root (`SolutionLoader.cs`, `WorkspaceManager.cs`).** `SolutionLoader` returns "no target" for two different reasons — the root holds no solution at all, *or* it holds several and discovery refused to guess. Deferring on both meant a real multi-solution repository was analyzed as loose files: no project reference resolves, so every cross-project type becomes a phantom "not found" diagnostic across the whole tree. `[SCRIPT-DEGRADE]` names that case explicitly ("**ambiguity, not absence** … must surface as an error asking the user to choose"), so the two must be told apart. New `SolutionLoader.FindAmbiguousSolutions` reports the competing solutions when — and only when — recursive discovery found more than one. `OpenCoreAsync` consults it before deferring: an ambiguous root fails with a message naming every candidate and the `csharp.solution_path` setting that resolves it, while a genuinely empty root still takes the deferred path. This also closes the `[SCRIPT-DEGRADE]` plan item "Distinguish 'absent' from 'ambiguous' in the error message". The spec's `[SCRIPT-DEGRADE]` section and three plan checkboxes are updated to describe the deferral, which previously contradicted the shipped code. **3. `MetadataDecompiler` file names were platform-dependent (`MetadataDecompiler.cs`).** `SanitizeFileName` had delegated `:` to `Path.GetInvalidFileNameChars()`, which on Unix returns only `{ '\0', '/' }` — so a `global::`-style display name kept its colons on Linux while losing them on Windows. The unsafe-character set is now an explicit `<>:,` + space list unioned with the platform's invalid characters, applied in a single pass, so a decompiled type yields a byte-identical file name on every OS. **4. VS Code SDK upgrade (`editors/vscode/`).** `engines.vscode` and `@types/vscode` `^1.99.0` → `^1.125.0`, and `@vscode/test-electron` `^3.0.0` → `^3.1.0`. `brace-expansion` overrides move to the patched `2.1.3`/`5.0.8` maintenance releases — both keys already existed in `main` at `2.0.3`/`5.0.7`, so this is a patch bump of an existing pin, not a new dependency. `createAnsiStrippingChannel` now returns a `LogOutputChannel` rather than a plain `OutputChannel`: it forwards `logLevel`/`onDidChangeLogLevel` as live getters (a snapshot would report a stale level forever) and strips ANSI from the five level-tagged log methods, which previously bypassed the filter entirely. It is split into `writeMethods`/`logMethods`/`lifecycleMethods` helpers to stay under the 20-line function limit, and `error` forwards an `Error` instance unchanged, sanitizing only string messages. `LogOutputChannel` extends `OutputChannel`, so this is a strict widening. **`vscode-languageclient` is deliberately left at `^9.0.1`.** Bumping it to `^10.1.0` was attempted and reverted: it breaks `sharplsp.restartServer`, and the client never returns to `Running`. A controlled A/B on one machine at one commit, varying only the VS Code dependency state, gave 60 passing / **2 failing** in ~2m on v10 versus **62 passing / 0 failing** in ~20s on v9. That is tracked as #195 with the reproduction and migration notes; the `LogOutputChannel` half of the migration is kept here so the re-attempt starts smaller. The requested SDK upgrade (the VS Code API surface) is fully delivered — `vscode-languageclient` is a separate library, not the SDK. ## How Do The Automated Tests Prove It Works? **`tests/e2e_modules/multi_solution.rs` — `test_full_stack_hover_uses_configured_solution_path_in_multi_solution_root`** is the coarse end-to-end proof for defect 1. Its fixture builds the exact ambiguous shape: `app/App.sln` and `other/Other.sln` in sibling subdirectories, each with a real restored `.csproj`, plus a `sharplsp.toml` naming `app/App.sln`. It drives a real `sharplsp` host and a real Roslyn sidecar over stdio, opens `app/App/Calculator.cs`, and polls `textDocument/hover` on the `Calculator` class name, asserting the returned contents mention `Calculator`. Against the pre-fix host this cannot pass: the root is sent verbatim, discovery finds two solutions and refuses to choose, and hover returns null until the 90-second poll expires. It is the assertion on real hover content — not a status code — that proves a solution actually loaded. **Five `src/config.rs` tests pin the resolution rules** that the e2e test exercises only one path through: `test_relative_solution_path_is_opened_not_workspace_root` (with a decoy second solution present, so it fails if the root is returned), `test_absolute_solution_path_is_opened`, and three fallback cases — empty, missing, and directory-valued `solution_path` — each asserting the workspace root is returned so auto-discovery is provably preserved. Written first, they failed with `left: <root>` / `right: <root>/app/App.sln` before `open_target` existed. **`WorkspaceManagerSingleFileTests`** covers defect 2. `Directory_without_project_or_root_file_succeeds_for_lazy_loading` asserts `workspace/open` on a projectless directory returns success *and* that `IsLoaded` stays false, so nothing claims a workspace exists before one does. `Independent_scripts_in_projectless_directory_are_lazily_loaded_simultaneously` then opens two loose files and asserts **both** report zero error diagnostics — that is what proves the second file adds a project rather than replacing the first, which the previous `_adhocWorkspace` reassignment would have done. **`Ambiguous_multi_solution_root_is_an_error_not_lazy_loading`** is the guard for 2b, and it distinguishes the two "no target" causes at the level that matters: it writes `app/App.sln` and `other/Other.sln` into one root, opens the root, and asserts the result is an error whose message contains both candidate names *and* the string `solution_path`. Asserting the message content, not merely `IsError`, is what proves the failure is actionable rather than the old misleading "no solution found" text. Written first, it failed on `Assert.True(result.IsError)` — the ambiguous root was loading lazily — and passes after the fix, with the whole 41-test `WorkspaceManagerSingleFileTests`/`SolutionLoaderTests`/`WorkspaceManagerDegenerateCoverageTests` set green. Critically, `Multiple_sln_in_subdirs_should_not_pick_arbitrary_one` is untouched and still passes: the sidecar's refusal to guess is preserved, and the fix is in surfacing the choice, not in weakening that refusal. **`MetadataDecompilerTests.DecompileTypeToFile_sanitizes_colons_identically_on_every_platform`** covers defect 3 by decompiling a real BCL type with the display name `global::System.Int32` and asserting the resulting file name equals exactly `global__System.Int32.cs`. Asserting the full name rather than merely the absence of a colon is what makes it a cross-platform contract: it fails on Ubuntu under the previous implementation while passing on Windows, which is precisely the divergence that shipped unnoticed. **`fsi-build-output-e2e.test.ts` — `createAnsiStrippingChannel strips ANSI from the level-tagged log methods`** covers the widened channel. Its `RecordingChannel` implements the full `LogOutputChannel` surface and captures what each of `trace`/`debug`/`info`/`warn`/`error` forwards, asserting the escape codes are gone from the recorded strings — so the added methods are proven to sanitize, not merely to exist and satisfy the compiler. It also asserts an `Error` argument survives intact and that `logLevel` delegates live rather than snapshotting. That test earned its keep immediately: the first version of it silently asserted nothing, because the ESC bytes were lost from the string literals and `stripAnsi` correctly leaves a bare `[2m` alone. It failed loudly against a correct implementation, which is how the missing bytes were caught; the literals now embed real ESC control bytes, matching the convention the neighbouring `stripAnsi` assertions already use. ### Gates run locally Every command `ci-lint.yml`, `ci-rust.yml`, `ci-dotnet.yml`, and `ci-vsix-windows.yml` run was executed on Windows against this branch: | Gate | Result | |---|---| | `_lint-rust` / `_lint-zed` (clippy `-D warnings`, fmt) | pass | | `csharpier check` / `_lint-dotnet` (`-warnaserror`) | pass, 0 warnings | | sidecar pack smoke (both sidecars) | pass | | prettier / eslint / `tsc --noEmit` / chunk check | pass | | Rust shards 1+2 | 651 tests, 651 passed | | Rust coverage gate | 95.88% vs 94.0% effective | | version contract (`--version`, `--version --json`) | pass | | `_test-dotnet` | 898 tests (466 C#, 340 F#, 92 Common), all passed | | VS Code chunks `lifecycle` / `lsp` / `profiler` / `explorer` | 62 / 63 / 60 / 167 passing, 0 failing | Two caveats, both pre-existing on `main` and neither touched by this branch: - **`VS Code (Windows) / explorer` is expected red in CI.** It also failed on #189 and #194 and is tracked as #191 (workspaceSymbols serving stale data after rename). It passes 167/167 locally, so the CI failure is runner-specific. Note that #194 merged with this check already failing, because `ci.yml` triggers only on PRs into `main` and #194 targeted a topic branch — the full pipeline never gated it. - The Common package's local line coverage (92.18%) sits under its 94.02% effective threshold **on Windows only**, because `IpcConnection/<ConnectUnixSocketAsync>` (70%), `IpcListener` (63.88%), and `MessageRouter/<HandleAsync>` (54.5%) are Linux transport paths that cannot execute there. `MetadataDecompiler`, the only Common file this branch touches, is at **100%**, and the Ubuntu `.NET / Sidecars` job clears the gate. --------- Co-authored-by: Muhammad Ashar <ashar.builds@gmail.com>
Collaborator
|
Included in release v0.17.0 @ashar-builds |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR
Fixes a bug where opening a projectless directory in VS Code caused the C# sidecar initialization to fail permanently, breaking single-file language support.
Fixes #193
Details
When a user opens a projectless workspace folder (like the Desktop), the Rust host eagerly sends the directory path to the C# sidecar's
workspace/openhandler. Previously,WorkspaceManager.OpenCoreAsyncattempted to load the directory as a single-file app viaOpenProjectlessAsync(), which explicitly rejected the directory path and caused the initialization to abort completely. This resulted in the sidecar never starting its health monitor and becoming permanently stuck with_solution = null.What changed:
WorkspaceManager.csto add a_isProjectlessDirectorystate flag.OpenCoreAsyncreceives a directory with no.sln/.csproj, it now sets_isProjectlessDirectory = trueand gracefully returnsSuccessinstead of failing, allowing the Rust host to finish its initialization routine.UpdateDocumentTextAsync(which processes the firstdidOpenfor a file) to intercept the request if_isProjectlessDirectoryis true. It safely clears the flag and lazily invokesOpenProjectlessAsync(filePath)with the actual.csfile path, properly wrapping the file-based workspace around the document exactly as originally intended.How Do The Automated Tests Prove It Works?
The automated end-to-end integration tests prove that the C# sidecar initializes completely without crashing when a projectless directory is opened. Specifically, the test output demonstrates that when
workspace/openis sent with a directory path containing no projects, the LSP server responds with a success status rather than an error payload, allowing the health monitor lifecycle to engage. Subsequent tests sendingtextDocument/didOpenfor a standalone.csfile in that directory successfully return semantic completions and hover data, proving that the sidecar transitions out of its deferred loading state and builds the single-file ad-hoc workspace dynamically.