Skip to content

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
MelbourneDeveloper merged 4 commits into
Nimblesite:singlefilefixfrom
ashar-builds:main
Jul 29, 2026

Conversation

@ashar-builds

Copy link
Copy Markdown
Contributor

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/open handler. Previously, WorkspaceManager.OpenCoreAsync attempted to load the directory as a single-file app via OpenProjectlessAsync(), 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:

  • Modified WorkspaceManager.cs to add a _isProjectlessDirectory state flag.
  • When OpenCoreAsync receives a directory with no .sln/.csproj, it now sets _isProjectlessDirectory = true and gracefully returns Success instead of failing, allowing the Rust host to finish its initialization routine.
  • Updated UpdateDocumentTextAsync (which processes the first didOpen for a file) to intercept the request if _isProjectlessDirectory is true. It safely clears the flag and lazily invokes OpenProjectlessAsync(filePath) with the actual .cs file 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/open is 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 sending textDocument/didOpen for a standalone .cs file 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.

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
MelbourneDeveloper changed the base branch from main to singlefilefix July 29, 2026 22:30
@MelbourneDeveloper
MelbourneDeveloper merged commit bcec9bf into Nimblesite:singlefilefix Jul 29, 2026
25 of 26 checks passed
@MelbourneDeveloper

Copy link
Copy Markdown
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>
@MelbourneDeveloper

Copy link
Copy Markdown
Collaborator

Included in release v0.17.0 @ashar-builds

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: C# Sidecar Initialization Fails Permanently When Opening a Projectless Directory

2 participants