diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 3afce112..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(gh --version)", - "Bash(node -e \"const j=require\\('C:/Users/chris/.claude.json'\\); console.log\\(JSON.stringify\\(j.mcpServers,null,2\\)\\)\")", - "Bash(dotnet --list-sdks)", - "Bash(dotnet --list-runtimes)", - "Bash(MOCHA_GREP='survives project dir deletion' npm test)", - "Read(//c/Users/chris/**)", - "Bash(grep -rl \"too-many-cooks\" ~/.claude/ ~/.claude.json ~/AppData/Roaming/Claude/ 2>/dev/null | head -5)", - "Read(//c/Users/chris/.claude/**)", - "Bash(dotnet build *)", - "Bash(dotnet test *)", - "Bash(gh pr *)", - "Bash(node -e \"const p=require\\('./package.json'\\); console.log\\(JSON.stringify\\(p.scripts,null,2\\)\\)\")", - "Bash(mkdir -p /c/Users/chris/AppData/Local/Temp/claude/c--Code-SharpLsp/b0bff274-5638-4008-9a57-ef580d5c7404/scratchpad)", - "Bash(make _test-vsix-win CHUNK=lifecycle)", - "Bash(echo \"EXIT=$?\")", - "Bash(git ls-tree *)" - ] - }, - "autoMemoryEnabled": false -} diff --git a/.claude/skills/ci-prep/SKILL.md b/.claude/skills/ci-prep/SKILL.md index 04e9c249..b7d69b3d 100644 --- a/.claude/skills/ci-prep/SKILL.md +++ b/.claude/skills/ci-prep/SKILL.md @@ -50,7 +50,7 @@ Read **every line** of `--log-failed` output. For each failure note the exact fi - `ci-vsix.yml` — full VS Code suite + coverage gate (Ubuntu) - `ci-vsix-windows.yml` — VS Code feature chunks on Windows ([DIST-CI-WIN-VSIX]) 2. Parse every job and every step, then extract the ordered list of commands the CI actually runs. -3. Note any environment variables, matrix strategies, or conditional steps that affect execution. In particular the Windows VS Code matrix expands from `editors/vscode/test-chunks.json` — enumerate the chunks with `node scripts/vsix-test-chunks.mjs matrix` and run each locally as `make _test-vsix-win CHUNK=`. +3. Note any environment variables, matrix strategies, or conditional steps that affect execution. In particular the Windows VS Code matrix expands from `src/editors/vscode/test-chunks.json` — enumerate the chunks with `node tools/vsix/vsix-test-chunks.mjs matrix` and run each locally as `make _test-vsix-win CHUNK=`. **Do NOT assume the steps are `make lint`, `make test`, `make build`.** The actual CI may run different commands, in a different order. Extract what the CI *actually does*. @@ -80,10 +80,10 @@ For each command extracted from the CI workflow: - **Rust clippy violations**: Fix the code. Never add `#[allow(clippy::...)]` without an extraordinary justification. - **Rust fmt**: Run `cargo fmt` to auto-fix, then verify with `cargo fmt --check`. -- **TypeScript prettier**: Run `cd editors/vscode && npx prettier@3 --write 'src/**/*.ts'` to auto-fix. +- **TypeScript prettier**: Run `cd src/editors/vscode && npx prettier@3 --write 'src/**/*.ts'` to auto-fix. - **TypeScript ESLint**: Fix lint errors in the source. Never add `// eslint-disable`. - **TypeScript tsc**: Fix type errors. Never use `any` to silence a type error. -- **.NET csharpier**: Run `dotnet csharpier sidecars/` to auto-fix. +- **.NET csharpier**: Run `dotnet csharpier src/sidecars/` to auto-fix. - **.NET build warnings**: Fix the actual warning in the source code. ### Hard constraints diff --git a/.claude/skills/code-dedup/SKILL.md b/.claude/skills/code-dedup/SKILL.md index 006d8116..469345a1 100644 --- a/.claude/skills/code-dedup/SKILL.md +++ b/.claude/skills/code-dedup/SKILL.md @@ -13,7 +13,7 @@ Carefully search for duplicate code, duplicate tests, and dead code across the r Before touching ANY code, verify these conditions. If any fail, stop and report why. 1. Run `make test` — all tests must pass. If tests fail, stop. Do not dedup a broken codebase. -2. Run `make test` — tests are fail-fast AND enforce the coverage threshold from `coverage-thresholds.json`. If anything fails, stop and fix it before deduping. +2. Run `make test` — tests are fail-fast AND enforce the coverage threshold from `.config/coverage/thresholds.json`. If anything fails, stop and fix it before deduping. 3. Verify the project uses **static typing**. Check for: - Rust, C#, F#: typed by default — proceed - TypeScript: `tsconfig.json` must have `"strict": true` — proceed @@ -38,7 +38,7 @@ Dedup Progress: Before deciding what to touch, understand what is tested. -1. Run `make test` to confirm green baseline. `make test` is fail-fast AND enforces the coverage threshold from `coverage-thresholds.json`. It exits non-zero on any test failure OR coverage shortfall. +1. Run `make test` to confirm green baseline. `make test` is fail-fast AND enforces the coverage threshold from `.config/coverage/thresholds.json`. It exits non-zero on any test failure OR coverage shortfall. 2. Note the current coverage percentage per project — this is the floor. It must not drop. 3. Identify which files/modules have coverage and which do not. Only files WITH coverage are candidates for dedup. diff --git a/.claude/skills/upgrade-packages/SKILL.md b/.claude/skills/upgrade-packages/SKILL.md index 60e07bab..1bf321fc 100644 --- a/.claude/skills/upgrade-packages/SKILL.md +++ b/.claude/skills/upgrade-packages/SKILL.md @@ -22,8 +22,8 @@ Scan the repo for these package ecosystems: | Marker File | Ecosystem | Location | |---|---|---| | `Cargo.toml` (workspace) | Rust (cargo) | Repo root | -| `package.json` / `package-lock.json` | Node.js (npm) | `editors/vscode/` | -| `*.csproj` / `*.fsproj` / `Directory.Build.props` | C#/F# (.NET / NuGet) | `sidecars/SharpLsp.Sidecars.sln` | +| `package.json` / `package-lock.json` | Node.js (npm) | `src/editors/vscode/` | +| `*.csproj` / `*.fsproj` / `.config/dotnet/common.props` | C#/F# (.NET / NuGet) | `src/sidecars/SharpLsp.Sidecars.sln` | ## Step 2 — List Outdated Packages @@ -40,16 +40,16 @@ If `cargo-outdated` is not installed: `cargo install cargo-outdated` ### Node.js (npm) ```bash -npm outdated --prefix editors/vscode +npm outdated --prefix src/editors/vscode ``` **Read the docs:** https://docs.npmjs.com/cli/v10/commands/npm-update ### C#/.NET (NuGet) ```bash -dotnet list sidecars/SharpLsp.Sidecars.sln package --outdated +dotnet list src/sidecars/SharpLsp.Sidecars.sln package --outdated ``` -For transitive dependencies too: `dotnet list sidecars/SharpLsp.Sidecars.sln package --outdated --include-transitive` +For transitive dependencies too: `dotnet list src/sidecars/SharpLsp.Sidecars.sln package --outdated --include-transitive` **Read the docs:** https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-list-package @@ -75,20 +75,20 @@ For workspace members, run from workspace root. ### Node.js (npm) ```bash -npm update --prefix editors/vscode # semver-compatible +npm update --prefix src/editors/vscode # semver-compatible # --major flag: -npx npm-check-updates -u --packageFile editors/vscode/package.json && npm install --prefix editors/vscode +npx npm-check-updates -u --packageFile src/editors/vscode/package.json && npm install --prefix src/editors/vscode ``` ### C#/.NET (NuGet) ```bash -dotnet outdated --upgrade sidecars/SharpLsp.Sidecars.sln +dotnet outdated --upgrade src/sidecars/SharpLsp.Sidecars.sln ``` If `dotnet-outdated` tool is not installed: `dotnet tool install -g dotnet-outdated-tool` **Read the docs:** https://github.com/dotnet-outdated/dotnet-outdated -Shared NuGet package versions live in `Directory.Build.props` — check there first and update centrally when possible, rather than editing individual `.csproj`/`.fsproj` files. +Shared NuGet package versions live in `.config/dotnet/common.props` — check there first and update centrally when possible, rather than editing individual `.csproj`/`.fsproj` files. ## Step 5 — Verify the upgrade @@ -125,6 +125,6 @@ Provide a summary: - **Never modify lockfiles manually** (`Cargo.lock`, `package-lock.json`) — let the package manager regenerate them - **Keep `Cargo.lock` changes** in the same commit as `Cargo.toml` changes - **Keep `package-lock.json` changes** in the same commit as `package.json` changes -- **`Directory.Build.props`** is the source of truth for shared .NET package versions — update there first +- **`.config/dotnet/common.props`** is the source of truth for shared .NET package versions — update there first - **If stuck after 3 attempts**, revert and report — do not loop forever - **Commit nothing** — leave changes in the working tree for the user to review diff --git a/.claude/skills/website-audit/SKILL.md b/.claude/skills/website-audit/SKILL.md index 688c1ec6..cdf84fe2 100644 --- a/.claude/skills/website-audit/SKILL.md +++ b/.claude/skills/website-audit/SKILL.md @@ -28,10 +28,10 @@ Audit Progress: - Check the outputted HTML/CSS/JavaScript AFTER the website is generated by the static content generator. - Don't just check the static content before the website is generated. -- Fix issues at the core where the static content templates are stored - not in the outputted HTML (e.g. `website/_site/`) +- Fix issues at the core where the static content templates are stored - not in the outputted HTML (e.g. `src/website/_site/`) - Never manually edit the generated website content directly -The SharpLsp website is in `website/` and uses Eleventy as the static site generator. Brand color: `#19d078` (green). +The SharpLsp website is in `src/website/` and uses Eleventy as the static site generator. Brand color: `#19d078` (green). ## Step 1 — Read guidelines @@ -43,7 +43,7 @@ Fetch and read each of these before auditing. These are the authoritative refere If the repo has a business plan doc, take it into account. -Identify the website source files in the repo. The framework is Eleventy — templates, metadata, and content live in `website/`. +Identify the website source files in the repo. The framework is Eleventy — templates, metadata, and content live in `src/website/`. ## Step 2 — Audit AI search readiness @@ -184,4 +184,4 @@ Summarize the audit results: - **One step at a time** — complete each step before moving to the next. - **Preserve existing content** — improve structure and metadata without rewriting the author's voice. - **No keyword stuffing** — keywords must read naturally in context. -- **Respect the framework** — edit Eleventy templates/configs in `website/`, not generated output files in `website/_site/`. +- **Respect the framework** — edit Eleventy templates/configs in `src/website/`, not generated output files in `src/website/_site/`. diff --git a/coverlet.runsettings b/.config/coverage/coverlet.runsettings similarity index 100% rename from coverlet.runsettings rename to .config/coverage/coverlet.runsettings diff --git a/coverage-thresholds.json b/.config/coverage/thresholds.json similarity index 100% rename from coverage-thresholds.json rename to .config/coverage/thresholds.json diff --git a/.config/dotnet/common.props b/.config/dotnet/common.props new file mode 100644 index 00000000..25f664a9 --- /dev/null +++ b/.config/dotnet/common.props @@ -0,0 +1,54 @@ + + + net10.0 + 0.1.0 + latest + enable + enable + true + IDE0301;IDE0063;IDE0005;MSB3243 + 9999 + true + true + true + All + true + + $(WarningsAsErrors);CS8600;CS8601;CS8602;CS8603;CS8604;CS8605;CS8618;CS8619;CS8625;CS8629;CS8631;CS8633;CS8634;CS8638;CS8643;CS8644;CS8645;CS8653;CS8654;CS8655;CS8667;CS8714;CS8762;CS8764;CS8765;CS8766;CS8767;CS8768;CS8769;CS8770;CS8774;CS8775;CS8776;CS8777;CS8794;CS8795;CS8796;CS8797;CS8798;CS8847;EXHAUSTION001 + + $(WarningsAsErrors);IDE0001;IDE0042;IDE0051;IDE0052;IDE0056;IDE0060;IDE0022;IDE0002;IDE0130;IDE0060;IDE0002 + + $(WarningsAsErrors);CA1805;CA1806;CA1810;CA1812;CA1813;CA1814;CA1815;CA1816;CA1819;CA1820;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1827;CA1828;CA1829;CA1830;CA1831;CA1832;CA1833;CA1834;CA1835;CA1836;CA1837;CA1838;CA1839;CA1840;CA1841;CA1842;CA1843;CA1844;CA1845;CA1846;CA1847;CA1849;CA1850;CA1851;CA1852;CA1853;CA1854;CA1855;CA1856;CA1857;CA1858;CA1859;CA1860;CA1861;CA1862;CA1863;CA1864;CA1865;CA1866;CA1867;CA1868;CA1869;CA1870 + + $(WarningsAsErrors);CA2100;CA2101;CA2102;CA2103;CA2104;CA2105;CA2106;CA2107;CA2108;CA2109;CA2110;CA2111;CA2112;CA2113;CA2114;CA2115;CA2116;CA2117;CA2118;CA2119;CA2120;CA2121;CA2122;CA2123;CA2124;CA2125;CA2126;CA2127;CA2128;CA2129;CA2130;CA2131;CA2132;CA2133;CA2134;CA2135;CA2136;CA2137;CA2138;CA2139;CA2140;CA2141;CA2142;CA2143;CA2144;CA2145;CA2146;CA2147;CA2148;CA2149;CA2150;CA2151;CA2152;CA2153;CA2154;CA2155;CA2156;CA2157;CA2158;CA2159;CA2160 + + $(WarningsAsErrors);IDE0004;SYSLIB1045;CA1000;CA1001;CA1003;CA1005;CA1008;CA1010;CA1012;CA1018;CA1019;CA1021;CA1024;CA1027;CA1028;CA1030;CA1031;CA1032;CA1033;CA1036;CA1040;CA1041;CA1043;CA1044;CA1045;CA1046;CA1047;CA1048;CA1049;CA1050;CA1051;CA1052;CA1053;CA1054;CA1055;CA1056;CA1057;CA1058;CA1059;CA1060;CA1061;CA1063;CA1064;CA1065;CA1066;CA1067;CA1068;CA1069;CA1070 + + $(WarningsAsErrors);VSTHRD001;VSTHRD002;VSTHRD003;VSTHRD004;VSTHRD005;VSTHRD006;VSTHRD010;VSTHRD011;VSTHRD012;VSTHRD100;VSTHRD101;VSTHRD102;VSTHRD103;VSTHRD104;VSTHRD105;VSTHRD106;VSTHRD107;VSTHRD108;VSTHRD109;VSTHRD110;VSTHRD111;VSTHRD112;VSTHRD114;VSTHRD200 + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + all + runtime; build; native; contentfiles; analyzers + + + diff --git a/.deslop.toml b/.deslop.toml index da0825b3..326266cb 100644 --- a/.deslop.toml +++ b/.deslop.toml @@ -23,14 +23,22 @@ max_duplication_percent = 20.0 # gitignore-style globs, matched relative to this file. exclude = [ # Example / sample projects (test input, not codebase). - "examples/**", + "src/examples/**", # Test-input fixtures (sample sources consumed by the suites). - "tests/fixtures/**", - "editors/vscode/test-fixtures/**", + "src/sharplsp/tests/fixtures/**", + "src/editors/vscode/test-fixtures/**", # Rust end-to-end test suite (coarse e2e request/response scaffolding). - "tests/**", + "src/sharplsp/tests/**", # .NET sidecar test projects. - "sidecars/SharpLsp.Sidecar.CSharp.Tests/**", - "sidecars/SharpLsp.Sidecar.FSharp.Tests/**", - "sidecars/SharpLsp.Sidecar.Common.Tests/**", + "src/sidecars/SharpLsp.Sidecar.CSharp.Tests/**", + "src/sidecars/SharpLsp.Sidecar.FSharp.Tests/**", + "src/sidecars/SharpLsp.Sidecar.Common.Tests/**", + # Sequestered formatting implementation — not shipped. The Rust module is + # behind `cfg(feature = "formatting")` (off by default) and the Roslyn + # resolver is unreachable because the host never sends the request. Its + # three handlers are deliberately parallel in shape, which would otherwise + # inflate the production duplication score for code we do not ship. + # See docs/formatting/README.md. + "src/sharplsp/src/formatting.rs", + "src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FormattingResolver.cs", ] diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index 6be7d6c8..67e8cee9 100644 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -11,6 +11,6 @@ cargo install cargo-llvm-cov dotnet tool restore # VS Code extension dependencies -cd editors/vscode && npm ci && cd ../.. +cd src/editors/vscode && npm ci && cd ../../.. echo "==> Setup complete." diff --git a/.editorconfig b/.editorconfig index 7f520746..26fc921f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -356,13 +356,13 @@ dotnet_diagnostic.CA2329.severity = error dotnet_diagnostic.CA2330.severity = error #------------------------------------------------------------------------------ -# BannedApiAnalyzers (RSxxxx) — activates the banned-symbol list in BannedSymbols.txt +# BannedApiAnalyzers (RSxxxx) — activates the list in src/sidecars/config/BannedSymbols.txt #------------------------------------------------------------------------------ -# RS0030: A symbol banned in BannedSymbols.txt is used (nondeterministic time, +# RS0030: A symbol banned in src/sidecars/config/BannedSymbols.txt is used (nondeterministic time, # thread-blocking). Error so `-warnaserror` fails the build on any banned use. dotnet_diagnostic.RS0030.severity = error -# RS0031: A type banned in BannedSymbols.txt is used. +# RS0031: A type banned in src/sidecars/config/BannedSymbols.txt is used. dotnet_diagnostic.RS0031.severity = error #------------------------------------------------------------------------------ diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 7f3ca9c6..5ce99c02 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -1,5 +1,5 @@ # Shared CodeQL configuration for the PR, weekly, and gated release scans -# (referenced by .github/workflows/codeql.yml). [GITHUB-CODE-SCANNING] +# (referenced by .github/workflows/codeql.yml). [DIST-CI-SECURITY] # # Test code is never shipped: the VSIX bundles dist/extension.js (esbuild output # of src/, excluding src/test) and the Rust/.NET binaries — never the TypeScript @@ -14,4 +14,4 @@ queries: paths-ignore: - '**/*.test.ts' - - editors/vscode/src/test + - src/editors/vscode/src/test diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a74a1117..924fce53 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -40,7 +40,7 @@ updates: # ── Rust: Zed extension crate ─────────────────────────────────────── - package-ecosystem: cargo - directory: "/editors/zed" + directory: "/src/editors/zed" target-branch: "dependabot-upgrades" schedule: interval: weekly @@ -51,7 +51,7 @@ updates: # ── .NET: C#/F# sidecars solution ─────────────────────────────────── - package-ecosystem: nuget - directory: "/sidecars" + directory: "/src/sidecars" target-branch: "dependabot-upgrades" schedule: interval: weekly @@ -62,7 +62,7 @@ updates: # ── npm: VS Code extension ────────────────────────────────────────── - package-ecosystem: npm - directory: "/editors/vscode" + directory: "/src/editors/vscode" target-branch: "dependabot-upgrades" schedule: interval: weekly @@ -73,7 +73,7 @@ updates: # ── npm: website ──────────────────────────────────────────────────── - package-ecosystem: npm - directory: "/website" + directory: "/src/website" target-branch: "dependabot-upgrades" schedule: interval: weekly diff --git a/.github/workflows/ci-dotnet.yml b/.github/workflows/ci-dotnet.yml index 678dae61..b83c9d79 100644 --- a/.github/workflows/ci-dotnet.yml +++ b/.github/workflows/ci-dotnet.yml @@ -27,15 +27,15 @@ jobs: shell: bash run: |- set -euo pipefail - CS_VERSION="$(dotnet msbuild sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj -getProperty:Version -nologo)" - FS_VERSION="$(dotnet msbuild sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj -getProperty:Version -nologo)" + CS_VERSION="$(dotnet msbuild src/sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj -getProperty:Version -nologo)" + FS_VERSION="$(dotnet msbuild src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj -getProperty:Version -nologo)" target/sidecar-csharp/SharpLsp.Sidecar.CSharp --version | grep -Fx "sharplsp-sidecar-csharp ${CS_VERSION}" target/sidecar-fsharp/SharpLsp.Sidecar.FSharp --version | grep -Fx "sharplsp-sidecar-fsharp ${FS_VERSION}" - name: Upload coverage uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-dotnet - path: coverage-thresholds.json + path: .config/coverage/thresholds.json test-dotnet-windows: name: Named-Pipe Transport (Windows) # Implements [DIST-CI-WIN-TRANSPORT]: the named-pipe arm of the sidecar IPC @@ -51,6 +51,6 @@ jobs: 9.0.x 10.0.300 - name: Restore full solution (caches FCS nuspec for dependency-consistency tests) - run: dotnet restore sidecars/SharpLsp.Sidecars.sln + run: dotnet restore src/sidecars/SharpLsp.Sidecars.sln - name: Test sidecar IPC transport on Windows - run: dotnet test sidecars/SharpLsp.Sidecar.Common.Tests/SharpLsp.Sidecar.Common.Tests.csproj --blame-hang-timeout 2min --blame-hang-dump-type none + run: dotnet test src/sidecars/SharpLsp.Sidecar.Common.Tests/SharpLsp.Sidecar.Common.Tests.csproj --blame-hang-timeout 2min --blame-hang-dump-type none diff --git a/.github/workflows/ci-lint.yml b/.github/workflows/ci-lint.yml index 4cd238a1..9296fdc0 100644 --- a/.github/workflows/ci-lint.yml +++ b/.github/workflows/ci-lint.yml @@ -37,30 +37,30 @@ jobs: - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: "~/.nuget/packages" - key: "${{ runner.os }}-nuget-${{ hashFiles('sidecars/**/*.csproj', 'sidecars/Directory.Build.props') }}" + key: "${{ runner.os }}-nuget-${{ hashFiles('src/sidecars/**/*.csproj', 'src/sidecars/Directory.Build.props') }}" restore-keys: "${{ runner.os }}-nuget-" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' cache: npm - cache-dependency-path: editors/vscode/package-lock.json + cache-dependency-path: src/editors/vscode/package-lock.json - name: Install dependencies run: | dotnet tool restore - npm ci --prefix editors/vscode + npm ci --prefix src/editors/vscode - name: Lint (Rust) run: make _lint-rust PROFILE=debug - name: Lint (Zed) run: make _lint-zed - name: Format check (.NET) - run: dotnet csharpier check sidecars/ + run: dotnet csharpier check src/sidecars/ - name: Lint (.NET) run: make _lint-dotnet - name: Sidecar pack smoke test run: | - dotnet pack sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj \ + dotnet pack src/sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj \ -p:PackageVersion=0.0.0-ci -c Release -o /tmp/nupkg-smoke - dotnet pack sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj \ + dotnet pack src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj \ -p:PackageVersion=0.0.0-ci -c Release -o /tmp/nupkg-smoke ls -la /tmp/nupkg-smoke/*.nupkg - name: Format check (VS Code) @@ -68,7 +68,7 @@ jobs: # so a new patch release can reformat previously-clean files and fail # this gate non-deterministically (local prettier drifts from CI's). # Bump this deliberately, in lockstep with a repo-wide `--write`. - run: cd editors/vscode && npx prettier@3.9.4 --check 'src/**/*.ts' + run: cd src/editors/vscode && npx prettier@3.9.4 --check 'src/**/*.ts' - name: Lint (VS Code) # Includes `_check-vsix-chunks`: fails if any VS Code suite belongs to no # Windows feature chunk, so a new suite cannot silently skip diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index ba481217..1c76bed2 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -47,7 +47,7 @@ jobs: - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: "~/.nuget/packages" - key: "${{ runner.os }}-nuget-${{ hashFiles('sidecars/**/*.csproj', 'sidecars/Directory.Build.props') }}" + key: "${{ runner.os }}-nuget-${{ hashFiles('src/sidecars/**/*.csproj', 'src/sidecars/Directory.Build.props') }}" restore-keys: "${{ runner.os }}-nuget-" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -93,7 +93,7 @@ jobs: name: coverage-rust path: | target/coverage-rust.lcov - coverage-thresholds.json + .config/coverage/thresholds.json version-contract: name: Version Contract # Implements [DIST-VERSION-OUTPUT] + the version line of [DIST-CI-SMOKE]. diff --git a/.github/workflows/ci-vsix-windows.yml b/.github/workflows/ci-vsix-windows.yml index 06ad2982..f3c9fbc5 100644 --- a/.github/workflows/ci-vsix-windows.yml +++ b/.github/workflows/ci-vsix-windows.yml @@ -16,8 +16,8 @@ # time and publishes them as an artifact; each chunk downloads and stages them. # Rebuilding per chunk would cost seven cold Windows Rust builds. # -# Chunk membership is declared in editors/vscode/test-chunks.json and read here -# via scripts/vsix-test-chunks.mjs — never duplicated into this YAML. `make +# Chunk membership is declared in src/editors/vscode/test-chunks.json and read here +# via tools/vsix/vsix-test-chunks.mjs — never duplicated into this YAML. `make # _lint-vsix` fails if a suite belongs to no chunk. name: CI / VS Code (Windows) 'on': @@ -57,7 +57,7 @@ jobs: shell: bash run: |- set -euo pipefail - chunks="$(node scripts/vsix-test-chunks.mjs matrix)" + chunks="$(node tools/vsix/vsix-test-chunks.mjs matrix)" echo "chunks=${chunks}" >> "$GITHUB_OUTPUT" echo "Windows VS Code feature chunks: ${chunks}" - name: Build sharplsp host and both sidecars @@ -100,9 +100,9 @@ jobs: with: node-version: '20' cache: npm - cache-dependency-path: editors/vscode/package-lock.json + cache-dependency-path: src/editors/vscode/package-lock.json - name: Install VS Code extension deps - run: npm ci --prefix editors/vscode + run: npm ci --prefix src/editors/vscode - name: Download Windows LSP artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -112,7 +112,7 @@ jobs: # Force the test host onto the freshly-staged bundled binaries, never a # dev copy that leaked onto the runner PATH. Mirrors the Ubuntu job. shell: bash - run: bash scripts/purge-path-binaries.sh + run: bash tools/vsix/purge-path-binaries.sh - name: Run VS Code feature chunk (real LSP) # No xvfb: the GitHub Windows runner has an interactive desktop session, # so the VS Code test host launches directly. `make` + Git-Bash on the diff --git a/.github/workflows/ci-vsix.yml b/.github/workflows/ci-vsix.yml index fb3f23a2..eb94073f 100644 --- a/.github/workflows/ci-vsix.yml +++ b/.github/workflows/ci-vsix.yml @@ -41,12 +41,12 @@ jobs: with: node-version: '20' cache: npm - cache-dependency-path: editors/vscode/package-lock.json + cache-dependency-path: src/editors/vscode/package-lock.json - name: Install VS Code extension deps - run: npm ci --prefix editors/vscode + run: npm ci --prefix src/editors/vscode - name: Remove PATH-installed SharpLsp binaries shell: bash - run: bash scripts/purge-path-binaries.sh + run: bash tools/vsix/purge-path-binaries.sh - name: Test VS Code extension with coverage run: xvfb-run -a make _test-vsix - name: Verify VSIX contains platform binary @@ -54,9 +54,9 @@ jobs: run: |- set -euo pipefail platform="$(node -e 'process.stdout.write(`${process.platform}-${process.arch}`)')" - unzip -l sharplsp.vsix | grep -F "bin/${platform}/sharplsp" + unzip -l dist/sharplsp.vsix | grep -F "bin/${platform}/sharplsp" - name: Upload coverage uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-vsix - path: coverage-thresholds.json + path: .config/coverage/thresholds.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b66a2c48..8b13abdf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,11 +46,11 @@ jobs: manifest_changed=false for file in "${files[@]}"; do case "$file" in - shipwright.json|editors/vscode/shipwright.json|.github/shipwright/deployment-toolkit.schema.json) + shipwright.json|src/editors/vscode/shipwright.json|.github/shipwright/deployment-toolkit.schema.json) manifest_changed=true code_changed=true ;; - website/*|docs/*|.github/ISSUE_TEMPLATE/*|*.md) + src/website/*|docs/*|.github/ISSUE_TEMPLATE/*|*.md) ;; *) code_changed=true @@ -76,7 +76,7 @@ jobs: # Owns vulnerable *dependencies* (Cargo, npm, NuGet). CodeQL (codeql.yml) # owns vulnerable *code* and `make lint` owns style - no overlap. There is # no native vuln-gate (cargo-deny/osv) in this repo, so dependency-review is - # the single dependency scanner. [GITHUB-DEP-REVIEW] + # the single dependency scanner. [DIST-CI-SECURITY] permissions: contents: read steps: @@ -105,7 +105,7 @@ jobs: npm install --prefix "$tmp" @nimblesite/shipwright-validate-manifest@0.9.0 "$tmp/node_modules/.bin/shipwright-validate-manifest" \ --schema .github/shipwright/deployment-toolkit.schema.json \ - shipwright.json editors/vscode/shipwright.json + shipwright.json src/editors/vscode/shipwright.json needs: - detect-changes if: ${{ needs.detect-changes.outputs.manifest_changed == 'true' }} @@ -142,8 +142,8 @@ jobs: # NOTE: the former `coverage` job that git-committed+pushed ratcheted # thresholds was removed. On a pull_request, actions/checkout is a detached # HEAD, so `git push` failed the moment coverage changed — a latent red build, - # and a direct push to a protected branch ([BRANCH-RULES]). Coverage is - # already enforced inside `make test` (scripts/check-coverage.sh reads - # coverage-thresholds.json and fails below threshold in each test-* job). + # and a direct push to a protected branch ([DIST-CI-LAYOUT]). Coverage is + # already enforced inside `make test` (tools/coverage/check-coverage.sh reads + # .config/coverage/thresholds.json and fails below threshold in each test-* job). # Thresholds ratchet via the reviewed PR diff, not a bot commit. - # [COVERAGE-THRESHOLDS-JSON] + # [DIST-CI-RUST-SHARDS] diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e37a7968..6d4e801f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -15,7 +15,7 @@ on: # scan covers the diff and the weekly scan covers query drift; the gated call # covers the released commit itself, so a release never ships code that was last # analyzed weeks ago — and now actually FAILS the release instead of just - # filing alerts after the VSIX already shipped. [GITHUB-CODE-SCANNING] + # filing alerts after the VSIX already shipped. [DIST-CI-SECURITY] workflow_call: inputs: gate: @@ -43,7 +43,7 @@ jobs: # flipped back to private without GitHub Advanced Security, the SARIF upload # would fail with "Code Security must be enabled for this repository" - the # gate skips the job cleanly instead, and self-re-enables once the repo is - # public again. No follow-up edit needed. [GITHUB-CODE-SCANNING] + # public again. No follow-up edit needed. [DIST-CI-SECURITY] if: github.event.repository.visibility == 'public' permissions: security-events: write @@ -69,7 +69,7 @@ jobs: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} # Query suite and test-code exclusions live in the shared config so the - # PR, weekly, and gated release scans stay identical. [GITHUB-CODE-SCANNING] + # PR, weekly, and gated release scans stay identical. [DIST-CI-SECURITY] config-file: ./.github/codeql/codeql-config.yml - name: Perform CodeQL analysis uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 @@ -86,7 +86,7 @@ jobs: # never green a release it could not actually evaluate. # Caveat: this reads the freshly produced SARIF, which does NOT reflect alert # dismissals — a dismissed false positive re-blocks until the query is tuned - # or excluded via the CodeQL config. [GITHUB-CODE-SCANNING] + # or excluded via the CodeQL config. [DIST-CI-SECURITY] - name: Enforce no high/critical findings (release gate) if: inputs.gate shell: bash diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 1ee12235..f10c410c 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -1,26 +1,33 @@ --- # agent-pmo:0b21609 name: Deploy Pages -# workflow_call lets release.yml deploy the site at the release ref via `uses:`. -# workflow_dispatch keeps manual deploys available. +# Website changes merged to main deploy immediately. workflow_call keeps release +# tags deployable at the release ref, and workflow_dispatch remains the manual +# recovery path. 'on': + push: + branches: + - main + paths: + - src/website/** + - docs/designs/logo/** + - .github/workflows/deploy-pages.yml workflow_call: workflow_dispatch: concurrency: group: pages + queue: max cancel-in-progress: false -permissions: - contents: read - pages: write - id-token: write jobs: build: name: Build runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} timeout-minutes: 5 + permissions: + contents: read defaults: run: - working-directory: website + working-directory: src/website steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -29,21 +36,26 @@ jobs: with: node-version: '20' cache: npm - cache-dependency-path: website/package-lock.json - - run: npm install + cache-dependency-path: src/website/package-lock.json + - run: npm ci - name: Build site run: npm run build env: GITHUB_TOKEN: ${{ github.token }} + - name: Stamp deployment + run: printf '%s\n' "$GITHUB_SHA" > _site/deployment-sha.txt - name: Upload artifact uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: - path: website/_site + path: src/website/_site deploy: name: Deploy needs: build runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} - timeout-minutes: 5 + timeout-minutes: 15 + permissions: + pages: write + id-token: write environment: name: github-pages url: "${{ steps.deployment.outputs.page_url }}" @@ -51,3 +63,40 @@ jobs: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 + - name: Verify production website + shell: bash + env: + PRODUCTION_URL: https://sharplsp.dev + run: | + set -euo pipefail + expected_headline='SharpLsp: full C# and F# development.' + expected_install='href="vscode:extension/nimblesite.sharplsp"' + fetch() { + curl --fail --silent --show-error --location \ + --connect-timeout 5 \ + --max-time 10 \ + --header 'Cache-Control: no-cache' \ + --header 'Pragma: no-cache' \ + "$1" || true + } + + for attempt in {1..30}; do + probe="run_id=${GITHUB_RUN_ID}&run_attempt=${GITHUB_RUN_ATTEMPT}&attempt=${attempt}" + deployed_sha="$(fetch "${PRODUCTION_URL}/deployment-sha.txt?${probe}")" + + if [[ "$deployed_sha" == "$GITHUB_SHA" ]]; then + html="$(fetch "${PRODUCTION_URL}/?${probe}")" + + if grep --fixed-strings --quiet "$expected_headline" <<<"$html" \ + && grep --fixed-strings --quiet "$expected_install" <<<"$html"; then + echo "Production is serving commit ${GITHUB_SHA} with the new SharpLsp experience." + exit 0 + fi + fi + + echo "Production has not converged yet (attempt ${attempt}/30)." + sleep 10 + done + + echo "ERROR: ${PRODUCTION_URL} did not serve commit ${GITHUB_SHA} with the expected experience." >&2 + exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b202095..546a0644 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ name: Release # Least-privilege default: every job is read-only unless it explicitly # escalates (the GitHub Release job needs contents: write; deploy-pages needs -# pages/id-token). [SWR-SEC-TOKEN-PRIVILEGE] +# pages/id-token). [DIST-SECRETS] permissions: contents: read @@ -19,7 +19,7 @@ env: jobs: # Tag → release. The tagged SHA is built verbatim: NO branch detection, NO # commit, NO push. Stamping happens runner-local in each build job. - # [CI-RELEASE] [SWR-VERSION-BUILD-STAMPING] + # [DIST-RELEASE] [DIST-VERSION-INVARIANT] version: name: Prepare release version runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} @@ -45,14 +45,14 @@ jobs: npm install --prefix "$tmp" @nimblesite/shipwright-validate-manifest@0.9.0 "$tmp/node_modules/.bin/shipwright-validate-manifest" \ --schema .github/shipwright/deployment-toolkit.schema.json \ - shipwright.json editors/vscode/shipwright.json + shipwright.json src/editors/vscode/shipwright.json # CodeQL release gate. Scans the tagged SHA with the current query set and # FAILS on any High/Critical finding. The `release` job `needs:` this, so a # failed scan blocks the GitHub Release and every downstream publish # (marketplace, open-vsx, pages). Runs in PARALLEL with build-vsix — rebuilding # is cheap; shipping an unscanned release is not. Reuses codeql.yml via - # workflow_call (DRY — no duplicated matrix). [GITHUB-CODE-SCANNING] [CI-RELEASE] + # workflow_call (DRY — no duplicated matrix). [DIST-CI-SECURITY] [DIST-RELEASE] codeql: name: CodeQL release gate permissions: @@ -101,7 +101,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # Each build job stamps the version into the runner working tree from the # tag — deterministic, no artifact hand-off, nothing committed. - # [CI-RELEASE] [SWR-VERSION-BUILD-STAMPING] + # [DIST-RELEASE] [DIST-VERSION-INVARIANT] - name: Stamp release version shell: bash run: make _stamp-version VERSION="${{ needs.version.outputs.version }}" @@ -121,9 +121,9 @@ jobs: with: node-version: '20' cache: npm - cache-dependency-path: editors/vscode/package-lock.json + cache-dependency-path: src/editors/vscode/package-lock.json - name: Install VS Code extension dependencies - run: npm ci --prefix editors/vscode + run: npm ci --prefix src/editors/vscode - name: Install Linux ARM64 cross linker if: matrix.rust_target == 'aarch64-unknown-linux-gnu' shell: bash @@ -160,7 +160,7 @@ jobs: unzip -l "dist/sharplsp-${{ matrix.platform }}.vsix" | grep -F "bin/all/sharplsp-sidecar-csharp" unzip -l "dist/sharplsp-${{ matrix.platform }}.vsix" | grep -F "bin/all/sharplsp-sidecar-fsharp" # Negative check: no foreign-platform binary dirs leaked into the VSIX. - # [SWR-VSIX-VERIFY] + # [DIST-CI-SMOKE] if unzip -l "dist/sharplsp-${{ matrix.platform }}.vsix" | grep -Eo 'bin/[a-z0-9]+-[a-z0-9]+/sharplsp' | grep -v "bin/${{ matrix.platform }}/sharplsp"; then echo "::error::foreign-platform binary found in dist/sharplsp-${{ matrix.platform }}.vsix" exit 1 @@ -199,7 +199,7 @@ jobs: done cat SHA256SUMS # Hyphenated SemVer tags (v0.2.0-rc.1, v0.2.0-beta) are marked prerelease so - # they never become the "Latest release". [BRANCH] [CI-RELEASE] + # they never become the "Latest release". [DIST-RELEASE] [DIST-RELEASE] - name: Create GitHub release env: GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" @@ -216,8 +216,8 @@ jobs: artifacts/*.vsix \ artifacts/SHA256SUMS - # Website deploys on every v* release tag (never on push to main) so the site - # never gets ahead of the released artifact. [CI-WORKFLOWS] + # Release tags deploy their exact website revision. Website-only changes also + # deploy after merging to main through deploy-pages.yml's push trigger. deploy-pages: name: Deploy website to GitHub Pages needs: release @@ -239,7 +239,7 @@ jobs: # deterministic (repo:OWNER/REPO:environment:release), so ONE federated # credential covers every v* tag — Entra rejects tag wildcards in an explicit # federated subject. Secrets (Azure app's client + tenant id, neither - # sensitive) live on the `release` environment. [SWR-SEC-TOKEN-PRIVILEGE] + # sensitive) live on the `release` environment. [DIST-SECRETS] environment: release permissions: contents: read diff --git a/.gitignore b/.gitignore index a7cdf584..4e846d1d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,10 @@ nohup.out # Coverage artifacts (all languages) coverage/ +!.config/coverage/ +!.config/coverage/** +!tools/coverage/ +!tools/coverage/** lcov.info *.profraw *.profdata @@ -65,12 +69,14 @@ mutants.out/ tmp/ temp/ scratch/ +.tmp-*/ +.tmp-* # ============================================================================= # RUST # ============================================================================= /target -editors/zed/target/ +src/editors/zed/target/ # ============================================================================= # TYPESCRIPT / NODE @@ -112,25 +118,21 @@ project.lock.json # Screenshot test artifacts at root /*.png -editors/rider/.intellijPlatform/ +src/editors/rider/.intellijPlatform/ -website/test-results/ +src/website/test-results/ test-results/ -docs/plans/editors/zed/target/ - -sharplsp-rider.zip - -sharplsp-zed-extension.tar.gz - +/src/editors/zed/target/ .deslop-cache/ # Deslop report artifacts (generated locally by `deslop`, never committed). deslop-report.* deslop-*.log +/debug.log .ghissues/ .vscode/code-navigator/ -# Real-world repos cloned by the e2e stress suites (editors/vscode real-repo tests). +# Real-world repos cloned by the e2e stress suites (src/editors/vscode real-repo tests). # Never committed - fetched on demand at pinned tags. -/real-world-fixtures/ +/src/fixtures/real-world/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 565c0c25..2aa14413 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,7 @@ "titleBar.inactiveBackground": "#005826", "titleBar.inactiveForeground": "#ffffffcc" }, - "deslop.topOffenders.splitByLanguage": true -} \ No newline at end of file + "deslop.topOffenders.splitByLanguage": true, + "makefile.makefilePath": "${workspaceFolder}/tools/make/vscode.mk", + "makefile.phonyOnlyTargets": true +} diff --git a/AGENTS.md b/AGENTS.md index a6fce0a1..61769c98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1 +1 @@ -@CLAUDE.md \ No newline at end of file +@CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md index 0ce610d4..4c19c68c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,17 +1,15 @@ # CLAUDE.md -⚠️ Never kill VS Code processes — not desktop, not browser. They belong to the user. ⚠️ +⚠️ Never kill VS Code processes — not desktop, not browser. They belong to the user. ⚠️ -⚠️ Don't ask the user questions — use your judgment. ⚠️ +⚠️ Don't ask the user questions — use your judgment. ⚠️ -⚠️ Don't use git. Especially critical: don't stamp yourself as coauthor on commits ⚠️ - -> "Git" here means **version-control operations**: commits, branches, merges, rebases, tags, pushes — and never stamping yourself as coauthor. It does **NOT** mean GitHub. **GitHub issues are allowed and encouraged** — use the `gh` CLI to file, comment on, and manage issues for bugs and tracking. GitHub ≠ Git. +⚠️ Don't perform Git version-control operations (commits, branches, merges, rebases, tags, or pushes) or add yourself as coauthor. GitHub issues are allowed and encouraged via `gh`. ⚠️ SharpLsp is an open-source, editor-agnostic .NET LSP (C# + F#) built in Rust. One LSP server = complete .NET development experience across every editor. **Overall aim #1: FIX THE .NET DEVELOPER EXPERIENCE.** -Match — and ultimately go beyond — Visual Studio, Rider, and C# Dev Kit. Full feature-for-feature parity, then more. Zero proprietary dependencies. Zero licenses. Zero vendor lock-in. +Match and surpass Visual Studio, Rider, and C# Dev Kit without proprietary dependencies, licenses, or vendor lock-in. **Overall aim #2: TREAT F# AS A FIRST CLASS CITIZEN.** F# ahead of C# when building new features. F# never takes the backseat. @@ -20,23 +18,18 @@ F# ahead of C# when building new features. F# never takes the backseat. ## Principles -This code would pass a review at Google, Meta, or Microsoft. No bad or duplicate code. Grade A+. Anything less must be fixed immediately. +Write review-ready, maintainable code with no duplication. - Logging is critical. Use structured logging: `tracing` crate in Rust, `ILogger` + Serilog in .NET. No raw `println!`/`Console.WriteLine`/`console.log` for diagnostics -- 100% test coverage is only the start -- Use libraries like Signals for reactivity -- No feature is complete without e2e tests -- Building a feature without tests is not allowed -- No unit tests. Only COARSE e2e tests +- Every feature requires coarse end-to-end tests; do not add unit tests ## Hard Rules -- Do not use Git. - All screens MUST BE 100% reactive. If underlying data changes, the screen must be listening and update accordingly - Zero duplication. Apply DRY rigorously. Check for existing code before writing new code — highest priority - Any function that can throw/panic must return Result (outcome package in .NET) - Avoid RegEx and string matching. Always use ACTUAL parsers and traverse the AST/CST -- **NEVER hand-manipulate structured files.** XML (csproj/fsproj/props/vsixmanifest), JSON, TOML, YAML, solution files, etc. MUST be loaded into a proper document model, mutated via the DOM/AST, and serialized back. Line splicing, regex replacement, and string concatenation on structured files are not permitted. No exceptions for "performance" or "formatting preservation" — use a parser that preserves trivia (e.g. `Microsoft.Build.Construction` for MSBuild, `XDocument`/`quick-xml` with trivia preservation for XML, `serde_json` with `preserve_order` for JSON). +- **Never hand-manipulate structured files.** Load XML, JSON, TOML, YAML, and solution files into a proper DOM/AST, mutate the model, and serialize it with a trivia-preserving parser where needed. Do not use line splicing, regex replacement, or string concatenation. Prefer Microsoft.Build.Construction for MSBuild, XDocument or quick-xml for XML, and serde_json with preserve_order for JSON. - `allow(clippy::` is not permitted without a strong, documented reason. **Aggressively remove** existing allows. - All code files < 500 LOC. Functions < 20 LOC - Aggressively move shared code to shared crates/modules @@ -48,10 +41,8 @@ This code would pass a review at Google, Meta, or Microsoft. No bad or duplicate 100% test coverage and high mutation score. Focus on assertions, not just coverage. -- Never delete failing tests -- Never remove assertions that cause test failures -- Add more failing tests for broken/missing functionality — never remove them -- Do not reduce test assertiveness to make tests pass +- Never delete failing tests or remove/weaken assertions to make tests pass +- Add failing tests for broken or missing functionality - Tests must not be skipped or ignored - Test against real .sln/.csproj/.fsproj files, not mocks @@ -127,8 +118,6 @@ All documentation lives in `docs/`. Every spec section MUST have a hierarchical ID: `[GROUP-TOPIC]` or `[GROUP-TOPIC-DETAIL]`. IDs are uppercase, hyphen-separated, NEVER numbered. The first word is the group — sections sharing a group must be adjacent. All code and tests implementing a spec section MUST reference its ID in a comment (e.g., `// Implements [AUTH-TOKEN-VERIFY]`). -Always propagate these to code and tests. We want as much cross-referencing as possible - # Critical Docs - [LSP Specification 3.17](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/) @@ -147,25 +136,14 @@ Three-tier architecture: IPC: MessagePack over named pipes (Windows) / Unix domain sockets (Linux, macOS). 4-byte LE length prefix framing. Target <500us round-trip overhead. -C# and F# are equal first-class citizens. F# is NOT a second-class bolt-on. - See `docs/specs/SHARPLSP-SPEC.md` for the full technical specification. -## Code Structure - -- Small, focused functions (<20 lines) -- Low cognitive complexity (clippy::cognitive_complexity enabled) -- Descriptive variable names (no single letters except in closures) -- Group related functionality into modules -- Public APIs must have documentation - ## Bug Fix Process 1. Write a test that fails because of the bug -2. Run the test — confirm it fails BECAUSE of the bug -3. Repeat until it's failing for the right reason -4. Fix the bug (do NOT change the test) -5. Run the test — confirm it passes +2. Run it and confirm the bug is the reason it fails +3. Fix the bug without changing the test +4. Run the test and confirm it passes ## Performance Targets @@ -189,8 +167,9 @@ See `docs/specs/SHARPLSP-SPEC.md` for the full technical specification. ## Website and CSS - **MINIMIZE CSS CLASSES** — consolidate where possible +- CSS Budget 2k LOC - Name classes after what the element IS, not what section it's in -- **Do not use common LLM colors like purple** — use RNG and color wheels +- Avoid default LLM palettes such as purple ## Key Technology Stack @@ -214,15 +193,15 @@ Mapping (current → toolkit crate): | Current path | Toolkit crate | |---|---| -| `src/main.rs:138–262` `lsp-server`-based entrypoint | `lspkit-server` (hand-rolled JSON-RPC + `Dispatcher` + `Capabilities`) | -| `src/vfs.rs` `Vfs` document state | `lspkit-vfs::Vfs` + `lspkit-vfs::PositionEncoding` | -| `src/sidecar/protocol.rs` `Envelope` framing | `lspkit-sidecar::transport` (length-prefixed frames, payload format is consumer's choice) | -| `src/sidecar/transport.rs` `FramedTransport` | `lspkit-sidecar::transport::{read_frame, write_frame}` | -| `src/sidecar/manager.rs` `SidecarManager` (spawn / health / restart / correlation) | `lspkit-sidecar::lifecycle::Sidecar` + `lspkit-sidecar::correlator::Correlator` | -| `src/diagnostics.rs` + `pull_diagnostics.rs` diagnostic publication | `lspkit-server::diagnostics::DiagnosticsBus` | -| `src/config.rs` `sharplsp.toml` loader | `lspkit-config::load_from_ancestor` | -| `src/handlers.rs` syntax-only handlers | `lspkit-server::Dispatcher::register` per method name | -| `src/semantic_tokens.rs` `TokenCache` | (consumer-side cache; not in toolkit) | -| .NET sidecar projects (`sidecars/SharpLsp.Sidecar.*`) | (engine — stays here. `lspkit-sidecar` is pure transport and does not bundle .NET- or Roslyn-specific code) | +| `src/sharplsp/src/main.rs:138–262` `lsp-server`-based entrypoint | `lspkit-server` (hand-rolled JSON-RPC + `Dispatcher` + `Capabilities`) | +| `src/sharplsp/src/vfs.rs` `Vfs` document state | `lspkit-vfs::Vfs` + `lspkit-vfs::PositionEncoding` | +| `src/sharplsp/src/sidecar/protocol.rs` `Envelope` framing | `lspkit-sidecar::transport` (length-prefixed frames, payload format is consumer's choice) | +| `src/sharplsp/src/sidecar/transport.rs` `FramedTransport` | `lspkit-sidecar::transport::{read_frame, write_frame}` | +| `src/sharplsp/src/sidecar/manager.rs` `SidecarManager` (spawn / health / restart / correlation) | `lspkit-sidecar::lifecycle::Sidecar` + `lspkit-sidecar::correlator::Correlator` | +| `src/sharplsp/src/diagnostics.rs` + `pull_diagnostics.rs` diagnostic publication | `lspkit-server::diagnostics::DiagnosticsBus` | +| `src/sharplsp/src/config.rs` `sharplsp.toml` loader | `lspkit-config::load_from_ancestor` | +| `src/sharplsp/src/handlers.rs` syntax-only handlers | `lspkit-server::Dispatcher::register` per method name | +| `src/sharplsp/src/semantic_tokens.rs` `TokenCache` | (consumer-side cache; not in toolkit) | +| .NET sidecar projects (`src/sidecars/SharpLsp.Sidecar.*`) | (engine — stays here. `lspkit-sidecar` is pure transport and does not bundle .NET- or Roslyn-specific code) | Code in this repo is **not** being removed — it stays canonical until the toolkit matures. This note exists so future agents reuse `lspkit` for new servers and avoid widening this repo's scaffolding. diff --git a/Cargo.toml b/Cargo.toml index 2aff4185..f479fd5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,67 +1,7 @@ -[package] -name = "sharplsp" -version = "0.1.0" -edition = "2021" -description = "SharpLsp: The .NET Language Server Platform" -license = "MIT" -homepage = "https://github.com/Nimblesite/SharpLsp" -repository = "https://github.com/Nimblesite/SharpLsp" - -[features] -# Sequestered formatting module (Roslyn + Fantomas). Disabled by default. -# Use CSharpier for C# and Fantomas via Ionide for F# instead. -formatting = [] - -[dependencies] -# Version dispatch — Shipwright binary version contract [SWR-VERSION-RUST] -shipwright = "0.10" -shipwright-manifest = "0.10" - -# LSP -lsp-server = "0.9" -lsp-types = "0.97" - -# Tree-sitter -tree-sitter = "0.26" -tree-sitter-c-sharp = "0.23" - -# Async runtime -crossbeam-channel = "0.5" -tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "io-util", "time", "process", "sync", "macros", "fs"] } - - -# Serialization -serde = { version = "1", features = ["derive"] } -serde_json = "1" -toml = "1.1" -rmp-serde = "1" -serde_bytes = "0.11" - -# Logging -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } -tracing-appender = "0.2" - -# Error handling -anyhow = "1" - -# HTTP client (NuGet API) -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } - -# Utilities -dashmap = "6" -# RFC 8089 file:// URI ↔ path conversion (Windows drive letters, percent-decoding) -url = "2" -# Percent-decoding for file URIs with no native path representation (url's own dep) -percent-encoding = "2" - -[dev-dependencies] -tempfile = "3" -wait-timeout = "0.2" -serde_json = "1" - -[lints] -workspace = true +[workspace] +members = ["src/sharplsp"] +default-members = ["src/sharplsp"] +resolver = "2" [workspace.lints.clippy] # all + pedantic at maximum — restriction & nursery cherry-picked (contain contradictions) @@ -134,5 +74,11 @@ future_incompatible = { level = "deny", priority = -1 } nonstandard_style = { level = "deny", priority = -1 } rust_2018_idioms = { level = "deny", priority = -1 } -[target."cfg(windows)".dependencies] -sysinfo = { version = "0.39.6", default-features = false, features = ["system"] } +[workspace.package] +version = "0.1.0" +edition = "2021" +description = "SharpLsp: The .NET Language Server Platform" +license = "MIT" +homepage = "https://github.com/Nimblesite/SharpLsp" +repository = "https://github.com/Nimblesite/SharpLsp" +readme = "README.md" diff --git a/Directory.Build.props b/Directory.Build.props index 72933f8d..07ed7aa0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,62 +1,4 @@ - - net10.0 - 0.1.0 - latest - enable - enable - true - IDE0301;IDE0063;IDE0005;MSB3243 - 9999 - true - true - true - All - true - - $(WarningsAsErrors);CS8600;CS8601;CS8602;CS8603;CS8604;CS8605;CS8618;CS8619;CS8625;CS8629;CS8631;CS8633;CS8634;CS8638;CS8643;CS8644;CS8645;CS8653;CS8654;CS8655;CS8667;CS8714;CS8762;CS8764;CS8765;CS8766;CS8767;CS8768;CS8769;CS8770;CS8774;CS8775;CS8776;CS8777;CS8794;CS8795;CS8796;CS8797;CS8798;CS8847;EXHAUSTION001 - - $(WarningsAsErrors);IDE0001;IDE0042;IDE0051;IDE0052;IDE0056;IDE0060;IDE0022;IDE0002;IDE0130;IDE0060;IDE0002 - - $(WarningsAsErrors);CA1805;CA1806;CA1810;CA1812;CA1813;CA1814;CA1815;CA1816;CA1819;CA1820;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1827;CA1828;CA1829;CA1830;CA1831;CA1832;CA1833;CA1834;CA1835;CA1836;CA1837;CA1838;CA1839;CA1840;CA1841;CA1842;CA1843;CA1844;CA1845;CA1846;CA1847;CA1849;CA1850;CA1851;CA1852;CA1853;CA1854;CA1855;CA1856;CA1857;CA1858;CA1859;CA1860;CA1861;CA1862;CA1863;CA1864;CA1865;CA1866;CA1867;CA1868;CA1869;CA1870 - - $(WarningsAsErrors);CA2100;CA2101;CA2102;CA2103;CA2104;CA2105;CA2106;CA2107;CA2108;CA2109;CA2110;CA2111;CA2112;CA2113;CA2114;CA2115;CA2116;CA2117;CA2118;CA2119;CA2120;CA2121;CA2122;CA2123;CA2124;CA2125;CA2126;CA2127;CA2128;CA2129;CA2130;CA2131;CA2132;CA2133;CA2134;CA2135;CA2136;CA2137;CA2138;CA2139;CA2140;CA2141;CA2142;CA2143;CA2144;CA2145;CA2146;CA2147;CA2148;CA2149;CA2150;CA2151;CA2152;CA2153;CA2154;CA2155;CA2156;CA2157;CA2158;CA2159;CA2160 - - $(WarningsAsErrors);IDE0004;SYSLIB1045;CA1000;CA1001;CA1003;CA1005;CA1008;CA1010;CA1012;CA1018;CA1019;CA1021;CA1024;CA1027;CA1028;CA1030;CA1031;CA1032;CA1033;CA1036;CA1040;CA1041;CA1043;CA1044;CA1045;CA1046;CA1047;CA1048;CA1049;CA1050;CA1051;CA1052;CA1053;CA1054;CA1055;CA1056;CA1057;CA1058;CA1059;CA1060;CA1061;CA1063;CA1064;CA1065;CA1066;CA1067;CA1068;CA1069;CA1070 - - $(WarningsAsErrors);VSTHRD001;VSTHRD002;VSTHRD003;VSTHRD004;VSTHRD005;VSTHRD006;VSTHRD010;VSTHRD011;VSTHRD012;VSTHRD100;VSTHRD101;VSTHRD102;VSTHRD103;VSTHRD104;VSTHRD105;VSTHRD106;VSTHRD107;VSTHRD108;VSTHRD109;VSTHRD110;VSTHRD111;VSTHRD112;VSTHRD114;VSTHRD200 - - - - - - all - runtime; build; native; contentfiles; analyzers - - - - - all - runtime; build; native; contentfiles; analyzers - - - - - - all - runtime; build; native; contentfiles; analyzers - - + + diff --git a/Makefile b/Makefile index 3f8953f6..3de6c608 100644 --- a/Makefile +++ b/Makefile @@ -1,636 +1,3 @@ -# SharpLsp build system -# -# Public targets: -# make build everything (host platform, release) -# make PROFILE=debug build everything (debug) -# make ci lint → test → build -# make test run all tests with coverage -# make lint lint all languages -# make fmt format all languages -# make clean remove build artifacts -# make setup install toolchain dependencies -# make screenshots capture website screenshots from real VS Code -# make package-vsix-linux-x64 [VERSION=x.y.z] build + package VSIX for linux-x64 -# make package-vsix-linux-arm64 [VERSION=x.y.z] build + package VSIX for linux-arm64 -# make package-vsix-darwin-arm64 [VERSION=x.y.z] build + package VSIX for darwin-arm64 -# make package-vsix-darwin-x64 [VERSION=x.y.z] build + package VSIX for darwin-x64 -# make package-vsix-win32-x64 [VERSION=x.y.z] build + package VSIX for win32-x64 -# make package-vsix-win32-arm64 [VERSION=x.y.z] build + package VSIX for win32-arm64 -# -# VERSION is optional for all package-vsix-* targets; it defaults to the -# 0.0.0 placeholder when omitted. -# -# make print-publish-commands download VSIXs from latest release and print vsce publish commands - -# ── OS detection ────────────────────────────────────────────────── -# All recipes assume a POSIX shell. On Windows we use Git Bash (bundled with -# Git for Windows) — NOT WSL's bash, which lives in System32 and would mangle -# Windows paths. Install Git for Windows if no bash is found. -ifeq ($(OS),Windows_NT) - DETECTED_OS := windows - EXE_EXT := .exe - # Probe well-known Git-for-Windows install locations. DOS 8.3 short names - # avoid the space in "Program Files" which GNU Make cannot quote in SHELL. - GIT_BASH_CANDIDATES := \ - C:/PROGRA~1/Git/bin/bash.exe \ - C:/PROGRA~2/Git/bin/bash.exe \ - C:/msys64/usr/bin/bash.exe \ - C:/cygwin64/bin/bash.exe - SHELL := $(firstword $(wildcard $(GIT_BASH_CANDIDATES))) - ifeq ($(SHELL),) - $(error No POSIX bash found. Install Git for Windows from https://git-scm.com/download/win) - endif -else - DETECTED_OS := $(shell uname -s | tr '[:upper:]' '[:lower:]') - EXE_EXT := - SHELL := /bin/bash -endif -.SHELLFLAGS := -eo pipefail -c - -PROFILE ?= release -CARGO_FLAG = $(if $(filter release,$(PROFILE)),--release,) -DOTNET_CFG = $(if $(filter release,$(PROFILE)),Release,Debug) -RUST_TEST_THREADS ?= 1 -# [DIST-CI-RUST-SHARDS] CI splits the Rust e2e suite into nextest hash -# partitions (`_test-rust-shard`); SHARD_COUNT is the total number of slices. -SHARD_COUNT ?= 2 - -VSCODE_DIR = editors/vscode -ZED_DIR = editors/zed -SIDECAR_CS = sidecars/SharpLsp.Sidecar.CSharp -SIDECAR_FS = sidecars/SharpLsp.Sidecar.FSharp -SIDECAR_SLN = sidecars/SharpLsp.Sidecars.sln -RIDER_DIR = editors/rider - -BINARY = target/$(PROFILE)/sharplsp$(EXE_EXT) -SIDECAR_CS_OUT = target/sidecar-csharp -SIDECAR_FS_OUT = target/sidecar-fsharp -ZED_WASM = $(ZED_DIR)/target/wasm32-wasip1/$(PROFILE)/sharplsp_zed.wasm -ZED_PKG_DIR = target/zed-extension -ZED_PKG_TAR = sharplsp-zed-extension.tar.gz -RIDER_ZIP = sharplsp-rider.zip - -# Host platform for local VSIX dev builds -HOST_PLATFORM = $(shell node -e "process.stdout.write(process.platform + '-' + process.arch)") -HOST_VSIX_BIN = $(VSCODE_DIR)/bin/$(HOST_PLATFORM)/sharplsp$(EXE_EXT) - -PREFIX ?= $(HOME)/.local -BINDIR = $(PREFIX)/bin -CHECK_COV = scripts/check-coverage.sh - -.PHONY: build ci test lint fmt clean setup screenshots \ - package-vsix-linux-x64 package-vsix-linux-arm64 \ - package-vsix-darwin-arm64 package-vsix-darwin-x64 \ - package-vsix-win32-x64 package-vsix-win32-arm64 \ - print-publish-commands \ - _stamp-version \ - _build-rust _build-dotnet _build-vsix _build-zed _build-rider \ - _stage-vsix-binary _stage-vsix-binary-only _stage-sidecars \ - test-rust _test-rust _prepare-rust-tests _test-rust-shard \ - _gate-rust-coverage _test-vsix _test-vsix-win _check-vsix-chunks \ - _test-dotnet _test-website \ - _lint-rust _lint-zed _lint-vsix _lint-dotnet \ - _fmt-rust _fmt-zed _fmt-vsix _fmt-dotnet \ - _package-vsix \ - _deploy-rust _deploy-sidecars \ - _kill _clean-rider - -# ── Build ───────────────────────────────────────────────────────── - -build: _build-rust _build-dotnet _build-vsix _build-zed _build-rider - @echo "" - @echo "==> Build complete." - @echo " Server: $(BINARY)" - @echo " Sidecar C#: $(SIDECAR_CS_OUT)" - @echo " Sidecar F#: $(SIDECAR_FS_OUT)" - @echo " Zed: $(ZED_WASM)" - @[ -f $(RIDER_ZIP) ] && echo " Rider: $(RIDER_ZIP)" || true - -_build-rust: - @echo "==> Building sharplsp ($(PROFILE))..." - cargo build $(CARGO_FLAG) - @test -f $(BINARY) || { echo "ERROR: $(BINARY) not found" >&2; exit 1; } - -_build-dotnet: - @echo "==> Checking for .NET 10 SDK..." - @dotnet --list-sdks 2>/dev/null | grep -q '^10\.' || { \ - echo "ERROR: .NET 10 SDK is not installed. The sidecars target net10.0 and require the .NET 10 SDK to build." >&2; \ - echo " Install it from https://dot.net or via: brew install dotnet-sdk" >&2; \ - exit 1; \ - } - @echo "==> Building sidecars ($(DOTNET_CFG))..." - dotnet publish $(SIDECAR_CS)/SharpLsp.Sidecar.CSharp.csproj --configuration $(DOTNET_CFG) --no-self-contained -p:DebugType=none -p:DebugSymbols=false $(if $(VERSION),-p:Version=$(VERSION) -p:PackageVersion=$(VERSION),) --output $(SIDECAR_CS_OUT) - dotnet publish $(SIDECAR_FS)/SharpLsp.Sidecar.FSharp.fsproj --configuration $(DOTNET_CFG) --no-self-contained -p:DebugType=none -p:DebugSymbols=false $(if $(VERSION),-p:Version=$(VERSION) -p:PackageVersion=$(VERSION),) --output $(SIDECAR_FS_OUT) - -_build-vsix: _stage-vsix-binary - @echo "==> Packaging VS Code extension (host: $(HOST_PLATFORM))..." - npm run build --prefix $(VSCODE_DIR) - cd $(VSCODE_DIR) && npx @vscode/vsce package --no-dependencies -o ../../sharplsp.vsix - rm -rf $(VSCODE_DIR)/bin - -_build-zed: - @echo "==> Building Zed extension..." - @rustup target list --installed | grep -q wasm32-wasip1 || rustup target add wasm32-wasip1 - cargo build $(CARGO_FLAG) --manifest-path $(ZED_DIR)/Cargo.toml --target wasm32-wasip1 - @test -f $(ZED_WASM) || { echo "ERROR: $(ZED_WASM) not found" >&2; exit 1; } - @rm -rf $(ZED_PKG_DIR) && mkdir -p $(ZED_PKG_DIR) - cp $(ZED_DIR)/extension.toml $(ZED_DIR)/Cargo.toml $(ZED_DIR)/Cargo.lock $(ZED_PKG_DIR)/ - cp -R $(ZED_DIR)/src $(ZED_PKG_DIR)/src - rm -f $(ZED_PKG_TAR) && tar -czf $(ZED_PKG_TAR) -C $(dir $(ZED_PKG_DIR)) $(notdir $(ZED_PKG_DIR)) - -_build-rider: - @command -v java >/dev/null 2>&1 || { echo "==> Skipping Rider plugin (no java on PATH)"; exit 0; } - @echo "==> Building Rider plugin..." - cd $(RIDER_DIR) && ./gradlew buildPlugin --no-daemon - @zip=$$(ls $(RIDER_DIR)/build/distributions/sharplsp-rider-*.zip 2>/dev/null | head -n1); \ - test -n "$$zip" || { echo "ERROR: no Rider plugin zip in $(RIDER_DIR)/build/distributions/" >&2; exit 1; }; \ - cp "$$zip" $(RIDER_ZIP) - -_stage-vsix-binary: _build-rust _build-dotnet - @$(MAKE) _stage-vsix-binary-only - -# Staging with the build prerequisites stripped off. CI's Windows VSIX feature -# chunks ([DIST-CI-WIN-VSIX]) download the host binary + both sidecars as -# artifacts from a single build job and fan out, so each chunk must stage what -# is already on disk instead of rebuilding Rust and .NET seven times over. -_stage-vsix-binary-only: - @echo "==> Staging required VSIX binaries ($(HOST_PLATFORM))..." - rm -rf $(VSCODE_DIR)/bin - mkdir -p $(dir $(HOST_VSIX_BIN)) $(VSCODE_DIR)/bin/all - cp $(BINARY) $(HOST_VSIX_BIN) - chmod +x $(HOST_VSIX_BIN) 2>/dev/null || true - cp -r $(SIDECAR_CS_OUT)/. $(VSCODE_DIR)/bin/all/ - cp -r $(SIDECAR_FS_OUT)/. $(VSCODE_DIR)/bin/all/ - @mv $(VSCODE_DIR)/bin/all/SharpLsp.Sidecar.CSharp$(EXE_EXT) \ - $(VSCODE_DIR)/bin/all/sharplsp-sidecar-csharp$(EXE_EXT) 2>/dev/null || true - @mv $(VSCODE_DIR)/bin/all/SharpLsp.Sidecar.FSharp$(EXE_EXT) \ - $(VSCODE_DIR)/bin/all/sharplsp-sidecar-fsharp$(EXE_EXT) 2>/dev/null || true - chmod +x $(VSCODE_DIR)/bin/all/sharplsp-sidecar-csharp$(EXE_EXT) \ - $(VSCODE_DIR)/bin/all/sharplsp-sidecar-fsharp$(EXE_EXT) 2>/dev/null || true - @$(VERIFY_STAGED_SIDECARS) - @bash scripts/fetch-netcoredbg.sh $(HOST_PLATFORM) - -# A .NET apphost is only a launcher: strip SharpLsp.Sidecar..dll from beside -# it and the executable still EXISTS but cannot run. Every copy/rename above is -# best-effort (`2>/dev/null || true`), and a publish raced by a concurrent rebuild -# can hand us an incomplete tree, so "the file is there" proves nothing. -# -# Without this check such a stage packages cleanly, passes the existence tests, and -# only fails on the user's machine: shipwright runs `--version` (its -# `versionCheckStrategy`), rejects the unusable `bundled` source, falls through to -# the `path` source and reports "required binaries are missing" against whatever -# unrelated PATH directory it tried last. Fail here instead. [DIST-FAILURE-UX] -VERIFY_STAGED_SIDECARS = \ - for sidecar in sharplsp-sidecar-csharp sharplsp-sidecar-fsharp; do \ - out="$$("$(VSCODE_DIR)/bin/all/$$sidecar$(EXE_EXT)" --version 2>&1)" || { \ - echo "ERROR: staged $$sidecar does not run: $$out" >&2; \ - echo " The apphost is staged without its managed assembly, or the" >&2; \ - echo " publish output was incomplete. Re-run: make _build-dotnet" >&2; \ - exit 1; \ - }; \ - echo " verified $$out"; \ - done - -_stage-sidecars: - @mkdir -p target/debug/sidecar-csharp target/debug/sidecar-fsharp - @mkdir -p target/llvm-cov-target/debug/sidecar-csharp target/llvm-cov-target/debug/sidecar-fsharp - @cp -r $(SIDECAR_CS_OUT)/. target/debug/sidecar-csharp/ - @cp -r $(SIDECAR_FS_OUT)/. target/debug/sidecar-fsharp/ - @cp -r $(SIDECAR_CS_OUT)/. target/llvm-cov-target/debug/sidecar-csharp/ - @cp -r $(SIDECAR_FS_OUT)/. target/llvm-cov-target/debug/sidecar-fsharp/ - -# ── CI ──────────────────────────────────────────────────────────── - -ci: lint test build - @echo "==> CI pipeline passed." - -# ── Test ───────────────────────────────────────────────────────── - -test: _test-rust _test-vsix _test-dotnet _test-website - @echo "==> All tests passed." - -# Public alias — CI and developers call this. -test-rust: _test-rust - -# The e2e tests spawn the real sidecars from these paths. -RUST_E2E_SIDECARS = \ - SHARPLSP_CSHARP_SIDECAR_PATH="$(abspath $(SIDECAR_CS_OUT))/SharpLsp.Sidecar.CSharp" \ - SHARPLSP_FSHARP_SIDECAR_PATH="$(abspath $(SIDECAR_FS_OUT))/SharpLsp.Sidecar.FSharp" - -_prepare-rust-tests: _build-dotnet _stage-sidecars - @echo "==> Pre-building ProfileTarget fixture..." - dotnet build tests/fixtures/ProfileTarget/ProfileTarget.csproj -c Release --nologo -v q - -_test-rust: _prepare-rust-tests - @echo "==> Running sharplsp tests with coverage..." - # --no-fail-fast is intentional ([TEST-RULES] documented exception): coverage - # enforcement requires every test to run so the measured line percentage is - # complete; stopping at the first failure would under-report coverage and make - # the threshold gate meaningless. A real test failure still fails the build via - # nextest's non-zero exit, which then fails `make test`. - $(RUST_E2E_SIDECARS) \ - cargo llvm-cov nextest --json --output-path target/coverage-rust.json --no-fail-fast --test-threads $(RUST_TEST_THREADS) - @$(CHECK_COV) sharplsp "$$(jq '.data[0].totals.lines.percent' target/coverage-rust.json)" - -# [DIST-CI-RUST-SHARDS] One CI slice of the suite: identical tests, identical -# serialization (RUST_TEST_THREADS), but only the hash:$(SHARD)/$(SHARD_COUNT) -# nextest partition. Exports lcov instead of JSON so _gate-rust-coverage can -# union the shards. The coverage gate deliberately does NOT run here — a -# partition can never meet the full-suite threshold on its own. -_test-rust-shard: _prepare-rust-tests - @test -n "$(SHARD)" || { echo "ERROR: SHARD is required (e.g. make _test-rust-shard SHARD=1)" >&2; exit 1; } - @echo "==> Running sharplsp test shard $(SHARD)/$(SHARD_COUNT) with coverage..." - $(RUST_E2E_SIDECARS) \ - cargo llvm-cov nextest --lcov --output-path target/coverage-rust-shard$(SHARD).lcov \ - --no-fail-fast --test-threads $(RUST_TEST_THREADS) --partition hash:$(SHARD)/$(SHARD_COUNT) - -# [DIST-CI-RUST-SHARDS] Union-merge the shard tracefiles and enforce the same -# ratcheted threshold a single-job run enforces. -_gate-rust-coverage: - @PERCENT="$$(node scripts/merge-lcov.mjs target/coverage-rust.lcov target/coverage-rust-shard*.lcov)" && \ - $(CHECK_COV) sharplsp "$$PERCENT" - -# Every SharpLsp path override the extension honours, cleared so the test host -# resolves ONLY the freshly-staged bundled binaries — never a dev copy that -# leaked onto PATH or into the environment. -VSIX_TEST_ENV = env -u SHARPLSP_EXECUTABLE_PATH \ - -u SHARPLSP_LSP_PATH \ - -u SHARPLSP_BINARY_DIR \ - -u SHARPLSP_CSHARP_SIDECAR_PATH \ - -u SHARPLSP_FSHARP_SIDECAR_PATH \ - -u FORGE_LSP_PATH \ - -u FORGE_BINARY_DIR - -_test-vsix: _build-rust _build-dotnet _build-vsix _stage-vsix-binary - @echo "==> Running VS Code extension tests..." - @$(MAKE) _stage-vsix-binary - status=0; \ - cd $(VSCODE_DIR); \ - $(VSIX_TEST_ENV) npm test -- --coverage || status=$$?; \ - rm -rf "$(abspath $(VSCODE_DIR))/bin" || true; \ - exit $$status - @$(CHECK_COV) vscode-extension "$$(jq '.total.lines.pct' $(VSCODE_DIR)/coverage/coverage-summary.json)" - -# ── VSIX Windows feature chunks ─────────────────────────────────── -# [DIST-CI-WIN-VSIX] Runs ONE declared feature chunk of the VS Code end-to-end -# suite — the same suites the Ubuntu `_test-vsix` job runs, sliced so each -# chunk is one parallel Windows CI job. Every chunk drives the REAL LSP -# (sharplsp host + Roslyn/FCS sidecars) through the actual VS Code extension -# host over win32 named-pipe IPC, which the Linux-only `_test-vsix` job can -# never exercise (same rationale as test-dotnet-windows / -# [DIST-CI-WIN-TRANSPORT], one level up: that job checks the pipes, these check -# the whole editor experience on top of them — debugging, profiling, the test -# explorer, the solution tree, scaffolding, NuGet, and both languages' LSP). -# -# Chunk membership lives in editors/vscode/test-chunks.json (single source of -# truth, never duplicated into CI YAML); scripts/vsix-test-chunks.mjs turns a -# chunk name into the MOCHA_FILES glob list the inner mocha runner applies, and -# `_check-vsix-chunks` fails lint if any suite escapes every chunk. -# -# Deliberately runs WITHOUT --coverage and skips the coverage gate: one chunk -# can't meet the line threshold, so the Ubuntu `_test-vsix` job owns coverage. -VSIX_CHUNKS = node scripts/vsix-test-chunks.mjs - -_check-vsix-chunks: - @$(VSIX_CHUNKS) check - -_test-vsix-win: _stage-vsix-binary-only - @test -n "$(CHUNK)" || { echo "ERROR: CHUNK is required (e.g. make _test-vsix-win CHUNK=debug)" >&2; exit 1; } - @echo "==> Running VS Code extension chunk '$(CHUNK)' (real LSP, no coverage)..." - status=0; \ - files="$$($(VSIX_CHUNKS) files $(CHUNK))"; \ - cd $(VSCODE_DIR); \ - npm run pretest && $(VSIX_TEST_ENV) MOCHA_FILES="$$files" npx vscode-test || status=$$?; \ - rm -rf "$(abspath $(VSCODE_DIR))/bin" || true; \ - exit $$status - -_test-dotnet: _build-dotnet - @echo "==> Running .NET sidecar tests..." - @rm -rf target/coverage-dotnet - dotnet test $(SIDECAR_SLN) --configuration $(DOTNET_CFG) \ - --collect:"XPlat Code Coverage" \ - --results-directory target/coverage-dotnet \ - --settings coverlet.runsettings \ - -- RunConfiguration.FailFastEnabled=true - @_check_cov() { \ - local pkg=$$1 label=$$2 ; \ - pct=$$(for f in target/coverage-dotnet/*/coverage.cobertura.xml; do \ - sed -n "s/.*package name=\"$$pkg\" line-rate=\"\([^\"]*\)\".*/\1/p" "$$f" 2>/dev/null | head -1; \ - done | sort -rn | head -1) ; \ - $(CHECK_COV) "$$label" "$$(echo "$${pct:-0} * 100" | bc 2>/dev/null || echo 0)" ; \ - } ; \ - _check_cov SharpLsp.Sidecar.CSharp sharplsp-sidecar-csharp ; \ - _check_cov SharpLsp.Sidecar.FSharp sharplsp-sidecar-fsharp ; \ - _check_cov SharpLsp.Sidecar.Common sharplsp-sidecar-common - -_test-website: - @echo "==> Running website Playwright tests..." - cd website && npm ci && npx playwright install --with-deps chromium && npx playwright test - -# ── Lint ───────────────────────────────────────────────────────── - -lint: build _lint-rust _lint-zed _lint-vsix _lint-dotnet - @echo "==> All lints passed." - -_lint-rust: - cargo fmt --check - cargo clippy $(CARGO_FLAG) --all-targets -- -D warnings - -_lint-zed: - cargo fmt --manifest-path $(ZED_DIR)/Cargo.toml --check - cargo clippy --manifest-path $(ZED_DIR)/Cargo.toml --all-targets -- -D warnings - -_lint-vsix: _check-vsix-chunks - npm run lint:eslint --prefix $(VSCODE_DIR) - npm run typecheck --prefix $(VSCODE_DIR) - -# Dash-form MSBuild switches only: Git Bash (MSYS) mangles slash-form switches -# like `/p:...` on Windows (strips the `/`, MSBuild then reads it as a project -# path and fails with MSB1008). Dash-form behaves identically on all platforms. -_lint-dotnet: - dotnet build $(SIDECAR_SLN) --configuration $(DOTNET_CFG) -warnaserror \ - -p:UseSharedCompilation=false -nodeReuse:false -maxcpucount:1 - -# ── Format ─────────────────────────────────────────────────────── - -fmt: _fmt-rust _fmt-zed _fmt-vsix _fmt-dotnet - @echo "==> All formatting complete." - -_fmt-rust: - cargo fmt - -_fmt-zed: - cargo fmt --manifest-path $(ZED_DIR)/Cargo.toml - -_fmt-vsix: - cd $(VSCODE_DIR) && npx prettier --write 'src/**/*.ts' - -_fmt-dotnet: - dotnet csharpier format $(SIDECAR_SLN)/.. - dotnet format $(SIDECAR_SLN) - -# ── Screenshots ─────────────────────────────────────────────────── - -screenshots: _build-rust _build-dotnet _build-vsix - @echo "==> Capturing all website screenshots from real VS Code..." - # MUST re-stage in a fresh make process, exactly as _test-vsix does. Listing - # _stage-vsix-binary as a prerequisite does NOT work: _build-vsix already - # depends on it, so make marks it updated and skips it here — and the last - # thing _build-vsix's recipe does is `rm -rf $(VSCODE_DIR)/bin`. Without this - # line the screenshot run starts with no bundled binary at all, activation is - # blocked by shipwright, and every capture comes out empty. - @$(MAKE) _stage-vsix-binary-only - (cd $(VSCODE_DIR) && node src/test/suite/screenshot-watcher.mjs) & \ - WATCHER_PID=$$!; \ - cd $(VSCODE_DIR) && \ - env -u SHARPLSP_EXECUTABLE_PATH \ - -u SHARPLSP_LSP_PATH \ - -u SHARPLSP_BINARY_DIR \ - SHARPLSP_SCREENSHOTS=1 \ - SHARPLSP_CSHARP_SIDECAR_PATH="$(abspath $(SIDECAR_CS_OUT))/SharpLsp.Sidecar.CSharp" \ - SHARPLSP_FSHARP_SIDECAR_PATH="$(abspath $(SIDECAR_FS_OUT))/SharpLsp.Sidecar.FSharp" \ - npm test -- --coverage; \ - STATUS=$$?; \ - kill $$WATCHER_PID 2>/dev/null || true; \ - rm -rf "$(abspath $(VSCODE_DIR))/bin"; \ - exit $$STATUS - -# ── Version stamping ───────────────────────────────────────────── -# Rewrites the version field in all manifest files before a package build. -# Invoked only by the package-vsix-* targets, which supply VERSION (defaulting -# to the 0.0.0 placeholder when the caller omits it — see PACKAGE_VSIX_TARGETS). - -_stamp-version: - @echo "==> Stamping version $(VERSION) into all manifests..." - sed -i.bak 's/^version = "[^"]*"/version = "$(VERSION)"/' Cargo.toml - sed -i.bak 's/^version = "[^"]*"/version = "$(VERSION)"/' $(ZED_DIR)/Cargo.toml - sed -i.bak 's/^version = "[^"]*"/version = "$(VERSION)"/' $(ZED_DIR)/extension.toml - node -e " \ - const fs = require('fs'); \ - const p = '$(VSCODE_DIR)/package.json'; \ - const j = JSON.parse(fs.readFileSync(p,'utf8')); \ - j.version = '$(VERSION)'; \ - fs.writeFileSync(p, JSON.stringify(j, null, 2) + '\n'); \ - " - node -e " \ - const fs = require('fs'); \ - const p = '$(VSCODE_DIR)/package-lock.json'; \ - const j = JSON.parse(fs.readFileSync(p, 'utf8')); \ - j.version = '$(VERSION)'; \ - if (j.packages && j.packages['']) j.packages[''].version = '$(VERSION)'; \ - fs.writeFileSync(p, JSON.stringify(j, null, 2) + '\n'); \ - " - node -e " \ - const fs = require('fs'); \ - const p = '$(VSCODE_DIR)/shipwright.json'; \ - const j = JSON.parse(fs.readFileSync(p,'utf8')); \ - j.product.version = '$(VERSION)'; \ - fs.writeFileSync(p, JSON.stringify(j, null, 2) + '\n'); \ - " - node -e " \ - const fs = require('fs'); \ - const p = 'shipwright.json'; \ - const j = JSON.parse(fs.readFileSync(p,'utf8')); \ - j.product.version = '$(VERSION)'; \ - fs.writeFileSync(p, JSON.stringify(j, null, 2) + '\n'); \ - " - @find . -name '*.bak' -maxdepth 3 -delete 2>/dev/null || true - @echo "==> Version $(VERSION) stamped." - -# ── Package VSIX (per platform) ─────────────────────────────────── -# Builds the Rust binary for the given target triple, stages it, and packages -# a platform-specific VSIX into dist/. -# -# Usage: -# make package-vsix-darwin-arm64 (VERSION defaults to 0.0.0) -# make package-vsix-darwin-arm64 VERSION=0.3.0 -# make package-vsix-darwin-arm64 RUST_TARGET=aarch64-apple-darwin VERSION=0.3.0 -# -# VERSION is optional (defaults to the 0.0.0 placeholder) and is stamped into -# all manifests before building. -# RUST_TARGET defaults to the canonical triple for each platform. - -package-vsix-linux-x64: RUST_TARGET ?= x86_64-unknown-linux-gnu -package-vsix-linux-arm64: RUST_TARGET ?= aarch64-unknown-linux-gnu -package-vsix-darwin-arm64: RUST_TARGET ?= aarch64-apple-darwin -# package-vsix-darwin-x64: RUST_TARGET ?= x86_64-apple-darwin -package-vsix-win32-x64: RUST_TARGET ?= x86_64-pc-windows-msvc -package-vsix-win32-arm64: RUST_TARGET ?= aarch64-pc-windows-msvc - -PACKAGE_VSIX_TARGETS = \ - package-vsix-linux-x64 package-vsix-linux-arm64 \ - package-vsix-darwin-arm64 package-vsix-darwin-x64 \ - package-vsix-win32-x64 package-vsix-win32-arm64 - -# VERSION is optional and scoped to packaging ONLY. As a target-specific -# variable it also reaches the _stamp-version prerequisite and the recursive -# _build-dotnet / _package-vsix sub-makes, so the whole package build shares one -# version. When the caller omits it, the 0.0.0 placeholder is stamped (valid -# SemVer, no '-', so it never trips the --pre-release path). It is deliberately -# NOT a global default: standalone build/test invocations leave VERSION empty so -# each project keeps its committed baseline version and the sidecar `--version` -# contract (test-dotnet) holds. A release passes VERSION=x.y.z, overriding this. -$(PACKAGE_VSIX_TARGETS): VERSION ?= 0.0.0 - -$(PACKAGE_VSIX_TARGETS): _stamp-version - $(eval VSIX_PLAT := $(subst package-vsix-,,$@)) - $(eval EXE := $(if $(filter win32-%,$(VSIX_PLAT)),.exe,)) - @echo "==> Building sharplsp for $(RUST_TARGET)..." - cargo build --release --target $(RUST_TARGET) - $(MAKE) _build-dotnet DOTNET_CFG=Release VERSION=$(VERSION) - $(MAKE) _package-vsix VSIX_PLAT=$(VSIX_PLAT) RUST_TARGET=$(RUST_TARGET) EXE=$(EXE) VERSION=$(VERSION) - -_package-vsix: - @echo "==> Packaging VSIX for $(VSIX_PLAT)..." - rm -rf $(VSCODE_DIR)/bin/$(VSIX_PLAT) $(VSCODE_DIR)/bin/all - mkdir -p $(VSCODE_DIR)/bin/$(VSIX_PLAT) $(VSCODE_DIR)/bin/all - cp target/$(RUST_TARGET)/release/sharplsp$(EXE) $(VSCODE_DIR)/bin/$(VSIX_PLAT)/sharplsp$(EXE) - chmod +x $(VSCODE_DIR)/bin/$(VSIX_PLAT)/sharplsp$(EXE) 2>/dev/null || true - cp -r $(SIDECAR_CS_OUT)/. $(VSCODE_DIR)/bin/all/ - cp -r $(SIDECAR_FS_OUT)/. $(VSCODE_DIR)/bin/all/ - @mv $(VSCODE_DIR)/bin/all/SharpLsp.Sidecar.CSharp$(EXE_EXT) \ - $(VSCODE_DIR)/bin/all/sharplsp-sidecar-csharp$(EXE_EXT) 2>/dev/null || true - @mv $(VSCODE_DIR)/bin/all/SharpLsp.Sidecar.FSharp$(EXE_EXT) \ - $(VSCODE_DIR)/bin/all/sharplsp-sidecar-fsharp$(EXE_EXT) 2>/dev/null || true - chmod +x $(VSCODE_DIR)/bin/all/sharplsp-sidecar-csharp$(EXE_EXT) \ - $(VSCODE_DIR)/bin/all/sharplsp-sidecar-fsharp$(EXE_EXT) 2>/dev/null || true - @bash scripts/fetch-netcoredbg.sh $(VSIX_PLAT) - npm run build --prefix $(VSCODE_DIR) - mkdir -p dist - # vsce/ovsx refuse to PUBLISH with --pre-release unless the VSIX was also - # PACKAGED with --pre-release (it sets preRelease=true in the embedded - # manifest). A hyphenated SemVer VERSION (e.g. 0.2.0-rc.1) is a prerelease. - cd $(VSCODE_DIR) && npx @vscode/vsce package --no-dependencies \ - $(if $(findstring -,$(VERSION)),--pre-release,) \ - --target $(VSIX_PLAT) \ - -o ../../dist/sharplsp-$(VSIX_PLAT).vsix - rm -rf $(VSCODE_DIR)/bin - @echo "==> dist/sharplsp-$(VSIX_PLAT).vsix ready." - -# ── Marketplace publish helpers ────────────────────────────────── -# Downloads all VSIX assets from the latest GitHub release and prints the -# vsce publish command for each one. Does NOT publish anything. -# -# Usage: -# make print-publish-commands - -print-publish-commands: - @echo "==> Fetching VSIX assets from latest release..." - @mkdir -p dist/publish-latest - @gh release download --pattern "*.vsix" --dir dist/publish-latest --clobber - @echo "" - @echo "==> Run these commands to publish to the VS Code Marketplace:" - @echo "" - @for vsix in dist/publish-latest/*.vsix; do \ - echo "npx @vscode/vsce publish --packagePath $$vsix"; \ - done - @echo "" - -# ── Deploy (private) ───────────────────────────────────────────── - -_deploy-rust: - @echo "==> Installing sharplsp to $(BINDIR)/..." - mkdir -p $(BINDIR) - cp $(BINARY) $(BINDIR)/sharplsp - chmod +x $(BINDIR)/sharplsp - -_deploy-sidecars: - @echo "==> Installing sidecars to $(BINDIR)/..." - mkdir -p $(BINDIR) - cp -r $(SIDECAR_CS_OUT)/. $(BINDIR)/ - cp -r $(SIDECAR_FS_OUT)/. $(BINDIR)/ - @mv $(BINDIR)/SharpLsp.Sidecar.CSharp \ - $(BINDIR)/sharplsp-sidecar-csharp 2>/dev/null || true - @mv $(BINDIR)/SharpLsp.Sidecar.FSharp \ - $(BINDIR)/sharplsp-sidecar-fsharp 2>/dev/null || true - chmod +x $(BINDIR)/sharplsp-sidecar-csharp \ - $(BINDIR)/sharplsp-sidecar-fsharp 2>/dev/null || true - -# ── Install (private) ───────────────────────────────────────────── - -_uninstall-vsix: - @echo "==> Uninstalling existing SharpLsp extension..." - -code --uninstall-extension sharplsp.sharp-lsp 2>/dev/null || true - -_install-binaries: _kill _build-rust _build-dotnet _deploy-rust _deploy-sidecars - @echo "==> All binaries installed:" - @echo " $(BINDIR)/sharplsp" - @echo " $(BINDIR)/sharplsp-sidecar-csharp" - @echo " $(BINDIR)/sharplsp-sidecar-fsharp" - -_install-rust: _build-rust _kill _deploy-rust - @echo "==> Installed: $(BINDIR)/sharplsp" - -_install-sidecars: _build-dotnet _kill _deploy-sidecars - @echo "==> Sidecars installed." - -# ── Kill (private) ──────────────────────────────────────────────── - -_kill: - @echo "==> Killing stale sharplsp processes..." - -@pkill -9 -f 'sharplsp' 2>/dev/null || true - -@pkill -9 -f 'SharpLsp\.Sidecar\.' 2>/dev/null || true - @sleep 0.5 - -# ── Clean ───────────────────────────────────────────────────────── - -clean: _clean-rider - @echo "==> Cleaning build artifacts..." - cargo clean - cargo clean --manifest-path $(ZED_DIR)/Cargo.toml - rm -rf $(SIDECAR_CS_OUT) $(SIDECAR_FS_OUT) - rm -rf $(VSCODE_DIR)/bin $(VSCODE_DIR)/dist $(VSCODE_DIR)/out - rm -rf $(ZED_PKG_DIR) dist - rm -f sharplsp.vsix $(ZED_PKG_TAR) - @echo "==> Clean." - -_clean-rider: - @[ -d $(RIDER_DIR) ] && command -v java >/dev/null 2>&1 && \ - cd $(RIDER_DIR) && ./gradlew clean --no-daemon || true - rm -rf $(RIDER_DIR)/build $(RIDER_DIR)/.gradle $(RIDER_ZIP) - -# ── Setup ───────────────────────────────────────────────────────── - -setup: - @echo "==> Setting up development environment..." - rustup component add clippy rustfmt llvm-tools-preview - cargo install cargo-llvm-cov || true - npm install --prefix $(VSCODE_DIR) - dotnet restore $(SIDECAR_SLN) - dotnet tool restore - @echo "==> Setup complete. Run 'make ci' to validate." - -# ── .NET 10 SDK + Runtime install/uninstall ─────────────────────── - -DOTNET_INSTALL_SCRIPT = $(HOME)/.dotnet-install/dotnet-install.sh - -install-dotnet-10: - @echo "==> Installing .NET 10 SDK + runtime via dotnet-install.sh..." - @mkdir -p $(HOME)/.dotnet-install - @if [ ! -f $(DOTNET_INSTALL_SCRIPT) ]; then \ - echo "==> Downloading dotnet-install.sh..."; \ - curl -sSL https://dot.net/v1/dotnet-install.sh -o $(DOTNET_INSTALL_SCRIPT); \ - chmod +x $(DOTNET_INSTALL_SCRIPT); \ - else \ - echo "==> dotnet-install.sh already cached at $(DOTNET_INSTALL_SCRIPT)"; \ - fi - sudo bash $(DOTNET_INSTALL_SCRIPT) --channel 10.0 --install-dir /usr/local/share/dotnet - @echo "==> .NET 10 installed:" - @dotnet --list-sdks | grep '^10\.' || true - @dotnet --list-runtimes | grep '^Microsoft.*10\.' || true - -uninstall-dotnet-10: - @echo "==> Uninstalling .NET 10 SDK + runtime from /usr/local/share/dotnet..." - @for sdk in $$(dotnet --list-sdks 2>/dev/null | awk '/^10\./ {print $$1}'); do \ - echo " Removing SDK $$sdk..."; \ - sudo rm -rf "/usr/local/share/dotnet/sdk/$$sdk"; \ - done - @for rt in $$(dotnet --list-runtimes 2>/dev/null | awk '/10\./ {print $$2}'); do \ - echo " Removing runtime $$rt..."; \ - sudo rm -rf "/usr/local/share/dotnet/shared/Microsoft.NETCore.App/$$rt"; \ - sudo rm -rf "/usr/local/share/dotnet/shared/Microsoft.AspNetCore.App/$$rt"; \ - sudo rm -rf "/usr/local/share/dotnet/host/fxr/$$rt"; \ - done - @echo "==> .NET 10 removed. Remaining:" - @dotnet --list-sdks || true - @dotnet --list-runtimes || true +# SharpLsp build entry point. +# Keep this root shim for conventional `make` discovery; implementation lives below. +include tools/make/main.mk diff --git a/README.md b/README.md index efa95905..163f23a7 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ Rider, Zed, Neovim, Helix, and Emacs support is coming soon. Full documentation is available at [sharplsp.dev/docs](https://sharplsp.dev/docs). +The repository includes a complete [`sharplsp.toml` configuration template](src/examples/config/sharplsp.example.toml). + For the full argument behind the project, read [Why .NET Needs Editor-Agnostic Tooling](https://sharplsp.dev/blog/editor-agnostic-dotnet-lsp/). ## Contributing diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 59205286..490a6aa0 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -28,7 +28,7 @@ which this file satisfies. - **Component:** `netcoredbg` (managed-code debugger / DAP adapter), bundled per platform at `bin//netcoredbg/` and launched by the - `sharplsp-coreclr` debug adapter factory (`editors/vscode/src/debug.ts`). + `sharplsp-coreclr` debug adapter factory (`src/editors/vscode/src/debug.ts`). - **Upstream:** https://github.com/Samsung/netcoredbg - **Pinned version:** `3.2.0-1092` - **License:** MIT — **© 2017 Samsung Electronics Co., LTD** (verified against diff --git a/docs/bugs/BUILD-GENERATEDEPSFILE-LOCK-BUG.md b/docs/bugs/BUILD-GENERATEDEPSFILE-LOCK-BUG.md deleted file mode 100644 index b727e431..00000000 --- a/docs/bugs/BUILD-GENERATEDEPSFILE-LOCK-BUG.md +++ /dev/null @@ -1,138 +0,0 @@ -# BUILD-GENERATEDEPSFILE-LOCK — `GenerateDepsFile` fails: deps.json "used by another process" - -- **Status:** RESOLVED (2026-06-22) — see [Resolution](#resolution) -- **Severity:** Critical — blocks `dotnet build` of a sidecar project -- **Date logged:** 2026-06-22 -- **Reporter:** Christian Findlay -- **Tracking:** GitHub issue [#111](https://github.com/Nimblesite/SharpLsp/issues/111) -- **Area:** Build / .NET sidecars (`sidecars/SharpLsp.Sidecar.Common`) -- **Reproducibility:** Intermittent (file-lock race) - -## Resolution - -`SharpLsp.Sidecar.Common` is a **referenced-only class library** — its `deps.json` -is never read at runtime (the executable sidecar `SharpLsp.Sidecar.CSharp` and the -test project each generate their own `deps.json`, which already enumerate Common's -dependency graph). That unused artifact existed only to be re-written into `bin/` -on every build, where a transient holder (build-server node / Spotlight indexer) -could lock it and fail the `GenerateDepsFile` task with MSB4018. - -**Fix:** set `false` on the Common -library ([sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj](../../sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj)). -No `deps.json` is generated for Common, so the lock-prone write — and the MSB4018 -failure — can no longer occur for this project. - -**Test:** [tests/build_deps_file_e2e.rs](../../tests/build_deps_file_e2e.rs) — -`common_library_disables_dependency_file_generation` evaluates the real `.csproj` -via `dotnet msbuild -getProperty:GenerateDependencyFile` and asserts it is `false` -(failed pre-fix with `true`, passes post-fix). - -**Verification:** full `SharpLsp.Sidecars.sln` build succeeds (0 warnings, 0 errors); -Common emits its DLL but no `deps.json`; `SharpLsp.Sidecar.CSharp.deps.json` still -lists Common (runtime unaffected). - -**Follow-up (not blocking):** executable projects (CSharp/FSharp sidecars) legitimately -need a `deps.json` and could still hit the same transient lock. If it recurs there, -apply the systemic mitigation (disable MSBuild server / node reuse for repo + CI -builds) tracked in the original analysis below. - -## Symptom - -Building the Common sidecar project on its own fails during the -`GenerateDepsFile` MSBuild task with an `IOException` saying the generated -`deps.json` is locked by another process. - -``` -dotnet build sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj - -/usr/local/share/dotnet/sdk/10.0.203/Sdks/Microsoft.NET.Sdk/targets/Microsoft.NET.Sdk.targets(308,5): error MSB4018: - The "GenerateDepsFile" task failed unexpectedly. - System.IO.IOException: The process cannot access the file - '.../sidecars/SharpLsp.Sidecar.Common/bin/Debug/net10.0/SharpLsp.Sidecar.Common.deps.json' - because it is being used by another process. - at Microsoft.Win32.SafeHandles.SafeFileHandle.Init(...) - at System.IO.File.Create(String path) - at Microsoft.NET.Build.Tasks.GenerateDepsFile.WriteDepsFile(String depsFilePath) - at Microsoft.NET.Build.Tasks.TaskBase.Execute() - -Build failed with 1 error(s) in 2.9s -``` - -## Reproduction (as observed) - -1. Build the whole sidecar solution — **succeeds**: - `dotnet build sidecars/SharpLsp.Sidecars.sln` -2. Immediately build the Common project alone — **fails** with the error above: - `dotnet build sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj` - -The failure does not reproduce every time — a subsequent build wrote the file -successfully (it is present on disk, 8906 bytes), confirming a transient lock -rather than a permanently held handle. - -## Environment - -- **OS:** macOS (Darwin 25.5.0), arm64 -- **.NET SDK:** 10.0.203 -- **Target:** `net10.0` -- **Project:** `SharpLsp.Sidecar.Common` (class library — no `OutputType`) - -## Diagnostics captured at time of failure - -- No process was holding the `deps.json` by the time `lsof` ran (lock had already - been released — consistent with a transient/race lock). -- A persistent Roslyn build-server node was alive and had started right around - the failing build: - `…/sdk/10.0.203/Roslyn/bincore/VBCSCompiler -pipename:…` (started ~21:02). -- Several **long-running SharpLsp sidecar processes** were running, but all from - the **installed VS Code extension** directories - (`~/.vscode/extensions/nimblesite.sharplsp-*/bin/all/sharplsp-sidecar-*`), - **not** from the repo's `bin/Debug` output. These therefore do not hold a - handle on the repo's `deps.json` and are not the direct cause, though they - confirm sidecars are designed to be long-lived. - -## Suspected root cause - -A race on the freshly written `bin/Debug/net10.0/SharpLsp.Sidecar.Common.deps.json`: -`GenerateDepsFile` calls `File.Create` while another process still has a handle -open on the just-emitted file. On macOS the most likely transient holders are: - -1. **Persistent build-server / compiler node** (`VBCSCompiler`, MSBuild node - reuse) carrying handles to project outputs across back-to-back builds — note a - live `VBCSCompiler` was observed at failure time. -2. **Spotlight / file indexing** (`mdworker`/`mds`) momentarily opening the newly - created `.deps.json`. -3. **Concurrent writers** to the same output path — e.g. the IDE's background - build (or an in-flight build-server request) overlapping the CLI build of the - same project right after a full-solution build. - -The intermittency, the prior full-solution build, and the live build-server node -together point at handle reuse / indexing rather than a SharpLsp code defect. - -## Workarounds (not yet verified as fixes) - -- Disable build-server/node reuse for the failing build: - `dotnet build … /nodeReuse:false /p:UseRazorBuildServer=false` and/or - `dotnet build-server shutdown` before rebuilding. -- Re-run the build (the lock is transient and usually clears on retry). -- `export DOTNET_CLI_USE_MSBUILD_SERVER=0` for repo builds. -- Exclude `**/bin/` and `**/obj/` from Spotlight indexing for this workspace. - -## Proposed fix / next steps - -- [ ] Reproduce deterministically (tight loop alternating solution build then - single-project build; vary `nodeReuse`/build-server on and off). -- [ ] Confirm which process holds the handle (`lsof` in a loop, or `fs_usage` - filtered on `deps.json` during the build) — distinguish build-server vs. - `mdworker`. -- [ ] If build-server/node reuse is the cause, standardize repo builds (Makefile - / CI) on `nodeReuse:false` or `DOTNET_CLI_USE_MSBUILD_SERVER=0`, and keep - `.devcontainer`/`ci.yml` in sync per CLAUDE.md. -- [ ] Consider a build-output exclusion from indexing as a developer-environment - note in the README. -- [ ] Do **not** kill VS Code processes as part of any fix (CLAUDE.md hard rule). - -## Notes - -Per CLAUDE.md this repo does not use Git/GitHub issues for tracking, so this bug -is logged here under `docs/bugs/`. Move to `docs/specs`/`docs/plans` only if it -turns into a structural change to the build setup. diff --git a/docs/bugs/open-issues.csv b/docs/bugs/open-issues.csv new file mode 100644 index 00000000..e6d335eb --- /dev/null +++ b/docs/bugs/open-issues.csv @@ -0,0 +1,56 @@ +"repository","issue_number","issue_url","title","author_login","author_name","summary_line_1","summary_line_2","labels","issue_date","created_at_utc","updated_at_utc","state","comments_count","component","functionality_area","functionality_subarea","language_scope","area_source","area_confidence","parent_issue_number","child_issue_numbers" +"Nimblesite/SharpLsp","9","https://github.com/Nimblesite/SharpLsp/issues/9","Rename: classes, structs, interfaces, records, and delegates","MelbourneDeveloper","MelbourneDeveloper","Add semantic rename for C# and F# type-like declarations.","Update constructors and references across real solutions with invalid-name e2e coverage.",".NET; cluster:rename","2026-04-27","2026-04-27T06:57:52Z","2026-08-03T09:45:12Z","open","0",".NET sidecars (Roslyn/FCS)","Code actions & refactoring","Rename","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","10","https://github.com/Nimblesite/SharpLsp/issues/10","Rename: enums","MelbourneDeveloper","MelbourneDeveloper","Add semantic rename for C# and F# enum declarations.","Update every type reference across real solutions and cover invalid names end to end.",".NET; cluster:rename","2026-04-27","2026-04-27T06:57:54Z","2026-08-03T09:45:13Z","open","0",".NET sidecars (Roslyn/FCS)","Code actions & refactoring","Rename","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","11","https://github.com/Nimblesite/SharpLsp/issues/11","Rename: enum members","MelbourneDeveloper","MelbourneDeveloper","Add semantic rename for C# enum members and F# enum cases.","Update declarations and all semantic usages with invalid-name e2e coverage.",".NET; cluster:rename","2026-04-27","2026-04-27T06:57:55Z","2026-08-03T09:45:14Z","open","0",".NET sidecars (Roslyn/FCS)","Code actions & refactoring","Rename","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","12","https://github.com/Nimblesite/SharpLsp/issues/12","Rename: methods, functions, local functions, and operators","MelbourneDeveloper","MelbourneDeveloper","Add semantic rename for C# methods, functions, and operators plus their F# equivalents.","Update declarations and references with invalid-name e2e coverage.",".NET; cluster:rename","2026-04-27","2026-04-27T06:57:58Z","2026-08-03T09:45:15Z","open","0",".NET sidecars (Roslyn/FCS)","Code actions & refactoring","Rename","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","13","https://github.com/Nimblesite/SharpLsp/issues/13","Rename: constructors through containing type rename","MelbourneDeveloper","MelbourneDeveloper","Keep constructor declarations and calls correct when their containing type is renamed.","Treat constructors as dependent references rather than independently renameable symbols.",".NET; cluster:rename","2026-04-27","2026-04-27T06:57:59Z","2026-08-03T09:45:16Z","open","0",".NET sidecars (Roslyn/FCS)","Code actions & refactoring","Rename","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","14","https://github.com/Nimblesite/SharpLsp/issues/14","Rename: properties and indexers","MelbourneDeveloper","MelbourneDeveloper","Add semantic rename for C# and F# properties and indexers.","Update accessors, implementations, overrides, and references across real solutions.",".NET; cluster:rename","2026-04-27","2026-04-27T06:58:01Z","2026-08-03T09:45:17Z","open","0",".NET sidecars (Roslyn/FCS)","Code actions & refactoring","Rename","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","15","https://github.com/Nimblesite/SharpLsp/issues/15","Rename: fields and events","MelbourneDeveloper","MelbourneDeveloper","Add semantic rename for C# fields, constants, and events plus F# fields, values, and events.","Update declarations and references with invalid-name e2e coverage.",".NET; cluster:rename","2026-04-27","2026-04-27T06:58:03Z","2026-08-03T09:45:18Z","open","0",".NET sidecars (Roslyn/FCS)","Code actions & refactoring","Rename","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","16","https://github.com/Nimblesite/SharpLsp/issues/16","Rename: local variables and pattern/deconstruction bindings","MelbourneDeveloper","MelbourneDeveloper","Add semantic rename for local and pattern-bound variables in C# and F#.","Cover loops, catches, deconstruction, patterns, and local bindings across their valid scopes.",".NET; cluster:rename","2026-04-27","2026-04-27T06:58:05Z","2026-08-03T09:45:19Z","open","0",".NET sidecars (Roslyn/FCS)","Code actions & refactoring","Rename","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","17","https://github.com/Nimblesite/SharpLsp/issues/17","Rename: parameters and lambda parameters","MelbourneDeveloper","MelbourneDeveloper","Add semantic rename for parameters in methods, constructors, functions, delegates, and lambdas.","Support both C# and F# parameter forms with invalid-name e2e coverage.",".NET; cluster:rename","2026-04-27","2026-04-27T06:58:07Z","2026-08-03T09:45:20Z","open","0",".NET sidecars (Roslyn/FCS)","Code actions & refactoring","Rename","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","18","https://github.com/Nimblesite/SharpLsp/issues/18","Rename: namespaces and modules","MelbourneDeveloper","MelbourneDeveloper","Add semantic rename for C# and F# namespaces, modules, and applicable aliases.","Update declarations and semantic references across real solutions.",".NET; cluster:rename","2026-04-27","2026-04-27T06:58:09Z","2026-08-03T09:45:21Z","open","0",".NET sidecars (Roslyn/FCS)","Code actions & refactoring","Rename","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","19","https://github.com/Nimblesite/SharpLsp/issues/19","Rename: generic type parameters","MelbourneDeveloper","MelbourneDeveloper","Add semantic rename for generic type parameters in C# and F#.","Support both type-level and method-level parameters with invalid-name e2e coverage.",".NET; cluster:rename","2026-04-27","2026-04-27T06:58:11Z","2026-08-03T09:45:22Z","open","0",".NET sidecars (Roslyn/FCS)","Code actions & refactoring","Rename","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","20","https://github.com/Nimblesite/SharpLsp/issues/20","Rename: aliases and type abbreviations","MelbourneDeveloper","MelbourneDeveloper","Add safe semantic rename for C# using aliases and F# type abbreviations or module aliases.","Limit support to locations exposed reliably by compiler services.",".NET; cluster:rename","2026-04-27","2026-04-27T06:58:12Z","2026-08-03T09:45:23Z","open","0",".NET sidecars (Roslyn/FCS)","Code actions & refactoring","Rename","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","21","https://github.com/Nimblesite/SharpLsp/issues/21","Rename: F# record fields","MelbourneDeveloper","MelbourneDeveloper","Add semantic rename for F# record fields.","Update declarations, construction and copy expressions, patterns, and field access sites.",".NET; cluster:rename","2026-04-27","2026-04-27T06:58:14Z","2026-08-03T09:45:24Z","open","0","F# sidecar (FCS)","Code actions & refactoring","Rename","F# only","inferred","high","","" +"Nimblesite/SharpLsp","22","https://github.com/Nimblesite/SharpLsp/issues/22","Rename: F# discriminated union cases","MelbourneDeveloper","MelbourneDeveloper","Add semantic rename for F# discriminated union cases.","Update declarations, construction sites, and pattern matches across real solutions.",".NET; cluster:rename","2026-04-27","2026-04-27T06:58:16Z","2026-08-03T09:45:25Z","open","0","F# sidecar (FCS)","Code actions & refactoring","Rename","F# only","inferred","high","","" +"Nimblesite/SharpLsp","23","https://github.com/Nimblesite/SharpLsp/issues/23","Rename: F# active patterns","MelbourneDeveloper","MelbourneDeveloper","Add semantic rename for F# active patterns.","Update declarations and every pattern usage site with invalid-name e2e coverage.",".NET; cluster:rename","2026-04-27","2026-04-27T06:58:18Z","2026-08-03T09:45:26Z","open","0","F# sidecar (FCS)","Code actions & refactoring","Rename","F# only","inferred","high","","" +"Nimblesite/SharpLsp","33","https://github.com/Nimblesite/SharpLsp/issues/33","feat: update Shipwright repo fixtures for Forge after all deployment issues are resolved","MelbourneDeveloper","MelbourneDeveloper","Synchronize Shipwright’s Forge manifest and version-output fixtures after prerequisite deployment work.","Validate all fixtures with Shipwright’s test suite before closing.","shipwright","2026-04-28","2026-04-28T10:48:14Z","2026-06-23T21:31:31Z","open","0","Shipwright repository","Distribution & platform","Shipwright fixtures","Editor/tooling (language-agnostic)","label","high","","" +"Nimblesite/SharpLsp","43","https://github.com/Nimblesite/SharpLsp/issues/43","Shipwright deployment-contract hardening checklist","MelbourneDeveloper","MelbourneDeveloper","Bring release and IDE deployment into full Shipwright contract compliance.","Harden version checks, Actions permissions and pinning, provenance, SBOMs, checksums, and trusted publishing.","shipwright","2026-06-05","2026-06-05T21:16:02Z","2026-06-05T21:36:56Z","open","0","Release workflows + editor integrations","Security","Supply-chain and deployment hardening","Editor/tooling (language-agnostic)","inferred","high","","45; 46; 47; 48" +"Nimblesite/SharpLsp","45","https://github.com/Nimblesite/SharpLsp/issues/45","Enforce the expected version during resolution","MelbourneDeveloper","MelbourneDeveloper","Reject resolved binaries whose versions differ from the expected release.","Surface a precise startup error under the Shipwright contract.","shipwright","2026-06-05","2026-06-05T21:37:10Z","2026-06-05T21:37:10Z","open","0","Binary resolution","Distribution & platform","Expected-version enforcement","Editor/tooling (language-agnostic)","label","high","43","" +"Nimblesite/SharpLsp","46","https://github.com/Nimblesite/SharpLsp/issues/46","Add Zed LSP-initialize version enforcement","MelbourneDeveloper","MelbourneDeveloper","Enforce binary compatibility during Zed’s LSP initialize handshake.","Reject version mismatches under the Shipwright contract.","shipwright","2026-06-05","2026-06-05T21:37:12Z","2026-06-05T21:37:12Z","open","0","Zed extension","Distribution & platform","Zed version compatibility","Editor/tooling (language-agnostic)","label","high","43","" +"Nimblesite/SharpLsp","47","https://github.com/Nimblesite/SharpLsp/issues/47","Sign + notarize the macOS binaries","MelbourneDeveloper","MelbourneDeveloper","Developer ID sign, notarize, and staple every macOS host and sidecar binary.","Retain cosign provenance alongside Apple signing.","cluster:macos-release; shipwright","2026-06-05","2026-06-05T21:37:38Z","2026-08-03T09:45:27Z","open","0","Release workflows","Distribution & platform","macOS signing and notarization","Editor/tooling (language-agnostic)","label","high","43","" +"Nimblesite/SharpLsp","48","https://github.com/Nimblesite/SharpLsp/issues/48","Windows code signing — current position","MelbourneDeveloper","MelbourneDeveloper","Track the unresolved path to native Windows Authenticode signing.","Use package-manager trust and cosign provenance until a durable option is chosen.","shipwright","2026-06-05","2026-06-05T21:37:45Z","2026-06-05T21:37:45Z","open","0","Release workflows","Distribution & platform","Windows code signing","Editor/tooling (language-agnostic)","label","high","43","" +"Nimblesite/SharpLsp","107","https://github.com/Nimblesite/SharpLsp/issues/107","[Feature]: F# treesitter","ShalokShalom","ShalokShalom","Evaluate the newer generic tree-sitter grammar for F# parsing.","Compare it with the older Neovim-focused implementation before adoption.","","2026-06-20","2026-06-20T15:41:16Z","2026-08-03T09:26:42Z","open","1","Rust LSP host","Core LSP & runtime","F# syntax parsing","F# only","issue_form","high","","" +"Nimblesite/SharpLsp","122","https://github.com/Nimblesite/SharpLsp/issues/122","F#: completion auto-`open` insertion (FSAC parity) needs an unopened-symbol entity index","MelbourneDeveloper","MelbourneDeveloper","Index symbols from referenced assemblies and current F# files so unopened namespaces appear in completion.","Cache insertion edits so resolved completions can add the required open directive.","cluster:fsharp-project-model","2026-06-24","2026-06-24T08:34:56Z","2026-08-03T09:45:28Z","open","0","F# sidecar (FCS)","Code intelligence","F# completion auto-open insertion","F# only","inferred","high","","" +"Nimblesite/SharpLsp","123","https://github.com/Nimblesite/SharpLsp/issues/123","NuGet browser: finish post-install sidecar workspace reload + prerelease/restore polish","MelbourneDeveloper","MelbourneDeveloper","Reload sidecar workspaces after NuGet install or uninstall and add prerelease and restore workflows.","Later add caching, cancellation, performance coverage, and notification-ordering tests.","cluster:nuget-pipeline","2026-06-24","2026-06-24T09:11:43Z","2026-08-03T09:45:29Z","open","0","NuGet browser + .NET sidecars","NuGet & package management","Workspace reload and prerelease/restore","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","142","https://github.com/Nimblesite/SharpLsp/issues/142","Flaky e2e: stale F# error diagnostic survives 60s after file correction (clears-diagnostics race)","abdushakoor12","abdushakoor12","A flaky F# diagnostic can remain for 60 seconds after corrected content is closed and reopened.","Investigate stale pull-result versioning and FCS invalidation, adding an old-version guard if the race is confirmed.",".NET; bug","2026-07-09","2026-07-09T07:50:50Z","2026-07-15T09:36:04Z","open","0","F# sidecar (FCS)","Diagnostics & analyzers","F# stale-diagnostic clearing","F# only","inferred","high","","" +"Nimblesite/SharpLsp","150","https://github.com/Nimblesite/SharpLsp/issues/150","Sidecar listener failure is invisible: exit 0, no stderr, error only in temp log","MelbourneDeveloper","MelbourneDeveloper","Make sidecar listener failures visible through nonzero exits and stderr diagnostics.","Have the Rust host report child status and the sidecar log path when READY never arrives.",".NET; bug; cluster:sidecar-startup","2026-07-15","2026-07-15T09:18:02Z","2026-08-03T09:45:30Z","open","0",".NET sidecar common + Rust LSP host","Core LSP & runtime","Sidecar startup observability","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","151","https://github.com/Nimblesite/SharpLsp/issues/151","Deterministic sidecar IPC endpoint collides across hosts on the same workspace","MelbourneDeveloper","MelbourneDeveloper","Generate unique sidecar IPC endpoints for concurrent hosts on one workspace.","Prevent Windows pipe collisions and Unix socket stealing, with busy-pipe retry as defense in depth.",".NET; bug; cluster:multi-host-isolation; cluster:sidecar-startup; critical","2026-07-15","2026-07-15T09:18:08Z","2026-08-03T09:45:31Z","open","0","Rust LSP host + .NET sidecars","Core LSP & runtime","IPC endpoint allocation","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","152","https://github.com/Nimblesite/SharpLsp/issues/152","Sidecar spawn-time failures bypass crash backoff — unthrottled respawn storm","MelbourneDeveloper","MelbourneDeveloper","Apply crash backoff when a sidecar fails before completing startup.","Prevent semantic requests from spawning unthrottled doomed processes.","bug; cluster:sidecar-startup; critical","2026-07-15","2026-07-15T09:18:12Z","2026-08-03T09:45:32Z","open","0","Rust LSP host","Core LSP & runtime","Sidecar crash backoff","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","153","https://github.com/Nimblesite/SharpLsp/issues/153","SidecarHost.MessageLoopAsync hot-loops on persistent transport exceptions","MelbourneDeveloper","MelbourneDeveloper","Stop the sidecar message loop after persistent transport exceptions.","Avoid hot zombie processes that consume CPU and flood logs.",".NET; bug; cluster:sidecar-lifecycle; critical","2026-07-15","2026-07-15T09:18:17Z","2026-08-03T09:45:33Z","open","0","Shared .NET sidecar infrastructure","Core LSP & runtime","IPC exception handling","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","154","https://github.com/Nimblesite/SharpLsp/issues/154","READY echoes the requested endpoint even when the listener bound a relocated path","MelbourneDeveloper","MelbourneDeveloper","A latent long-Unix-socket-path bug reports the requested endpoint instead of the shortened bound path.","Prevent the host from connecting to the original unbound path.",".NET; bug; cluster:sidecar-startup","2026-07-15","2026-07-15T09:18:22Z","2026-08-03T09:45:34Z","open","0","Rust LSP host + shared .NET sidecar infrastructure","Core LSP & runtime","Unix socket endpoint reporting","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","155","https://github.com/Nimblesite/SharpLsp/issues/155","Sidecar solution restore broken: MessagePack 3.1.7/3.1.8 downgrade + FSharp.Core 10.1.301 violates FCS exact pin","MelbourneDeveloper","MelbourneDeveloper","Align MessagePack versions across sidecar projects and match FSharp.Core to FCS’s exact requirement.","Add dependency-consistency guards to prevent restore-blocking version drift.",".NET; bug; showstopper","2026-07-15","2026-07-15T11:40:26Z","2026-08-03T09:43:46Z","open","0",".NET sidecars","Engineering infrastructure",".NET dependency consistency","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","157","https://github.com/Nimblesite/SharpLsp/issues/157","[Feature]: .sqlproj support","Dazfl","Dazfl","SharpLsp does not load SDK-style .sqlproj projects when opening a solution.","Support should include SQL projects alongside C# and F# projects during solution loading.","","2026-07-15","2026-07-15T22:00:28Z","2026-08-03T09:54:54Z","open","2","C# sidecar (Roslyn)","Workspace & project system",".sqlproj loading","C#; F#","inferred","medium","","" +"Nimblesite/SharpLsp","161","https://github.com/Nimblesite/SharpLsp/issues/161","C# rename returns a single whole-document TextEdit — SourceText normalization defeats granular GetTextChanges (contrary to its own comment)","MelbourneDeveloper","MelbourneDeveloper","C# rename emits one whole-document TextEdit instead of granular symbol edits.","Using unrelated SourceText instances loses change history and disrupts editor state and partial previews.","bug; cluster:rename","2026-07-16","2026-07-16T13:00:45Z","2026-08-03T09:45:35Z","open","0","C# sidecar (Roslyn)","Code actions & refactoring","Rename edit granularity","C# only","inferred","high","","" +"Nimblesite/SharpLsp","162","https://github.com/Nimblesite/SharpLsp/issues/162","make _test-vsix cannot go green on Windows — Linux-shaped staging assertions + temp-dir EPERM cleanup flakes","MelbourneDeveloper","MelbourneDeveloper","The full VSIX test gate fails on Windows because sidecar filenames are asserted without .exe and cleanup hits EPERM.","Platform-aware staging assertions and retrying cleanup after watcher disposal should make the gate reliable.","bug","2026-07-16","2026-07-16T13:22:14Z","2026-08-03T09:43:01Z","open","0","VS Code extension + test tooling","Engineering infrastructure","Windows VSIX test reliability","Editor/tooling (language-agnostic)","inferred","high","","" +"Nimblesite/SharpLsp","163","https://github.com/Nimblesite/SharpLsp/issues/163","Sidecar process-tree cleanup on Windows: no Job Object / tree kill, no parent-death watchdog","MelbourneDeveloper","MelbourneDeveloper","Windows kills only direct sidecar children, allowing dotnet grandchildren and build workers to survive host failure.","A Job Object and parent-death watchdog are needed to terminate the full process tree and release named pipes.","bug; cluster:sidecar-lifecycle; critical","2026-07-16","2026-07-16T22:42:08Z","2026-08-03T09:45:36Z","open","0","Rust LSP host + .NET sidecars","Core LSP & runtime","Windows process-tree cleanup","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","164","https://github.com/Nimblesite/SharpLsp/issues/164","Sidecar protocol hardening: response-id correlation + health-check lock race","MelbourneDeveloper","MelbourneDeveloper","The sidecar manager neither validates response IDs nor performs health checks without a transport lock race.","Mismatched responses should force reconnection and health monitoring should track genuinely stalled requests.","bug; cluster:sidecar-lifecycle","2026-07-16","2026-07-16T22:42:09Z","2026-08-03T09:45:38Z","open","0","Rust LSP host","Core LSP & runtime","IPC response correlation and health monitoring","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","165","https://github.com/Nimblesite/SharpLsp/issues/165","F# sidecar loads only the first .fsproj discovered — multi-project F# workspaces mostly unanalyzed","MelbourneDeveloper","MelbourneDeveloper","The F# sidecar loads only the first discovered .fsproj, leaving other projects on synthetic single-file analysis.","It should load every solution project and route each file through its owning FSharpProjectOptions.","bug; cluster:fsharp-project-model; critical","2026-07-16","2026-07-16T22:42:10Z","2026-08-03T09:45:38Z","open","0","F# sidecar (FCS)","Workspace & project system","F# multi-project loading","F# only","inferred","high","","" +"Nimblesite/SharpLsp","166","https://github.com/Nimblesite/SharpLsp/issues/166","MSBuildInstanceSelector: exact Roslyn version equality — SDK servicing skew falls back to a known-broken registration","MelbourneDeveloper","MelbourneDeveloper","Exact Roslyn assembly version matching rejects compatible serviced SDKs and can trigger the known broken fallback registration.","SDK selection should accept a compatible minimum version or fully isolate bundled Roslyn assemblies.","bug; critical","2026-07-16","2026-07-16T22:42:12Z","2026-08-03T09:43:40Z","open","0","C# sidecar (Roslyn)","Distribution & platform","MSBuild SDK selection","C# only","inferred","high","","" +"Nimblesite/SharpLsp","167","https://github.com/Nimblesite/SharpLsp/issues/167","Sidecar PATH resolution accepts .cmd/.bat/extensionless shims the spawn then cannot execute","MelbourneDeveloper","MelbourneDeveloper","Windows PATH discovery accepts command shims that CreateProcess cannot execute and then suppresses working fallback locations.","Resolution should launch a supported absolute executable or continue through the fallback chain after spawn failure.","bug; cluster:sidecar-startup","2026-07-16","2026-07-16T22:42:14Z","2026-08-03T09:45:39Z","open","0","Rust LSP host","Distribution & platform","Sidecar executable discovery","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","168","https://github.com/Nimblesite/SharpLsp/issues/168","Profiler: default output paths are CWD-relative and ProfilerConfig.output_directory is dead","MelbourneDeveloper","MelbourneDeveloper","Profiler outputs default to a host-CWD-relative directory that may be unwritable, while the configured output directory is ignored.","Defaults should use the workspace or user data directory and report clear write failures.","bug","2026-07-16","2026-07-16T22:42:15Z","2026-08-03T09:43:08Z","open","0","Rust LSP host","Profiling","Output path configuration","Editor/tooling (language-agnostic)","inferred","high","","" +"Nimblesite/SharpLsp","169","https://github.com/Nimblesite/SharpLsp/issues/169","workspace symbols: collect_source_files follows directory junctions with no cycle detection or depth cap","MelbourneDeveloper","MelbourneDeveloper","Workspace-symbol file discovery follows symlink or junction cycles without a visited set or depth limit.","Canonical-path cycle detection and a depth cap are needed to prevent host stack overflow.","bug; cluster:workspace-symbols; critical","2026-07-16","2026-07-16T22:42:16Z","2026-08-03T09:45:40Z","open","0","Rust LSP host","Navigation & symbols","Workspace-symbol file traversal","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","170","https://github.com/Nimblesite/SharpLsp/issues/170","NuGet: packages-root prefix strip lacks a separator boundary; parse.rs scrapes MSBuild XML line-wise","MelbourneDeveloper","MelbourneDeveloper","NuGet path matching can confuse sibling package roots, and project XML is parsed with fragile line scanning.","Boundary-aware paths and a real MSBuild or XML document model should replace both behaviors.","bug; cluster:nuget-pipeline","2026-07-16","2026-07-16T22:42:17Z","2026-08-03T09:45:41Z","open","0","Rust LSP host","NuGet & package management","Project parsing and package-root handling","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","171","https://github.com/Nimblesite/SharpLsp/issues/171","native_paths_equal is ASCII-only case-insensitive — NTFS is case-insensitive across Unicode","MelbourneDeveloper","MelbourneDeveloper","VFS path equality folds ASCII only, so equivalent non-ASCII NTFS paths can miss the live buffer.","Use operating-system case comparison or suitable Unicode folding and cover non-ASCII fixture paths.","bug","2026-07-16","2026-07-16T22:42:19Z","2026-08-03T09:43:12Z","open","0","Rust LSP host","Core LSP & runtime","VFS path normalization","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","172","https://github.com/Nimblesite/SharpLsp/issues/172","Sidecar shutdown acknowledgement is never sent — handler cancels the token the response write depends on","MelbourneDeveloper","MelbourneDeveloper","The sidecar cancels the token used to write its shutdown acknowledgement, so graceful shutdown always times out.","It should flush the acknowledgement before cancellation and hard termination.","bug; cluster:sidecar-lifecycle","2026-07-16","2026-07-16T22:42:21Z","2026-08-03T09:45:42Z","open","0","Shared .NET sidecar infrastructure","Core LSP & runtime","Sidecar shutdown","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","173","https://github.com/Nimblesite/SharpLsp/issues/173","Decompiled-source temp files are shared across sidecar processes and written without sharing — Windows write race","MelbourneDeveloper","MelbourneDeveloper","Concurrent C# sidecars share and exclusively overwrite the same decompiled-source temp paths.","Process-scoped paths or atomic write-and-rename semantics should prevent Windows races and partial reads.","bug; cluster:multi-host-isolation","2026-07-16","2026-07-16T22:42:22Z","2026-08-03T09:45:43Z","open","0","C# sidecar (Roslyn)","Navigation & symbols","Metadata navigation and decompilation","C# only","inferred","high","","" +"Nimblesite/SharpLsp","174","https://github.com/Nimblesite/SharpLsp/issues/174","C# parity: textDocument/signatureHelp is F#-only — Roslyn sidecar has no handler, host answers null","MelbourneDeveloper","MelbourneDeveloper","C# signature-help requests return null because only the F# sidecar implements the shared route.","The Roslyn sidecar should produce the shared SignatureHelpResult and the C# end-to-end test should require it.","bug","2026-07-16","2026-07-16T22:51:39Z","2026-08-03T09:43:17Z","open","0","C# sidecar (Roslyn)","Code intelligence","Signature help","C# only","inferred","high","","" +"Nimblesite/SharpLsp","176","https://github.com/Nimblesite/SharpLsp/issues/176","[Feature]: MacOS support","et1975","et1975","The VS Code extension is unavailable for macOS.","Publishing and packaging should support macOS as a language-agnostic editor platform.","bug; cluster:macos-release; critical","2026-07-17","2026-07-17T15:10:49Z","2026-08-03T09:45:44Z","open","0","VS Code extension","Distribution & platform","macOS packaging and support","Editor/tooling (language-agnostic)","issue_form","high","","" +"Nimblesite/SharpLsp","178","https://github.com/Nimblesite/SharpLsp/issues/178","[Bug]: Completion inserts duplicate method name after . (e.g. Console.WriteLineWriteLine)","sammychinedu2ky","sammychinedu2ky","Selecting a C# member completion after a dot appends the method name to itself.","Completion edits should replace the active identifier range instead of duplicating the selected item.","bug; critical","2026-07-18","2026-07-18T14:52:07Z","2026-08-03T09:42:20Z","open","0","Not specified in issue","Code intelligence","Completion edit ranges","C# only","inferred","high","","" +"Nimblesite/SharpLsp","180","https://github.com/Nimblesite/SharpLsp/issues/180","Test Explorer: MSTest tests not discovered (dotnet test --list-tests emits bare DisplayName, not FQN)","MelbourneDeveloper","MelbourneDeveloper","MSTest tests are omitted because text scraping expects dotted names while dotnet test emits bare display names.","Discovery should consume TestCase.FullyQualifiedName through VSTest or another structured result source.","bug","2026-07-18","2026-07-18T23:58:18Z","2026-08-03T09:43:19Z","open","0","VS Code extension","Testing & coverage","Test Explorer discovery","C#; F#","inferred","high","","" +"Nimblesite/SharpLsp","190","https://github.com/Nimblesite/SharpLsp/issues/190","Closure dedup is case-insensitive on case-sensitive filesystems, silently dropping includes","MelbourneDeveloper","MelbourneDeveloper","Case-insensitive closure deduplication silently drops distinct include files on case-sensitive filesystems.","Comparer behavior should match the actual volume and report every skipped already-visited include.","bug; cluster:csharp-single-file","2026-07-28","2026-07-28T22:14:25Z","2026-08-03T09:45:45Z","open","0","C# sidecar (Roslyn)","Scripting & file-based apps","C# file-based include closure","C# only","inferred","high","","" +"Nimblesite/SharpLsp","191","https://github.com/Nimblesite/SharpLsp/issues/191","Windows: Solution Explorer / workspaceSymbols serve stale data after rename (3 e2e failures)","MelbourneDeveloper","MelbourneDeveloper","Windows Solution Explorer and workspaceSymbols can show the previous symbol name after unsaved or rapid renames.","Instrument VFS/path resolution and refresh ordering; add generation-aware responses if that race is confirmed.","bug; cluster:workspace-symbols","2026-07-28","2026-07-28T22:44:56Z","2026-08-03T09:45:46Z","open","0","VS Code extension + Rust LSP host","Workspace & project system","Solution Explorer and workspace-symbol reactivity","Editor/tooling (language-agnostic)","inferred","medium","","" +"Nimblesite/SharpLsp","192","https://github.com/Nimblesite/SharpLsp/issues/192","Branch protection on main requires no functional test job — a fully red test run can merge","MelbourneDeveloper","MelbourneDeveloper","Branch protection on main requires no functional or coverage test jobs, so fully failing changes can merge.","Required checks should include Rust, .NET, VS Code, Windows, and coverage jobs with verified skip behavior.","bug; critical","2026-07-28","2026-07-28T22:45:34Z","2026-08-03T09:43:44Z","open","0","GitHub repository settings","Engineering infrastructure","CI branch protection","Editor/tooling (language-agnostic)","inferred","high","","" +"Nimblesite/SharpLsp","193","https://github.com/Nimblesite/SharpLsp/issues/193","[Bug]: C# Sidecar Initialization Fails Permanently When Opening a Projectless Directory","ashar-builds","ashar-builds","Opening a projectless directory leaves the C# sidecar permanently uninitialized when a C# file is created later.","Directory initialization should succeed provisionally and lazily open single-file mode on the first C# document.","bug; cluster:csharp-single-file; critical","2026-07-29","2026-07-29T05:00:18Z","2026-08-03T09:45:47Z","open","0","C# sidecar (Roslyn)","Workspace & project system","Projectless and single-file initialization","C# only","issue_form","high","","" +"Nimblesite/SharpLsp","195","https://github.com/Nimblesite/SharpLsp/issues/195","vscode-languageclient v10 breaks restartServer: client never returns to Running","MelbourneDeveloper","MelbourneDeveloper","vscode-languageclient 10 prevents restartServer from returning the client and status bar to Running.","Investigate vscode-languageclient v10 restart readiness and stop-start sequencing while retaining the completed logging API migration.","bug","2026-07-29","2026-07-29T23:35:01Z","2026-08-03T09:43:25Z","open","0","VS Code extension","Editor integrations & UI","Extension lifecycle and server restart","Editor/tooling (language-agnostic)","inferred","high","","" +"Nimblesite/SharpLsp","196","https://github.com/Nimblesite/SharpLsp/issues/196","Flaky e2e: profiler object-graph test fails when the baseline heap dump has no StringBuilder instances","MelbourneDeveloper","MelbourneDeveloper","The profiler object-graph test races heap capture against StringBuilder allocation and fails intermittently.","A readiness signal or bounded polling should synchronize the baseline dump without weakening the assertion.","bug","2026-07-30","2026-07-30T00:06:36Z","2026-08-03T09:43:27Z","open","0","Rust profiler e2e tests","Engineering infrastructure","Profiler e2e reliability","Editor/tooling (language-agnostic)","inferred","high","","" diff --git a/docs/formatting/README.md b/docs/formatting/README.md index eb2659f1..3830b724 100644 --- a/docs/formatting/README.md +++ b/docs/formatting/README.md @@ -17,12 +17,12 @@ It may become the foundation for a built-in SharpLsp formatter in the future, bu | Component | File(s) | Engine | |-----------|---------|--------| -| Rust LSP handler | `src/formatting.rs` | Routes to sidecar (gated behind `cfg(feature = "formatting")`) | -| C# sidecar resolver | `sidecars/SharpLsp.Sidecar.CSharp/Workspace/FormattingResolver.cs` | Roslyn `Formatter.FormatAsync()` | -| C# sidecar handlers | `sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Features.cs` (formatting methods) | Delegates to resolver | -| C# workspace manager | `sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs` (formatting methods) | Delegates to resolver | -| F# features | `sidecars/SharpLsp.Sidecar.FSharp/FSharpFeatures.fs` (formatting section) | Fantomas `CodeFormatter` | -| F# sidecar handlers | `sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs` (formatting registrations) | Delegates to features | +| Rust LSP handler | `src/sharplsp/src/formatting.rs` | Routes to sidecar (gated behind `cfg(feature = "formatting")`) | +| C# sidecar resolver | `src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FormattingResolver.cs` | Roslyn `Formatter.FormatAsync()` | +| C# sidecar handlers | `src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Features.cs` (formatting methods) | Delegates to resolver | +| C# workspace manager | `src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs` (formatting methods) | Delegates to resolver | +| F# features | `src/sidecars/SharpLsp.Sidecar.FSharp/FSharpFeatures.fs` (formatting section) | Fantomas `CodeFormatter` | +| F# sidecar handlers | `src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs` (formatting registrations) | Delegates to features | ## How It's Disabled @@ -30,7 +30,7 @@ It may become the foundation for a built-in SharpLsp formatter in the future, bu 2. **Cargo.toml**: Declares a `formatting` feature flag (off by default). 3. **C# sidecar**: Handler registrations still exist (the sidecar responds if asked) but the Rust host never asks. `FormattingResolver` and workspace formatting methods are marked `[ExcludeFromCodeCoverage]`. 4. **F# sidecar**: Handler registrations still exist but the Rust host never asks. Formatting functions are marked as sequestered in comments. -5. **Coverage**: `FormattingResolver.cs` is excluded via `coverlet.runsettings` `ExcludeByFile`. Workspace formatting methods have `[ExcludeFromCodeCoverage]` attributes. +5. **Coverage**: `FormattingResolver.cs` is excluded via `.config/coverage/coverlet.runsettings` `ExcludeByFile`. Workspace formatting methods have `[ExcludeFromCodeCoverage]` attributes. ## Supported Formatting Features (If Re-enabled) diff --git a/docs/plans/DEBUGGING-PLAN.md b/docs/plans/DEBUGGING-PLAN.md index 6dfde79e..d725b264 100644 --- a/docs/plans/DEBUGGING-PLAN.md +++ b/docs/plans/DEBUGGING-PLAN.md @@ -172,7 +172,7 @@ Goal: Ship a production-quality debugging experience for all editors using netco ## Phase 5 — SharpLsp Debug Sidecar (Months 21–26) -Goal: Replace netcoredbg with a C# Tier 4 sidecar achieving full vsdbg parity. Close all gaps documented in DEBUGGING-SPEC §7. +Goal: Replace netcoredbg with a C# Tier 4 sidecar achieving full vsdbg parity. Close all gaps documented in [DEBUG-GAPS]. --- @@ -297,7 +297,7 @@ Goal: Replace netcoredbg with a C# Tier 4 sidecar achieving full vsdbg parity. C ### 5.12 Phase 5 Quality Gates -- [ ] All DAP capability flags match Phase 5 capability matrix in DEBUGGING-SPEC §4 +- [ ] All DAP capability flags match Phase 5 capability matrix in [DEBUG-PROTOCOL-CAPABILITIES] - [ ] Expression evaluation: LINQ + lambda tier (T3) passes all test cases - [ ] Async logical stack: 100% of C# async test cases show logical frames (zero `MoveNext` frames) - [ ] Data breakpoints: field change detection works for reference and value types @@ -316,7 +316,7 @@ Goal: Replace netcoredbg with a C# Tier 4 sidecar achieving full vsdbg parity. C - [ ] Samsung/netcoredbg: contribute logpoint native implementation (Phase 4 emulation algorithm documented for upstream adoption) - [ ] Samsung/netcoredbg: contribute macOS ARM64 CI and official binary release - [ ] Samsung/netcoredbg: contribute musl/Alpine stack size workaround + dotnet/runtime#103741 upstreaming -- [ ] Samsung/netcoredbg: contribute async stack reconstruction (algorithm from §5.4.1) +- [ ] Samsung/netcoredbg: contribute async stack reconstruction from [DEBUG-FEATURES-STACK-ASYNC](../specs/DEBUGGING-SPEC.md) - [ ] Samsung/netcoredbg: track and test fix for attach reliability issue #205 - [ ] Samsung/netcoredbg: track and test fix for stability regression #217, #206 - [ ] Samsung/netcoredbg: contribute `[DebuggerDisplay]` rendering (from SharpDbg implementation learnings) diff --git a/docs/plans/DEFINITION-PLAN.md b/docs/plans/DEFINITION-PLAN.md index ab0c92f0..5bf44583 100644 --- a/docs/plans/DEFINITION-PLAN.md +++ b/docs/plans/DEFINITION-PLAN.md @@ -8,7 +8,7 @@ Implements `textDocument/definition`, `textDocument/typeDefinition`, `textDocume All four C# navigation methods are implemented end-to-end: the Rust host registers all four capabilities, routes requests to the C# sidecar via MessagePack IPC, and the sidecar resolves symbols via Roslyn's `SemanticModel`, `GetSymbolInfo`, `GetTypeInfo`, and `SymbolFinder.FindImplementationsAsync`. Tree-sitter pre-validation short-circuits requests on comments, string literals, and whitespace. Multi-location responses work for both `textDocument/definition` (partial classes) and `textDocument/implementation`. The `DefinitionResolver` (in `DefinitionResolver.cs`) supports `CandidateSymbols` fallback, `GetDeclaredSymbol` fallback for cursors on declarations, override-to-base navigation, interface impl-to-interface member navigation, and partial method definition parts. -**Navigation cache** (`nav_cache.rs`): Caches definition/typeDefinition/declaration results keyed by `(uri, version, line, character, method)`. Invalidated on `didChange` and `didClose`. Returns cached results in <1ms on hit. +**Interim nonconformant memoizer** (`nav_cache.rs`): the current `HashMap` retains definition/typeDefinition/declaration results by `(uri, version, line, character, method)` and drops entries on `didChange`/`didClose`. It is not salsa, does not model solution or sidecar-generation inputs, and invalidation does not cancel in-flight requests. [DEFINITION-CACHE] remains the required design. **LocationResult** includes end positions (`EndLine`, `EndCharacter`) enabling proper range highlighting in peek preview. @@ -28,8 +28,9 @@ The remaining work covers F# sidecar navigation, metadata/decompiled source navi - [x] Support multi-location responses (`Location[]`) for partial classes and implementations - [x] Support `DefinitionLink[]` response format for peek preview — `LocationResult` includes `EndLine`/`EndCharacter` for proper range highlighting - [x] Add tree-sitter pre-validation to short-circuit on whitespace/comments/string literals -- [x] Add navigation cache keyed by `(document_uri, document_version, position, method)` — `nav_cache.rs` -- [x] Implement stale request cancellation via cache invalidation on `didChange`/`didClose` +- [x] Add the interim `nav_cache.rs` result map keyed by `(document_uri, document_version, position, method)` (nonconformant; removal tracked below) +- [ ] Replace `nav_cache.rs` with Rust-host salsa queries keyed by document/version/position/method plus solution and sidecar-generation inputs +- [ ] Implement cancellation for superseded requests; `didChange`/`didClose` invalidation currently prevents only later reuse - [x] Add fallback behavior: return `null` when sidecar is unavailable or loading - [x] Add tracing/logging for definition request lifecycle (dispatch, cache hit/miss, latency) @@ -112,7 +113,8 @@ The remaining work covers F# sidecar navigation, metadata/decompiled source navi - [x] Integrate ICSharpCode.Decompiler v9.1.0 for metadata symbol navigation — `MetadataNavigator.cs` - [x] Decompile containing type to temporary file on definition request — writes to `{tempdir}/sharplsp-decompiled/{type}.cs` -- [x] Cache decompiled sources keyed by `(assemblyPath, typeFullName)` in `ConcurrentDictionary` — avoids repeated decompilation +- [x] Add interim decompiled-source reuse keyed by `(assemblyPath, typeFullName)` in `ConcurrentDictionary` (nonconformant) +- [ ] Move decompiled-source memoization to the Rust-host salsa database and remove the sidecar `ConcurrentDictionary` - [x] Fallback in `DefinitionResolver`: when `ToAllSourceLocations` returns empty, calls `MetadataNavigator.ResolveMetadataSymbol` - [x] Symbol position search in decompiled source using kind-specific patterns (method, property, field, type) @@ -129,10 +131,10 @@ See `[DEFINITION-CROSSLANG]`. - [x] Shared metadata-as-source decompiler in Common — `MetadataDecompiler` (used by both sidecars; C# `MetadataNavigator` delegates to it) - [x] C# → F#: re-attach the dropped F# `` output DLL as a metadata reference and drop the empty stub project — `WorkspaceManager.AddCrossLanguageMetadataReferences` - [x] F# → C#: wire referenced C# project output DLLs into FCS options (`buildProjectOptions`) + `FSharpMetadataNavigator` decompiles external symbols in `extractDefinition` -- [x] E2E test: cross-language navigation on a mixed C#/F# solution — `test_cross_language_definition_csharp_to_fsharp`, `test_cross_language_definition_fsharp_to_csharp` (`tests/e2e_modules/definition_cross_language.rs`) +- [x] E2E test: cross-language navigation on a mixed C#/F# solution — `test_cross_language_definition_csharp_to_fsharp`, `test_cross_language_definition_fsharp_to_csharp` (`src/sharplsp/tests/e2e_modules/definition_cross_language.rs`) - [ ] Source-to-source cross-language navigation (land in the original `.fs`/`.cs` rather than decompiled metadata) — needs a cross-sidecar symbol index (P2, Phase 4) -### Testing — Rust E2E (`tests/lsp_e2e.rs`) +### Testing — Rust E2E (`src/sharplsp/tests/lsp_e2e.rs`) - [x] E2E test: C# go-to-definition on class name navigates to class declaration - [x] E2E test: C# go-to-definition on method call navigates to method body @@ -158,14 +160,14 @@ See `[DEFINITION-CROSSLANG]`. - [ ] E2E test: F# go-to-implementation on abstract member returns implementations - [x] E2E test: definition after document edit returns updated location - [ ] E2E test: definition after sidecar crash recovery works correctly -- [ ] E2E test: definition cache hit returns result in <1ms +- [ ] E2E test: definition salsa hit returns result in <1ms - [ ] E2E test: definition latency p50 <100ms, p95 <250ms on medium solution - [x] E2E test: all four nav methods interleaved in single session ### Performance Validation - [ ] Benchmark definition latency on cold start (first request after project load) -- [ ] Benchmark definition latency on warm cache (repeated request on same position) +- [ ] Benchmark definition latency on a warm salsa query (repeated request on the same position) - [ ] Benchmark definition latency on large solution (~2M LOC) - [ ] Benchmark find-implementations latency with 100+ implementations - [ ] Validate tree-sitter pre-validation rejects non-symbol positions in <1ms diff --git a/docs/plans/DIAGNOSTICS-PLAN.md b/docs/plans/DIAGNOSTICS-PLAN.md index b5abf9f2..d0cc81e0 100644 --- a/docs/plans/DIAGNOSTICS-PLAN.md +++ b/docs/plans/DIAGNOSTICS-PLAN.md @@ -2,7 +2,7 @@ Implementation plan for [DIAGNOSTICS-SPEC](../specs/DIAGNOSTICS-SPEC.md). -> **Architecture pivot (current).** The previous push-based, eager-solution-scan plan produced phantom CS0246/CS0234 errors during workspace load and could not be repaired by the verification pass. The plan now mirrors `Microsoft.CodeAnalysis.LanguageServer` (the engine behind C# Dev Kit): NuGet restore gate → workspace open → pull-driven diagnostics with `global_state_version`-keyed `resultId` → debounced `workspace/diagnostic/refresh`. See [DIAGNOSTICS-SPEC §1.1](../specs/DIAGNOSTICS-SPEC.md#11-the-pull--refresh-cycle). Phases 1–2 below are partially completed; the parts that still apply are kept, the parts that contradict the new architecture are marked obsolete with rationale. +> **Architecture pivot (current).** The previous push-based, eager-solution-scan plan produced phantom CS0246/CS0234 errors during workspace load and could not be repaired by the verification pass. The plan now mirrors `Microsoft.CodeAnalysis.LanguageServer` (the engine behind C# Dev Kit): NuGet restore gate → workspace open → pull-driven diagnostics with `global_state_version`-keyed `resultId` → debounced `workspace/diagnostic/refresh`. See [DIAG-ARCHITECTURE-PULL-REFRESH](../specs/DIAGNOSTICS-SPEC.md). Phases 1–2 below are partially completed; the parts that still apply are kept, the parts that contradict the new architecture are marked obsolete with rationale. ## Phase 1: Per-Document Diagnostics IPC (P0) — DONE @@ -10,7 +10,7 @@ The sidecar's per-document diagnostics path is correct and survives the pivot. T ### Rust LSP Host -- [x] Create `diagnostics` module in Rust host (`src/diagnostics.rs`) +- [x] Create `diagnostics` module in Rust host (`src/sharplsp/src/diagnostics.rs`) - [x] Map sidecar `DiagnosticResult` → LSP `Diagnostic` struct - Map severity: `"Error"` → 1, `"Warning"` → 2, `"Info"` → 3, `"Hidden"` → 4 - Map code, message, range @@ -18,7 +18,7 @@ The sidecar's per-document diagnostics path is correct and survives the pivot. T - [x] On `textDocument/didOpen` / `textDocument/didChange` / `textDocument/didSave`: request diagnostics from sidecar (background task) — kept as the push fallback path - [x] Send `textDocument/publishDiagnostics` notification with mapped results — push fallback only; pull is primary (Phase 5) - [x] Clear diagnostics on `textDocument/didClose` -- [x] Version-gate the push pipeline ([DIAG-PUSH-GATE](../specs/DIAGNOSTICS-SPEC.md#13-diag-push-gate-push-convergence-guarantee)): per-URI push generations, stale results never published, failed fetch for the newest generation retried until published or superseded. Hardening found while investigating GitHub #160 — that issue's actual root cause was path-qualified `_._` placeholder references poisoning FCS ([PKG-ASSETS-FS](../specs/PACKAGE-MAINTENANCE-SPEC.md)), fixed in `FSharpAssets.packageAssemblies` with regression test `path-qualified placeholder compile entries are never handed to FCS as references`. Rust test: `failed_fetch_after_revert_must_not_strand_stale_published_diagnostics`; F# sidecar guard: `diagnostics clear after an error edit is reverted` +- [x] Version-gate the push pipeline ([DIAG-PUSH-GATE](../specs/DIAGNOSTICS-SPEC.md)): per-URI push generations, stale results never published, failed fetch for the newest generation retried until published or superseded. Hardening found while investigating GitHub #160 — that issue's actual root cause was path-qualified `_._` placeholder references poisoning FCS ([PKG-ASSETS-FS](../specs/PACKAGE-MAINTENANCE-SPEC.md)), fixed in `FSharpAssets.packageAssemblies` with regression test `path-qualified placeholder compile entries are never handed to FCS as references`. Rust test: `failed_fetch_after_revert_must_not_strand_stale_published_diagnostics`; F# sidecar guard: `diagnostics clear after an error edit is reverted` - [ ] Add debounce (150ms window) before sidecar push request — superseded by Phase 5's 2000ms refresh debounce; only relevant if push fallback is in use - [x] Rust e2e tests: `test_diagnostics_cleared_on_close`, `test_request_works_after_diagnostic_notification` - [x] VSCode extension tests: `diagnostics.test.ts` (6 tests — error detection, missing type, clean file, edit cycle, range check, close clears) @@ -32,13 +32,13 @@ The sidecar's per-document diagnostics path is correct and survives the pivot. T ## Phase 2: Solution-Wide Eager Scan (P0) — REMOVED -> ⚠️ **Removed by architecture pivot.** This phase implemented the eager `workspace/diagnostics/all` bulk RPC and the post-load solution scan. The eager scan iterates `Solution.Projects` and calls `GetCompilationAsync()` on each, which produces phantom CS0246/CS0234 because consumer projects are compiled before their dependencies are cached as `CompilationReference`s. The replacement is Phase 5 (pull-driven workspace diagnostics) plus Phase 5.6 (NuGet restore gate). See [DIAGNOSTICS-SPEC §1.2](../specs/DIAGNOSTICS-SPEC.md#12-why-no-eager-solution-scan). +> ⚠️ **Removed by architecture pivot.** This phase implemented the eager `workspace/diagnostics/all` bulk RPC and the post-load solution scan. The eager scan iterates `Solution.Projects` and calls `GetCompilationAsync()` on each, which produces phantom CS0246/CS0234 because consumer projects are compiled before their dependencies are cached as `CompilationReference`s. The replacement is Phase 5 (pull-driven workspace diagnostics) plus Phase 5.6 (NuGet restore gate). See [DIAG-ARCHITECTURE-EAGER-SCAN](../specs/DIAGNOSTICS-SPEC.md). ### Rust LSP Host - [x] Read `diagnostics.solution_wide_analysis` from config (default: `true`) — kept; now controls whether the server answers `workspace/diagnostic` pulls - [x] Read `diagnostics.project_filter` from config (default: empty = all projects) — kept; now restricts `workspace/diagnostic` results -- [x] On solution load: request solution-wide diagnostics — ⚠️ **OBSOLETE.** Removed. The host no longer triggers a scan on load; the editor pulls when it wants data. `request_solution_in_background` in `src/diagnostics.rs` will be deleted in Phase 5. +- [x] On solution load: request solution-wide diagnostics — ⚠️ **OBSOLETE.** Removed. The host no longer triggers a scan on load; the editor pulls when it wants data. `request_solution_in_background` in `src/sharplsp/src/diagnostics.rs` will be deleted in Phase 5. - [x] Stream diagnostics incrementally (batch by file) to avoid blocking — ⚠️ **OBSOLETE.** Replaced by `workspace/diagnostic` partial-result streaming in Phase 5. - [ ] On file change: re-request diagnostics for changed file + dependents — ⚠️ **OBSOLETE.** Replaced by `diagnostics/refresh` IPC + debounced `workspace/diagnostic/refresh` in Phase 5. - [x] Advertise `workspaceDiagnostics: true` in server capabilities — kept; required for pull workspace diagnostics @@ -83,7 +83,7 @@ for both C# and F#. ## Phase 5: Pull Diagnostics + Refresh Cycle (P0 — primary path) -This is now the **primary** diagnostic pipeline. Pull is mandatory for editors that advertise `textDocument.diagnostic` client capability; push (Phase 1 wiring) is fallback only. Implements [DIAGNOSTICS-SPEC §1.1](../specs/DIAGNOSTICS-SPEC.md#11-the-pull--refresh-cycle), [§4.2](../specs/DIAGNOSTICS-SPEC.md#42-pull-model-primary-textdocumentdiagnostic-workspacediagnostic), [§4.3](../specs/DIAGNOSTICS-SPEC.md#43-refresh-notifications-workspacediagnosticrefresh). +This is now the **primary** diagnostic pipeline. Pull is mandatory for editors that advertise `textDocument.diagnostic` client capability; push (Phase 1 wiring) is fallback only. Implements [DIAG-ARCHITECTURE-PULL-REFRESH](../specs/DIAGNOSTICS-SPEC.md), [DIAG-LSP-PULL](../specs/DIAGNOSTICS-SPEC.md), and [DIAG-LSP-REFRESH](../specs/DIAGNOSTICS-SPEC.md). ### Rust LSP Host @@ -93,11 +93,11 @@ This is now the **primary** diagnostic pipeline. Pull is mandatory for editors t - [ ] Return `RelatedFullDocumentDiagnosticReport` when changed, `RelatedUnchangedDocumentDiagnosticReport` (`{ kind: "unchanged" }`) when sidecar reports `Changed = false` - [ ] Construct `resultId = "p:{project_version}|d:{doc_version}|g:{global_state_version}"` from sidecar response fields - [ ] `workspace/diagnostic` partial-result streaming via `WorkDoneProgress` partialResultToken — emit one `WorkspaceDocumentDiagnosticReport` per project as it completes -- [ ] Subscribe to sidecar `diagnostics/refresh` IPC notification (defined in [SPEC §5.4](../specs/DIAGNOSTICS-SPEC.md#54-notification-diagnosticsrefresh)) +- [ ] Subscribe to the [DIAG-IPC-REFRESH](../specs/DIAGNOSTICS-SPEC.md) sidecar notification - [ ] Implement debounced refresh queue: `tokio::sync::Notify` + 2000ms `tokio::time::sleep` collapse, matches `Microsoft.CodeAnalysis.LanguageServer`'s `AsyncBatchingWorkQueue` - [ ] Send LSP `workspace/diagnostic/refresh` notification when the debounce drains -- [ ] **Delete** `request_solution_in_background` from `src/diagnostics.rs` (the eager-scan trigger) -- [ ] **Delete** `verify_error_files`, `sync_text_to_sidecar` from `src/diagnostics.rs` (verification pass — see Phase 5.5) +- [ ] **Delete** `request_solution_in_background` from `src/sharplsp/src/diagnostics.rs` (the eager-scan trigger) +- [ ] **Delete** `verify_error_files`, `sync_text_to_sidecar` from `src/sharplsp/src/diagnostics.rs` (verification pass — see Phase 5.5) - [ ] Cancel in-flight per-document IPC pulls when editor sends a fresh pull for the same document - [ ] Server capability: add `diagnosticProvider.identifier = "sharplsp"` so editors distinguish SharpLsp's diagnostics @@ -126,7 +126,7 @@ This is now the **primary** diagnostic pipeline. Pull is mandatory for editors t ## Phase 5.5: Diagnostic Verification (P0) — REMOVED -> ⚠️ **Removed by architecture pivot.** The verification pass re-sent `textDocument/didChange` with the same disk text and re-fetched diagnostics, expecting Roslyn to clear false positives. It does not work — `Solution.WithDocumentText` does not re-resolve metadata references or re-run source generators, so the same phantom errors come back. The pull + refresh model in Phase 5 removes the pass's reason to exist: SharpLsp no longer asserts diagnostics until the editor pulls. See [DIAGNOSTICS-SPEC §10.3](../specs/DIAGNOSTICS-SPEC.md#103-why-the-previous-verification-pass-is-gone). +> ⚠️ **Removed by architecture pivot.** The verification pass re-sent `textDocument/didChange` with the same disk text and re-fetched diagnostics, expecting Roslyn to clear false positives. It does not work — `Solution.WithDocumentText` does not re-resolve metadata references or re-run source generators, so the same phantom errors come back. The pull + refresh model in Phase 5 removes the pass's reason to exist: SharpLsp no longer asserts diagnostics until the editor pulls. See [DIAG-ARCHITECTURE-EAGER-SCAN](../specs/DIAGNOSTICS-SPEC.md). Original tasks (kept here for traceability — every item is undone in Phase 5): @@ -140,7 +140,7 @@ Original tasks (kept here for traceability — every item is undone in Phase 5): ## Phase 5.6: NuGet Restore Gate (P0) -The single biggest source of phantom CS0246 is unresolved NuGet `` items at workspace open. Mirrors `Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.ProjectDependencyHelper`. Implements [DIAGNOSTICS-SPEC §6](../specs/DIAGNOSTICS-SPEC.md#6-nuget-restore-gate). +The single biggest source of phantom CS0246 is unresolved NuGet `` items at workspace open. Mirrors `Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.ProjectDependencyHelper`. Implements [DIAG-RESTORE](../specs/DIAGNOSTICS-SPEC.md). ### C# Sidecar @@ -176,7 +176,7 @@ The single biggest source of phantom CS0246 is unresolved NuGet `` returning the path to `dotnet` / `dotnet.exe` +- [x] Add `"extensionDependencies": ["ms-dotnettools.vscode-dotnet-runtime"]` to [src/editors/vscode/package.json](../../src/editors/vscode/package.json) (insert after the `engines` block, around line 11) +- [x] Create new file `src/editors/vscode/src/dotnetRuntime.ts` exporting `acquireDotnet10(log, statusBar): Promise` returning the path to `dotnet` / `dotnet.exe` - [x] In `dotnetRuntime.ts`, first call `dotnet.findPath` with `{ acquireContext: { version: '10.0', mode: 'runtime', requestingExtensionId: 'nimblesite.sharplsp' }, versionSpecRequirement: 'greater_than_or_equal' }` — if it returns a path, skip acquisition - [x] Otherwise call `dotnet.acquire` with `{ version: '10.0', mode: 'runtime', requestingExtensionId: 'nimblesite.sharplsp' }` - [x] Wrap the call in `vscode.window.withProgress({ location: vscode.window.ProgressLocation.Notification, title: 'SharpLsp: Installing .NET 10 runtime', cancellable: false }, ...)` — non-interactive toast spinner - [x] Update `SharpLspStatusBar` to show "Installing .NET 10…" via `statusBar.setState(ServerState.Starting)` plus a custom message during acquisition - [x] Define a typed `DotnetAcquireError` thrown on acquisition failure -- [x] In [editors/vscode/src/extension.ts](../../editors/vscode/src/extension.ts), insert `step 10c: acquireDotnet10` between line 133 (`initProjectDepsStore`) and line 135 (`activateDeploymentToolkit`); store `dotnetPath` for downstream use +- [x] In [src/editors/vscode/src/extension.ts](../../src/editors/vscode/src/extension.ts), insert `step 10c: acquireDotnet10` between line 133 (`initProjectDepsStore`) and line 135 (`activateDeploymentToolkit`); store `dotnetPath` for downstream use - [x] On `DotnetAcquireError`, render a non-modal error notification with `[Open dot.net]` (uses `vscode.env.openExternal`) and `[Show log]` buttons — both informational, no required action; enter degraded state without throwing - [x] Register a `sharplsp.retryDotnetAcquisition` command for the degraded-state recovery path (re-runs `acquireDotnet10` and resumes activation if it succeeds) -- [x] In [editors/vscode/src/client.ts](../../editors/vscode/src/client.ts), extend `sidecarEnv` (lines 78–87) to accept `dotnetPath` and set `DOTNET_ROOT` to its directory on the env passed to the Rust LSP host +- [x] In [src/editors/vscode/src/client.ts](../../src/editors/vscode/src/client.ts), extend `sidecarEnv` (lines 78–87) to accept `dotnetPath` and set `DOTNET_ROOT` to its directory on the env passed to the Rust LSP host - [x] Update `client.start(...)` signature in extension.ts to thread `dotnetPath` through ### Rust host (sidecar spawn) -- [x] Locate the Rust sidecar spawn site — `src/sidecar/manager.rs` lines 168–179 (`tokio::process::Command::new(&self.spawn_command)`) -- [x] Verify `Command::spawn` inherits the parent process env — confirmed: no `env_clear` / `env_remove` / explicit `.env(…)` calls anywhere in `src/sidecar/`, so `DOTNET_ROOT` flows VS Code → sharplsp → sidecar via tokio's default env inheritance +- [x] Locate the Rust sidecar spawn site — `src/sharplsp/src/sidecar/manager.rs` lines 168–179 (`tokio::process::Command::new(&self.spawn_command)`) +- [x] Verify `Command::spawn` inherits the parent process env — confirmed: no `env_clear` / `env_remove` / explicit `.env(…)` calls anywhere in `src/sharplsp/src/sidecar/`, so `DOTNET_ROOT` flows VS Code → sharplsp → sidecar via tokio's default env inheritance - [~] Unit test for `DOTNET_ROOT` propagation — skipped per CLAUDE.md ("No unit tests. Only COARSE e2e tests."). The end-to-end activation checklist below validates the full path. ### Specs & docs -- [x] Rewrite DISTRIBUTION-SPEC.md §2 "Runtime Prerequisite" → "Runtime Acquisition — .NET 10 via .NET Install Tool" -- [x] Add DISTRIBUTION-SPEC.md §2 reference paragraph noting C# Dev Kit's `extensionDependencies` declaration as the authoritative pattern -- [x] Update DISTRIBUTION-SPEC.md §7 Editor Extension Contract item 4 (degraded mode for missing .NET) and item 6 (acquire instead of crash) -- [x] Update DISTRIBUTION-SPEC.md §12 Forbidden Patterns: replace "crash on missing .NET" with "no modal/asking UI", remove blanket "no graceful degradation", add "no hand-rolled .NET acquisition" and "no required-action UI" +- [x] Rewrite [DIST-RUNTIME-ACQUIRE](../specs/DISTRIBUTION-SPEC.md) from a runtime prerequisite into .NET 10 SDK acquisition via the .NET Install Tool +- [x] Add the C# Dev Kit `extensionDependencies` pattern to [DIST-RUNTIME-ACQUIRE](../specs/DISTRIBUTION-SPEC.md) +- [x] Update [DIST-EDITOR-CONTRACT](../specs/DISTRIBUTION-SPEC.md) for degraded mode and SDK acquisition +- [x] Update [DIST-FORBIDDEN](../specs/DISTRIBUTION-SPEC.md): prohibit required-action UI and hand-rolled .NET acquisition - [x] Update DISTRIBUTION-PLAN.md (this file) with the new TODO block and Context section -- [x] Add a brief callout to [docs/specs/SHARPLSP-SPEC.md](../specs/SHARPLSP-SPEC.md) Distribution section linking to the rewritten §2 +- [x] Add a callout from [SHARPLSP-DISTRIBUTION](../specs/SHARPLSP-SPEC.md) to [DIST-RUNTIME-ACQUIRE](../specs/DISTRIBUTION-SPEC.md) ### Verification (clean Windows machine, no .NET 10 installed) - [ ] `make package-vsix-win32-x64 VERSION=0.1.1` succeeds -- [ ] Uninstall both extensions: `code --uninstall-extension nimblesite.sharplsp && code --uninstall-extension ms-dotnettools.vscode-dotnet-runtime` +- [ ] Uninstall SharpLsp: `code --uninstall-extension nimblesite.sharplsp` +- [ ] Uninstall the .NET Install Tool: `code --uninstall-extension ms-dotnettools.vscode-dotnet-runtime` - [ ] `code --install-extension dist/sharplsp-win32-x64.vsix` — VS Code auto-installs the .NET Install Tool dependency without prompting - [ ] `code --list-extensions | grep ms-dotnettools.vscode-dotnet-runtime` prints the ID - [ ] Open a `.csproj`-containing folder. Observe the `SharpLsp: Installing .NET 10 runtime` toast appear with spinner, plus the status-bar message. No buttons. 30-90 s later toast disappears. @@ -60,26 +61,26 @@ This rev replaces that stance with delegation to Microsoft's `ms-dotnettools.vsc Triggered by the v0.1.0 production log captured 2026-04-30: missing bundled binaries caused `activate()` to throw, which VS Code logs to its developer console where users do not see it. Spec section: `[DIST-FAILURE-UX]`. -- [x] Introduce `editors/vscode/src/result.ts` with `Result`, `ok()`, `err()` per CLAUDE.md "all fallible functions return Result" -- [x] Rewrite `editors/vscode/src/dotnetRuntime.ts` so `acquireDotnet10` returns `Result` (no throws); `safeExecuteCommand` adapts upstream rejections into `Err` +- [x] Introduce `src/editors/vscode/src/result.ts` with `Result`, `ok()`, `err()` per CLAUDE.md "all fallible functions return Result" +- [x] Rewrite `src/editors/vscode/src/dotnetRuntime.ts` so `acquireDotnet10` returns `Result` (no throws); `safeExecuteCommand` adapts upstream rejections into `Err` - [x] Add the missing `architecture` field to both `dotnet.acquire` and `dotnet.findPath` payloads (per `[DIST-API-PARAMETERS]`); export `dotnetArchitecture()` for tests -- [x] In `editors/vscode/src/extension.ts`, make `activate()` always resolve — outer catch surfaces a non-modal toast and returns a degraded API +- [x] In `src/editors/vscode/src/extension.ts`, make `activate()` always resolve — outer catch surfaces a non-modal toast and returns a degraded API - [x] Replace the `throw new Error(msg)` on the deployment-toolkit failure path with a non-modal toast + degraded return - [x] Replace the deferred `window.showErrorMessage` on the `client.start` failure with `notifyActivationFailure(headline, detail)` (consistent UX) - [x] Add `notifyActivationFailure(headline, detail)` exported helper with `[Show Log]` and `[Restart Window]` buttons - [x] Add `degradedApi()` helper so every error path returns a usable API surface - [x] Convert the retry command to consume `Result` from `acquireDotnet10` - [x] Tag every Result-based path with `Implements [DIST-FAILURE-UX]` / `Implements [DIST-API-PARAMETERS]` per CLAUDE.md spec-ID rule -- [x] Add `editors/vscode/src/test/suite/unit-result.test.ts` — pins the Result type contract *(deleted in the #125 e2e conversion; coverage lives in the e2e suites)* -- [x] Add `editors/vscode/src/test/suite/unit-dotnet-runtime.test.ts` — patches `vscode.commands.executeCommand`, asserts the four required fields are sent, asserts no path throws *(deleted in the #125 e2e conversion; `lifecycle-e2e.test.ts` covers the acquisition flow end-to-end)* -- [x] Add `editors/vscode/src/test/suite/unit-failure-ux.test.ts` — asserts `activate()` resolves (never rejects), the retry command is registered, `extensionDependencies` declares the .NET Install Tool, `notifyActivationFailure` is exported *(deleted in the #125 e2e conversion; most coverage moved to `lifecycle-e2e.test.ts`/`extension.test.ts` — the `extensionDependencies` guards were dropped and restored below)* +- [x] Add `src/editors/vscode/src/test/suite/unit-result.test.ts` — pins the Result type contract *(deleted in the #125 e2e conversion; coverage lives in the e2e suites)* +- [x] Add `src/editors/vscode/src/test/suite/unit-dotnet-runtime.test.ts` — patches `vscode.commands.executeCommand`, asserts the four required fields are sent, asserts no path throws *(deleted in the #125 e2e conversion; `lifecycle-e2e.test.ts` covers the acquisition flow end-to-end)* +- [x] Add `src/editors/vscode/src/test/suite/unit-failure-ux.test.ts` — asserts `activate()` resolves (never rejects), the retry command is registered, `extensionDependencies` declares the .NET Install Tool, `notifyActivationFailure` is exported *(deleted in the #125 e2e conversion; most coverage moved to `lifecycle-e2e.test.ts`/`extension.test.ts` — the `extensionDependencies` guards were dropped and restored below)* ### Salvaged from the `fixrelease` branch (2026-07-16 audit) A full audit of the retired `fixrelease` branch (39 commits, 90 files) found everything absorbed by main except two items, restored here: - [x] Restore the `[DIST-RUNTIME-ACQUIRE]` manifest guards dropped by the #125 e2e conversion — `extension.test.ts` now asserts `extensionDependencies` declares the .NET Install Tool and that it resolves in the test host (the test host installs it unconditionally via `.vscode-test.mjs`, so nothing else fails when the declaration is removed) -- [x] Salvage `scripts/resolve-symlink-stubs.mjs` (from the branch's auto-stash) — resolves Git text-symlink stubs for the icon assets on `core.symlinks=false` checkouts; wired into `pretest`/`vscode:prepublish` per [DIST-VSIX-ASSET-INTEGRITY], invariant asserted e2e in `bundled-binary.test.ts` +- [x] Salvage `tools/vsix/resolve-symlink-stubs.mjs` (from the branch's auto-stash) — resolves Git text-symlink stubs for the icon assets on `core.symlinks=false` checkouts; wired into `pretest`/`vscode:prepublish` per [DIST-VSIX-ASSET-INTEGRITY], invariant asserted e2e in `bundled-binary.test.ts` ### Spec hygiene — sweep numbered headings (CLAUDE.md violation) @@ -134,7 +135,7 @@ CLAUDE.md mandates hierarchical IDs (`[GROUP-TOPIC]`), uppercase, hyphen-separat ### CI wall-clock ([DIST-CI-RUST-SHARDS]) - [x] Split `test-rust` into 2 nextest hash-partition shards (`make _test-rust-shard`) -- [x] Union-merge shard lcov + single ratchet gate (`coverage-rust` job, `scripts/merge-lcov.mjs`) +- [x] Union-merge shard lcov + single ratchet gate (`coverage-rust` job, `tools/coverage/merge-lcov.mjs`) - [x] Move the `--version` contract checks to a dedicated `version-contract` job - [x] Run test jobs concurrently with `lint` (removed `needs: lint`) - [ ] Confirm shard wall times on a real PR run; rebalance `SHARD_COUNT` if a shard drifts past ~6 min @@ -142,7 +143,7 @@ CLAUDE.md mandates hierarchical IDs (`[GROUP-TOPIC]`), uppercase, hyphen-separat ### Windows VS Code feature chunks ([DIST-CI-WIN-VSIX]) - [x] Replace the `MOCHA_GREP` smoke subset with file-glob chunk selection (`MOCHA_FILES` in `src/test/suite/index.ts`); a glob matching nothing is a hard error -- [x] Declare chunk membership once in `editors/vscode/test-chunks.json`, read by `scripts/vsix-test-chunks.mjs` (`files` / `matrix` / `check`) +- [x] Declare chunk membership once in `src/editors/vscode/test-chunks.json`, read by `tools/vsix/vsix-test-chunks.mjs` (`files` / `matrix` / `check`) - [x] Cover the whole feature surface on Windows: `lifecycle`, `lsp`, `fsharp`, `debug` (netcoredbg + Test Explorer + CodeLens), `profiler` (trace/counters/dumps + FSI/build/hot-reload), `explorer` (tree + context menus), `packages` (scaffolding + NuGet) - [x] Guard completeness in lint (`_check-vsix-chunks`) so a new suite cannot silently skip Windows CI - [x] Build once / fan out: one Windows `build` job publishes host + sidecars; chunks stage via `_stage-vsix-binary-only` @@ -158,7 +159,7 @@ Every one of these shipped green on Ubuntu and had never executed on Windows at - [x] `debug-e2e.test.ts` compared three resolved `program` paths the same way — the auto-detected `.dll` from a real `.csproj`, the `provideDebugConfigurations` default, and the `sharplsp.debugProgram` entry point. All three now use `comparablePath()`. - [x] `scaffolding-e2e.test.ts` compared `generateFileContent`'s `\n` output against the editor buffer and the saved file. VS Code gives a new document the platform EOL and rewrites inserted text to match, so on Windows both legitimately hold `\r\n`. Now normalized via `comparableText()`; the content assertions are unchanged. - [x] `testing-lens-e2e.test.ts` asserted the "no discovered test matches" warning path using a fixture method named `Adds_TwoNumbers` — the same name `test-explorer-e2e.test.ts` discovers into the shared `SharpLspTestController`. The suite therefore passed or failed on whether that discovery won the race (green on Ubuntu, red on Windows). Fixture methods are now suite-unique (`Lens_Adds*`), so the precondition holds regardless of execution order. - - **Open product bug this de-pressurized:** `runTestByMethodName` ([src/test-lens.ts](../../editors/vscode/src/test-lens.ts)) discards the URI its command receives and matches ANY discovered test whose id's last dot-segment equals the bare method name, last-match-wins — so run/debug-at-cursor can execute a test from a different project, and `findResultByMethodName` mis-attributes the CodeLens badge the same way. The rename is still correct (suites must be order-independent), but the collision is now untested. Needs a URI/project-scoped lookup plus a regression test. + - **Open product bug this de-pressurized:** `runTestByMethodName` ([src/test-lens.ts](../../src/editors/vscode/src/test-lens.ts)) discards the URI its command receives and matches ANY discovered test whose id's last dot-segment equals the bare method name, last-match-wins — so run/debug-at-cursor can execute a test from a different project, and `findResultByMethodName` mis-attributes the CodeLens badge the same way. The rename is still correct (suites must be order-independent), but the collision is now untested. Needs a URI/project-scoped lookup plus a regression test. Two further failure classes were investigated and turned out **not** to be defects, so no code changed: @@ -167,7 +168,7 @@ Two further failure classes were investigated and turned out **not** to be defec ### CI workflow layout ([DIST-CI-LAYOUT]) - [x] Split `ci.yml` into reusable workflows: `ci-lint`, `ci-rust`, `ci-dotnet`, `ci-vsix`, `ci-vsix-windows` -- [x] De-duplicate the PATH-purge step into `scripts/purge-path-binaries.sh` (was inline in three jobs) +- [x] De-duplicate the PATH-purge step into `tools/vsix/purge-path-binaries.sh` (was inline in three jobs) - [x] De-duplicate the test-host env scrubbing into the `VSIX_TEST_ENV` Make variable - [x] Fix the Rust test job's NuGet cache step (was `actions/setup-node` with `actions/cache` inputs, so it never cached) diff --git a/docs/plans/FSHARP-FEATURES-PLAN.md b/docs/plans/FSHARP-FEATURES-PLAN.md index 5368319d..9c7fcbe7 100644 --- a/docs/plans/FSHARP-FEATURES-PLAN.md +++ b/docs/plans/FSHARP-FEATURES-PLAN.md @@ -6,8 +6,8 @@ LSP capability routed by the Rust host works identically for `.fs` and `.cs`/`.c ## How requests reach a sidecar -The Rust host ([src/main.rs](../../src/main.rs)) routes each LSP method to **one** -sidecar chosen purely by document language ([src/main.rs:763](../../src/main.rs#L763) +The Rust host ([src/sharplsp/src/main.rs](../../src/sharplsp/src/main.rs)) routes each LSP method to **one** +sidecar chosen purely by document language ([src/sharplsp/src/main.rs:763](../../src/sharplsp/src/main.rs#L763) `pick_sidecar`): `.fs` → F# sidecar, everything else → C# sidecar. The wire contract for every method is fixed by the Rust handler's MessagePack request/response structs (positional, `[Key(n)]`-ordered). A sidecar reaches parity for a method by @@ -19,32 +19,32 @@ that feature — that is the parity gap. | LSP method | Rust handler | C# (Roslyn) | F# (FCS) | Spec | |---|---|---|---|---| -| `textDocument/completion` | [semantic.rs:24](../../src/semantic.rs#L24) | ✅ | ✅ **(this plan)** | [FS-COMPLETION] | -| `completionItem/resolve` | [semantic.rs:87](../../src/semantic.rs#L87) | ✅ | ✅ **(this plan)** | [FS-COMPLETION-RESOLVE] | -| `textDocument/prepareRename` | [semantic.rs:920](../../src/semantic.rs#L920) | ✅ | ✅ **(this plan)** | [FS-RENAME-PREPARE] | -| `textDocument/rename` | [semantic.rs:965](../../src/semantic.rs#L965) | ✅ | ✅ **(this plan)** | [FS-RENAME-APPLY] | -| `textDocument/codeLens` | [code_lens.rs:15](../../src/code_lens.rs#L15) | ✅ | ✅ **(this plan)** | [FS-CODELENS] | -| `textDocument/prepareCallHierarchy` | [call_hierarchy.rs:19](../../src/call_hierarchy.rs#L19) | ✅ | ✅ **(this plan)** | [FS-CALLHIER-PREPARE] | -| `callHierarchy/incomingCalls` | [call_hierarchy.rs:59](../../src/call_hierarchy.rs#L59) | ✅ | ✅ **(this plan)** | [FS-CALLHIER-INCOMING] | -| `callHierarchy/outgoingCalls` | [call_hierarchy.rs:108](../../src/call_hierarchy.rs#L108) | ✅ | ✅ **(this plan)** | [FS-CALLHIER-OUTGOING] | -| `textDocument/prepareTypeHierarchy` | [type_hierarchy.rs:18](../../src/type_hierarchy.rs#L18) | ✅ | ✅ **(this plan)** | [FS-TYPEHIER-PREPARE] | -| `typeHierarchy/supertypes` | [type_hierarchy.rs:58](../../src/type_hierarchy.rs#L58) | ✅ | ✅ **(this plan)** | [FS-TYPEHIER-SUPER] | -| `typeHierarchy/subtypes` | [type_hierarchy.rs:93](../../src/type_hierarchy.rs#L93) | ✅ | ✅ **(this plan)** | [FS-TYPEHIER-SUB] | -| `textDocument/references` | [semantic.rs](../../src/semantic.rs) | ✅ solution-wide | ✅ **project-wide (this plan)** | [FS-REFS-PROJECT] | -| `textDocument/hover` | [semantic.rs:134](../../src/semantic.rs#L134) | ✅ | ✅ | — | -| `textDocument/definition` etc. | [main.rs:657](../../src/main.rs#L657) | ✅ | ✅ | — | +| `textDocument/completion` | [semantic.rs:24](../../src/sharplsp/src/semantic.rs#L24) | ✅ | ✅ **(this plan)** | [FS-COMPLETION] | +| `completionItem/resolve` | [semantic.rs:87](../../src/sharplsp/src/semantic.rs#L87) | ✅ | ✅ **(this plan)** | [FS-COMPLETION-RESOLVE] | +| `textDocument/prepareRename` | [semantic.rs:920](../../src/sharplsp/src/semantic.rs#L920) | ✅ | ✅ **(this plan)** | [RENAME-FSHARP-PREPARE] | +| `textDocument/rename` | [semantic.rs:965](../../src/sharplsp/src/semantic.rs#L965) | ✅ | ✅ **(this plan)** | [RENAME-FSHARP-APPLY] | +| `textDocument/codeLens` | [code_lens.rs:15](../../src/sharplsp/src/code_lens.rs#L15) | ✅ | ✅ **(this plan)** | [FS-CODELENS] | +| `textDocument/prepareCallHierarchy` | [call_hierarchy.rs:19](../../src/sharplsp/src/call_hierarchy.rs#L19) | ✅ | ✅ **(this plan)** | [FS-CALLHIER-PREPARE] | +| `callHierarchy/incomingCalls` | [call_hierarchy.rs:59](../../src/sharplsp/src/call_hierarchy.rs#L59) | ✅ | ✅ **(this plan)** | [FS-CALLHIER-INCOMING] | +| `callHierarchy/outgoingCalls` | [call_hierarchy.rs:108](../../src/sharplsp/src/call_hierarchy.rs#L108) | ✅ | ✅ **(this plan)** | [FS-CALLHIER-OUTGOING] | +| `textDocument/prepareTypeHierarchy` | [type_hierarchy.rs:18](../../src/sharplsp/src/type_hierarchy.rs#L18) | ✅ | ✅ **(this plan)** | [FS-TYPEHIER-PREPARE] | +| `typeHierarchy/supertypes` | [type_hierarchy.rs:58](../../src/sharplsp/src/type_hierarchy.rs#L58) | ✅ | ✅ **(this plan)** | [FS-TYPEHIER-SUPER] | +| `typeHierarchy/subtypes` | [type_hierarchy.rs:93](../../src/sharplsp/src/type_hierarchy.rs#L93) | ✅ | ✅ **(this plan)** | [FS-TYPEHIER-SUB] | +| `textDocument/references` | [semantic.rs](../../src/sharplsp/src/semantic.rs) | ✅ solution-wide | ✅ **project-wide (this plan)** | [REFERENCES-FSHARP-FIND] | +| `textDocument/hover` | [semantic.rs:134](../../src/sharplsp/src/semantic.rs#L134) | ✅ | ✅ | — | +| `textDocument/definition` etc. | [main.rs:657](../../src/sharplsp/src/main.rs#L657) | ✅ | ✅ | — | | `textDocument/typeDefinition` | nav | ✅ | ✅ | — | | `textDocument/declaration` | nav | ✅ | ✅ | — | | `textDocument/implementation` | nav | ✅ | ✅ | — | | `textDocument/documentHighlight` | nav | ✅ | ✅ | — | -| `textDocument/codeAction` + resolve | [code_actions.rs](../../src/code_actions.rs) | ✅ | ✅ | — | -| `textDocument/semanticTokens/{full,range}` | [semantic_tokens.rs](../../src/semantic_tokens.rs) | ✅ | ✅ | — | -| `textDocument/documentSymbol` | [document_symbols.rs:20](../../src/document_symbols.rs#L20) | ✅ tree-sitter (host) | ✅ **FCS nav items (this plan)** | [FS-DOCSYMBOL] | -| `workspace/symbol` | [main.rs](../../src/main.rs) `handle_standard_workspace_symbol` | ✅ tree-sitter (host) | ✅ **FCS document symbols** | [FS-WORKSPACE-SYMBOL] | -| `textDocument/signatureHelp` | [signature_help.rs:21](../../src/signature_help.rs#L21) | — | ✅ **FCS GetMethods (this plan)** | [FS-SIGHELP] | -| `textDocument/inlayHint` | [inlay_hints.rs](../../src/inlay_hints.rs) | ✅ | ✅ | — | -| `workspace/diagnostics` (pull) | [pull_diagnostics.rs](../../src/pull_diagnostics.rs) | ✅ | ✅ | — | -| `project/unusedPackages` | [nuget](../../src/nuget) | ✅ | ✅ | — | +| `textDocument/codeAction` + resolve | [code_actions.rs](../../src/sharplsp/src/code_actions.rs) | ✅ | ✅ | — | +| `textDocument/semanticTokens/{full,range}` | [semantic_tokens.rs](../../src/sharplsp/src/semantic_tokens.rs) | ✅ | ✅ | — | +| `textDocument/documentSymbol` | [document_symbols.rs:20](../../src/sharplsp/src/document_symbols.rs#L20) | ✅ tree-sitter (host) | ✅ **FCS nav items (this plan)** | [FS-DOCSYMBOL] | +| `workspace/symbol` | [main.rs](../../src/sharplsp/src/main.rs) `handle_standard_workspace_symbol` | ✅ tree-sitter (host) | ✅ **FCS document symbols** | [FS-WORKSPACE-SYMBOL] | +| `textDocument/signatureHelp` | [signature_help.rs:21](../../src/sharplsp/src/signature_help.rs#L21) | — | ✅ **FCS GetMethods (this plan)** | [FS-SIGHELP] | +| `textDocument/inlayHint` | [inlay_hints.rs](../../src/sharplsp/src/inlay_hints.rs) | ✅ | ✅ | — | +| `workspace/diagnostics` (pull) | [pull_diagnostics.rs](../../src/sharplsp/src/pull_diagnostics.rs) | ✅ | ✅ | — | +| `project/unusedPackages` | [nuget](../../src/sharplsp/src/nuget) | ✅ | ✅ | — | ### Not routed by the Rust host (parity N/A) @@ -52,11 +52,11 @@ These are registered by one or both sidecars but the Rust host never forwards th so they are out of scope for parity until the host wires them: - `textDocument/formatting`, `rangeFormatting`, `onTypeFormatting` — formatting is - **intentionally disabled** in the host ([src/main.rs:539](../../src/main.rs#L539)); + **intentionally disabled** in the host ([src/sharplsp/src/main.rs:539](../../src/sharplsp/src/main.rs#L539)); use Fantomas (F#) / CSharpier (C#) directly. F# additionally exposes `textDocument/formattingPreview` for the diff UI. - `textDocument/didChange` — the host only notifies the **C#** sidecar - ([src/main.rs:1050](../../src/main.rs#L1050)). The F# sidecar reads source from + ([src/sharplsp/src/main.rs:1050](../../src/sharplsp/src/main.rs#L1050)). The F# sidecar reads source from disk per request. See "Known limitations" below. - `workspace/diagnostics/all` — C#-only batch path; the host pulls per-document. @@ -70,7 +70,7 @@ unopened namespaces expose `NamespaceToOpen`, surfaced as an `(open )` detai hint (mirrors C#'s `(import) `). `completionItem/resolve` returns the wire-empty `AdditionalEdits` for now; **auto-`open` insertion is a follow-up** (see below). -### [FS-RENAME-PREPARE] / [FS-RENAME-APPLY] / [FS-REFS-PROJECT] +### [RENAME-FSHARP-PREPARE] / [RENAME-FSHARP-APPLY] / [REFERENCES-FSHARP-FIND] Rename and references both need **project-wide** symbol uses, not just the current file. A shared `getProjectUsages` helper runs `ParseAndCheckProject` and `GetUsesOfSymbol` so `textDocument/references` becomes project-wide (was current-file @@ -82,14 +82,14 @@ A reference-count lens above every top-level definition (functions, values, type union cases, members). Counts come from project-wide uses; the title format (`"N references"`) matches C#'s `CodeLensResolver`. -### [FS-CALLHIER-*] +### Call hierarchy — [FS-CALLHIER-PREPARE], [FS-CALLHIER-INCOMING], [FS-CALLHIER-OUTGOING] FCS has no built-in call graph, so the enclosing caller/callee is resolved from the untyped AST (`ParsedInput`) via `SyntaxTraversal`: incoming = project-wide call sites of the symbol mapped to their enclosing binding/member; outgoing = function/member applications inside the symbol's own binding range. Kind strings are capitalized to match the host's `parse_symbol_kind`. -### [FS-TYPEHIER-*] +### Type hierarchy — [FS-TYPEHIER-PREPARE], [FS-TYPEHIER-SUPER], [FS-TYPEHIER-SUB] Supertypes come from `FSharpEntity.BaseType` + `AllInterfaces`. Subtypes are found by scanning project entities for any whose base type or interfaces include the target. @@ -106,9 +106,9 @@ scanning project entities for any whose base type or interfaces include the targ ## Analyzers & diagnostics — FSAC parity + beyond -Implemented in [FSharpAnalyzers.fs](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpAnalyzers.fs), +Implemented in [FSharpAnalyzers.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpAnalyzers.fs), merged into the `workspace/diagnostics` handler -([FSharpSidecar.fs](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs)), configured +([FSharpSidecar.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs)), configured by the host via `analyzers/configure`. Full design in [DIAGNOSTICS-STATIC-ANALYZERS-SPEC](../specs/DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md). @@ -124,23 +124,23 @@ the headline differentiator: when `[analyzers] monorepo = true`, an unreferenced public symbol is a hard **error** (the repo is the whole world), which no FSAC/Ionide rule offers. Private/internal dead code is reported even outside monorepo mode. -- [x] [FS-ANALYZER-DEADCODE] monorepo dead-code (`GetAllUsesOfAllSymbols`, config-gated severity) -- [x] [FS-ANALYZER-UNUSEDOPEN] unused `open` detection (FCS `UnusedOpens`) -- [x] [FS-ANALYZER-SIMPLIFYNAME] simplify-name (FCS `SimplifyNames`) +- [x] [ANALYZERS-DEADCODE-SEVERITY] monorepo dead-code (`GetAllUsesOfAllSymbols`, config-gated severity) +- [x] [ANALYZERS-FSAC-UNUSED-OPEN] unused `open` detection (FCS `UnusedOpens`) +- [x] [ANALYZERS-FSAC-SIMPLIFY-NAME] simplify-name (FCS `SimplifyNames`) - [x] e2e + unit coverage (dead-code fixture, unused-open fixture, pure-helper unit tests) - [x] C# parity: Roslyn `SymbolFinder` monorepo dead-code (`SLSPC0101`) + `analyzers/configure` - ([DeadCodeAnalyzer.cs](../../sidecars/SharpLsp.Sidecar.CSharp/Workspace/DeadCodeAnalyzer.cs), + ([DeadCodeAnalyzer.cs](../../src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DeadCodeAnalyzer.cs), 5 e2e tests; same `[analyzers]` config flows to both sidecars from the host) -- [x] code fixes: remove-unused-open (`[FS-CODEFIX-UNUSEDOPEN]`) + simplify-name (`[FS-CODEFIX-SIMPLIFYNAME]`) +- [x] code fixes: remove-unused-open (`[ANALYZERS-FSAC-CODEFIX-UNUSED-OPEN]`) + simplify-name (`[ANALYZERS-FSAC-CODEFIX-SIMPLIFY-NAME]`) — `removeUnusedOpenActions`/`simplifyNameActions` in - [FSharpCodeFixes.fs](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpCodeFixes.fs), backed by the - shared [FSharpLocalAnalysis.fs](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpLocalAnalysis.fs) + [FSharpCodeFixes.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpCodeFixes.fs), backed by the + shared [FSharpLocalAnalysis.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpLocalAnalysis.fs) - [ ] code fixes: safe-delete dead symbol ## Pre-existing backlog (unchanged) - [ ] Ionide.ProjInfo integration for project cracking (currently manual `.fsproj` - XML parse in [FSharpWorkspace.fs:41](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspace.fs#L41)) + XML parse in [FSharpWorkspace.fs:41](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspace.fs#L41)) - [ ] File ordering awareness + reorder suggestions (F# compilation order matters) - [ ] Type provider navigation support - [ ] Convert pipe to/from nested function calls (refactoring) @@ -151,25 +151,25 @@ rule offers. Private/internal dead code is reported even outside monorepo mode. ## TODO — parity pass All parity methods are registered in the F# sidecar -([FSharpSidecar.fs](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs)) and -backed by dedicated modules ([FSharpCompletion.fs](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpCompletion.fs), -[FSharpRename.fs](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs), -[FSharpCodeLens.fs](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpCodeLens.fs), -[FSharpHierarchy.fs](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpHierarchy.fs), -[FSharpReferences.fs](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs)), +([FSharpSidecar.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs)) and +backed by dedicated modules ([FSharpCompletion.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpCompletion.fs), +[FSharpRename.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs), +[FSharpCodeLens.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpCodeLens.fs), +[FSharpHierarchy.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpHierarchy.fs), +[FSharpReferences.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs)), with the MessagePack wire contract in -[FSharpWire.fs](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs). +[FSharpWire.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs). E2E coverage lives in the F# sidecar IPC round-trip suite -([SidecarEndToEndTests.fs](../../sidecars/SharpLsp.Sidecar.FSharp.Tests/SidecarEndToEndTests.fs)), +([SidecarEndToEndTests.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SidecarEndToEndTests.fs)), which loads a real two-file `.fsproj` over a Unix socket and asserts each method's MessagePack response (completion, resolve, project-wide references, prepare/apply rename, code lens, and call/type hierarchy). - [x] [FS-COMPLETION] `textDocument/completion` via `GetDeclarationListInfo` - [x] [FS-COMPLETION-RESOLVE] `completionItem/resolve` (wire-empty edits + ns hint) -- [x] [FS-REFS-PROJECT] project-wide `textDocument/references` -- [x] [FS-RENAME-PREPARE] `textDocument/prepareRename` -- [x] [FS-RENAME-APPLY] `textDocument/rename` (project-wide edits) +- [x] [REFERENCES-FSHARP-FIND] project-wide `textDocument/references` +- [x] [RENAME-FSHARP-PREPARE] `textDocument/prepareRename` +- [x] [RENAME-FSHARP-APPLY] `textDocument/rename` (project-wide edits) - [x] [FS-CODELENS] `textDocument/codeLens` reference counts - [x] [FS-CALLHIER-PREPARE] `textDocument/prepareCallHierarchy` - [x] [FS-CALLHIER-INCOMING] `callHierarchy/incomingCalls` @@ -179,12 +179,11 @@ rename, code lens, and call/type hierarchy). - [x] [FS-TYPEHIER-SUB] `typeHierarchy/subtypes` - [x] [FS-DOCSYMBOL] `textDocument/documentSymbol` via FCS `GetNavigationItems` (parse-only; host routes `.fs` to the sidecar, `.cs` stays tree-sitter) - [x] [FS-SIGHELP] `textDocument/signatureHelp` via FCS `GetMethods` (capability advertised; overloads surfaced) -- [x] [FS-DIDCHANGE-OVERLAY] canonical overlay-aware check funnel: all per-file analyses (hover, completion, diagnostics, signature help, inlay hints, code fixes, file ordering) funnel through the single `parseAndCheckOnce`/`checkFileWithParse`, so every feature type-checks the live didChange buffer and a reverted file clears its errors (GitHub #160, sidecar-side complement of the host's `[DIAG-PUSH-GATE]`; IPC dispatch is sequential so no mid-check gate is needed) +- [x] [HOVER-FSHARP-OVERLAY] canonical overlay-aware check funnel: all per-file analyses (hover, completion, diagnostics, signature help, inlay hints, code fixes, file ordering) funnel through the single `parseAndCheckOnce`/`checkFileWithParse`, so every feature type-checks the live didChange buffer and a reverted file clears its errors (GitHub #160, sidecar-side complement of the host's `[DIAG-PUSH-GATE]`; IPC dispatch is sequential so no mid-check gate is needed) - [x] e2e tests for every method above (real `.fsproj`, IPC round-trip) > **Routing note:** `callHierarchy/incomingCalls`/`outgoingCalls` and > `typeHierarchy/super`/`subtypes` carry the document URI inside `params.item`, > not `params.textDocument`. `extract_document_uri` -> ([main.rs](../../src/main.rs)) now also reads `params.item.uri` so these +> ([main.rs](../../src/sharplsp/src/main.rs)) now also reads `params.item.uri` so these > follow-up requests route to the **F#** sidecar instead of defaulting to C#. - diff --git a/docs/plans/FSHARP-FSAC-PARITY-PLAN.md b/docs/plans/FSHARP-FSAC-PARITY-PLAN.md index da827d6c..11966fb7 100644 --- a/docs/plans/FSHARP-FSAC-PARITY-PLAN.md +++ b/docs/plans/FSHARP-FSAC-PARITY-PLAN.md @@ -18,12 +18,12 @@ identically for `.cs` and `.fs`. **This** doc tracks the *external* target: matching and beating **FsAutoComplete/Ionide**. Complementary — keep the internal-parity matrix there and the FSAC-parity matrix here; do not duplicate. -Spec IDs use the `[FSAC-PARITY-*]` group; existing per-feature IDs (`[FS-*]`, -`[PKG-*]`) are referenced where they already implement a row. +Spec IDs use the `FSAC-PARITY-...` group; existing `FS-...` and `PKG-...` +feature IDs are referenced where they already implement a row. ## How F# requests are served -The Rust host ([src/main.rs](../../src/main.rs)) routes each LSP method to exactly +The Rust host ([src/sharplsp/src/main.rs](../../src/sharplsp/src/main.rs)) routes each LSP method to exactly one sidecar by document language (`pick_sidecar`): `.fs`/`.fsx`/`.fsi` → F# (FCS) sidecar, everything else → C# (Roslyn). A method is "at parity" only when the F# sidecar registers it **and** returns a wire-compatible payload. Unregistered @@ -42,7 +42,7 @@ Legend: ✅ have · 🟡 partial · ❌ missing · ⭐ beyond FSAC (we have, FSA | Type definition | `textDocument/typeDefinition` | ✅ | resolves to the type decl; `test_full_stack_fsharp_navigation` (was gap [#112] — invalid-fixture artifact) | | Implementation | `textDocument/implementation` | ✅ | | | Declaration | `textDocument/declaration` | ⭐✅ | not in FSAC's list | -| Find references | `textDocument/references` | ✅ | `[FS-REFS-PROJECT]` project-wide incl. type use-sites; `test_full_stack_fsharp_references_type_use_sites` (was gap [#112] — invalid-fixture artifact) | +| Find references | `textDocument/references` | ✅ | `[REFERENCES-FSHARP-FIND]` project-wide incl. type use-sites; `test_full_stack_fsharp_references_type_use_sites` (was gap [#112] — invalid-fixture artifact) | | Hover | `textDocument/hover` | ✅ | XML-doc rendering; e2e covered | | Signature help | `textDocument/signatureHelp` | ✅ | `[FS-SIGHELP]` | | Document symbols | `textDocument/documentSymbol` | ✅ | `[FS-DOCSYMBOL]` (parse-only) | @@ -54,16 +54,16 @@ Legend: ✅ have · 🟡 partial · ❌ missing · ⭐ beyond FSAC (we have, FSA | FSAC capability | SharpLsp | Spec / notes | |---|---|---| -| Rename | ⭐✅ | `[FS-RENAME-PREPARE]`/`[FS-RENAME-APPLY]` — **project-wide** (FSAC is file-local) | +| Rename | ⭐✅ | `[RENAME-FSHARP-PREPARE]`/`[RENAME-FSHARP-APPLY]` — **project-wide** (FSAC is file-local) | | Resolve namespace (auto-`open`) | ✅ | FS0039 `open` suggestions | | Replace unused symbol with `_` | ✅ | FS1182 | | Generate DU match cases | ✅ | union-case stub generation | | Generate record stub | ✅ | record-field stub generation | -| Remove unused `open` | ✅ | `[FS-CODEFIX-UNUSEDOPEN]` — quick fix for the `SLSPF0102` hint (deletes the `open` line) | -| Remove redundant qualifiers (`SimplifyName`) | ✅ | `[FS-CODEFIX-SIMPLIFYNAME]` — quick fix for the `SLSPF0103` hint (strips the qualifier prefix) | +| Remove unused `open` | ✅ | `[ANALYZERS-FSAC-CODEFIX-UNUSED-OPEN]` — quick fix for the `SLSPF0102` hint (deletes the `open` line) | +| Remove redundant qualifiers (`SimplifyName`) | ✅ | `[ANALYZERS-FSAC-CODEFIX-SIMPLIFY-NAME]` — quick fix for the `SLSPF0103` hint (strips the qualifier prefix) | | Fix typo from compiler error ("did you mean") | ❌ | **gap** | | Add missing `new` for `IDisposable` | ❌ | **gap** | -| Generate interface implementation | ✅ | `[FS-CODEFIX-INTERFACESTUB]` — FCS `InterfaceStubGenerator` ("Implement interface"), completes the union/record/interface stub trio | +| Generate interface implementation | ✅ | `[ANALYZERS-FSAC-CODEFIX-INTERFACE-STUB]` — FCS `InterfaceStubGenerator` ("Implement interface"), completes the union/record/interface stub trio | | Extra fixes beyond FSAC list | ⭐✅ | FS0020 `\|> ignore`, FS0025 wildcard arm, FS0026 remove redundant case, FS0001 type conversion | | Formatting via Fantomas | 🟡 | implemented in `FSharpFeatures.fs` but **sequestered** — not routed by host. **gap: enable routing** | | Code lens (reference counts) | ✅ | `[FS-CODELENS]` | @@ -73,9 +73,9 @@ Legend: ✅ have · 🟡 partial · ❌ missing · ⭐ beyond FSAC (we have, FSA | FSAC capability | SharpLsp | Spec / notes | |---|---|---| -| Unused declarations analyzer | ✅ | `[FS-ANALYZER-DEADCODE]` (monorepo-aware; SharpLsp1 extending) | -| Unused opens analyzer | ✅ | `[FS-ANALYZER-UNUSEDOPEN]` — `SLSPF0102` hint (`UnusedOpens.getUnusedOpens`) | -| SimplifyName analyzer | ✅ | `[FS-ANALYZER-SIMPLIFYNAME]` — `SLSPF0103` hint (`SimplifyNames.getSimplifiableNames`) | +| Unused declarations analyzer | ✅ | `[ANALYZERS-DEADCODE-SEVERITY]` (monorepo-aware; SharpLsp1 extending) | +| Unused opens analyzer | ✅ | `[ANALYZERS-FSAC-UNUSED-OPEN]` — `SLSPF0102` hint (`UnusedOpens.getUnusedOpens`) | +| SimplifyName analyzer | ✅ | `[ANALYZERS-FSAC-SIMPLIFY-NAME]` — `SLSPF0103` hint (`SimplifyNames.getSimplifiableNames`) | | FSharpLint linting | ❌ | **gap** — in tech stack (`FSharpLint.Core`), not yet wired | | FSI / `.fsx` script type-check (`UseSdkScripts`, `fsiExtraParameters`) | 🟡 | `.fsx`/`.fsi` recognized + routed; full FSI script checking missing — **gap** | | `fsharp/workspacePeek` / `workspaceLoad` / `project` / `compile` | 🟡 | workspace loading + `.fsproj` cracking exist; Ionide custom endpoints not exposed | @@ -90,8 +90,8 @@ Legend: ✅ have · 🟡 partial · ❌ missing · ⭐ beyond FSAC (we have, FSA |---|---| | Call hierarchy (incoming/outgoing) | `[FS-CALLHIER-PREPARE/INCOMING/OUTGOING]` — FSAC has none | | Type hierarchy (super/subtypes) | `[FS-TYPEHIER-PREPARE/SUPER/SUB]` — FSAC has none | -| Project-wide references & rename | `[FS-REFS-PROJECT]`, `[FS-RENAME-*]` | -| Monorepo dead-code (errors vs warnings) | `[FS-ANALYZER-DEADCODE]` | +| Project-wide references & rename | `[REFERENCES-FSHARP-FIND]`, `[RENAME-FSHARP-PREPARE]`, `[RENAME-FSHARP-APPLY]` | +| Monorepo dead-code (errors vs warnings) | `[ANALYZERS-DEADCODE-SEVERITY]` | | Unused NuGet package detection | `[PKG-UNUSED-DETECT-FS]` | | F# file-order dependency analysis | `FSharpFileOrder.fs` | @@ -102,16 +102,16 @@ Each item is sized to one focused change with e2e + sidecar tests. 1. ~~**references/typeDefinition completeness**~~ — ✅ **done** `[#112]`. Root cause was the shared e2e fixture, not the sidecar: `Library.fs` placed `let area` / `let sumOfSquares` directly in a `namespace` (illegal F#, FS0201), so those bindings never type-checked and FCS recorded no `Shape` use-sites inside them — refs/typeDefinition on the `Shape` type saw only the declaration. Making the fixture a valid top-level `module` restored full parity. e2e: `test_full_stack_fsharp_references_type_use_sites` + tightened `test_full_stack_fsharp_navigation`. 2. ~~**workspace/symbol** for F#~~ — ✅ **done** `[FS-WORKSPACE-SYMBOL]`: F# files route to the FCS sidecar's document symbols inside the standard `workspace/symbol` handler - ([main.rs](../../src/main.rs) `collect_fsharp_ws_symbols`, - [document_symbols.rs](../../src/document_symbols.rs) `fsharp_workspace_symbols`). + ([main.rs](../../src/sharplsp/src/main.rs) `collect_fsharp_ws_symbols`, + [document_symbols.rs](../../src/sharplsp/src/document_symbols.rs) `fsharp_workspace_symbols`). 3. **FSharpLint integration** — wire `FSharpLint.Core` into the diagnostics pipeline. 4. ~~**Unused-opens** analyzer + "remove unused open" code fix.~~ — ✅ **done**: analyzer - `[FS-ANALYZER-UNUSEDOPEN]` (`SLSPF0102`) + fix `[FS-CODEFIX-UNUSEDOPEN]`. + `[ANALYZERS-FSAC-UNUSED-OPEN]` (`SLSPF0102`) + fix `[ANALYZERS-FSAC-CODEFIX-UNUSED-OPEN]`. 5. ~~**SimplifyName** analyzer + "remove redundant qualifier" code fix.~~ — ✅ **done**: analyzer - `[FS-ANALYZER-SIMPLIFYNAME]` (`SLSPF0103`) + fix `[FS-CODEFIX-SIMPLIFYNAME]`. -6. ~~**Interface-implementation stub** code action~~ — ✅ **done** `[FS-CODEFIX-INTERFACESTUB]`: FCS + `[ANALYZERS-FSAC-SIMPLIFY-NAME]` (`SLSPF0103`) + fix `[ANALYZERS-FSAC-CODEFIX-SIMPLIFY-NAME]`. +6. ~~**Interface-implementation stub** code action~~ — ✅ **done** `[ANALYZERS-FSAC-CODEFIX-INTERFACE-STUB]`: FCS `InterfaceStubGenerator` ("Implement interface"), completing the union/record/interface stub trio - ([FSharpCodeActions.fs](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpCodeActions.fs) `tryGenerateInterfaceStub`). + ([FSharpCodeActions.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpCodeActions.fs) `tryGenerateInterfaceStub`). 7. **Fantomas formatting** — un-sequester: route `textDocument/formatting` + `rangeFormatting` to the F# sidecar. 8. **Compiler-error typo fix** ("did you mean") + **add `new` for IDisposable**. 9. **FSI/`.fsx`** full script type-checking incl. `fsiExtraParameters`. @@ -128,7 +128,7 @@ Each item is sized to one focused change with e2e + sidecar tests. ## E2E coverage status (`[FSAC-PARITY-E2E]`) Rust-host full-stack F# e2e lives in -[tests/e2e_modules/fsharp.rs](../../tests/e2e_modules/fsharp.rs) and drives the +[src/sharplsp/tests/e2e_modules/fsharp.rs](../../src/sharplsp/tests/e2e_modules/fsharp.rs) and drives the real `sharplsp` host + F# sidecar against `create_fsharp_test_workspace`. | Feature | E2E test | Status | diff --git a/docs/plans/INFRASTRUCTURE-PLAN.md b/docs/plans/INFRASTRUCTURE-PLAN.md index 6d5dd888..ee584d2a 100644 --- a/docs/plans/INFRASTRUCTURE-PLAN.md +++ b/docs/plans/INFRASTRUCTURE-PLAN.md @@ -21,7 +21,9 @@ Core infrastructure improvements for the SharpLsp LSP host. ## Incremental Computation -- [ ] Evaluate salsa database for incremental caching of semantic results +- [ ] Implement the Rust-host salsa database as the only semantic-result memoization mechanism +- [ ] Replace and remove the interim, nonconformant `nav_cache.rs` `HashMap`; model document, solution, and sidecar-generation state as salsa inputs +- [ ] Cancel superseded semantic requests; input invalidation alone does not cancel in-flight sidecar work - [ ] Request coalescing and cancellation (150ms debounce window) — config exists, wire up active debouncing ## File Watching diff --git a/docs/plans/REFERENCES-PLAN.md b/docs/plans/REFERENCES-PLAN.md index 713736a1..71b738d0 100644 --- a/docs/plans/REFERENCES-PLAN.md +++ b/docs/plans/REFERENCES-PLAN.md @@ -6,15 +6,15 @@ Implements `textDocument/references` and `textDocument/documentHighlight` for C# (Roslyn) and F# (FCS). The Rust host routes requests to the appropriate sidecar, which resolves the symbol and returns all reference locations across the solution (references) or within the current document (highlights). Both methods are P0 features targeting Phase 2. -**Current status:** Core implementation complete for Rust host, C# sidecar, and F# sidecar. Caching enabled. Remaining: edge-case handling (override chains, interface members, partial classes), E2E tests, and performance validation. +**Current status:** Core routing exists for the Rust host and both sidecars. The current `nav_cache.rs` `HashMap` is interim and nonconformant with [REFERENCES-SALSA]; solution-wide invalidation, salsa migration, cancellation, edge cases, E2E tests, and performance validation remain. -**Dependencies:** The definition infrastructure (symbol resolution pipeline, `PositionRequest`/`LocationListResult` wire types, tree-sitter pre-validation, nav cache, sidecar IPC routing) is fully implemented and can be reused. `SymbolFinder.FindReferencesAsync()` in Roslyn and `GetUsesOfSymbolInFile/Project()` in FCS are the primary APIs. +**Dependencies:** The definition infrastructure supplies symbol resolution, `PositionRequest`/`LocationListResult` wire types, tree-sitter pre-validation, and sidecar IPC routing. Its interim `nav_cache.rs` must not be treated as the target architecture. `SymbolFinder.FindReferencesAsync()` in Roslyn and `GetUsesOfSymbolInFile/Project()` in FCS are the primary APIs. **Architecture notes:** -- References reuse the same request routing pattern as definition (Rust host → sidecar dispatch → cache result). +- References reuse the same request routing pattern as definition (Rust host → sidecar dispatch → salsa query result). - `ReferencesRequest` extends `PositionRequest` with an `IncludeDeclaration` boolean. - References return `LocationListResult` (reused from definition). Document highlights need a new `DocumentHighlightListResult` with read/write kind annotation. -- Document highlights are document-scoped and cheaper than solution-wide references — cache more aggressively. +- Document highlights are document-scoped; their memoization still belongs only to Rust-host salsa. ## TODO @@ -37,8 +37,10 @@ Implements `textDocument/references` and `textDocument/documentHighlight` for C# - [x] Pass `context.includeDeclaration` through to sidecar via `ReferencesRequest` - [x] Add tree-sitter pre-validation to short-circuit on whitespace/comments/string literals - [ ] Support partial result streaming for large result sets (P1) -- [x] Add references cache keyed by `(document_uri, document_version, position, include_declaration)` -- [x] Invalidate references cache on any document change in the solution +- [x] Add the interim `nav_cache.rs` references map keyed by `(document_uri, document_version, position, include_declaration)` (nonconformant) +- [ ] Replace the interim map with a Rust-host salsa query and remove `nav_cache.rs` +- [ ] Make every solution document, project graph, and sidecar generation/readiness value a salsa input so any relevant change invalidates references; current code invalidates only entries requested from the edited URI +- [ ] Cancel superseded references requests and prevent late results from updating newer inputs - [x] Add fallback behavior: return `null` when sidecar is unavailable or loading - [x] Add tracing/logging for references request lifecycle (dispatch, cache hit/miss, result count, latency) @@ -48,8 +50,9 @@ Implements `textDocument/references` and `textDocument/documentHighlight` for C# - [x] Add `textDocument/documentHighlight` handler registration in LSP request dispatcher - [x] Implement request routing: identify language from VFS, dispatch to correct sidecar - [x] Add tree-sitter pre-validation to short-circuit on whitespace/comments/string literals -- [x] Add highlights cache keyed by `(document_uri, document_version, position)` -- [x] Invalidate highlights cache on document edit (version change) +- [x] Add the interim `nav_cache.rs` highlight map keyed by `(document_uri, document_version, position)` (nonconformant) +- [ ] Replace the interim map with a Rust-host salsa query driven by document/version and sidecar-generation inputs +- [ ] Cancel superseded highlight requests; version invalidation alone does not cancel in-flight work - [x] Add fallback behavior: return `null` when sidecar is unavailable or loading - [x] Add tracing/logging for highlight request lifecycle @@ -107,7 +110,7 @@ Implements `textDocument/references` and `textDocument/documentHighlight` for C# - [x] Implement F# symbol → C# references (Rust host dispatches to C# sidecar for C# projects) - [ ] E2E test: cross-language references on a mixed C#/F# solution -### Testing — Rust E2E (`tests/lsp_e2e.rs`) +### Testing — Rust E2E (`src/sharplsp/tests/lsp_e2e.rs`) - [ ] E2E test: C# find-all-references on method returns all call sites - [ ] E2E test: C# find-all-references on class returns all type usages @@ -131,5 +134,5 @@ Implements `textDocument/references` and `textDocument/documentHighlight` for C# - [ ] Benchmark references latency on small solution (<100 files): target <500ms - [ ] Benchmark references latency on medium solution (~1000 files): target <2s - [ ] Benchmark document highlight latency: target <100ms -- [ ] Benchmark references cache hit: target <1ms +- [ ] Benchmark references salsa hit: target <1ms - [ ] Validate tree-sitter pre-validation rejects non-symbol positions in <1ms diff --git a/docs/plans/RELEASE-PUBLISHING-OIDC-PLAN.md b/docs/plans/RELEASE-PUBLISHING-OIDC-PLAN.md index 8836b679..bb33e7c7 100644 --- a/docs/plans/RELEASE-PUBLISHING-OIDC-PLAN.md +++ b/docs/plans/RELEASE-PUBLISHING-OIDC-PLAN.md @@ -180,7 +180,8 @@ Pre-flight (all must be true): use `v0.7.0` (or higher). Reusing a burned tag will not move published code. ```bash -git checkout main && git pull +git checkout main +git pull --ff-only git tag v0.7.0 # plain tag = stable; v0.7.0-rc.1 = pre-release everywhere git push origin v0.7.0 ``` diff --git a/docs/plans/RIDER-PLUGIN-PLAN.md b/docs/plans/RIDER-PLUGIN-PLAN.md index 55d99e5b..e492edaa 100644 --- a/docs/plans/RIDER-PLUGIN-PLAN.md +++ b/docs/plans/RIDER-PLUGIN-PLAN.md @@ -26,7 +26,7 @@ The Rider plugin is "done" when: ## Phase summaries -**Phase 1 — Gradle scaffold.** Stand up `editors/rider/` with a Gradle +**Phase 1 — Gradle scaffold.** Stand up `src/editors/rider/` with a Gradle wrapper, `build.gradle.kts` using the 2.x `org.jetbrains.intellij.platform` plugin targeting Rider 2024.3, a `plugin.xml` that depends on `com.intellij.modules.lsp`, and just enough Kotlin stubs for @@ -72,8 +72,8 @@ server launch. **Phase 7 — Build infrastructure.** New Makefile targets: `build-rider`, `package-rider` (alias), `test-rider`, `lint-rider`, `clean-rider`. Wire `build-rider` into the top-level `build` and `test-rider` into `test`. -Copy the produced plugin zip to the repo root as `sharplsp-rider.zip` for -parity with `sharplsp.vsix` and `sharplsp-zed-extension.tar.gz`. Gracefully skip +Copy the produced plugin zip to `dist/sharplsp-rider.zip` for parity with +`dist/sharplsp.vsix` and `dist/sharplsp-zed-extension.tar.gz`. Gracefully skip with a warning if no JVM is available so the rest of the repo still builds. @@ -94,7 +94,7 @@ only — Community editions are not supported. ### Phase 1: Gradle scaffold -- [x] Create `editors/rider/` directory +- [x] Create `src/editors/rider/` directory - [x] Write `settings.gradle.kts` — `rootProject.name = "sharplsp-rider"` - [x] Write `build.gradle.kts` using `org.jetbrains.intellij.platform` 2.14 - [x] Write `gradle.properties` pinning platform version (Rider 2024.3) @@ -173,7 +173,7 @@ only — Community editions are not supported. ### Phase 7: Build infrastructure - [ ] Add `build-rider` Makefile target — calls `./gradlew buildPlugin` - and copies the zip to repo root as `sharplsp-rider.zip` — **but only if** + and copies the zip to `dist/sharplsp-rider.zip` — **but only if** the environment has a JVM; otherwise skip with a warning - [ ] Add `package-rider` Makefile target — alias for `build-rider` for naming symmetry with `package-zed` diff --git a/docs/plans/SCREENSHOT-FIX-PLAN.md b/docs/plans/SCREENSHOT-FIX-PLAN.md index 3d69c8dd..cc50c899 100644 --- a/docs/plans/SCREENSHOT-FIX-PLAN.md +++ b/docs/plans/SCREENSHOT-FIX-PLAN.md @@ -17,7 +17,7 @@ All Zed screenshots show plain code only — Zed cannot trigger features program ## Root Cause Analysis -The capture script (`editors/vscode/screenshots/capture.mjs`) uses `code serve-web` with Playwright. The SharpLsp VSIX is installed to `~/.vscode-server/extensions/` and the extension DOES activate (status bar shows "SharpLsp" and "C#"). The LSP sidecar IS running. +The capture script (`src/editors/vscode/screenshots/capture.mjs`) uses `code serve-web` with Playwright. The SharpLsp VSIX is installed to `~/.vscode-server/extensions/` and the extension DOES activate (status bar shows "SharpLsp" and "C#"). The LSP sidecar IS running. However: @@ -44,14 +44,14 @@ The SharpLsp activity bar icon (`sharplsp-explorer`) is not visible in `code ser ## Test Workspace -The test workspace at `editors/vscode/test-fixtures/workspace/` now contains: +The test workspace at `src/editors/vscode/test-fixtures/workspace/` now contains: - `Calculator.cs` — main test file for screenshots - `TestFixtures.csproj` — .NET 9.0 project file (added to enable Roslyn sidecar) - `Empty.cs`, `Nested.cs`, `Greeter.fs` — additional test files ## Capture Script -`editors/vscode/screenshots/capture.mjs` handles: +`src/editors/vscode/screenshots/capture.mjs` handles: 1. Installing the SharpLsp VSIX to both desktop and serve-web extensions directories 2. Writing workspace settings with `sharplsp.lspPath` pointing to `target/release/sharplsp` 3. Launching `code serve-web` and connecting via Playwright @@ -62,8 +62,8 @@ The test workspace at `editors/vscode/test-fixtures/workspace/` now contains: When a feature's screenshot is fixed: 1. Replace `eleventyExcludeFromCollections: true` with the original `eleventyNavigation` frontmatter -2. Add the page to the footer in `website/src/_data/navigation.json` -3. Add the page to `SCREENSHOT_PAGES` in `website/tests/screenshots.spec.js` +2. Add the page to the footer in `src/website/src/_data/navigation.json` +3. Add the page to `SCREENSHOT_PAGES` in `src/website/tests/screenshots.spec.js` ### Frontmatter to restore diff --git a/docs/plans/SCRIPTING-FILEBASED-PLAN.md b/docs/plans/SCRIPTING-FILEBASED-PLAN.md index 065c3573..1480d870 100644 --- a/docs/plans/SCRIPTING-FILEBASED-PLAN.md +++ b/docs/plans/SCRIPTING-FILEBASED-PLAN.md @@ -52,12 +52,12 @@ remaining gaps are explicit rather than implied. - [x] Send the **file path**, not the parent directory, for script and file-based documents — implements [SCRIPT-ROUTE-TARGET] - [x] Route to the sidecar matching the document's language (`sidecar_for_path`); `.fsi` is - deliberately excluded — implements [SCRIPT-DETECT], [FSX-FSI] + deliberately excluded — implements [SCRIPT-DETECT], [SCRIPT-FSX-FSI] - [x] Keep health-monitor start strictly after `workspace/open` completes — [SCRIPT-ROUTE-HEALTH] -- [x] Remove trailing whitespace introduced in `src/main.rs` (failed `cargo fmt --check`) +- [x] Remove trailing whitespace introduced in `src/sharplsp/src/main.rs` (failed `cargo fmt --check`) - [x] Flatten the 4-level `if let` nest in `init_workspace_for_file` into `opened_document_path` + `sidecar_for_path` (functions <20 LOC) -- [ ] Extract classification into `src/document_kind.rs` with the full +- [ ] Extract classification into `src/sharplsp/src/document_kind.rs` with the full `ProjectOwned` / `CSharpFileBasedApp` / `CSharpScript` / `FSharpScript` / `FSharpSignature` lattice; currently an extension match inside `main.rs` — [SCRIPT-DETECT] - [ ] Implement cone search with the four stop conditions (project file, workspace root, `.git`, @@ -71,45 +71,45 @@ remaining gaps are explicit rather than implied. - [x] Add `FileLevelDirectives.cs`: parse `IgnoredDirectiveTriviaSyntax` / `ShebangDirectiveTriviaSyntax` off the CST into a typed directive model — no regex anywhere — - implements [FILEBASED-DIRECTIVES] + implements [SCRIPT-FILEBASED-DIRECTIVES] - [x] Support `#:sdk`, `#:package` (`Name`, `Name@Version`, `Name@*`), `#:project`, `#:property`, - `#:include` — implements [FILEBASED-DIRECTIVES] + `#:include` — implements [SCRIPT-FILEBASED-DIRECTIVES] - [x] **Replace `ResolveCsFiles` directory glob** with root-file + transitive `#:include` closure, cycle-safe, bounded to 64 files / 8 levels — implements [SCRIPT-CLOSURE], kills [SCRIPT-ANTIPATTERN] - [x] Pass the file verbatim to Roslyn; never strip or rewrite the shebang. Requires the - `FileBasedProgram` parse feature or Roslyn reports CS9314 — [FILEBASED-SHEBANG] + `FileBasedProgram` parse feature or Roslyn reports CS9314 — [SCRIPT-FILEBASED-SHEBANG] - [x] Emit the SDK's implicit global usings as a synthetic document — `CSharpCompilationOptions.Usings` is honoured only for `SourceCodeKind.Script`, so a file-based - app needs the generated-file route the SDK itself uses — [FILEBASED-PARSEOPTIONS] + app needs the generated-file route the SDK itself uses — [SCRIPT-FILEBASED-PARSEOPTIONS] - [x] Dispose the previous `AdhocWorkspace` when reopening (was leaked on repeat `OpenAsync`) - [ ] Map `#:include` item types by extension (`.cs`→Compile, `.resx`→EmbeddedResource, `.json`→None, `.razor`→Content); only `Compile` joins the semantic closure. Currently every resolved include - is treated as Compile — [FILEBASED-DIRECTIVES] + is treated as Compile — [SCRIPT-FILEBASED-DIRECTIVES] - [ ] Diagnose a `#:` directive appearing after the first non-trivia token. Detection and the diagnostics-pipeline wiring land together — a detector with no consumer is dead code, so - neither half ships alone — [FILEBASED-DIRECTIVES] + neither half ships alone — [SCRIPT-FILEBASED-DIRECTIVES] - [ ] Resolve `LanguageVersion` from the target framework band instead of `Latest` — - [FILEBASED-PARSEOPTIONS] -- [ ] Diagnostic when an `#:include`d file declares top-level statements — [FILEBASED-ENTRYPOINT] + [SCRIPT-FILEBASED-PARSEOPTIONS] +- [ ] Diagnostic when an `#:include`d file declares top-level statements — [SCRIPT-FILEBASED-ENTRYPOINT] - [ ] Keep a root-path → workspace map so two apps in one directory stay independent *concurrently*; today each `OpenAsync` replaces the workspace, which is correct per-open but not concurrent — [SCRIPT-MULTIROOT] -- [ ] Report `filebased-degraded` from `workspace/status` while on tier 2 — [FILEBASED-REFERENCES-FALLBACK] +- [ ] Report `filebased-degraded` from `workspace/status` while on tier 2 — [SCRIPT-FILEBASED-REFERENCES-FALLBACK] - [ ] Publish an informational diagnostic naming why `#:package` symbols are unresolved on tier 2 — - [FILEBASED-REFERENCES-FALLBACK] + [SCRIPT-FILEBASED-REFERENCES-FALLBACK] ### C# Sidecar — scripts - [x] `.csx` parsed with `SourceCodeKind.Script`, `OutputKind.DynamicallyLinkedLibrary`. `SourceCodeKind` is per-**document**, not inherited from project parse options — implements - [CSX-OPTIONS] -- [x] Apply the ten script default imports via `CSharpCompilationOptions.Usings` — [CSX-OPTIONS] + [SCRIPT-CSX-OPTIONS] +- [x] Apply the ten script default imports via `CSharpCompilationOptions.Usings` — [SCRIPT-CSX-OPTIONS] - [x] `SourceReferenceResolver` for `#load`, rooted at the script directory. The closure stays root-only so Roslyn owns `#load` resolution rather than double-adding loaded files — - [CSX-RESOLVERS] -- [ ] `MetadataReferenceResolver` for `#r "assembly.dll"`, rooted at the script directory — [CSX-RESOLVERS] -- [ ] Clear unresolved-reference diagnostic for `#r "nuget:"` (phase 3) — [CSX-RESOLVERS] + [SCRIPT-CSX-RESOLVERS] +- [ ] `MetadataReferenceResolver` for `#r "assembly.dll"`, rooted at the script directory — [SCRIPT-CSX-RESOLVERS] +- [ ] Clear unresolved-reference diagnostic for `#r "nuget:"` (phase 3) — [SCRIPT-CSX-RESOLVERS] ### C# Sidecar — regression guards on the existing MSBuild path @@ -132,16 +132,16 @@ remaining gaps are explicit rather than implied. ### F# Sidecar — scripts - [x] Route `.fsx`/`.fsscript` through `FSharpChecker.GetProjectOptionsFromScript` — implements - [FSX-OPTIONS] + [SCRIPT-FSX-OPTIONS] - [x] Pass `assumeDotNetFramework=false`, `useSdkRefs=true`, `useFsiAuxLib=true` so the `fsi` object - binds — [FSX-OPTIONS] -- [x] Define `INTERACTIVE` and `EDITING`, not `COMPILED`, for scripts — implements [FSX-SYMBOLS] + binds — [SCRIPT-FSX-OPTIONS] +- [x] Define `INTERACTIVE` and `EDITING`, not `COMPILED`, for scripts — implements [SCRIPT-FSX-SYMBOLS] - [x] `loadProject` hard-failed with `"No .fsproj found"`; it now dispatches on document kind before reaching that point — [SCRIPT-DETECT] - [x] Honour the overlay buffer so an unsaved script checks against editor text, not disk -- [x] `.fsi` with no owning project is syntax-only; no F# workspace is opened — implements [FSX-FSI] +- [x] `.fsi` with no owning project is syntax-only; no F# workspace is opened — implements [SCRIPT-FSX-FSI] - [ ] Run `#r "nuget:"` resolution off the request path; check without packages first, re-check and - republish diagnostics when resolution completes — [FSX-NUGET] + republish diagnostics when resolution completes — [SCRIPT-FSX-NUGET] ### Lifecycle @@ -152,27 +152,27 @@ remaining gaps are explicit rather than implied. ### Phase 2 — tier 1 references - [ ] Synthesize the virtual project via `Microsoft.Build.Construction.ProjectRootElement` (XML DOM, - never string concatenation) — implements [FILEBASED-REFERENCES-MSBUILD] + never string concatenation) — implements [SCRIPT-FILEBASED-REFERENCES-MSBUILD] - [ ] Cache directory keyed by hash of the root file's full path, mirroring the SDK's - `/dotnet/runfile/-/` scheme — [FILEBASED-REFERENCES-MSBUILD] + `/dotnet/runfile/-/` scheme — [SCRIPT-FILEBASED-REFERENCES-MSBUILD] - [ ] Run `dotnet restore`, then load through the existing `MSBuildWorkspace` path — - [FILEBASED-REFERENCES-MSBUILD] + [SCRIPT-FILEBASED-REFERENCES-MSBUILD] - [ ] Apply SDK defaults (`ImplicitUsings`, `Nullable`, `TargetFramework`, `PublishAot`, `PackAsTool`) - — [FILEBASED-REFERENCES-MSBUILD] -- [ ] Automatic tier 2 → tier 1 upgrade when restore completes — [FILEBASED-REFERENCES-FALLBACK] + — [SCRIPT-FILEBASED-REFERENCES-MSBUILD] +- [ ] Automatic tier 2 → tier 1 upgrade when restore completes — [SCRIPT-FILEBASED-REFERENCES-FALLBACK] ### Testing Coarse, real-artifact tests only — real files on disk, real Roslyn, real FCS, no mocks. -`sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerSingleFileTests.cs`: +`src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerSingleFileTests.cs`: - [x] `.cs` file-based app: BCL symbols bind with **zero** error diagnostics — [SCRIPT-TESTS] - [x] `.cs` file-based app with `#:include`: symbols from the included file resolve — [SCRIPT-TESTS] - [x] Two file-based apps in one directory produce **no** duplicate-entry-point diagnostic — regression test for [SCRIPT-ANTIPATTERN] -- [x] Shebang produces no diagnostic — [FILEBASED-SHEBANG] -- [x] `.csx`: script semantics load and the script `#load` path resolves — [CSX-OPTIONS] +- [x] Shebang produces no diagnostic — [SCRIPT-FILEBASED-SHEBANG] +- [x] `.csx`: script semantics load and the script `#load` path resolves — [SCRIPT-CSX-OPTIONS] - [x] Closure cycle (`a.cs` includes `b.cs` includes `a.cs`) terminates — [SCRIPT-CLOSURE] - [x] A directory with neither project nor root file defers to lazy per-file loading rather than building a synthetic workspace, and each loose file becomes its own ad-hoc project — @@ -181,19 +181,19 @@ Coarse, real-artifact tests only — real files on disk, real Roslyn, real FCS, `csharp.solution_path`, never the deferred path — [SCRIPT-DEGRADE] - [x] `Classify` maps extensions to compilation models — [SCRIPT-DETECT] -`sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpScriptTests.fs`: +`src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpScriptTests.fs`: -- [x] Standalone `.fsx` loads without an `.fsproj` — [FSX-OPTIONS] +- [x] Standalone `.fsx` loads without an `.fsproj` — [SCRIPT-FSX-OPTIONS] - [x] `#load` closure includes the loaded script — [SCRIPT-CLOSURE] -- [x] `.fsx` defines `INTERACTIVE` and `EDITING` but not `COMPILED` — [FSX-SYMBOLS] +- [x] `.fsx` defines `INTERACTIVE` and `EDITING` but not `COMPILED` — [SCRIPT-FSX-SYMBOLS] Still to write: -- [ ] Host-level `tests/lsp_e2e.rs`: opening a `.md` first, then a `.cs`, still initializes the C# +- [ ] Host-level `src/sharplsp/tests/lsp_e2e.rs`: opening a `.md` first, then a `.cs`, still initializes the C# workspace — latch regression test for [SCRIPT-ROUTE-LAZY] - [ ] Host-level: opening a `.cs` does not spawn the F# sidecar, and vice versa — [SCRIPT-ROUTE-LAZY] - [ ] `.cs` file-based app with `#:package`: package symbols bind after restore (phase 2) — [SCRIPT-TESTS] -- [ ] `.fsx` with `#r "nuget:"` resolves after dependency resolution — [FSX-NUGET] +- [ ] `.fsx` with `#r "nuget:"` resolves after dependency resolution — [SCRIPT-FSX-NUGET] ### Test debt inherited from PR #188 @@ -210,5 +210,5 @@ Still to write: - [x] Write [SCRIPTING-FILEBASED-SPEC.md](../specs/SCRIPTING-FILEBASED-SPEC.md) - [x] Write this plan -- [x] Cross-link from `docs/specs/SHARPLSP-SPEC.md` §2.5 +- [x] Cross-link from [SHARPLSP-ARCHITECTURE-PROJECTS](../specs/SHARPLSP-SPEC.md) - [x] Reference spec IDs from implementing code and tests per repo policy diff --git a/docs/plans/SIDECAR-LIFECYCLE-PLAN.md b/docs/plans/SIDECAR-LIFECYCLE-PLAN.md new file mode 100644 index 00000000..8fde35ae --- /dev/null +++ b/docs/plans/SIDECAR-LIFECYCLE-PLAN.md @@ -0,0 +1,621 @@ +# Sidecar Lifecycle Reliability Implementation Plan `[SIDECAR-PLAN]` + +**Status:** Active — implements the normative specification; checklist state records completion +**Normative specification:** [SIDECAR-LIFECYCLE-SPEC.md](../specs/SIDECAR-LIFECYCLE-SPEC.md) +**Primary cluster:** `cluster:sidecar-startup` + `cluster:sidecar-lifecycle` +**Issues:** [#150](https://github.com/Nimblesite/SharpLsp/issues/150), +[#151](https://github.com/Nimblesite/SharpLsp/issues/151), +[#152](https://github.com/Nimblesite/SharpLsp/issues/152), +[#153](https://github.com/Nimblesite/SharpLsp/issues/153), +[#154](https://github.com/Nimblesite/SharpLsp/issues/154), +[#163](https://github.com/Nimblesite/SharpLsp/issues/163), +[#164](https://github.com/Nimblesite/SharpLsp/issues/164), +[#167](https://github.com/Nimblesite/SharpLsp/issues/167), and +[#172](https://github.com/Nimblesite/SharpLsp/issues/172) + +## 1. Outcome `[SIDECAR-PLAN-OUTCOME]` + +Implement one per-language sidecar supervisor and one per-generation connection driver, then route +all startup, health, request, crash, recovery, and shutdown events through them. This is the +highest-value cluster because the nine issues share three mutable resources—child process, IPC +endpoint/transport, and retry state—and the current code lets multiple call paths manage those +resources independently. + +The finished system will: + +- start one isolated C# or F# sidecar generation even under concurrent semantic requests; +- select only a directly spawnable absolute executable and fall through bad non-explicit candidates; +- use a new unpredictable IPC endpoint per spawn and connect to the endpoint actually bound; +- make every pre-READY failure visible and subject to the same bounded backoff as runtime crashes; +- own all frames in one driver, validate response IDs, and dispatch interleaved notifications; +- distinguish a busy request from an idle or genuinely stalled sidecar without a lock race; +- acknowledge shutdown before cancellation and clean the whole process tree on every platform; +- rehydrate workspace, analyzer configuration, and current VFS documents after recovery; and +- prove all of the above with coarse, real-process end-to-end tests on Windows and Unix. + +## 2. Why this cluster is the highest-value fix `[SIDECAR-PLAN-CLUSTER]` + +The cluster affects every Roslyn- and FCS-backed feature. A startup storm, endpoint collision, +protocol desynchronization, or orphaned sidecar can disable completion, hover, navigation, +diagnostics, refactoring, and Solution Explorer together. The root problem is not any language +engine; it is fragmented lifecycle ownership in `src/sharplsp/src/sidecar/manager.rs` and +`SidecarHost.MessageLoopAsync`. + +The supervisor architecture directly resolves or supplies the necessary foundation for every issue: + +| Priority | Issue | Current user impact | Architectural owner after this plan | +|---|---|---|---| +| P0 / critical | #151 | Second editor host collides with or steals the first host's endpoint | Per-spawn endpoint lease + handshake validation | +| P0 / critical | #152 | Each semantic request can launch another doomed process | Single supervisor attempt + unified backoff state | +| P0 / critical | #153 | Broken transport can leave a 100%-CPU zombie | Terminal message-loop outcome + process watcher | +| P0 / critical | #163 | Host failure leaves sidecars, BuildHost, or MSBuild descendants | Parent watcher + Job Object/process group | +| P1 / bug | #150 | Listener failures appear as opaque “before READY” exits | Structured startup outcome + stderr/status/log capture | +| P1 / bug | #154 | Relocated Unix listener advertises the wrong path | Versioned READY record with effective endpoint | +| P1 / bug | #164 | Wrong response can reach a caller; health can kill healthy work | Single transport owner + exact ID/activity tracking | +| P1 / bug | #167 | Windows PATH shim blocks valid fallback artifacts | Typed candidate list + absolute direct spawn | +| P1 / bug | #172 | Graceful shutdown always falls into hard kill | Ack-after-flush shutdown state | + +All nine issues currently list `MelbourneDeveloper` as reporter, so severity and implementation +dependency—not a reporter-priority boost—determine their order inside this cluster. The broader issue +inventory can still prioritize reports from outside the owner/Abdul group when clusters have similar +impact. + +All nine issues are siblings under this implementation effort. #150 is the label's lead issue, but it +is an observability defect rather than a genuine parent of the other bugs; the plan must not fabricate +native parent/child relationships by making the siblings children of #150. + +## 3. Existing plans and present baseline `[SIDECAR-PLAN-BASELINE]` + +No existing document owns the complete cluster. Three plans contain adjacent requirements: + +- [SCRIPTING-FILEBASED-PLAN.md](SCRIPTING-FILEBASED-PLAN.md) requires one eager/lazy initialization + path and health only after `workspace/open`. +- [DIAGNOSTICS-PLAN.md](DIAGNOSTICS-PLAN.md) needs sidecar generation invalidation, retries, and + server-initiated notifications. +- [INFRASTRUCTURE-PLAN.md](INFRASTRUCTURE-PLAN.md) contains only sidecar startup performance (R2R), + not reliability or process ownership. + +This plan becomes the lifecycle source of truth and those plans consume its ready-generation and +notification APIs. + +The current tree already contains useful partial patches. They should be retained as behavior but +folded into the new ownership model: + +| Issue | Present implementation | Remaining gap | +|---|---|---| +| #150 | `StartupFailed`, one fatal stderr line, non-zero entry-point exit, and host exit-status/log hint | Boolean outcome is listener-specific; startup output is not centrally captured/classified; no full host process test | +| #151 | PID + process-local counter makes managers distinct | Token is reused across restarts; endpoint is not random; Unix listener still deletes a pre-existing path | +| #152 | `spawn_retry_after` throttles an immediate retry | State is split across locks; crash and spawn failure paths differ; concurrent waiters and stable-reset semantics are incomplete | +| #153 | I/O exceptions break and generic failures are capped | Run result does not consistently communicate fatal exit; no process/CPU/log-flood acceptance test | +| #154 | `BoundEndpoint` is printed in READY | Handshake is unversioned/unfenced and is not exercised through the real Rust host | +| #163 | No complete implementation | Direct-child kill, parent death before connect, Windows descendants, and Unix group cleanup remain | +| #164 | Request timeout drops a suspect transport | Response ID is unchecked; health performs check/drop/reacquire; notifications can be mistaken for responses | +| #167 | PATH finder accepts `.cmd`, `.bat`, extensionless entries and spawns a bare name | Candidate type/validation/fallback must be redesigned | +| #172 | Host waits briefly for a response | Sidecar cancels the response write token before returning the ack | + +## 4. Target design `[SIDECAR-PLAN-DESIGN]` + +### 4.1 Rust facade and supervisor `[SIDECAR-PLAN-DESIGN-SUPERVISOR]` + +Keep `SidecarManager` as the stable facade so feature call sites do not learn process details. Internally +it owns bounded `tokio::mpsc` senders to a long-lived supervisor task. Define typed models roughly as: + +- `SidecarKind` (`CSharp`, `FSharp`); +- `Generation(u64)`; +- `SupervisorCommand` (`EnsureReady`, `Request`, `UpdateSession`, `Restart`, `Status`, `Shutdown`); +- `SupervisorState` matching [SIDECAR-STATE-MODEL]; +- `LaunchCandidate` with absolute program, arguments, and source; +- `EndpointLease` with requested/effective endpoint and ownership; +- `FailureKind` matching [SIDECAR-RECOVERY-FAILURES]; +- `SessionSnapshot` for target/configuration/open documents; and +- `SidecarUnavailable` with language, category, and retry time. + +The supervisor uses `tokio::select!` across commands, generation-scoped child exit, connection events, +startup timeout, stable-ready timer, and shutdown. It performs no blocking OS wait on the Tokio worker. +It uses `Result`/`Option`, structured errors, and generation checks; there are no production +`unwrap`/`expect`/`panic` paths. + +### 4.2 Connection driver `[SIDECAR-PLAN-DESIGN-CONNECTION]` + +Move `FramedTransport` out of the manager mutex and into a task that is its sole owner. It maintains +one `ActiveRequest { id, method, written_at, deadline, completion }`, a bounded pending queue, and an +idle-health deadline. It reads continuously, dispatches `id=null/method!=null` notifications, and +requires an exact ID for the active response. + +This deliberately keeps semantic dispatch sequential. It supplies correct correlation and +notifications now without introducing concurrent Roslyn/FCS mutation ordering. If multiplexed +handlers are added later, the driver can replace `ActiveRequest` with a pending map without changing +the supervisor contract. + +### 4.3 Managed host lifecycle `[SIDECAR-PLAN-DESIGN-DOTNET]` + +Replace the `StartupFailed` boolean with a typed `SidecarRunResult`/exit outcome that distinguishes +normal peer close, acknowledged shutdown, startup fatal, transport fatal, and parent death. Parse a +shared `SidecarStartupOptions` in both C# and F# entry points. Initialize `ParentProcessWatchdog` and +`ProcessContainment` before listener creation and before any engine can spawn descendants. + +The shutdown request records “shutdown requested” but does not cancel. `ProcessOneMessageAsync` writes +and flushes the response, then cancels the loop. Persistent stream failures terminate the run outcome +instead of re-entering the broken read. + +### 4.4 Session recovery `[SIDECAR-PLAN-DESIGN-RECOVERY]` + +Extract the duplicated eager startup, lazy project-less startup, second-language startup, and +`sharplsp/loadSolution` code in `src/sharplsp/src/main.rs` into one session-update path. The supervisor stores the +desired target and analyzer configuration; a VFS snapshot provider supplies current open documents. +On every generation it performs the ordered bootstrap in [SIDECAR-RECOVERY-REHYDRATE] before making +the request queue available. + +The connection driver exposes sidecar notifications to the LSP orchestration layer. This is the +required transport foundation for `diagnostics/refresh` and `workspace/projectInitializationComplete` +in the diagnostics plan. + +## 5. File-level change map `[SIDECAR-PLAN-FILES]` + +| Path | Planned responsibility/change | +|---|---| +| `src/sharplsp/src/sidecar/manager.rs` | Thin facade, public request/session/status/shutdown API; remove child/transport/backoff lock ownership | +| `src/sharplsp/src/sidecar/supervisor.rs` | New actor, state transitions, generation fencing, launch/bootstrap/backoff/shutdown orchestration | +| `src/sharplsp/src/sidecar/connection.rs` | New sole transport owner, request queue, ID validation, notifications, activity/deadlines | +| `src/sharplsp/src/sidecar/launch.rs` | New typed resolution candidates, spawn validation, versioned READY parsing, capped output collection, endpoint leases | +| `src/sharplsp/src/sidecar/process_tree.rs` | New safe platform abstraction for direct child/process group and hard termination; no Rust unsafe code | +| `src/sharplsp/src/sidecar/protocol.rs` | Envelope shape validators, READY DTO, notification classification, typed protocol faults | +| `src/sharplsp/src/sidecar/transport.rs` | Keep bounded framing; distinguish clean EOF from truncated frame; split/ownership support if required by driver | +| `src/sharplsp/src/sidecar/mod.rs` | Export only facade/public status types; keep internal modules private | +| `src/sharplsp/src/main.rs` | Replace eager/lazy health tasks with session updates; provide target/config/VFS replay and notification sink | +| `src/sharplsp/src/diagnostics.rs` / pull diagnostics path | Invalidate on generation change and consume sidecar notifications without owning lifecycle | +| `src/sidecars/SharpLsp.Sidecar.Common/SidecarStartupOptions.cs` | Shared strict argument parser for endpoint, parent PID, generation, protocol | +| `src/sidecars/SharpLsp.Sidecar.Common/SidecarRunResult.cs` | Shared typed terminal outcome and failure category | +| `src/sidecars/SharpLsp.Sidecar.Common/ParentProcessWatchdog.cs` | Pre-READY hard-parent-death detection | +| `src/sidecars/SharpLsp.Sidecar.Common/ProcessContainment.cs` | Windows safe Job Object lifetime and Unix group termination support | +| `src/sidecars/SharpLsp.Sidecar.Common/SidecarHost.cs` | Versioned READY, terminal loop faults, ack-before-cancel, typed run outcome | +| `src/sidecars/SharpLsp.Sidecar.Common/Ipc/IpcConnection.cs` | No blind socket deletion; owned path cleanup; effective endpoint; current-user access | +| C# and F# `Program` entry points | Use shared options/outcome; emit correct non-zero status once; remove duplicated lifecycle decisions | +| `src/sharplsp/tests/fixtures/SidecarLifecycleFixture/` | Real separately spawned shared-host fixture for protocol faults, delayed handlers, and child-process containment | +| `src/sharplsp/tests/e2e_modules/sidecar_lifecycle.rs` | Full host/process/IPC recovery scenarios and issue traceability | +| `src/sidecars/SharpLsp.Sidecar.Common.Tests/SidecarHostEndToEndTests.cs` | Keep only coarse real-IPC host lifecycle coverage; add ack and process-exit assertions | +| `.github/workflows/ci-rust.yml` / `ci-dotnet.yml` | Run platform-relevant real-process lifecycle cases | +| `.github/workflows/ci-vsix-windows.yml` | Gate the lifecycle chunk on concurrent hosts, restart, and Windows tree cleanup | + +File names may be adjusted to match an equivalent existing abstraction discovered during +implementation, but responsibilities MUST remain single-owner and the final tree MUST not retain a +second restart/health loop. + +## 6. Implementation sequence `[SIDECAR-PLAN-SEQUENCE]` + +### 6.1 Phase 0 — characterize the contract `[SIDECAR-PLAN-PHASE-0]` + +First add real-process failing scenarios for the nine issues and record current attempt counts, PIDs, +exit statuses, endpoint paths, and shutdown behavior. Build the lifecycle fixture on production +`SidecarHost` and OS IPC; it is an executable artifact, not an in-memory mock. Add deterministic +commands to induce wrong response IDs, delayed responses, malformed frames, and a real child helper. + +Audit available safe process/job primitives before adding dependencies. Any reused crate must work +with `unsafe_code = "deny"`; if Windows native calls are needed, keep safe handles and P/Invoke in the +shared managed process-containment implementation rather than adding Rust unsafe blocks. + +### 6.2 Phase 1 — supervisor skeleton and generation state `[SIDECAR-PLAN-PHASE-1]` + +Introduce the actor and facade behind the current `SidecarManager` API. Move coalesced ensure-ready, +child exit monitoring, one retry timestamp, failure classification, backoff growth/reset, and +idempotent shutdown into the actor before changing the wire protocol. Preserve observable request +behavior while deleting the independent health-loop and spawn-failure state only after call sites use +the actor. + +This phase is the root fix for #152 and prevents later endpoint/connection work from creating another +set of shared locks. + +### 6.3 Phase 2 — resolution, endpoint, and startup handshake `[SIDECAR-PLAN-PHASE-2]` + +Replace `sidecar_launch`/`find_on_path` with the candidate model. Resolve absolute apphosts or explicit +`dotnet ` launches, remove `dotnet run`, and iterate only allowed fallback failures. Allocate a +new CSPRNG endpoint lease per generation. Stop deleting pre-existing Unix sockets and clean only +owned paths. + +Add shared sidecar option parsing and emit the versioned READY JSON with generation, PID, and effective +endpoint. The supervisor races READY, child exit, EOF, and timeout; captures bounded output; validates +the record; performs transient connect retries; and reports one classified error/backoff event. + +This phase completes #150, #151, #154, and #167 and folds their current partial patches into the actor. + +### 6.4 Phase 3 — connection driver, correlation, and health `[SIDECAR-PLAN-PHASE-3]` + +Move the transport into its driver task. Add strict envelope classification, exact response-ID +validation, notification dispatch, bounded command capacity, pre/post-write cancellation behavior, +and transport poisoning on timeout/correlation/framing faults. Replace the external health monitor +with the driver's idle/activity timer. Keep existing 600s/120s request budgets and the 2s ping budget. + +This phase completes #164 and supplies notification support needed by diagnostics recovery. + +### 6.5 Phase 4 — managed loop and graceful shutdown `[SIDECAR-PLAN-PHASE-4]` + +Return typed run outcomes from `SidecarHost`. Treat permanent stream errors as terminal, cap recoverable +decode failures, and make both entry points map terminal outcomes consistently. Change shutdown to +flush the correlated response before cancelling and let the supervisor wait for ack/clean exit before +hard termination. + +This phase completes #153 and #172. + +### 6.6 Phase 5 — process-tree containment `[SIDECAR-PLAN-PHASE-5]` + +Make production and development launches direct. Start the parent watcher and containment before +READY. On Windows, create/retain a kill-on-close Job Object and assign the sidecar before engine child +processes can start. On Unix, create a dedicated process group and target only that generation's group +for planned hard termination. Verify host death while the sidecar is still waiting for a connection, +while idle, and while a real child helper exists. + +This phase completes #163. It must land for C# and F# together. + +### 6.7 Phase 6 — bootstrap and recovery integration `[SIDECAR-PLAN-PHASE-6]` + +Unify `start_sidecar`, lazy initialization, second-language initialization, and load-solution updates +around `SessionSnapshot`. On a new generation, open the target, configure analyzers, replay latest VFS +documents, attach notification consumers, then mark ready. Emit generation-change invalidation so +diagnostics and semantic caches retry/refresh safely. + +This phase turns process restart into actual feature recovery instead of merely reconnecting an empty +sidecar. + +### 6.8 Phase 7 — cross-platform gates and rollout `[SIDECAR-PLAN-PHASE-7]` + +Run focused lifecycle e2e tests during development, then the complete Rust, .NET, and Windows VSIX +gates. Capture structured logs for one forced failure/recovery cycle and prove there is one spawn per +backoff window, no stale endpoint, no child process, and no hard kill on normal shutdown. Close each +issue only with its specific platform evidence; do not close the cluster solely because the refactor +compiled. + +## 7. Test and verification strategy `[SIDECAR-PLAN-TESTING]` + +### 7.1 Test artifact policy `[SIDECAR-PLAN-TESTING-ARTIFACTS]` + +The test fixture is a real executable using the same `SidecarHost`, `IpcListener`, framing, argument +parser, parent watcher, containment, and shutdown code as production. It may expose handlers whose +normal behavior is “delay”, “respond with a selected protocol fault”, or “spawn a child helper”; the +production supervisor contains no test-only branch. Engine recovery tests use the actual published +C# and F# sidecars and real workspaces. + +Do not add in-memory transport tests as acceptance evidence. Existing narrow tests may remain, but +issue closure requires the real-process scenarios in [SIDECAR-TESTING]. + +### 7.2 Platform matrix `[SIDECAR-PLAN-TESTING-MATRIX]` + +| Scenario | Windows | Linux | macOS | +|---|---:|---:|---:| +| Listener fatal/status/log path | Required | Required | Required | +| Two hosts, one workspace | Required named pipe | Required Unix socket | Required Unix socket | +| Spawn backoff and recovery | Required | Required | Required | +| Long/effective Unix endpoint | N/A | Required | Required | +| PATH shim fallback | Required | N/A | N/A | +| Wrong ID, notification interleave, health activity | Required | Required | Required | +| Ack-before-exit shutdown | Required | Required | Required | +| Job Object descendants | Required | N/A | N/A | +| Process-group/parent-death cleanup | N/A | Required | Required | +| C# and F# VFS rehydration | Required | Required | At least CI smoke if runner budget is constrained | +| Full packaged editor lifecycle | Required VSIX | Existing VSIX gate | Existing VSIX gate | + +Use event-driven readiness, process exit, log record, and semantic-response assertions. Polling may be +bounded where an OS API has no awaitable interface, but a fixed sleep is never the only success +condition. + +### 7.3 Validation commands `[SIDECAR-PLAN-TESTING-COMMANDS]` + +During implementation, use the narrowest relevant real-process target first, followed by: + +```text +make _test-dotnet +make _test-rust +make _test-vsix-win # Windows lifecycle/editor surface +make lint +``` + +The final verification also runs the release-built sidecars' `--version` and startup contracts and +checks that both C# and F# artifacts use the shared lifecycle implementation. + +## 8. Rollout and failure handling `[SIDECAR-PLAN-ROLLOUT]` + +Ship the host and sidecars together behind the protocol version in READY. During one release the host +may accept the legacy handshake only after exact binary version verification; remove the compatibility +parser after all supported bundles emit protocol 1. No user-facing feature flag should select between +old and new supervisors because dual lifecycle implementations would make failures untriageable. + +Before release, exercise one forced failure of every category and inspect the editor output channel: +one concise event and log link are allowed, while repeated request chatter, raw stacks, and ANSI are +not. Measure startup and request latency against [SIDECAR-PERFORMANCE]. If a regression occurs, fix the +single supervisor path; do not restore the independent health or spawn loops. + +## 9. Risks and mitigations `[SIDECAR-PLAN-RISKS]` + +| Risk | Mitigation | +|---|---| +| Actor refactor changes many call sites at once | Keep the facade signature, land state/driver behind it, then migrate session updates | +| Bootstrap deadlocks by calling the public facade from its own actor | Give the supervisor a private internal connection request path; never re-enter its public command channel | +| Slow `workspace/open` is mistaken for death | Driver owns the 600s deadline; health is idle-only | +| Old response/exit mutates a replacement | Fence every event and completion with generation | +| Random endpoint cleanup deletes another host's resource | Lease ownership + no pre-bind unlink + private runtime directory | +| Job Object conflicts with an enclosing job | Establish before READY, use safe handles, fail visibly, and cover the packaged VSIX environment in CI | +| Development `dotnet run` removal hurts local workflow | Build sidecars through existing make target and launch the resulting apphost/assembly directly | +| Recovery replays stale document text | Snapshot latest version/text from authoritative VFS immediately before bootstrap; generation-gate completion | +| Shutdown races parent watcher or health timer | `Stopping` disables health/admission; normal ack path is the sole clean-exit owner | +| New driver is mistaken for permission to run handlers concurrently | Keep one active request and document ordering; concurrency is a separate future design | + +## 10. Definition of done `[SIDECAR-PLAN-DONE]` + +The effort is complete only when all nine issue scenarios pass on their required platforms, the +published C# and F# sidecars share the same lifecycle code, restart restores usable semantic state, +normal shutdown is acknowledged without hard kill, hard host death leaves no sidecar or descendant, +and no old spawn/health/retry owner remains. Documentation IDs, issue links, implementation comments, +and test names must provide a direct trace from each issue to its requirement and evidence. + +## 11. Detailed implementation checklist `[SIDECAR-PLAN-CHECKLIST]` + +This is the execution checklist. Keep it at the bottom of this document; update boxes only when the +item and its required evidence are complete. + +### 11.1 Contract and baseline `[SIDECAR-PLAN-CHECKLIST-CONTRACT]` + +- [x] Confirm all nine issues are still open and capture their current labels, descriptions, and + platform scope in the implementation PR/working notes. +- [x] Reconcile [SIDECAR-LIFECYCLE-SPEC.md](../specs/SIDECAR-LIFECYCLE-SPEC.md) with any issue-body + updates made after 2026-08-03; change the spec before code when behavior differs. +- [x] Verify every heading in the lifecycle spec has one unique hierarchical uppercase ID. +- [x] Confirm [SHARPLSP-ARCHITECTURE-SIDECARS-TIMEOUT], [SCRIPT-ROUTE-HEALTH], [DIST-CLEAN-OUTPUT], and + [DIST-CI-WIN-TRANSPORT] do not contradict the detailed lifecycle contract. +- [x] Record the current behavior for #150–#154 so the useful partial fixes are preserved during the + refactor rather than accidentally reverted. +- [ ] Record current spawn count, retry timing, child PID, endpoint, exit status, and shutdown path for + one healthy C# and one healthy F# session. +- [ ] Record the same evidence for a forced pre-READY failure, runtime transport failure, and host + hard death on Windows and one Unix platform. +- [ ] Audit existing crates and shared libraries for a maintained safe process/job abstraction that + satisfies `unsafe_code = "deny"`; document the reuse or rejection decision. +- [x] Confirm the implementation creates no new native parent/child GitHub relationship among + #150–#172 unless a genuine umbrella tracking issue is created; keep the defects as siblings. + +### 11.2 Real-process test harness first `[SIDECAR-PLAN-CHECKLIST-HARNESS]` + +- [ ] Add `src/sharplsp/tests/fixtures/SidecarLifecycleFixture` as a separately built executable referencing the + production shared sidecar host and IPC assemblies. +- [ ] Give the fixture a normal echo/ping handler for healthy request/response verification. +- [ ] Give the fixture a bounded delayed handler to exercise busy-within-budget and deadline-expired + behavior without mocked clocks or streams. +- [ ] Give the fixture a protocol-fault mode that can emit a wrong response ID and a notification + before a valid response over the real connection. +- [ ] Give the fixture a malformed-frame/connection-close mode that exercises terminal .NET loop + errors in a separate process. +- [ ] Give the fixture a handler that starts a real long-lived child helper and reports its PID for + containment assertions. +- [ ] Make all fixture modes available through normal command arguments or handlers; add no + `cfg(test)`/test-only behavior to the production supervisor. +- [ ] Add bounded helpers that await READY, process exit, retry timestamp, child disappearance, and + endpoint rebinding through observable events rather than fixed sleeps. +- [ ] Add issue numbers and spec IDs to scenario names/comments so test failure output is traceable. + +### 11.3 Supervisor state and typed errors `[SIDECAR-PLAN-CHECKLIST-SUPERVISOR]` + +- [ ] Define `SidecarKind`, `Generation`, `FailureKind`, `SidecarUnavailable`, and `SessionSnapshot` + with structured fields and no string parsing for control flow. +- [ ] Define every state in [SIDECAR-STATE-MODEL] and make impossible resource combinations + unrepresentable where practical. +- [ ] Create a bounded supervisor command channel and per-command completion channels. +- [ ] Keep `SidecarManager` as a cloneable facade; remove direct public access to child, transport, + endpoint, backoff, and health-loop internals. +- [ ] Implement coalesced `EnsureReady` so concurrent callers wait on one generation. +- [ ] Allocate a monotonic generation before every spawn attempt and attach it to every async event. +- [ ] Ignore stale child-exit, driver, timeout, bootstrap, and shutdown events after logging their + generation mismatch at debug level. +- [ ] Race supervisor commands, child exit, driver events, startup deadlines, stable-ready reset, and + shutdown using non-blocking Tokio primitives. +- [ ] Implement one failure transition function that records category/context, cleans the generation, + advances backoff, and completes affected callers. +- [ ] Implement the 1/2/4/8/16/30-second base sequence with ±20% jitter and monotonic retry timestamp. +- [ ] Return `SidecarUnavailable` immediately during backoff; prove requests do not sleep or spawn. +- [ ] Reset backoff only after 60 continuous ready seconds or a new full LSP session. +- [ ] Make restart and shutdown idempotent and ensure C# state cannot mutate F# state. +- [ ] Delete `spawn_retry_after`, the separate crash sleep, and all other superseded retry owners only + after the actor tests pass. +- [ ] Remove production `unwrap`, `expect`, `panic`, and unstructured expected-error paths introduced + or touched by the refactor. + +### 11.4 Resolution and direct launch `[SIDECAR-PLAN-CHECKLIST-RESOLUTION]` + +- [ ] Define `LaunchCandidate` with source, absolute program, arguments, explicit/non-explicit policy, + and redacted diagnostic rendering. +- [ ] Re-resolve candidates for each generation rather than caching one command at manager creation. +- [ ] Treat an explicit sidecar environment override as authoritative and surface a clear hard error + when it is missing, the wrong file type, or unspawnable. +- [ ] Resolve Shipwright/bundled and PATH candidates to the exact absolute path passed to spawn. +- [ ] On Windows accept only direct `.exe` candidates or explicit `dotnet.exe ` pairs. +- [ ] On Windows reject `.cmd`, `.bat`, PowerShell, and extensionless shims without invoking a shell. +- [ ] On Unix require a regular executable file for direct candidates. +- [ ] Continue through non-explicit candidates only for absence/invalid-format/mechanical spawn + failures; stop and classify application/listener/handshake failures. +- [ ] Remove the `dotnet run` fallback and launch prebuilt development output directly. +- [ ] Ensure the development/build instructions produce the required apphost or DLL before tests. +- [ ] Add the real Windows PATH test with a bad shim before a valid candidate and assert the valid + absolute executable starts. + +### 11.5 Endpoint leases and READY `[SIDECAR-PLAN-CHECKLIST-STARTUP]` + +- [ ] Generate at least 64 unpredictable bits from the OS CSPRNG for every spawn attempt. +- [ ] Include language, host PID, generation, and nonce in a length-bounded platform endpoint. +- [ ] Create/use a validated owner-only Unix runtime directory and keep socket mode `0600`. +- [ ] Keep `PipeOptions.CurrentUserOnly` and a single named-pipe server instance on Windows. +- [ ] Remove blind pre-bind `File.Delete` of Unix socket paths. +- [ ] Track listener ownership and delete only the Unix path actually created by that listener. +- [ ] Allocate a new endpoint after every failed generation; never reuse an endpoint because a + `SidecarManager` instance survived. +- [ ] Add shared parsing for `--endpoint`, `--parent-pid`, `--generation`, and `--protocol` in C# and + F# entry points. +- [ ] Initialize logging, containment, parent watcher, and listener before READY. +- [ ] Emit and flush one versioned READY JSON record containing protocol, generation, actual PID, and + effective bound endpoint. +- [ ] Validate READY schema, protocol, generation, PID, platform endpoint shape, and lease attribution + in the host. +- [ ] Race READY against exit, stdout EOF, and the 30-second deadline; terminate and reap every losing + child. +- [ ] Retry only transient post-READY connect errors for at most 2 seconds with bounded delay. +- [ ] Capture/drain stdout and stderr concurrently, cap retained tails at 16KiB each, and prevent pipe + backpressure from blocking startup. +- [ ] Preserve one sanitized pre-READY `FATAL` stderr line, structured file details, non-zero exit, + host exit status, and log-directory hint for #150. +- [ ] Exercise listener failure end to end with both published sidecar entry points. +- [ ] Start two hosts on one real workspace on Windows and Unix; assert distinct endpoint/PID and + successful semantic requests from both. +- [ ] Exercise an overlong Unix requested path through READY and connect to the advertised effective + path without host-side shortening. + +### 11.6 Connection driver and protocol `[SIDECAR-PLAN-CHECKLIST-CONNECTION]` + +- [ ] Move `FramedTransport` into a connection driver task as its sole reader and writer. +- [ ] Add a bounded request queue and typed saturation result. +- [ ] Allocate non-zero request IDs monotonically within each generation. +- [ ] Validate request, response, and notification envelope shapes before dispatch. +- [ ] Require every response—including ping/bootstrap/shutdown—to match the active request ID. +- [ ] On missing, duplicate, unknown, or wrong ID, fail the request, stop writes, poison transport, and + report one protocol failure to the supervisor. +- [ ] Dispatch `id=null/method!=null` sidecar notifications while a request is active and continue + waiting for the correct response. +- [ ] Preserve one host-to-sidecar active request at a time and document arrival-order semantics. +- [ ] Start response deadlines when the request frame is written, not while queued. +- [ ] Cancel queued/unwritten requests without poisoning the connection. +- [ ] After post-write cancellation, send cancellation when supported and drain/discard the matching + response before admitting the next request. +- [ ] Poison and restart when a written request cannot be drained within its 120s/600s budget. +- [ ] Distinguish clean EOF between frames from truncated length/payload EOF and classify the latter as + a protocol failure. +- [ ] Keep the 64MiB frame check before allocation in Rust and .NET. +- [ ] Add a real wrong-ID scenario proving the stale response reaches neither current nor next caller. +- [ ] Add a real notification-before-response scenario proving both are delivered correctly. + +### 11.7 Activity-aware health `[SIDECAR-PLAN-CHECKLIST-HEALTH]` + +- [ ] Put the idle timer and ping request inside the connection driver; create no second transport + caller or monitor task. +- [ ] Suppress ping outside `Ready` and while an ordinary request is active within its budget. +- [ ] Send a ping only after 5 idle seconds and require its exact response within 2 seconds. +- [ ] Treat request deadline, ping deadline, EOF, process exit, and protocol fault as distinct failure + categories routed through the supervisor. +- [ ] Remove the `try_lock`/drop/reacquire health sequence and `start_health_monitor` call sites. +- [ ] Remove eager/lazy manual monitor ordering once the supervisor owns health internally. +- [ ] Prove a delayed request inside its budget survives multiple nominal ping intervals. +- [ ] Prove an idle unresponsive sidecar is terminated/backed off and later recovers. +- [ ] Prove a request beyond its budget poisons the transport and no late response is reused. + +### 11.8 Managed message loop and shutdown `[SIDECAR-PLAN-CHECKLIST-DOTNET]` + +- [ ] Replace `StartupFailed` with a typed run result covering normal close, acknowledged shutdown, + startup fatal, transport fatal, and parent death. +- [ ] Make C# and F# entry points map the same run result to the same zero/non-zero semantics. +- [ ] Treat `IOException`, `ObjectDisposedException`, truncated frame, and response write failure as + terminal message-loop outcomes. +- [ ] Bound recoverable decode/dispatch failures and reset the counter only after a complete valid + message/response cycle. +- [ ] Emit one structured terminal error and exit; do not retry the same permanently broken stream. +- [ ] Change the shutdown handler to create the `ok` payload without cancelling `_shutdownCts`. +- [ ] Write and flush the correlated shutdown response with a bounded write token. +- [ ] Cancel dispatch and dispose listener/transport only after the response flush succeeds. +- [ ] In the supervisor, stop admission, cancel unwritten commands, send shutdown, and wait 1 second + for the exact acknowledgement. +- [ ] After acknowledgement, wait within the remaining 5-second graceful budget for zero process exit. +- [ ] On ack/exit timeout, hard-terminate only the current generation's contained process tree and reap + the direct child. +- [ ] Add a real-process test that observes the matching ack before process exit and asserts the hard + kill path was not used. +- [ ] Add a persistent broken-stream/decode-storm test that exits within a bound and produces bounded + logs rather than a hot loop. + +### 11.9 Parent death and process-tree cleanup `[SIDECAR-PLAN-CHECKLIST-PROCESS]` + +- [ ] Guarantee every production/development launch is direct so Rust child PID equals READY PID. +- [ ] Start parent-death detection before listener bind and fail pre-READY when the parent cannot be + validated. +- [ ] On Windows open a waitable handle to the exact parent process object and detect death within one + second even while waiting in accept. +- [ ] On Unix verify direct parent identity and detect reparenting/disappearance within one second. +- [ ] Implement Windows Job Object creation with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` through safe + managed handles and assign the sidecar before engine descendants can start. +- [ ] Retain the Job Object handle for the full sidecar lifetime and make setup failure visible before + READY. +- [ ] Launch Unix sidecars as leaders of dedicated process groups without placing the Rust host in the + group. +- [ ] On planned Unix hard termination, signal only the current generation's group and escalate within + a bound. +- [ ] On hard parent death, terminate descendants, dispose the listener, and exit without an IPC + shutdown request. +- [ ] Never enumerate/kill processes by executable name and never target VS Code or an unverified PID. +- [ ] Add a Windows test: fixture spawns child, host dies before IPC connect, sidecar and child vanish, + and the pipe can be rebound. +- [ ] Add a Windows test: wedged sidecar is hard-terminated and its child/BuildHost does not survive. +- [ ] Add Linux/macOS equivalents asserting no process-group member or owned socket survives. +- [ ] Assert every child is awaited/reaped and every containment/listener handle is disposed on all + success and failure paths. + +### 11.10 Session bootstrap and feature recovery `[SIDECAR-PLAN-CHECKLIST-RECOVERY]` + +- [ ] Define the desired per-language session snapshot: workspace/solution/root-file target, analyzer + configuration, notification subscriptions, and current open-document snapshots. +- [ ] Source open-document URI, language, version, and full text from the authoritative VFS. +- [ ] Replace eager `start_sidecar` orchestration with one supervisor session-update/bootstrap path. +- [ ] Replace lazy project-less startup with that same path and start only the owning language. +- [ ] Start the second language on its first supported document without duplicating bootstrap/health + tasks. +- [ ] Route `sharplsp/loadSolution` through a desired-target update rather than an independent + `workspace/open` task. +- [ ] Bootstrap each generation in order: `workspace/open`, analyzer/configuration, stable-URI VFS + replay, then notification activation. +- [ ] Keep the generation out of `Ready` until every required bootstrap step succeeds. +- [ ] Route bootstrap failure through normal cleanup/backoff; do not expose a half-initialized sidecar. +- [ ] Emit generation-change invalidation to semantic/diagnostic caches before new results publish. +- [ ] Connect `diagnostics/refresh` and project-initialization notifications through the driver without + giving diagnostics ownership of the transport. +- [ ] Rate-limit user-facing unavailable/recovery notices to one per failure episode. +- [ ] Keep Rust syntax features usable during sidecar backoff and return typed errors for semantic + requests. +- [ ] Add a C# recovery e2e: edit an open document, kill the sidecar, await recovery, and assert hover + or diagnostics reflects the latest VFS text. +- [ ] Add the equivalent F# recovery e2e with a real FCS semantic request. +- [ ] Add a mixed-language recovery e2e proving one language restart does not reset or kill the other. + +### 11.11 Documentation, CI, and issue closure `[SIDECAR-PLAN-CHECKLIST-RELEASE]` + +- [x] Update `SHARPLSP-SPEC.md` lifecycle/IPC summaries to link the detailed spec and accurately state + one active request plus interleaved notifications. +- [x] Update [DIST-CLEAN-OUTPUT] to sanction one fatal pre-READY diagnostic and no other sidecar stderr + chatter. +- [x] Update [DIST-CI-WIN-TRANSPORT] to require unpredictable per-spawn endpoints instead of calling + endpoint names deterministic. +- [x] Update [SCRIPT-ROUTE-HEALTH] to reference activity-aware supervisor health and prohibit a + duplicate caller-started monitor. +- [ ] Add implementation comments citing the most specific spec IDs at state, timeout, handshake, + correlation, containment, and shutdown boundaries. +- [ ] Run the focused shared-host real-process lifecycle suite on Windows, Linux, and macOS. +- [ ] Run the Rust e2e lifecycle module with published C# and F# sidecars. +- [ ] Run `make _test-dotnet` and resolve every failure without weakening assertions. +- [ ] Run `make _test-rust` and resolve every failure without filtering lifecycle cases. +- [ ] Run the complete Windows VSIX lifecycle chunk via `make _test-vsix-win`. +- [ ] Run `make lint`; keep Rust `unsafe_code = "deny"`, missing-doc, and structured-error rules green. +- [ ] Inspect one healthy and one forced-failure editor output: no ANSI, raw stack flood, payload text, + or repeated per-request toast. +- [ ] Verify startup/request/shutdown/backoff timings against [SIDECAR-PERFORMANCE]. +- [ ] Verify concurrent hosts have distinct endpoints and that restart uses a new endpoint. +- [ ] Verify normal shutdown received an ack and left no sidecar/descendant/socket/pipe. +- [ ] Verify hard host death left no sidecar/descendant/socket/pipe on every supported platform. +- [ ] Verify both C# and F# recovered latest open-document state after a forced generation change. +- [ ] Search the tree for old independent spawn, health, crash sleep, transport mutex, and shutdown + owners; remove or document every remaining occurrence. +- [ ] Attach platform-specific passing evidence to #150 and close only when fatal/status/log behavior + is proven. +- [ ] Attach concurrent-host Windows+Unix evidence to #151 and close only when no collision/steal is + possible. +- [ ] Attach measured attempt/backoff evidence to #152 and close only when requests cannot create a + respawn storm. +- [ ] Attach bounded-exit/log evidence to #153 and close only when the hot loop is impossible. +- [ ] Attach overlong-Unix-path evidence to #154 and close only when the effective endpoint connects. +- [ ] Attach Windows parent-death and descendant cleanup evidence to #163 and close only when no + process-tree member survives. +- [ ] Attach wrong-ID and activity-aware health evidence to #164 and close only when both halves pass. +- [ ] Attach real Windows PATH fallback evidence to #167 and close only when shims cannot block the + valid candidate. +- [ ] Attach ack-before-exit evidence to #172 and close only when graceful shutdown avoids hard kill. +- [ ] Re-export `docs/bugs/open-issues.csv` after issue states/relationships change so the inventory + remains synchronized with GitHub. diff --git a/docs/plans/STRUCTURED-FILE-DOM-PLAN.md b/docs/plans/STRUCTURED-FILE-DOM-PLAN.md index 2c980424..dd0bcbf5 100644 --- a/docs/plans/STRUCTURED-FILE-DOM-PLAN.md +++ b/docs/plans/STRUCTURED-FILE-DOM-PLAN.md @@ -6,11 +6,11 @@ ## Status -**✅ Done (issue #4 — NuGet package editing).** The line-oriented `src/nuget/xml_edit.rs` is deleted. `PackageReference` / `PackageVersion` add / update / remove now go through the C# sidecar's `Microsoft.Build.Construction.ProjectRootElement` (`preserveFormatting: true`), covering install, uninstall, and consolidate for `.csproj` / `.fsproj` / `.props`. Verified full-stack in `tests/nuget_e2e.rs` (multi-line children, wrapped attributes, conditional `ItemGroup`, comments, CPM `Directory.Packages.props`, multi-ItemGroup). +**✅ Done (issue #4 — NuGet package editing).** The line-oriented `src/sharplsp/src/nuget/xml_edit.rs` is deleted. `PackageReference` / `PackageVersion` add / update / remove now go through the C# sidecar's `Microsoft.Build.Construction.ProjectRootElement` (`preserveFormatting: true`), covering install, uninstall, and consolidate for `.csproj` / `.fsproj` / `.props`. Verified full-stack in `src/sharplsp/tests/nuget_e2e.rs` (multi-line children, wrapped attributes, conditional `ItemGroup`, comments, CPM `Directory.Packages.props`, multi-ItemGroup). Deviations from the original design below (all functionally equivalent): -- Editor lives in `sidecars/SharpLsp.Sidecar.CSharp/PackageEditor.cs` (+ handler in `CSharpSidecar.Packages.cs`), not `Workspace/ProjectEditor.cs`. -- IPC types live in the C# sidecar `Messages.cs`; the Rust wrapper is `src/nuget/edit.rs` (not `src/sidecar/project_editor.rs`). +- Editor lives in `src/sidecars/SharpLsp.Sidecar.CSharp/PackageEditor.cs` (+ handler in `CSharpSidecar.Packages.cs`), not `Workspace/ProjectEditor.cs`. +- IPC types live in the C# sidecar `Messages.cs`; the Rust wrapper is `src/sharplsp/src/nuget/edit.rs` (not `src/sharplsp/src/sidecar/project_editor.rs`). - `updatePackageVersion` is folded into `addPackage` (upsert), so there are two methods: `project/addPackage`, `project/removePackage`. - `.fsproj` edits route to the **C# sidecar** rather than a mirrored F# handler: `MSBuildLocator` (MSBL001) forbids a shared `Microsoft.Build` reference in the F#/Common projects, and `ProjectRootElement` is language-agnostic (the issue itself notes "it's not F#-specific"). @@ -30,32 +30,32 @@ No `std::fs::write` of concatenated strings. No `.replace()` / `.splice()` / `.l ### CRITICAL (writes corrupt output for real-world inputs) -#### V1 — `src/nuget/xml_edit.rs` (entire file) +#### V1 — `src/sharplsp/src/nuget/xml_edit.rs` (entire file) - **What:** Line-oriented "fast-path" editor for `PackageReference`/`PackageVersion` in csproj/fsproj/props. - **Why broken:** The module's own docstring defends this as a deliberate choice to preserve whitespace. It can't handle multi-line `` with children (``, ``, conditions), CDATA, comments mid-attribute list, namespaces, or `/` blocks. **This is the file that caused issue #4.** - **Callers:** - - `src/nuget/handlers.rs:195` — `handle_install` - - `src/nuget/handlers.rs:204` — CPM `Directory.Packages.props` write - - `src/nuget/handlers.rs:238` — `handle_remove` - - `src/nuget/handlers.rs:253` — `pick_install_element` -- **Tests that pin the existing contract:** `tests/nuget_e2e.rs` + `src/nuget/xml_edit.rs` `mod tests`. + - `src/sharplsp/src/nuget/handlers.rs:195` — `handle_install` + - `src/sharplsp/src/nuget/handlers.rs:204` — CPM `Directory.Packages.props` write + - `src/sharplsp/src/nuget/handlers.rs:238` — `handle_remove` + - `src/sharplsp/src/nuget/handlers.rs:253` — `pick_install_element` +- **Tests that pin the existing contract:** `src/sharplsp/tests/nuget_e2e.rs` + `src/sharplsp/src/nuget/xml_edit.rs` `mod tests`. -#### V2 — `editors/vscode/src/scaffolding.ts` lines 123-144 (`autoAddFileToProject`) +#### V2 — `src/editors/vscode/src/scaffolding.ts` lines 123-144 (`autoAddFileToProject`) - **What:** When scaffolding a new `.cs` file, splices `` into the `.csproj` by finding `` with `.lastIndexOf()` and concatenating strings. - **Why broken:** Fails if `` appears in a comment, in a string literal inside a property value, or if the project has multiple `` blocks (it always picks the last one regardless of what's in it). Doesn't understand SDK-style projects where `` is globbed by default and should **not** be added explicitly. -#### V3 — `sidecars/SharpLsp.Sidecar.FSharp/FSharpFileOrder.fs` lines 158-184 (`generateReorderEdit`) +#### V3 — `src/sidecars/SharpLsp.Sidecar.FSharp/FSharpFileOrder.fs` lines 158-184 (`generateReorderEdit`) - **What:** Reorders `` elements in `.fsproj` by reading the file as lines, matching `.Contains("Include=\"{name}\"")` on trimmed lines, building a new array, and `String.Join("\n", ...)`-ing it back together. - **Why broken:** File order matters in F# (compilation order is semantic). A corrupted reorder can break the whole project. Fails on multi-line `` elements (with `` children, conditions, copy metadata), comments between elements, and attribute-wrapped elements. Also silently drops `\r` on CRLF files by splitting on `\n`. ### MEDIUM (currently OK, but flag for audit) -- `editors/vscode/src/dependencies.ts` — **READ-ONLY** inspection using `fast-xml-parser`. OK, leave as-is. -- `editors/vscode/src/testing.ts` Cobertura parser — **READ-ONLY** using `fast-xml-parser`. OK. -- `editors/vscode/src/debug.ts` `readLaunchProfiles` — **READ-ONLY** using `JSON.parse`. OK. -- `sidecars/SharpLsp.Sidecar.CSharp/Workspace/MetadataNavigator.cs` — writes to temp `.cs` files for decompiled source, line-based search for symbol position. **Transient cache**, not a structured project file. OK. -- `sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspace.fs` line 37 `parseFsprojSourceFiles` — already uses `XDocument.Load`. OK. -- `scripts/check-coverage.sh` — uses `jq`. OK. +- `src/editors/vscode/src/dependencies.ts` — **READ-ONLY** inspection using `fast-xml-parser`. OK, leave as-is. +- `src/editors/vscode/src/testing.ts` Cobertura parser — **READ-ONLY** using `fast-xml-parser`. OK. +- `src/editors/vscode/src/debug.ts` `readLaunchProfiles` — **READ-ONLY** using `JSON.parse`. OK. +- `src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/MetadataNavigator.cs` — writes to temp `.cs` files for decompiled source, line-based search for symbol position. **Transient cache**, not a structured project file. OK. +- `src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspace.fs` line 37 `parseFsprojSourceFiles` — already uses `XDocument.Load`. OK. +- `tools/coverage/check-coverage.sh` — uses `jq`. OK. ## Design @@ -91,14 +91,14 @@ project.Save(path); // trivia-preserving write ### Rust host changes -- Delete `src/nuget/xml_edit.rs` entirely. -- `src/nuget/handlers.rs` becomes a thin IPC forwarder: serialize the request, send to the C# sidecar (or F# sidecar for `.fsproj`), return the response. +- Delete `src/sharplsp/src/nuget/xml_edit.rs` entirely. +- `src/sharplsp/src/nuget/handlers.rs` becomes a thin IPC forwarder: serialize the request, send to the C# sidecar (or F# sidecar for `.fsproj`), return the response. - No XML parsing in Rust. No line manipulation in Rust. The Rust host only routes. ### TypeScript client changes -- `editors/vscode/src/scaffolding.ts autoAddFileToProject` — delete the fs-based splicing. Call `sharplsp/project/addCompileItem` via the LSP client instead. If the project is SDK-style and the file is already globbed, the sidecar returns a no-op result and the extension does nothing. -- `editors/vscode/src/dependencies.ts` — no change (read-only). +- `src/editors/vscode/src/scaffolding.ts autoAddFileToProject` — delete the fs-based splicing. Call `sharplsp/project/addCompileItem` via the LSP client instead. If the project is SDK-style and the file is already globbed, the sidecar returns a no-op result and the extension does nothing. +- `src/editors/vscode/src/dependencies.ts` — no change (read-only). ### F# sidecar changes @@ -110,7 +110,7 @@ project.Save(path); // trivia-preserving write ### Phase 1 — Introduce the sidecar API (package editing) ✅ - [x] Add IPC message types (`PackageEditRequest`, `PackageEditResult { Modified, Message }`) in the C# sidecar `Messages.cs`. (Kept in the C# sidecar rather than Common — see Status deviations.) -- [x] Implement handlers in `sidecars/SharpLsp.Sidecar.CSharp/PackageEditor.cs` using `ProjectRootElement`: +- [x] Implement handlers in `src/sidecars/SharpLsp.Sidecar.CSharp/PackageEditor.cs` using `ProjectRootElement`: - [x] `Add` — find/create ``, add ``; CPM variant adds `` / a versionless ``; upsert updates an existing `Version`. - [x] `Remove` — find the matching ``/`` and `.Parent.RemoveChild(el)` (removes the whole subtree, fixing issue #4). - [x] `UpdatePackageVersion` — folded into `Add` (upsert path sets `metadata.Value`). @@ -118,7 +118,7 @@ project.Save(path); // trivia-preserving write - [x] Register `project/addPackage` / `project/removePackage` in the `CSharpSidecar` router. - [x] `.fsproj` edits route to the C# sidecar (language-agnostic `ProjectRootElement`) — see Status for the MSBL001 rationale. - [ ] `ReorderCompileItems` handler — _not done (separate violation V3)._ -- [x] Tests for the scenarios the line-based code broke on (full-stack in `tests/nuget_e2e.rs`, per CLAUDE.md's coarse-e2e preference): +- [x] Tests for the scenarios the line-based code broke on (full-stack in `src/sharplsp/tests/nuget_e2e.rs`, per CLAUDE.md's coarse-e2e preference): - [x] `` with ``/`` children (issue #4) - [x] Conditional `` - [x] CPM with `Directory.Packages.props` — writes `` in props and a versionless `` in csproj @@ -128,17 +128,17 @@ project.Save(path); // trivia-preserving write ### Phase 2 — Route Rust host through the sidecar, delete `xml_edit.rs` ✅ -- [x] Add the Rust wrapper (`src/nuget/edit.rs`) — thin wrappers that serialize/send each request. -- [x] Rewrite `src/nuget/handlers.rs::handle_install`, `handle_uninstall`, the CPM props-file case, **and** `consolidate.rs` to call the sidecar instead of `xml_edit::*`. -- [x] Delete `src/nuget/xml_edit.rs`. -- [x] Delete the `xml_edit` module export from `src/nuget/mod.rs`. -- [x] Update `src/nuget/cli.rs` (and `parse.rs`) header comments that referenced `xml_edit`. -- [x] `tests/nuget_e2e.rs` — every existing scenario green (now full-stack via the sidecar), plus new multi-line-child, wrapped-attribute, conditional-`ItemGroup`, and comment scenarios from issue #4. +- [x] Add the Rust wrapper (`src/sharplsp/src/nuget/edit.rs`) — thin wrappers that serialize/send each request. +- [x] Rewrite `src/sharplsp/src/nuget/handlers.rs::handle_install`, `handle_uninstall`, the CPM props-file case, **and** `consolidate.rs` to call the sidecar instead of `xml_edit::*`. +- [x] Delete `src/sharplsp/src/nuget/xml_edit.rs`. +- [x] Delete the `xml_edit` module export from `src/sharplsp/src/nuget/mod.rs`. +- [x] Update `src/sharplsp/src/nuget/cli.rs` (and `parse.rs`) header comments that referenced `xml_edit`. +- [x] `src/sharplsp/tests/nuget_e2e.rs` — every existing scenario green (now full-stack via the sidecar), plus new multi-line-child, wrapped-attribute, conditional-`ItemGroup`, and comment scenarios from issue #4. - [x] Remove the now-dead `xml_edit` tests (coverage moved to `nuget_e2e.rs`) and the `quick-xml` dependency. ### Phase 3 — Switch scaffolding.ts to the sidecar -- [ ] `editors/vscode/src/scaffolding.ts autoAddFileToProject` — replace the file-system splicing with a call to the new `sharplsp/project/addCompileItem` LSP custom request. +- [ ] `src/editors/vscode/src/scaffolding.ts autoAddFileToProject` — replace the file-system splicing with a call to the new `sharplsp/project/addCompileItem` LSP custom request. - [ ] Delete the `fs.readFileSync`/`fs.writeFileSync` path and the `lastIndexOf('')` code. - [ ] Add a VSCode test that scaffolds a new `.cs` file into a project with a comment containing `` to prove the old bug is gone. - [ ] Add a test for SDK-style projects that have default `Compile` globs — the no-op path. @@ -154,7 +154,7 @@ project.Save(path); // trivia-preserving write ### Phase 5 — Enforce the CLAUDE.md rule -- [ ] Add a lint/CI check that greps the codebase for banned patterns in source (not tests/fixtures): +- [ ] Add a lint/CI check that greps the codebase for banned patterns in source (not src/sharplsp/tests/fixtures): - `fs.writeFileSync(.*\.(csproj|fsproj|props|targets|sln|vsixmanifest|json)` - `File.WriteAllText(.*\.(csproj|fsproj|props|targets))` - `std::fs::write(.*\.(csproj|fsproj|props|targets))` @@ -164,12 +164,12 @@ project.Save(path); // trivia-preserving write ## Acceptance Criteria -- [ ] `src/nuget/xml_edit.rs` **does not exist**. -- [ ] No source file under `src/`, `editors/vscode/src/`, or `sidecars/**/*.{cs,fs}` writes to a `.csproj`/`.fsproj`/`.props`/`.targets`/`.sln`/`.vsixmanifest`/`.json` via string concatenation, `.replace()`, regex, or line-array joins. Verified by grep. +- [ ] `src/sharplsp/src/nuget/xml_edit.rs` **does not exist**. +- [ ] No source file under `src/`, `src/editors/vscode/src/`, or `src/sidecars/**/*.{cs,fs}` writes to a `.csproj`/`.fsproj`/`.props`/`.targets`/`.sln`/`.vsixmanifest`/`.json` via string concatenation, `.replace()`, regex, or line-array joins. Verified by grep. - [ ] Issue #4's exact reproduction case (multi-line `` with ``/`` children) is covered by a test and passes. - [ ] Scaffolding a new `.cs` into a project whose comments contain `` works correctly. - [ ] F# file-order reordering preserves comments, CRLF, conditions, and child elements. -- [ ] Existing `tests/nuget_e2e.rs` scenarios still pass. +- [ ] Existing `src/sharplsp/tests/nuget_e2e.rs` scenarios still pass. - [ ] CI has a grep-based guard that blocks re-introduction. ## Non-Goals diff --git a/docs/plans/ZED-PLAN.md b/docs/plans/ZED-PLAN.md index def07ddf..b5818228 100644 --- a/docs/plans/ZED-PLAN.md +++ b/docs/plans/ZED-PLAN.md @@ -62,7 +62,7 @@ When Zed adds custom panel support, the solution tree should be migrated from sl ## File Structure ``` -editors/zed/ +src/editors/zed/ ├── extension.toml Extension manifest (language server + slash commands) ├── Cargo.toml Rust project (compiles to cdylib → WASM) └── src/ @@ -76,13 +76,13 @@ editors/zed/ ```bash # Native check (development) -cd editors/zed && cargo check +cargo check --manifest-path src/editors/zed/Cargo.toml # WASM build (release) -cd editors/zed && cargo build --release --target wasm32-wasip1 +cargo build --manifest-path src/editors/zed/Cargo.toml --release --target wasm32-wasip1 # Run tests -cd editors/zed && cargo test +cargo test --manifest-path src/editors/zed/Cargo.toml ``` Requires `rustup target add wasm32-wasip1` for WASM builds. diff --git a/docs/specs/BINARY-DEPLOYMENT.md b/docs/specs/BINARY-DEPLOYMENT.md index 06ac6d83..394f5dba 100644 --- a/docs/specs/BINARY-DEPLOYMENT.md +++ b/docs/specs/BINARY-DEPLOYMENT.md @@ -1,36 +1,16 @@ -# Distribute SharpLsp via Homebrew, Scoop, and dotnet tool +# Distribute SharpLsp via Homebrew, Scoop, and dotnet tool `[BINARY-DEPLOYMENT]` -## Context +## Context `[BINARY-CONTEXT]` -SharpLsp currently ships a single tagged GitHub release containing one monolithic -archive per platform with `bin/sharplsp` + `sidecar-csharp/` + `sidecar-fsharp/` -folders. The VS Code extension downloads that archive and extracts it into -`~/.local/` (see [install.ts:308-384](editors/vscode/src/install.ts#L308-L384)). +SharpLsp currently ships a single tagged GitHub release containing one monolithic archive per platform with `bin/sharplsp` + `sidecar-csharp/` + `sidecar-fsharp/` folders. The VS Code extension downloads that archive and extracts it into `~/.local/` (see [`install.ts`](../../src/editors/vscode/src/install.ts)). -The user wants three distinct distribution channels: +The distribution contract defines three channels: -1. **Rust `sharplsp` binary** — Homebrew (macOS/Linux) and Scoop (Windows), - driven by GitHub release assets. -2. **C# and F# sidecars** — published as global `dotnet tool` packages - (`dotnet tool install -g SharpLsp.Sidecar.CSharp` / `.FSharp`). -3. **VS Code extension (and any future editor extension)** — at activation, - MUST verify that all three components are installed at the exact version - the VSIX expects by spawning each binary with `--version` and comparing - the output to the version in `package.json`. This is non-negotiable: no - trust-on-presence, no bundled fallback, no version drift. +1. **Rust `sharplsp` binary** — Homebrew (macOS/Linux) and Scoop (Windows), driven by GitHub release assets. +2. **C# and F# sidecars** — published as global `dotnet tool` packages (`dotnet tool install -g SharpLsp.Sidecar.CSharp` / `.FSharp`). +3. **VS Code and future editor extensions** — on activation, MUST spawn each component with `--version` and compare it with `package.json`; file presence, bundled fallback, and version drift are invalid. A missing or mismatched component MUST trigger one modal prompt, then installation or update through `brew`, `scoop`, or `dotnet tool`. Editor extensions MUST NOT download binaries directly. - If any component is missing or mismatched, the extension MUST actively - run the matching package manager (`brew` / `scoop` / `dotnet tool install`) - to install or update it — after prompting the user once with a modal. - Editor extensions are forbidden from downloading binaries directly; all - installation goes through Homebrew, Scoop, or the dotnet tool CLI. - -The reference is [dart_mutant's release.yml](../../Documents/Code/dart_mutant/.github/workflows/release.yml): -tag-triggered build → GitHub release → auto-update of `Nimblesite/homebrew-tap` -and `Nimblesite/scoop-bucket` via `BREW_SCOOP_PAT`. SharpLsp follows the same -pattern, plus a NuGet.org push for the two sidecars. - -## Architecture +## Architecture `[BINARY-ARCHITECTURE]` ```mermaid flowchart TD @@ -50,7 +30,7 @@ flowchart TD UserBrew & UserScoop & UserCS & UserFS --> VSIX[VS Code extension activates] ``` -## Runtime resolution in the VSIX +## Runtime resolution in the VSIX `[BINARY-RUNTIME]` ```mermaid flowchart TD @@ -78,77 +58,44 @@ flowchart TD Rules: -- Version check is ALWAYS by spawning the binary with `--version` and string - matching against the `package.json` version. No file-presence shortcuts, no - cached results across sessions. -- The extension is forbidden from downloading binaries directly over HTTPS. - The only installation paths are `brew`, `scoop`, and `dotnet tool install`. -- If the package manager itself is missing (no `brew` on macOS, no `scoop` on - Windows, no `dotnet` anywhere), show a modal with a link to install the - package manager and abort activation. -- Never fall back to a "best effort" older version. Expected version == installed - version, byte-for-byte. Mismatch = install/update. +- Version check is ALWAYS by spawning the binary with `--version` and string matching against the `package.json` version. No file-presence shortcuts, no cached results across sessions. +- The extension is forbidden from downloading binaries directly over HTTPS. The only installation paths are `brew`, `scoop`, and `dotnet tool install`. +- If the package manager itself is missing (no `brew` on macOS, no `scoop` on Windows, no `dotnet` anywhere), show a modal with a link to install the package manager and abort activation. +- Never fall back to a "best effort" older version. Expected version == installed version, byte-for-byte. Mismatch = install/update. -## Changes +## Required changes `[BINARY-CHANGES]` -### 1. Sidecar projects — make them dotnet tools (framework-dependent) +### Framework-dependent sidecar tools `[BINARY-SIDECARS]` -`sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj` -`sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj` +`src/sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj` `src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj` Changes: -- **Remove `true`.** Sidecars ship as - framework-dependent dotnet tools. One .nupkg per sidecar, cross-platform. - Users install `.NET 10 Runtime` as a prerequisite (checked by the VSIX - before prompting — if `dotnet --version` is missing, send the user to - dotnet.microsoft.com). Roslyn's `BuildHost-netcore` DLLs and FCS's runtime - dependencies ship inside the tool package's `tools//any/` directory - and are resolved at runtime relative to the tool entry point. +- **Remove `true`.** Sidecars ship as framework-dependent dotnet tools. One .nupkg per sidecar, cross-platform. Users install `.NET 10 Runtime` as a prerequisite (checked by the VSIX before prompting — if `dotnet --version` is missing, send the user to dotnet.microsoft.com). Roslyn's `BuildHost-netcore` DLLs and FCS's runtime dependencies ship inside the tool package's `tools//any/` directory and are resolved at runtime relative to the tool entry point. - Add `true` - Add `sharplsp-sidecar-csharp` / `sharplsp-sidecar-fsharp` - Add `SharpLsp.Sidecar.CSharp` / `SharpLsp.Sidecar.FSharp` -- Add ``, ``, ``, - ``, `` -- `` injected at pack time from the git tag - (`dotnet pack -p:PackageVersion=$VERSION`) -- Add `--version` flag handling in `Program.cs` / `Program.fs` that prints - `sharplsp-sidecar-csharp ` (read from the assembly's - `InformationalVersion` attribute, stamped at pack time) so the extension - can version-check by spawning the installed tool. - -**Risk: will MSBuildWorkspace still work?** `MSBuildWorkspace` spawns -`BuildHost-netcore.dll` as a child process using a path resolved relative to -the Roslyn assembly location. Inside a dotnet global tool, the Roslyn -assemblies are unpacked to -`~/.dotnet/tools/.store/sharplsp.sidecar.csharp//sharplsp.sidecar.csharp//tools/net10.0/any/` -and `BuildHost-netcore.dll` is in the same folder as the Roslyn package -dependencies (dotnet pack copies all `PackageReference` content into the -tool output). This should Just Work — but verification step 1 MUST confirm -it against a real `.csproj` before merging. If it genuinely breaks (e.g. FCS -using `Assembly.Location` returning a path that no longer contains FSharp.Core), -the fix is to flip `LatestMajor` and ensure -`true` so every -transitive dep lands next to the tool DLL. Still dotnet tool. No self-contained. - -### 2. Rust binary — already has `--version`, confirm via `sharplsp --version` - -No changes needed in the Rust source. Already verified at -[install.ts:74-91](editors/vscode/src/install.ts#L74-L91). - -### 3. `.github/workflows/release.yml` — rewrite +- Add ``, ``, ``, ``, `` +- `` injected at pack time from the git tag (`dotnet pack -p:PackageVersion=$VERSION`) +- Add `--version` flag handling in `Program.cs` / `Program.fs` that prints `sharplsp-sidecar-csharp ` (read from the assembly's `InformationalVersion` attribute, stamped at pack time) so the extension can version-check by spawning the installed tool. + +**Risk: will MSBuildWorkspace still work?** `MSBuildWorkspace` spawns `BuildHost-netcore.dll` as a child process using a path resolved relative to the Roslyn assembly location. Inside a dotnet global tool, the Roslyn assemblies are unpacked to `~/.dotnet/tools/.store/sharplsp.sidecar.csharp//sharplsp.sidecar.csharp//tools/net10.0/any/` and `BuildHost-netcore.dll` is in the same folder as the Roslyn package dependencies (dotnet pack copies all `PackageReference` content into the tool output). This should Just Work — but verification step 1 MUST confirm it against a real `.csproj` before merging. If it genuinely breaks (e.g. FCS using `Assembly.Location` returning a path that no longer contains FSharp.Core), the fix is to flip `LatestMajor` and ensure `true` so every transitive dep lands next to the tool DLL. Still dotnet tool. No self-contained. + +### Rust binary version contract `[BINARY-RUST]` + +No changes needed in the Rust source. Already verified at [`install.ts`](../../src/editors/vscode/src/install.ts). + +### Release workflow `[BINARY-RELEASE]` Replace the current monolithic archive job with: **Job A: `build-sharplsp`** (matrix: 4 targets) - Build `cargo build --release --target ` -- Package single binary as `sharplsp--.{tar.gz,zip}` (no - sidecar dirs — just the binary, like dart_mutant) +- Package single binary as `sharplsp--.{tar.gz,zip}` (no sidecar dirs — just the binary, like dart_mutant) - Upload artifact **Job B: `pack-sidecars`** (single ubuntu job — framework-dependent, no RID matrix) -- `dotnet pack sidecars/SharpLsp.Sidecar.CSharp -p:PackageVersion= - -c Release -o nupkgs` → one cross-platform `.nupkg` +- `dotnet pack src/sidecars/SharpLsp.Sidecar.CSharp -p:PackageVersion= -c Release -o nupkgs` → one cross-platform `.nupkg` - Same for `SharpLsp.Sidecar.FSharp` - Total: 2 nupkgs per release - Upload nupkg artifacts @@ -157,14 +104,12 @@ Replace the current monolithic archive job with: - `gh release create` with all tar.gz/zip assets (sharplsp only) **Job D: `publish-nuget`** (needs [B]) -- `dotnet nuget push *.nupkg --api-key ${{ secrets.NUGET_API_KEY }} - --source https://api.nuget.org/v3/index.json` +- `dotnet nuget push *.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json` **Job E: `update-homebrew`** (needs [release]) - Checkout `Nimblesite/homebrew-tap` with `BREW_SCOOP_PAT` - Download macOS arm64 + macOS x64 + linux x64 tar.gz assets, sha256 each -- Generate `Formula/sharplsp.rb` with `on_macos {on_arm / on_intel}` and - `on_linux { on_intel }` blocks, one url+sha256 per block +- Generate `Formula/sharplsp.rb` with `on_macos {on_arm / on_intel}` and `on_linux { on_intel }` blocks, one url+sha256 per block - `def install; bin.install "sharplsp"; end` - `test do; assert_match "sharplsp", shell_output("#{bin}/sharplsp --version"); end` - Commit and push @@ -172,15 +117,12 @@ Replace the current monolithic archive job with: **Job F: `update-scoop`** (needs [release]) - Checkout `Nimblesite/scoop-bucket` with `BREW_SCOOP_PAT` - Download win x64 zip, sha256 it -- Write `bucket/sharplsp.json` with `architecture."64bit".{url,hash,bin}`, - `checkver.github`, `autoupdate.architecture."64bit".url` template +- Write `bucket/sharplsp.json` with `architecture."64bit".{url,hash,bin}`, `checkver.github`, `autoupdate.architecture."64bit".url` template - Commit and push -### 4. VS Code extension — rewrite `editors/vscode/src/install.ts` +### VS Code installation flow `[BINARY-VSCODE]` -Replace `ensureBinaries` and the entire download path -([install.ts:107-306](editors/vscode/src/install.ts#L107-L306)) with a -verify-then-install-via-package-manager layer. +Replace `ensureBinaries` and the entire download path in [`install.ts`](../../src/editors/vscode/src/install.ts) with a verify-then-install-via-package-manager layer. **Version check (mandatory, always via `--version`):** @@ -191,8 +133,7 @@ function getVersion(command: string): string | undefined { } ``` -This is called for all three: `sharplsp`, `sharplsp-sidecar-csharp`, -`sharplsp-sidecar-fsharp`. No file-existence checks, no fallbacks. +This is called for all three: `sharplsp`, `sharplsp-sidecar-csharp`, `sharplsp-sidecar-fsharp`. No file-existence checks, no fallbacks. **Package-manager-driven install (the only install path):** @@ -216,161 +157,88 @@ const INSTALL_COMMANDS = { }; ``` -- `update` (not `install`) is used so the same command works for both first - install and version bump. `dotnet tool update -g --version X` installs if - absent and re-pins if present. -- For `sharplsp` on Scoop, version pinning uses `scoop install sharplsp@X` - if the bucket manifest supports it, otherwise `scoop update sharplsp`. +- `update` (not `install`) is used so the same command works for both first install and version bump. `dotnet tool update -g --version X` installs if absent and re-pins if present. +- For `sharplsp` on Scoop, version pinning uses `scoop install sharplsp@X` if the bucket manifest supports it, otherwise `scoop update sharplsp`. **Flow for each binary:** 1. `getVersion(binary)` → compare to `expectedVersion()` 2. If match: use it, done. -3. If mismatch: show modal with OK/Cancel — "SharpLsp needs to install - `` at version ``. Run ``?" -4. OK → spawn the command, stream stdout/stderr to an Output Channel so - the user sees progress. On exit, re-run step 1. +3. If mismatch: show modal with OK/Cancel — "SharpLsp needs to install `` at version ``. Run ``?" +4. OK → spawn the command, stream stdout/stderr to an Output Channel so the user sees progress. On exit, re-run step 1. 5. Cancel → throw, activation aborts. **Preflight — package manager presence:** -Before running any install command, run `getVersion("brew")` / -`getVersion("scoop")` / `getVersion("dotnet")`. If the required package -manager is missing, show a modal with a link to the install page and -abort. Do not offer to install package managers automatically. +Before running any install command, run `getVersion("brew")` / `getVersion("scoop")` / `getVersion("dotnet")`. If the required package manager is missing, show a modal with a link to the install page and abort. Do not offer to install package managers automatically. **Deletions:** -- `downloadAndInstall`, `downloadToFile`, `extractTarGz`, `platformRid`, - `bundledBinaryPath`, and the whole GitHub-release HTTPS path. -- The `bin/` VSIX bundling path and the `~/.local/lib/sharplsp/` staging from - both the Makefile `install` target and `.github/workflows/test-vscode.yml` - (recent commits `c6f29f0` and `e1dd2ca` become partially obsolete). +- `downloadAndInstall`, `downloadToFile`, `extractTarGz`, `platformRid`, `bundledBinaryPath`, and the whole GitHub-release HTTPS path. +- The `bin/` VSIX bundling path and the `~/.local/lib/sharplsp/` staging from both the Makefile `install` target and `.github/workflows/ci-vsix.yml` (recent commits `c6f29f0` and `e1dd2ca` become partially obsolete). **Forbidden patterns (encoded as lint / code review):** - `https.get(...)` or `fetch(...)` for binary downloads -- Any path that writes executables into `~/.local/`, `extensionPath/bin/`, - or a temp dir with intent to execute +- Any path that writes executables into `~/.local/`, `extensionPath/bin/`, or a temp dir with intent to execute - Any "skip version check if binary exists" shortcut -### 5. Makefile — simplify `install` target +### Makefile installation targets `[BINARY-MAKEFILE]` -`Makefile:370-383` — the `install` target currently stages sharplsp + -sidecars into `$PREFIX`. Replace with: +`tools/make/main.mk:584-600` — the install targets currently stage sharplsp + sidecars into `$PREFIX`. Replace with: - `install-rust`: just copies `sharplsp` to `$PREFIX/bin` (for local dev) -- `install-sidecars`: runs `dotnet tool install -g` from locally packed - nupkgs so contributors can test the tool install flow end-to-end -- Drop `~/.local/lib/sharplsp/` entirely. Sidecars now live wherever - `dotnet tool` puts them (`~/.dotnet/tools` on macOS/Linux, - `%USERPROFILE%\.dotnet\tools` on Windows). - -### 6. Docs - -New: `docs/specs/DISTRIBUTION-SPEC.md` — canonical spec for how SharpLsp is -distributed. MUST state the following as normative requirements (not -suggestions): - -1. **Three channels, no alternatives.** - - `sharplsp` → Homebrew (macOS/Linux) and Scoop (Windows). - - `SharpLsp.Sidecar.CSharp` → dotnet global tool on NuGet.org. - - `SharpLsp.Sidecar.FSharp` → dotnet global tool on NuGet.org. - - Sidecars are **framework-dependent** dotnet tools. `SelfContained=true` - is forbidden in sidecar csproj/fsproj files. - -2. **Version invariant.** `Cargo.toml` `version` is the single source of - truth. The release workflow stamps the tag version into: - - `Cargo.toml` (at build time only, not committed) - - `editors/vscode/package.json` (at build time only) - - Sidecar `.nupkg` package versions - - Assembly `InformationalVersion` for sidecar `--version` output - All five must match byte-for-byte for a release to be valid. - -3. **Editor extension contract.** Any editor extension (VS Code today, - Zed/JetBrains/Neovim in the future) MUST: - - Check all three binary versions on activation by spawning each with - `--version` and string-matching against the extension's own version. - - NEVER download binaries directly over HTTPS. The only installation - mechanisms are `brew`, `scoop`, and `dotnet tool install`/`update`. - - On mismatch, prompt the user once (modal) and then run the matching - package-manager command, streaming output to a visible log. - - Abort activation on user cancel or install failure — never fall back - to a degraded mode or older version. - -4. **Tap/bucket repo layout.** `Nimblesite/homebrew-tap` contains - `Formula/sharplsp.rb`. `Nimblesite/scoop-bucket` contains - `bucket/sharplsp.json`. Both are auto-updated by the release workflow - using `BREW_SCOOP_PAT`. Manual edits are forbidden. - -5. **Required secrets on `Nimblesite/SharpLsp`:** `BREW_SCOOP_PAT` (PAT with - `contents:write` on both tap repos), `NUGET_API_KEY` (push rights to - `SharpLsp.Sidecar.*` on nuget.org). - -New: `docs/plans/DISTRIBUTION-PLAN.md` — TODO checklist mirroring the -changes in this plan file. - -Update: `docs/specs/SHARPLSP-SPEC.md` — add a short "Distribution" section -linking to `DISTRIBUTION-SPEC.md`. - -## Critical files - -- [.github/workflows/release.yml](.github/workflows/release.yml) — rewrite -- [editors/vscode/src/install.ts](editors/vscode/src/install.ts) — gut and replace -- [sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj](sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj) — add `PackAsTool` -- [sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj](sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj) — add `PackAsTool` -- [sidecars/SharpLsp.Sidecar.CSharp/Program.cs](sidecars/SharpLsp.Sidecar.CSharp/Program.cs) — add `--version` -- [sidecars/SharpLsp.Sidecar.FSharp/Program.fs](sidecars/SharpLsp.Sidecar.FSharp/Program.fs) — add `--version` -- [Makefile](Makefile) — simplify `install` target +- `install-sidecars`: runs `dotnet tool install -g` from locally packed nupkgs so contributors can test the tool install flow end-to-end +- Drop `~/.local/lib/sharplsp/` entirely. Sidecars now live wherever `dotnet tool` puts them (`~/.dotnet/tools` on macOS/Linux, `%USERPROFILE%\.dotnet\tools` on Windows). + +### Canonical documentation `[BINARY-DOCS]` + +[`DISTRIBUTION-SPEC.md`](DISTRIBUTION-SPEC.md) is the canonical distribution contract; [`DISTRIBUTION-PLAN.md`](../plans/DISTRIBUTION-PLAN.md) tracks implementation. `SHARPLSP-SPEC.md` links to the canonical contract rather than duplicating it. + +## Critical files `[BINARY-FILES]` + +- [`.github/workflows/release.yml`](../../.github/workflows/release.yml) — rewrite +- [`src/editors/vscode/src/install.ts`](../../src/editors/vscode/src/install.ts) — replace the download path +- [`src/sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj`](../../src/sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj) — add `PackAsTool` +- [`src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj`](../../src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj) — add `PackAsTool` +- [`src/sidecars/SharpLsp.Sidecar.CSharp/Program.cs`](../../src/sidecars/SharpLsp.Sidecar.CSharp/Program.cs) — add `--version` +- [`src/sidecars/SharpLsp.Sidecar.FSharp/Program.fs`](../../src/sidecars/SharpLsp.Sidecar.FSharp/Program.fs) — add `--version` +- [`Makefile`](../../Makefile) — simplify `install` target - `docs/specs/DISTRIBUTION-SPEC.md` — new - `docs/plans/DISTRIBUTION-PLAN.md` — new -## External prerequisites +## External prerequisites `[BINARY-PREREQUISITES]` -These must exist before the release workflow will succeed. Create them -before merging the changes: +These must exist before the release workflow will succeed. Create them before merging the changes: - GitHub repo `Nimblesite/homebrew-tap` (empty, default branch `main`) - GitHub repo `Nimblesite/scoop-bucket` (empty, default branch `main`) -- PAT with `contents:write` on both repos → add as `BREW_SCOOP_PAT` secret - on `Nimblesite/SharpLsp` -- NuGet.org account + API key with push rights to `SharpLsp.Sidecar.*` → - add as `NUGET_API_KEY` secret -- Reserve package IDs `SharpLsp.Sidecar.CSharp` and `SharpLsp.Sidecar.FSharp` on - nuget.org via a manual 0.0.1-preview push, to prevent squatting +- PAT with `contents:write` on both repos → add as `BREW_SCOOP_PAT` secret on `Nimblesite/SharpLsp` +- NuGet.org account + API key with push rights to `SharpLsp.Sidecar.*` → add as `NUGET_API_KEY` secret +- Reserve package IDs `SharpLsp.Sidecar.CSharp` and `SharpLsp.Sidecar.FSharp` on nuget.org via a manual 0.0.1-preview push, to prevent squatting -## Verification +## Verification `[BINARY-VERIFY]` 1. **Local dry-run of sidecar packaging** ``` - dotnet pack sidecars/SharpLsp.Sidecar.CSharp -p:PackageVersion=0.1.1 \ - -p:RuntimeIdentifier=osx-arm64 -o /tmp/nupkgs + dotnet pack src/sidecars/SharpLsp.Sidecar.CSharp -p:PackageVersion=0.1.1 -o /tmp/nupkgs dotnet tool install -g --add-source /tmp/nupkgs SharpLsp.Sidecar.CSharp sharplsp-sidecar-csharp --version # must print "sharplsp-sidecar-csharp 0.1.1" ``` - Confirms `PackAsTool` + `SelfContained` + multi-RID strategy actually works. - **If this fails**, the whole dotnet-tool channel is invalid and we need - to reconsider (fallback: ship sidecars as GitHub release tarballs beside - sharplsp, verified by path on PATH). + Confirms `PackAsTool` and the framework-dependent dependency layout. Failure blocks the dotnet-tool channel. 2. **VSIX verification path** - Build VSIX locally with version `0.1.1` - Install sidecars at `0.1.0` and `sharplsp` at `0.1.1` - Activate extension in a fresh VS Code window - - Expect: activation fails fast with a modal showing the exact - `dotnet tool update -g SharpLsp.Sidecar.CSharp --version 0.1.1` command + - Expect: activation fails fast with a modal showing the exact `dotnet tool update -g SharpLsp.Sidecar.CSharp --version 0.1.1` command - Install matching versions, reactivate — expect clean startup 3. **Tag-driven end-to-end** - Push tag `v0.1.1-rc1` to a test fork - - Observe: `release` workflow succeeds, GitHub release created, - `homebrew-tap` and `scoop-bucket` forks receive commits, nupkgs - appear on nuget.org - - On a clean macOS VM: `brew install Nimblesite/tap/sharplsp` + both - `dotnet tool install` commands → VS Code extension activates cleanly + - Observe: `release` workflow succeeds, GitHub release created, `homebrew-tap` and `scoop-bucket` forks receive commits, nupkgs appear on nuget.org + - On a clean macOS VM: `brew install Nimblesite/tap/sharplsp` + both `dotnet tool install` commands → VS Code extension activates cleanly - On a clean Windows VM: same via scoop 4. **CI smoke test** - - Add a job to `ci.yml` that runs `dotnet pack` on both sidecars - (without publishing) on every PR, so packaging regressions are caught - before tag time. + - Add a job to `ci.yml` that runs `dotnet pack` on both sidecars (without publishing) on every PR, so packaging regressions are caught before tag time. diff --git a/docs/specs/DEBUGGING-SPEC.md b/docs/specs/DEBUGGING-SPEC.md index e3c8cbcf..27bd6286 100644 --- a/docs/specs/DEBUGGING-SPEC.md +++ b/docs/specs/DEBUGGING-SPEC.md @@ -1,51 +1,16 @@ -# DEBUGGING-SPEC +# SharpLsp Debugging Technical Specification `[DEBUG-SPEC]` -**SharpLsp Debugging Technical Specification** +## Mission `[DEBUG-MISSION]` -*March 2026 | DRAFT* +SharpLsp debugging MUST use redistributable open-source components, work through DAP in any editor, and provide the same specified behavior for C# and F#. The proprietary `vsdbg` binary MUST NOT be distributed or invoked. ---- +## Debugger Adapter Selection `[DEBUG-ADAPTER]` -## 1. Mission +### Phase Four Adapter `[DEBUG-ADAPTER-NETCOREDBG]` -SharpLsp must deliver a top-tier .NET debugging experience that is fully open-source, editor-agnostic, and license-free. Microsoft's proprietary `vsdbg` is explicitly forbidden by its license from use in any editor except Visual Studio, Visual Studio Code (Microsoft-signed binary), and Visual Studio for Mac. SharpLsp must match or exceed the vsdbg experience using only open-source infrastructure. +Phase Four uses the MIT-licensed netcoredbg `3.2.0-1092` adapter over DAP `1.71.0` on stdin/stdout. Phase Five replaces it with the native SharpLsp Debug Sidecar in [DEBUG-ARCHITECTURE-SIDECAR]. -The benchmark is brutal: a developer coming from `vsdbg` must not feel degraded. Every mainstream debugging workflow must work. Gaps in open-source tooling that cannot be closed by configuration must be closed by engineering. - -C# and F# are treated as equal first-class citizens. F# debugging is not an afterthought. - ---- - -## 2. Debugger Adapter Selection - -### 2.1 The Landscape - -| Debugger | License | Language | Production-Ready | Notable Gaps | -|---|---|---|---|---| -| **vsdbg** (Microsoft) | **Proprietary — FORBIDDEN** | C++ | Yes | License bars non-VS-Code products | -| **netcoredbg** (Samsung) | MIT | C++ over ICorDebug | Mostly (see §2.3) | Expression eval, async stacks, logpoints, DebuggerDisplay, EnC on Linux/macOS | -| **SharpDbg** (MattParkerDev) | MIT | C# over ClrDebug | Preview (0.1.0-preview5) | Lambda stepping incomplete; Source Link absent; pre-production | -| **Mono SDB** | MIT | Mono | Yes (Mono only) | Incompatible with CoreCLR; not applicable | -| **Rider debugger** (JetBrains) | Proprietary | Java + .NET | Yes | Not redistributable; IntelliJ-coupled | - -**Decision: netcoredbg is the primary debugger for Phase 4, with a parallel investment in a SharpLsp-native C# Debug Sidecar (Tier 4) targeting full vsdbg parity in Phase 5.** - -### 2.2 Why netcoredbg - -- Only MIT-licensed CoreCLR debugger with production DAP support -- Implements the full DAP protocol (v1.71.0) over stdin/stdout — drop-in compatible with any editor -- Used in production by VSCodium, Neovim, Helix, Emacs, and MonoDevelop communities -- Actively maintained by Samsung's Linux Platform team (latest: 3.1.3-1062, December 2025) -- Covers all P1 debugging scenarios: line/conditional/function/exception breakpoints, step in/over/out, variable inspection, call stack navigation -- Supports Linux (x64, ARM64, ARM, RISCV64), Windows (x64, x86, ARM64), macOS (x64, ARM64 community builds) -- Three protocol frontends: CLI, GDB/MI, VSCode DAP — all sharing the same `ManagedDebugger` core -- Supports mixed-mode (managed + native interop) debugging on Linux x64/ARM64 - -### 2.3 netcoredbg Known Gaps and Open Issues - -netcoredbg has real, material gaps versus vsdbg. These are not cosmetic. - -**Confirmed missing features:** +### netcoredbg Gaps `[DEBUG-ADAPTER-GAPS]` | Gap | Impact | Upstream Issue | |---|---|---| @@ -64,123 +29,67 @@ netcoredbg has real, material gaps versus vsdbg. These are not cosmetic. | No parallel stacks data | Multi-threaded debugging crippled — can't visualize all thread stacks at once | Not documented | | C# 12 primary constructor params not inspectable | Compiler-generated fields not mapped back to source syntax | Issue #203 | | `Nullable` expansion broken | `Nullable` and similar value types cannot be expanded in debugger | Issue #213 | -| Version 3.1.3 stability regression | Crashes on every run in some configurations | Issue #217, #206 | -### 2.4 Why Not Stop at netcoredbg +SharpDbg `0.1.0-preview5` MAY replace a from-scratch Phase Five sidecar only after it gains lambda stepping and Source Link support and passes SharpLsp DAP acceptance tests. ICorDebug wrapper fixes SHOULD go upstream; SharpLsp MUST NOT maintain a product fork. -The SharpLsp answer is a **two-phase approach**: +## Architecture `[DEBUG-ARCHITECTURE]` -1. **Phase 4**: Ship netcoredbg integration, closing the most impactful gaps via DapRouter-layer workarounds and upstream contributions -2. **Phase 5**: Ship the SharpLsp Debug Sidecar — a C# Tier 4 process built on `ClrDebug` + `ICorDebug` that achieves full vsdbg feature parity +The current Phase Four factory and resolver in [`debug.ts`](../../src/editors/vscode/src/debug.ts) launch netcoredbg and are covered by [`debug-e2e.test.ts`](../../src/editors/vscode/src/test/suite/debug-e2e.test.ts). The target Rust `DapRouter` proxies netcoredbg or the Phase Five C# Debug Sidecar; both control CoreCLR through ICorDebug/DbgShim. -### 2.5 SharpDbg — Watch and Contribute +### Rust DapRouter `[DEBUG-ARCHITECTURE-ROUTER]` -SharpDbg (MattParkerDev, MIT, C#) is the most promising long-term foundation for community .NET debugging. It already implements `[DebuggerDisplay]`, `[DebuggerTypeProxy]`, and `[DebuggerBrowsable]` — all absent in netcoredbg. It uses ClrDebug, the same foundation as the planned SharpLsp Debug Sidecar. +Target `DapRouter` responsibilities: -**SharpLsp's relationship with SharpDbg:** -- Monitor SharpDbg for production readiness; evaluate as a Phase 5 foundation vs. building from scratch -- Contribute upstream: any ICorDebug wrapper gaps discovered during SharpLsp Debug Sidecar work -- Do not fork SharpDbg; if it reaches production maturity before Phase 5, adopt rather than reinvent - ---- - -## 3. Architecture - -### 3.1 System Topology - -``` -┌────────────────────────────────────────────────────────────────────┐ -│ Editor (VS Code, Neovim, Helix, Zed, Emacs, …) │ -│ DAP JSON-RPC over stdio/socket │ -└───────────────────────────┬────────────────────────────────────────┘ - │ DAP 1.71.0 (JSON-RPC) - ▼ -┌────────────────────────────────────────────────────────────────────┐ -│ Tier 1: Rust LSP/DAP Host (sharplsp) │ -│ ┌──────────────────────────────────────────────────────────────┐ │ -│ │ DapRouter │ │ -│ │ - Proxies DAP to active debug adapter │ │ -│ │ - Augments: logpoints, async stacks, DebuggerDisplay │ │ -│ │ - Manages adapter lifecycle (spawn, health, restart) │ │ -│ │ - Multiplexes multi-process debug sessions │ │ -│ └──────────────────────┬───────────────────────────────────────┘ │ -└─────────────────────────┼──────────────────────────────────────────┘ - │ - ┌───────────────┴───────────────┐ - │ │ - ▼ Phase 4 ▼ Phase 5 -┌─────────────────────┐ ┌──────────────────────────────────┐ -│ netcoredbg │ │ Tier 4: SharpLsp Debug Sidecar │ -│ (external process) │ │ (C# process) │ -│ DAP stdin/stdout │ │ ClrDebug + ICorDebug + DbgShim │ -│ MIT licensed │ │ DAP stdin/stdout │ -└──────────┬──────────┘ └──────────────────┬───────────────┘ - │ │ - └───────────────┬──────────────────────┘ - │ ICorDebug / DbgShim - ▼ - ┌─────────────────────┐ - │ Target .NET Process│ - │ (CoreCLR runtime) │ - └─────────────────────┘ -``` - -### 3.2 Rust DapRouter - -The Rust host runs a `DapRouter` module responsible for: - -- **Adapter lifecycle**: spawning/monitoring netcoredbg (Phase 4) or the Debug Sidecar (Phase 5), auto-restart on crash with exponential backoff -- **DAP proxy**: forwarding DAP messages between the editor and the active adapter with minimal latency overhead -- **Capability augmentation**: intercepts DAP `initialize` responses to advertise capabilities the underlying adapter lacks but SharpLsp implements at the proxy layer (logpoints, async stack enrichment, DebuggerDisplay) +- **Adapter lifecycle**: spawn and monitor netcoredbg or the Debug Sidecar; restart crashes with exponential backoff +- **DAP proxy**: forward messages between the editor and active adapter +- **Capability augmentation**: amend `initialize` responses for proxy-layer features - **Logpoint emulation**: translates DAP `setBreakpoints` logpoint requests into conditional breakpoints that evaluate + log + continue (Phase 4) - **Async stack enrichment**: post-processes `stackTrace` responses by reconstructing logical async frames using state-machine field analysis via the C# sidecar (Roslyn) - **DebuggerDisplay emulation**: in Phase 4, queries the C# sidecar to evaluate `[DebuggerDisplay]` format strings and rewrites `variables` responses with user-friendly display values -- **Multi-session management**: maintains a registry of active debug sessions for multi-process/multi-project scenarios -- **Hot Reload coordination**: integrates with `dotnet watch` / `MetadataUpdater.ApplyUpdate` for hot reload during debug sessions - -### 3.3 netcoredbg Integration (Phase 4) +- **Multi-session management**: track active sessions for multi-process/multi-project debugging +- **Hot Reload coordination**: integrate `dotnet watch` / `MetadataUpdater.ApplyUpdate` -netcoredbg is managed as an external subprocess: +### netcoredbg Integration (Phase Four) `[DEBUG-ARCHITECTURE-NETCOREDBG]` -- **Distribution**: bundled with SharpLsp release artifacts (platform-specific binary), or auto-downloaded on first debug launch if not present (with SHA-256 hash verification) -- **Version pinning**: SharpLsp pins a specific netcoredbg release (currently 3.1.3-1062) and upgrades on a tested cadence +- **Distribution**: [`debug.ts`](../../src/editors/vscode/src/debug.ts) resolves a configured path, bundled platform artifact, standard user install, or `PATH`; downloaded artifacts require SHA-256 verification +- **Version pinning**: [`tools/vsix/fetch-netcoredbg.sh`](../../tools/vsix/fetch-netcoredbg.sh) pins `3.2.0-1092`; upgrades require the debug end-to-end suite - **Transport**: DAP over stdin/stdout; DapRouter opens the child process and pipes JSON-RPC - **Launch modes**: - `launch`: spawn a new .NET process - - `attach`: attach to an existing PID (known reliability issues — see §6.3) + - `attach`: attach to an existing PID (known reliability issues; see [DEBUG-GAPS]) - **Platform matrix**: | Platform | Source | Notes | |---|---|---| | Linux x64 | Official Samsung release binary | Full feature set including interop debugging | | Linux ARM64 | Official Samsung release binary | Full feature set | +| Linux ARM / RISCV64 | Official Samsung release binary | Managed debugging; validate architecture-specific release availability | | macOS x64 | Official Samsung release binary | No interop/native debugging | | macOS ARM64 | SharpLsp CI build from source | Samsung does not ship official ARM64 macOS binaries | | Windows x64 | Official Samsung release binary | Full feature set | +| Windows x86 | Official Samsung release binary | Full feature set | | Windows ARM64 | Official Samsung release binary | Full feature set | -| Alpine/musl x64 | SharpLsp CI musl-linked build | Workaround for SIGSEGV on musl — see §6.5 | +| Alpine/musl x64 | SharpLsp CI musl-linked build | Workaround for SIGSEGV on musl; see [DEBUG-GAPS] | | Alpine/musl ARM64 | SharpLsp CI musl-linked build | Same musl workaround | -### 3.4 SharpLsp Debug Sidecar (Phase 5) +### SharpLsp Debug Sidecar (Phase Five) `[DEBUG-ARCHITECTURE-SIDECAR]` -A new C# process (Tier 4) that implements the full ICorDebug-based debugger natively: +A Tier 4 C# process implements DAP and controls CoreCLR through ICorDebug: -- **Language**: C# 13 on .NET 9+, matching the existing sidecar architecture -- **Core dependency**: [`ClrDebug`](https://github.com/lordmilko/ClrDebug) v0.3.4+ — managed type-safe P/Invoke wrappers for every ICorDebug COM interface (MIT). On .NET 8+, uses source-generated COM interop for zero-overhead marshaling. +- **Runtime**: C# sidecar targeting `net10.0` +- **Core dependency**: [`ClrDebug`](https://github.com/lordmilko/ClrDebug) v0.3.4+ MIT wrappers for ICorDebug COM interfaces; .NET 8+ uses source-generated COM interop - **Bootstrap**: `Microsoft.Diagnostics.DbgShim` NuGet package (v9.0.661903+, MIT) for runtime discovery and ICorDebug bootstrapping -- **Protocol**: DAP over stdin/stdout (same as netcoredbg, fully drop-in from the DapRouter's perspective) +- **Protocol**: DAP over stdin/stdout, interchangeable with netcoredbg behind `DapRouter` - **IPC with Rust host**: MessagePack over Unix socket / named pipe for side-channel requests (async stack analysis, expression compilation via Roslyn, DebuggerDisplay/TypeProxy evaluation) -- **Expression evaluation**: delegates expression compilation to the C# sidecar (Tier 2, Roslyn ScriptingWorkspace); receives compiled IL; evaluates via `ICorDebugEval`. Same approach as vsdbg. -- **Async stack reconstruction**: reads state-machine fields from heap objects via `ICorDebugValue` traversal; reconstructs the logical async continuation chain -- **DebuggerDisplay/TypeProxy**: first-class support; evaluates attribute format strings in the debuggee context and returns formatted display values +- **Expression evaluation**: compile through the Tier 2 Roslyn sidecar and evaluate returned IL via `ICorDebugEval` +- **Async stack reconstruction**: traverse state-machine heap fields through `ICorDebugValue` +- **DebuggerDisplay/TypeProxy**: evaluate attribute formats in the debuggee context ---- - -## 4. DAP Protocol +## DAP Protocol `[DEBUG-PROTOCOL]` SharpLsp targets **DAP specification version 1.71.0**. -### 4.1 Key Capabilities Used +### Key Capabilities `[DEBUG-PROTOCOL-CAPABILITIES]` | Capability | Phase 4 | Phase 5 | Notes | |---|---|---|---| @@ -206,11 +115,9 @@ SharpLsp targets **DAP specification version 1.71.0**. | `supportsGotoTargetsRequest` | Yes | Yes | Run to cursor via `goto` | | `supportsLocationReference` | No | Yes | DAP 1.68+ location navigation | ---- - -## 5. Feature Specification +## Feature Specification `[DEBUG-FEATURES]` -### 5.1 Launch and Attach +### Launch and Attach `[DEBUG-FEATURES-LAUNCH]` | Feature | DAP Method | Priority | Notes | |---|---|---|---| @@ -221,7 +128,7 @@ SharpLsp targets **DAP specification version 1.71.0**. | Launch with environment variables | `launch` (env) | P1 | | | Launch with custom working directory | `launch` (cwd) | P1 | | | Launch browser for Blazor WASM | `launch` (browser) | P3 | Requires browser devtools bridge | -| Hot Reload enabled launch | `launch` (hotReload: true) | P2 | See §5.9 | +| Hot Reload enabled launch | `launch` (hotReload: true) | P2 | See [DEBUG-FEATURES-HOT-RELOAD] | | Child process auto-attach | `launch` event | P2 | Phase 5: `ICorDebugManagedCallback::CreateProcess` | **Launch configuration schema** (`launch.json` / inline config): @@ -230,7 +137,7 @@ SharpLsp targets **DAP specification version 1.71.0**. { "type": "sharplsp", "request": "launch", - "program": "${workspaceFolder}/bin/Debug/net9.0/MyApp.dll", + "program": "${workspaceFolder}/bin/Debug/net10.0/MyApp.dll", "args": [], "cwd": "${workspaceFolder}", "env": {}, @@ -257,7 +164,7 @@ SharpLsp targets **DAP specification version 1.71.0**. } ``` -### 5.2 Breakpoints +### Breakpoints `[DEBUG-FEATURES-BREAKPOINTS]` | Feature | DAP Method | Priority | Implementation | |---|---|---|---| @@ -272,14 +179,15 @@ SharpLsp targets **DAP specification version 1.71.0**. **Logpoint emulation (Phase 4):** -netcoredbg does not support logpoints natively. DapRouter intercepts `setBreakpoints` requests containing `logMessage`, rewrites them as conditional breakpoints with an expression that: +For Phase Four logpoints, `DapRouter` rewrites `setBreakpoints` requests containing `logMessage` as conditional breakpoints that: + 1. Evaluates the interpolated log string (referencing frame-local variables) 2. Calls `System.Diagnostics.Debug.WriteLine(msg)` to emit the output 3. Returns `false` so execution is never paused -Output is captured from the debug output channel and surfaced as a DAP `output` event. This is transparent to the editor. +The debug output becomes a DAP `output` event. Hit conditions accept `>`, `>=`, `<`, `<=`, `==`, and `%`. Phase Five uses `ICorDebugBreakpoint`, immediate `ICorDebugEval`, and `ICorDebugProcess::Continue` without a visible pause. -### 5.3 Stepping +### Stepping `[DEBUG-FEATURES-STEPPING]` | Feature | DAP Method | Priority | |---|---|---| @@ -292,52 +200,36 @@ Output is captured from the debug output channel and surfaced as a DAP `output` | Just My Code (skip non-user code) | launch config | P1 | | Smart Step Into (F# pipelines) | `stepIn` (targetId) | P2 — Phase 5 | -**Just My Code implementation**: netcoredbg supports `justMyCode: true` in launch config. The Debug Sidecar implements full JMC by checking `[DebuggerNonUserCode]`, `[DebuggerHidden]`, and `[GeneratedCode]` attributes on methods/types, matching vsdbg behavior. +With `justMyCode: true`, Phase Five excludes methods/types marked `[DebuggerNonUserCode]`, `[DebuggerHidden]`, or `[GeneratedCode]`, matching vsdbg. -**Smart Step Into (F# Phase 5)**: When a single source line in F# calls multiple functions (pipeline operators, function composition), Smart Step Into presents a list of step targets via the DAP `stepIn` `targetId` mechanism. This requires FCS-provided source location analysis to identify callsites on the current line. +For F# lines with multiple calls, Phase Five returns FCS-derived DAP `stepIn` targets keyed by `targetId`. -### 5.4 Call Stack +### Call Stack `[DEBUG-FEATURES-STACK]` | Feature | DAP Method | Priority | Notes | |---|---|---|---| | Call stack display | `stackTrace` | P1 | Physical frames | -| Logical async call stack | `stackTrace` (enriched) | P1 | DapRouter + Roslyn reconstruction (§5.4.1) | +| Logical async call stack | `stackTrace` (enriched) | P1 | DapRouter + Roslyn reconstruction ([DEBUG-FEATURES-STACK-ASYNC]) | | Navigate to source from frame | `source` | P1 | | | Load symbols on demand | — | P2 | PDB loading, symbol server | | Decompiled source navigation | — | P2 | ICSharpCode.Decompiler in C# sidecar | | Parallel Stacks data | custom `sharplsp/parallelStacks` | P2 | Phase 5: enumerate all thread stacks | -#### 5.4.1 Async Call Stack Reconstruction - -This is the most impactful gap in netcoredbg. When code is paused inside an async state machine, the physical call stack only shows the `MoveNext` frame — not the logical chain of `await` continuations. - -**Reconstruction algorithm (implemented in C# sidecar, called by DapRouter):** +#### Async Call Stack Reconstruction `[DEBUG-FEATURES-STACK-ASYNC]` -1. DapRouter receives a `stopped` event from netcoredbg -2. DapRouter requests `stackTrace` from netcoredbg; identifies frames where the type name matches the compiler-generated state machine pattern (`d__N`) -3. For each such frame, DapRouter sends a side-channel request to the C# sidecar with the type name and the `this` object address (extracted from frame locals) -4. C# sidecar uses Roslyn's compilation model to resolve the state machine type; reads `<>1__state`, `<>4__this` (captured instance), and continuation fields from heap via `ICorDebugObjectValue::GetFieldValue` -5. C# sidecar walks the continuation chain by reading `_continuation`/`MoveNextRunner` from the `AsyncTaskMethodBuilder._builder` field to find the next logical frame -6. Reconstructed logical frames are injected into the `stackTrace` response before forwarding to the editor +netcoredbg reports physical `MoveNext` frames. `DapRouter` and the C# sidecar reconstruct the logical chain: -This reconstruction is best-effort: degrades gracefully (shows physical stack unchanged) when compiler-generated fields cannot be resolved. +1. On `stopped`, request `stackTrace` and find types matching `d__N`. +2. Send each type and its frame-local `this` address to the C# sidecar. +3. Resolve the type with Roslyn; read `<>1__state`, `<>4__this`, and continuation fields through `ICorDebugObjectValue::GetFieldValue`. +4. Follow `_continuation`/`MoveNextRunner` from `AsyncTaskMethodBuilder._builder`. +5. Inject the logical frames before forwarding `stackTrace`. -**Phase 5 improvement**: Debug Sidecar reads continuation chains directly via `ICorDebugProcess::ReadMemory` without requiring a Roslyn compilation model, making reconstruction faster and more reliable. +If compiler-generated fields cannot be resolved, the response retains the physical stack unchanged. -#### 5.4.2 F# Async Stack Reconstruction +Phase Five reads continuation chains directly through `ICorDebugProcess::ReadMemory`, without a Roslyn compilation model. -F# `async { }` computation expressions and `task { }` resumable state machines require separate handling. - -**F# PDB limitations** (confirmed gaps in F# compiler, tracked in dotnet/fsharp): -- `StateMachineMethod` table not emitted — debugger cannot map `MoveNext` frames to source without extra heuristics (dotnet/fsharp#12000) -- `StateMachineHoistedLocalScopes` table not emitted — hoisted local variable scopes unavailable - -**SharpLsp approach:** -- `task { }` (resumable state machines, F# 6+): use same async stack reconstruction as C# with type name pattern matching adjusted for F# compiler-generated names -- `async { }` (legacy CPS-based): best-effort reconstruction; degrade gracefully to physical stack where continuation chains cannot be followed -- Phase 5: contribute `StateMachineMethod` PDB table emission to dotnet/fsharp, or implement workaround via FCS symbol analysis - -### 5.5 Variables and Inspection +### Variables and Inspection `[DEBUG-FEATURES-VARIABLES]` | Feature | DAP Method | Priority | |---|---|---| @@ -360,7 +252,7 @@ F# `async { }` computation expressions and `task { }` resumable state machines r **DebuggerDisplay emulation (Phase 4):** -netcoredbg does not render `[DebuggerDisplay]`. DapRouter intercepts `variables` responses, queries the C# sidecar to evaluate `[DebuggerDisplay]` format strings using Roslyn expression evaluation against the frame context, and replaces the default `toString()` value in the response. This is best-effort; complex format strings may fall back to the raw class name. +For Phase Four, `DapRouter` asks the C# sidecar to evaluate `[DebuggerDisplay]` formats against the frame and replaces the `variables` response's default `toString()` value. Failure falls back to the raw class name. **Expression evaluation quality tiers:** @@ -376,15 +268,9 @@ netcoredbg does not render `[DebuggerDisplay]`. DapRouter intercepts `variables` | T3 | Generic type inference in expressions | Fails | Works | | T3 | `dynamic` type evaluation | Fails | Partial | -The Debug Sidecar achieves T3 by delegating expression compilation to the C# sidecar (Roslyn `CSharpScriptCompilation`), receiving compiled IL, loading it as an in-memory assembly into the debuggee, and evaluating via `ICorDebugEval`. This is the same approach as vsdbg. - -**F# discriminated union inspection (Phase 4):** - -F# DUs compile to class hierarchies in IL. Without F# semantic knowledge, debuggers show raw compiler-generated fields (`_tag`, `_value`, etc.). SharpLsp addresses this via: -- Phase 4: DapRouter queries FCS sidecar to decode DU case names from the type's compiled representation, rewriting the variable display name to match F# syntax (e.g., `Some(42)`) -- Phase 5: Debug Sidecar calls FCS sidecar for full DU-aware variable formatting +For T3, the Debug Sidecar loads C#-sidecar `CSharpScriptCompilation` output into the debuggee and evaluates it through `ICorDebugEval`. -### 5.6 Exception Handling +### Exception Handling `[DEBUG-FEATURES-EXCEPTIONS]` | Feature | Priority | |---|---| @@ -398,36 +284,17 @@ F# DUs compile to class hierarchies in IL. Without F# semantic knowledge, debugg Configuration via `setExceptionBreakpoints` with `filterOptions` and `exceptionOptions` per the DAP 1.71.0 specification. -### 5.7 Conditional Breakpoints and Logpoints +### Hot Reload During Debug `[DEBUG-FEATURES-HOT-RELOAD]` -**Conditional breakpoints:** - -- C# expression evaluated in the context of the paused frame -- Phase 4: expression passed verbatim to netcoredbg's built-in evaluator (T1/T2 tier — see §5.5) -- Phase 5: expression compiled by Roslyn (C# sidecar) and evaluated via `ICorDebugEval` — full T3 support including LINQ -- Hit condition: `>`, `>=`, `<`, `<=`, `==`, `%` operators against hit counter - -**Logpoints:** - -- Interpolated string with `{expression}` placeholders evaluated in frame context -- Phase 4: DapRouter emulation — conditional breakpoint with `always-continue` semantics (see §5.2) -- Phase 5: native implementation — `ICorDebugBreakpoint` + immediate `ICorDebugEval` + `ICorDebugProcess::Continue`, zero pause visible to user - -### 5.8 Hot Reload During Debug - -Hot Reload allows modifying method bodies at runtime without restarting the debug session. SharpLsp uses `.NET Hot Reload` (`MetadataUpdater.ApplyUpdate`), not legacy Edit and Continue (`ICorDebugModule2::ApplyChanges`). This distinction is critical: - -- `MetadataUpdater.ApplyUpdate` is cross-platform (Linux, macOS, Windows) since .NET 6 -- Classic EnC via `ICorDebugModule2::ApplyChanges` requires the debugger to generate delta files; no open-source client generates these deltas for Linux/macOS targets (netcoredbg issue #214) -- `MetadataUpdater.ApplyUpdate` and debugger-based EnC **cannot be used simultaneously** (documented limitation); SharpLsp uses Hot Reload exclusively +SharpLsp uses cross-platform `.NET Hot Reload` (`MetadataUpdater.ApplyUpdate`, .NET 6+) exclusively. It MUST NOT run alongside debugger-based Edit and Continue; no open-source client currently generates `ICorDebugModule2::ApplyChanges` deltas for Linux/macOS (netcoredbg #214). **Architecture:** -1. SharpLsp VFS monitors document changes during active debug session -2. On save, C# sidecar computes delta: uses Roslyn `WatchHotReloadService` to generate metadata delta + IL delta + PDB delta as binary blobs -3. DapRouter delivers the delta to the target process via DAP `evaluate` injection (Phase 4) or direct `MetadataUpdater.ApplyUpdate` call (Phase 5) -4. The debug session continues without interruption; next method invocation uses new IL -5. Rude edits (unsupported changes) are detected and reported with reason; user prompted to restart +1. During an active session, the VFS detects a save. +2. Roslyn `WatchHotReloadService` produces metadata, IL, and PDB deltas. +3. `DapRouter` applies them through DAP `evaluate` injection in Phase Four or directly in Phase Five. +4. Subsequent calls use the new IL without interrupting the session. +5. Unsupported rude edits report the reason and prompt a restart. **Supported hot reload edits:** @@ -442,9 +309,7 @@ Hot Reload allows modifying method bodies at runtime without restarting the debu | Modify lambda captured variables | No — requires restart | | Change inheritance hierarchy | No — requires restart | -**Note on classic EnC (out of scope):** The .NET 8+ runtime supports EnC on Linux/macOS (dotnet/runtime#12409 closed Sept 2023). However, generating the delta files requires IDE tooling that no open-source project currently provides for non-Windows targets. If this gap is closed upstream, SharpLsp will adopt it. Until then, Hot Reload is the cross-platform path. - -### 5.9 Multi-Process and Multi-Project Debugging +### Multi-Process and Multi-Project Debugging `[DEBUG-FEATURES-MULTIPROCESS]` | Feature | Priority | |---|---| @@ -454,11 +319,11 @@ Hot Reload allows modifying method bodies at runtime without restarting the debu | Docker container attach | P2 | | WSL process attach (Windows) | P3 | -**Implementation:** DapRouter maintains a `DebugSessionRegistry` indexed by session ID. Each session owns an independent adapter process. The editor communicates with multiple sessions via session-ID-prefixed DAP messages. Compound launch configs define multiple named configurations that start simultaneously. +`DapRouter` indexes independent adapter processes by session ID. Session-prefixed DAP messages multiplex them; compound configs start multiple named sessions. -### 5.10 Remote Debugging +### Remote Debugging `[DEBUG-FEATURES-REMOTE]` -SharpLsp manages SSH tunnel setup transparently. The debug adapter always runs locally (against a forwarded socket), avoiding the complexity of cross-machine DAP transport. +SharpLsp creates the SSH tunnel; DapRouter connects to its local forwarded socket. | Step | Action | |---|---| @@ -486,7 +351,7 @@ SharpLsp manages SSH tunnel setup transparently. The debug adapter always runs l } ``` -### 5.11 Test Debugging +### Test Debugging `[DEBUG-FEATURES-TESTS]` | Feature | Protocol | Priority | |---|---|---| @@ -497,11 +362,11 @@ SharpLsp manages SSH tunnel setup transparently. The debug adapter always runs l | Debug entire test class/suite | DAP + `sharplsp/testDebug` | P2 | | Expecto/FsCheck test debugging | DAP + `sharplsp/testDebug` | P1 (F# parity) | -**Test host process attach**: `dotnet test` spawns a separate test host process (`testhost.exe`/`dotnet-testhost`). SharpLsp must attach to the child test host, not the parent `dotnet test` process. The `VSTEST_HOST_DEBUG=1` environment variable causes the test host to pause and wait for a debugger attach before executing tests. SharpLsp sets this variable in the test debug launch and attaches to the waiting process. +For test debugging, SharpLsp sets `VSTEST_HOST_DEBUG=1` and attaches to the waiting `testhost.exe`/`dotnet-testhost` child, not the parent `dotnet test` process. -### 5.12 Diagnostic Tools Integration +### Diagnostic Tools Integration `[DEBUG-FEATURES-DIAGNOSTICS]` -Debugging and diagnostics are complementary. SharpLsp integrates the .NET diagnostic tools (all MIT, dotnet/diagnostics v9.0.661903+) alongside the debugger. +SharpLsp exposes dotnet/diagnostics `9.0.661903+` tools through DAP custom messages: | Feature | Tool | DAP Integration | Priority | |---|---|---|---| @@ -512,17 +377,13 @@ Debugging and diagnostics are complementary. SharpLsp integrates the .NET diagno | Process dump on crash | `dotnet-dump` | Auto-triggered on unhandled exception | P3 | | Dump analysis | `dotnet-dump analyze` + SOS | `sharplsp/analyzeDump` custom request | P3 | -These are exposed as DAP custom events/notifications, surfaced in the editor as a diagnostics panel alongside the debugger. See `PROFILER-SPEC.md` for full profiler specification. - -**Note on musl/Alpine support**: `Microsoft.Diagnostics.NETCore.Client` (the backing library for all diagnostic tools) ships musl/Alpine builds as part of the dotnet/diagnostics release. This is a broader platform support story than netcoredbg. Diagnostic tools work on Alpine even when netcoredbg does not. +The editor presents these events in a diagnostics panel. See [`PROFILER-SPEC.md`](PROFILER-SPEC.md) for profiling behavior. ---- +`Microsoft.Diagnostics.NETCore.Client` ships musl/Alpine builds, so diagnostic tools MUST remain available there even when netcoredbg cannot start. -## 6. F# Debugging: First-Class Status +## F# Behavior `[DEBUG-FSHARP]` -F# debugging requires dedicated investment beyond what C# infrastructure provides automatically. - -### 6.1 F# Compiler PDB Gaps +### Compiler PDB Gaps `[DEBUG-FSHARP-PDB]` The F# compiler does not emit the following PDB tables that debuggers rely on: @@ -534,111 +395,57 @@ The F# compiler does not emit the following PDB tables that debuggers rely on: | `DynamicLocalVariables` | Dynamic-typed locals lose type info | Minor impact | **SharpLsp's approach:** + - Phase 4: implement heuristic PDB mapping for F# state machines via FCS sidecar symbol analysis - Phase 5: contribute `StateMachineMethod` table emission to dotnet/fsharp; until accepted, maintain SharpLsp-local patch or workaround -### 6.2 Computation Expression Stepping +### Computation Expression Stepping `[DEBUG-FSHARP-STEPPING]` F# `async { }` desugars into CPS (continuation-passing style) library calls. Stepping behavior reflects the desugared form, not the source. This is documented as a known limitation. -`task { }` (resumable state machines since F# 6) behaves significantly better due to inlining and more predictable PDB mapping. Prefer `task {}` over `async {}` in internal SharpLsp test code. +`task { }` resumable state machines use the C# reconstruction algorithm with F#-specific generated-name matching. Legacy CPS-based `async { }` reconstruction is best-effort and retains the physical stack when its continuation chain cannot be followed. Internal SharpLsp debug tests SHOULD prefer `task { }`. **Smart Step Into (Phase 5)**: Uses DAP `stepIn` with `targetId` to let users choose which function to step into when F# pipelines or function composition calls multiple functions on one line. -### 6.3 Discriminated Union Inspection +### Discriminated Union Inspection `[DEBUG-FSHARP-UNIONS]` -DUs compile to class hierarchies. Without F# semantic knowledge, a variable `Some 42` displays as `FSharpOption`1 { Tag = 1, Value = 42 }` instead of `Some(42)`. +DUs compile to class hierarchies. Without F# semantic knowledge, a variable `Some 42` displays as ``FSharpOption`1 { Tag = 1, Value = 42 }`` instead of `Some(42)`. SharpLsp addresses this in three layers: + 1. **Phase 4 DapRouter**: queries FCS sidecar for DU type metadata; rewrites `variables` response display values to F# syntax 2. **Phase 5 Debug Sidecar**: native DU-aware `variables` formatting via FCS sidecar channel 3. **Longer term**: contribute `[DebuggerDisplay]` attribute emission in F# compiler for DU cases -### 6.4 F# Mailbox Processor Debugging +### Mailbox Processor Inspection `[DEBUG-FSHARP-MAILBOX]` + +For `MailboxProcessor<'Msg>`, SharpLsp exposes: -`MailboxProcessor<'Msg>` actors are a common F# pattern. SharpLsp exposes: - Current message queue depth as a pseudo-variable in the variables panel (Phase 5) - Ability to inspect pending messages (Phase 5, best-effort) -### 6.5 F# Expression Evaluation +### Expression Evaluation `[DEBUG-FSHARP-EVALUATION]` -F# expression evaluation in the watch/immediate window: - Phase 4: limited to T1/T2 tier (same as C#; F# syntax not supported — user must use compiled IL names) - Phase 5: route `evaluate` requests to FCS sidecar for F# expression compilation, then evaluate via `ICorDebugEval` ---- - -## 7. Known Gaps and Closure Strategy - -### 7.1 Async Call Stack (Phase 4 partial, Phase 5 complete) - -**Gap:** netcoredbg shows physical call stack only. - -**Closure:** DapRouter + C# sidecar enrichment (§5.4.1). Phase 4 ships best-effort. Phase 5 ships full reconstruction. - -### 7.2 Expression Evaluation (Phase 4 limited, Phase 5 full) - -**Gap:** netcoredbg fails on LINQ, complex lambdas. - -**Closure:** Phase 5 Roslyn ScriptingWorkspace → ICorDebugEval pipeline. - -### 7.3 DebuggerDisplay/TypeProxy (Phase 4 emulated, Phase 5 native) - -**Gap:** netcoredbg does not render `[DebuggerDisplay]`, `[DebuggerTypeProxy]`, or `[DebuggerBrowsable]`. - -**Closure:** Phase 4 DapRouter emulation via C# sidecar evaluation. Phase 5 Debug Sidecar implements natively (same as SharpDbg). - -### 7.4 Process Attach Reliability (Phase 4 improved, Phase 5 fixed) - -**Gap:** netcoredbg `attach` mode returns `0x80070057` error (issue #205). - -**Closure:** SharpLsp contributes fix upstream. DapRouter implements retry with exponential backoff. Phase 5 Debug Sidecar uses `DbgShim.RegisterForRuntimeStartup` for reliable race-free attach. - -### 7.5 macOS ARM64 (Phase 4 fixed, Phase 5 native) - -**Gap:** Samsung does not ship macOS ARM64 binaries for netcoredbg. - -**Closure:** SharpLsp CI builds netcoredbg from source for `darwin-arm64`. Phase 5 Debug Sidecar is managed .NET 9 code — no native compilation issues on ARM64. - -### 7.6 musl/Alpine (Phase 4 worked around, Phase 5 native) - -**Gap:** netcoredbg SIGSEGV on musl due to CoreCLR `EnsureStackSize` overrunning musl's fixed 1.5MB thread stack (dotnet/runtime#103741). This is a CoreCLR bug, not a netcoredbg bug. - -**Closure:** SharpLsp CI maintains a musl-linked netcoredbg build with patched stack size pre-reservation. Contribute fix to dotnet/runtime. Phase 5 Debug Sidecar runs as managed code; the musl issue affects the C++ ICorDebug shim layer, which ClrDebug wraps but does not eliminate. Monitor dotnet/runtime#103741 for upstream fix. +## Gap Closure `[DEBUG-GAPS]` -### 7.7 Logpoints (Phase 4 emulated, Phase 5 native) - -**Gap:** netcoredbg has no logpoint support. - -**Closure:** DapRouter emulation ships in Phase 4. Phase 5 implements native zero-pause logpoints. - -### 7.8 Edit and Continue (cross-platform, Phase 5+) - -**Gap:** .NET 8+ runtime supports EnC on Linux/macOS, but no open-source client generates delta files for these platforms. - -**Closure:** SharpLsp uses Hot Reload (`MetadataUpdater.ApplyUpdate`) which is fully cross-platform. Classic EnC is explicitly out of scope until an upstream open-source delta generator exists. SharpLsp will adopt immediately if/when that gap closes. - -### 7.9 Return Value Display (Phase 5) - -**Gap:** netcoredbg does not show method return values on step-over. - -**Closure:** Phase 5 Debug Sidecar captures return values via `ICorDebugILFrame::GetReturnValueForILOffset` and synthesizes a `returnValue` pseudo-variable in the `variables` response under a dedicated `Return Value` scope (per DAP 1.67+ `returnValue` presentation hint). - -### 7.10 Data Breakpoints (Phase 5) - -**Gap:** netcoredbg does not support data breakpoints. - -**Closure:** Phase 5 Debug Sidecar implements via field value polling on `StepComplete` events or hardware watchpoints via platform-specific APIs where available. - -### 7.11 F# PDB Tables (Phase 4 heuristic, Phase 5 contribution) - -**Gap:** F# compiler does not emit `StateMachineMethod` or `StateMachineHoistedLocalScopes` PDB tables. - -**Closure:** Phase 4 uses FCS sidecar heuristics. Phase 5 contributes PDB table emission to dotnet/fsharp; maintains fallback heuristics indefinitely. - ---- - -## 8. Security Considerations +| Area | Phase Four | Phase Five or later | +|---|---|---| +| Async stacks | Best-effort DapRouter and C# sidecar enrichment per [DEBUG-FEATURES-STACK-ASYNC] | Direct continuation traversal | +| Expression evaluation | netcoredbg T1/T2 | Roslyn `ScriptingWorkspace` to `ICorDebugEval` | +| Debugger attributes | DapRouter emulates `[DebuggerDisplay]` | Native `[DebuggerDisplay]`, `[DebuggerTypeProxy]`, and `[DebuggerBrowsable]` | +| Attach error `0x80070057` | Retry with exponential backoff and contribute issue #205 upstream | Race-free `DbgShim.RegisterForRuntimeStartup` | +| macOS ARM64 | CI-built `darwin-arm64` netcoredbg | Managed sidecar | +| musl/Alpine SIGSEGV | CI build patches stack-size pre-reservation; track dotnet/runtime#103741 | Keep the patch while the wrapped C++ ICorDebug shim remains affected | +| Logpoints | DapRouter evaluate/log/continue emulation | Native zero-visible-pause implementation | +| Cross-platform EnC | Use `MetadataUpdater.ApplyUpdate` Hot Reload | Classic EnC remains out of scope until an open-source delta generator exists | +| Return values | Unavailable | `ICorDebugILFrame::GetReturnValueForILOffset` exposed in a `Return Value` scope with DAP `returnValue` presentation hint | +| Data breakpoints | Unavailable | Field polling on `StepComplete`, or hardware watchpoints where available | +| F# PDB tables | FCS heuristics | Contribute missing tables to dotnet/fsharp and retain fallback heuristics | + +## Security Considerations `[DEBUG-SECURITY]` - The debug adapter runs as the same user as the target process; SharpLsp does not elevate privileges - Remote debugging SSH keys are user-managed; SharpLsp does not store credentials @@ -647,9 +454,7 @@ F# expression evaluation in the watch/immediate window: - `dotnet-dump` output may contain sensitive heap data; SharpLsp stores dumps in user-specified paths only - `ICorDebugEval` expression evaluation executes arbitrary code in the debuggee — scope is limited to the current debug session; no cross-session execution ---- - -## 9. Performance Targets +## Performance Targets `[DEBUG-PERFORMANCE]` | Metric | Target | |---|---| @@ -665,41 +470,32 @@ F# expression evaluation in the watch/immediate window: | Attach to running process | <3s | | DapRouter proxy overhead (added latency) | <5ms per message | ---- - -## 10. Dependencies +## Dependencies `[DEBUG-DEPENDENCIES]` | Dependency | Version | License | Use | |---|---|---|---| -| [netcoredbg](https://github.com/Samsung/netcoredbg) | 3.1.3-1062+ | MIT | Phase 4 debug adapter | +| [netcoredbg](https://github.com/Samsung/netcoredbg) | 3.2.0-1092 | MIT | Phase 4 debug adapter | | [ClrDebug](https://github.com/lordmilko/ClrDebug) | 0.3.4+ | MIT | Phase 5 managed ICorDebug wrapper | | [Microsoft.Diagnostics.DbgShim](https://www.nuget.org/packages/Microsoft.Diagnostics.DbgShim) | 9.0.661903+ | MIT | DbgShim for runtime discovery | | [Microsoft.Diagnostics.NETCore.Client](https://www.nuget.org/packages/Microsoft.Diagnostics.NETCore.Client) | 9.0.661903+ | MIT | EventPipe / diagnostics IPC | -| [Microsoft.CodeAnalysis.CSharp.Scripting](https://www.nuget.org/packages/Microsoft.CodeAnalysis.CSharp.Scripting) | 5.3.0+ | MIT | Expression compilation for C# eval | +| [Microsoft.CodeAnalysis.CSharp.Scripting](https://www.nuget.org/packages/Microsoft.CodeAnalysis.CSharp.Scripting) | 5.6.0 | MIT | Expression compilation; keep aligned with `.config/dotnet/common.props` | | [FSharp.Compiler.Service](https://www.nuget.org/packages/FSharp.Compiler.Service) | 43.12+ | MIT | F# expression compilation + DU analysis | | DAP specification | 1.71.0 | CC-BY 4.0 | Protocol reference | ---- - -## 11. Reference Documents +## Reference Documents `[DEBUG-REFERENCES]` - [Debug Adapter Protocol Specification 1.71.0](https://microsoft.github.io/debug-adapter-protocol/specification) -- [DAP Changelog](https://microsoft.github.io/debug-adapter-protocol/changelog.html) - [Samsung/netcoredbg — GitHub](https://github.com/Samsung/netcoredbg) -- [netcoredbg Features Wiki](https://github.com/Samsung/netcoredbg/wiki/Features) -- [netcoredbg Issue Tracker](https://github.com/Samsung/netcoredbg/issues) - [ClrDebug — Managed ICorDebug Wrappers](https://github.com/lordmilko/ClrDebug) - [SharpDbg — C# DAP Debugger](https://github.com/MattParkerDev/sharpdbg) - [ICorDebug Interface — Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/core/unmanaged-api/debugging/icordebug/icordebug-interface) - [Microsoft.Diagnostics.DbgShim NuGet](https://www.nuget.org/packages/Microsoft.Diagnostics.DbgShim/) - [.NET Hot Reload — MetadataUpdater](https://learn.microsoft.com/en-us/dotnet/api/system.reflection.metadata.metadataupdater) - [dotnet/diagnostics — GitHub](https://github.com/dotnet/diagnostics) -- [Microsoft.Diagnostics.NETCore.Client docs](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/microsoft-diagnostics-netcore-client) - [F# Debug Emit Guide](https://fsharp.github.io/fsharp-compiler-docs/debug-emit.html) - [dotnet/fsharp#12000 — StateMachineMethod PDB table](https://github.com/dotnet/fsharp/issues/12000) - [dotnet/runtime#103741 — musl SIGSEGV in netcoredbg](https://github.com/dotnet/runtime/issues/103741) - [dotnet/runtime#12409 — Linux EnC support (closed)](https://github.com/dotnet/runtime/issues/12409) - [Samsung/netcoredbg#214 — Cross-platform EnC](https://github.com/Samsung/netcoredbg/issues/214) -- [Samsung/netcoredbg#201 — musl SIGSEGV](https://github.com/Samsung/netcoredbg/issues/201) - [SHARPLSP-SPEC.md](./SHARPLSP-SPEC.md) — parent specification - [PROFILER-SPEC.md](./PROFILER-SPEC.md) — performance profiling specification diff --git a/docs/specs/DEFINITION-SPEC.md b/docs/specs/DEFINITION-SPEC.md index 57ba1860..dc22b5bc 100644 --- a/docs/specs/DEFINITION-SPEC.md +++ b/docs/specs/DEFINITION-SPEC.md @@ -1,24 +1,16 @@ -# Go to Definition Specification +# Go to Definition Specification `[DEFINITION-NAVIGATION]` **Parent:** [SHARPLSP-SPEC.md](SHARPLSP-SPEC.md) -## 1. Overview +## Overview `[DEFINITION-OVERVIEW]` Go to Definition navigates the user from a symbol usage to its declaration site. SharpLsp implements `textDocument/definition` ([LSP 3.17 §3.17.4](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition)), `textDocument/typeDefinition` ([§3.17.7](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_typeDefinition)), `textDocument/declaration` ([§3.17.3](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_declaration)), and `textDocument/implementation` ([§3.17.8](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_implementation)) for both C# and F# as equal first-class citizens. -All four navigation methods are **P0** (launch blocker) and target Phase 2 delivery. +## LSP Protocol `[DEFINITION-PROTOCOL]` -## 2. LSP Protocol +All four methods accept their corresponding `DefinitionParams`, `TypeDefinitionParams`, `DeclarationParams`, or `ImplementationParams`, each containing a `TextDocumentIdentifier` and `Position`. -### 2.1 textDocument/definition - -``` -method: textDocument/definition -params: DefinitionParams { - textDocument: TextDocumentIdentifier - position: Position -} -``` +### `textDocument/definition` `[DEFINITION-PROTOCOL-DEFINITION]` ```typescript result: Definition | DefinitionLink[] | null @@ -38,43 +30,19 @@ interface DefinitionLink { - `DefinitionLink[]` when the server advertises `definitionProvider: { linkSupport: true }` and the client supports it — provides richer origin/target ranges for peek preview. - `null` when no definition can be resolved (unresolved symbol, error recovery). -### 2.2 textDocument/typeDefinition - -``` -method: textDocument/typeDefinition -params: TypeDefinitionParams { - textDocument: TextDocumentIdentifier - position: Position -} -``` +### `textDocument/typeDefinition` `[DEFINITION-PROTOCOL-TYPE]` Same response shape as `textDocument/definition`. Navigates to the type of the symbol at the cursor rather than the symbol itself. For example, on a variable `var x = new Foo()`, go-to-definition navigates to the constructor; go-to-type-definition navigates to `class Foo`. -### 2.3 textDocument/declaration - -``` -method: textDocument/declaration -params: DeclarationParams { - textDocument: TextDocumentIdentifier - position: Position -} -``` +### `textDocument/declaration` `[DEFINITION-PROTOCOL-DECLARATION]` Same response shape. Navigates to the declaration site (interface member, partial declaration, abstract method) rather than the implementation. -### 2.4 textDocument/implementation - -``` -method: textDocument/implementation -params: ImplementationParams { - textDocument: TextDocumentIdentifier - position: Position -} -``` +### `textDocument/implementation` `[DEFINITION-PROTOCOL-IMPLEMENTATION]` Same response shape. Navigates from an interface member or abstract/virtual method to all concrete implementations. Returns `Location[]` when multiple implementations exist. -## 3. Request Routing +## Request Routing `[DEFINITION-ROUTING]` All four definition-family requests are **semantic** requests. The Rust host routes them to the appropriate sidecar based on document language. @@ -84,13 +52,15 @@ All four definition-family requests are **semantic** requests. The Rust host rou | 2 | Rust host | Checks salsa cache for matching `(uri, version, position, method)` | | 3 | Rust host | On cache miss, dispatches to C# sidecar (Roslyn) or F# sidecar (FCS) via IPC | | 4 | Sidecar | Resolves symbol at position, finds definition location(s) | -| 5 | Rust host | Caches result, returns LSP response to client | +| 5 | Rust host | Records the query result in salsa and returns the LSP response | The Rust host MAY use tree-sitter to pre-validate the position (e.g., reject whitespace, comments, string literals) and short-circuit with `null` before dispatching to the sidecar. -## 4. C# Implementation (Roslyn) +Implementations: [semantic.rs](../../src/sharplsp/src/semantic.rs) and [syntax.rs](../../src/sharplsp/src/syntax.rs). + +## C# Implementation (Roslyn) `[DEFINITION-CSHARP]` -### 4.1 textDocument/definition +### `textDocument/definition` `[DEFINITION-CSHARP-DEFINITION]` 1. Obtain `Document` from the current `Solution` snapshot for the given URI. 2. Get the source text and convert `(line, character)` to an absolute position via [`SourceText.Lines.GetPosition()`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.text.textlinecollection.getposition). @@ -101,28 +71,28 @@ The Rust host MAY use tree-sitter to pre-validate the position (e.g., reject whi 7. For each resolved symbol, extract source locations from [`ISymbol.Locations`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.isymbol.locations) where `Location.IsInSource` is true. 8. Map each `Location` back to `(filePath, line, character)` via the location's `SourceSpan` and `SyntaxTree`. -### 4.2 textDocument/typeDefinition +### `textDocument/typeDefinition` `[DEFINITION-CSHARP-TYPE]` 1. Steps 1–4 as above. 2. Get the type via [`SemanticModel.GetTypeInfo()`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.semanticmodel.gettypeinfo). 3. Use `TypeInfo.Type` (or `TypeInfo.ConvertedType` as fallback). -4. Navigate to the type symbol's `Locations` as in §4.1 step 7. +4. Navigate to the type symbol's `Locations` as in step seven of [DEFINITION-CSHARP-DEFINITION]. -### 4.3 textDocument/declaration +### `textDocument/declaration` `[DEFINITION-CSHARP-DECLARATION]` -1. Steps 1–6 as in §4.1. +1. Resolve the symbol through steps one through six of [DEFINITION-CSHARP-DEFINITION]. 2. For the resolved symbol, find the declaration that is an interface member or partial declaration: - If the symbol is an override, navigate to the base virtual/abstract member via `IMethodSymbol.OverriddenMethod` or `IPropertySymbol.OverriddenProperty`. - If the symbol implements an interface member, navigate to the interface member via [`ISymbol.FindImplementationForInterfaceMember()`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.isymbol) (reverse lookup via `INamedTypeSymbol.Interfaces`). - If the symbol is a partial method/class, navigate to the defining partial declaration via [`IMethodSymbol.PartialDefinitionPart`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.imethodsymbol.partialdefinitionpart). -### 4.4 textDocument/implementation +### `textDocument/implementation` `[DEFINITION-CSHARP-IMPLEMENTATION]` -1. Steps 1–6 as in §4.1. +1. Resolve the symbol through steps one through six of [DEFINITION-CSHARP-DEFINITION]. 2. Use [`SymbolFinder.FindImplementationsAsync()`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.findusages.symbolfinder.findimplementationsasync) to find all concrete implementations. 3. Return `Location[]` with one entry per implementation. -### 4.5 Special Cases +### Special Cases `[DEFINITION-CSHARP-CASES]` | Symbol at Cursor | definition | typeDefinition | declaration | implementation | |---|---|---|---|---| @@ -139,16 +109,18 @@ The Rust host MAY use tree-sitter to pre-validate the position (e.g., reject whi | Metadata symbol (no source) | Decompiled source (P1) | Decompiled source (P1) | Same as definition | N/A | | Implicitly declared symbol | Generated source (if available) | Type definition | Same as definition | N/A | -### 4.6 Metadata and Decompiled Source Navigation +### Metadata and Decompiled Source Navigation `[DEFINITION-CSHARP-METADATA]` When a symbol's definition is in metadata (referenced assembly, NuGet package) rather than source: 1. **Phase 2 (MVP):** Return `null` — no navigation for metadata symbols. 2. **Phase 3 (P1):** Use [ICSharpCode.Decompiler](https://github.com/icsharpcode/ILSpy) to decompile the containing type, write it to a temporary file, and return a `Location` pointing to the decompiled source. Use the custom `sharplsp/decompileSource` method to serve decompiled content on demand. -## 5. F# Implementation (FCS) +Implementation: [DefinitionResolver.cs](../../src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs). + +## F# Implementation (FCS) `[DEFINITION-FSHARP]` -### 5.1 textDocument/definition +### `textDocument/definition` `[DEFINITION-FSHARP-DEFINITION]` 1. Get `FSharpCheckFileResults` for the document via `FSharpChecker.CheckFileInProject()`. 2. Call [`GetDeclarationLocation(line, col, lineText, names)`](https://fsharp.github.io/fsharp-compiler-docs/) to obtain the declaration location. @@ -157,7 +129,7 @@ When a symbol's definition is in metadata (referenced assembly, NuGet package) r - `FindDeclResult.DeclNotFound(reason)` — return `null`. - `FindDeclResult.ExternalDecl(assembly, fullName)` — decompilation target (Phase 3). -### 5.2 textDocument/typeDefinition +### `textDocument/typeDefinition` `[DEFINITION-FSHARP-TYPE]` 1. Get `FSharpCheckFileResults`. 2. Call `GetSymbolUseAtLocation(line, col, lineText, names)` to obtain `FSharpSymbolUse`. @@ -167,18 +139,18 @@ When a symbol's definition is in metadata (referenced assembly, NuGet package) r - For `FSharpEntity`: use the entity itself. 4. Navigate to the type's declaration range. -### 5.3 textDocument/declaration +### `textDocument/declaration` `[DEFINITION-FSHARP-DECLARATION]` 1. Same as definition for most F# symbols (F# does not have partial classes). 2. For interface implementations, navigate to the interface member declaration. -### 5.4 textDocument/implementation +### `textDocument/implementation` `[DEFINITION-FSHARP-IMPLEMENTATION]` 1. Use `GetSymbolUseAtLocation()` to find the symbol. 2. For abstract members in abstract classes or interfaces, search the project for implementing types. 3. Return `Location[]` for each implementation found. -### 5.5 F#-Specific Cases +### F#-Specific Cases `[DEFINITION-FSHARP-CASES]` | Symbol at Cursor | Behavior | |---|---| @@ -190,9 +162,11 @@ When a symbol's definition is in metadata (referenced assembly, NuGet package) r | Module function | Navigate to the `let` binding | | Pattern binding (`let (x, y) = ...`) | Navigate to the binding site | -## 6. Cross-Language Navigation `[DEFINITION-CROSSLANG]` +Implementation: [FSharpWorkspace.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspace.fs). -When a C# project references an F# project (or vice versa), go-to-definition crosses the language boundary. Each engine sees the *other* language only as a compiled assembly — Roslyn's `MSBuildWorkspace` has no F# language service, and FCS does not resolve a `` to a C# project — so the mechanism is **metadata-as-source**: wire the referenced project's built output DLL into the resolving engine, then decompile the target type to a navigable location. This is what mature IDEs do for a compiled cross-language reference, and it needs no cross-sidecar symbol index. +## Cross-Language Navigation `[DEFINITION-CROSSLANG]` + +When a C# project references an F# project or vice versa, each engine sees the other language as a compiled assembly. Cross-language navigation therefore wires the referenced output DLL into the resolving engine and decompiles the target type to a metadata-as-source location; it requires no cross-sidecar symbol index. | Scenario | Approach | |---|---| @@ -201,9 +175,9 @@ When a C# project references an F# project (or vice versa), go-to-definition cro Both directions decompile through the shared `MetadataDecompiler` (`SharpLsp.Sidecar.Common`), and the referenced project must be built (its output DLL must exist) for resolution to succeed. Requirement: navigating from a use site in one language onto a symbol defined in the other resolves to a decompiled metadata-as-source location for that symbol's type. -**Not yet implemented (P2, Phase 4):** *source-to-source* cross-language navigation — landing in the original `.fs`/`.cs` file rather than decompiled metadata — which requires the Rust host to maintain a cross-sidecar symbol index. +Source-to-source cross-language navigation into the original `.fs` or `.cs` file is not implemented; it requires a Rust-host cross-sidecar symbol index. -## 7. Caching Strategy +## Caching Strategy `[DEFINITION-CACHE]` Definition results are cached via the [salsa](https://salsa-rs.github.io/salsa/) incremental computation database in the Rust host. @@ -214,9 +188,9 @@ Definition results are cached via the [salsa](https://salsa-rs.github.io/salsa/) The `method` component distinguishes between `definition`, `typeDefinition`, `declaration`, and `implementation` results for the same position. -The Rust host SHOULD cache the most recent result per document per method and return it immediately if the position and version match. Stale requests for superseded document versions MUST be cancelled. +The salsa query returns its memoized result when document, method, position, and version inputs match. Stale requests for superseded document versions MUST be cancelled. No second navigation cache is permitted. -## 8. Performance Requirements +## Performance Requirements `[DEFINITION-PERFORMANCE]` | Metric | Target | Measurement | |---|---|---| @@ -226,7 +200,7 @@ The Rust host SHOULD cache the most recent result per document per method and re | Tree-sitter pre-validation | <1ms | Whitespace/comment/literal rejection | | Find implementations (100 impls) | <500ms | Time to enumerate all implementations | -## 9. Error Handling +## Error Handling `[DEFINITION-ERRORS]` | Condition | Response | |---|---| @@ -235,14 +209,14 @@ The Rust host SHOULD cache the most recent result per document per method and re | Symbol resolution fails | Return `null` | | Symbol is in metadata (no source, Phase 2) | Return `null` | | Symbol is in metadata (Phase 3+) | Return decompiled source location | -| Sidecar crashes during request | Return `null`, trigger crash recovery (see SHARPLSP-SPEC §5) | +| Sidecar crashes during request | Return `null` and trigger [SIDECAR-RECOVERY](SIDECAR-LIFECYCLE-SPEC.md) | | Multiple partial definitions | Return `Location[]` with all partial sites | Definition requests MUST NOT block, hang, or return errors to the client. On any failure, return `null`. -## 10. Wire Types (IPC) +## Wire Types (IPC) `[DEFINITION-IPC]` -### 10.1 Request +### Request `[DEFINITION-IPC-REQUEST]` Reuses `PositionRequest` shared with hover: @@ -256,7 +230,7 @@ public class PositionRequest } ``` -### 10.2 Response +### Response `[DEFINITION-IPC-RESPONSE]` ```csharp [MessagePackObject] @@ -278,7 +252,7 @@ public class LocationListResult } ``` -### 10.3 IPC Methods +### IPC Methods `[DEFINITION-IPC-METHODS]` | IPC Method | LSP Method | Response Type | |---|---|---| @@ -286,21 +260,3 @@ public class LocationListResult | `textDocument/typeDefinition` | `textDocument/typeDefinition` | `LocationResult` or `LocationListResult` | | `textDocument/declaration` | `textDocument/declaration` | `LocationResult` or `LocationListResult` | | `textDocument/implementation` | `textDocument/implementation` | `LocationListResult` | - -## 11. Competitive Parity Matrix - -| Feature | VS | CDK | Rider | SharpLsp Target | Priority | -|---|---|---|---|---|---| -| Go to definition (in-source) | ✓ | ✓ | ✓ | ✓ | P0 | -| Go to definition (metadata) | ✓ | ✓ | ✓ | ✓ | P1 | -| Go to type definition | ✓ | ✓ | ✓ | ✓ | P0 | -| Go to declaration | ✓ | ✓ | ✓ | ✓ | P0 | -| Go to implementation | ✓ | ✓ | ✓ | ✓ | P0 | -| Go to base member | ✓ | ✗ | ✓ | ✓ | P1 | -| Partial class navigation | ✓ | ✓ | ✓ | ✓ | P0 | -| Cross-language (C#↔F#) | ✗ | ✗ | ✓* | ✓ | P2 | -| Decompiled source navigation | ✓ | ✓ | ✓ | ✓ | P1 | -| Source generator output navigation | ✓ | ✓ | ✗ | ✓ | P2 | -| Peek definition (editor-side) | ✓ | ✓ | ✓ | ✓ (via DefinitionLink) | P0 | - -*\* Rider supports both languages but via proprietary code, not LSP.* diff --git a/docs/specs/DESIGN-SYSTEM.md b/docs/specs/DESIGN-SYSTEM.md index 293e9233..a52d6b3e 100644 --- a/docs/specs/DESIGN-SYSTEM.md +++ b/docs/specs/DESIGN-SYSTEM.md @@ -1,173 +1,120 @@ -# Design System +# [WEB-DESIGN] Design System -SharpLsp's visual identity and component guidelines. All colors were generated via random color wheel selection — zero hand-picked "designer" colors, zero LLM defaults. +## [WEB-DESIGN-PRINCIPLES] Principles -## Color Palette +- Use strong type hierarchy, whitespace, borders, and restrained surfaces to organize content. +- Use green as the only brand accent. Do not use purple, gradients, glows, decorative noise, or competing accent colors. +- Keep shadows functional and infrequent: menus and major product imagery only. +- Name classes for what an element is, minimize class count, and reuse existing components. +- Store colors and shared dimensions in custom properties. Component rules consume tokens rather than hardcoded colors. -### Generation Method +## [WEB-DESIGN-CSS] CSS Architecture -Primary hue selected by RNG from 0-359 color wheel (excluding 240-330 to avoid purple/magenta). Accent hue offset by a random triadic interval. Neutrals are desaturated tints of the primary. +The site has three CSS layers, loaded in this order: -### Primary — Hue 151 (Teal-Green) +1. [`styles.css`](../../src/website/src/assets/css/styles.css) — tokens, reset/base rules, navigation, buttons, shared headings, and footer. +2. [`pages.css`](../../src/website/src/assets/css/pages.css) — homepage, blog index, releases, grids, cards, and page-specific composition. +3. [`prose.css`](../../src/website/src/assets/css/prose.css) — long-form docs, blog posts, release notes, and documentation navigation. -| Token | Hex | Usage | -|-------|-----|-------| -| `--color-primary-300` | `#84d6ae` | Hover backgrounds, light accents | -| `--color-primary-400` | `#49d491` | Secondary buttons, links on dark bg | -| `--color-primary-500` | `#19d078` | **Primary brand color**, buttons, links | -| `--color-primary-600` | `#14a35e` | Hover state for primary actions | -| `--color-primary-700` | `#0f7f49` | Active/pressed states, dark accents | +Shared primitives belong in `styles.css`; page composition belongs in `pages.css`; rendered Markdown and its supporting article/docs components belong in `prose.css`. Do not duplicate rules across layers. -### Accent — Hue 16 (Burnt Sienna) +## [WEB-DESIGN-COLOR] Color -| Token | Hex | Usage | -|-------|-----|-------| -| `--color-accent-400` | `#c67456` | Hover state for accent elements | -| `--color-accent-500` | `#b54f2a` | **Accent color**, callouts, badges | -| `--color-accent-600` | `#8c3d20` | Hover state for accent actions | +Light and dark themes use the same semantic tokens. `data-theme="dark"` on `` supplies the dark values and `color-scheme` informs browser controls. -### Neutrals +| Token | Light | Dark | Purpose | +|---|---:|---:|---| +| `--color-bg` | `#f6f7f7` | `#0d110f` | Page canvas | +| `--color-surface` | `#ffffff` | `#131916` | Cards and menus | +| `--color-surface-subtle` | `#eef0ef` | `#19211d` | Quiet grouping and hover states | +| `--color-surface-strong` | `#dee1e0` | `#25302a` | Stronger neutral surface | +| `--color-text` | `#161c19` | `#f1f4f2` | Primary text | +| `--color-muted` | `#58625d` | `#aab4af` | Supporting text | +| `--color-soft` | `#78827d` | `#87938d` | De-emphasized text | +| `--color-border` | `#d4dad7` | `#28332d` | Standard dividers | +| `--color-border-strong` | `#aeb8b3` | `#46554d` | Emphasized boundaries | +| `--color-primary` | `#0f7f49` | `#49d491` | Links, focus, labels, primary actions | +| `--color-primary-hover` | `#09663a` | `#84d6ae` | Primary hover state | +| `--color-primary-soft` | `#dcefe5` | `#183a29` | Selected and quiet accent surfaces | +| `--color-on-primary` | `#ffffff` | `#07110b` | Text on primary | +| `--color-code` | `#101613` | `#080c0a` | Code-block surface | +| `--color-code-text` | `#e7ece9` | `#e7ece9` | Code-block text | -| Token | Hex | Usage | -|-------|-----|-------| -| `--color-neutral-50` | `#f6f7f7` | Page background (light) | -| `--color-neutral-100` | `#eef0ef` | Card/surface background (light) | -| `--color-neutral-200` | `#dee1e0` | Borders (light) | -| `--color-neutral-300` | `#c4c9c6` | Disabled text, subtle borders | -| `--color-neutral-400` | `#8b928e` | Muted text, placeholders | -| `--color-neutral-500` | `#6c7370` | Secondary text | -| `--color-neutral-600` | `#48504c` | Body text (dark mode) | -| `--color-neutral-700` | `#2a312e` | Headings (dark mode), borders (dark) | -| `--color-neutral-800` | `#161c19` | Surface background (dark) | -| `--color-neutral-900` | `#0d110f` | Page background (dark) | +## [WEB-DESIGN-TYPE] Typography and Icons -### Semantic +Use system fonts only. The UI stack is `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`; the code stack is `"SFMono-Regular", "Cascadia Code", "Liberation Mono", Consolas, monospace`. -| Token | Hex | Usage | -|-------|-----|-------| -| `--color-success` | `#249c64` | Success states, passing tests | -| `--color-warning` | `#e29d12` | Warnings, deprecation notices | -| `--color-error` | `#c72e23` | Errors, breaking changes | -| `--color-info` | `#277cb9` | Informational callouts | +Body copy is `1rem/1.65`. Display headings use responsive `clamp()` sizing, tight negative letter spacing, and compact line height. Long-form prose uses a more relaxed `1.78` line height. -## Typography +Do not request web fonts or external icon fonts. Use existing local assets, text symbols, or small accessible inline SVGs with `currentColor` for interface icons. -### Font Stack +## [WEB-DESIGN-SPACING] Spacing and Shape -```css -font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Helvetica Neue", Arial, sans-serif; -``` +Spacing follows a 4px base scale: -System fonts only. No external font requests. Instant rendering. +| Token | Value | +|---|---:| +| `--space-1` | `0.25rem` | +| `--space-2` | `0.5rem` | +| `--space-3` | `0.75rem` | +| `--space-4` | `1rem` | +| `--space-6` | `1.5rem` | +| `--space-8` | `2rem` | +| `--space-12` | `3rem` | +| `--space-16` | `4rem` | -### Monospace Stack +Radii are `0.35rem`, `0.65rem`, and `1rem` through `--radius-sm`, `--radius-md`, and `--radius-lg`. Prefer borders over elevation. `--shadow-sm` and `--shadow-lg` are neutral shadows, never colored glows. -```css -font-family: "SF Mono", "Cascadia Code", "Fira Code", Consolas, - "Liberation Mono", Menlo, monospace; -``` +## [WEB-DESIGN-LAYOUT] Layout -### Scale +| Context | Token | Limit | +|---|---|---:| +| Main shell | `--max-width` | `1120px` | +| Docs and article frame | `--content-width` | `56rem` | +| Long-form reading measure | `--reading-width` | `46rem` | +| Docs sidebar | `--sidebar-width` | `16rem` | +| Site header | `--header-height` | `4rem` | -| Element | Size | Weight | Line Height | -|---------|------|--------|-------------| -| `h1` | 2rem | 700 | 1.2 | -| `h2` | 1.4rem | 700 | 1.3 | -| `h3` | 1.1rem | 600 | 1.4 | -| Body | 1rem | 400 | 1.7 | -| Small / Muted | 0.925rem | 400 | 1.6 | -| Code | 0.875rem | 400 | 1.6 | +Main page sections center within the shell and retain fluid side gutters. Article titles, media, code, and tables use the full editorial frame; paragraphs, lists, quotes, and secondary headings use the narrower reading measure. The docs sidebar collapses below `1024px` so it never crushes the prose column. Images are responsive by default, and wide code blocks, diagrams, and tables scroll within their own bounds rather than widening the page. -## Spacing +## [WEB-DESIGN-COMPONENTS] Components -All spacing uses a 4px base unit. Prefer multiples: 4, 8, 12, 16, 24, 32, 48, 64. +Primary and secondary actions use `.button` with `.primary` or `.secondary`; `.nav-button` shares the same control geometry. Controls have a minimum height of `2.75rem` (44px), a visible border or fill, and a clear hover/focus state. -| Token | Value | Usage | -|-------|-------|-------| -| `--space-1` | 0.25rem (4px) | Tight gaps, inline padding | -| `--space-2` | 0.5rem (8px) | Button padding, small gaps | -| `--space-3` | 0.75rem (12px) | Card padding, nav items | -| `--space-4` | 1rem (16px) | Standard spacing | -| `--space-6` | 1.5rem (24px) | Section gaps | -| `--space-8` | 2rem (32px) | Page padding | -| `--space-12` | 3rem (48px) | Section padding | -| `--space-16` | 4rem (64px) | Major section breaks | +Cards use a neutral surface, one-pixel border, restrained radius, and content-driven spacing. Hover may strengthen the border but must not add movement or spectacle. Grids use `minmax(0, 1fr)` where content could otherwise overflow. -## Border Radius +## [WEB-DESIGN-PROSE] Prose Contract -| Token | Value | Usage | -|-------|-------|-------| -| `--radius-sm` | 3px | Code inline, small badges | -| `--radius-md` | 6px | Buttons, inputs | -| `--radius-lg` | 8px | Cards, code blocks | +Every documentation page, blog post, and other long-form rendered body must use the `.prose` wrapper. Markdown typography must never rely on unscoped element selectors. -## Layout +Within `.prose`: -### Max Widths +- headings, paragraphs, lists, links, quotes, media, tables, and code receive the canonical reading styles; +- links are visibly underlined and use `--color-primary`; +- inline code and code blocks remain visually distinct and horizontally safe; +- tables are scrollable, and media never exceeds the content width; +- article callouts, author metadata, related content, and docs navigation use the supporting rules in `prose.css`. -| Context | Width | -|---------|-------| -| Content area | 1100px | -| Blog / prose | 700px | -| Docs sidebar | 240px | +Page-level grids and marketing card styles must not leak into `.prose`. Do not recreate prose styling in templates or `pages.css`. -### Breakpoints +## [WEB-DESIGN-RESPONSIVE] Responsive Behavior -| Name | Width | Behavior | -|------|-------|----------| -| Mobile | ≤768px | Single column, collapsed nav | -| Desktop | >768px | Multi-column, full nav | +Design mobile-first: content order, meaning, and actions must work in a single column without hover. `768px` is the primary responsive boundary; at and below it: -## Components +- navigation becomes an explicit menu with stacked links and actions; +- multi-column workflows, reasons, releases, blog cards, and language sections collapse to one column; +- featured posts return to normal card flow; +- primary action rows stack to full-width controls where needed; +- the docs sidebar becomes an off-canvas panel opened by a full-width menu control; +- prose and shell gutters reduce to `1rem`. -### Buttons +At `380px`, proof items become one column and dense release metadata stacks. New components must remain usable at 320px without horizontal page overflow. -```html -Primary Action -Secondary Action -``` +## [WEB-DESIGN-ACCESSIBILITY] Accessibility -- Primary: `--color-primary-500` background, white text -- Secondary: transparent background, border, current text color -- All buttons: 6px radius, 600 weight, 0.75rem/1.75rem padding - -### Feature Cards - -```html -
-

Title

-

Description

-
-``` - -- 1px border using `--color-border` -- 8px radius -- 1.5rem padding -- Surface background - -### Code Blocks - -- Background: `--color-code-bg` -- 1px border -- 6px radius -- Syntax highlighting via Prism (eleventy-plugin-syntaxhighlight) - -## Dark Mode - -Dark mode is toggled via `data-theme="dark"` on ``. The theme toggle persists to `localStorage`. - -Every color token must have both light and dark values defined in `:root` and `[data-theme="dark"]` respectively. Never hardcode hex values outside CSS custom properties. - -## Favicon - -SVG favicon at `/assets/favicon.svg`. The `site.json` data file drives the `` tag via the theme's `base.njk` template. - -## Rules - -1. **No purple.** Not even a little. Not even "it's more of a violet." No. -2. **No external font/icon CDN requests.** System fonts, inline SVGs. -3. **Name CSS classes after what the element IS**, not what section it's in. -4. **Minimize CSS classes.** Consolidate where possible. -5. All colors via CSS custom properties. Zero hardcoded hex in component styles. -6. Mobile-first: single column is the default, multi-column is the enhancement. +- Preserve semantic HTML, logical heading order, and meaningful link/control labels. +- Preserve the skip link and the `3px` `:focus-visible` outline with a `3px` offset. +- Interactive targets must be at least 44px in the constrained dimension and remain keyboard operable. +- Do not communicate state by color alone. Maintain readable contrast in both themes. +- Decorative SVGs are hidden from assistive technology; meaningful images and icons require accessible text. +- Honor `prefers-reduced-motion`; essential information must never depend on animation or hover. diff --git a/docs/specs/DIAGNOSTICS-SPEC.md b/docs/specs/DIAGNOSTICS-SPEC.md index ae227061..5e77dfa0 100644 --- a/docs/specs/DIAGNOSTICS-SPEC.md +++ b/docs/specs/DIAGNOSTICS-SPEC.md @@ -1,72 +1,48 @@ -# DIAGNOSTICS-SPEC +# [DIAG-SPEC] Diagnostics Specification -Diagnostics are the core feedback loop for developers. SharpLsp must surface all compiler errors, warnings, and analyzer diagnostics across the entire solution in real-time, matching Visual Studio's Solution-Wide Error Analysis (SWEA) from day one — **without ever lying about compilation state**. +SharpLsp MUST surface compiler errors, warnings, and analyzer diagnostics across the solution without reporting stale compilation state. -## 1. Architecture +## [DIAG-ARCHITECTURE] Architecture -SharpLsp uses the **LSP 3.17 pull-diagnostics model with workspace refresh**, mirroring `Microsoft.CodeAnalysis.LanguageServer` (the engine behind C# Dev Kit). This is the only architecture that produces correct diagnostics during workspace load. +SharpLsp uses LSP 3.17 pull diagnostics: the Rust host answers editor pulls through Roslyn/FCS sidecars, and sidecar workspace changes trigger `workspace/diagnostic/refresh`. -``` -Editor ←→ Rust LSP Host ←→ C#/F# Sidecar (Roslyn / FCS) - ↑ ↑ ↑ - Problems workspace/diagnostic Workspace.RegisterWorkspaceChangedHandler - window ←refresh notifs DocumentDiagnosticsService (per-doc) - textDocument/ (no eager solution scan — ever) - diagnostic←pull -``` +Implementations: [diagnostics.rs](../../src/sharplsp/src/diagnostics.rs), [pull_diagnostics.rs](../../src/sharplsp/src/pull_diagnostics.rs), and the [full-stack diagnostics tests](../../src/sharplsp/tests/e2e_modules/diagnostics_full_stack.rs). -### 1.1 The Pull + Refresh Cycle +### [DIAG-ARCHITECTURE-PULL-REFRESH] Pull and Refresh Cycle -**SharpLsp never proactively asserts diagnostics.** It does not push errors during workspace load, because at that moment Roslyn cannot tell the truth — NuGet may be restoring, source generators are lazy, cross-project `CompilationReference`s are still resolving. Pushing during this window produces phantom CS0246/CS0234 errors that contradict `dotnet build`. SharpLsp does not lie. +SharpLsp MUST NOT push errors during workspace load while NuGet restore, source generators, or cross-project `CompilationReference` resolution is incomplete; doing so can produce phantom CS0246/CS0234 errors. Instead: -1. **Workspace open**: Rust host opens the workspace in the sidecar. Sidecar runs the NuGet restore gate (see §6) BEFORE creating `MSBuildWorkspace`. Once the workspace is created, the sidecar subscribes to `Workspace.RegisterWorkspaceChangedHandler` and seeds a monotonic `global_state_version: u64`. +1. **Workspace open**: Rust host opens the workspace in the sidecar. Sidecar runs [DIAG-RESTORE] before creating `MSBuildWorkspace`. Once the workspace is created, the sidecar subscribes to `Workspace.RegisterWorkspaceChangedHandler` and seeds a monotonic `global_state_version: u64`. 2. **Server advertises pull**: capabilities include `diagnosticProvider.workspaceDiagnostics: true` and `interFileDependencies: true`. -3. **Editor pulls**: editor sends `textDocument/diagnostic` (per file) and/or `workspace/diagnostic` (whole workspace) on its own schedule. Each request includes any `previousResultId` it has cached. -4. **Sidecar answers per-document**: for each pull, the sidecar calls `Project.GetCompilationAsync().GetSemanticModel(tree).GetDiagnostics()` (and `CompilationWithAnalyzers` for analyzer diagnostics) for **just the requested document(s)**. Roslyn's lazy compilation transparently forces topological resolution of the requested project's dependencies. -5. **Result identity**: response carries `resultId = "{project_version}:{doc_version}:{global_state_version}"`. If the editor's `previousResultId` matches, the server returns `DiagnosticReport.Unchanged` (per LSP 3.17) and skips re-computation. -6. **Refresh on change**: any sidecar-side `WorkspaceChanged` event (`ProjectAdded`, `ProjectReloaded`, `SolutionChanged`, `DocumentChanged`, restore completion) bumps `global_state_version` and emits a `diagnostics/refresh` IPC notification. Rust host coalesces these via a 2000ms debounced batch (matching Roslyn LSP's `AsyncBatchingWorkQueue`) and sends LSP `workspace/diagnostic/refresh` to the editor. The editor re-pulls — diagnostics converge to truth. +3. **Editor pulls**: editor sends `textDocument/diagnostic` (per file) and/or `workspace/diagnostic` (whole workspace) on its own schedule. A request may carry the opaque `previousResultId` returned earlier; the SharpLsp extension MUST NOT retain diagnostic results. +4. **Host queries salsa**: the Rust host evaluates the per-document diagnostic salsa query. On a memoized hit it reuses the query value; on a miss it asks the sidecar to call `Project.GetCompilationAsync().GetSemanticModel(tree).GetDiagnostics()` and `CompilationWithAnalyzers` for only the requested document, then supplies the result to salsa. +5. **Result identity**: response carries `resultId = "{project_version}:{doc_version}:{global_state_version}"`. If `previousResultId` matches the current salsa query identity, the host returns `DiagnosticReport.Unchanged` and skips IPC and semantic analysis. +6. **Refresh on change**: any sidecar-side `WorkspaceChanged` event (`ProjectAdded`, `ProjectReloaded`, `SolutionChanged`, `DocumentChanged`, restore completion) bumps `global_state_version` and emits `diagnostics/refresh`. The Rust host updates the corresponding salsa inputs, coalesces refreshes for 2000ms, and sends LSP `workspace/diagnostic/refresh`; the editor then re-pulls. -This is the **only** way to give correct diagnostics during multi-second workspace loads. OmniSharp (event-driven push) and Roslyn LSP (pull + refresh) both refuse to assert correctness at any single instant; they converge via invalidation. SharpLsp does the same. +### [DIAG-ARCHITECTURE-SALSA] Salsa Ownership -### 1.2 Why no eager solution scan +Rust-host salsa is the only diagnostic memoization mechanism. Its inputs include document text and version, project version, `global_state_version`, and effective diagnostic configuration; its tracked query returns the per-document diagnostic report and result identity. -Earlier versions of this spec described a one-shot solution-wide scan on workspace load, followed by a "verification pass" that re-checked files with errors. **Both have been removed.** They are incompatible with not lying: +The extension and sidecars may retain protocol, UI, compiler-workspace, and process state, but MUST NOT retain diagnostic arrays, result lookups, compilation-result memoization, or other client-side/sidecar-local caches. A sidecar computes a document on a salsa miss and returns the value without storing a SharpLsp cache. Workspace and configuration changes update salsa inputs, and salsa dependency tracking performs invalidation; no LRU, map-backed memo table, or parallel ad-hoc cache is permitted. -- The eager scan iterates `Solution.Projects` and calls `GetCompilationAsync()` on each. The first compilation a consumer project produces — before its dependencies have been cached as `CompilationReference`s — is missing types and emits phantom CS0246s. Topological iteration only partially mitigates this; source generators and NuGet restore still produce wrong-then-right state transitions during load. -- The verification pass tried to repair stale diagnostics by sending `textDocument/didChange` with the same disk text and re-fetching. Roslyn's `WithDocumentText` creates a new immutable `Solution` snapshot, but it does not re-run source generators, re-resolve NuGet, or rebuild metadata references — the underlying compilation is still incomplete, so the same phantom errors come back. It was a band-aid on the wrong premise. +### [DIAG-ARCHITECTURE-EAGER-SCAN] No Eager Solution Scan -The pull model removes the failure mode entirely: there is no moment at which SharpLsp proactively claims a file has errors. The editor asks; SharpLsp answers with whatever Roslyn currently knows. When Roslyn learns more, the `global_state_version` bumps and the editor re-asks. +The server MUST NOT scan `Solution.Projects` eagerly with `GetCompilationAsync()` during load: consumer projects can compile before dependency `CompilationReference`s are ready, and source generators or restore can still be incomplete. It also MUST NOT simulate a verification pass by sending unchanged text through `textDocument/didChange`; `WithDocumentText` does not rebuild metadata references or generator state. Pull responses report the current snapshot, and a later `global_state_version` bump causes the editor to re-pull. -### 1.3 [DIAG-PUSH-GATE] Push Convergence Guarantee +### [DIAG-PUSH-GATE] Push Convergence Guarantee -Editors without pull support still receive `textDocument/publishDiagnostics` -pushes triggered by `didOpen`/`didChange`. Because a push asserts state until -the *next* push replaces it, the push pipeline must never let a result for -older text stand as the final published state. The Rust host therefore -version-gates every push: +Editors without pull support receive `textDocument/publishDiagnostics` pushes triggered by `didOpen`/`didChange`. Because a push persists until replaced, the Rust host version-gates every push: -1. Each `didOpen`/`didChange`/`didClose` for a document registers a new, - monotonically increasing **push generation** for that URI. -2. A completed sidecar fetch publishes **only if its generation is still the - newest** — a slower fetch for older text is dropped, never published. -3. A **failed** fetch for the newest generation is **retried** (1s interval, - bounded budget that outlasts a sidecar kill + respawn) until it publishes - or a newer generation supersedes it. Dropping it would strand the previous - publication — possibly an error set for text that no longer exists — on - screen forever. (Hardening found while investigating GitHub #160; that - issue's actual root cause was `_._` placeholder references poisoning FCS — - see [PKG-ASSETS-FS](PACKAGE-MAINTENANCE-SPEC.md).) -4. Generations are never reused: reusing a counter after `didClose` would let - an ancient in-flight fetch match a fresh generation and publish stale - results. +1. Each `didOpen`/`didChange`/`didClose` registers a monotonically increasing push generation for the document URI. +2. A completed sidecar fetch publishes only if its generation is still newest; older results are dropped. +3. A failed fetch for the newest generation is retried at 1s intervals for up to 120 attempts, until it publishes or a newer generation supersedes it. Dropping the fetch could leave the previous publication on screen indefinitely. +4. Generations are never reused after `didClose`, preventing an old in-flight fetch from matching a new document generation. -The guarantee: **the last publication for a document always reflects its -newest known text** — phantom diagnostics cannot outlive the edit that -resolved them. +The last publication for a document MUST reflect its newest known text. -### 1.4 Analysis Scope +### [DIAG-ARCHITECTURE-SCOPE] Analysis Scope | Mode | Scope | Default | Use Case | |------|-------|---------|----------| @@ -74,9 +50,9 @@ resolved them. | **Open files only** | Editor only pulls `textDocument/diagnostic` for documents it has opened | Optional | Editors that don't issue `workspace/diagnostic` | | **Per-project filter** | `workspace/diagnostic` partial-result handler restricts to filtered projects | Optional | Focus analysis on active development targets | -Solution-wide analysis is the default because developers need to see errors **everywhere**. The C# Dev Kit limitation is not that it lacks SWEA semantically — it serves `workspace/diagnostic` — but that VS Code's UI doesn't surface workspace diagnostics until the file is opened. SharpLsp's VS Code extension explicitly drives the workspace pull and renders results in the Problems panel before files are opened. This is the SWEA win. +Solution-wide analysis is the default. The VS Code extension explicitly drives the workspace pull and renders results in the Problems panel before files are opened. -## 2. Configuration +## [DIAG-CONFIG] Configuration ```toml # sharplsp.toml @@ -115,7 +91,7 @@ refresh_debounce_ms = 2000 auto_restore_on_open = true ``` -### 2.1 Project Filter +### [DIAG-CONFIG-PROJECT-FILTER] Project Filter The `project_filter` field accepts glob patterns matched against project names or relative paths: @@ -127,32 +103,15 @@ project_filter = ["MyApp.Core", "MyApp.Api", "MyApp.Tests.*"] When empty (default), every project in the solution is included. Per-document pulls (`textDocument/diagnostic`) are never filtered — the editor asked for that file specifically, so the server always answers. -### 2.2 Runtime Reconfiguration +### [DIAG-CONFIG-RELOAD] Runtime Reconfiguration Diagnostics settings are hot-reloadable via `workspace/didChangeConfiguration`. Changing `solution_wide_analysis`, `project_filter`, or `min_severity` bumps `global_state_version` and triggers `workspace/diagnostic/refresh` so the editor re-pulls under the new policy. -### 2.3 [ANALYZERS-MONOREPO-GATE] Static Analyzer Monorepo Gate - -SharpLsp-owned static analyzers are specified in -[DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md](DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md). -Unused-public-code analyzers for C# and F# run only when the workspace is -explicitly configured as a monorepo: - -```toml -[workspace] -repository_kind = "monorepo" - -[diagnostics.static_analyzers] -enabled = true -unused_public_symbols = true -``` - -The default `repository_kind` is `"standard"`, which disables unused-public-code -diagnostics even if ordinary compiler/analyzer diagnostics are enabled. +Static analyzer configuration and its monorepo-only gate are specified by [ANALYZERS-MONOREPO-GATE](DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md#analyzers-monorepo-gate-monorepo-gate). -## 3. Diagnostic Categories +## [DIAG-CATEGORIES] Diagnostic Categories -### 3.1 Compiler Diagnostics (P0) +### [DIAG-CATEGORIES-COMPILER] Compiler Diagnostics | Source | C# (Roslyn) | F# (FCS) | |--------|------------|----------| @@ -161,7 +120,7 @@ diagnostics even if ordinary compiler/analyzer diagnostics are enabled. | Missing references | `CS0246`, `CS0103`, ... | `FS0039`, ... | | Nullable warnings | `CS8600`–`CS8798` | N/A (F# uses `option`) | -### 3.2 Analyzer Diagnostics (P0) +### [DIAG-CATEGORIES-ANALYZER] Analyzer Diagnostics | Source | API | Examples | |--------|-----|----------| @@ -171,25 +130,19 @@ diagnostics even if ordinary compiler/analyzer diagnostics are enabled. | FSharp.Analyzers.SDK | Plugin-based analyzers | Community F# analyzers | | SharpLsp static analyzers | Solution-wide symbol/reference index | Monorepo-only unused public C#/F# code elements | -### 3.3 [ANALYZERS-UNUSED-PUBLIC] Monorepo-Only Unused Public Code - -SharpLsp reports unused public C# and F# symbols only when the workspace is -configured as a monorepo. The analyzer is solution-wide, uses compiler symbol -APIs rather than text matching, and reports through `workspace/diagnostic` -partial results. See -[DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md](DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md). +Monorepo-only unused-public-code behavior is specified by [ANALYZERS-UNUSED-PUBLIC](DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md#analyzers-unused-public-unused-public-code-elements). -### 3.4 Live Squiggles (P0) +### [DIAG-CATEGORIES-LIVE] Live Squiggles -Live diagnostics flow through the **pull + refresh cycle** described in §1.1: +Live diagnostics flow through [DIAG-ARCHITECTURE-PULL-REFRESH]: - **On document change**: editor's pull-diagnostic client sends `textDocument/diagnostic` after its own debounce. Sidecar's `LspWorkspaceManager` change handler bumps `global_state_version`, host emits debounced `workspace/diagnostic/refresh`, editor re-pulls anything else that may have been affected by inter-file dependencies. - **On project change**: sidecar's `Workspace.RegisterWorkspaceChangedHandler` fires for `ProjectReloaded` / `ProjectAdded`. Sidecar bumps `global_state_version` and signals `diagnostics/refresh`. - **On workspace load**: NO eager analysis. After NuGet restore + workspace open complete, the sidecar fires `diagnostics/refresh` once. The editor pulls — that pull is the first diagnostic computation, and it is correct because restore has finished. -## 4. LSP Protocol +## [DIAG-LSP] LSP Protocol -### 4.1 Server Capabilities +### [DIAG-LSP-CAPABILITIES] Server Capabilities ```json { @@ -203,7 +156,7 @@ Live diagnostics flow through the **pull + refresh cycle** described in §1.1: `workspaceDiagnostics: true` is mandatory — it is how the editor knows it can ask SharpLsp for solution-wide errors. `identifier: "sharplsp"` lets the editor distinguish SharpLsp's diagnostics from other servers. -### 4.2 Pull Model (PRIMARY: `textDocument/diagnostic`, `workspace/diagnostic`) +### [DIAG-LSP-PULL] Pull Model (`textDocument/diagnostic`, `workspace/diagnostic`) LSP 3.17 pull diagnostics is the **primary** model. The server returns whatever Roslyn currently knows for the requested document(s); it never preemptively asserts. @@ -242,19 +195,19 @@ Per-document request: } ``` -`resultId` format is `p:{project_version}|d:{doc_version}|g:{global_state_version}`. When the editor's `previousResultId` matches the current key for that document, the server returns `{ kind: "unchanged" }` (per LSP 3.17 §10.6.1) and skips both the IPC round-trip and the Roslyn semantic analysis. +`resultId` format is `p:{project_version}|d:{doc_version}|g:{global_state_version}`. When `previousResultId` matches the current salsa query identity, the host returns `{ kind: "unchanged" }` (LSP 3.17 §10.6.1) and skips both IPC and semantic analysis. Workspace request (`workspace/diagnostic`) is supported with partial-result streaming so large solutions don't block on a single response. -### 4.3 Refresh Notifications (`workspace/diagnostic/refresh`) +### [DIAG-LSP-REFRESH] Refresh Notifications (`workspace/diagnostic/refresh`) -When sidecar state changes invalidate cached diagnostics, the host sends: +When sidecar state changes update diagnostic salsa inputs, the host sends: ```json { "method": "workspace/diagnostic/refresh" } ``` -This tells the editor to discard its cached `previousResultId`s and re-pull. Refreshes are **debounced 2000ms** (matching `Microsoft.CodeAnalysis.LanguageServer`'s `AsyncBatchingWorkQueue`) — multiple workspace events within the debounce window collapse into one refresh. +This tells the editor that prior result identities are invalid and it must re-pull. The SharpLsp extension retains no diagnostic result data. Refreshes are debounced 2000ms, collapsing multiple workspace events within the window into one notification. Refresh triggers (sidecar → host IPC notification `diagnostics/refresh` carrying the new `global_state_version`): @@ -264,13 +217,13 @@ Refresh triggers (sidecar → host IPC notification `diagnostics/refresh` carryi - `.editorconfig` file change inside the solution - Analyzer reference added/removed -### 4.4 Push Model (FALLBACK: `textDocument/publishDiagnostics`) +### [DIAG-LSP-PUSH] Push Model (`textDocument/publishDiagnostics`) Push exists only as a fallback for editors that do not advertise `textDocument.diagnostic.dynamicRegistration` (i.e. older LSP clients that predate 3.17 pull). When push is the only option, the host treats every refresh trigger as a per-document publish, reusing the same per-document analysis pipeline. SharpLsp's VS Code extension always negotiates pull. Push fallback exists for editor coverage (some Vim plugins, older Eclipse JDT-LSP-style clients), not as the canonical path. -### 4.4 Severity Mapping +### [DIAG-LSP-SEVERITY] Severity Mapping | Roslyn Severity | LSP DiagnosticSeverity | |-----------------|----------------------| @@ -279,9 +232,9 @@ SharpLsp's VS Code extension always negotiates pull. Push fallback exists for ed | `Info` | 3 (Information) | | `Hidden` | 4 (Hint) | -## 5. Sidecar IPC Messages +## [DIAG-IPC] Sidecar IPC Messages -### 5.1 Request: `workspace/diagnostics` +### [DIAG-IPC-DOCUMENT-REQUEST] Request: `workspace/diagnostics` Per-document pull. Called by the Rust host in response to LSP `textDocument/diagnostic`. @@ -292,13 +245,13 @@ Payload (MessagePack): class DiagnosticsRequest { [Key(0)] string FilePath; - [Key(1)] string? PreviousResultId; // sidecar can short-circuit if unchanged + [Key(1)] string? PreviousResultId; // reserved for wire compatibility; host sends null } ``` -Response: `DiagnosticResult[]` (see §5.2) plus `ResultId` and a `Changed` flag. When `Changed = false`, the items array is empty and the host returns `{ kind: "unchanged" }` to the editor. +The sidecar always returns `DiagnosticResult[]` and MUST NOT memoize results or decide `unchanged`. The Rust host's [DIAG-ARCHITECTURE-SALSA] query owns result reuse, assigns `ResultId`, and returns the LSP `Changed`/`Unchanged` report. -### 5.2 Response: `DiagnosticResult[]` +### [DIAG-IPC-DOCUMENT-RESPONSE] Response: `DiagnosticResult[]` ```csharp [MessagePackObject] @@ -315,15 +268,15 @@ class DiagnosticResult } ``` -### 5.3 Workspace Pull: `workspace/diagnostics/pull` +### [DIAG-IPC-WORKSPACE-PULL] Workspace Pull: `workspace/diagnostics/pull` -Called by the Rust host in response to LSP `workspace/diagnostic`. The sidecar streams per-document results (one `WorkspaceDocumentDiagnosticReport` per document) so the editor sees results progressively. Results omit unchanged documents (matching `DiagnosticReport.Unchanged` semantics). +Called by the Rust host for salsa misses during LSP `workspace/diagnostic`. The host streams one `WorkspaceDocumentDiagnosticReport` per document; salsa hits produce `DiagnosticReport.Unchanged` without sidecar IPC. -The legacy `workspace/diagnostics/all` bulk RPC has been **removed**. It eagerly iterated every project and ran `GetCompilationAsync` synchronously, producing the phantom CS0246s described in §1.2. There is no replacement — workspace-wide analysis happens lazily via per-document pulls. +The legacy `workspace/diagnostics/all` bulk RPC MUST NOT be restored; workspace-wide analysis happens lazily through per-document pulls as specified by [DIAG-ARCHITECTURE-EAGER-SCAN]. -### 5.4 Notification: `diagnostics/refresh` +### [DIAG-IPC-REFRESH] Notification: `diagnostics/refresh` -Sidecar → host notification fired when any input invalidates cached diagnostics. Payload: +Sidecar → host notification fired when an input changes and the host must update diagnostic salsa inputs. Payload: ```csharp [MessagePackObject] @@ -336,103 +289,85 @@ class RefreshNotification The host coalesces refreshes via a 2000ms debounced batch and emits LSP `workspace/diagnostic/refresh`. -### 5.5 Notification: `workspace/initializationComplete` +### [DIAG-IPC-INITIALIZED] Notification: `workspace/initializationComplete` Sidecar → host notification fired exactly once after NuGet restore + `MSBuildWorkspace.OpenSolutionAsync` complete. The host forwards as the LSP custom notification `workspace/projectInitializationComplete` (matching `Microsoft.CodeAnalysis.LanguageServer`'s contract). Editors use this to dismiss "Loading projects…" UI. -## 6. NuGet Restore Gate +## [DIAG-RESTORE] NuGet Restore Gate -Phantom CS0246 for NuGet types is the most common false-positive class. SharpLsp mirrors `Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.ProjectDependencyHelper`: +Before workspace creation, SharpLsp applies this restore gate: 1. Before calling `MSBuildWorkspace.OpenSolutionAsync`, the sidecar inspects each project's `obj/project.assets.json`. 2. If `assets.json` is missing, older than the `.csproj`, or its `PackageReference` set differs from the `.csproj`, the sidecar shells `dotnet restore ` via a `DotnetCliHelper` equivalent. Restore progress is reported via LSP `$/progress` (work-done token established at workspace open). 3. Only after restore completes does the sidecar create `MSBuildWorkspace`. 4. Restore completion bumps `global_state_version` and triggers an initial `diagnostics/refresh`. -Without this gate, the editor's first pull happens against a workspace with unresolved `` items, producing CS0246/CS0234 for every NuGet type. The gate is non-optional — `dotnet restore` may take several seconds, but the alternative is a lie. +The gate is mandatory because unresolved `` items can produce CS0246/CS0234 diagnostics on the first pull. -## 7. Performance Targets +## [DIAG-PERFORMANCE] Performance Targets | Metric | Target | |--------|--------| -| Per-document pull (cached) | <5ms (returns `unchanged`) | +| Per-document pull (salsa memoized) | <5ms (returns `unchanged`) | | Per-document pull (cold) | <200ms p50, <500ms p95 | | Workspace pull, partial result for first document | <500ms after restore completes | | Workspace pull, full result for 50-project solution | <10s after restore completes | | Refresh debounce window | 2000ms (matches Roslyn LSP) | -| NuGet restore (cached / `assets.json` valid) | <100ms (gate skipped) | +| NuGet restore (`assets.json` valid) | <100ms (gate skipped) | | NuGet restore (cold) | bounded only by `dotnet restore` itself; surface via `$/progress` | -| Memory overhead (per-document caching) | <200MB additional for 50-project solution | - -## 8. Competitive Analysis - -**Legend:** VS = Visual Studio, CDK = C# Dev Kit, R = Rider. ✓ = the tool has this feature. - -| Feature | VS | CDK | R | SharpLsp | Priority | Phase | -|---|---|---|---|---|---|---| -| Compiler errors and warnings | ✓ | ✓ | ✓ | **P0** | P0 | 2 | -| Roslyn analyzer diagnostics | ✓ | ✓ | ✓ | **P0** | P0 | 2 | -| Solution-wide error analysis (SWEA) | ✓ | ✗ | ✓ | **P0 (default on)** | P0 | 2 | -| Unused using/open detection | ✓ | ✓ | ✓ | **P0** | P0 | 2 | -| Monorepo-only unused public code detection | ✗ | ✗ | ✓ | **P0** | P0 | 4 | -| Nullable reference analysis | ✓ | ✓ | ✓ | **P1** | P1 | 3 | -| Code style enforcement (.editorconfig) | ✓ | ✓ | ✓ | **P1** | P1 | 3 | -| Third-party NuGet analyzers | ✓ | ✓ | ✓ | **P1** | P1 | 4 | -| FSharp.Analyzers.SDK support | ✗ | ✗ | ✗ | **P1** | P1 | 4 | -| Code metrics (cyclomatic complexity) | ✓ | ✗ | ✓ | **P2** | P2 | 4 | -| Value tracking / data flow | ✓ | ✗ | ✓ | **P2** | P2 | 4 | -| IL inspection / viewer | ✓ | ✗ | ✓ | **P3** | P3 | 5 | -| Heap allocation viewer | ✗ | ✗ | ✓ | **P3** | P3 | 5 | - -Key differentiators: - -- **SWEA surfaced in Problems panel without opening files.** C# Dev Kit's underlying server (`Microsoft.CodeAnalysis.LanguageServer`) implements `workspace/diagnostic` correctly — the gap is the VS Code extension UX, which doesn't drive the workspace pull. SharpLsp's extension does, so SWEA actually works for the user. -- **Pull + refresh from day one.** SharpLsp ships LSP 3.17 pull diagnostics as the primary path. OmniSharp uses event-driven push (correct semantics, but every editor sees the convergence flicker). SharpLsp uses pull, so editors with cached `previousResultId`s avoid the flicker entirely. -- **No phantom errors.** SharpLsp's NuGet restore gate (§6) and pull-only model (§1.1) eliminate the false-positive class that haunts every other LSP-based .NET tool. - -## 9. Background Analysis Strategy - -### 9.1 Pull-driven, lazy by construction - -There is no background scan thread. Roslyn analysis happens **only when the editor pulls**. The `Microsoft.CodeAnalysis.LanguageServer` model proves this is sufficient: editors pull aggressively for visible documents, lazily for the rest, and the server amortizes computation across pulls. Adding a background scanner on top would either duplicate work or race with pulls. - -What replaces the old "background scan": - -- **Lazy compilation**: `Project.GetCompilationAsync()` is invoked on demand for the project of the document being pulled. Roslyn topologically resolves and caches dependency compilations as `CompilationReference`s. Subsequent pulls within the same `Solution` snapshot reuse the cache — the second pull on any file in the same project completes in milliseconds. -- **Caching by `resultId`**: per §4.2, repeat pulls for unchanged documents return `{ kind: "unchanged" }` without re-running Roslyn. The cache key includes `global_state_version`, so any workspace mutation invalidates the entire cache atomically. +| Memory overhead (salsa diagnostic memoization) | <200MB additional for 50-project solution | + +## [DIAG-SCOPE] Supported Scope + +| Capability | Priority | Phase | +|------------|----------|-------| +| Compiler errors and warnings | P0 | Two | +| Roslyn analyzer diagnostics | P0 | Two | +| Solution-wide error analysis, default enabled | P0 | Two | +| Unused using/open detection | P0 | Two | +| Monorepo-only unused public code detection | P0 | Four | +| Nullable reference analysis | P1 | Three | +| `.editorconfig` code-style enforcement | P1 | Three | +| Third-party NuGet analyzers | P1 | Four | +| FSharp.Analyzers.SDK support | P1 | Four | +| Code metrics | P2 | Four | +| Value tracking and data flow | P2 | Four | +| IL inspection | P3 | Five | +| Heap allocation viewing | P3 | Five | + +## [DIAG-ANALYSIS] Background Analysis Strategy + +### [DIAG-ANALYSIS-PULL] Pull-Driven Analysis + +There is no background scan thread. Roslyn analysis happens only when the editor pulls: + +- **Lazy compilation**: on a salsa miss, the sidecar invokes `Project.GetCompilationAsync()` for the requested document's project and returns the resulting diagnostics without retaining a SharpLsp memo table. +- **Salsa query identity**: per [DIAG-LSP-PULL], repeat pulls for unchanged documents return `{ kind: "unchanged" }` from the Rust-host salsa query without re-running Roslyn. The tracked inputs include `global_state_version`, so workspace mutations invalidate affected query values. - **Workspace event subscription**: the sidecar's `Workspace.RegisterWorkspaceChangedHandler` is the only active background work. It mutates `global_state_version` and emits `diagnostics/refresh`. It does not analyze anything itself. -### 9.2 Cancellation +### [DIAG-ANALYSIS-CANCELLATION] Cancellation - The Rust host cancels in-flight per-document IPC requests when the editor sends a fresh pull for the same document with a higher `previousResultId`-implied version (or a different `previousResultId`). - The sidecar passes the IPC `CancellationToken` straight into `GetSemanticModelAsync` / `GetAnalyzerSemanticDiagnosticsAsync`. - A `WorkspaceChanged` event mid-pull does not cancel the pull. The pull completes against its snapshot, returns its `resultId`, and the bumped `global_state_version` causes the next refresh to invalidate it. This matches `AbstractPullDiagnosticHandler`'s snapshot-isolation behavior in `dotnet/roslyn`. -### 9.3 Incremental updates +### [DIAG-ANALYSIS-INCREMENTAL] Incremental Updates When a file changes: - The host updates its VFS, sends `textDocument/didChange` IPC to the sidecar (which calls `_solution.WithDocumentText(...)`), and the sidecar emits `diagnostics/refresh` carrying only the affected project's IDs in `AffectedProjectIds`. - The host's debounced refresh queue collapses bursts; the LSP `workspace/diagnostic/refresh` notification fires once per debounce window. -- The editor re-pulls. Files unaffected by the change return `{ kind: "unchanged" }` cheaply because their `resultId` (which incorporates project version) hasn't moved. - -Roslyn's `Compilation` is immutable — there is no incremental analyzer state to manage on our side. Roslyn handles fork-and-cache internally. - -## 10. Truth Guarantees (No False Positives) +- The editor re-pulls. Files whose salsa inputs did not change return `{ kind: "unchanged" }` because their `resultId` remains valid. -**SharpLsp does not lie.** Every diagnostic shown to the developer must reflect Roslyn's current best understanding of the workspace. +## [DIAG-TRUTH] Truth Guarantees -### 10.1 What we promise +### [DIAG-TRUTH-GUARANTEES] Guarantees - If `dotnet build` succeeds with zero errors against the same source, the next pull (after refresh debounce + restore completion) returns zero Error-severity diagnostics. - A diagnostic in the Problems panel corresponds to a real Roslyn compiler or analyzer diagnostic from the current `Solution` snapshot. - A workspace mutation that changes a file's diagnostics produces an LSP `workspace/diagnostic/refresh` within 2000ms (the debounce window). Editors converge to truth one pull cycle after that. -### 10.2 What we do not promise +### [DIAG-TRUTH-LIMITS] Limits -- We do not promise that the **first** pull during workspace load is complete. NuGet restore may still be running for some projects; source generators may not yet have produced output. The pull will return whatever Roslyn knows at that instant — which after the §6 restore gate is correct for project-reference and NuGet types, but may be missing generator output. +- The first pull during workspace load may be incomplete. The response reflects the current snapshot; after [DIAG-RESTORE], project-reference and NuGet types are resolved, but source-generator output may still be missing. - The remedy for "incomplete but not wrong" is `workspace/diagnostic/refresh`. Generator output materializing fires a `WorkspaceChanged` event → refresh → re-pull → complete result. - -### 10.3 Why the previous "verification pass" is gone - -Earlier revisions of this spec mandated a low-priority verification pass that re-checked files with errors and cleared false positives. **It has been deleted.** The pass was based on a wrong premise: it assumed re-sending `textDocument/didChange` with the same disk text would cause Roslyn to re-resolve missing references. It does not — `Solution.WithDocumentText` invalidates only the per-document syntax tree, not the metadata-reference graph or the generator-driver state. The pass therefore re-fetched the same phantom errors. The pull + refresh model removes the pass's reason to exist: SharpLsp no longer asserts diagnostics until the editor pulls, so there is nothing stale to repair. diff --git a/docs/specs/DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md b/docs/specs/DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md index 1ae7f751..f4e8d9f2 100644 --- a/docs/specs/DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md +++ b/docs/specs/DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md @@ -1,30 +1,16 @@ -# DIAGNOSTICS-STATIC-ANALYZERS-SPEC +# Static Analyzer Specification `[ANALYZERS-STATIC]` -SharpLsp-owned static analyzers fill gaps left by compiler diagnostics, Roslyn -IDE analyzers, FSharpLint, and third-party analyzer packages. They are part of -the diagnostics pipeline, but they are solution-wide by design: an analyzer that -needs repository context must never pretend that an open-file-only answer is -complete. +SharpLsp-owned analyzers run through the diagnostics pipeline. Repository-aware analyzers MUST use the loaded solution graph, never present open-file analysis as complete. ## [ANALYZERS-GOAL] Goal -The first SharpLsp static analyzers detect unused public code elements in C# and -F# at the configured solution/repository boundary. +The analyzers detect unused public C# and F# elements at the configured solution boundary. In monorepo mode, public symbols with no references in the loaded graph can be reported because the repository is the declared API boundary. -These diagnostics exist because public code is normally treated as externally -reachable by compilers and ordinary IDE analyzers. In a monorepo, the repository -can be the real API boundary, so SharpLsp can report public surface area that is -not referenced anywhere in the loaded solution graph. - -The feature is opt-in for monorepos only. Standard repositories must not receive -unused-public-code diagnostics, because a public symbol may be consumed by an -unloaded external product, package consumer, plugin, or runtime integration. +Standard repositories MUST NOT receive unused-public diagnostics because unloaded external consumers may exist. ## [ANALYZERS-MONOREPO-GATE] Monorepo Gate -SharpLsp classifies the workspace from explicit configuration, never from -directory shape, project count, Git remotes, naming conventions, or solution -size. +SharpLsp classifies the workspace from explicit configuration, never from directory shape, project count, Git remotes, naming conventions, or solution size. ```toml [workspace] @@ -48,21 +34,13 @@ The unused-public-code analyzers run only when all of these are true: - `diagnostics.static_analyzers.enabled == true` - `diagnostics.static_analyzers.unused_public_symbols == true` -The default `repository_kind` is `"standard"`. This means the analyzer is off by -default even though `diagnostics.analyzers_enabled` remains on by default for -ordinary compiler and package analyzer diagnostics. +`repository_kind` defaults to `"standard"`, so unused-public analysis is off while ordinary compiler and package analyzers remain enabled. -Changing the monorepo gate or static analyzer settings via -`workspace/didChangeConfiguration` bumps `global_state_version` and triggers -`workspace/diagnostic/refresh`. +Changing the monorepo gate or static analyzer settings via `workspace/didChangeConfiguration` bumps `global_state_version` and triggers `workspace/diagnostic/refresh`. ### [ANALYZERS-CONFIG-IMPL] Implemented Configuration (F#) -The F# sidecar gate is live. The Rust host reads an `[analyzers]` table from -`sharplsp.toml` and pushes the flags to each sidecar via the `analyzers/configure` -request immediately after `workspace/open` (see -[config.rs](../../src/config.rs) `AnalyzersConfig` and -[main.rs](../../src/main.rs) `configure_analyzers`): +The F# sidecar gate is live. The Rust host reads an `[analyzers]` table from `sharplsp.toml` and pushes the flags to each sidecar via the `analyzers/configure` request immediately after `workspace/open` (see [config.rs](../../src/sharplsp/src/config.rs) `AnalyzersConfig` and [main.rs](../../src/sharplsp/src/main.rs) `configure_analyzers`): ```toml [analyzers] @@ -74,57 +52,34 @@ dead_code = true monorepo = false ``` -`analyzers/configure` carries a positional MessagePack payload -(`AnalyzerConfigRequest`: `[Key(0)] DeadCode`, `[Key(1)] Monorepo`). A sidecar -keeps the flags as mutable state across re-opens. This `[analyzers]` table is the -shipping schema; the richer `[workspace] repository_kind` / `[diagnostics.static_analyzers]` -form above is the forward-compatible target the loader will also accept. +`analyzers/configure` carries a positional MessagePack payload (`AnalyzerConfigRequest`: `[Key(0)] DeadCode`, `[Key(1)] Monorepo`). A sidecar keeps the flags as mutable state across re-opens. This `[analyzers]` table is the shipping schema; the richer `[workspace] repository_kind` / `[diagnostics.static_analyzers]` form above is the forward-compatible target the loader will also accept. ## [ANALYZERS-SOLUTION-SCOPE] Solution-Wide Scope -Static analyzer diagnostics are IDE-level workspace diagnostics. They are -computed from the complete loaded solution graph and surfaced through -`workspace/diagnostic` partial results. +Static analyzer diagnostics are IDE-level workspace diagnostics. They are computed from the complete loaded solution graph and surfaced through `workspace/diagnostic` partial results. -`textDocument/diagnostic` may include cached static analyzer diagnostics for the -requested file after a solution-wide snapshot has been computed. It must not -start a local-only unused-public-code analysis, because that would create false -positives for symbols referenced outside the open document. +`textDocument/diagnostic` may include salsa-memoized static analyzer diagnostics for the requested file after a solution-wide snapshot has been computed. It must not start a local-only unused-public-code analysis, because that would create false positives for symbols referenced outside the open document. -The initial implementation scope is every C# and F# project loaded from the -configured `.sln` or `.slnx`. If SharpLsp later supports multi-solution -workspaces, the analysis universe becomes every loaded project in the configured -workspace solution set. +The analysis scope is every loaded C# and F# project in the configured `.sln` or `.slnx`. ## [ANALYZERS-UNUSED-PUBLIC] Unused Public Code Elements -A public code element is unused when it has a declaration in the loaded -solution graph and no non-declaration semantic references anywhere in that same -graph. +A public code element is unused when it has a declaration in the loaded solution graph and no non-declaration semantic references anywhere in that same graph. -Declaration candidates are collected from compiler symbol APIs, not string -matching: +Declaration candidates are collected from compiler symbol APIs, not string matching: | Language | Candidate symbols | |---|---| | C# | Public named types, delegates, enums, records, interfaces, constructors, methods, properties, indexers, events, fields, operators, conversion operators, and extension methods | | F# | Public modules, types, union cases, record fields, values/functions, members, active patterns, delegates, interfaces, and members exposed through `.fsi` signature files | -For C#, "public" means symbols whose Roslyn accessibility makes them callable -from another assembly, including public members and protected/protected-internal -members on externally visible inheritable types. +For C#, "public" means symbols whose Roslyn accessibility makes them callable from another assembly, including public members and protected/protected-internal members on externally visible inheritable types. -For F#, implicit public accessibility counts as public unless the declaration is -hidden by `private`, `internal`, a signature file, or compiler visibility rules. -When a `.fsi` signature file exists, the signature file defines the public -surface and diagnostics are reported at the signature declaration when possible. +For F#, implicit public accessibility counts as public unless the declaration is hidden by `private`, `internal`, a signature file, or compiler visibility rules. When a `.fsi` signature file exists, the signature file defines the public surface and diagnostics are reported at the signature declaration when possible. -When an enclosing public type/module is already reported unused, nested public -members are suppressed in that diagnostic batch to avoid noisy cascades. +When an enclosing public type/module is already reported unused, nested public members are suppressed in that diagnostic batch to avoid noisy cascades. -The Rust tree-sitter indexes may prefilter declaration ranges and file scopes for -speed, but Roslyn/FCS symbol identity is the source of truth for every reported -diagnostic. +The Rust tree-sitter indexes may prefilter declaration ranges and file scopes for speed, but Roslyn/FCS symbol identity is the source of truth for every reported diagnostic. ## [ANALYZERS-REFERENCE-MODEL] Reference Model @@ -132,23 +87,17 @@ References must be semantic references: - C# uses Roslyn symbols and `SymbolFinder.FindReferencesAsync`. - F# uses FSharp.Compiler.Service parse/check results and symbol-use APIs. -- Cross-language references through project references are counted by metadata - identity where Roslyn and FCS expose a stable assembly/type/member identity. -- Generated code, `obj/`, `bin/`, package cache files, and metadata-only - assemblies are not diagnostic targets. +- Cross-language references through project references are counted by metadata identity where Roslyn and FCS expose a stable assembly/type/member identity. +- Generated code, `obj/`, `bin/`, package cache files, and metadata-only assemblies are not diagnostic targets. The following count as uses: -- Construction, invocation, member access, field/property/event access, and - delegate conversion. -- Inheritance, interface implementation, override binding, and attribute - application. -- Pattern matching, union-case construction, record construction/update, and - active-pattern use in F#. +- Construction, invocation, member access, field/property/event access, and delegate conversion. +- Inheritance, interface implementation, override binding, and attribute application. +- Pattern matching, union-case construction, record construction/update, and active-pattern use in F#. - References from test projects in the loaded solution. -Declaration syntax, XML documentation text, comments, and unbound identifier text -do not count as uses. +Declaration syntax, XML documentation text, comments, and unbound identifier text do not count as uses. ## [ANALYZERS-SUPPRESSION] Suppression And Known Entry Points @@ -156,24 +105,16 @@ The analyzer must support normal IDE suppression mechanisms: - `.editorconfig` severity for the SharpLsp diagnostic code. - C# `#pragma warning disable` and `SuppressMessageAttribute`. -- F# `#nowarn` for the SharpLsp diagnostic code where supported by the F# sidecar - mapping. +- F# `#nowarn` for the SharpLsp diagnostic code where supported by the F# sidecar mapping. - SharpLsp config entries for project/path exclusions. -The analyzer must also avoid known entry points and convention-bound public -surface: +The analyzer must also avoid known entry points and convention-bound public surface: -- Program entry points, top-level program artifacts, source-generated entry - points, and test framework entry points. -- Overrides and interface implementations when the base/interface contract is - outside the loaded repo graph. -- Symbols annotated with recognized framework/reflection preservation attributes - such as `DynamicallyAccessedMembers`, `DynamicDependency`, `JsonConstructor`, - dependency injection attributes, routing attributes, serializer attributes, or - JetBrains `PublicAPI`/`UsedImplicitly`. +- Program entry points, top-level program artifacts, source-generated entry points, and test framework entry points. +- Overrides and interface implementations when the base/interface contract is outside the loaded repo graph. +- Symbols annotated with recognized framework/reflection preservation attributes such as `DynamicallyAccessedMembers`, `DynamicDependency`, `JsonConstructor`, dependency injection attributes, routing attributes, serializer attributes, or JetBrains `PublicAPI`/`UsedImplicitly`. -The attribute list is configurable so teams can add framework-specific public -entry points without changing SharpLsp. +The attribute list is configurable so teams can add framework-specific public entry points without changing SharpLsp. ## [ANALYZERS-DIAGNOSTICS] Diagnostic Shape @@ -192,77 +133,50 @@ Public {kind} '{symbol}' has no references in the configured monorepo. ### [ANALYZERS-DEADCODE-SEVERITY] Severity (implemented, F#) -By project decision the F# dead-code analyzer (`SLSPF0101`) escalates severity in -monorepo mode — an unreferenced symbol in a declared monorepo is a hard error, not -a hint, because nothing outside the repo can be the missing consumer: +By project decision the F# dead-code analyzer (`SLSPF0101`) escalates severity in monorepo mode — an unreferenced symbol in a declared monorepo is a hard error, not a hint, because nothing outside the repo can be the missing consumer: | Mode | Private/internal dead code | Public dead code | |---|---|---| | `monorepo = false` | **Warning** | not reported (assumed external API) | | `monorepo = true` | **Error** | **Error** | -Reporting private/internal dead code (regardless of monorepo mode) extends beyond -[ANALYZERS-UNUSED-PUBLIC]: a private/internal symbol can never be reached from -outside its assembly, so its deadness is sound without the monorepo gate. +Reporting private/internal dead code (regardless of monorepo mode) extends beyond [ANALYZERS-UNUSED-PUBLIC]: a private/internal symbol can never be reached from outside its assembly, so its deadness is sound without the monorepo gate. ### [ANALYZERS-FSAC-PARITY] File-Local Analyzers (F#, FSAC parity) -The F# sidecar also runs two always-on file-local analyzers via FCS -`EditorServices`, surfaced as `Hint` diagnostics so editors grey the range and can -offer the matching code fix (parity with FsAutoComplete / Ionide): +The F# sidecar also runs two always-on file-local analyzers via FCS `EditorServices`, surfaced as `Hint` diagnostics so editors grey the range and can offer the matching code fix (parity with FsAutoComplete / Ionide): -| Code | Source rule | Message | -|---|---|---| -| `SLSPF0102` | `UnusedOpens.getUnusedOpens` | `Unused 'open' statement; safe to remove.` | -| `SLSPF0103` | `SimplifyNames.getSimplifiableNames` | `Redundant qualifier; '{name}' is sufficient here.` | +#### [ANALYZERS-FSAC-UNUSED-OPEN] Unused open diagnostics + +`UnusedOpens.getUnusedOpens` emits `SLSPF0102` with message `Unused 'open' statement; safe to remove.` + +#### [ANALYZERS-FSAC-SIMPLIFY-NAME] Simplifiable name diagnostics + +`SimplifyNames.getSimplifiableNames` emits `SLSPF0103` with message `Redundant qualifier; '{name}' is sufficient here.` These are independent of the monorepo gate and the `dead_code` flag. -Diagnostics include a stable symbol identity in `Diagnostic.data` so future code -actions can offer safe-delete, visibility reduction, or suppression insertion. +Diagnostics include stable symbol identity in `Diagnostic.data`. -The raw FCS findings (`open` ranges and `(range, relativeName)` simplifications) -are computed once in [FSharpLocalAnalysis.fs](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpLocalAnalysis.fs) -(`getFileAnalyzerFindings`) and shared by both the hint producer above and the -code fixes below, so the greyed range and the offered fix can never disagree. +The raw FCS findings (`open` ranges and `(range, relativeName)` simplifications) are computed once in [FSharpLocalAnalysis.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpLocalAnalysis.fs) (`getFileAnalyzerFindings`) and shared by both the hint producer above and the code fixes below, so the greyed range and the offered fix can never disagree. -#### [FS-CODEFIX-UNUSEDOPEN] "Remove unused open" code fix +#### [ANALYZERS-FSAC-CODEFIX-UNUSED-OPEN] "Remove unused open" code fix -The `textDocument/codeAction` handler turns each `SLSPF0102` finding overlapping -the request range into a `Remove unused open` quick fix -([FSharpCodeFixes.fs](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpCodeFixes.fs) -`removeUnusedOpenActions`). Resolving it deletes the whole `open` line — from the -start of its first line through the start of the line after its last — matching -FsAutoComplete. E2E: the F# sidecar IPC suite (`code action offers -remove-unused-open …`) and the VSIX suite (`F# LSP — Code Fixes`). +The `textDocument/codeAction` handler turns each `SLSPF0102` finding overlapping the request range into a `Remove unused open` quick fix ([FSharpCodeFixes.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpCodeFixes.fs) `removeUnusedOpenActions`). Resolving it deletes the whole `open` line — from the start of its first line through the start of the line after its last — matching FsAutoComplete. E2E: the F# sidecar IPC suite (`code action offers remove-unused-open …`) and the VSIX suite (`F# LSP — Code Fixes`). -#### [FS-CODEFIX-SIMPLIFYNAME] "Simplify name" code fix +#### [ANALYZERS-FSAC-CODEFIX-SIMPLIFY-NAME] "Simplify name" code fix -Each `SLSPF0103` finding overlapping the request range becomes a `Simplify name` -quick fix (`simplifyNameActions`). FCS reports the simplifiable `Range` as the -**redundant qualifier prefix including its trailing dot**, so the fix deletes that -span (e.g. `System.DateTime.MinValue` → `DateTime.MinValue` when `System` is open). -E2E: the IPC suite (`code action offers simplify-name …`) and the VSIX suite. +Each `SLSPF0103` finding overlapping the request range becomes a `Simplify name` quick fix (`simplifyNameActions`). FCS reports the simplifiable `Range` as the **redundant qualifier prefix including its trailing dot**, so the fix deletes that span (e.g. `System.DateTime.MinValue` → `DateTime.MinValue` when `System` is open). E2E: the IPC suite (`code action offers simplify-name …`) and the VSIX suite. -#### [FS-CODEFIX-INTERFACESTUB] "Implement interface" code fix +#### [ANALYZERS-FSAC-CODEFIX-INTERFACE-STUB] "Implement interface" code fix -A type-informed (not analyzer-driven) code action that completes the F# stub trio — -union cases, record fields, **interface members**. When the cursor is on an -`interface IFoo …` declaration with unimplemented members, FCS -`InterfaceStubGenerator` (`TryFindInterfaceDeclaration` → `GetImplementedMemberSignatures` -→ `FormatInterface`) generates `member _.X … = failwith "…"` stubs for the missing -members ([FSharpCodeActions.fs](../../sidecars/SharpLsp.Sidecar.FSharp/FSharpCodeActions.fs) -`tryGenerateInterfaceStub`, wired into `getCodeActions` Phase 4). E2E: the IPC suite -(`code action offers implement-interface stub …`) and the VSIX suite -(`F# LSP — Implement Interface`). +A type-informed code action handles missing **interface members**. When the cursor is on an `interface IFoo …` declaration with unimplemented members, FCS `InterfaceStubGenerator` (`TryFindInterfaceDeclaration` → `GetImplementedMemberSignatures` → `FormatInterface`) generates `member _.X … = failwith "…"` stubs for the missing members ([FSharpCodeActions.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpCodeActions.fs) `tryGenerateInterfaceStub`, wired into `getCodeActions` Phase 4). E2E: the IPC suite (`code action offers implement-interface stub …`) and the VSIX suite (`F# LSP — Implement Interface`). -## [ANALYZERS-PERFORMANCE] Performance And Caching +## [ANALYZERS-PERFORMANCE] Performance And Salsa Queries -Static analyzers are lower priority than compiler diagnostics. A -`workspace/diagnostic` request must stream compiler/analyzer diagnostics first -and static analyzer diagnostics as later partial results. +Static analyzers are lower priority than compiler diagnostics. A `workspace/diagnostic` request must stream compiler/analyzer diagnostics first and static analyzer diagnostics as later partial results. -Each sidecar owns a language-specific static analysis index keyed by: +Static-analysis memoization belongs exclusively to the Rust host's salsa database. Sidecars compute requested language results and MUST NOT retain a second result cache. Salsa query inputs are: - Solution snapshot identity. - Project version. @@ -270,9 +184,7 @@ Each sidecar owns a language-specific static analysis index keyed by: - `global_state_version`. - Static analyzer config hash. -Workspace changes invalidate only affected project indexes when possible. A -full invalidation is required when project references, analyzer config, -signature files, or workspace kind changes. +Workspace changes invalidate only affected project indexes when possible. A full invalidation is required when project references, analyzer config, signature files, or workspace kind changes. Targets: @@ -280,19 +192,14 @@ Targets: |---|---| | First static analyzer partial result | <2s after workspace initialization for a 50-project solution | | Full unused-public-code pass | <15s for a 50-project solution | -| Cached repeat workspace pull | <50ms before partial-result streaming completes | +| Salsa-memoized repeat workspace pull | <50ms before partial-result streaming completes | | Additional memory | <250MB for a 50-project solution | ## [ANALYZERS-TRUTH] Truth Guarantees The analyzer must prefer silence over false positives: -- If the workspace is not explicitly configured as a monorepo, return no - unused-public-code diagnostics. -- If a project is unloaded or failed to load, return no unused-public-code - diagnostics for symbols that could be referenced by that project. -- If cross-language identity cannot be proven for a symbol, do not report it as - unused. -- If the analyzer cannot distinguish a framework entry point from ordinary public - API, suppress the diagnostic and emit structured trace logging for future rule - tuning. +- If the workspace is not explicitly configured as a monorepo, return no unused-public-code diagnostics. +- If a project is unloaded or failed to load, return no unused-public-code diagnostics for symbols that could be referenced by that project. +- If cross-language identity cannot be proven for a symbol, do not report it as unused. +- If the analyzer cannot distinguish a framework entry point from ordinary public API, suppress the diagnostic and emit structured trace logging for future rule tuning. diff --git a/docs/specs/DISTRIBUTION-SPEC.md b/docs/specs/DISTRIBUTION-SPEC.md index e6911c14..2ccff44d 100644 --- a/docs/specs/DISTRIBUTION-SPEC.md +++ b/docs/specs/DISTRIBUTION-SPEC.md @@ -1,14 +1,8 @@ -# Distribution Specification +# [DIST-SPEC] Distribution Specification -This document is the canonical specification for how SharpLsp is distributed. -All statements below are normative requirements, not suggestions. +This is the normative specification for SharpLsp distribution. -Every section has a hierarchical ID per CLAUDE.md (`[GROUP-TOPIC]` / -`[GROUP-TOPIC-DETAIL]`, uppercase, hyphen-separated, never numbered). Code -that implements a section MUST reference its ID in a comment. Cross-references -inside this spec MUST use IDs, never numbers. - -## [DIST-COMPONENTS] +## [DIST-COMPONENTS] Required Components SharpLsp has three executable components. All three are REQUIRED and MUST be bundled in the VSIX. Missing any one of them puts activation into degraded mode with a user-facing error notification (see [DIST-FAILURE-UX]). @@ -20,31 +14,31 @@ SharpLsp has three executable components. All three are REQUIRED and MUST be bun All three are verified by Shipwright on every VS Code activation via `activationVerifies` in `shipwright.json`. -## [DIST-DEBUGGER-BUNDLE] +## [DIST-DEBUGGER-BUNDLE] Debugger Bundle -Debugging uses **netcoredbg** — the managed-code DAP adapter the `sharplsp-coreclr` debug type launches (`editors/vscode/src/debug.ts`, `SharpLspDebugAdapterFactory`). It is bundled in the VSIX so debugging works out of the box, mirroring how C# Dev Kit ships its own debugger. +Debugging uses **netcoredbg**, the managed-code DAP adapter launched for the `sharplsp-coreclr` debug type by `SharpLspDebugAdapterFactory` in `src/editors/vscode/src/debug.ts`. It is bundled in the VSIX. | Aspect | Requirement | |---|---| | Source | `Samsung/netcoredbg`, pinned to `3.2.0-1092`, MIT-licensed | -| Staging | `scripts/fetch-netcoredbg.sh ` downloads + extracts the upstream archive into `bin//netcoredbg/` (with an archive cache under `target/netcoredbg-cache/`); wired into `_stage-vsix-binary-only` and `_package-vsix` in the Makefile | +| Staging | `tools/vsix/fetch-netcoredbg.sh ` downloads and extracts the upstream archive into `bin//netcoredbg/` without retaining a download memo; wired into `_stage-vsix-binary-only` and `_package-vsix` in the Makefile | | Layout | `bin//netcoredbg/netcoredbg[.exe]` **plus** its sibling managed assemblies (`ManagedPart.dll`, `dbgshim.dll`, `Microsoft.CodeAnalysis*.dll`) — the whole directory ships, since the executable loads them | | Resolution | `getNetcoredbgCandidates(extensionPath)` prefers the bundled binary; scan order is user-setting (`sharplsp.debug.netcoredbgPath`) → **bundled** → common install paths → `PATH` | | Platform coverage | Upstream ships prebuilt binaries for `win32-x64`, `linux-x64`, `linux-arm64`, `darwin-arm64` only. On `win32-arm64` and `darwin-x64` the VSIX cannot bundle netcoredbg; debugging falls back to a `PATH` copy / the setting. The fetch script skips those platforms cleanly (exit 0). | Unlike the three [DIST-COMPONENTS], a missing netcoredbg degrades **only** the debugging feature (surfaced via an error toast pointing at the install), not whole-extension activation. -**Licensing.** netcoredbg (MIT, © 2017 Samsung Electronics Co., LTD) and every other bundled third-party component are acknowledged in [THIRD-PARTY-NOTICES.md](../../THIRD-PARTY-NOTICES.md); all bundled licenses are permissive and compatible with SharpLsp's MIT license. Bumping the pinned netcoredbg version MUST update `scripts/fetch-netcoredbg.sh` and the notices file in lockstep. +**Licensing.** netcoredbg (MIT, © 2017 Samsung Electronics Co., LTD) and every other bundled third-party component are acknowledged in [THIRD-PARTY-NOTICES.md](../../THIRD-PARTY-NOTICES.md); all bundled licenses are permissive and compatible with SharpLsp's MIT license. Bumping the pinned netcoredbg version MUST update `tools/vsix/fetch-netcoredbg.sh` and the notices file in lockstep. -## [DIST-RUNTIME-ACQUIRE] +## [DIST-RUNTIME-ACQUIRE] .NET SDK Acquisition -The sidecars are framework-dependent .NET assemblies that target `net10.0`. They require a .NET 10 **SDK** — not merely a runtime — because the C# sidecar runs an in-process MSBuild design-time build and locates MSBuild via `MSBuildLocator.QueryVisualStudioInstances(options)` (see `sidecars/SharpLsp.Sidecar.CSharp/MSBuildInstanceSelector.cs` and [DIST-SDK-DISCOVERY] for why the query is workspace-independent), which **only enumerates installed SDKs**. A machine with a runtime alone — or with only an older SDK such as the .NET 9 SDK — has no MSBuild whose Roslyn matches the bundled `Microsoft.CodeAnalysis`, so every project load fails (`FUSION_E_REF_DEF_MISMATCH`) or MSBuild cannot be located at all. SharpLsp therefore acquires the **SDK** automatically via Microsoft's [`ms-dotnettools.vscode-dotnet-runtime`](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.vscode-dotnet-runtime) extension (the .NET Install Tool) — the same mechanism used by C# Dev Kit, the C# extension, .NET MAUI, Unity, CMake, and Bicep. +The framework-dependent `net10.0` sidecars require a .NET 10 SDK, not merely a runtime. The C# sidecar performs an in-process MSBuild design-time build and `MSBuildLocator.QueryVisualStudioInstances(options)` enumerates installed SDKs; a runtime-only or older-SDK machine cannot provide matching MSBuild/Roslyn and project load fails with `FUSION_E_REF_DEF_MISMATCH` or no MSBuild. SharpLsp therefore acquires the SDK through Microsoft's [`ms-dotnettools.vscode-dotnet-runtime`](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.vscode-dotnet-runtime) extension. See `src/sidecars/SharpLsp.Sidecar.CSharp/MSBuildInstanceSelector.cs` and [DIST-SDK-DISCOVERY]. -> **Reference — how other extensions do this.** The .NET Install Tool exposes `dotnet.acquire` (local *runtime*), `dotnet.acquireGlobalSDK` (system-wide *SDK*), and `dotnet.findPath` (discover an existing install). C# Dev Kit ([`ms-dotnettools.csdevkit`](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit)) declares the tool via `extensionDependencies` in its `package.json`. Authoritative API documentation lives at . SharpLsp follows this exact pattern — there is no Anthropic / Nimblesite-specific mechanism here, and any future maintainer asking "how do other VS Code extensions install .NET silently?" should land on this section and the linked docs. +> The .NET Install Tool exposes `dotnet.acquire` for a local runtime, `dotnet.acquireGlobalSDK` for a system-wide SDK, and `dotnet.findPath` for discovery. Its API contract is documented at . **Hard rules:** -1. SharpLsp's [editors/vscode/package.json](../../editors/vscode/package.json) MUST declare `"extensionDependencies": ["ms-dotnettools.vscode-dotnet-runtime"]`. VS Code installs declared dependencies silently when SharpLsp is installed — no user prompt. +1. SharpLsp's [src/editors/vscode/package.json](../../src/editors/vscode/package.json) MUST declare `"extensionDependencies": ["ms-dotnettools.vscode-dotnet-runtime"]`. VS Code installs declared dependencies silently when SharpLsp is installed — no user prompt. 2. SharpLsp MUST explicitly activate the .NET Install Tool extension (`vscode.extensions.getExtension(...).activate()`) before invoking its commands. `extensionDependencies` activates it first, but the explicit await turns a missing/disabled dependency into a clear `[DIST-FAILURE-UX]` message instead of an opaque "command `dotnet.findPath` not found". 3. On every activation SharpLsp MUST call the `dotnet.acquireGlobalSDK` command exposed by the .NET Install Tool with the parameter shape mandated in [DIST-API-PARAMETERS]. The command returns `{ dotnetPath: string }` pointing at the `dotnet` executable of a system-wide SDK install. A global SDK install runs the platform installer and **may prompt for elevation** — that UI belongs to the .NET Install Tool, and is the unavoidable cost of providing MSBuild; SharpLsp never shows the elevation prompt itself. 4. Before `dotnet.acquireGlobalSDK`, SharpLsp MUST call `dotnet.findPath` with `mode: 'sdk'` and `versionSpecRequirement: 'greater_than_or_equal'` to skip acquisition when the user already has a compatible SDK (>= 10.0). The path returned by either call is the SDK SharpLsp uses. @@ -60,11 +54,11 @@ The sidecars are framework-dependent .NET assemblies that target `net10.0`. They Shipwright continues to verify sidecar startup via `verifyStartup: true`. With `DOTNET_ROOT` pointed at the SDK, the apphost finds the runtime, MSBuild loads, and the version probe succeeds. -## [DIST-SDK-DISCOVERY] +## [DIST-SDK-DISCOVERY] Workspace-Independent SDK Discovery The C# sidecar enumerates installed SDKs to pick the one whose Roslyn matches its bundled `Microsoft.CodeAnalysis` ([DIST-RUNTIME-ACQUIRE]). That enumeration MUST be **independent of the opened workspace**. MSBuildLocator resolves an SDK from a *working directory* via `hostfxr_resolve_sdk2`, which honours any `global.json` at or above that directory. The sidecar process inherits the workspace root as its working directory, so a naïve `MSBuildLocator.QueryVisualStudioInstances()` resolves the *workspace's* `global.json`. When that file pins a `version`/`rollForward` band with no installed match (e.g. Fantomas pins `10.0.100` on a box that has only `10.0.203`), `hostfxr_resolve_sdk2` throws `InvalidOperationException` ("A compatible .NET SDK was not found"). -Before this rule the throw was fatal: `Program.cs` caught it and called `Environment.Exit(1)` *before* the `READY:` handshake, so the Rust host saw "sidecar exited before READY" and restarted forever. That crash-loop broke every C#-sidecar request — including `solution/read`, which needs no MSBuild at all (it uses `Microsoft.VisualStudio.SolutionPersistence`), so the Solution Explorer failed even for **pure-F# solutions** that never touch Roslyn. Captured in issue #134. +Discovery failure before the `READY:` handshake can cause an endless sidecar restart loop and block MSBuild-free requests such as `solution/read`, including for pure-F# solutions. It MUST therefore follow the degraded path below (issue #134). **Hard rules:** @@ -75,11 +69,11 @@ Before this rule the throw was fatal: `Program.cs` caught it and called `Environ The one-shot startup hint emitted on the degraded path is a sanctioned sidecar stderr write per [DIST-CLEAN-OUTPUT] (alongside the Roslyn-mismatch hint) — it is actionable, level-appropriate, and fires at most once per process, never per request. **Implementation reference:** -- `sidecars/SharpLsp.Sidecar.CSharp/MSBuildInstanceSelector.cs` — `QueryInstalledSdks` (explicit `DiscoveryType.DotNetSdk` + neutral `WorkingDirectory`), `NewestInstancePath` fallback, `BuildDiscoveryFailedHint`; `Register` no longer calls `RegisterDefaults()`. -- `sidecars/SharpLsp.Sidecar.CSharp/Program.cs` — MSBuild registration failure logs and continues instead of `Environment.Exit(1)`. -- `sidecars/SharpLsp.Sidecar.CSharp.Tests/GlobalJsonSdkPinEndToEndTests.cs` — spawns the real sidecar apphost with a workspace whose `global.json` pins an uninstalled SDK and asserts it reaches `READY` and serves `solution/read`. +- `src/sidecars/SharpLsp.Sidecar.CSharp/MSBuildInstanceSelector.cs` — `QueryInstalledSdks` (explicit `DiscoveryType.DotNetSdk` + neutral `WorkingDirectory`), `NewestInstancePath` fallback, `BuildDiscoveryFailedHint`; `Register` no longer calls `RegisterDefaults()`. +- `src/sidecars/SharpLsp.Sidecar.CSharp/Program.cs` — MSBuild registration failure logs and continues instead of `Environment.Exit(1)`. +- `src/sidecars/SharpLsp.Sidecar.CSharp.Tests/GlobalJsonSdkPinEndToEndTests.cs` — spawns the real sidecar apphost with a workspace whose `global.json` pins an uninstalled SDK and asserts it reaches `READY` and serves `solution/read`. -## [DIST-API-PARAMETERS] +## [DIST-API-PARAMETERS] .NET Install Tool Parameters Every call SharpLsp makes to the .NET Install Tool MUST include all four required fields in the `IDotnetAcquireContext`: @@ -95,57 +89,46 @@ Every call SharpLsp makes to the .NET Install Tool MUST include all four require `dotnet.findPath` takes the same four required fields nested under `acquireContext` (no `installType`), plus `versionSpecRequirement: 'greater_than_or_equal'`. `dotnet.acquireGlobalSDK` takes them flat, plus `installType: 'global'`. -`architecture` is derived from Node's `process.arch` and mapped as: `x64` → `x64`, `arm64` → `arm64`, `ia32` → `x86`, default → `x64`. This mapping lives in `editors/vscode/src/dotnetRuntime.ts`. - -**Reasoning — why architecture is non-optional.** -The first SharpLsp v0.1.0 release omitted `architecture` from the `dotnet.findPath` payload. The .NET Install Tool rejected the request with `"The find path request was missing required information: a mode, version, architecture, and requestingExtensionId."` — a runtime error that our code silently swallowed via `try/catch`, falling through to `dotnet.acquire` (which also lacked `architecture` but happened to succeed because the install path uses different defaulting). This produced misleading log messages and would have failed entirely on architectures without a default. The lesson: every required field in the upstream API contract is a hard precondition, even when an "optional" code path papers over the omission. +`architecture` is derived from Node's `process.arch` and mapped as: `x64` → `x64`, `arm64` → `arm64`, `ia32` → `x86`, default → `x64`. This mapping lives in `src/editors/vscode/src/dotnetRuntime.ts`. -This applies symmetrically to `dotnet.findPath` — its `acquireContext` MUST include `architecture` for the same reason. +The .NET Install Tool rejects a `dotnet.findPath` payload missing `mode`, `version`, `architecture`, or `requestingExtensionId`; `acquireContext` MUST contain all four fields. See the upstream contract at . -**Verification:** Confirmed against the upstream contract at and against the live extension's own error message captured in the SharpLsp activation log on 2026-04-30. - -## [DIST-FAILURE-UX] +## [DIST-FAILURE-UX] Activation Failure UX Whenever activation cannot deliver a working language server — for any reason, at any step — SharpLsp MUST inform the user with a non-modal notification. The extension MUST NEVER fail silently and MUST NEVER throw out of `activate()`. **Hard rules:** 1. **`activate()` MUST always resolve, never reject.** Any error caught at the top level results in a non-modal error notification + degraded return value, never a re-throw. VS Code logs uncaught activation rejections to its own developer console where users do not see them — that is exactly the failure mode this rule prevents. -2. **Every non-trivial helper invoked from activation MUST return `Result`** (from `editors/vscode/src/result.ts`). Helpers MUST NOT use `throw` for expected error paths. The only `throw` in the codebase is the one VS Code itself produces when an extension dependency is missing — and even that is caught and surfaced. +2. **Every non-trivial helper invoked from activation MUST return `Result`** (from `src/editors/vscode/src/result.ts`). Helpers MUST NOT use `throw` for expected error paths. The only `throw` in the codebase is the one VS Code itself produces when an extension dependency is missing — and even that is caught and surfaced. 3. **Every failure surfaces a non-modal `vscode.window.showErrorMessage(…)`** with at minimum a `[Show Log]` button that calls `log.output().show()`. Where applicable, additional informational links MAY be added (`[Open dot.net]`, `[Retry]`, `[Reinstall]`). Buttons are convenience links, never required actions. 4. **The status bar MUST move to `ServerState.Error`** so the persistent indicator reflects the degraded state. 5. **The error message MUST name the failure mode in plain language** ("required binaries are missing or version-mismatched", ".NET 10 install failed", "language server crashed during startup") — never just dump a stack trace into the toast. The full diagnostic text goes to the output channel reachable via `[Show Log]`. 6. **Recovery commands MUST be registered** so the user can re-attempt without uninstalling. Examples: `sharplsp.retryDotnetAcquisition`, `sharplsp.restartServer`. These appear in the command palette under the `SharpLsp:` category. -**Reasoning — why this rule exists.** -The first v0.1.0 release threw out of `activate()` when bundled binaries were missing or had a version mismatch. VS Code logged the failure to its developer console — invisible to the user. The user opened a `.csproj` folder, saw absolutely nothing happen, and had no way to discover the problem without manually inspecting the extension log file. This is the worst possible UX: the extension is broken, the user does not know it is broken, and there is no in-product hint that anything went wrong. This section makes that mode of failure a normative bug going forward. Captured from the activation log on 2026-04-30: every error path now MUST produce a visible toast and an actionable command. - **Implementation reference:** -- `editors/vscode/src/result.ts` — `Result`, `ok`, `err`. -- `editors/vscode/src/extension.ts` — outer `activate()` catch surfaces the toast; inner `activateInner()` step paths return early with toast + degraded API instead of throwing. -- `editors/vscode/src/dotnetRuntime.ts` — `acquireDotnet10Sdk` returns `Result`; the caller pattern-matches. +- `src/editors/vscode/src/result.ts` — `Result`, `ok`, `err`. +- `src/editors/vscode/src/extension.ts` — outer `activate()` catch surfaces the toast; inner `activateInner()` step paths return early with toast + degraded API instead of throwing. +- `src/editors/vscode/src/dotnetRuntime.ts` — `acquireDotnet10Sdk` returns `Result`; the caller pattern-matches. -## [DIST-CLEAN-OUTPUT] +## [DIST-CLEAN-OUTPUT] Clean Output Editors capture the language server's `stderr` into a user-facing Output panel (VS Code: the **SharpLsp** channel). Because the Rust host inherits each sidecar's `stderr`, that single stream carries host logs *and* both sidecars' logs. The panel MUST therefore stay clean, human-readable, and level-appropriate — never a dumping ground for raw, colorized, or per-request diagnostics. **Hard rules:** 1. **No ANSI escape codes reach the panel.** The captured stream is a pipe, not a TTY, so color/cursor escapes render as garbage. The Rust host gates its `tracing` stderr layer on `std::io::IsTerminal` (`.with_ansi(stderr_is_terminal)`), emitting plain text whenever stderr is not an interactive terminal. The VS Code extension additionally strips ANSI defensively before anything reaches the channel (`createAnsiStrippingChannel`). -2. **Sidecars MUST NOT write diagnostics to `Console.Error` / `eprintfn`.** Per the project logging rule, sidecar diagnostics use structured logging (Serilog) routed to a per-sidecar rolling file under the system temp directory (`sharplsp-logs/sidecar-.log`) — never the inherited stderr. The only legitimate sidecar `stdout`/`stderr` writes are the `READY:` IPC handshake, the `--version` banner, the CLI usage message, and the one-shot actionable SDK-resolution hints ([DIST-RUNTIME-ACQUIRE] portability, below, and [DIST-SDK-DISCOVERY]) — the Roslyn-mismatch, missing-SDK, and unresolvable-`global.json` startup diagnostics, each emitted at most once per process. +2. **Sidecars MUST NOT write routine diagnostics to `Console.Error` / `eprintfn`.** Per the project logging rule, sidecar diagnostics use structured logging (Serilog) routed to a per-sidecar rolling file under the system temp directory (`sharplsp-logs/sidecar-.log`)—never the inherited stderr. The only legitimate sidecar `stdout`/`stderr` writes are the versioned `READY:` IPC handshake, the `--version` banner, the CLI usage message, one sanitized pre-READY `FATAL:` diagnostic required by [SIDECAR-STARTUP-FAILURE](SIDECAR-LIFECYCLE-SPEC.md), and the one-shot actionable SDK-resolution hints ([DIST-RUNTIME-ACQUIRE] portability, below, and [DIST-SDK-DISCOVERY])—the Roslyn-mismatch, missing-SDK, and unresolvable-`global.json` startup diagnostics, each emitted at most once per process. 3. **Per-request chatter goes to the file log, not the panel.** Routine traces (e.g. the router's per-request `[Router] Handling …`) are logged at `Debug` to the rolling file. Genuinely user-facing failures still surface (via the host's `error!` on a failed sidecar request, or a `[Show Log]` action per [DIST-FAILURE-UX]). 4. **A type-load failure is summarized once.** MSBuild surfaces a `ReflectionTypeLoadException` as a diagnostic carrying dozens of identical "Could not load file or assembly" lines, repeated once per project. Repeated lines MUST be collapsed (`SidecarLog.CollapseRepeatedLines`) and duplicate summaries de-duplicated so the log records one distinct, actionable line — not a flood. -**Reasoning — why this rule exists.** -The first releases piped the host's colorized `tracing` output and each sidecar's raw `Console.Error` straight into the Output panel. Activation filled it with `\x1b[2m…\x1b[0m` escape garbage, a per-request `[Router] Handling …` line, and ~200 near-identical type-load lines dumped from a single exception — making the panel unreadable and masking the real failure (a Roslyn version mismatch). Captured in issue #78. - **Implementation reference:** -- `src/main.rs` — `IsTerminal`-gated `.with_ansi(…)` on the stderr `tracing` layer. -- `editors/vscode/src/output-filter.ts` — `stripAnsi` + `createAnsiStrippingChannel`, wired into the client's `outputChannel` in `editors/vscode/src/client.ts`. -- `sidecars/SharpLsp.Sidecar.Common/Logging/SidecarLog.cs` — Serilog rolling-file configuration + `CollapseRepeatedLines`; initialized by `SidecarHost`. -- `sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs` — `LogWorkspaceFailure` collapses and de-duplicates MSBuild workspace-load diagnostics. +- `src/sharplsp/src/main.rs` — `IsTerminal`-gated `.with_ansi(…)` on the stderr `tracing` layer. +- `src/editors/vscode/src/output-filter.ts` — `stripAnsi` + `createAnsiStrippingChannel`, wired into the client's `outputChannel` in `src/editors/vscode/src/client.ts`. +- `src/sidecars/SharpLsp.Sidecar.Common/Logging/SidecarLog.cs` — Serilog rolling-file configuration + `CollapseRepeatedLines`; initialized by `SidecarHost`. +- `src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs` — `LogWorkspaceFailure` collapses and de-duplicates MSBuild workspace-load diagnostics. -## [DIST-VSIX-MODEL] +## [DIST-VSIX-MODEL] VSIX Distribution Model The VSIX is self-contained. A user who installs the extension gets everything they need with zero additional installation steps beyond the .NET 10 SDK (which is acquired automatically per [DIST-RUNTIME-ACQUIRE]). @@ -155,7 +138,7 @@ The VSIX is self-contained. A user who installs the extension gets everything th **No component is ever installed via `dotnet tool install`, package manager, or any mechanism outside the VSIX.** The `dotnet-tool` source type is NOT used for VSIX distribution. -## [DIST-VSIX-LAYOUT] +## [DIST-VSIX-LAYOUT] VSIX Layout A separate VSIX is published for each platform. Every VSIX contains all three components: @@ -180,22 +163,20 @@ bin/ The sidecar binaries are identical across all platform VSIXs — they are managed assemblies and require no platform-specific build. -## [DIST-VSIX-ASSET-INTEGRITY] +## [DIST-VSIX-ASSET-INTEGRITY] VSIX Asset Integrity -The extension's icon assets (`editors/vscode/icons/`) are tracked as symlinks into `docs/designs/logo/` — a single source of truth for brand assets. On checkouts where Git cannot create symlinks (`core.symlinks=false`, the default on most Windows machines), Git materializes each symlink as a small text file containing the target path. `vsce` packages whatever is on disk, so such a checkout silently produces a VSIX whose Marketplace and activity-bar icons are broken text stubs, and the extension-development host renders broken icons. +The extension's icon assets in `src/editors/vscode/icons/` are symlinks into `docs/designs/logo/`. With `core.symlinks=false`, Git materializes target paths as text files, which `vsce` would package as broken icons. 1. Every image asset referenced by the extension manifest MUST be packaged as real image content. A VSIX containing symlink text stubs is broken. -2. `scripts/resolve-symlink-stubs.mjs` rewrites stub files in place with their target's content. It MUST leave real OS symlinks untouched (macOS/Linux, and Windows checkouts with `core.symlinks=true`), making it a cross-platform no-op wherever symlinks work. It only rewrites plain files whose entire content is a relative POSIX path resolving to an existing file. +2. `tools/vsix/resolve-symlink-stubs.mjs` rewrites stub files in place with their target's content. It MUST leave real OS symlinks untouched (macOS/Linux, and Windows checkouts with `core.symlinks=true`), making it a cross-platform no-op wherever symlinks work. It only rewrites plain files whose entire content is a relative POSIX path resolving to an existing file. 3. The resolver MUST run automatically before packaging (`vscode:prepublish`) and before the e2e suite (`pretest`), so both the packaged VSIX and the extension-development host load real images. The e2e suite asserts the invariant (`bundled-binary.test.ts`). -4. Resolved stubs modify the working tree and MUST NOT be committed — Git would record the binary content as the symlink's target text, corrupting the symlink for every other platform. Restore with `git restore editors/vscode/icons`. - -CI and releases are unaffected: GitHub's hosted runners (including `windows-latest`) check out with working symlinks, and published VSIXs contain real icons (verified against the `v0.13.0` `win32-x64` asset). +4. Resolved stubs modify the working tree and MUST NOT be committed — Git would record the binary content as the symlink's target text, corrupting the symlink for every other platform. Restore with `git restore src/editors/vscode/icons`. -## [DIST-RESOLUTION] +## [DIST-RESOLUTION] Binary Resolution Resolution is driven by the `sources` array per component in `shipwright.json`. The `activateDeploymentToolkit` call verifies all three on activation. Failure to resolve any required component triggers [DIST-FAILURE-UX] (degraded mode + toast), not a host-crashing throw. -## [DIST-RESOLUTION-LSP] +### [DIST-RESOLUTION-LSP] LSP Host `sharplsp` (LSP server — native binary). @@ -209,7 +190,7 @@ Sources: `["user-setting", "env", "bundled", "path", "pkgmgr"]` | 4 | `path` | `sharplsp` on `$PATH`; exact version match required | | 5 | `pkgmgr` | Shows modal prompt: `brew install nimblesite/tap/sharplsp` / `scoop install nimblesite/sharplsp` | -## [DIST-RESOLUTION-CSHARP] +### [DIST-RESOLUTION-CSHARP] C# Sidecar `sharplsp-sidecar-csharp` (C# Roslyn sidecar — .NET assembly). @@ -224,7 +205,7 @@ Sources: `["user-setting", "env", "bundled", "path"]` **If bundled binary is missing the VSIX is broken — fix the build, not the resolution.** Surface per [DIST-FAILURE-UX]. -## [DIST-RESOLUTION-FSHARP] +### [DIST-RESOLUTION-FSHARP] F# Sidecar `sharplsp-sidecar-fsharp` (F# FCS sidecar — .NET assembly). @@ -239,7 +220,7 @@ Sources: `["user-setting", "env", "bundled", "path"]` **F# is first-class. No SharpLsp without F# support. If bundled binary is missing the VSIX is broken — fix the build.** Surface per [DIST-FAILURE-UX]. -## [DIST-VERSION-MATCH] +## [DIST-VERSION-MATCH] Version Mismatch Behavior | Source | Version mismatch behaviour | |---|---| @@ -248,13 +229,13 @@ Sources: `["user-setting", "env", "bundled", "path"]` | `bundled` | `ok-with-warning` — activation continues | | `path` | Skipped (no match) — falls through to next source | -## [DIST-VERSION-INVARIANT] +## [DIST-VERSION-INVARIANT] Release Version Invariant -`Cargo.toml` `version` is the single source of truth. The release workflow stamps the tag version into `Cargo.toml` and `editors/vscode/package.json`, commits and pushes those changes, then builds all artifacts from that commit. Sidecar versions are set via `-p:PackageVersion` at publish time. +`Cargo.toml` `version` is the single source of truth. The release workflow stamps the tag version into `Cargo.toml` and `src/editors/vscode/package.json`, commits and pushes those changes, then builds all artifacts from that commit. Sidecar versions are set via `-p:PackageVersion` at publish time. All versions MUST match byte-for-byte for a release to be valid. -## [DIST-VERSION-OUTPUT] +## [DIST-VERSION-OUTPUT] Version Command Output | Binary | Expected stdout | |---|---| @@ -264,7 +245,7 @@ All versions MUST match byte-for-byte for a release to be valid. The first whitespace-delimited token MUST exactly match the component `id` in `shipwright.json`. -## [DIST-EDITOR-CONTRACT] +## [DIST-EDITOR-CONTRACT] Editor Activation Contract The VS Code extension uses `@nimblesite/shipwright-vscode` (`activateDeploymentToolkit`) to resolve all three components. The extension MUST: @@ -276,7 +257,13 @@ The VS Code extension uses `@nimblesite/shipwright-vscode` (`activateDeploymentT 6. **Acquire the .NET 10 SDK at activation start** via `dotnet.acquireGlobalSDK` from the .NET Install Tool extension (see [DIST-RUNTIME-ACQUIRE]). Show a non-interactive progress notification + status-bar spinner. SharpLsp's own UI never prompts or blocks on user action. 7. **Use `Result` everywhere** per [DIST-FAILURE-UX]. No `throw` inside extension code; no unhandled rejections out of `activate()`. -## [DIST-PATH-INSTALL] +## [DIST-WORKSPACE-TRUST] Workspace Trust + +An untrusted workspace MUST NOT select an executable or inject process arguments. `src/editors/vscode/package.json` declares `capabilities.untrustedWorkspaces.supported: "limited"` and restricts `sharplsp.lspPath`, `sharplsp.csharpSidecarPath`, `sharplsp.fsharpSidecarPath`, `sharplsp.server.extraArgs`, `sharplsp.fsi.extraArgs`, and `sharplsp.debug.netcoredbgPath`. + +While `workspace.isTrusted` is false, the runtime guards in `src/editors/vscode/src/config.ts` MUST return no custom LSP path, server arguments, or FSI arguments, leaving Shipwright's bundled binaries in use. When `workspace.onDidGrantWorkspaceTrust` fires, `src/editors/vscode/src/extension.ts` MUST restart the language client so newly trusted path and argument settings take effect without a window reload. + +## [DIST-PATH-INSTALL] PATH Installation Users who want `sharplsp` on their system PATH outside VS Code may install via: @@ -285,7 +272,7 @@ Users who want `sharplsp` on their system PATH outside VS Code may install via: This is entirely optional. The bundled VSIX binary is sufficient for VS Code users. -## [DIST-RELEASE] +## [DIST-RELEASE] Release Workflow Tag-triggered (`v*`). Jobs: @@ -294,9 +281,9 @@ Tag-triggered (`v*`). Jobs: 3. **`build-vsix`** — for each platform: stages `bin//sharplsp[.exe]` + `bin/all/sharplsp-sidecar-*`, runs `vsce package --target `. Produces 6 per-platform `.vsix` files, each fully self-contained. 4. **`release`** — creates GitHub release with all archives and VSIXs, updates Homebrew tap, updates Scoop bucket, publishes VSIXs to VS Code Marketplace. -## [DIST-CI-LAYOUT] +## [DIST-CI-LAYOUT] CI Workflow Layout -The PR pipeline is split across reusable workflows (`on: workflow_call`) rather than one monolith, so no CI file outgrows comprehension and each leg is readable and editable in isolation: +The PR pipeline uses reusable workflows (`on: workflow_call`): | Workflow | Leg | |---|---| @@ -310,9 +297,13 @@ The PR pipeline is split across reusable workflows (`on: workflow_call`) rather Invariants: - **`detect-changes` is the only gate.** Every leg is `needs: detect-changes` and guarded by `code_changed`; no leg `needs:` another. Lint and tests are independent required gates — serializing tests behind lint added ~3 minutes to every PR's critical path, and a lint failure still blocks the merge. -- **Legs are called, never duplicated.** Shared shell logic lives in `scripts/` (e.g. `purge-path-binaries.sh`, `vsix-test-chunks.mjs`) and shared build logic in the `Makefile`, so a step is written once and called from every workflow that needs it. +- **Legs are called, never duplicated.** Shared VSIX shell logic lives in `tools/vsix/` (for example `purge-path-binaries.sh` and `vsix-test-chunks.mjs`) and shared build logic in the `Makefile`, so a step is written once and called from every workflow that needs it. + +### [DIST-CI-SECURITY] Security Gates + +[ci.yml](../../.github/workflows/ci.yml) MUST run dependency review for pull requests. [codeql.yml](../../.github/workflows/codeql.yml) MUST scan pull requests, weekly schedules, and tagged releases; `release.yml` calls it with `gate: true`, and any high or critical finding blocks release and publication. Workflow permissions default to `contents: read`; only jobs that publish security events or artifacts receive narrower write permissions. -## [DIST-CI-NODE] +## [DIST-CI-NODE] Node.js Toolchain **Minimum: Node.js 20.x.x.** This is the minimum required by `@vscode/vsce` v3.x. @@ -320,40 +311,44 @@ Ground truth: All CI jobs that run `vsce package` or `vsce publish` MUST use `node-version: '20'` or higher. Do not upgrade beyond what vsce requires without checking the above URL first. -## [DIST-CI-DOTNET] +## [DIST-CI-DOTNET] .NET Toolchain **Required: .NET 10.** All sidecar publish steps use `dotnet publish --no-self-contained` targeting `net10.0`. -## [DIST-CI-RUST] +### [DIST-CI-DOTNET-DEPSFILE] Dependency File Generation + +`src/sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj` is a referenced-only class library and MUST set `false`. Its consumers generate their own runtime dependency files; emitting the unused `SharpLsp.Sidecar.Common.deps.json` lets concurrent builds or indexers lock the shared `bin/` artifact and fail `GenerateDepsFile` with MSB4018. `src/sharplsp/tests/build_deps_file_e2e.rs` MUST verify the evaluated MSBuild property, not project-file text (GitHub #111). + +## [DIST-CI-RUST] Rust Toolchain Stable toolchain. Cross-compilation targets must be added via `dtolnay/rust-toolchain@stable` with explicit `targets:`. -## [DIST-CI-RUST-SHARDS] +### [DIST-CI-RUST-SHARDS] Rust Test Shards The Rust e2e suite runs single-threaded (`RUST_TEST_THREADS=1` — tests spawn real Roslyn/FCS sidecars), so its wall time scales with test count, not runner cores. CI therefore splits it into `SHARD_COUNT` nextest **hash partitions** (`make _test-rust-shard SHARD=`, i.e. `--partition hash:/`), run as a `test-rust` job matrix. Invariants: - **Same tests, same serialization.** A shard changes only *which* slice of the suite runs, never how: `--no-fail-fast` and the `--test-threads` serialization apply to every shard. Sharding MUST NOT skip, filter, or reorder tests beyond the partition itself. -- **One gate, over the union.** Each shard exports lcov (`target/coverage-rust-shard.lcov`). No shard can meet the line threshold alone, so no shard runs the coverage gate; the `coverage-rust` job union-merges the tracefiles (`scripts/merge-lcov.mjs`) and enforces the identical `check-coverage.sh` ratchet a single-job run enforces. Every shard tracefile carries the full instrumented line set (unexecuted lines as `DA:,0`), so the union reproduces exactly the line percentage of an unsharded run. +- **One gate, over the union.** Each shard exports lcov (`target/coverage-rust-shard.lcov`). No shard can meet the line threshold alone, so no shard runs the coverage gate; the `coverage-rust` job union-merges the tracefiles (`tools/coverage/merge-lcov.mjs`) and enforces the identical `tools/coverage/check-coverage.sh` ratchet a single-job run enforces. Every shard tracefile carries the full instrumented line set (unexecuted lines as `DA:,0`), so the union reproduces exactly the line percentage of an unsharded run. - **Local runs stay unsharded.** `make test` / `make _test-rust` remain the single-invocation JSON + inline-gate path; sharding is a CI wall-clock concern only. - **Version contract is its own job.** The `--version` contract checks ([DIST-VERSION-OUTPUT]) run in the `version-contract` job: the release-profile build shares no artifacts with the instrumented test build, so bundling it into a test job serializes it onto the critical path for zero reuse. -## [DIST-CI-WIN-TRANSPORT] +## [DIST-CI-WIN-TRANSPORT] Windows Sidecar Transport `tokio::net::UnixStream` is **unix-only** and MUST NOT be used unconditionally. All sidecar transport code MUST be gated: - `#[cfg(unix)]` — use `tokio::net::UnixStream` -- `#[cfg(windows)]` — use TCP loopback (`127.0.0.1:0`) or `tokio::net::windows::named_pipe` +- `#[cfg(windows)]` — use `tokio::net::windows::named_pipe`; TCP loopback is not an IPC fallback Both the Rust host and the .NET sidecar MUST use the same transport on each platform. Win32 builds failing to compile due to `UnixStream` is a hard blocker. The .NET sidecars are platform-neutral assemblies shipped identically in every VSIX ([DIST-VSIX-LAYOUT]), so **their transport selection MUST be a runtime decision keyed on the endpoint shape**: an endpoint starting with `\\.\pipe\` selects a named pipe server/client; anything else selects a Unix domain socket. Compile-time gating (`#if WINDOWS`) is forbidden in sidecar transport code — the symbol is never defined for the platform-neutral `net10.0` build, which silently compiles the Unix branch into the Windows VSIX and makes the sidecars exit before READY (GitHub #110). -Both listener flavors MUST restrict the endpoint to the current user: `0600` on the Unix domain socket, `PipeOptions.CurrentUserOnly` on the named pipe server. The endpoint names are deterministic, so an unrestricted endpoint is claimable/connectable by any co-located local user. CI MUST run the sidecar transport tests on a Windows runner — an ubuntu-only matrix never executes the named-pipe arm, which is how GitHub #110 shipped. +Both listener flavors MUST restrict the endpoint to the current user: `0600` on the Unix domain socket, `PipeOptions.CurrentUserOnly` on the named pipe server. Endpoint names MUST also be unpredictable and unique per spawn per [SIDECAR-STARTUP-ENDPOINT](SIDECAR-LIFECYCLE-SPEC.md), preventing concurrent hosts or an orphaned prior generation from intentionally sharing a name. Current-user restriction remains mandatory defense in depth. CI MUST run the sidecar transport tests on a Windows runner—an Ubuntu-only matrix never executes the named-pipe arm, which is how GitHub #110 shipped. -## [DIST-CI-WIN-VSIX] +## [DIST-CI-WIN-VSIX] Windows VS Code End-to-End Tests -The transport tests ([DIST-CI-WIN-TRANSPORT]) prove the named pipes carry frames; they do NOT prove the whole editor experience works on top of them. CI MUST therefore run the VS Code end-to-end suite's **whole feature surface** on Windows runners (`ci-vsix-windows.yml`, driven by the `_test-vsix-win` Make target), through the REAL LSP — release-built `sharplsp` host plus the Roslyn and FCS sidecars — inside the actual VS Code extension host over win32 named-pipe IPC. A grep-selected smoke subset is NOT sufficient: the features most likely to break on Windows are the ones that shell out to platform-specific executables (`netcoredbg.exe`, `dotnet-trace`, `dotnet test`, `dotnet new`) and manipulate Windows paths, none of which a completion/hover subset touches. +CI MUST run the VS Code end-to-end suite's whole feature surface on Windows runners through `ci-vsix-windows.yml` and `_test-vsix-win`: the release-built `sharplsp` host, Roslyn and FCS sidecars, actual VS Code extension host, and win32 named-pipe IPC. [DIST-CI-WIN-TRANSPORT] covers frames only, while Windows-specific executables (`netcoredbg.exe`, `dotnet-trace`, `dotnet test`, `dotnet new`) and paths require full feature coverage; a grep-selected smoke subset is insufficient. The suite is sliced into **feature chunks**, one Windows CI job each, run with `fail-fast: false` so one failing feature area never hides the state of the others: @@ -369,21 +364,19 @@ The suite is sliced into **feature chunks**, one Windows CI job each, run with ` Invariants: -- **One declaration.** Chunk membership lives in `editors/vscode/test-chunks.json` and is read by `scripts/vsix-test-chunks.mjs` (`files ` → `MOCHA_FILES` globs, `matrix` → the CI job matrix, `check` → the completeness guard). It MUST NOT be duplicated into CI YAML. +- **One declaration.** Chunk membership lives in `src/editors/vscode/test-chunks.json` and is read by `tools/vsix/vsix-test-chunks.mjs` (`files ` → `MOCHA_FILES` globs, `matrix` → the CI job matrix, `check` → the completeness guard). It MUST NOT be duplicated into CI YAML. - **Nothing escapes.** `make _lint-vsix` runs `vsix-test-chunks.mjs check`, which fails if any `*.test.ts` suite is claimed by no chunk or by more than one. A new suite is therefore gated on Windows by default; opting out requires an explicit entry under `excluded` with a written reason. - **Selection is by file, not by title.** The inner mocha runner selects suites via the `MOCHA_FILES` glob list. Title-regex selection (`MOCHA_GREP`) is a local debugging aid only — it silently drops tests when a suite is renamed. A glob matching zero compiled suites is a hard error, so a mistyped chunk fails instead of reporting a green run of nothing. - **Build once, fan out.** A single `build` job compiles the Rust host and both sidecars and publishes them as an artifact; each chunk job downloads and stages them (`_stage-vsix-binary-only`). Rebuilding per chunk would cost one cold Windows Rust build per feature area. - **Ubuntu owns coverage.** Windows chunks run **without** `--coverage` and enforce no coverage gate — one chunk can never meet the line threshold. The Ubuntu `test-vsix` job owns the full single-process run plus the ratcheted gate, and is the only job that runs the `real-repo-*` stress suites (each clones and restores a pinned third-party repository; that is repo ingestion, not platform behaviour). -- **No PATH leakage.** Every VS Code job runs `scripts/purge-path-binaries.sh` first, so the test host can only resolve the freshly-staged bundled binaries. A dev copy on `PATH` would substitute itself for the artifact under test and turn a broken bundle green. - -Two assertion rules follow from running on win32 at all, and both are load-bearing — each one silently passed on Ubuntu for the life of the suite and failed on the first Windows run: +- **No PATH leakage.** Every VS Code job runs `tools/vsix/purge-path-binaries.sh` first, so the test host can only resolve the freshly-staged bundled binaries. A dev copy on `PATH` would substitute itself for the artifact under test and turn a broken bundle green. - **Compare paths case-insensitively on Windows.** VS Code lowercases the drive letter whenever a path travels through `Uri.fsPath`, while `extensionPath` and `os.tmpdir()` preserve the original casing, so the same file legitimately has two spellings. Any assertion comparing a `Uri`-derived path against a directly-constructed one MUST go through `comparablePath()` (`test-helpers.ts`), which lowercases on win32 only — POSIX paths stay case-sensitive, because there `/tmp/A` and `/tmp/a` really are different files. - **Suites MUST be order-independent.** Chunking changes which suites share an extension host, so no suite may depend on state another suite left in a shared singleton. Fixture identifiers that feed a shared registry — notably test method names discovered into the `SharpLspTestController` — MUST be unique per suite, or a test asserting "nothing matches" passes or fails on whichever suite's discovery won the race. The LSP e2e temp-dir helper MUST fall back to `os.tmpdir()` (never a hardcoded `/tmp`) so these suites run on Windows. -## [DIST-SECRETS] +## [DIST-SECRETS] Publishing Credentials The VS Code Marketplace publishes **passwordless via Microsoft Entra ID OIDC** (workload identity federation) — there is **no** long-lived Marketplace PAT. The `release.yml` `publish-marketplace` job runs in the `release` GitHub Environment so its OIDC subject is the deterministic `repo:Nimblesite/SharpLsp:environment:release`, which one Entra federated credential trusts. Open VSX has **no** OIDC/trusted-publishing path (verified 2026), so it still requires a long-lived access token. @@ -394,7 +387,7 @@ The VS Code Marketplace publishes **passwordless via Microsoft Entra ID OIDC** ( | `AZURE_TENANT_ID` | `release` env | Entra ID tenant (directory) id — Marketplace OIDC publish. | | `OPEN_VSX_PAT` | repo | Open VSX access token. No OIDC path exists; long-lived token required (rotate on a schedule — post-2025 tokens expire by default). | -## [DIST-CI-SMOKE] +## [DIST-CI-SMOKE] CI Smoke Checks Every PR: - Validates `shipwright.json` with `shipwright-validate-manifest` @@ -404,7 +397,7 @@ Every PR: - Verifies `bin/all/sharplsp-sidecar-fsharp` exists in the staged VSIX layout - Runs `sharplsp --version`, `sharplsp-sidecar-csharp --version`, `sharplsp-sidecar-fsharp --version` -## [DIST-FORBIDDEN] +## [DIST-FORBIDDEN] Forbidden Distribution Patterns - `https.get(...)` / `fetch(...)` / `child_process` spawning for downloading any binary, including .NET. The .NET runtime is delegated exclusively to the .NET Install Tool extension (see [DIST-RUNTIME-ACQUIRE]); other binaries ship in the VSIX. - `dotnet tool install` / `dotnet tool update` as a distribution mechanism for VSIX users. diff --git a/docs/specs/HOVER-SPEC.md b/docs/specs/HOVER-SPEC.md index 87a7db2f..4dee0636 100644 --- a/docs/specs/HOVER-SPEC.md +++ b/docs/specs/HOVER-SPEC.md @@ -1,16 +1,14 @@ -# Hover / Quick Info Specification +# [HOVER-SPEC] Hover / Quick Info Specification **Parent:** [SHARPLSP-SPEC.md](SHARPLSP-SPEC.md) -## 1. Overview +## [HOVER-OVERVIEW] Overview -Hover (Quick Info) provides rich tooltip information when the user hovers over a symbol or keyword. SharpLsp implements `textDocument/hover` ([LSP 3.17 §3.17.5](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover)) for both C# and F# as equal first-class citizens. +SharpLsp implements P0 `textDocument/hover` ([LSP 3.17 §3.17.5](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover)) for C# and F#. -This feature is **P0** (launch blocker) and targets Phase 2 delivery. +## [HOVER-PROTOCOL] LSP Protocol -## 2. LSP Protocol - -### 2.1 Request +### [HOVER-PROTOCOL-REQUEST] Request ``` method: textDocument/hover @@ -20,7 +18,7 @@ params: HoverParams { } ``` -### 2.2 Response +### [HOVER-PROTOCOL-RESPONSE] Response ``` result: Hover | null @@ -38,10 +36,12 @@ interface Hover { SharpLsp MUST return `MarkupContent` with `kind: "markdown"`. Plain-text fallback is not supported — all LSP 3.17 clients support Markdown. -## 3. Request Routing +## [HOVER-ROUTING] Request Routing Hover is a **semantic** request. The Rust host routes it to the appropriate sidecar based on document language. +Implementations: [semantic.rs](../../src/sharplsp/src/semantic.rs), [CSharpHoverBuilder.cs](../../src/sidecars/SharpLsp.Sidecar.CSharp/Hover/CSharpHoverBuilder.cs), [FSharpHoverBuilder.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/Hover/FSharpHoverBuilder.fs), and the [C# hover end-to-end tests](../../src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HoverEndToEndTests.cs). + | Step | Component | Action | |---|---|---| | 1 | Rust host | Receives `textDocument/hover`, identifies language from VFS | @@ -51,9 +51,9 @@ Hover is a **semantic** request. The Rust host routes it to the appropriate side The Rust host MAY use tree-sitter to pre-validate the hovered position (e.g., skip hover for whitespace/comments) and short-circuit with `null` before dispatching to the sidecar. -## 4. C# Implementation (Roslyn) +## [HOVER-CSHARP] C# Implementation -### 4.1 Symbol Resolution +### [HOVER-CSHARP-RESOLUTION] Symbol Resolution 1. Obtain `Document` from the current `Solution` snapshot for the given URI. 2. Get `SemanticModel` via [`Document.GetSemanticModelAsync()`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.document.getsemanticmodelasync). @@ -62,7 +62,7 @@ The Rust host MAY use tree-sitter to pre-validate the hovered position (e.g., sk 5. If `GetSymbolInfo()` returns no symbol, fall back to [`SemanticModel.GetTypeInfo()`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.semanticmodel.gettypeinfo) for implicit types and expressions. 6. For keywords (`var`, `await`, `async`, `nameof`, etc.), provide keyword-specific documentation. -### 4.2 Markdown Rendering +### [HOVER-CSHARP-RENDERING] Markdown Rendering The hover response MUST include: @@ -76,7 +76,7 @@ The hover response MUST include: | Accessibility | `public`, `internal`, `protected`, etc. | Yes | | Deprecation | `[Obsolete]` message | Yes (if present) | -#### XML Documentation Rendering +#### [HOVER-CSHARP-RENDERING-XML] XML Documentation Rendering - `` — Rendered as the primary description paragraph. - `` — Rendered as a parameter list with descriptions. @@ -96,7 +96,7 @@ XML docs are sourced from: 2. XML documentation files from NuGet packages (`.xml` files alongside assemblies). 3. Roslyn's built-in documentation provider as fallback. -### 4.3 Special Cases +### [HOVER-CSHARP-CASES] Special Cases | Hover Target | Behavior | |---|---| @@ -111,15 +111,15 @@ XML docs are sourced from: | Preprocessor directives | Show directive documentation | | `using` alias | Show the aliased type | -## 5. F# Implementation (FCS) +## [HOVER-FSHARP] F# Implementation -### 5.1 Symbol Resolution +### [HOVER-FSHARP-RESOLUTION] Symbol Resolution 1. Get `FSharpCheckFileResults` for the document via `FSharpChecker.CheckFileInProject()`. 2. Call `GetToolTip(line, col, lineText, names, tokenTag)` to obtain `ToolTipText`. 3. `ToolTipText` contains `ToolTipElement[]`, each with a structured layout and XML documentation. -### 5.2 Markdown Rendering +### [HOVER-FSHARP-RENDERING] Markdown Rendering F# hover follows the same Markdown structure as C#: @@ -131,7 +131,7 @@ F# hover follows the same Markdown structure as C#: | Constraints | Generic constraints | Extracted from signature | | Union cases | Case fields and types | `ToolTipElement` for DU cases | -### 5.3 F#-Specific Cases +### [HOVER-FSHARP-CASES] F#-Specific Cases | Hover Target | Behavior | |---|---| @@ -143,54 +143,28 @@ F# hover follows the same Markdown structure as C#: | Discriminated union cases | Show case fields with types | | Record fields | Show field type and containing record | -### 5.4 Live-Buffer Resolution `[FS-DIDCHANGE-OVERLAY]` - -Hover MUST resolve against the editor's **in-memory buffer**, not the on-disk -file. The Rust host forwards `textDocument/didOpen`/`didChange` to the document's -own sidecar (F# → F# sidecar, C# → C# sidecar); routing by language is mandatory, -since a misrouted edit leaves the owning sidecar resolving positions against stale -text. The F# sidecar keeps an in-memory overlay keyed by absolute file path and -every per-file analysis (hover, completion, signature help, …) reads source via -that overlay, falling back to disk only when no open buffer exists. This restores -F# to parity with C#, whose Roslyn workspace is already updated in place on -`didChange`. Without this, F# hover misaligns the moment the buffer diverges from -disk (i.e. as soon as the user types) and returns the wrong symbol or `null`. - -### 5.5 Canonical Check Funnel `[FS-DIDCHANGE-OVERLAY]` - -Every per-file FCS analysis (hover, completion, diagnostics, signature help, -inlay hints, code fixes, file ordering) funnels through **one** canonical -check — `parseAndCheckOnce` (the raw parse+check) and its `checkFileWithParse` -/ `checkFile` views — rather than each call site invoking -`FSharpChecker.ParseAndCheckFileInProject` itself. This keeps overlay-aware -source resolution and `FSharpCheckFileAnswer` handling in exactly one place -(DRY) and guarantees every feature type-checks the **live didChange buffer**, -so a reverted or freshly edited file is always analysed as its newest text -instead of stale on-disk content — the property that lets a reverted buffer -clear its phantom errors on the next pull (GitHub #160). - -The sidecar processes IPC messages strictly sequentially — `SidecarHost` -awaits each handler to completion before reading the next frame — so a -`didChange` never lands while a check is in flight; the source a check reads is -always the newest committed buffer. (Should dispatch ever become concurrent, a -mid-check stability re-read would be needed here; it is deliberately omitted -today because that path is unreachable and cannot be exercised by a -deterministic test.) This is the sidecar-side complement of the Rust host's -push gate `[DIAG-PUSH-GATE]` (DIAGNOSTICS-SPEC §1.3), which guarantees stale -results are never *published*. - -## 6. Caching Strategy - -Hover results are cached via the [salsa](https://salsa-rs.github.io/salsa/) incremental computation database in the Rust host. - -| Cache Key | Invalidation Trigger | +### [HOVER-FSHARP-OVERLAY] Live-Buffer Resolution + +Hover MUST resolve against the editor's **in-memory buffer**, not the on-disk file. The Rust host routes `textDocument/didOpen` and `didChange` by document language to the owning sidecar. The F# sidecar keeps an authoritative document-state overlay keyed by absolute file path, and every per-file analysis (hover, completion, signature help, and others) reads from it, falling back to disk only when no open buffer exists. This overlay is state, not memoization. The C# sidecar updates its Roslyn workspace in place on `didChange`. + +#### [HOVER-FSHARP-OVERLAY-CHECK] Canonical Check Funnel + +Every per-file FCS analysis (hover, completion, diagnostics, signature help, inlay hints, code fixes, and file ordering) MUST use the canonical `parseAndCheckOnce` operation through its `checkFileWithParse` or `checkFile` view instead of calling `FSharpChecker.ParseAndCheckFileInProject` directly. This centralizes overlay-aware source resolution and `FSharpCheckFileAnswer` handling, ensures checks use the latest `didChange` text, and lets a reverted buffer clear phantom errors on the next pull (GitHub #160). + +`SidecarHost` processes IPC messages sequentially, awaiting each handler before reading the next frame, so `didChange` cannot arrive during a check. If dispatch becomes concurrent, checks MUST re-read buffer stability before publishing. The host-side `[DIAG-PUSH-GATE]` in [DIAGNOSTICS-SPEC.md](DIAGNOSTICS-SPEC.md) independently prevents stale results from being published. + +## [HOVER-CACHING] Caching Strategy + +Hover results MUST be memoized only through the [salsa](https://salsa-rs.github.io/salsa/) database in the Rust host. Sidecars and clients MUST NOT maintain hover-result caches. + +| Salsa Query Input | Invalidation Trigger | |---|---| -| `(document_uri, document_version, position)` | Document edit (version change) | -| Semantic model snapshot | Any document change in the project | +| `(document_uri, document_version, position, language)` | Document version change | +| Project generation | Any project or referenced-document change | -The Rust host SHOULD cache the most recent hover result per document and return it immediately if the position and version match. Stale hover requests for superseded document versions MUST be cancelled. +The Rust host SHOULD reuse the salsa result when all query inputs match. It MUST NOT maintain a separate most-recent-result slot or ad-hoc map. Stale hover requests for superseded document versions MUST be cancelled. -## 7. Performance Requirements +## [HOVER-PERFORMANCE] Performance Requirements | Metric | Target | Measurement | |---|---|---| @@ -199,7 +173,7 @@ The Rust host SHOULD cache the most recent hover result per document and return | Hover for cached position | <1ms | salsa cache hit | | Tree-sitter pre-validation | <1ms | Whitespace/comment rejection | -## 8. Error Handling +## [HOVER-ERRORS] Error Handling | Condition | Response | |---|---| @@ -207,15 +181,15 @@ The Rust host SHOULD cache the most recent hover result per document and return | Sidecar not ready / loading | Return `null` with `window/showMessage` notification | | Symbol resolution fails | Return `null` | | XML documentation unavailable | Return signature without documentation section | -| Sidecar crashes during hover | Return `null`, trigger crash recovery (see SHARPLSP-SPEC §5) | +| Sidecar crashes during hover | Return `null`, trigger [SHARPLSP-ARCHITECTURE-SIDECARS](SHARPLSP-SPEC.md) recovery | Hover MUST NOT block, hang, or return errors to the client. On any failure, return `null`. -## 9. Solution Explorer Tree Hover +## [HOVER-TREE] Solution Explorer Tree Hover The Solution Explorer tree view MUST use the **same hover** as the code editor. When a user hovers over a symbol in the tree, the tooltip MUST be identical to the tooltip shown when hovering over the same symbol in the code editor. -### Implementation +### [HOVER-TREE-IMPLEMENTATION] Implementation Tree item tooltips are resolved via `resolveTreeItem()`, which calls `vscode.executeHoverProvider` at the symbol's source position. This triggers the exact same `textDocument/hover` LSP request pipeline (Rust host -> sidecar -> Roslyn/FCS) used by the code editor. @@ -227,18 +201,4 @@ Tree item tooltips are resolved via `resolveTreeItem()`, which calls `vscode.exe | Project Reference | Static metadata (reference name) | | Solution / Project / Folder | No tooltip | -**Critical invariant:** Tree hover and code hover MUST produce identical content for the same symbol. They are the same code path. Any divergence is a bug. - -## 10. Competitive Parity Matrix - -| Feature | VS | CDK | Rider | SharpLsp Target | Priority | -|---|---|---|---|---|---| -| Basic symbol hover | ✓ | ✓ | ✓ | ✓ | P0 | -| XML doc rendering | ✓ | ✓ | ✓ | ✓ | P0 | -| Inferred type hover (`var`) | ✓ | ✓ | ✓ | ✓ | P0 | -| Exception documentation | ✓ | ✗ | ✓ | ✓ | P1 | -| Nullable annotation display | ✓ | ✓ | ✓ | ✓ | P1 | -| Deprecation warnings | ✓ | ✓ | ✓ | ✓ | P0 | -| NuGet package XML docs | ✓ | ✓ | ✓ | ✓ | P0 | -| Color preview in hover | ✓ | ✗ | ✓ | ✓ | P2 | -| Quick navigation from hover | ✓ | ✗ | ✓ | ✓ | P2 | +Tree hover and code hover MUST produce identical content for the same symbol; any divergence is a bug. diff --git a/docs/specs/NUGET-BROWSER-SPEC.md b/docs/specs/NUGET-BROWSER-SPEC.md index 47ed99ea..af876d90 100644 --- a/docs/specs/NUGET-BROWSER-SPEC.md +++ b/docs/specs/NUGET-BROWSER-SPEC.md @@ -1,63 +1,51 @@ -# NuGet Browser Specification +# [NUGET-BROWSER] NuGet Browser Specification **Parent:** [SHARPLSP-SPEC.md](SHARPLSP-SPEC.md) -## 1. Overview +## [NUGET-OVERVIEW] Overview SharpLsp provides a built-in NuGet package manager UI accessible from the Solution Explorer. Users can search, browse, install, update, and remove NuGet packages for any project in the solution. The UI is a webview panel rendered by the editor extension, but **all NuGet operations are routed through the LSP server** via custom requests. The extension NEVER talks directly to nuget.org or the dotnet CLI. **Priority:** P2 (Phase 4 - Essential Features) -**Design reference:** `docs/designs/code.html`, `docs/designs/screen.png` +**Design reference:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) -## 2. Architecture +## [NUGET-ARCHITECTURE] Architecture -### 2.1 Component Placement +### [NUGET-ARCHITECTURE-PLACEMENT] Component Placement -NuGet operations live in the **Rust LSP host** (Tier 1). The dotnet CLI runs as a child process managed by the host. No sidecar involvement. +The **Rust LSP host** owns requests, target discovery, NuGet API calls, `dotnet` child processes, restore notifications, and sidecar reloads. It delegates package-file mutations to the C# sidecar's MSBuild editor as specified by [NUGET-XML-DOM]. Editor extensions MUST NOT perform package operations. + +Implementations: [handlers.rs](../../src/sharplsp/src/nuget/handlers.rs), [nuget-browser.ts](../../src/editors/vscode/src/nuget-browser.ts), and the [host end-to-end tests](../../src/sharplsp/tests/nuget_e2e.rs). ``` -Editor Webview ──postMessage──> Extension ──LSP custom request──> Rust Host ──spawns──> dotnet CLI - │ - ├── dotnet list package - ├── dotnet add package - ├── dotnet remove package - └── HTTP fetch to nuget.org API +Editor Webview ──postMessage──> Extension ──LSP request──> Rust Host + ├── HTTP / dotnet list / dotnet restore + └── IPC edit ──> C# Sidecar ──> MSBuild DOM ``` -### 2.2 Why Rust Host, Not Sidecar - -- `dotnet` CLI operations are standalone commands, not Roslyn/FCS APIs -- No workspace or compilation context needed for package management -- NuGet.org search API is a simple HTTP GET - no .NET runtime required -- Keeps the extension editor-agnostic: any LSP client (Neovim, Helix, Zed) can consume the same requests -- Sidecar crash must not interfere with package management - -### 2.3 Why NOT the Extension +## [NUGET-XML-DOM] MSBuild XML Mutation -- Editor extensions must remain thin LSP clients -- Direct CLI/HTTP calls from the extension make the feature VS Code-only -- Other editors (Neovim, Helix, Zed) cannot reuse extension-side logic -- LSP is the single integration point for all editors +All `.csproj`, `.fsproj`, and `.props` package mutations MUST be delegated to the C# sidecar and performed with `Microsoft.Build.Construction.ProjectRootElement` using formatting preservation. String replacement, line splicing, and regex mutation are forbidden. Untouched whitespace, comments, attribute order, and conditional item groups MUST survive the edit. The Rust host orchestrates edits and starts background restore after the sidecar commits them. -## 3. LSP Custom Requests +## [NUGET-REQUESTS] LSP Custom Requests -### 3.0 Target Selection +### [NUGET-REQUESTS-TARGET] Target Selection **Critical:** every NuGet operation MUST be scoped to a concrete install target. The UI cannot assume the "current project" — the user MUST pick one explicitly from a dropdown rendered at the top of the panel (next to the Browse/Installed tabs). Without a selected target, the Install / Uninstall / Update actions MUST be disabled and display a tooltip "Select a target first". -#### 3.0.1 Target kinds +#### [NUGET-REQUESTS-TARGET-KINDS] Target Kinds A target is one of: | Kind | Example path | `dotnet` command | Notes | |------|--------------|------------------|-------| -| `project` | `/repo/src/Foo/Foo.csproj` | `dotnet add package …` | A single `.csproj` / `.fsproj`. | -| `project` | `/repo/src/Bar/Bar.fsproj` | `dotnet add package …` | Same as above for F#. | -| `buildProps` | `/repo/Directory.Build.props` | **Direct XML edit** — NOT `dotnet add` | `dotnet add` does not support props files. The Rust host edits the `` block directly, preserving formatting. Requires follow-up `dotnet restore` at the props file's directory. | +| `project` | `/repo/src/Foo/Foo.csproj` | MSBuild DOM edit + restore | A single `.csproj` / `.fsproj`. | +| `project` | `/repo/src/Bar/Bar.fsproj` | MSBuild DOM edit + restore | Same as above for F#. | +| `buildProps` | `/repo/Directory.Build.props` | MSBuild DOM edit + restore | The sidecar adds or updates `` through [NUGET-XML-DOM], followed by restore at the props file's directory. | | `buildProps` | `/repo/src/Directory.Packages.props` | Central Package Management | When CPM is enabled (`ManagePackageVersionsCentrally=true`), version lives in `Directory.Packages.props` as ``, and the `` in the csproj has no `Version=`. The host must detect CPM and route accordingly. | -#### 3.0.2 `sharplsp/nuget/targets` +#### [NUGET-REQUESTS-TARGET-ENUMERATE] `sharplsp/nuget/targets` Enumerate all valid install targets in the currently open solution/workspace. @@ -95,7 +83,7 @@ interface NuGetTarget { - Detect CPM by parsing the nearest `Directory.Packages.props` and checking `ManagePackageVersionsCentrally`. - Persist last-used target per workspace (via extension `Memento` / workspaceState) so the dropdown defaults to it next session. -#### 3.0.3 UI contract +#### [NUGET-REQUESTS-TARGET-UI] UI Contract - A **target dropdown** is rendered in the panel header, to the **right of the tabs, left of the search box**. - The dropdown lists projects first (grouped under a "Projects" header), then props files (grouped under a "Build Props" header). @@ -106,7 +94,7 @@ interface NuGetTarget { - When CPM is enabled, installing to a `project` target MUST transparently update `Directory.Packages.props` (add/update ``) AND the csproj (`` without a version). The host handles this — the UI does not care. - When CPM is enabled AND the user explicitly picks the `Directory.Packages.props` target, the operation is a pure version-management edit (add/update `` only; no `` is touched). -### 3.1 `sharplsp/nuget/search` +### [NUGET-REQUESTS-SEARCH] `sharplsp/nuget/search` Search nuget.org for packages matching a query. @@ -115,7 +103,7 @@ Search nuget.org for packages matching a query. ```typescript interface NuGetSearchParams { query: string; // Search query (empty = popular packages) - target: NuGetTarget; // Target (§ 3.0) — used to resolve isInstalled / installedVersion + target: NuGetTarget; // [NUGET-REQUESTS-TARGET], used to resolve installation state prerelease: boolean; // Include prerelease versions take: number; // Max results (default 50) skip: number; // Pagination offset (default 0) @@ -150,9 +138,9 @@ interface NuGetPackageInfo { - When `query` is empty, return popular packages (curated list of high-download-count packages) - Cross-reference results with installed packages in the target project - HTTP GET to `https://azuresearch-usnc.nuget.org/query?q={query}&prerelease={prerelease}&take={take}&skip={skip}` -- Cache search results for 60s to avoid hammering the API +- Memoize search only through a Rust-host salsa query keyed by query, prerelease flag, `take`, `skip`, and a 60-second epoch input -### 3.2 `sharplsp/nuget/versions` +### [NUGET-REQUESTS-VERSIONS] `sharplsp/nuget/versions` Get all available versions for a specific package. @@ -176,7 +164,7 @@ interface NuGetVersionsResponse { - HTTP GET to `https://api.nuget.org/v3-flatcontainer/{id}/index.json` - Return versions in reverse chronological order (newest first) -### 3.3 `sharplsp/nuget/installed` +### [NUGET-REQUESTS-INSTALLED] `sharplsp/nuget/installed` List installed packages for a target. @@ -184,7 +172,7 @@ List installed packages for a target. ```typescript interface NuGetInstalledParams { - target: NuGetTarget; // § 3.0 + target: NuGetTarget; // [NUGET-REQUESTS-TARGET] } ``` @@ -206,9 +194,9 @@ interface InstalledPackageInfo { - Executes `dotnet list package --format json` - Parses JSON output to extract installed packages across all target frameworks -### 3.4 `sharplsp/nuget/install` +### [NUGET-REQUESTS-INSTALL] `sharplsp/nuget/install` -Install or update a NuGet package against a chosen target (see § 3.0). +Install or update a NuGet package against a [NUGET-REQUESTS-TARGET]. **Request:** @@ -233,15 +221,15 @@ interface NuGetInstallResponse { **Behavior by target kind:** - `target.kind === "project"`: - - **CPM disabled:** `dotnet add package --version `. - - **CPM enabled:** edit `Directory.Packages.props` to add/update ``, then edit the csproj to add `` (no `Version`). Do NOT shell out to `dotnet add` in CPM mode — it writes a `Version=` attribute that violates CPM. + - **CPM disabled:** the sidecar adds or updates `` through [NUGET-XML-DOM], then the host starts background `dotnet restore`. + - **CPM enabled:** the sidecar adds or updates `` in `Directory.Packages.props`, then adds `` without `Version` to the project; the host starts background restore. - `target.kind === "buildProps"`: - - Parse the props XML (preserving whitespace / comments), locate an `` containing `` (create one if none exists), and add/update ``. When the file is `Directory.Packages.props`, use `` instead of ``. - - After writing, run `dotnet restore` at the props file's directory so the lockfile and `obj/project.assets.json` for every consuming project refresh. + - Through [NUGET-XML-DOM], locate an `` containing `` (create one if absent) and add or update ``. For `Directory.Packages.props`, use ``. + - After writing, start `dotnet restore` at the props file's directory in the background so the lockfile and `obj/project.assets.json` for every consuming project refresh. - On success, trigger sidecar workspace reload for every project that transitively imports the modified file. - Return `modifiedFiles` so the UI can show a toast like `Updated Directory.Build.props`. -### 3.5 `sharplsp/nuget/uninstall` +### [NUGET-REQUESTS-UNINSTALL] `sharplsp/nuget/uninstall` Remove a NuGet package from a target. @@ -266,28 +254,26 @@ interface NuGetUninstallResponse { **Behavior by target kind:** -- `target.kind === "project"`: `dotnet remove package ` (CPM aware — if CPM is on and the package version lives in `Directory.Packages.props`, also prompt the user whether to remove the `` entry). -- `target.kind === "buildProps"`: edit the XML to remove the matching `` / `` node, then `dotnet restore`. +- `target.kind === "project"`: remove the matching `` from the project XML. With CPM, also prompt whether to remove its `` from `Directory.Packages.props`. +- `target.kind === "buildProps"`: edit the XML to remove the matching `` or `` node, then start background `dotnet restore`. - On success, trigger sidecar workspace reload. -## 3A. Loading State & Instant Feedback +## [NUGET-FEEDBACK] Loading and Feedback -The current UI looks frozen because long-running operations (`dotnet add`, `dotnet restore`, search) give no visible feedback. That is a P0 bug. The spec now hard-requires the following: +### [NUGET-FEEDBACK-SPINNERS] Spinners -### 3A.1 Spinners — every async operation - -Every LSP round trip MUST show a spinner at a location that tells the user *what* is loading. Spinners use the Material Symbols `progress_activity` icon with a CSS `@keyframes spin` rotation (1 s linear infinite). No emoji, no text-only "Loading…". +Every LSP round trip MUST show a spinner at a location that identifies what is loading. Spinners use a local accessible inline SVG or text symbol with a CSS `@keyframes spin` rotation (1s linear infinite); remote icon fonts, emoji, and text-only "Loading…" are forbidden by [WEB-DESIGN-TYPE]. | Operation | Spinner location | Extra UI | |-----------|------------------|----------| | `sharplsp/nuget/targets` (initial) | Target dropdown shows a centered spinner in place of its label. | Tabs / search disabled. | -| `sharplsp/nuget/installed` | Inline spinner row at the top of the package list under the "Installed" tab. | Cached stale list stays visible underneath. | +| `sharplsp/nuget/installed` | Inline spinner row at the top of the package list under the "Installed" tab. | The currently rendered list stays visible until replacement state arrives. | | `sharplsp/nuget/search` | Spinner inside the search box (right edge, replacing the search icon) AND a skeleton-list in the results area on first search. | Debounce 250 ms before firing. | | `sharplsp/nuget/versions` | Spinner next to the version dropdown in the details panel. | Dropdown disabled until resolved. | | `sharplsp/nuget/install` / `update` | Spinner replaces the Install button label ("Installing…" + spinner). Details panel shows a progress strip. | Global non-blocking toast: `Installing into …` | | `sharplsp/nuget/uninstall` | Spinner replaces the Uninstall button label. | Global toast. | -### 3A.2 Optimistic UI +### [NUGET-FEEDBACK-OPTIMISTIC] Optimistic UI Install / uninstall MUST update the UI optimistically: @@ -296,19 +282,19 @@ Install / uninstall MUST update the UI optimistically: 3. On success, swap the spinner for a checkmark for 1.5 s, then clear. 4. On failure, revert the optimistic state AND show an error toast with the LSP error message. -### 3A.3 Cancellation +### [NUGET-FEEDBACK-CANCELLATION] Cancellation Every spinner-bearing operation MUST be cancellable. When the user switches targets, re-types in the search box, or navigates away, any in-flight request for the previous state MUST be cancelled via LSP `$/cancelRequest`. The Rust host MUST honor cancellation — in particular, `dotnet` child processes spawned for a cancelled request MUST be killed. -### 3A.4 Install latency budget +### [NUGET-FEEDBACK-LATENCY] Install Latency Budget -`dotnet add` on a warm machine typically takes 2–8 s because of NuGet restore. That's **not acceptable as a blocking modal**. The contract is: +Install and restore MUST NOT block the UI: -- **< 100 ms**: optimistic UI update is visible (§ 3A.2 step 1). -- **< 500 ms**: spinner + toast visible (§ 3A.1). -- **Host-side fast path**: for `kind: "project"` without CPM, the host MUST edit the csproj XML directly to add the `` first, *then* fire `dotnet restore` in the background. The LSP `install` response returns as soon as the XML edit is committed (typically < 50 ms). The subsequent restore is reported via a separate `sharplsp/nuget/restoreProgress` notification (see § 3.6) so the UI can keep its spinner until restore finishes, without blocking the user from clicking Install on the next package. +- **< 100 ms**: the [NUGET-FEEDBACK-OPTIMISTIC] update is visible. +- **< 500 ms**: the [NUGET-FEEDBACK-SPINNERS] spinner and toast are visible. +- **Edit fast path**: for `kind: "project"` without CPM, the host delegates the `` edit through [NUGET-XML-DOM], then runs `dotnet restore` in the background. The `install` response returns after the edit commits, typically in <50 ms. [NUGET-FEEDBACK-RESTORE] keeps the spinner active until restore finishes without blocking further package operations. -### 3.6 `sharplsp/nuget/restoreProgress` (server → client notification) +### [NUGET-FEEDBACK-RESTORE] `sharplsp/nuget/restoreProgress` ```typescript interface NuGetRestoreProgress { @@ -320,42 +306,27 @@ interface NuGetRestoreProgress { Fired by the Rust host while `dotnet restore` runs in the background after a fast-path XML edit. The extension routes these to the webview so the spinner can stay alive and the toast updates (`Restoring…` → `Restored` / `Restore failed`). -## 4. Webview UI - -### 4.1 Design +## [NUGET-WEBVIEW] Webview UI -The NuGet browser uses a webview panel rendered by the editor extension. The design follows the Material Design 3 dark theme specified in `docs/designs/code.html`. +### [NUGET-WEBVIEW-DESIGN] Design -> ⚠️ **CRITICAL — Read [`docs/designs/DESIGN.md`](../designs/DESIGN.md) § 0 -> before touching this UI.** The mockups in `code.html` and `screen.png` show -> a full IDE window for context. The activity bar (left icon column) and -> status bar (blue bar at the bottom of the mockup) belong to **VS Code -> itself** and **MUST NOT** be reimplemented in the webview panel. The panel -> renders **only** the header (tabs + search + refresh), package list, and -> details panel — nothing else. +The NuGet browser uses a webview panel rendered by the editor extension and MUST follow [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md). It renders only its header, package list, and details panel; VS Code supplies the surrounding activity and status bars. **Key design requirements:** -- Material Symbols Outlined icons (NOT emoji) -- Inter font family -- M3 dark color tokens (see `docs/designs/code.html` tailwind config) +- Local accessible text symbols or inline SVG icons, never emoji or external icon fonts ([WEB-DESIGN-TYPE]) +- System UI font stack ([WEB-DESIGN-TYPE]) +- Semantic light and dark tokens ([WEB-DESIGN-COLOR]) - Two-column layout: package list | details panel - Tabs: Browse | Installed -- **Target dropdown** (§ 3.0.3) between tabs and search — lists projects AND `Directory.Build.props` / `Directory.Packages.props` -- **Spinners** for every async op (§ 3A.1) — no blank/frozen states ever -- **NO** activity bar (VS Code provides one) -- **NO** status bar (VS Code provides one) +- **Target dropdown** ([NUGET-REQUESTS-TARGET-UI]) between tabs and search — lists projects AND `Directory.Build.props` / `Directory.Packages.props` +- **Spinners** for every async operation ([NUGET-FEEDBACK-SPINNERS]) - **NO** decorative buttons without real handlers -### 4.2 Layout Structure - -The panel renders only what's inside the editor area. Activity bar and -status bar shown below are **VS Code's own chrome** — drawn here for -orientation only, NOT part of the panel. +### [NUGET-WEBVIEW-LAYOUT] Layout Structure ``` -[VS Code activity bar — NOT part of panel] +-----------------------------------------------------------------+ -| Header: [logo] [Browse|Installed] [Target ▾] [search] [refresh] | ← panel starts +| Header: [logo] [Browse|Installed] [Target ▾] [search] [refresh] | +---------------------------+-------------------------------------+ | Package List | Details Panel | | | | @@ -364,8 +335,7 @@ orientation only, NOT part of the panel. | [Package Item] | [Description] | | [Package Item] | [Info Grid] | | | [Tags] | -+---------------------------+-------------------------------------+ ← panel ends -[VS Code status bar — NOT part of panel] ++---------------------------+-------------------------------------+ ``` Target dropdown contents (example): @@ -380,7 +350,7 @@ Build Props src/Directory.Packages.props (CPM) ``` -### 4.3 Extension Responsibilities +### [NUGET-WEBVIEW-EXTENSION] Extension Responsibilities The extension is responsible ONLY for: 1. Creating and managing the webview panel lifecycle @@ -395,20 +365,20 @@ The extension MUST NOT: - Parse .csproj/.fsproj files - Perform any NuGet logic -### 4.4 Message Flow +### [NUGET-WEBVIEW-FLOW] Message Flow ``` User clicks "Install" in webview -> webview postMessage({ command: "install", data: { packageId, version } }) -> extension receives message - -> extension sends LSP request: sharplsp/nuget/install { projectPath, packageId, version } - -> Rust host executes dotnet add ... - -> Rust host returns { success: true, message: "..." } + -> extension sends LSP request: sharplsp/nuget/install { target, packageId, version } + -> Rust host delegates the target edit to the C# sidecar and starts background restore + -> Rust host returns { success: true, message: "...", modifiedFiles: [...] } -> extension forwards result to webview -> webview updates UI ``` -## 5. Error Handling +## [NUGET-ERRORS] Error Handling All LSP responses use `Result` semantics: - Success: return the typed response @@ -418,26 +388,24 @@ The extension displays errors via: - `vscode.window.showErrorMessage()` for critical failures - Inline error state in the webview for recoverable errors (e.g., search timeout) -## 6. Performance Targets +## [NUGET-PERFORMANCE] Performance Targets -Every target below is **end-to-end, user-perceived** — measured from click to UI update, not just from LSP send to LSP response. Spinners (§ 3A.1) MUST appear within the "first paint" budget of each row. +Every target below is end-to-end, measured from click to UI update. [NUGET-FEEDBACK-SPINNERS] MUST appear within each row's first-paint budget. | Operation | First paint (spinner/optimistic) | LSP response | Full completion | Method | |-----------|----------------------------------|--------------|-----------------|--------| -| Open panel | < 50 ms | `sharplsp/nuget/targets` < 300 ms | < 1 s | Targets cached per workspace; refresh in background. | -| Search | < 50 ms (spinner) | < 500 ms p95 | < 500 ms p95 | HTTP GET with 60 s cache; 250 ms debounce before firing. | -| List installed | < 50 ms (spinner over stale cache) | < 300 ms from cache, < 2 s cold | < 2 s | `dotnet list` cold; subsequent calls served from in-memory cache keyed by target + csproj mtime. | -| Version list | < 50 ms (spinner) | < 500 ms | < 500 ms | HTTP GET with 5 min cache. | -| Install (project, no CPM) | < 100 ms (optimistic) | **< 150 ms** (XML fast path) | restore < 10 s (background, reported via `restoreProgress`) | Host edits csproj XML directly, returns immediately, fires `dotnet restore` in background. | -| Install (project, CPM) | < 100 ms (optimistic) | **< 150 ms** (XML fast path) | restore < 10 s (background) | Host edits `Directory.Packages.props` + csproj, then background restore. | -| Install (buildProps) | < 100 ms (optimistic) | **< 200 ms** (XML edit) | restore < 10 s (background) | Host edits props XML, then background restore at the props directory. | +| Open panel | < 50 ms | `sharplsp/nuget/targets` < 300 ms | < 1 s | Rust-host salsa query keyed by workspace root and filesystem generation; refresh in background. | +| Search | < 50 ms (spinner) | < 500 ms p95 | < 500 ms p95 | Rust-host salsa query with a 60s epoch input; 250ms debounce before firing. | +| List installed | < 50 ms (spinner over rendered state) | < 300 ms from salsa, < 2 s cold | < 2 s | `dotnet list` on salsa miss; query inputs include target and project-file fingerprint. | +| Version list | < 50 ms (spinner) | < 500 ms | < 500 ms | Rust-host salsa query with a five-minute epoch input. | +| Install (project, no CPM) | < 100 ms (optimistic) | **< 150 ms** (XML fast path) | restore < 10 s (background, reported via `restoreProgress`) | Sidecar commits [NUGET-XML-DOM], host starts background restore. | +| Install (project, CPM) | < 100 ms (optimistic) | **< 150 ms** (XML fast path) | restore < 10 s (background) | Sidecar edits `Directory.Packages.props` + project, host starts restore. | +| Install (buildProps) | < 100 ms (optimistic) | **< 200 ms** (XML edit) | restore < 10 s (background) | Sidecar edits props, host restores at the props directory. | | Uninstall | < 100 ms (optimistic) | < 200 ms (XML edit) | restore < 10 s (background) | Same fast-path model as install. | -**Non-negotiable:** the user must never wait > 200 ms for the Install button to visibly respond. If the restore is slow, the spinner keeps spinning in the background — the user is free to keep browsing, installing other packages, or close the panel. +## [NUGET-TESTS] Testing -## 7. Testing - -### 7.1 Rust LSP Host Tests (E2E) +### [NUGET-TESTS-HOST] Rust LSP Host Tests - [ ] `sharplsp/nuget/targets` enumerates all `.csproj`, `.fsproj`, `Directory.Build.props`, `Directory.Packages.props` in workspace - [ ] `sharplsp/nuget/targets` detects Central Package Management @@ -460,7 +428,7 @@ Every target below is **end-to-end, user-perceived** — measured from click to - [ ] Error handling: nonexistent package returns error - [ ] Error handling: malformed `Directory.Build.props` returns a structured parse error -### 7.2 Extension Tests (VSIX) +### [NUGET-TESTS-EXTENSION] Extension Tests - [ ] NuGet browser panel opens from command - [ ] Panel reuses existing instance (singleton) @@ -482,13 +450,6 @@ Every target below is **end-to-end, user-perceived** — measured from click to - [ ] Tab switching triggers correct data reload - [ ] Panel disposes cleanly -## 8. Editor Support Matrix - -| Editor | NuGet Search | Install/Remove | Browse UI | -|--------|-------------|----------------|-----------| -| VS Code | LSP request | LSP request | Webview panel | -| Neovim | LSP request | LSP request | Telescope picker (future) | -| Helix | LSP request | LSP request | CLI prompt (future) | -| Zed | LSP request | LSP request | Custom panel (future) | +## [NUGET-EDITORS] Editor Support Matrix -All editors share the same LSP requests. Only the UI layer differs per editor. +VS Code provides the webview in [NUGET-WEBVIEW]; other editors may consume the same custom requests, but this specification mandates no additional editor UI. diff --git a/docs/specs/PACKAGE-MAINTENANCE-SPEC.md b/docs/specs/PACKAGE-MAINTENANCE-SPEC.md index 399c775f..eceb1bb2 100644 --- a/docs/specs/PACKAGE-MAINTENANCE-SPEC.md +++ b/docs/specs/PACKAGE-MAINTENANCE-SPEC.md @@ -1,155 +1,82 @@ -# Package Maintenance Spec +# [PKG-MAINTENANCE] Package Maintenance Specification -Solution-Explorer context-menu actions that keep a .NET solution's NuGet -references tidy. Two operations, both editor-agnostic (the logic lives in the -Rust host / sidecars; the VS Code extension is a thin shell). +The Rust host and sidecars implement two NuGet-maintenance operations exposed through Solution Explorer; the VS Code extension is a thin client. -All NuGet plumbing reuses the existing `src/nuget/` module — `xml_edit` -(trivia-preserving `PackageReference`/`PackageVersion` edits), `targets` -(workspace/solution enumeration + CPM detection), `cli` (`dotnet list` / -`restore`) and the `sharplsp/nuget/*` request family. No new XML editor, no new -restore pipeline. +Both operations MUST reuse [the Rust NuGet modules](../../src/sharplsp/src/nuget/): `edit` delegates formatting-preserving `PackageReference`/`PackageVersion` mutations to the C# sidecar's `Microsoft.Build.Construction.ProjectRootElement`, `parse` reads package items, `targets` enumerates workspaces and detects CPM, and `cli` runs `dotnet list`/`restore`. They MUST NOT introduce another XML editor or restore pipeline. -C# and F# are equal first-class citizens: unused-package detection is wired for -both Roslyn (`.csproj`) and FSharp.Compiler.Service (`.fsproj`). +Unused-package detection MUST support Roslyn `.csproj` and FSharp.Compiler.Service `.fsproj` projects. + +Primary entry points: [unused.rs](../../src/sharplsp/src/nuget/unused.rs), [consolidate.rs](../../src/sharplsp/src/nuget/consolidate.rs), [WorkspaceManager.Packages.cs](../../src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Packages.cs), and [package-maintenance.ts](../../src/editors/vscode/src/package-maintenance.ts). ## [PKG-UNUSED] Remove Unused Packages -Remove direct `` entries whose assemblies are not referenced -by any code in the project. Available on **project** nodes and on the -**solution** node (where it runs across every project). +Remove direct `` entries whose assemblies are not referenced by project code. The action is available on project nodes and on the solution node, where it runs across every project. ### [PKG-UNUSED-DETECT-CS] C# detection (Roslyn) -For a `.csproj`, the C# sidecar resolves the project in its loaded -`MSBuildWorkspace`, builds the `Compilation`, and calls -`Compilation.GetUsedAssemblyReferences()` — Roslyn's canonical "which references -are actually used" API. Every `PortableExecutableReference` is classified as -used / unused; assembly file paths are mapped back to packages -(see [PKG-UNUSED-MAP]). A package is **unused** iff it contributes at least one -compile-time assembly to the compilation and **none** of those assemblies is in -the used set. +For a `.csproj`, the C# sidecar resolves the project in its loaded `MSBuildWorkspace`, builds the `Compilation`, and calls `Compilation.GetUsedAssemblyReferences()`. Every `PortableExecutableReference` is classified as used or unused, and assembly paths are mapped to packages through [PKG-UNUSED-MAP]. A package is unused iff it contributes at least one compile-time assembly and none of those assemblies is in the used set. ### [PKG-UNUSED-DETECT-FS] F# detection (FCS) -For an `.fsproj`, the F# sidecar resolves the package's compile assemblies from -`obj/project.assets.json`, builds an **isolated** `FSharpProjectOptions` that -includes those `-r:` references (the persistent workspace options are left -untouched so other F# features are unaffected), runs `ParseAndCheckProject` -with `keepAssemblyContents = true`, and walks the typed assembly contents to -collect the set of assemblies whose entities are actually referenced. The -used/unused classification and package mapping are identical to the C# path. +For an `.fsproj`, the F# sidecar resolves package compile assemblies from `obj/project.assets.json`, builds isolated `FSharpProjectOptions` containing those `-r:` references without modifying persistent workspace options, runs `ParseAndCheckProject` with `keepAssemblyContents = true`, and walks typed assembly contents to collect assemblies whose entities are referenced. Classification and package mapping then follow the C# path. ### [PKG-ASSETS-FS] Restored-package reference resolution -`FSharpAssets` is the single source of truth for turning `obj/project.assets.json` -into FCS `-r:` reference arguments, shared by the persistent workspace options -and the unused-package analysis so the compiler sees one reference set across -diagnostics, hover, and usage. +`FSharpAssets` is the single source of truth for turning `obj/project.assets.json` into FCS `-r:` arguments. Persistent workspace options and unused-package analysis MUST use it so diagnostics, hover, and usage share one reference set. Rules: -- **Fail-safe**: a missing or malformed assets file yields no references (the - caller falls back to framework-only options) rather than an error. -- **Existence-gated**: compile paths that do not exist on disk are dropped — - a missing-assembly reference would itself surface as a false diagnostic. -- **Placeholders are never references**: NuGet emits `_._` placeholder files - ("no assemblies for this TFM") **path-qualified** in the compile section - (e.g. `lib/netstandard1.0/_._` from `netstandard.library`), and the - placeholder physically exists inside the package folder. The filter must - match the *filename component*, not the whole compile key — handing `_._` - to FCS as a reference attaches FS0229/FS3160 startup errors to **every** - checked file: standing phantom errors no edit can clear (GitHub #160, - observed against FsToolkit.ErrorHandling). +- **Fail-safe**: a missing or malformed assets file yields no references; the caller falls back to framework-only options. +- **Existence-gated**: compile paths that do not exist on disk are dropped because a missing-assembly reference would produce a false diagnostic. +- **Placeholders are never references**: NuGet emits path-qualified `_._` compile placeholders (for example `lib/netstandard1.0/_._`) that can exist physically. The filter MUST compare the filename component, not the whole compile key; passing `_._` to FCS attaches FS0229/FS3160 startup errors to every checked file (GitHub #160). ### [PKG-UNUSED-MAP] Assembly → package mapping -NuGet restores package assemblies under the global packages folder as -`///lib//.dll`. The package -id is the path segment immediately under the global packages root. The mapping -is a pure function over the assembly path and is therefore unit-testable without -a live compilation. Assemblies that do not resolve to a package (framework -reference assemblies, project-to-project references) are ignored — they are -never reported as unused packages. +NuGet restores package assemblies as `///lib//.dll`; the package ID is the segment immediately below the global packages root. Assemblies outside a package, including framework and project references, are ignored and never reported as unused packages. -Conservatism is mandatory: a package is only ever reported unused when it has a -resolvable compile assembly that is provably not used. Packages contributing no -compile-time assembly (analyzers, build/tooling, MSBuild-only, runtime -metapackages) are **never** flagged, because their usage cannot be proven from -the compilation reference set. +A package is reported unused only when it has a resolvable compile assembly that is provably unused. Packages with no compile-time assembly, including analyzers, build/tooling, MSBuild-only packages, and runtime metapackages, are never flagged. ### [PKG-UNUSED-REQUEST] Request flow -`sharplsp/nuget/unused` (host request): params carry the project path (and -optional solution-wide flag). The host picks the sidecar by file extension, -forwards a `project/unusedPackages` sidecar request, intersects the returned -candidate ids with the project's direct `` ids (a transitive -dependency is never in the project file and must never be "removed"), and -returns `{ projectPath, unused: [{ id, version }] }`. +`sharplsp/nuget/unused` accepts `{ projectPath }` for one `.csproj` or `.fsproj`. The host selects the sidecar by file extension, forwards `project/unusedPackages`, intersects returned candidate IDs with direct `` IDs so transitive dependencies are never removed, and returns `{ projectPath, unused: [{ id, version }] }`. Solution-node analysis enumerates descendant projects in the editor and sends one request per project. -Removal reuses the existing `sharplsp/nuget/uninstall` request per package id — -trivia-preserving XML removal plus a background restore. The host does not -invent a second removal path. +Removal MUST reuse `sharplsp/nuget/uninstall` per package ID, including trivia-preserving XML removal and a background restore. ### [PKG-UNUSED-UI] UX -- Command `sharplsp.removeUnusedPackages`, shown on `viewItem == project` and - `viewItem == solution` in `sharplsp.solutionExplorer`. -- Detect first; if none are unused, inform and stop. Otherwise show a modal - listing the packages to be removed and require explicit confirmation - (destructive, behaviour-changing). -- On the solution node, detection + confirmation aggregate across all projects; - the confirmation names each project and its unused packages. +- Command `sharplsp.removeUnusedPackages`, shown on `viewItem == project` and `viewItem == solution` in `sharplsp.solutionExplorer`. +- Detect first; if none are unused, inform and stop. Otherwise show a modal listing the packages to remove and require explicit confirmation. +- On the solution node, detection and confirmation aggregate across all projects; the confirmation names each project and its unused packages. - After removal the Solution Explorer refreshes reactively. ## [PKG-CONSOLIDATE] Consolidate Shared Packages to Directory.Build.props -Hoist NuGet packages that are referenced by **two or more** projects in the -solution into a single solution-root `Directory.Build.props`, declaring each -once and removing the per-project `` entries. Available on the -**solution** node. +Hoist NuGet packages referenced by two or more projects into a solution-root `Directory.Build.props`, declare each once, and remove their per-project `` entries. The action is available on the solution node. ### [PKG-CONSOLIDATE-SCAN] Scan -Enumerate every project under the solution directory (reuse `targets`). Parse -each project's direct `` ids + versions. A package is -**shared** when it appears in ≥ 2 projects. When versions differ across -projects the highest (by semantic ordering, lexical fallback) is chosen and the -divergence is reported. +Enumerate every project under the solution directory through `targets` and parse direct `` IDs and versions. A package is shared when it appears in at least two projects. When versions differ, select the highest by semantic ordering with lexical fallback and report the divergence. ### [PKG-CONSOLIDATE-APPLY] Apply -1. Ensure a `Directory.Build.props` exists at the solution root (create a - minimal `` if absent). -2. For each shared package, add it to `Directory.Build.props` and remove it from - every project that declared it, via `xml_edit`. -3. CPM-aware: when the solution has Central Package Management - (`Directory.Packages.props` with `ManagePackageVersionsCentrally=true`), the - hoisted `Directory.Build.props` entry is written **versionless** and the - version is ensured in `Directory.Packages.props` (``), matching - the existing install behaviour. +1. Ensure a `Directory.Build.props` exists at the solution root; create a minimal `` if absent. +2. For each shared package, use the C# sidecar's MSBuild DOM edit requests to add it to `Directory.Build.props` and remove it from every declaring project. +3. With Central Package Management (`Directory.Packages.props` and `ManagePackageVersionsCentrally=true`), write a versionless `Directory.Build.props` entry; the existing central `` remains authoritative. 4. Fire a single background restore for the modified files. -Hoisting to `Directory.Build.props` makes a package apply solution-wide; the -result message states exactly which packages moved, at which version, and which -projects were edited so the behaviour change is explicit and auditable. +Because hoisted packages apply solution-wide, the result MUST name each moved package, selected version, and edited project. ### [PKG-CONSOLIDATE-REQUEST] Request flow -`sharplsp/nuget/consolidate` (host request): params carry the solution path -(and/or workspace root). Pure Rust — no sidecar. Returns -`{ moved: [{ id, version, fromProjects: [...] }], propsFile, modifiedFiles }`. +`sharplsp/nuget/consolidate` accepts `{ solutionPath, dryRun }`. With `dryRun: true`, it returns the preview without modifying files. Apply mode uses the C# sidecar for MSBuild DOM edits and returns `{ message, moved: [{ id, version, fromProjects: [...] }], propsFile: string | null, modifiedFiles }`; a non-empty edit set triggers one background restore. ### [PKG-CONSOLIDATE-UI] UX - Command `sharplsp.consolidatePackages`, shown on `viewItem == solution`. -- Scan first; if nothing is shared, inform and stop. Otherwise show a modal - summarising what will move, then apply on confirmation and refresh. +- Scan first; if nothing is shared, inform and stop. Otherwise show a modal summarising what will move, then apply on confirmation and refresh. -## Non-goals +## [PKG-NONGOALS] Non-Goals - Transitive / framework / analyzer package pruning (cannot be proven unused). - Rewriting version ranges, floating versions, or condition-bearing references. -- Per-`` metadata (`PrivateAssets`, `IncludeAssets`) merging - beyond a straight hoist — references carrying item metadata are reported and - skipped rather than silently flattened. +- Per-`` metadata (`PrivateAssets`, `IncludeAssets`) merging beyond a straight hoist; references carrying item metadata or conditions are skipped rather than flattened. diff --git a/docs/specs/PROFILER-SPEC.md b/docs/specs/PROFILER-SPEC.md index 4446a6b8..f2d9413c 100644 --- a/docs/specs/PROFILER-SPEC.md +++ b/docs/specs/PROFILER-SPEC.md @@ -1,86 +1,75 @@ -# Profiler Integration Specification +# [PROFILER-INTEGRATION] Profiler Integration Specification **Parent:** [SHARPLSP-SPEC.md](SHARPLSP-SPEC.md) -## 1. Overview +## [PROFILER-OVERVIEW] Overview -SharpLsp integrates .NET diagnostic tools (`dotnet-trace`, `dotnet-counters`, `dotnet-dump`) directly into the editor via LSP custom requests, giving developers a simple UI around the standard .NET diagnostics CLI. No external tools, no terminal juggling — profile, trace, and analyze memory leaks from your editor. +SharpLsp exposes `dotnet-trace`, `dotnet-counters`, and `dotnet-dump` through LSP custom requests and editor UI. **Reference:** [dotnet-trace documentation](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-trace) -**Priority:** P2 (Phase 5 — Beyond Parity) +## [PROFILER-TOOLS] Diagnostic Tools -## 2. Diagnostic Tools - -### 2.1 dotnet-trace +### [PROFILER-TOOLS-TRACE] dotnet-trace Collects performance traces from running .NET processes using EventPipe. Produces `.nettrace` files convertible to Chromium/SpeedScope formats for visualization. -| Capability | CLI Equivalent | Description | -|-----------|---------------|-------------| -| List processes | `dotnet-trace ps` | Discover running .NET processes | -| Collect trace | `dotnet-trace collect -p ` | Attach and record EventPipe trace | -| Stop trace | Ctrl+C equivalent | Gracefully stop collection | -| Convert trace | `dotnet-trace convert` | Convert `.nettrace` to `.speedscope.json` or Chromium format | +| Capability | CLI equivalent | +|---|---| +| List processes | Native process table | +| Collect trace | `dotnet-trace collect -p ` | +| Stop trace | Ctrl+C equivalent | +| Convert trace | `dotnet-trace convert` to `.speedscope.json` or Chromium | -### 2.2 dotnet-counters +### [PROFILER-TOOLS-COUNTERS] dotnet-counters Real-time monitoring of .NET runtime performance counters (GC, CPU, exceptions, thread pool). -| Capability | CLI Equivalent | Description | -|-----------|---------------|-------------| -| List processes | `dotnet-counters ps` | Discover running .NET processes | -| Monitor counters | `dotnet-counters monitor -p ` | Stream live counter values | -| Collect counters | `dotnet-counters collect -p ` | Record counters to CSV/JSON | +| Capability | CLI equivalent | +|---|---| +| List processes | `dotnet-counters ps` | +| Monitor counters | `dotnet-counters monitor -p ` | +| Collect counters | `dotnet-counters collect -p ` to CSV/JSON | -### 2.3 dotnet-dump (Memory Leak Tracing) +### [PROFILER-TOOLS-DUMP] dotnet-dump Captures and analyzes process dumps for memory leak investigation without a native debugger. -| Capability | CLI Equivalent | Description | -|-----------|---------------|-------------| -| Collect dump | `dotnet-dump collect -p ` | Capture managed heap dump | -| Analyze dump | `dotnet-dump analyze ` | Open interactive analysis session | -| Heap stats | `dumpheap -stat` | Show object type counts and sizes | -| GC roots | `gcroot ` | Trace GC root references for an object | -| Object references | `dumpobj ` | Inspect individual managed objects | +| Capability | CLI equivalent | +|---|---| +| Collect dump | `dotnet-dump collect -p ` | +| Analyze dump | `dotnet-dump analyze ` | +| Heap stats | `dumpheap -stat` | +| GC roots | `gcroot ` | +| Object references | `dumpobj ` | -## 3. Architecture +## [PROFILER-ARCHITECTURE] Architecture -### 3.1 Component Placement +Implementations: [handlers.rs](../../src/sharplsp/src/profiler/handlers.rs), [session.rs](../../src/sharplsp/src/profiler/session.rs), [object_graph.rs](../../src/sharplsp/src/profiler/object_graph.rs), [profiler.ts](../../src/editors/vscode/src/profiler.ts), and the [full-stack profiler tests](../../src/sharplsp/tests/e2e_modules/profiler_full_stack.rs). -Profiler integration lives in the **Rust LSP host** (Tier 1). The diagnostic CLI tools run as child processes managed by the host — no sidecar involvement. +### [PROFILER-ARCHITECTURE-PLACEMENT] Component Placement -``` -Editor ──LSP custom request──▶ Rust Host ──spawns──▶ dotnet-trace / dotnet-counters / dotnet-dump - │ - ├── Process discovery (dotnet-trace ps) - ├── Session lifecycle (start / stop / convert) - └── Output parsing + streaming to editor -``` +The Rust host spawns the diagnostic CLIs and owns discovery, session lifecycle, output parsing, and editor streaming; no sidecar or workspace is involved. -### 3.2 Why Rust Host, Not Sidecar +### [PROFILER-ARCHITECTURE-HOST] Rust Host Ownership -- Diagnostic tools are standalone CLI executables, not Roslyn/FCS APIs -- No workspace or compilation context needed -- Direct process spawning from Rust is simpler and lower latency -- Sidecar crash must not kill profiling sessions +Profiler sessions MUST survive a sidecar crash and MUST be cleaned up when the LSP host shuts down. -### 3.3 Tool Discovery +### [PROFILER-ARCHITECTURE-DISCOVERY] Tool Discovery On startup (lazy, first use), the host locates diagnostic tools: -| Step | Action | Fallback | -|------|--------|----------| -| 1 | Check `PATH` for `dotnet-trace`, `dotnet-counters`, `dotnet-dump` | — | -| 2 | Check `dotnet tool list -g` output | — | -| 3 | If missing, prompt user to install via `dotnet tool install -g` | Return error with install instructions | +| Step | Action | +|------|--------| +| 1 | Check `PATH` for `dotnet-trace`, `dotnet-counters`, and `dotnet-dump` | +| 2 | Check `/.dotnet/tools` roots derived from `DOTNET_CLI_HOME`, `HOME`, and `USERPROFILE` on Windows | +| 3 | If missing, return an error containing the corresponding `dotnet tool install -g ` command | -## 4. LSP Custom Requests +## [PROFILER-PROTOCOL] LSP Custom Requests All profiler requests use the `sharplsp/` namespace. -### 4.1 Process Discovery +### [PROFILER-PROCESS-LIST] Process Discovery and Termination **Method:** `sharplsp/profiler/listProcesses` @@ -94,15 +83,32 @@ interface ListProcessesParams {} interface DotNetProcess { pid: number; name: string; - commandLine: string; + command_line: string; + /** Shared-framework version or target framework; null when unknown. Always present. */ + runtime_version: string | null; } type ListProcessesResult = DotNetProcess[]; ``` -Calls `dotnet-trace ps` and parses output. Returns all discoverable .NET processes. +The host enumerates the native process table through `ps` on Unix and `sysinfo` on Windows. It returns only `dotnet` host processes and apphosts whose output directory contains a `*.runtimeconfig.json`, sorted case-insensitively by name and then by PID. + +**Method:** `sharplsp/profiler/killProcess` + +```typescript +interface KillProcessParams { + pid: number; +} + +interface KillProcessResult { + killed: true; + pid: number; +} +``` + +The host MUST re-enumerate processes and refuse a PID that is not currently a .NET process. A valid target is forcibly terminated with `SIGKILL` on Unix or `taskkill /F` on Windows; a missing or non-.NET PID returns an error without terminating any process. -### 4.2 Trace Session +### [PROFILER-TRACE] Trace Session **Method:** `sharplsp/profiler/startTrace` @@ -117,15 +123,15 @@ interface StartTraceParams { /** Max duration in seconds. 0 = unlimited. Default: 30 */ duration?: number; /** Output file path. Auto-generated if omitted */ - outputPath?: string; + output_path?: string; } ``` **Result:** ```typescript interface StartTraceResult { - sessionId: string; - outputPath: string; + session_id: string; + output_path: string; } ``` @@ -134,22 +140,22 @@ interface StartTraceResult { **Params:** ```typescript interface StopTraceParams { - sessionId: string; + session_id: string; } ``` **Result:** ```typescript interface StopTraceResult { - outputPath: string; - fileSizeBytes: number; - durationMs: number; + output_path: string; + file_size_bytes: number; + duration_ms: number; } ``` -### 4.2.1 Trace File Conversion +#### [PROFILER-TRACE-CONVERSION] Trace File Conversion -A `.nettrace` file is not directly viewable — it must be converted to SpeedScope JSON (or Chromium JSON) before it can be opened in a visualizer. SharpLsp exposes an explicit conversion entrypoint so that any trace file on disk (including orphaned files from a previous session, a colleague's dump, or a CI artifact) can be opened in SharpLsp without re-recording. +A `.nettrace` file MUST be converted to SpeedScope or Chromium JSON before visualization. The conversion request accepts any trace file on disk and does not require a live session. **Method:** `sharplsp/profiler/convertTrace` @@ -157,7 +163,7 @@ A `.nettrace` file is not directly viewable — it must be converted to SpeedSco ```typescript interface ConvertTraceParams { /** Absolute path to a `.nettrace` file. */ - inputPath: string; + input_path: string; /** Output format: "speedscope" (default) or "chromium". */ format?: "speedscope" | "chromium"; } @@ -166,10 +172,10 @@ interface ConvertTraceParams { **Result:** ```typescript interface ConvertTraceResult { - /** Path to the converted file — always a sibling of inputPath. */ - outputPath: string; + /** Path to the converted file — always a sibling of input_path. */ + output_path: string; /** Size of the converted file in bytes. */ - fileSizeBytes: number; + file_size_bytes: number; } ``` @@ -177,12 +183,12 @@ Invokes `dotnet-trace convert --format `. The resulting sibling | Format | Output sibling | |--------|----------------| -| `speedscope` | `.speedscope.json` | -| `chromium` | `.chromium.json` | +| `speedscope` | Replace `.nettrace` with `.speedscope.json` | +| `chromium` | Replace `.nettrace` with `.chromium.json` | -Stopping a trace session (`sharplsp/profiler/stopTrace`) already runs this conversion automatically when the session produced data. `convertTrace` is for files where no live session exists — for example, when the editor was closed during recording, or when opening a `.nettrace` the user recorded elsewhere. +`sharplsp/profiler/stopTrace` automatically converts a session that produced data; `convertTrace` handles files with no live session. -### 4.3 Counter Monitoring +### [PROFILER-PROTOCOL-COUNTERS] Counter Monitoring **Method:** `sharplsp/profiler/startCounters` @@ -193,14 +199,14 @@ interface StartCountersParams { /** Counter providers. Default: ["System.Runtime"] */ providers?: string[]; /** Refresh interval in seconds. Default: 1 */ - refreshInterval?: number; + refresh_interval?: number; } ``` **Result:** ```typescript interface StartCountersResult { - sessionId: string; + session_id: string; } ``` @@ -210,14 +216,14 @@ Counter values streamed via LSP notification: ```typescript interface CounterUpdateParams { - sessionId: string; + session_id: string; counters: CounterValue[]; } interface CounterValue { provider: string; name: string; - displayName: string; + display_name: string; value: number; unit: string; } @@ -228,11 +234,11 @@ interface CounterValue { **Params:** ```typescript interface StopCountersParams { - sessionId: string; + session_id: string; } ``` -### 4.4 Memory Dump Collection +### [PROFILER-PROTOCOL-DUMP-COLLECT] Memory Dump Collection **Method:** `sharplsp/profiler/collectDump` @@ -240,48 +246,48 @@ interface StopCountersParams { ```typescript interface CollectDumpParams { pid: number; - /** Dump type: "full" | "heap" | "mini". Default: "heap" */ - dumpType?: string; + /** Dump type. Default: "Heap". */ + dump_type?: "Full" | "Heap" | "Mini"; /** Output file path. Auto-generated if omitted */ - outputPath?: string; + output_path?: string; } ``` **Result:** ```typescript interface CollectDumpResult { - outputPath: string; - fileSizeBytes: number; + output_path: string; + file_size_bytes: number; } ``` -### 4.5 Memory Dump Analysis +### [PROFILER-PROTOCOL-DUMP-ANALYZE] Memory Dump Analysis **Method:** `sharplsp/profiler/analyzeHeap` **Params:** ```typescript interface AnalyzeHeapParams { - dumpPath: string; + dump_path: string; /** Max rows to return. Default: 50 */ limit?: number; /** Filter by type name substring */ - typeFilter?: string; + type_filter?: string; } ``` **Result:** ```typescript interface HeapStats { - totalObjects: number; - totalSizeBytes: number; + total_objects: number; + total_size_bytes: number; types: HeapTypeInfo[]; } interface HeapTypeInfo { - typeName: string; + type_name: string; count: number; - totalSizeBytes: number; + total_size_bytes: number; } ``` @@ -290,9 +296,9 @@ interface HeapTypeInfo { **Params:** ```typescript interface FindGCRootsParams { - dumpPath: string; + dump_path: string; /** Object address (hex string) */ - objectAddress: string; + object_address: string; } ``` @@ -304,18 +310,18 @@ interface GCRootChain { interface GCRootNode { address: string; - typeName: string; - rootKind: string; + type_name: string; + root_kind: string; } type FindGCRootsResult = GCRootChain[]; ``` -## 5. Memory Leak Tracing Workflow +## [PROFILER-LEAKS] Memory Leak Tracing Workflow Memory leak investigation follows a structured workflow exposed through the UI: -### 5.1 Baseline → Exercise → Compare +### [PROFILER-LEAKS-WORKFLOW] Baseline → Exercise → Compare | Step | Action | Tool | |------|--------|------| @@ -326,7 +332,7 @@ Memory leak investigation follows a structured workflow exposed through the UI: | 5 | Identify growing types | Editor diff view of heap stats | | 6 | Trace GC roots of suspect objects | `sharplsp/profiler/findGCRoots` | -### 5.2 Live Counter Monitoring for Leak Detection +### [PROFILER-LEAKS-COUNTERS] Live Counter Monitoring Monitor `System.Runtime` counters to detect leaks in real-time: @@ -339,11 +345,11 @@ Monitor `System.Runtime` counters to detect leaks in real-time: The editor highlights counters that show sustained growth patterns. -### 5.3 Automated Leak Detection +### [PROFILER-LEAKS-AUTOMATION] Automated Leak Detection -SharpLsp automatically detects memory leaks by comparing two heap snapshots taken at different points in time. The user triggers "Baseline → Exercise → Compare" and SharpLsp does the analysis automatically. +Automated leak detection compares baseline and comparison heap snapshots. -#### 5.3.1 Heap Snapshot Diffing +#### [PROFILER-LEAKS-AUTOMATION-DIFF] Heap Snapshot Diffing **Method:** `sharplsp/profiler/diffHeapSnapshots` @@ -351,13 +357,13 @@ SharpLsp automatically detects memory leaks by comparing two heap snapshots take ```typescript interface DiffHeapSnapshotsParams { /** Path to the baseline dump file */ - baselineDumpPath: string; + baseline_dump_path: string; /** Path to the comparison dump file */ - comparisonDumpPath: string; + comparison_dump_path: string; /** Only show types where count or size grew. Default: true */ - growingOnly?: boolean; + growing_only?: boolean; /** Minimum growth percentage to report. Default: 10.0 */ - minGrowthPercent?: number; + min_growth_percent?: number; /** Max rows to return. Default: 50 */ limit?: number; } @@ -366,37 +372,37 @@ interface DiffHeapSnapshotsParams { **Result:** ```typescript interface HeapDiffResult { - baselineTotalObjects: number; - baselineTotalSizeBytes: number; - comparisonTotalObjects: number; - comparisonTotalSizeBytes: number; + baseline_total_objects: number; + baseline_total_size_bytes: number; + comparison_total_objects: number; + comparison_total_size_bytes: number; /** Types sorted by size growth descending */ diffs: HeapTypeDiff[]; /** Types flagged as probable leaks */ - leakSuspects: LeakSuspect[]; + leak_suspects: LeakSuspect[]; } interface HeapTypeDiff { - typeName: string; - baselineCount: number; - comparisonCount: number; - countDelta: number; - baselineSizeBytes: number; - comparisonSizeBytes: number; - sizeDeltaBytes: number; - growthPercent: number; + type_name: string; + baseline_count: number; + comparison_count: number; + count_delta: number; + baseline_size_bytes: number; + comparison_size_bytes: number; + size_delta_bytes: number; + growth_percent: number; } interface LeakSuspect { - typeName: string; + type_name: string; severity: "high" | "medium" | "low"; reason: string; - countDelta: number; - sizeDeltaBytes: number; + count_delta: number; + size_delta_bytes: number; } ``` -#### 5.3.2 Leak Classification Heuristics +#### [PROFILER-LEAKS-AUTOMATION-HEURISTICS] Leak Classification Heuristics SharpLsp classifies leak suspects by combining snapshot diff data with heuristics: @@ -409,9 +415,9 @@ SharpLsp classifies leak suspects by combining snapshot diff data with heuristic Additional signals that elevate severity: - Type is a known leak-prone pattern (event handlers, delegates, `CancellationTokenSource`, timers) - Type contains `[]` or `List` (collection growth) -- Multiple instances of the same generic type growing (e.g., `Dictionary` with different type args) +- Multiple instantiations of the same growing generic collection type -#### 5.3.3 Automated Leak Detection Flow +#### [PROFILER-LEAKS-AUTOMATION-FLOW] Automated Leak Detection Flow ```mermaid flowchart TD @@ -430,26 +436,26 @@ flowchart TD M --> N[Show Retention Path] ``` -## 5A. Object Graph Visualization +## [PROFILER-GRAPH] Object Graph Visualization -SharpLsp provides an interactive object retention graph that shows what objects exist in memory and what's holding on to them. This is the killer feature for memory leak investigation — you see the actual reference chains keeping objects alive. +SharpLsp provides an interactive graph of objects and the reference chains retaining them. -### 5A.1 Object Graph Data Model +### [PROFILER-GRAPH-DATA] Object Graph Data Model **Method:** `sharplsp/profiler/getObjectGraph` **Params:** ```typescript interface GetObjectGraphParams { - dumpPath: string; - /** Starting object address (hex). If omitted, starts from leak suspects */ - rootAddress?: string; + dump_path: string; + /** Required starting object address (hex). */ + root_address: string; /** Max depth to traverse from root. Default: 5 */ - maxDepth?: number; - /** Max nodes to return. Default: 200 */ - maxNodes?: number; + max_depth?: number; + /** Max nodes to return. Default: 100 */ + max_nodes?: number; /** Filter: only include paths through this type name (substring match) */ - typeFilter?: string; + type_filter?: string; } ``` @@ -466,19 +472,19 @@ interface ObjectGraphNode { /** Unique node ID (object address) */ id: string; /** Fully qualified type name */ - typeName: string; + type_name: string; /** Short display name (last segment of type) */ - displayName: string; + display_name: string; /** Size in bytes of this single object */ - sizeBytes: number; + size_bytes: number; /** Total retained size (this object + everything it keeps alive) */ - retainedSizeBytes: number; + retained_size_bytes: number; /** Number of instances of this type on the heap */ - instanceCount: number; + instance_count: number; /** Whether this node is a GC root */ - isRoot: boolean; - /** The kind of root if isRoot is true */ - rootKind?: "Static" | "ThreadLocal" | "Pinned" | "Finalizer" | "Stack"; + is_root: boolean; + /** Root classification when is_root is true. */ + root_kind?: string; /** Depth from the query root */ depth: number; } @@ -489,29 +495,29 @@ interface ObjectGraphEdge { /** Target node ID (the held object) */ to: string; /** Field name or index that holds the reference */ - fieldName: string; - /** Whether this is a strong or weak reference */ - referenceKind: "Strong" | "Weak"; + field_name: string; + /** Current implementation emits strong references only. */ + reference_kind: "Strong"; } interface ObjectGraphStats { - totalNodesTraversed: number; - totalEdgesTraversed: number; - maxDepthReached: number; + total_nodes_traversed: number; + total_edges_traversed: number; + max_depth_reached: number; truncated: boolean; } ``` -### 5A.2 Object Inspection +### [PROFILER-GRAPH-INSPECTION] Object Inspection **Method:** `sharplsp/profiler/inspectObject` **Params:** ```typescript interface InspectObjectParams { - dumpPath: string; + dump_path: string; /** Object address (hex string) */ - objectAddress: string; + object_address: string; } ``` @@ -519,29 +525,29 @@ interface InspectObjectParams { ```typescript interface ObjectInspection { address: string; - typeName: string; - sizeBytes: number; + type_name: string; + size_bytes: number; /** Field values for this object */ fields: ObjectField[]; /** Generation (0, 1, 2, LOH, POH) */ generation: string; /** Whether the object is pinned */ - isPinned: boolean; + is_pinned: boolean; } interface ObjectField { name: string; - typeName: string; + type_name: string; /** Value for primitives/strings, address for reference types */ value: string; /** Whether this field holds a reference to another managed object */ - isReference: boolean; - /** If isReference, the address of the referenced object */ - referenceAddress?: string; + is_reference: boolean; + /** If is_reference, the referenced object's address. */ + reference_address?: string; } ``` -### 5A.3 Architecture — How the Object Graph is Built +### [PROFILER-GRAPH-BUILD] Object Graph Construction The object graph is assembled from `dotnet-dump analyze` commands: @@ -549,7 +555,7 @@ The object graph is assembled from `dotnet-dump analyze` commands: flowchart LR A[getObjectGraph Request] --> B[dumpobj root_addr] B --> C[Parse Fields + References] - C --> D{Depth < maxDepth?} + C --> D{Depth < max_depth?} D -->|Yes| E[dumpobj each reference] E --> C D -->|No| F[Return Graph] @@ -566,34 +572,15 @@ Commands used per node: | `dumpheap -mt ` | Count all instances of a specific method table | | `objsize ` | Calculate retained size (object + transitive refs) | -### 5A.4 Interactive Graph Webview +### [PROFILER-GRAPH-WEBVIEW] Interactive Graph Webview -The object graph renders as an interactive force-directed graph in a VSCode webview panel. +The object graph renders as an interactive force-directed graph in a VS Code webview panel. -#### Graph Layout +#### [PROFILER-GRAPH-WEBVIEW-LAYOUT] Graph Layout -```mermaid -graph LR - subgraph GC Roots - R1[Static Field
AppState._cache] - R2[Thread Stack
Main] - end - - subgraph Retention Chain - A[Dictionary<string,Widget>
1.2 MB retained] - B[Widget[]
entries array] - C[Widget
48 bytes] - D[EventHandler
leak suspect ⚠️] - end - - R1 -->|_cache| A - A -->|entries| B - B -->|[42]| C - C -->|OnClick| D - R2 -->|local| A -``` +GC roots and retention chains MUST be connected by labelled reference edges; roots appear before retained objects in the initial layout. -#### Webview Features +#### [PROFILER-GRAPH-WEBVIEW-FEATURES] Webview Features | Feature | Description | |---------|-------------| @@ -610,35 +597,19 @@ graph LR | **Export** | Save graph as SVG or PNG | | **Depth slider** | Control max traversal depth (1–10) | -#### Node Visual Encoding - -```mermaid -graph TD - subgraph Legend - L1[🔴 Leak Suspect
High severity] - L2[🟠 Large Retained Size
> 1MB] - L3[🔵 GC Root
Static/Thread/Pinned] - L4[⚪ Normal Object
No concerns] - L5[⚠️ Warning Border
Growing type from diff] - end -``` - -### 5A.5 Retention Path View +#### [PROFILER-GRAPH-WEBVIEW-ENCODING] Node Visual Encoding -For any selected object, SharpLsp shows the complete chain from GC root to the object. This answers the question: **"Why isn't this being garbage collected?"** +| Node state | Encoding | +|------------|----------| +| High-severity leak suspect | Red | +| Retained size greater than 1MB | Orange | +| GC root | Blue | +| Normal object | Gray | +| Type growing between snapshots | Warning border | -```mermaid -graph TD - Root["🔵 GC Root
Static: AppState._instance"] --> A["AppState
retains 4.2 MB"] - A -->|_subscriptions| B["List<EventHandler>
retains 2.1 MB"] - B -->|[0]| C["EventHandler
retains 1.0 MB"] - C -->|_target| D["🔴 LeakyService
48 bytes
⚠️ 1,247 instances"] - D -->|_buffer| E["byte[]
1.0 MB"] +### [PROFILER-GRAPH-RETENTION] Retention Path View - style Root fill:#4488ff,color:#fff - style D fill:#ff4444,color:#fff - style E fill:#ff8844,color:#fff -``` +For any selected object, SharpLsp shows the complete chain from a GC root to that object. Each node in the retention path shows: - Type name and size @@ -646,26 +617,22 @@ Each node in the retention path shows: - Instance count (if many instances of same type exist — leak signal) - Retained size (total memory kept alive through this node) -### 5A.6 Heap Snapshot Diff Visualization +### [PROFILER-GRAPH-DIFF] Heap Snapshot Diff Visualization -When two snapshots are compared, the diff is shown as an annotated table AND as a visual graph overlay. +When two snapshots are compared, the diff is shown as an annotated table and a visual graph overlay. -#### Diff Table View +#### [PROFILER-GRAPH-DIFF-TABLE] Diff Table View -| Type | Baseline Count | Current Count | Delta | Baseline Size | Current Size | Delta | Severity | -|------|---------------|--------------|-------|--------------|-------------|-------|----------| -| `EventHandler` | 12 | 1,247 | +1,235 | 576 B | 59.9 KB | +59.3 KB | 🔴 High | -| `byte[]` | 340 | 1,580 | +1,240 | 1.2 MB | 5.6 MB | +4.4 MB | 🔴 High | -| `String` | 8,200 | 9,100 | +900 | 320 KB | 355 KB | +35 KB | 🟡 Low | +The table MUST show type, baseline and current counts, count delta, baseline and current sizes, size delta, and severity. -#### Diff Graph Overlay +#### [PROFILER-GRAPH-DIFF-OVERLAY] Diff Graph Overlay In graph view, nodes from the comparison snapshot are annotated with growth indicators: - **Pulsing red border** — count grew >100% - **Growing arrow** — size delta shown on hover - **New nodes** (not in baseline) appear with dashed border -### 5A.7 Performance Requirements +### [PROFILER-GRAPH-PERFORMANCE] Performance Requirements | Metric | Target | |--------|--------| @@ -677,9 +644,9 @@ In graph view, nodes from the comparison snapshot are annotated with growth indi | Graph webview node expansion | <1s | | Retained size calculation | <5s per node | -## 6. Session Management +## [PROFILER-SESSIONS] Session Management -### 6.1 Session Lifecycle +### [PROFILER-SESSIONS-LIFECYCLE] Session Lifecycle ``` Created ──start──▶ Running ──stop──▶ Stopped ──cleanup──▶ Disposed @@ -688,12 +655,12 @@ Created ──start──▶ Running ──stop──▶ Stopped ──clea └──error──▶ Failed ``` -- Each session gets a unique ID (UUID v4) -- Sessions tracked in a `DashMap` on the Rust host +- Each session ID has the form `prof--` +- The Rust host keeps a concurrent session registry containing live child-process state; this registry is state, not memoization - Maximum concurrent sessions: 5 (configurable via `sharplsp.toml`) - Orphaned sessions (editor disconnect) cleaned up on LSP shutdown -### 6.2 Configuration +### [PROFILER-SESSIONS-CONFIG] Configuration `sharplsp.toml` settings: @@ -707,9 +674,9 @@ default_counter_interval = 1 output_directory = ".sharplsp/profiles" ``` -## 7. Editor Integration +## [PROFILER-EDITOR] Editor Integration -### 7.1 VSCode Extension +### [PROFILER-EDITOR-VSCODE] VS Code Extension | UI Element | Purpose | |-----------|---------| @@ -723,11 +690,11 @@ output_directory = ".sharplsp/profiles" | Quick pick | Process selection from discovered .NET processes | | File open | Open `.speedscope.json` output in browser/SpeedScope viewer | -### 7.1.1 Profiler Tree View — Intent-Revealing UX +#### [PROFILER-EDITOR-VSCODE-TREE] Profiler Tree View -The PROFILER tree view MUST make every action discoverable **directly from the node the user is looking at**. A user who right-clicks a session must be able to stop it. A user who right-clicks a process must be able to profile it. No toolbar hunting. No blind QuickPicks. +The PROFILER tree view MUST expose session and process actions directly from the corresponding node: sessions can be stopped and processes can be profiled from their context menus. -#### Tree Structure +##### [PROFILER-EDITOR-VSCODE-TREE-STRUCTURE] Tree Structure ``` PROFILER [refresh] [open-trace] [⋯ overflow] @@ -739,7 +706,7 @@ PROFILER [refresh] [open-trace] [⋯ overflow] └── Claude (PID 98153) ``` -#### Context Values +##### [PROFILER-EDITOR-VSCODE-TREE-CONTEXT] Context Values Every tree item MUST set a `contextValue` that the `view/item/context` menu `when` clauses key off: @@ -751,18 +718,18 @@ Every tree item MUST set a `contextValue` that the `view/item/context` menu `whe | Counters session | `profiler-session-counters` | | Process entry | `profiler-process` | -#### Default Click Behavior +##### [PROFILER-EDITOR-VSCODE-TREE-CLICK] Default Click Behavior Clicking a node performs the most common action for that node kind — never a no-op. -| Node | Default Click | Rationale | -|------|--------------|-----------| -| Trace session | Stop trace + open result in SpeedScope | Click = "I'm done, show me the flamegraph." | -| Counters session | Reveal the live counters webview | Click = "Show me the numbers" (stopping is a menu item). | -| Process | Start trace on this PID | Click = "profile this." | -| Header / empty | No-op | Informational. | +| Node | Default Click | +|------|---------------| +| Trace session | Stop trace and open the result in SpeedScope | +| Counters session | Reveal the live counters webview | +| Process | Start trace on this PID | +| Header / empty | No-op | -#### Context Menu (Right-Click) Entries +##### [PROFILER-EDITOR-VSCODE-TREE-MENU] Context Menu Entries **On a trace session:** - Stop & Open (inline icon = `debug-stop`) @@ -777,18 +744,17 @@ Clicking a node performs the most common action for that node kind — never a n - Start Trace on This Process (inline icon = `record`) - Start Counters on This Process - Collect Memory Dump of This Process +- Kill Process; show a destructive modal naming the process and PID, then invoke `sharplsp/profiler/killProcess` only after explicit confirmation - Copy PID -#### Tooltips +##### [PROFILER-EDITOR-VSCODE-TREE-TOOLTIPS] Tooltips Every session and process node MUST have a Markdown tooltip that includes: - Node identity (PID, session ID, kind) - Output path if any - A one-line hint describing what clicking does -This eliminates "what is this thing and what do I do with it?" confusion. - -#### Toolbar Organisation +##### [PROFILER-EDITOR-VSCODE-TREE-TOOLBAR] Toolbar Organisation The view title bar keeps only actions that don't belong to a specific node: @@ -798,20 +764,18 @@ The view title bar keeps only actions that don't belong to a specific node: | `navigation@2` | Open Trace File… | `folder-opened` | | `overflow` | Start Trace (picker), Start Counters (picker), Collect Dump (picker), Convert .nettrace, Analyze Heap, Compare Snapshots, Detect Leaks | — | -The overflow menu (`⋯`) holds picker-based workflows that don't need a visible button. All direct-action equivalents live on the tree node context menus. - -### 7.1.2 Trace File Opening +#### [PROFILER-EDITOR-VSCODE-TRACE] Trace File Opening -SharpLsp MUST let the user open a `.nettrace` **file** as a first-class action, not just as a side-effect of stopping a session. Users who find an orphaned `.nettrace` (e.g. because the editor was closed mid-recording) need a path forward. +SharpLsp MUST let the user open a `.nettrace` file independently of a live session. The `sharplsp.profiler.openTrace` command: 1. Shows an open-file dialog filtering for `.nettrace`, `.speedscope.json`, and `.json` files. 2. If the chosen file is `.nettrace`, invokes `sharplsp/profiler/convertTrace` to produce a sibling `.speedscope.json`. 3. Opens the resulting SpeedScope file in the external SpeedScope web viewer. -Stopping a trace session uses the same pipeline, so the UX is consistent: every trace — freshly captured or loaded from disk — ends up in SpeedScope with one interaction. +Stopping a trace session uses the same conversion-and-open pipeline. -### 7.2 Commands +### [PROFILER-EDITOR-COMMANDS] Commands | Command | Title | |---------|-------| @@ -835,9 +799,10 @@ Stopping a trace session uses the same pipeline, so the UX is consistent: every | `sharplsp.profiler.traceProcess` | SharpLsp: Start Trace on This Process | | `sharplsp.profiler.countersProcess` | SharpLsp: Start Counters on This Process | | `sharplsp.profiler.dumpProcess` | SharpLsp: Collect Memory Dump of This Process | +| `sharplsp.profiler.killProcess` | SharpLsp: Kill Process | | `sharplsp.profiler.copyPid` | SharpLsp: Copy PID | -## 8. Performance Requirements +## [PROFILER-PERFORMANCE] Performance Requirements | Metric | Target | |--------|--------| @@ -848,7 +813,7 @@ Stopping a trace session uses the same pipeline, so the UX is consistent: every | Heap analysis (50k types) | <5s | | GC root traversal | <10s | -## 9. Error Handling +## [PROFILER-ERRORS] Error Handling | Condition | Response | |-----------|----------| @@ -860,19 +825,19 @@ Stopping a trace session uses the same pipeline, so the UX is consistent: every | Tool produces unexpected output | Log raw output at `warn` level, return parse error | | Editor disconnects during session | Clean up all sessions on LSP shutdown | -## 10. Competitive Parity Matrix - -| Feature | VS | Rider | CDK | SharpLsp Target | Priority | -|---------|----|----|-----|-------------|----------| -| CPU trace collection | Yes | Yes | No | Yes | P0 | -| Live performance counters | Yes (PerfView) | Yes | No | Yes | P0 | -| Memory dump collection | Yes | Yes | No | Yes | P0 | -| Heap analysis | Yes | Yes (dotMemory) | No | Yes (basic) | P1 | -| GC root analysis | Yes | Yes (dotMemory) | No | Yes (basic) | P1 | -| Leak detection heuristics | Partial | Yes | No | Yes (counter-based) | P1 | -| Automated leak detection | No | Yes (dotMemory) | No | Yes (snapshot diff) | P1 | -| Heap snapshot diffing | No | Yes (dotMemory) | No | Yes | P1 | -| Object retention graph | Yes | Yes (dotMemory) | No | Yes (interactive) | P1 | -| Object inspection | Yes | Yes (dotMemory) | No | Yes | P1 | -| Flame graph visualization | External | Built-in | No | External (SpeedScope) | P1 | -| Allocation tracking | Yes | Yes (dotTrace) | No | Future | P2 | +## [PROFILER-SCOPE] Target Scope + +| Capability | Target | Priority | +|------------|--------|----------| +| CPU trace collection | Required | P0 | +| Live performance counters | Required | P0 | +| Memory dump collection | Required | P0 | +| Heap analysis | Basic analysis | P1 | +| GC root analysis | Basic analysis | P1 | +| Leak detection heuristics | Counter-based | P1 | +| Automated leak detection | Snapshot diff | P1 | +| Heap snapshot diffing | Required | P1 | +| Object retention graph | Interactive | P1 | +| Object inspection | Required | P1 | +| Flame graph visualization | External SpeedScope viewer | P1 | +| Allocation tracking | Deferred; no request or UI contract | P2 | diff --git a/docs/specs/REFERENCES-SPEC.md b/docs/specs/REFERENCES-SPEC.md index af5f45b9..68a0c758 100644 --- a/docs/specs/REFERENCES-SPEC.md +++ b/docs/specs/REFERENCES-SPEC.md @@ -1,16 +1,14 @@ -# Find All References & Document Highlights Specification +# Find All References & Document Highlights Specification `[REFERENCES-SPEC]` **Parent:** [SHARPLSP-SPEC.md](SHARPLSP-SPEC.md) -## 1. Overview +## Overview `[REFERENCES-OVERVIEW]` -Find All References locates every usage of a symbol across the entire solution. Document Highlights locates usages within the current document only (used for read/write highlighting on cursor move). SharpLsp implements `textDocument/references` ([LSP 3.17 §3.17.10](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references)) and `textDocument/documentHighlight` ([LSP 3.17 §3.17.5](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentHighlight)) for both C# and F# as equal first-class citizens. +`textDocument/references` ([LSP 3.17](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references)) locates symbol usages across the solution. `textDocument/documentHighlight` ([LSP 3.17](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentHighlight)) locates read/write usages in the current document. Both methods apply equally to C# and F#. -Both methods are **P0** (launch blocker) and target Phase 2 delivery. +## LSP Protocol `[REFERENCES-PROTOCOL]` -## 2. LSP Protocol - -### 2.1 textDocument/references +### textDocument/references `[REFERENCES-PROTOCOL-FIND]` ``` method: textDocument/references @@ -37,7 +35,7 @@ interface Location { - `null` when no symbol can be resolved at the given position. - Results are sorted by file path, then by position within each file. -### 2.2 textDocument/documentHighlight +### textDocument/documentHighlight `[REFERENCES-PROTOCOL-HIGHLIGHT]` ``` method: textDocument/documentHighlight @@ -66,23 +64,23 @@ enum DocumentHighlightKind { - Each highlight is annotated with `Read` or `Write` kind where determinable. - `null` when no symbol can be resolved at the given position. -## 3. Request Routing - -Both requests are **semantic** requests. The Rust host routes them to the appropriate sidecar based on document language. +## Request Routing `[REFERENCES-ROUTING]` | Step | Component | Action | |---|---|---| | 1 | Rust host | Receives request, identifies language from VFS | -| 2 | Rust host | Checks salsa cache for matching key (see §7) | -| 3 | Rust host | On cache miss, dispatches to C# sidecar (Roslyn) or F# sidecar (FCS) via IPC | +| 2 | Rust host | Evaluates the salsa query for the request (see [REFERENCES-SALSA]) | +| 3 | Rust host | When the query is invalidated, dispatches to C# sidecar (Roslyn) or F# sidecar (FCS) via IPC | | 4 | Sidecar | Resolves symbol at position, finds all reference locations | -| 5 | Rust host | Caches result, returns LSP response to client | +| 5 | Rust host | Returns the salsa-derived LSP response to the client | + +The Rust host MAY use tree-sitter to reject whitespace, comments, and string literals with `null` before sidecar dispatch. -The Rust host MAY use tree-sitter to pre-validate the position (reject whitespace, comments, string literals) and short-circuit with `null` before dispatching to the sidecar. +Implementation anchors: Rust routing and DTO conversion live in [`src/sharplsp/src/semantic.rs`](../../src/sharplsp/src/semantic.rs); C# dispatch, symbol resolution, and wire types live in [`CSharpSidecar.cs`](../../src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.cs), [`DefinitionResolver.cs`](../../src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs), and [`Messages.cs`](../../src/sidecars/SharpLsp.Sidecar.CSharp/Messages.cs); F# behavior and wire types live in [`FSharpReferences.fs`](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs) and [`FSharpWire.fs`](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs). Coarse protocol coverage is in [`src/sharplsp/tests/e2e_modules/references.rs`](../../src/sharplsp/tests/e2e_modules/references.rs). -## 4. C# Implementation (Roslyn) +## C# Implementation (Roslyn) `[REFERENCES-CSHARP]` -### 4.1 textDocument/references +### textDocument/references `[REFERENCES-CSHARP-FIND]` 1. Obtain `Document` from the current `Solution` snapshot for the given URI. 2. Get the source text and convert `(line, character)` to an absolute position via [`SourceText.Lines.GetPosition()`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.text.textlinecollection.getposition). @@ -94,9 +92,9 @@ The Rust host MAY use tree-sitter to pre-validate the position (reject whitespac 8. If `context.includeDeclaration` is true, also include the symbol's declaration location(s) from `ReferencedSymbol.Definition.Locations`. 9. Map each location to `(filePath, line, character, endLine, endCharacter)`. -### 4.2 textDocument/documentHighlight +### textDocument/documentHighlight `[REFERENCES-CSHARP-HIGHLIGHT]` -1. Steps 1–5 as in §4.1. +1. Resolve the `Document` and symbol as in [REFERENCES-CSHARP-FIND]. 2. Call [`SymbolFinder.FindReferencesAsync(symbol, solution)`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.findusages.symbolfinder.findreferencesasync) scoped to the current document. 3. Filter results to only locations within the requested document. 4. Classify each reference as `Read` or `Write`: @@ -105,7 +103,7 @@ The Rust host MAY use tree-sitter to pre-validate the position (reject whitespac - Declaration site → `Write` 5. Include the declaration location with `Write` kind. -### 4.3 Symbol Resolution Special Cases +### Symbol Resolution Special Cases `[REFERENCES-CSHARP-RESOLUTION]` | Symbol at Cursor | Behavior | |---|---| @@ -122,9 +120,11 @@ The Rust host MAY use tree-sitter to pre-validate the position (reject whitespac | `using` alias | References to the alias + aliased type | | Implicit references (attribute `[Foo]` → `FooAttribute`) | Include the implicit form | -## 5. F# Implementation (FCS) +## F# Implementation (FCS) `[REFERENCES-FSHARP]` -### 5.1 textDocument/references +### textDocument/references `[REFERENCES-FSHARP-FIND]` + +F# references MUST search every compile item in the loaded project; a file-local result is nonconforming. 1. Get `FSharpCheckFileResults` for the document via `FSharpChecker.CheckFileInProject()`. 2. Call `GetSymbolUseAtLocation(line, col, lineText, names)` to obtain the `FSharpSymbolUse` at the cursor. @@ -133,15 +133,15 @@ The Rust host MAY use tree-sitter to pre-validate the position (reject whitespac 5. If `context.includeDeclaration` is true, include the symbol's declaration range. 6. Map each `FSharpSymbolUse.Range` to LSP `Location`. -### 5.2 textDocument/documentHighlight +### textDocument/documentHighlight `[REFERENCES-FSHARP-HIGHLIGHT]` -1. Steps 1–3 as in §5.1 (document-scoped only). +1. Resolve document-scoped symbol uses as in [REFERENCES-FSHARP-FIND]. 2. Classify each `FSharpSymbolUse`: - `FSharpSymbolUse.IsFromDefinition` → `Write` - `FSharpSymbolUse.IsFromPattern` → `Write` - All other usages → `Read` -### 5.3 F#-Specific Cases +### F#-Specific Cases `[REFERENCES-FSHARP-CASES]` | Symbol at Cursor | Behavior | |---|---| @@ -152,7 +152,7 @@ The Rust host MAY use tree-sitter to pre-validate the position (reject whitespac | Module function | All call sites across the project | | Type abbreviation | All usages of the abbreviation | -## 6. Cross-Language References (P2) +## Cross-Language References `[REFERENCES-CROSS-LANGUAGE]` When a C# project references an F# project (or vice versa), find-all-references must cross the language boundary. @@ -161,24 +161,24 @@ When a C# project references an F# project (or vice versa), find-all-references | C# symbol used in F# code | C# sidecar finds references in C# projects → Rust host also dispatches to F# sidecar for F# projects | | F# symbol used in C# code | F# sidecar finds references in F# projects → Rust host also dispatches to C# sidecar for C# projects | -Cross-language references are a P2 feature targeting Phase 4. The Rust host merges results from both sidecars and deduplicates by location. +The Rust host merges results from both sidecars and deduplicates by location. -## 7. Caching Strategy +## Extended Results `[REFERENCES-EXTENSIONS]` -Reference results are cached via the [salsa](https://salsa-rs.github.io/salsa/) incremental computation database in the Rust host. +Metadata-symbol references, grouped find-usages results, and reference-count code lenses are supported extensions to the base methods. Large result sets SHOULD stream through `partialResult`; metadata and grouped results MUST retain the sorting, declaration-inclusion, and deduplication rules in this specification. -| Cache Key | Invalidation Trigger | -|---|---| -| `(document_uri, document_version, position, include_declaration)` for references | Any document change in the project | -| `(document_uri, document_version, position)` for document highlights | Document edit (version change) | +## Salsa Queries `[REFERENCES-SALSA]` -Document highlight results are cached more aggressively since they are scoped to a single file. +All references and document-highlight memoization MUST be implemented as Rust-host salsa queries. The editor, sidecars, handlers, and ad-hoc Rust maps MUST NOT cache these results. -References results SHOULD be invalidated when any document in the solution changes, since references are solution-wide. The Rust host MAY use a coarse invalidation strategy (invalidate all reference caches on any edit) for simplicity. +| Query | Inputs that invalidate the result | +|---|---| +| `references(document_uri, position, include_declaration)` | Any solution document text/version, solution/project graph, language routing, or relevant sidecar generation/readiness change | +| `document_highlights(document_uri, position)` | Requested document text/version or relevant sidecar generation/readiness change | -Stale requests for superseded document versions MUST be cancelled. +Sidecar readiness and generation MUST be salsa inputs so a result produced while a sidecar is unavailable cannot remain memoized after recovery. A solution-wide edit invalidates reference results even when the requested URI did not change. Closing a document removes its inputs. Requests for superseded versions MUST be cancelled, and late results MUST NOT update salsa inputs for the newer version. -## 8. Performance Requirements +## Performance Requirements `[REFERENCES-PERFORMANCE]` | Metric | Target | Measurement | |---|---|---| @@ -186,89 +186,45 @@ Stale requests for superseded document versions MUST be cancelled. | Find references (medium solution, ~1000 files) | <2 seconds | Time to enumerate all references | | Find references (large solution, ~5000 files) | <5 seconds | Time to enumerate all references | | Document highlights | <100ms | Time to highlight all occurrences in current document | -| Cached reference lookup | <1ms | salsa cache hit | +| Memoized reference lookup | <1ms | Salsa query hit | | Tree-sitter pre-validation | <1ms | Whitespace/comment/literal rejection | -References may be returned incrementally via partial results (`partialResult` token) for large result sets to provide progressive UI feedback. +Large result sets MAY use the LSP `partialResult` token. -## 9. Error Handling +## Error Handling `[REFERENCES-ERRORS]` | Condition | Response | |---|---| | Position is whitespace or comment | Return `null` (no references) | | Sidecar not ready / loading | Return `null` with `window/showMessage` notification | | Symbol resolution fails | Return `null` | -| Sidecar crashes during request | Return `null`, trigger crash recovery (see SHARPLSP-SPEC §5) | +| Sidecar crashes during request | Return `null` and trigger [sidecar recovery](SIDECAR-LIFECYCLE-SPEC.md) | | No references found (only declaration) | Return `[]` (empty array) if `includeDeclaration` is false; `[declaration]` if true | -Reference requests MUST NOT block, hang, or return errors to the client. On any failure, return `null`. - -## 10. Wire Types (IPC) - -### 10.1 Request - -```csharp -[MessagePackObject] -public class ReferencesRequest -{ - [Key(0)] public string FilePath { get; set; } - [Key(1)] public int Line { get; set; } - [Key(2)] public int Character { get; set; } - [Key(3)] public bool IncludeDeclaration { get; set; } -} -``` +Reference requests MUST NOT hang or return protocol errors to the client; failures return `null`. -For document highlights, reuses `PositionRequest` (shared with hover/definition). +## Wire Types (IPC) `[REFERENCES-IPC]` -### 10.2 Response +### Request `[REFERENCES-IPC-REQUEST]` -Reuses `LocationListResult` from the definition spec for references: +The C# [`Messages.cs`](../../src/sidecars/SharpLsp.Sidecar.CSharp/Messages.cs) and F# [`FSharpWire.fs`](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs) definitions MUST encode identical keys. -```csharp -[MessagePackObject] -public class LocationListResult -{ - [Key(0)] public List Locations { get; set; } -} -``` +| Type | MessagePack keys | +|---|---| +| `ReferencesRequest` | `0: FilePath string`, `1: Line int`, `2: Character int`, `3: IncludeDeclaration bool` | +| `PositionRequest` for highlights | `0: FilePath string`, `1: Line int`, `2: Character int` | -For document highlights, a new response type with highlight kind: - -```csharp -[MessagePackObject] -public class DocumentHighlightResult -{ - [Key(0)] public int StartLine { get; set; } - [Key(1)] public int StartCharacter { get; set; } - [Key(2)] public int EndLine { get; set; } - [Key(3)] public int EndCharacter { get; set; } - [Key(4)] public int Kind { get; set; } // 1=Text, 2=Read, 3=Write -} +### Response `[REFERENCES-IPC-RESPONSE]` -[MessagePackObject] -public class DocumentHighlightListResult -{ - [Key(0)] public List Highlights { get; set; } -} -``` +| Type | MessagePack keys | +|---|---| +| `LocationListResult` | `0: Locations list` | +| `DocumentHighlightResult` | `0: StartLine int`, `1: StartCharacter int`, `2: EndLine int`, `3: EndCharacter int`, `4: Kind int` (`Text=1`, `Read=2`, `Write=3`) | +| `DocumentHighlightListResult` | `0: Highlights list` | -### 10.3 IPC Methods +### IPC Methods `[REFERENCES-IPC-METHODS]` | IPC Method | LSP Method | Response Type | |---|---|---| | `textDocument/references` | `textDocument/references` | `LocationListResult` | | `textDocument/documentHighlight` | `textDocument/documentHighlight` | `DocumentHighlightListResult` | - -## 11. Competitive Parity Matrix - -| Feature | VS | CDK | Rider | SharpLsp Target | Priority | -|---|---|---|---|---|---| -| Find all references (in-source) | Y | Y | Y | Y | P0 | -| Find all references (metadata) | Y | N | Y | Y (P1) | P1 | -| Document highlights (read/write) | Y | Y | Y | Y | P0 | -| Find usages (advanced, grouped) | Y | N | Y | Y | P1 | -| Cross-language references (C# to F#) | N | N | Y* | Y | P2 | -| Reference count code lens | Y | Y | Y | Y | P1 | -| Partial result streaming | Y | N | Y | Y | P1 | - -*\* Rider supports both languages but via proprietary code, not LSP.* diff --git a/docs/specs/RENAME-SPEC.md b/docs/specs/RENAME-SPEC.md index 2124565a..a678b7c9 100644 --- a/docs/specs/RENAME-SPEC.md +++ b/docs/specs/RENAME-SPEC.md @@ -1,4 +1,4 @@ -# Rename Specification +# [RENAME-SPEC] Rename Specification **Parent:** [SHARPLSP-SPEC.md](SHARPLSP-SPEC.md) @@ -10,7 +10,7 @@ Rename is not a generic code action. Editors invoke it through the dedicated LSP ## [RENAME-PROTOCOL] LSP Protocol -### [RENAME-PROTOCOL-PREPARE] textDocument/prepareRename +### [RENAME-PREPARE] textDocument/prepareRename ``` method: textDocument/prepareRename @@ -28,7 +28,7 @@ result: Range | { range: Range; placeholder: string } | null - Return the current symbol name as `placeholder`. - Return `null` when the position is whitespace, trivia, a keyword that is not a renameable symbol, metadata-only source, generated source that cannot be edited, or a symbol kind SharpLsp does not yet support. -### [RENAME-PROTOCOL-EXECUTE] textDocument/rename +### [RENAME-APPLY] textDocument/rename ``` method: textDocument/rename @@ -74,6 +74,8 @@ SharpLsp MUST support rename for these code element categories before rename is Rename requests are semantic requests. +Implementations: [semantic.rs](../../src/sharplsp/src/semantic.rs), [CSharpSidecar.Features.cs](../../src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Features.cs), [FSharpRename.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs), and the [full-stack feature tests](../../src/sharplsp/tests/e2e_modules/full_stack_features.rs). + | Step | Component | Action | |---|---|---| | 1 | Rust host | Receives prepare or rename request and identifies the document language from the VFS | @@ -99,12 +101,17 @@ The C# sidecar MUST use Roslyn semantics. The F# sidecar MUST use FCS symbol resolution and rename support rather than text matching. +### [RENAME-FSHARP-PREPARE] Prepare + 1. Get checked file results for the current document. 2. Resolve the `FSharpSymbolUse` at the requested position. 3. Validate that the symbol kind is renameable and that the new name is valid F# syntax for that symbol kind. -4. Compute all symbol uses across the project or solution scope required for a safe rename. -5. Produce file-scoped text edits for every declaration and usage location. -6. Preserve F# file ordering semantics and avoid edits in generated or metadata-only files. + +### [RENAME-FSHARP-APPLY] Apply + +1. Compute all symbol uses across the project or solution scope required for a safe rename. +2. Produce file-scoped text edits for every declaration and usage location. +3. Preserve F# file ordering semantics and avoid edits in generated or metadata-only files. ## [RENAME-CROSSLANGUAGE] Cross-Language Rename diff --git a/docs/specs/RIDER-PLUGIN-SPEC.md b/docs/specs/RIDER-PLUGIN-SPEC.md index 9e88b3fe..c31c1c53 100644 --- a/docs/specs/RIDER-PLUGIN-SPEC.md +++ b/docs/specs/RIDER-PLUGIN-SPEC.md @@ -1,120 +1,86 @@ -# Rider Plugin Specification +# Rider Plugin Specification `[RIDER-PLUGIN]` **Parent:** [SHARPLSP-SPEC.md](SHARPLSP-SPEC.md) -https://plugins.jetbrains.com/docs/intellij/language-server-protocol.html +## Overview `[RIDER-OVERVIEW]` -## 1. Overview +The SharpLsp Rider plugin wires the `sharplsp` binary into JetBrains Rider (and every other non-Community IntelliJ-based IDE that ships LSP support) and adds a **SharpLsp Solution Explorer** tool window that renders the full solution tree by calling the same custom LSP requests the VS Code extension uses. -The SharpLsp Rider plugin wires the `sharplsp` binary into JetBrains Rider (and -every other non-Community IntelliJ-based IDE that ships LSP support) and adds -a **SharpLsp Solution Explorer** tool window that renders the full solution tree -by calling the same custom LSP requests the VS Code extension uses. +**Target IDEs:** Rider 2026.1 (primary), IntelliJ IDEA Ultimate 2023.2+, WebStorm, PhpStorm, PyCharm Professional, CLion, GoLand, RustRover, DataGrip, RubyMine, DataSpell. **Not** IntelliJ Community or Android Studio — LSP API is paid-tier only. -**Priority:** P1 — first-class parity with the VS Code extension is a stated -project goal. +The plugin uses JetBrains' [LSP API](https://plugins.jetbrains.com/docs/intellij/language-server-protocol.html). -**Target IDEs:** Rider 2023.2+ (primary), IntelliJ IDEA Ultimate 2023.2+, -WebStorm, PhpStorm, PyCharm Professional, CLion, GoLand, RustRover, DataGrip, -RubyMine, DataSpell. **Not** IntelliJ Community or Android Studio — LSP API -is paid-tier only. +## Paid-platform scope `[RIDER-PLATFORM-SCOPE]` -## 2. Why Rider, not Community Edition +JetBrains gates `com.intellij.modules.lsp` to paid products. The plugin declares that module as a hard dependency and MUST refuse to load where it is absent. -JetBrains gates the LSP API (`com.intellij.modules.lsp`) to paid products. -There is no workaround — even a hand-rolled lsp4j integration can't register -a language as a first-class Rider language because Rider owns C# / F# -language registration. The SharpLsp plugin therefore declares a hard dependency -on the `com.intellij.modules.lsp` module and fails to load on Community. - -## 3. Architecture +## Architecture `[RIDER-ARCHITECTURE]` ``` -Rider JVM ──lsp4j──> sharplsp (stdio, MessagePack for IPC to sidecars) +Rider JVM ──lsp4j──> sharplsp (stdio; sidecars remain host-owned) │ - ├── SharpLspLspServerSupportProvider (extension point) - │ └── SharpLspLspServerDescriptor (launches sharplsp, sets env) - │ └── SharpLspLsp4jServer (custom request interface) + ├── ForgeLspServerSupportProvider (extension point) + │ └── ForgeLspServerDescriptor (launches sharplsp, sets env) + │ └── ForgeLsp4jServer (custom request interface) │ - └── SharpLspSolutionToolWindow (toolWindow extension point) - └── SharpLspSolutionTreeModel (AsyncTreeModel + JTree) - └── calls SharpLspLsp4jServer.workspaceSymbols() / + └── ForgeSolutionToolWindow (toolWindow extension point) + └── ForgeTreeNode hierarchy + └── calls ForgeLsp4jServer.workspaceSymbols() / nugetInstalled() ``` -No sidecar, no webview, no MessagePack on the Rider side. The plugin is -**thin** — it does nothing the LSP server can't do, it only renders. +The plugin owns no sidecar, webview, or MessagePack transport; it launches the Rust host and renders LSP responses. Implementations: [`lsp/`](../../src/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp) and [`toolwindow/`](../../src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow). -## 4. Build & Packaging +## Build and Packaging `[RIDER-BUILD]` -- **Language:** Kotlin (JetBrains-preferred, shorter boilerplate than Java). -- **Build tool:** Gradle with the `org.jetbrains.intellij.platform` plugin - (v2.x), which is the current supported build flow. The older - `gradle-intellij-plugin` (1.x) is legacy and must not be used. -- **JVM target:** 17 (Rider 2023.2+ ships JetBrains Runtime 17). -- **Kotlin target:** `jvmTarget = 17`, stdlib from the platform — do NOT - bundle `kotlin-stdlib` to avoid classpath conflicts. -- **Source layout:** `editors/rider/` with the conventional Gradle structure: +- **Language:** Kotlin 2.3. +- **Build tool:** Gradle with `org.jetbrains.intellij.platform` 2.14. The older `gradle-intellij-plugin` (1.x) is legacy and must not be used. +- **JVM target:** 21 for Rider 2026.1. +- **Kotlin target:** JVM toolchain 21, stdlib from the platform — do NOT bundle `kotlin-stdlib` to avoid classpath conflicts. +- **Source layout:** `src/editors/rider/` with the conventional Gradle structure: ``` - editors/rider/ + src/editors/rider/ ├── build.gradle.kts ├── settings.gradle.kts ├── gradle.properties ├── gradle/wrapper/ (generated) ├── gradlew, gradlew.bat (generated) └── src/main/ - ├── kotlin/com/sharplsp/rider/ - │ ├── lsp/SharpLspLspServerSupportProvider.kt - │ ├── lsp/SharpLspLspServerDescriptor.kt - │ ├── lsp/SharpLspLsp4jServer.kt - │ ├── toolwindow/SharpLspSolutionToolWindowFactory.kt - │ ├── toolwindow/SharpLspSolutionTree.kt - │ ├── toolwindow/SharpLspSolutionTreeModel.kt + ├── kotlin/com/forgelsp/rider/ + │ ├── lsp/ForgeLspServerSupportProvider.kt + │ ├── lsp/ForgeLspServerDescriptor.kt + │ ├── lsp/ForgeLsp4jServer.kt + │ ├── toolwindow/ForgeSolutionToolWindowFactory.kt + │ ├── toolwindow/ForgeSolutionToolWindow.kt │ └── toolwindow/nodes/*.kt └── resources/ ├── META-INF/plugin.xml - └── icons/sharplsp.svg + └── icons/forge.svg ``` -- **Distribution artifact:** `sharplsp-rider-plugin.zip`, produced by the - `buildPlugin` Gradle task at `editors/rider/build/distributions/`. - Copied to the repo root as `sharplsp.zip` (alongside `sharplsp.vsix`). -- **Gradle wrapper:** committed so contributors and CI don't need a system - Gradle. -- **Binary resolution:** the plugin does **not** bundle `sharplsp`. It - resolves the binary identically to the VS Code extension: +- **Distribution artifact:** `sharplsp-rider-plugin.zip`, produced by the `buildPlugin` Gradle task at `src/editors/rider/build/distributions/`. Copied to `dist/sharplsp-rider.zip` alongside the other packaged editor artifacts. +- **Gradle wrapper:** committed so contributors and CI don't need a system Gradle. +- **Binary resolution:** the plugin does **not** bundle `sharplsp`. It resolves the binary identically to the VS Code extension: 1. `sharplsp.lspPath` setting (per-project, stored in workspace.xml) 2. `~/.local/bin/sharplsp` 3. Anything on `$PATH` - 4. Clear error with install instructions if none found - This keeps the plugin zip under 200 KB and sidesteps Rider's plugin-size - warnings. + 4. Clear error with install instructions if none found The plugin zip MUST remain below 200 KB. -## 5. LSP Integration +## LSP Integration `[RIDER-LSP]` -### 5.1 `SharpLspLspServerSupportProvider` +### `ForgeLspServerSupportProvider` `[RIDER-LSP-PROVIDER]` -Registered via `com.intellij.platform.lsp.serverSupportProvider`. On -`fileOpened()` it checks the file extension (`.cs`, `.csx`, `.fs`, `.fsx`, -`.fsi`) and returns a shared `SharpLspLspServerDescriptor` keyed by project. -One server per Rider project, not per file. +[`ForgeLspServerSupportProvider.kt`](../../src/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLspServerSupportProvider.kt) is registered via `com.intellij.platform.lsp.serverSupportProvider`. On `fileOpened()` it checks the file extension (`.cs`, `.csx`, `.fs`, `.fsx`, `.fsi`) and returns a shared `ForgeLspServerDescriptor` keyed by project. One server per Rider project, not per file. -### 5.2 `SharpLspLspServerDescriptor` +### `ForgeLspServerDescriptor` `[RIDER-LSP-DESCRIPTOR]` - `isSupportedFile(VirtualFile)` — whitelist of C# / F# extensions. -- `createCommandLine()` — builds a `GeneralCommandLine` pointing at the - resolved `sharplsp` binary, sets `RUST_LOG=info`, inherits the project's - `VIRTUAL_FILE_DELIMITER` and working directory. -- `lsp4jServerClass = SharpLspLsp4jServer::class.java` — this is the hook - JetBrains documents for custom requests. The returned class extends - `org.eclipse.lsp4j.services.LanguageServer` with `@JsonRequest` and - `@JsonNotification` methods matching `sharplsp/*`. -- `createLsp4jClient()` — default client, we don't need server→client - notifications yet (restoreProgress is VS Code-only for now). - -### 5.3 `SharpLspLsp4jServer` (custom interface) +- `createCommandLine()` — builds a UTF-8 `GeneralCommandLine` for the resolved `sharplsp`, sets `RUST_LOG` from project settings, and uses the project base path as working directory. +- `lsp4jServerClass = ForgeLsp4jServer::class.java` — this is the hook JetBrains documents for custom requests. The returned class extends `org.eclipse.lsp4j.services.LanguageServer` with `@JsonRequest` and `@JsonNotification` methods matching `sharplsp/*`. + +### `ForgeLsp4jServer` custom interface `[RIDER-LSP-INTERFACE]` ```kotlin -interface SharpLspLsp4jServer : LanguageServer { +interface ForgeLsp4jServer : LanguageServer { @JsonRequest("sharplsp/workspaceSymbols") fun workspaceSymbols(params: WorkspaceSymbolsParams): CompletableFuture @@ -129,141 +95,89 @@ interface SharpLspLsp4jServer : LanguageServer { } ``` -All DTOs are plain Kotlin data classes with `@JvmField`-compatible shapes -matching the JSON wire format the Rust side already emits. **Zero schema -drift** — the Rust server is the source of truth. +DTO camel-case fields MUST match the Rust JSON wire format. Implementation: [`ForgeLsp4jServer.kt`](../../src/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLsp4jServer.kt). -## 6. Solution Explorer Tool Window +## Solution Explorer Tool Window `[RIDER-SOLUTION]` -### 6.1 Registration +### Registration `[RIDER-SOLUTION-REGISTRATION]` ```xml - + icon="/icons/forge.svg" + factoryClass="com.forgelsp.rider.toolwindow.ForgeSolutionToolWindowFactory"/> ``` -The tool window opens on the left, below Rider's own Solution Explorer so -the two sit side by side. The SharpLsp panel is clearly branded "SharpLsp" so -users can tell it apart. +The `Forge Solution` tool window is anchored left beside Rider's explorer. -### 6.2 Structure +### Structure `[RIDER-SOLUTION-STRUCTURE]` Top-level nodes, in order: -1. **Solution root** — the `.sln` or `.slnx` file discovered in the project root (or - picked via a right-click action if multiple). -2. **Projects** — one node per `.csproj` / `.fsproj` in the solution. Each - project node has three children: +1. **Solution root** — the `.sln` or `.slnx` file discovered in the project root (or picked via a right-click action if multiple). +2. **Projects** — one node per `.csproj` / `.fsproj` in the solution. Each project node has three children: - **Dependencies** - - **Packages** — from `sharplsp/nuget/installed`, one leaf per installed - NuGet package with version - - **Project References** — parsed from the csproj XML on the Rider - side (lightweight, no LSP round-trip) - - **Source** — namespaces → types → members, sourced from - `sharplsp/workspaceSymbols`. Lazy: we only ask the LSP for a project's - symbols the first time its node is expanded. - -### 6.3 Async / background behaviour - -- All LSP calls run on a bounded `AppExecutorUtil` background pool — never - on the EDT. The tree uses `AsyncTreeModel` wrapping a - `StructureTreeModel` so expansion and data load don't freeze the UI. -- Loading state is a spinning `AnimatedIcon.Default` leaf on the expanding - node until the real children arrive — matches Rider's built-in - "Loading..." convention. -- Errors surface as a red leaf with the error message; right-click → - "Retry" re-fires the request. - -### 6.4 Actions on tree nodes - -- **Double-click a file leaf** — opens it in the editor at the symbol's - range. + - **Packages** — from `sharplsp/nuget/installed`, one leaf per installed NuGet package with version + - **Project References** — parsed from the csproj XML on the Rider side (lightweight, no LSP round-trip) + - **Source** — namespaces → types → members, sourced from `sharplsp/workspaceSymbols`. Lazy: we only ask the LSP for a project's symbols the first time its node is expanded. + +### Async and background behavior `[RIDER-SOLUTION-ASYNC]` + +- LSP calls use `executeOnPooledThread` and return `CompletableFuture`; callbacks update a Swing `DefaultTreeModel` on the EDT. Server startup polls for at most 15 seconds and requests time out after 30 seconds. +- Loading state is a spinning `AnimatedIcon.Default` leaf on the expanding node until the real children arrive — matches Rider's built-in "Loading..." convention. +- Errors surface as a red leaf with the error message; right-click → "Retry" re-fires the request. + +### Actions on tree nodes `[RIDER-SOLUTION-ACTIONS]` + +- **Double-click a file leaf** — opens it in the editor at the symbol's range. - **Double-click a symbol** — opens the file and navigates to the symbol. -- **Right-click a project** — "Reveal in Explorer", "Open csproj", "Copy - path". -- **Right-click a NuGet package** — "Remove package" (future; gated on the - Rust host shipping `sharplsp/nuget/uninstall` with restore, which it - already does — so this is wirable day one). -- **Toolbar** — "Refresh" (re-fetches top level), "Collapse All", filter - text box. - -### 6.5 Auto-refresh - -The tool window subscribes to VFS events for `.sln`, `.slnx`, `.csproj`, `.fsproj`, -`Directory.Build.props`, `Directory.Packages.props`. Any change re-fires -the appropriate subtree load — no full reload. Debounced 300 ms so a -multi-file save burst doesn't thrash. - -## 7. Error handling - -- LSP binary not found → toast notification with a "Configure" button - that opens the settings panel. Tool window shows a single "sharplsp not - installed" node with install instructions as a tooltip. -- Server crash → lsp4j automatically restarts it (JetBrains LSP API - contract). The tool window shows a stale tree with a warning banner - until the first successful `workspaceSymbols` round-trip. -- Custom request returns an error → the failing subtree shows a red leaf - with the error text. The rest of the tree continues to work. - -## 8. Settings +- **Right-click a project** — "Reveal in Explorer", "Open csproj", "Copy path". +- **Right-click a NuGet package** — "Remove package" sends `sharplsp/nuget/uninstall`. +- **Toolbar** — "Refresh" (re-fetches top level), "Collapse All", filter text box. + +### Auto-refresh `[RIDER-SOLUTION-REFRESH]` + +The tool window subscribes to VFS events for `.sln`, `.slnx`, `.csproj`, `.fsproj`, `Directory.Build.props`, `Directory.Packages.props`. Any change re-fires the appropriate subtree load — no full reload. Debounced 300 ms so a multi-file save burst doesn't thrash. + +## Error handling `[RIDER-ERRORS]` + +- LSP binary not found → toast notification with a "Configure" button that opens the settings panel. Tool window shows a single "sharplsp not installed" node with install instructions as a tooltip. +- Server crash → lsp4j automatically restarts it (JetBrains LSP API contract). The tool window shows a stale tree with a warning banner until the first successful `workspaceSymbols` round-trip. +- Custom request returns an error → the failing subtree shows a red leaf with the error text. The rest of the tree continues to work. + +## Settings `[RIDER-SETTINGS]` Single settings panel at **Settings → Tools → SharpLsp**: -- **Server path** — override for `sharplsp` binary location (default: - auto-detect). -- **Log level** — dropdown (error / warn / info / debug / trace), - translates to `RUST_LOG`. +- **Server path** — override for `sharplsp` binary location (default: auto-detect). +- **Log level** — dropdown (error / warn / info / debug / trace), translates to `RUST_LOG`. - **Auto-load solution on open** — bool, default true. Stored in project-level `workspace.xml` via `PersistentStateComponent`. -## 9. Testing +## Testing `[RIDER-TESTS]` -### 9.1 Unit tests +### Descriptor and model coverage `[RIDER-TESTS-MODEL]` -- `SharpLspLspServerDescriptor.createCommandLine()` builds the expected - command on macOS / Linux / Windows given a known binary path. -- DTO round-trip: serialize a known JSON fixture → deserialize → assert - structure matches `sharplsp/workspaceSymbols` schema. -- Tree model: given a canned `WorkspaceSymbolsResponse`, the tree renders - the expected node hierarchy with correct icons. +- `SharpLspLspServerDescriptor.createCommandLine()` builds the expected command on macOS / Linux / Windows given a known binary path. +- DTO round-trip: serialize a known JSON fixture → deserialize → assert structure matches `sharplsp/workspaceSymbols` schema. +- Tree model: given a canned `WorkspaceSymbolsResponse`, the tree renders the expected node hierarchy with correct icons. -### 9.2 Integration tests +### Rider integration coverage `[RIDER-TESTS-INTEGRATION]` -Rider's test framework (`BasePlatformTestCase`) loads a test project with -a real `.sln` or `.slnx` plus one `.csproj`, spawns a fake stdio server that echoes -canned JSON responses, and asserts: +Rider's test framework (`BasePlatformTestCase`) loads a test project with a real `.sln` or `.slnx` plus one `.csproj`, spawns a fake stdio server that echoes canned JSON responses, and asserts: - The tool window populates within 5 s of project open. -- Double-clicking a symbol node opens the correct file at the correct - offset. +- Double-clicking a symbol node opens the correct file at the correct offset. - A VFS change to the `.csproj` triggers exactly one subtree reload. -### 9.3 Smoke test against a real sharplsp +### Real-server smoke coverage `[RIDER-TESTS-SMOKE]` A manual dev-loop test, run from `make test-rider`: 1. `make install` — binaries in `~/.local/bin` and `~/.local/lib/sharplsp`. 2. `./gradlew runIde` — boots a sandboxed Rider instance with the plugin. -3. Open `examples/HelloSharpLsp.sln` or `examples/HelloSharpLsp.slnx`. +3. Open `src/examples/Test.sln`. 4. Assert the SharpLsp Solution tool window renders the project tree. - -## 10. Editor Support Matrix (updated) - -| Editor | LSP | Solution Explorer | NuGet Browser | Profiler | -|--------|-----|-------------------|---------------|----------| -| VS Code | ✅ | ✅ webview tree | ✅ webview | ✅ | -| Rider / IntelliJ Ultimate | ✅ | ✅ **tool window** | ⏳ future | ⏳ | -| Neovim | ✅ | CLI `/sharplsp-tree` | CLI | ❌ | -| Helix | ✅ | CLI | CLI | ❌ | -| Zed | ✅ | `/sharplsp-tree` slash command | ❌ (no extension UI) | ❌ | - -The Rider plugin brings genuine feature parity with VS Code for the Solution -Explorer use case. NuGet Browser and Profiler UIs remain VS Code-only until -the Rider plugin grows them — their data flows (`sharplsp/nuget/*`, -`sharplsp/profiler/*`) are already LSP-native so future parity is purely a -rendering job. diff --git a/docs/specs/SCRIPTING-FILEBASED-SPEC.md b/docs/specs/SCRIPTING-FILEBASED-SPEC.md index 9b70852e..51001643 100644 --- a/docs/specs/SCRIPTING-FILEBASED-SPEC.md +++ b/docs/specs/SCRIPTING-FILEBASED-SPEC.md @@ -1,11 +1,10 @@ -# Scripting and File-Based Apps Specification +# Scripting and File-Based Apps Specification `[SCRIPT-SPEC]` **Parent:** [SHARPLSP-SPEC.md](SHARPLSP-SPEC.md) -## 1. Overview +## Overview `[SCRIPT-OVERVIEW]` -SharpLsp must provide full semantic language support for .NET source files that are **not owned by a -project file**. There are three distinct such formats, and they are not interchangeable: +SharpLsp provides semantic support for three distinct project-less .NET formats: | Format | Extension | Compilation model | Reference resolution | |---|---|---|---| @@ -13,59 +12,41 @@ project file**. There are three distinct such formats, and they are not intercha | C# script | `.csx` | `SourceCodeKind.Script` | `#r` / `#load` via Roslyn script resolvers | | F# script | `.fsx`, `.fsscript` | FSI script compilation | `#r "nuget:"` / `#load` / `#I` via FCS | -These are **first-class editing scenarios**, not a degraded fallback. A `.cs` file-based app opened -without a solution must get the same completion, hover, definition, rename, and diagnostic quality as -a file inside a `.csproj`. F# scripts are held to the same bar as C# per the project's F#-first -mandate. +A project-less `.cs` app and F# script MUST provide the same completion, hover, definition, rename, and diagnostic quality as project-owned code. -### 1.1 Why the naive approach is wrong `[SCRIPT-ANTIPATTERN]` +### Why directory globbing is wrong `[SCRIPT-ANTIPATTERN]` -The first implementation of this feature (PR #188) resolved a project-less file by globbing **every -`.cs` file in the containing directory** into one synthetic Roslyn project. This is incorrect and -must never be reintroduced. Concretely: +Never construct a project-less workspace by globbing every `.cs` file in its directory: -- A .NET file-based app's compilation closure is **one root file** plus its explicit `#:include` - closure. The .NET SDK documentation is unambiguous: *"By default, the single C# file is included."* -- Globbing a directory compiles unrelated programs together. Two sibling file-based apps each with - top-level statements produce `CS0017` (multiple entry points) and duplicate-type errors that do not - exist in a real build. -- It silently reads every `.cs` file in whatever directory the user happened to open a file from, - including generated output, `obj/`, and unrelated source. -- It ignores every `#:` directive, so `#:package`, `#:sdk`, and `#:property` have no effect — - the editor's view of the code diverges from what `dotnet run file.cs` actually compiles. +- A file-based app contains one root plus its explicit `#:include` closure. +- Globbing combines sibling apps, causing false `CS0017` and duplicate-type diagnostics. +- Globbing reads generated output, `obj/`, and unrelated source outside the declared closure. +- Ignoring `#:` directives makes editor semantics diverge from `dotnet run file.cs`. -The rule this spec enforces: **the compilation closure is derived from the file, never from the -directory.** +The compilation closure is derived from the file, never the directory. ---- +## Taxonomy and detection `[SCRIPT-TAXONOMY]` -## 2. Taxonomy and detection - -### 2.1 Document kind `[SCRIPT-DETECT]` +### Document kind `[SCRIPT-DETECT]` Every opened document resolves to exactly one `DocumentKind` before any workspace is created: | Kind | Trigger | |---|---| -| `ProjectOwned` | An owning `.csproj`/`.fsproj` is found by cone search (§2.2) | +| `ProjectOwned` | An owning `.csproj`/`.fsproj` is found by [SCRIPT-CONE] | | `CSharpFileBasedApp` | `.cs`, no owning project | | `CSharpScript` | `.csx` | | `FSharpScript` | `.fsx`, `.fsscript` | -| `FSharpSignature` | `.fsi`, no owning project — syntax-only, see §5.4 | +| `FSharpSignature` | `.fsi`, no owning project — syntax-only per [SCRIPT-FSX-FSI] | | `Unsupported` | Any other extension | -Classification is by extension **plus** cone search. It is never by content sniffing. +Classification uses extension plus cone search, never content sniffing. `Unsupported` documents MUST NOT initialize or latch a sidecar workspace; a later supported document must still initialize it. -`Unsupported` documents must not trigger sidecar workspace initialization. This is a hard requirement: -the host latches "workspace initialized" on the first document that successfully initializes a -workspace, and latching on a `.md` or `.json` file permanently prevents the real workspace from ever -opening. +Implementations: [main.rs](../../src/sharplsp/src/main.rs), [SolutionLoader.cs](../../src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionLoader.cs), and [WorkspaceManager.SingleFile.cs](../../src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SingleFile.cs). -### 2.2 Project cone precedence `[SCRIPT-CONE]` +### Project cone precedence `[SCRIPT-CONE]` -Before a document is treated as file-based or script, SharpLsp walks from the document's directory -toward the filesystem root looking for an owning project. The first directory containing any of -`*.sln`, `*.slnx`, `*.csproj`, `*.fsproj` wins, and the document is classified `ProjectOwned`. +Before treating a document as project-less, SharpLsp walks from its directory toward the filesystem root. The first directory containing `*.sln`, `*.slnx`, `*.csproj`, or `*.fsproj` wins and classifies the document as `ProjectOwned`. The walk stops at the first of: - a directory containing a project or solution file, @@ -73,14 +54,11 @@ The walk stops at the first of: - a directory containing `.git`, - the filesystem root. -Rationale: the .NET SDK documentation explicitly warns against placing file-based apps inside a -project cone because implicit build files interfere. When a user does it anyway, the project wins — -that matches what `dotnet run` does when a project file is present in the working directory. +The project wins inside a project cone, matching `dotnet run` when a project exists in the working directory. -A `.csx`/`.fsx` file is **never** `ProjectOwned`. Scripts are self-describing even inside a project -cone, because MSBuild does not compile `.csx`/`.fsx` by default. +A `.csx` or `.fsx` file is never `ProjectOwned`; MSBuild does not compile scripts by default. -### 2.3 Compilation closure `[SCRIPT-CLOSURE]` +### Compilation closure `[SCRIPT-CLOSURE]` | Kind | Closure | |---|---| @@ -88,24 +66,17 @@ cone, because MSBuild does not compile `.csx`/`.fsx` by default. | `CSharpScript` | root `.csx` + transitive `#load` expansion | | `FSharpScript` | root `.fsx` + transitive `#load` expansion (computed by FCS) | -Closure expansion is cycle-safe: a file already in the closure is not re-added, and a cycle is -reported as a diagnostic rather than causing unbounded recursion. Closure expansion is bounded at -**64 files** and **8 levels** of nesting; exceeding either bound produces a warning diagnostic and -truncates, so a pathological `#:include **/*.cs` cannot hang the sidecar. +Closure expansion does not re-add files and reports cycles as diagnostics. It is bounded at **64 files** and **8 levels**; exceeding either bound truncates expansion and emits a warning. ---- +## C# file-based apps `[SCRIPT-FILEBASED]` -## 3. C# file-based apps `[FILEBASED]` +Targets the [.NET 10 file-based app model](https://learn.microsoft.com/en-us/dotnet/core/sdk/file-based-apps). -Targets the .NET 10 SDK file-based app feature -([docs](https://learn.microsoft.com/en-us/dotnet/core/sdk/file-based-apps)). +Implementations: [FileLevelDirectives.cs](../../src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FileLevelDirectives.cs), [DocumentClosure.cs](../../src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentClosure.cs), and [WorkspaceManager.SingleFile.cs](../../src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SingleFile.cs). -### 3.1 Directive parsing `[FILEBASED-DIRECTIVES]` +### Directive parsing `[SCRIPT-FILEBASED-DIRECTIVES]` -File-level directives are parsed **off the Roslyn CST**, never with regular expressions or string -matching. Roslyn 5.6+ lexes `#:` as `IgnoredDirectiveTriviaSyntax` and `#!` as -`ShebangDirectiveTriviaSyntax`. The parser walks leading trivia of the compilation unit and collects -these nodes. +File-level directives are parsed **off the Roslyn CST**, never with regular expressions or string matching. Roslyn 5.6+ lexes `#:` as `IgnoredDirectiveTriviaSyntax` and `#!` as `ShebangDirectiveTriviaSyntax`. The parser walks leading trivia of the compilation unit and collects these nodes. Supported directives, matching the SDK exactly: @@ -117,111 +88,72 @@ Supported directives, matching the SDK exactly: | `#:property` | `#:property =` | Value may contain MSBuild expressions | | `#:include` | `#:include ` | Literal path, glob, or MSBuild property | -`#:include` maps to item types by extension, per the SDK: `*.cs` → `Compile`, `*.resx` → -`EmbeddedResource`, `*.json` → `None`, `*.razor` → `Content`. Only `Compile` items participate in the -semantic closure; the rest are recorded so the synthesized project stays faithful. +`#:include` maps to item types by extension, per the SDK: `*.cs` → `Compile`, `*.resx` → `EmbeddedResource`, `*.json` → `None`, `*.razor` → `Content`. Only `Compile` items participate in the semantic closure; the rest are recorded so the synthesized project stays faithful. -Directives must appear before the first non-trivia token. A `#:` directive that appears after real -code is reported as a diagnostic at its own location, matching compiler behavior. +Directives must appear before the first non-trivia token. A `#:` directive that appears after real code is reported as a diagnostic at its own location, matching compiler behavior. -### 3.2 Shebang `[FILEBASED-SHEBANG]` +### Shebang `[SCRIPT-FILEBASED-SHEBANG]` -A leading `#!` line is valid in a file-based app and must not produce a diagnostic. Because Roslyn -lexes it as `ShebangDirectiveTriviaSyntax`, no text preprocessing is required — the file is passed to -Roslyn verbatim. SharpLsp must never strip, rewrite, or offset the shebang line, because doing so -would desynchronize LSP positions from the on-disk text. +A leading `#!` line is valid in a file-based app and must not produce a diagnostic. Because Roslyn lexes it as `ShebangDirectiveTriviaSyntax`, no text preprocessing is required — the file is passed to Roslyn verbatim. SharpLsp must never strip, rewrite, or offset the shebang line, because doing so would desynchronize LSP positions from the on-disk text. -### 3.3 Reference resolution `[FILEBASED-REFERENCES]` +### Reference resolution `[SCRIPT-FILEBASED-REFERENCES]` Reference resolution has two tiers. Tier 1 is correct; tier 2 is a bounded degradation. -#### Tier 1 — synthesized project + real restore `[FILEBASED-REFERENCES-MSBUILD]` +#### Tier 1 — synthesized project + real restore `[SCRIPT-FILEBASED-REFERENCES-MSBUILD]` -1. Synthesize an MSBuild project equivalent to the SDK's virtual project from the parsed directives. - The project is constructed through `Microsoft.Build.Construction.ProjectRootElement` — an actual - XML DOM — and never by string concatenation, per the repo's structured-file rule. -2. Write it to a per-app cache directory keyed by a hash of the root file's full path, mirroring the - SDK's own `/dotnet/runfile/-/` scheme. +1. Synthesize an MSBuild project equivalent to the SDK's virtual project from the parsed directives. The project is constructed through `Microsoft.Build.Construction.ProjectRootElement` — an actual XML DOM — and never by string concatenation, per the repo's structured-file rule. +2. Write it to a deterministic per-app work directory keyed by a hash of the root file's full path, mirroring the SDK's `/dotnet/runfile/-/` scheme. This is build state, not a semantic-result cache. 3. Run `dotnet restore` on it. 4. Load it through the **existing** `MSBuildWorkspace` path. -This yields exactly the references, implicit usings, analyzers, framework references, and language -version that `dotnet build file.cs` yields, and it reuses the workspace pipeline already in -production rather than duplicating it. - -Defaults applied when no directive overrides them, matching the SDK: `TargetFramework` from the -resolved SDK band, `ImplicitUsings=enable`, `Nullable=enable`, `OutputKind=ConsoleApplication`, -`PublishAot=true`, `PackAsTool=true`. `PublishAot`/`PackAsTool` do not affect semantics but are -carried so `dotnet project convert` parity holds. +The resulting references, implicit usings, analyzers, framework references, and language version MUST match `dotnet build file.cs`. -Implicit build files — `Directory.Build.props`, `Directory.Build.targets`, -`Directory.Packages.props`, `nuget.config`, `global.json` — are honored because a real restore is -performed from the app's own directory. This is a correctness advantage over any hand-rolled -reference list and is the primary reason tier 1 is the default. +Defaults applied when no directive overrides them, matching the SDK: `TargetFramework` from the resolved SDK band, `ImplicitUsings=enable`, `Nullable=enable`, `OutputKind=ConsoleApplication`, `PublishAot=true`, `PackAsTool=true`. `PublishAot`/`PackAsTool` do not affect semantics but are carried so `dotnet project convert` parity holds. -#### Tier 2 — in-memory reference assemblies `[FILEBASED-REFERENCES-FALLBACK]` +Restore runs from the app directory and MUST honor `Directory.Build.props`, `Directory.Build.targets`, `Directory.Packages.props`, `nuget.config`, and `global.json`. -When the .NET SDK is unavailable, restore fails, or restore has not yet completed, the sidecar builds -an `AdhocWorkspace` using `Basic.Reference.Assemblies` for the target framework band. This gives -immediate BCL-level IntelliSense with zero I/O so the editor is never dead while restore runs. +#### Tier 2 — in-memory reference assemblies `[SCRIPT-FILEBASED-REFERENCES-FALLBACK]` -Tier 2 is explicitly **incomplete**: `#:package` references are unresolved, so symbols from NuGet -packages will not bind. The sidecar must publish an informational diagnostic naming the reason, and -must upgrade to tier 1 automatically when restore succeeds. +When the .NET SDK is unavailable, restore fails, or restore has not yet completed, the sidecar builds an `AdhocWorkspace` using `Basic.Reference.Assemblies` for the target framework band. This gives immediate BCL-level IntelliSense with zero I/O so the editor is never dead while restore runs. -Tier 2 must never be silently presented as a successful full load. `workspace/status` reports -`filebased-degraded` in this state. +Tier 2 is explicitly **incomplete**: `#:package` references are unresolved, so symbols from NuGet packages will not bind. The sidecar must publish an informational diagnostic naming the reason, and must upgrade to tier 1 automatically when restore succeeds. -### 3.4 Parse options `[FILEBASED-PARSEOPTIONS]` +Tier 2 must never be silently presented as a successful full load. `workspace/status` reports `filebased-degraded` in this state. -`LanguageVersion` is resolved from the target framework band, not hardcoded to `Preview`. `Preview` -enables unstable features that the user's SDK may reject, producing editor-only false negatives. -`LanguageVersion.Latest` is used when the band cannot be determined. +### Parse options `[SCRIPT-FILEBASED-PARSEOPTIONS]` -### 3.5 Entry points `[FILEBASED-ENTRYPOINT]` +`LanguageVersion` is resolved from the target framework band, not hardcoded to `Preview`. `Preview` enables unstable features that the user's SDK may reject, producing editor-only false negatives. `LanguageVersion.Latest` is used when the band cannot be determined. -A file-based app root file carries top-level statements. `#:include`d `.cs` files may add types, -methods, and namespaces but **may not** add top-level statements — the SDK forbids it. SharpLsp -reports a violation as a diagnostic on the offending included file rather than allowing a confusing -`CS0017` from the compiler. +### Entry points `[SCRIPT-FILEBASED-ENTRYPOINT]` ---- +A file-based app root file carries top-level statements. `#:include`d `.cs` files may add types, methods, and namespaces but **may not** add top-level statements — the SDK forbids it. SharpLsp reports a violation as a diagnostic on the offending included file rather than allowing a confusing `CS0017` from the compiler. -## 4. C# scripts `[CSX]` +## C# scripts `[SCRIPT-CSX]` -`.csx` is Roslyn scripting, **not** a file-based app. Conflating the two is a correctness bug: `#r` -and `#load` are script-only, `#:` directives are file-based-only, and the two use different -`SourceCodeKind` values. +`.csx` is Roslyn scripting, **not** a file-based app. Conflating the two is a correctness bug: `#r` and `#load` are script-only, `#:` directives are file-based-only, and the two use different `SourceCodeKind` values. -### 4.1 Parse and compilation options `[CSX-OPTIONS]` +### Parse and compilation options `[SCRIPT-CSX-OPTIONS]` -- `CSharpParseOptions` with `kind: SourceCodeKind.Script`. This enables top-level statements, - declarations, and a trailing expression. +- `CSharpParseOptions` with `kind: SourceCodeKind.Script`. This enables top-level statements, declarations, and a trailing expression. - `OutputKind.DynamicallyLinkedLibrary`. -- Script default imports applied as global usings: `System`, `System.IO`, `System.Collections.Generic`, - `System.Console`, `System.Diagnostics`, `System.Dynamic`, `System.Linq`, - `System.Linq.Expressions`, `System.Text`, `System.Threading.Tasks`. +- Script default imports applied as global usings: `System`, `System.IO`, `System.Collections.Generic`, `System.Console`, `System.Diagnostics`, `System.Dynamic`, `System.Linq`, `System.Linq.Expressions`, `System.Text`, `System.Threading.Tasks`. -### 4.2 Directive resolution `[CSX-RESOLVERS]` +### Directive resolution `[SCRIPT-CSX-RESOLVERS]` -- `#load` is resolved by a `SourceReferenceResolver` rooted at the script's directory, feeding - §2.3 closure expansion. +- `#load` is resolved by a `SourceReferenceResolver` rooted at the script's directory, feeding [SCRIPT-CLOSURE] expansion. - `#r "assembly.dll"` is resolved by a `MetadataReferenceResolver` rooted at the script's directory. -- `#r "nuget: Pkg, Version"` requires NuGet resolution and is **out of scope for phase 1**. It must - produce a clearly-worded unresolved-reference diagnostic, never a silent wrong answer. +- `#r "nuget: Pkg, Version"` requires NuGet resolution and is **out of scope for phase 1**. It must produce a clearly-worded unresolved-reference diagnostic, never a silent wrong answer. ---- +## F# scripts `[SCRIPT-FSX]` -## 5. F# scripts `[FSX]` +F# scripts use FCS directive resolution; SharpLsp MUST NOT reimplement it. -F# scripts are handled by FCS natively and require no directive parsing of our own — a significant -advantage over the C# path that must be preserved rather than reimplemented. +Implementation: [FSharpWorkspace.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspace.fs). -### 5.1 Project options `[FSX-OPTIONS]` +### Project options `[SCRIPT-FSX-OPTIONS]` -`FSharpChecker.GetProjectOptionsFromScript` is the single entry point. It resolves `#r`, `#r "nuget:"`, -`#I`, and `#load` closures, selects the framework references, and returns `FSharpProjectOptions` -directly consumable by the existing `parseAndCheckOnce` pipeline. +`FSharpChecker.GetProjectOptionsFromScript` is the single entry point. It resolves `#r`, `#r "nuget:"`, `#I`, and `#load` closures, selects the framework references, and returns `FSharpProjectOptions` directly consumable by the existing `parseAndCheckOnce` pipeline. Invocation parameters: - `assumeDotNetFramework = false` @@ -229,136 +161,81 @@ Invocation parameters: - `useFsiAuxLib = true` — makes the `fsi` object bind, so `fsi.CommandLineArgs` resolves. - `previewEnabled` follows the resolved language version. -### 5.2 Preprocessor symbols `[FSX-SYMBOLS]` +### Preprocessor symbols `[SCRIPT-FSX-SYMBOLS]` -Scripts opened in the editor define both `INTERACTIVE` and `EDITING`. `COMPILED` is **not** defined. -Getting this wrong makes `#if INTERACTIVE` blocks appear greyed-out-dead in the editor while being -live at runtime. +Scripts opened in the editor define both `INTERACTIVE` and `EDITING`. `COMPILED` is **not** defined. Getting this wrong makes `#if INTERACTIVE` blocks appear greyed-out-dead in the editor while being live at runtime. -### 5.3 NuGet references `[FSX-NUGET]` +### NuGet references `[SCRIPT-FSX-NUGET]` -`#r "nuget: ..."` resolution is performed by FCS's dependency manager and requires network and cache -access. It is slow on first use (seconds). Resolution runs off the request path; the script is first -checked without the package references so the editor is responsive, then re-checked once resolution -completes and diagnostics are republished. +`#r "nuget: ..."` resolution is performed by FCS's dependency manager and requires network or local package-store access. It is slow on first use (seconds). Resolution runs off the request path; the script is first checked without the package references so the editor is responsive, then re-checked once resolution completes and diagnostics are republished. -### 5.4 Signature files `[FSX-FSI]` +### Signature files `[SCRIPT-FSX-FSI]` -A `.fsi` signature file with no owning project has no meaningful semantic closure. It is served -syntax-only (document symbols, folding, selection range) by the Rust host, and no F# sidecar -workspace is opened for it. +A `.fsi` signature file with no owning project has no meaningful semantic closure. It is served syntax-only (document symbols, folding, selection range) by the Rust host, and no F# sidecar workspace is opened for it. ---- +## Host routing `[SCRIPT-ROUTE]` -## 6. Host routing `[SCRIPT-ROUTE]` +### Lazy workspace initialization `[SCRIPT-ROUTE-LAZY]` -### 6.1 Lazy workspace initialization `[SCRIPT-ROUTE-LAZY]` - -When the LSP client supplies no workspace root, the host defers `workspace/open` until the first -`textDocument/didOpen` that resolves to a supported `DocumentKind`. +When the LSP client supplies no workspace root, the host defers `workspace/open` until the first `textDocument/didOpen` that resolves to a supported `DocumentKind`. Requirements: -- The "initialized" latch is set **only** when a workspace was actually opened. A `didOpen` for an - `Unsupported` document must leave the latch clear so a later `.cs`/`.fs` open still initializes. -- Only the sidecar matching the document's language is started. Opening a `.cs` file must not spawn - the F# sidecar and vice versa. -- The second language's sidecar is started on demand when a document of that language is first - opened, so a mixed-language folder works without a restart. -- Lazy initialization performs the same steps as eager initialization — workspace open, analyzer - configuration, diagnostics wiring, then health monitoring. It must share one implementation with - the eager path rather than duplicating a subset of it. - -### 6.2 Workspace target `[SCRIPT-ROUTE-TARGET]` +- The "initialized" latch is set **only** when a workspace was actually opened. A `didOpen` for an `Unsupported` document must leave the latch clear so a later `.cs`/`.fs` open still initializes. +- Only the sidecar matching the document's language is started. Opening a `.cs` file must not spawn the F# sidecar and vice versa. +- The second language's sidecar is started on demand when a document of that language is first opened, so a mixed-language folder works without a restart. +- Lazy initialization performs the same steps as eager initialization — workspace open, analyzer configuration, diagnostics wiring, then health monitoring. It must share one implementation with the eager path rather than duplicating a subset of it. -The host sends the **file path**, not the parent directory, for script and file-based documents. The -parent directory is meaningful only for `ProjectOwned` documents. Sending a directory is what forces -the sidecar into directory-globbing and is prohibited. +### Workspace target `[SCRIPT-ROUTE-TARGET]` -### 6.3 Health monitor ordering `[SCRIPT-ROUTE-HEALTH]` +The host sends the **file path**, not the parent directory, for script and file-based documents. The parent directory is meaningful only for `ProjectOwned` documents. Sending a directory is what forces the sidecar into directory-globbing and is prohibited. -Health monitoring starts only after `workspace/open` completes, matching the existing eager path — a -health check that races workspace load can time out on the transport lock and kill a healthy sidecar. +### Health monitor ordering `[SCRIPT-ROUTE-HEALTH]` ---- +Per [SIDECAR-HEALTH-ACTIVITY](SIDECAR-LIFECYCLE-SPEC.md), the per-language supervisor sends no ping until `workspace/open` and generation bootstrap complete. Eager and lazy callers MUST NOT start another health task; workspace open uses its 600-second response budget without a competing transport-locking ping. -## 7. Lifecycle +## Lifecycle `[SCRIPT-LIFECYCLE]` -### 7.1 Directive edits `[SCRIPT-RELOAD]` +### Directive edits `[SCRIPT-RELOAD]` -Editing a `#:package`, `#:project`, `#:sdk`, or `#:include` directive changes the compilation closure -and reference set. On `didChange`, the sidecar re-parses directives from the in-memory text and, if -the directive set changed, schedules a workspace reload debounced by -`sharplsp.toml`'s `server.debounce_ms`. Text-only edits never trigger reload. +Editing a `#:package`, `#:project`, `#:sdk`, or `#:include` directive changes the compilation closure and reference set. On `didChange`, the sidecar re-parses directives from the in-memory text and, if the directive set changed, schedules a workspace reload debounced by `sharplsp.toml`'s `server.debounce_ms`. Text-only edits never trigger reload. -### 7.2 Closure membership changes `[SCRIPT-RELOAD-CLOSURE]` +### Closure membership changes `[SCRIPT-RELOAD-CLOSURE]` -A file entering or leaving the `#:include` / `#:load` closure adds or removes a Roslyn document. -Removal must also clear published diagnostics for that file, otherwise stale squiggles persist in -files no longer part of the app. +A file entering or leaving the `#:include` / `#:load` closure adds or removes a Roslyn document. Removal must also clear published diagnostics for that file, otherwise stale squiggles persist in files no longer part of the app. -### 7.3 Multiple roots `[SCRIPT-MULTIROOT]` +### Multiple roots `[SCRIPT-MULTIROOT]` -Two file-based apps in one directory are two independent compilations. The sidecar keeps a map of -root path → workspace and never merges them. Opening `foo.cs` and `bar.cs` from the same folder -yields two closures, not one project containing both. +Two file-based apps in one directory are two independent compilations. The sidecar keeps a map of root path → workspace and never merges them. Opening `foo.cs` and `bar.cs` from the same folder yields two closures, not one project containing both. ---- +## Error handling and degradation `[SCRIPT-DEGRADE]` -## 8. Error handling and degradation `[SCRIPT-DEGRADE]` +- 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. +- 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. -- A **file** path that resolves to no supported document kind returns a `Result` failure. It must not - be silently converted into an empty synthetic workspace — that turns a real "I could not load your - code" into a wall of phantom diagnostics. -- A **directory** holding no solution or project at all is not a failure. The host opens a workspace - folder eagerly, before any document exists, so `OpenCoreAsync` records the root as project-less and - returns success, deferring workspace creation to the first document update. That document is then - loaded as a file-based app or script, and each subsequent loose file is added as its own ad-hoc - project — two independent files in one folder stay two compilations, per [SCRIPT-ANTIPATTERN]. - `IsLoaded` stays false until a document arrives, so nothing claims a workspace exists before one - does. -- Ambiguous solution discovery (multiple `.sln` under the root) also returns "no target" from - `SolutionLoader`. That case is **ambiguity, not absence**, and must surface as an error naming every - candidate and the `csharp.solution_path` setting that resolves it — never the project-less deferral - above. Treating it as file-based mode would silently mis-analyze an entire repository: no project - reference resolves, and every cross-project type becomes a phantom "not found" diagnostic. - `SolutionLoader.FindAmbiguousSolutions` is what distinguishes the two, and - [WORKSPACE-SOLUTION-PATH] specifies the setting the message points at. -- 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. - ---- - -## 9. Performance `[SCRIPT-PERF]` +## Performance `[SCRIPT-PERF]` | Operation | Target | |---|---| | Classification + cone search | <5ms | | Tier 2 workspace ready (first IntelliSense) | <300ms | -| Tier 1 workspace ready (restore cached) | <1.5s | +| Tier 1 workspace ready (restore assets current) | <1.5s | | Tier 1 workspace ready (cold restore) | <10s, non-blocking | | Directive re-parse on keystroke | <1ms | -Cone search is bounded by the stop conditions in §2.2 and must not stat the whole tree. +Cone search is bounded by [SCRIPT-CONE] and must not stat the whole tree. ---- +## Security `[SCRIPT-SECURITY]` -## 10. Security `[SCRIPT-SECURITY]` - -- Opening a file must never cause SharpLsp to read files outside the declared closure. Directory-wide - reads are prohibited (§1.1). -- `#:include` and `#load` paths that escape the root file's directory are permitted (the SDK permits - `../`) but are logged at debug level. -- Tier 1 runs `dotnet restore`, which executes NuGet resolution and may execute package build logic. - This is the same trust boundary as opening any project and is acceptable, but restore must run only - for documents the user actually opened, never speculatively across a directory. +- Opening a file must never cause SharpLsp to read files outside the declared closure. Directory-wide reads are prohibited by [SCRIPT-ANTIPATTERN]. +- `#:include` and `#load` paths that escape the root file's directory are permitted (the SDK permits `../`) but are logged at debug level. +- Tier 1 `dotnet restore` may execute package build logic, so it runs only for opened documents, never speculatively across a directory. - No script is ever executed to obtain type information. All analysis is compile-time. ---- - -## 11. Testing `[SCRIPT-TESTS]` +## Testing `[SCRIPT-TESTS]` -Coarse end-to-end tests only, per repo policy. Every test drives the real sidecar over real IPC with -real files on disk. +Tests drive real sidecars over IPC with real files: [WorkspaceManagerSingleFileTests.cs](../../src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerSingleFileTests.cs) and [FSharpScriptTests.fs](../../src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpScriptTests.fs). Required coverage: - `.cs` file-based app: BCL completion resolves; hover on `Console.WriteLine` binds. diff --git a/docs/specs/SHARPLSP-SPEC.md b/docs/specs/SHARPLSP-SPEC.md index 16c6d2ac..dfa5dbb8 100644 --- a/docs/specs/SHARPLSP-SPEC.md +++ b/docs/specs/SHARPLSP-SPEC.md @@ -1,48 +1,26 @@ -# SHARPLSP - -**The .NET Language Server Platform** +# [SHARPLSP-SPEC] SHARPLSP **TECHNICAL SPECIFICATION v0.1** -C# + F# | Editor-Agnostic | Rust-Hosted | Open Source - -*March 2026 | DRAFT* - -## 1. Mission Statement - -SharpLsp is an open-source, editor-agnostic [Language Server Protocol (LSP)](https://microsoft.github.io/language-server-protocol/) implementation for the .NET ecosystem, written in Rust, aiming to match — and ultimately go beyond — what Visual Studio, [JetBrains Rider](https://www.jetbrains.com/rider/), and [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) deliver today, with C# and F# as equal first-class citizens. - -SharpLsp exists because .NET developers deserve world-class tooling that is not gated behind proprietary licenses, vendor lock-in, or single-editor coupling. Every .NET developer, in every editor, on every platform, should have access to the best possible development experience. +## [SHARPLSP-MISSION] Mission -### 1.1 Design Principles +SharpLsp is an open-source, editor-agnostic [LSP 3.17](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/) implementation for C# and F#, with a Rust host and compiler-backed .NET sidecars. -- **Editor-agnostic:** Pure [LSP 3.17+](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/) protocol. No editor-specific APIs. Works in [VS Code](https://code.visualstudio.com/), [Neovim](https://neovim.io/), [Helix](https://helix-editor.com/), [Zed](https://zed.dev/), [Emacs](https://www.gnu.org/software/emacs/), [Sublime](https://www.sublimetext.com/), or any LSP-capable editor. +### [SHARPLSP-MISSION-PRINCIPLES] Design Principles -- **C# and F# are equals:** F# is not a second-class citizen bolted on later. Both languages share infrastructure, both hit feature parity targets, both are tested to the same standard. +- **Editor-agnostic:** use LSP 3.17+ without editor-specific APIs. +- **Language parity:** C# and F# share infrastructure, feature targets, and test standards. +- **Open dependencies:** use Roslyn and FCS without proprietary Visual Studio or C# Dev Kit components. +- **Rust hot path:** keep protocol handling, document state, syntax parsing, routing, and memoization in Rust. +- **Compiler semantics:** delegate semantic analysis to Roslyn and FCS; do not reimplement type checkers. -- **Zero proprietary dependencies:** The only Microsoft components are the open-source, MIT-licensed [Roslyn compiler](https://github.com/dotnet/roslyn) and [F# Compiler Services](https://fsharp.github.io/fsharp-compiler-docs/). No Visual Studio licensing. No C# Dev Kit EULA. +## [SHARPLSP-ARCHITECTURE] Architecture -- **Rust for the hot path:** Protocol handling, document management, syntax parsing, request routing, and caching all happen in Rust for maximum throughput and minimum latency. +### [SHARPLSP-ARCHITECTURE-TIERS] High-Level Architecture -- **Correctness over cleverness:** Semantic analysis is delegated to the official compilers via managed sidecar processes. We do not reimplement type checkers. - -- **Match the leaders, then go further:** Not approximate parity. Not a lightweight alternative. Full feature-for-feature coverage of what Visual Studio, Rider, and C# Dev Kit do best — and then more. Every refactoring Rider has. Every code fix Visual Studio has. Every diagnostic, every navigation feature. - -### 1.2 Where SharpLsp Fits - -The .NET tooling landscape today is excellent in places, but no single product covers every developer. SharpLsp is positioned to complement three established tools by closing the gaps each leaves behind: - -| Tool | Gap SharpLsp Addresses | -|---|---| -| Visual Studio | Windows-only, closed-source IDE. Most language features are tied to the full IDE install. No LSP surface for external editors. | -| JetBrains Rider | Excellent product, but proprietary and paid ($169–$399/yr). Uses a custom protocol, not LSP. Dual-process JVM/.NET architecture is resource-heavy. | -| C# Dev Kit | VS Code-only. Proprietary license for teams >5. No F# support. Custom non-standard LSP extensions don't carry across other editors. | +SharpLsp uses a Rust host for the LSP protocol and syntax analysis, plus managed .NET sidecars for C#/[Roslyn](https://github.com/dotnet/roslyn) and F#/[FCS](https://fsharp.github.io/fsharp-compiler-docs/) semantic analysis. -## 2. Architecture - -### 2.1 High-Level Architecture - -SharpLsp uses a three-tier architecture: a Rust host process handles the LSP protocol and syntax-level analysis, communicating with two managed .NET sidecar processes (one for C#/[Roslyn](https://github.com/dotnet/roslyn), one for F#/[FCS](https://fsharp.github.io/fsharp-compiler-docs/)) that perform all semantic analysis. This is not a compromise — it is the optimal design, validated by Visual Studio's own ServiceHub architecture and [FsAutoComplete](https://github.com/fsharp/FsAutoComplete)'s production deployment. +Primary implementations: [main.rs](../../src/sharplsp/src/main.rs), [handlers.rs](../../src/sharplsp/src/handlers.rs), [semantic.rs](../../src/sharplsp/src/semantic.rs), and the [sidecar protocol](../../src/sharplsp/src/sidecar/protocol.rs). **Tier 1 — Rust LSP Host** @@ -65,12 +43,16 @@ SharpLsp uses a three-tier architecture: a Rust host process handles the LSP pro **Tier 3 — F# Sidecar (FCS)** - Long-running .NET process hosting [FSharp.Compiler.Service](https://www.nuget.org/packages/FSharp.Compiler.Service) v43.12+ -- [FSharpChecker](https://fsharp.github.io/fsharp-compiler-docs/reference/fsharp-compiler-codeanalysis-fsharpchecker.html) with incremental build caching (MRU caches for parse/check results) +- [FSharpChecker](https://fsharp.github.io/fsharp-compiler-docs/reference/fsharp-compiler-codeanalysis-fsharpchecker.html) for parsing, checking, and semantic queries - [Ionide.ProjInfo](https://github.com/ionide/proj-info) for project cracking (MSBuild evaluation for F# projects) - [FSharpLint](https://github.com/fsprojects/FSharpLint) for linting -- Same RPC interface and transport as C# sidecar for architectural symmetry +- Same RPC interface and transport as the C# sidecar + +### [SHARPLSP-ARCHITECTURE-CACHING] Cache Ownership -### 2.2 IPC Transport Protocol +Rust-host salsa MUST be the only memoization mechanism. Sidecars and clients MAY retain authoritative compiler, document, protocol, and rendered UI state, but MUST NOT cache feature results. LRU, dictionary/map-backed, sidecar-local, client-local, and other ad-hoc result caches are forbidden. + +### [SHARPLSP-ARCHITECTURE-IPC] IPC Transport Protocol Communication between the Rust host and .NET sidecars uses a custom binary RPC protocol: @@ -79,13 +61,13 @@ Communication between the Rust host and .NET sidecars uses a custom binary RPC p | Transport | Named pipes (Windows) / Unix domain sockets (Linux, macOS) | | Serialization | [MessagePack](https://msgpack.org/) via [rmp-serde](https://crates.io/crates/rmp-serde) (Rust) and [MessagePack-CSharp](https://github.com/MessagePack-CSharp/MessagePack-CSharp) (.NET) | | Framing | 4-byte little-endian length prefix + MessagePack payload | -| Concurrency | Request IDs for multiplexed async request/response + server-initiated notifications | +| Concurrency | One active host-to-sidecar request per connection; request IDs provide exact correlation, while a single connection driver dispatches interleaved server-initiated notifications | | Cancellation | Dedicated cancel notification matching LSP `$/cancelRequest` semantics | | Performance target | <500µs round-trip overhead (excluding compiler work) | -MessagePack was chosen over JSON-RPC because it is 2.3x faster to serialize and 57% smaller on the wire, and because Roslyn's own out-of-process ServiceHub uses MessagePack in production, proving it works at IDE scale. +The detailed frame ownership, correlation, notification, health, and poisoning rules are normative in [SIDECAR-LIFECYCLE-SPEC.md](SIDECAR-LIFECYCLE-SPEC.md). -### 2.3 Request Routing Strategy +### [SHARPLSP-ARCHITECTURE-ROUTING] Request Routing Strategy The Rust host classifies every incoming LSP request and routes it to the fastest handler: @@ -94,24 +76,28 @@ The Rust host classifies every incoming LSP request and routes it to the fastest | Syntax-only | Rust (tree-sitter) | <5ms | documentSymbol, foldingRange, selectionRange, linkedEditingRange | | Semantic | Sidecar (Roslyn/FCS) | <200ms | completion, hover, definition, references, rename, codeAction, diagnostics | | Hybrid | Rust + Sidecar | <100ms | semanticTokens (tree-sitter for structure, sidecar for classification) | -| Cached | Rust (salsa cache) | <1ms | Repeat requests for unchanged documents | +| Memoized | Rust-host salsa | <1ms | Repeat requests for unchanged inputs | Key optimization: on every keystroke, tree-sitter re-parses in <1ms and provides immediate feedback for syntax-level features, while semantic requests are coalesced with a debounce window (default 150ms) before dispatching to sidecars. Stale in-flight semantic requests are cancelled when superseded. -### 2.4 Sidecar Lifecycle Management +### [SHARPLSP-ARCHITECTURE-SIDECARS] Sidecar Lifecycle Management + +The normative state machine and platform contract are in [SIDECAR-LIFECYCLE-SPEC.md](SIDECAR-LIFECYCLE-SPEC.md). -- **Startup:** Sidecars are spawned lazily on first request for their language. Published as self-contained single-file executables (AOT is incompatible with Roslyn, FSharp.Compiler.Service, and other reflection-heavy dependencies). -- **Health monitoring:** Periodic heartbeat pings (every 5s). If a sidecar fails to respond within 2s, it is marked unhealthy. -- **Request timeouts** `[SIDECAR-REQUEST-TIMEOUT]`: Every host→sidecar request carries a response budget — 600s for `workspace/open` (a full MSBuild design-time build on a cold NuGet cache is legitimately slow), 120s for everything else. A request that exceeds its budget is failed to the client, the IPC connection is dropped, and the sidecar process is killed: the late response would otherwise be handed to the next caller and desync the framed protocol, and the health monitor deliberately skips pinging while a request is in flight, so a wedged handler would never be detected. The next request respawns a clean sidecar via the normal crash-recovery path. -- **Crash recovery:** On sidecar death, cache last-known-good results for graceful degradation. Restart with exponential backoff (1s, 2s, 4s, max 30s). Notify editor via LSP `window/showMessage`. -- **Isolation:** C# and F# sidecars are independent processes. A Roslyn OOM does not affect FCS, and vice versa. -- **Shutdown:** On LSP `shutdown` notification, send cancellation to sidecars, wait up to 5s for graceful exit, then SIGKILL. +- **Startup:** A per-language supervisor lazily launches one direct, version-matched sidecar process, using a new current-user-only IPC endpoint for every generation. `READY` identifies the generation, process, protocol, and effective bound endpoint; semantic readiness follows workspace bootstrap. +- **Health monitoring:** The connection driver pings only while `Ready` and idle (every 5s, with a 2s response budget). An in-flight request is governed by its own deadline and cannot race a second transport-locking health caller. -### 2.5 Project System +#### [SHARPLSP-ARCHITECTURE-SIDECARS-TIMEOUT] Request Timeouts -The project system is the hardest engineering problem in .NET tooling. [MSBuild](https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild) project files are Turing-complete, and correct evaluation requires handling SDK-style projects, legacy .csproj/.fsproj, multi-targeting, [Directory.Build.props](https://learn.microsoft.com/en-us/visualstudio/msbuild/customize-by-directory), [Directory.Packages.props](https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management) (Central Package Management), [global.json](https://learn.microsoft.com/en-us/dotnet/core/tools/global-json) SDK pinning, conditional compilation symbols, and NuGet package resolution. +Every host-to-sidecar request carries a response budget: 600s for `workspace/open` and 120s for everything else. A request that exceeds its budget fails, poisons the IPC connection, and terminates the contained sidecar process tree, so a late response cannot reach the next caller. -**SharpLsp's approach:** +- **Crash recovery:** Startup and runtime failures share one exponential backoff sequence (1s, 2s, 4s, up to 30s). A replacement generation replays workspace, configuration, and current VFS document state before becoming ready. Only Rust-host salsa query results MAY provide explicitly stale graceful degradation. +- **Isolation and containment:** C# and F# supervisors, backoff, endpoints, and process trees are independent. Windows Job Objects and Unix process groups/parent-death handling prevent orphaned sidecars and compiler descendants. +- **Shutdown:** The sidecar flushes a correlated shutdown acknowledgement before cancelling its loop. The host allows up to 5s for clean exit, then terminates and reaps only that generation's contained process tree. + +### [SHARPLSP-ARCHITECTURE-PROJECTS] Project System + +Project evaluation MUST handle SDK-style and legacy `.csproj`/`.fsproj` files, multi-targeting, [Directory.Build.props](https://learn.microsoft.com/en-us/visualstudio/msbuild/customize-by-directory), [Directory.Packages.props](https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management), [global.json](https://learn.microsoft.com/en-us/dotnet/core/tools/global-json), conditional symbols, and NuGet resolution. - **C# projects:** [MSBuildWorkspace](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.msbuild.msbuildworkspace) ([Microsoft.CodeAnalysis.Workspaces.MSBuild](https://www.nuget.org/packages/Microsoft.CodeAnalysis.Workspaces.MSBuild) + [Microsoft.Build.Locator](https://github.com/microsoft/MSBuildLocator)) performs design-time builds to extract source files, references, and compiler options. - **F# projects:** [Ionide.ProjInfo](https://github.com/ionide/proj-info) performs MSBuild evaluation with F#-specific handling (file ordering, which is semantically significant in F#). @@ -121,40 +107,26 @@ The project system is the hardest engineering problem in .NET tooling. [MSBuild] - **Multi-targeting:** Projects targeting multiple TFMs (e.g., `net8.0;net48;netstandard2.0`) present multiple analysis contexts. SharpLsp exposes a custom LSP extension for users to select the active TFM, defaulting to the first. - **Project-less files:** A `.cs` [file-based app](https://learn.microsoft.com/en-us/dotnet/core/sdk/file-based-apps), a `.csx` Roslyn script, and a `.fsx` F# script are all first-class editing targets with no owning project. Their compilation closure is derived from the root file — `#:include` for file-based apps, `#load` for scripts — and never from the containing directory. See [SCRIPTING-FILEBASED-SPEC.md](SCRIPTING-FILEBASED-SPEC.md). -#### Choosing the Solution to Open `[WORKSPACE-SOLUTION-PATH]` +#### [SHARPLSP-ARCHITECTURE-PROJECTS-SOLUTION-PATH] Choosing the Solution to Open -The host sends one path to each sidecar's `workspace/open`. When that path is a -directory, the C# sidecar discovers a target under it: an unambiguous `.sln`, -`.slnx`, or `.csproj` is opened directly. Discovery **never guesses** between -several nested solutions — a monorepo root holding `app/App.sln` and -`other/Other.sln` is ambiguous, and guessing would silently load the wrong half -of the repository. +The host sends one path to each sidecar's `workspace/open`. When that path is a directory, the C# sidecar discovers a target under it: an unambiguous `.sln`, `.slnx`, or `.csproj` is opened directly. Discovery **never guesses** between several nested solutions — a monorepo root holding `app/App.sln` and `other/Other.sln` is ambiguous, and guessing would silently load the wrong half of the repository. -`csharp.solution_path` in `sharplsp.toml` resolves that ambiguity by naming the -solution to open, absolute or relative to the workspace root: +`csharp.solution_path` in `sharplsp.toml` resolves that ambiguity by naming the solution to open, absolute or relative to the workspace root: ```toml [csharp] solution_path = "app/App.sln" ``` -The host resolves the setting and sends the **solution file** rather than the -root, so the sidecar opens it without running discovery at all. The setting -falls back to workspace-root discovery when unset, and when it names a path that -is not an existing file — a stale or misspelled entry degrades to auto-discovery -instead of wedging the workspace on a path that cannot load. +The host resolves the setting and sends the **solution file** rather than the root, so the sidecar opens it without running discovery at all. The setting falls back to workspace-root discovery when unset, and when it names a path that is not an existing file — a stale or misspelled entry degrades to auto-discovery instead of wedging the workspace on a path that cannot load. -Without this, an ambiguous root loads no solution, and every semantic -request — hover, completion, diagnostics, navigation — returns empty for the -whole workspace. +### [SHARPLSP-ARCHITECTURE-BINARIES] Binary Layout and Installation -### 2.6 Binary Layout & Installation - -**The `sharplsp` binary is bundled inside every per-platform VSIX.** A user who installs the VS Code extension gets a fully working LSP server with zero additional steps. Extensions are NOT thin clients that require a system-installed binary — the binary ships inside the extension. +Every per-platform VSIX MUST bundle `sharplsp`. **Per-platform VSIX layout:** -Each platform gets its own VSIX. The `sharplsp` binary lives at: +The binary lives at: ``` bin//sharplsp (Unix) @@ -181,8 +153,7 @@ bin//sharplsp.exe (Windows) **Sidecar install locations:** -Sidecars are required framework-dependent .NET executables. Every VSIX bundles them -under `bin/all/`; users do not install sidecars separately. +Sidecars are framework-dependent .NET 10 executables bundled under `bin/all/`; a missing runtime or sidecar is an activation failure. | Artifact | VSIX path | Resolver sources | |---|---|---| @@ -204,18 +175,9 @@ $ sharplsp-sidecar-fsharp --version sharplsp-sidecar-fsharp 0.1.0 ``` -Extensions use this to verify the correct version is active before starting. - -**Sidecar distribution:** +Extensions use this output to verify all versions before starting. -Both sidecars are required framework-dependent .NET assemblies. Every VSIX bundles -`sharplsp-sidecar-csharp` and `sharplsp-sidecar-fsharp` under `bin/all/`. They require -.NET 10 on the host machine. Missing .NET 10, a missing sidecar, or a failed sidecar -version probe is an activation failure. - -### 2.7 Editor Extension Binary Strategy - -**The VS Code extension bundles the `sharplsp` binary.** It ships as a per-platform VSIX with the correct pre-built binary for each OS/architecture combination. No system-level install is required for the LSP server. +### [SHARPLSP-ARCHITECTURE-EXTENSIONS] Editor Extension Binary Strategy Binary resolution is handled exclusively by `@nimblesite/shipwright-vscode` (`activateDeploymentToolkit`). Extensions MUST NOT hand-roll binary resolution. @@ -226,17 +188,20 @@ On activation, the VS Code extension follows this sequence: 3. **Version verification:** Shipwright probes each resolved binary with `--version` and compares against the manifest's `expectedVersion`. 4. **Start LSP client:** Pass the resolved `sharplsp` path to `LanguageClient`. Never hardcode a path. -**CRITICAL — Missing required components fail activation:** +#### [SHARPLSP-ARCHITECTURE-EXTENSIONS-SIDECAR-ENV] Sidecar Environment Overrides + +`SHARPLSP_CSHARP_SIDECAR_PATH` and `SHARPLSP_FSHARP_SIDECAR_PATH` take precedence when they name an existing path. A missing override path MUST emit a warning and continue with PATH, installed-layout, and development-build resolution. -When any required component step above fails — version mismatch, binary not found, missing -.NET 10, or `--version` returns garbage — the extension MUST: +**Missing required components fail activation:** + +When any required component step above fails — version mismatch, binary not found, missing .NET 10, or `--version` returns garbage — the extension MUST: - Show a clear, user-facing error message explaining what happened and how to fix it (e.g., "SharpLsp: sharplsp v0.1.0 required but v0.0.9 found.") -- Crash activation instead of starting without C# or F# support -- NEVER block the editor's main thread or event loop waiting for a binary that will never arrive -- NEVER leave the extension in a half-initialized zombie state where it eats CPU or holds locks +- Fail activation instead of starting without C# or F# support +- MUST NOT block the editor's main thread or event loop +- MUST release resources after partial initialization -This applies to ALL editor extensions: VS Code, Zed, Neovim, Helix, etc. An extension that locks up the editor because the binary version is wrong is a critical bug of the highest severity. +These requirements apply to every editor extension. **Version contract:** @@ -262,11 +227,9 @@ The Rust binary MUST have a test that proves: 1. `--version` prints the correct format: `sharplsp X.Y.Z` where X.Y.Z matches `Cargo.toml` 2. The process exits with code 0 -This is editor-agnostic by design. One set of binaries serves VS Code, Zed, Neovim, Helix, and any future editor. A user who runs `make install` already has everything every extension needs. An extension that auto-installs binaries provides them for every other extension too. - -## 3. Technology Stack +## [SHARPLSP-TECHNOLOGY] Technology Stack -### 3.1 Rust Host Crates +### [SHARPLSP-TECHNOLOGY-RUST] Rust Host Crates | Crate | Version | Purpose | |---|---|---| @@ -282,9 +245,9 @@ This is editor-agnostic by design. One set of binaries serves VS Code, Zed, Neov | [serde](https://serde.rs/) / [serde_json](https://crates.io/crates/serde_json) | 1.x | JSON handling for LSP protocol | | [tracing](https://crates.io/crates/tracing) | 0.1.x | Structured logging with [OpenTelemetry](https://opentelemetry.io/) export | | [notify](https://crates.io/crates/notify) | 7.x | Cross-platform filesystem watcher | -| [dashmap](https://crates.io/crates/dashmap) | 6.x | Concurrent hash map for shared caches | +| [dashmap](https://crates.io/crates/dashmap) | 6.x | Concurrent runtime state; MUST NOT store memoized feature results | -### 3.2 C# Sidecar Packages +### [SHARPLSP-TECHNOLOGY-CSHARP] C# Sidecar Packages | Package | Version | Purpose | |---|---|---| @@ -296,7 +259,7 @@ This is editor-agnostic by design. One set of binaries serves VS Code, Zed, Neov | [ICSharpCode.Decompiler](https://github.com/icsharpcode/ILSpy/tree/master/ICSharpCode.Decompiler) | latest | Decompiled metadata source navigation | | [MessagePack-CSharp](https://github.com/MessagePack-CSharp/MessagePack-CSharp) | latest | IPC serialization | -### 3.3 F# Sidecar Packages +### [SHARPLSP-TECHNOLOGY-FSHARP] F# Sidecar Packages | Package | Version | Purpose | |---|---|---| @@ -306,17 +269,17 @@ This is editor-agnostic by design. One set of binaries serves VS Code, Zed, Neov | [FSharp.Analyzers.SDK](https://github.com/ionide/FSharp.Analyzers.SDK) | latest | Third-party F# analyzer support | | [MessagePack-CSharp](https://github.com/MessagePack-CSharp/MessagePack-CSharp) | latest | IPC serialization | -## 4. Feature Specification +## [SHARPLSP-FEATURES] Feature Specification -This section specifies every feature SharpLsp will implement, mapped to the LSP protocol method, implementation source, and the Roslyn/FCS API that powers it. Features are organized by category. Both C# and F# columns indicate full support unless otherwise noted. +Both C# and F# columns require full support unless noted. -### 4.1 Code Intelligence +### [SHARPLSP-FEATURES-INTELLIGENCE] Code Intelligence | Feature | LSP Method | C# API (Roslyn) | F# API (FCS) | Priority | |---|---|---|---|---| | Auto-completion | `textDocument/completion` | [CompletionService.GetCompletionsAsync()](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.completion.completionservice.getcompletionsasync) | GetDeclarationListInfo() | P0 | | Completion resolve | `completionItem/resolve` | [CompletionService.GetDescriptionAsync()](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.completion.completionservice.getdescriptionasync) | GetDeclarationListInfo (detail) | P0 | -| Completion edit semantics | `textDocument/completion` | `GetDefaultCompletionListSpan` + trailing-ident extension → `textEdit` — `[COMPLETION-EDIT-REPLACE]` | `QuickParse.GetPartialLongNameEx` island + trailing-ident extension → `textEdit` — `[COMPLETION-EDIT-REPLACE]` | P0 | +| Completion edit semantics | `textDocument/completion` | `GetDefaultCompletionListSpan` + trailing-ident extension → `textEdit` — `[SHARPLSP-FEATURES-INTELLIGENCE-COMPLETION-EDIT]` | `QuickParse.GetPartialLongNameEx` island + trailing-ident extension → `textEdit` — `[SHARPLSP-FEATURES-INTELLIGENCE-COMPLETION-EDIT]` | P0 | | Hover / Quick Info | `textDocument/hover` | See [HOVER-SPEC.md](HOVER-SPEC.md) | See [HOVER-SPEC.md](HOVER-SPEC.md) | P0 | | Signature help | `textDocument/signatureHelp` | SignatureHelpService.GetItemsAsync() | GetMethods() | P0 | | Parameter hints | `textDocument/signatureHelp` | Same (active parameter tracking) | Same (active parameter tracking) | P0 | @@ -324,13 +287,13 @@ This section specifies every feature SharpLsp will implement, mapped to the LSP | Inlay hints (params) | `textDocument/inlayHint` | Parameter name hints | Parameter name hints | P1 | | Inline values | `textDocument/inlineValue` | Debugger expression eval | Debugger expression eval | P2 | -#### Completion edit semantics `[COMPLETION-EDIT-REPLACE]` +#### [SHARPLSP-FEATURES-INTELLIGENCE-COMPLETION-EDIT] Completion Edit Semantics Every completion item returned by either sidecar carries an explicit LSP `textEdit`, not just an `insertText`. Its range is the identifier span **at the caret** — the typed prefix to the left of the cursor *plus any identifier characters that already follow it on the same line*. Accepting an item therefore **replaces** that identifier instead of being appended to it: completing `WriteLine` at `Console.|WriteLine` yields `Console.WriteLine`, never `Console.WriteLineWriteLine` (GitHub #178). Without a `textEdit` the editor falls back to its own word-boundary heuristic, which appends after a member-access trigger character and duplicates the identifier. -The C# sidecar derives the span from [`CompletionService.GetDefaultCompletionListSpan`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.completion.completionservice.getdefaultcompletionlistspan) extended over trailing identifier characters; the F# sidecar derives it from the FCS partial-name island (`QuickParse.GetPartialLongNameEx`) with the same trailing-character extension. The `NewText` is the item's insert text. The Rust host maps the flat sidecar edit onto `CompletionItem.textEdit` in `src/semantic.rs`. +The C# sidecar derives the span from [`CompletionService.GetDefaultCompletionListSpan`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.completion.completionservice.getdefaultcompletionlistspan) extended over trailing identifier characters; the F# sidecar derives it from the FCS partial-name island (`QuickParse.GetPartialLongNameEx`) with the same trailing-character extension. The `NewText` is the item's insert text. The Rust host maps the flat sidecar edit onto `CompletionItem.textEdit` in `src/sharplsp/src/semantic.rs`. -### 4.2 Navigation +### [SHARPLSP-FEATURES-NAVIGATION] Navigation | Feature | LSP Method | C# API (Roslyn) | F# API (FCS) | Priority | |---|---|---|---|---| @@ -340,29 +303,27 @@ The C# sidecar derives the span from [`CompletionService.GetDefaultCompletionLis | Go to implementation | See [DEFINITION-SPEC.md](DEFINITION-SPEC.md) | | | P0 | | Find all references | `textDocument/references` | [SymbolFinder.FindReferencesAsync()](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.findusages.symbolfinder.findreferencesasync) | GetUsesOfSymbolInFile/Project() | P0 | | Document highlights | `textDocument/documentHighlight` | SymbolFinder (scoped to doc) | GetUsesOfSymbolInFile() | P0 | -| Workspace symbol search | `workspace/symbol` | tree-sitter over open docs (host) | FCS document symbols via sidecar — `[FS-WORKSPACE-SYMBOL]` (host has no F# tree-sitter grammar) | P0 | +| Workspace symbol search | `workspace/symbol` | tree-sitter over open docs (host) | FCS document symbols via sidecar (host has no F# tree-sitter grammar) | P0 | | Document symbols | `textDocument/documentSymbol` | tree-sitter structural extraction | tree-sitter structural extraction | P0 | -| Call hierarchy | `textDocument/prepareCallHierarchy` | SymbolFinder.FindCallersAsync() | Custom call graph analysis | P1 | -| Type hierarchy | `textDocument/prepareTypeHierarchy` | FindDerivedClasses + base types | Type hierarchy via FCS symbols | P1 | +| Call hierarchy prepare | `textDocument/prepareCallHierarchy` | Symbol resolution | FCS symbol resolution | P1 | +| Incoming calls | `callHierarchy/incomingCalls` | SymbolFinder.FindCallersAsync() | Project-wide FCS symbol uses | P1 | +| Outgoing calls | `callHierarchy/outgoingCalls` | Semantic model invocation walk | FCS parse/check traversal | P1 | +| Type hierarchy prepare | `textDocument/prepareTypeHierarchy` | Symbol resolution | FCS entity resolution | P1 | +| Supertypes | `typeHierarchy/supertypes` | Base type and interface symbols | `BaseType` + `DeclaredInterfaces` | P1 | +| Subtypes | `typeHierarchy/subtypes` | FindDerivedClasses | Project entity scan | P1 | | Breadcrumbs | `textDocument/documentSymbol` | Hierarchical symbol tree | Hierarchical symbol tree | P1 | | Go to decompiled source | Custom: `sharplsp/decompileSource` | [ICSharpCode.Decompiler](https://github.com/icsharpcode/ILSpy) | ICSharpCode.Decompiler | P1 | | Go to source generator output | Custom: `sharplsp/generatorOutput` | GeneratorDriverRunResult | N/A | P2 | -### 4.3 Diagnostics & Analysis +### [SHARPLSP-FEATURES-DIAGNOSTICS] Diagnostics and Analysis SharpLsp uses the LSP 3.17 **pull-diagnostics + workspace-refresh** model (`textDocument/diagnostic`, `workspace/diagnostic`, `workspace/diagnostic/refresh`), mirroring `Microsoft.CodeAnalysis.LanguageServer` (the engine behind C# Dev Kit). The Rust host never proactively pushes errors during workspace load — that is the only architecture that produces correct diagnostics while NuGet restore, source generators, and cross-project `CompilationReference`s are still resolving. A NuGet restore gate runs before `MSBuildWorkspace.OpenSolutionAsync` to eliminate the largest class of phantom CS0246s. See [DIAGNOSTICS-SPEC.md](DIAGNOSTICS-SPEC.md) for the full specification, including the pull + refresh cycle, the NuGet restore gate, project filtering, and the truth guarantees SharpLsp makes (and doesn't make) about diagnostic completeness during workspace load. -SharpLsp also owns custom static analyzers that run through the same workspace -diagnostics channel. The first rules detect unused public C# and F# code -elements at solution scope, but only when `sharplsp.toml` explicitly marks the -workspace as a monorepo. See -[DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md](DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md). +SharpLsp also owns custom static analyzers that run through the same workspace diagnostics channel. The first rules detect unused public C# and F# code elements at solution scope, but only when `sharplsp.toml` explicitly marks the workspace as a monorepo. See [DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md](DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md). -### 4.4 Code Actions & Refactoring - -This is where SharpLsp must match Rider's 2,200+ inspections and 60+ refactorings. Roslyn provides a substantial base of [CodeFixProviders](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.codefixes.codefixprovider) and [CodeRefactoringProviders](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.coderefactorings.coderefactoringprovider) out of the box. SharpLsp will expose all of them and add custom ones. +### [SHARPLSP-FEATURES-REFACTORING] Code Actions and Refactoring | Feature | LSP Method | C# API | F# API | Priority | |---|---|---|---|---| @@ -389,16 +350,14 @@ This is where SharpLsp must match Rider's 2,200+ inspections and 60+ refactoring | Convert auto-prop ↔ full prop | `textDocument/codeAction` | Roslyn property conversion | N/A | P1 | | Convert method ↔ property | `textDocument/codeAction` | Custom implementation | N/A | P2 | -### 4.5 Formatting +### [SHARPLSP-FEATURES-FORMATTING] Formatting SharpLsp does **not** provide document formatting. Use dedicated formatters: - **C#**: [CSharpier](https://csharpier.com/) — the community-standard opinionated C# formatter - **F#**: [Fantomas](https://github.com/fsprojects/fantomas) via the [Ionide](https://ionide.io/) extension — the standard F# formatter -These tools are excellent at what they do and there is no reason to duplicate their work inside an LSP server. - -### 4.6 Semantic Highlighting +### [SHARPLSP-FEATURES-HIGHLIGHTING] Semantic Highlighting | Feature | LSP Method | C# API | F# API | Priority | |---|---|---|---|---| @@ -406,7 +365,7 @@ These tools are excellent at what they do and there is no reason to duplicate th | Delta semantic tokens | `textDocument/semanticTokens/full/delta` | Incremental classification | Incremental classification | P1 | | Range semantic tokens | `textDocument/semanticTokens/range` | Classifier (range-scoped) | GetSemanticClassification (range) | P0 | -### 4.7 Code Lens +### [SHARPLSP-FEATURES-CODE-LENS] Code Lens | Feature | LSP Method | C# API | F# API | Priority | |---|---|---|---|---| @@ -416,13 +375,11 @@ These tools are excellent at what they do and there is no reason to duplicate th | Run/debug test | `textDocument/codeLens` | Custom test runner integration | Custom test runner integration | P2 | | Recent changes (git) | `textDocument/codeLens` | git log integration | git log integration | P3 | -### 4.8 Debugging (DAP Integration) +### [SHARPLSP-FEATURES-DEBUGGING] Debugging -> **Full specification:** [DEBUGGING-SPEC.md](./DEBUGGING-SPEC.md) -> -> SharpLsp delivers a fully open-source .NET debugging experience via [DAP](https://microsoft.github.io/debug-adapter-protocol/specification). Phase 4 uses [netcoredbg](https://github.com/Samsung/netcoredbg) (MIT) with a `DapRouter` layer in the Rust host for capability augmentation (logpoints, async call stack reconstruction, Hot Reload). Phase 5 replaces netcoredbg with a SharpLsp-native C# Debug Sidecar (Tier 4) built on [ClrDebug](https://github.com/lordmilko/ClrDebug) + ICorDebug, achieving full feature parity with Microsoft's proprietary vsdbg. +See [DEBUGGING-SPEC.md](DEBUGGING-SPEC.md) for the DAP router and debug-sidecar contract. -### 4.9 Test Discovery & Execution +### [SHARPLSP-FEATURES-TESTING] Test Discovery and Execution | Feature | Protocol | Implementation | Priority | |---|---|---|---| @@ -435,7 +392,7 @@ These tools are excellent at what they do and there is no reason to duplicate th | Code coverage | Custom: `sharplsp/coverage` | [coverlet](https://github.com/coverlet-coverage/coverlet) integration | P3 | | F# [Expecto](https://github.com/haf/expecto)/[FsCheck](https://github.com/fscheck/FsCheck) support | Custom: `sharplsp/testDiscovery` | Expecto test tree discovery | P1 | -### 4.10 Workspace Features +### [SHARPLSP-FEATURES-WORKSPACE] Workspace Features | Feature | LSP Method | Implementation | Priority | |---|---|---|---| @@ -448,15 +405,13 @@ These tools are excellent at what they do and there is no reason to duplicate th | NuGet uninstall package | Custom: `sharplsp/nuget/uninstall` | `dotnet remove package` + sidecar reload | P2 | | Multi-TFM selection | Custom: `sharplsp/targetFramework` | Active TFM switching per project | P1 | | File watching & reload | `workspace/didChangeWatchedFiles` | [notify](https://crates.io/crates/notify) crate + sidecar reload | P0 | -| Workspace diagnostics (pull) | `workspace/diagnostic` + `workspace/diagnostic/refresh` | Solution-wide error analysis via LSP 3.17 pull model + 2000ms-debounced refresh; primary diagnostic path (see [DIAGNOSTICS-SPEC §1.1](DIAGNOSTICS-SPEC.md#11-the-pull--refresh-cycle)) | P0 | +| Workspace diagnostics (pull) | `workspace/diagnostic` + `workspace/diagnostic/refresh` | Solution-wide error analysis via LSP 3.17 pull model + 2000ms-debounced refresh; primary diagnostic path (see [DIAG-ARCHITECTURE-PULL-REFRESH](DIAGNOSTICS-SPEC.md)) | P0 | | Monorepo static analyzers | `workspace/diagnostic` partial results | SharpLsp-owned unused-public-code analyzers for C# and F#; gated by `workspace.repository_kind = "monorepo"` | P0 | -| NuGet restore gate | (internal, before `workspace/open`) | `dotnet restore` if `obj/project.assets.json` is stale; eliminates phantom CS0246 for NuGet types ([DIAGNOSTICS-SPEC §6](DIAGNOSTICS-SPEC.md#6-nuget-restore-gate)) | P0 | +| NuGet restore gate | (internal, before `workspace/open`) | `dotnet restore` if `obj/project.assets.json` is stale; eliminates phantom CS0246 for NuGet types ([DIAG-RESTORE](DIAGNOSTICS-SPEC.md)) | P0 | | Project init complete | Custom: `workspace/projectInitializationComplete` | Notification fired once per workspace open after restore + `MSBuildWorkspace.OpenSolutionAsync`; matches Roslyn LSP contract | P0 | | Configuration | `workspace/didChangeConfiguration` | [.editorconfig](https://editorconfig.org/) + sharplsp.toml | P0 | -### 4.11 F#-Specific Features - -F# has unique language features that require dedicated support beyond what the shared infrastructure provides: +### [SHARPLSP-FEATURES-FSHARP] F#-Specific Features | Feature | LSP Method | Implementation | Priority | |---|---|---|---| @@ -470,7 +425,7 @@ F# has unique language features that require dedicated support beyond what the s | F# Interactive integration | Custom: `sharplsp/fsi` | Send selection to FSI, evaluate | P2 | | File ordering awareness | Custom: `sharplsp/fileOrder` | Semantic file reorder suggestions | P1 | -## 5. Performance Requirements +## [SHARPLSP-PERFORMANCE] Performance Requirements | Metric | Target | Measurement Method | |---|---|---| @@ -487,13 +442,11 @@ F# has unique language features that require dedicated support beyond what the s | Incremental re-parse on keystroke | <1ms | tree-sitter incremental parse time | | Sidecar crash recovery | <3 seconds | Time from crash detection to restored functionality | -## 6. Implementation Plan +## [SHARPLSP-PLAN] Implementation Plan -### Phase 1: Protocol Skeleton & Syntax Features (Months 1–3) +### [SHARPLSP-PLAN-PROTOCOL] Protocol Skeleton and Syntax Features -**Goal:** A working LSP server that handles all syntax-level features for both C# and F#, with a VS Code extension as test harness. - -**Deliverables:** +**Schedule:** Months 1–3. - Rust binary implementing [LSP 3.17](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/) lifecycle (initialize, initialized, shutdown, exit) - Full document synchronization (open, change, close, save) with VFS @@ -504,11 +457,9 @@ F# has unique language features that require dedicated support beyond what the s - CI/CD pipeline with cross-platform builds (Linux, macOS, Windows) - Logging infrastructure via [tracing](https://crates.io/crates/tracing) crate with [OpenTelemetry](https://opentelemetry.io/) export -### Phase 2: Sidecar Integration & Core Semantics (Months 4–8) - -**Goal:** Full semantic analysis for both languages. This is the phase where SharpLsp becomes genuinely useful. +### [SHARPLSP-PLAN-SEMANTICS] Sidecar Integration and Core Semantics -**Deliverables:** +**Schedule:** Months 4–8. - C# sidecar with MSBuildWorkspace, full project loading, design-time build evaluation - F# sidecar with FSharpChecker, Ionide.ProjInfo, project cracking @@ -522,11 +473,9 @@ F# has unique language features that require dedicated support beyond what the s - [salsa](https://salsa-rs.github.io/salsa/) database for incremental caching of semantic results - Request coalescing and cancellation -### Phase 3: Code Actions & Refactoring (Months 9–14) +### [SHARPLSP-PLAN-REFACTORING] Code Actions and Refactoring -**Goal:** Feature parity with C# Dev Kit for code actions. Approach Rider's refactoring depth. - -**Deliverables:** +**Schedule:** Months 9–14. - All Roslyn built-in CodeFixProviders exposed via LSP code actions - All Roslyn built-in CodeRefactoringProviders exposed via LSP code actions @@ -538,11 +487,9 @@ F# has unique language features that require dedicated support beyond what the s - Code lens (reference count, implementation count) - Decompiled source navigation via [ICSharpCode.Decompiler](https://github.com/icsharpcode/ILSpy) -### Phase 4: Advanced Features & Ecosystem (Months 15–20) - -**Goal:** Feature parity with Rider. Go beyond what any single tool offers today. +### [SHARPLSP-PLAN-ECOSYSTEM] Advanced Features and Ecosystem -**Deliverables:** +**Schedule:** Months 15–20. - Solution-wide error analysis (SWEA equivalent) - Test discovery and execution ([xUnit](https://xunit.net/), [NUnit](https://nunit.org/), [MSTest](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-mstest-intro), [Expecto](https://github.com/haf/expecto), [FsCheck](https://github.com/fscheck/FsCheck)) @@ -554,14 +501,12 @@ F# has unique language features that require dedicated support beyond what the s - Monorepo-only unused public C# and F# code element analyzers - Multi-editor verification (Neovim, Helix, Zed, Emacs, Sublime) - Hot reload support via [dotnet watch](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch) -- Performance optimization pass (memory budgets, cache eviction, lazy loading) +- Performance optimization pass (memory budgets, salsa query/input lifecycle, lazy loading) - Custom Rider-class inspections beyond Roslyn's built-in set -### Phase 5: Beyond Parity (Months 21+) - -**Goal:** Features no existing tool has. This is where SharpLsp moves from matching the field to leading it. +### [SHARPLSP-PLAN-LEADERSHIP] Beyond Parity -**Stretch deliverables:** +**Schedule:** Month 21 onward. - AI-assisted code actions (LLM-powered refactoring suggestions via [MCP](https://modelcontextprotocol.io/) or custom protocol) - Cross-language navigation (C# ↔ F# within the same solution, via binary references initially, source-level eventually) @@ -571,19 +516,17 @@ F# has unique language features that require dedicated support beyond what the s - Database-aware analysis (SQL-in-string validation, [EF Core](https://learn.microsoft.com/en-us/ef/core/) migration awareness) - Collaborative editing support (operational transform / CRDT) -## 7. Risks & Mitigations +## [SHARPLSP-RISKS] Risks and Mitigations | Risk | Impact | Likelihood | Mitigation | |---|---|---|---| | Roslyn Features APIs are internal | High | High | Use reflection for internal APIs. Contribute upstream PRs to make critical APIs public. Monitor Roslyn releases for API surface changes. | | MSBuild evaluation complexity | High | Certain | Leverage MSBuildWorkspace (proven by [OmniSharp](https://github.com/OmniSharp/omnisharp-roslyn)). Build comprehensive test suite against real-world `.sln` and `.slnx` files. Handle failure gracefully with partial project loading. | -| Memory pressure in large solutions | High | Medium | Implement per-project sidecar pooling. Add memory budget enforcement with cache eviction. Consider separate sidecar instances per project in extreme cases. | +| Memory pressure in large solutions | High | Medium | Implement per-project sidecar pooling. Enforce memory budgets through salsa query/input lifecycle. Consider separate sidecar instances per project in extreme cases. | | F# tree-sitter grammar incomplete | Medium | Medium | Fall back to FCS for any syntax feature where tree-sitter produces incorrect results. Contribute upstream to improve the grammar. | | Roslyn version coupling | Medium | Certain | Pin Roslyn version per SharpLsp release. Test against multiple Roslyn versions in CI. Abstract sidecar RPC to isolate version dependencies. | -| Microsoft ships improvements to Roslyn LSP server | Low | High | SharpLsp's value is unified C#+F#, editor-agnostic, open governance, and performance. These remain regardless of Microsoft's progress. | -| Adoption challenge | Medium | Medium | Ship early with partial features. Demonstrate clear value in editors Microsoft ignores (Neovim, Helix, Emacs). Build community around open governance. | -## 8. Licensing +## [SHARPLSP-LICENSING] Licensing SharpLsp is MIT-licensed. All dependencies are compatible: @@ -601,194 +544,189 @@ SharpLsp is MIT-licensed. All dependencies are compatible: **Critical:** SharpLsp must never incorporate code from C# Dev Kit's proprietary components (Solution Explorer, IntelliCode, test explorer). These are closed-source under Visual Studio licensing. All equivalent features must be reimplemented from publicly documented APIs and protocols. -## 9. Complete Feature TODO List - -Every feature SharpLsp must implement to match — and ultimately go beyond — Visual Studio, Rider, and C# Dev Kit. Features are grouped by category, prioritized (P0 = launch blocker, P1 = fast follow, P2 = competitive parity, P3 = beyond parity), and marked with their implementation status. - -**Legend:** VS = Visual Studio, CDK = C# Dev Kit, R = Rider. ✓ = the tool has this feature. - -### 9.1 Code Intelligence - -| Feature | VS | CDK | R | Priority | Phase | -|---|---|---|---|---|---| -| Auto-completion with full semantic context | ✓ | ✓ | ✓ | P0 | 2 | -| Completion with import suggestions | ✓ | ✓ | ✓ | P0 | 2 | -| AI-powered completion ranking | ✓ | ✓ | ✓ | P3 | 5 | -| Snippet completion | ✓ | ✓ | ✓ | P0 | 2 | -| Override member completion | ✓ | ✓ | ✓ | P1 | 3 | -| Postfix completion templates | ✗ | ✗ | ✓ | P2 | 4 | -| Hover / Quick Info | See [HOVER-SPEC.md](HOVER-SPEC.md) | | | P0 | 2 | -| Signature help / parameter info | ✓ | ✓ | ✓ | P0 | 2 | -| Inlay hints — type inference | ✓ | ✓ | ✓ | P1 | 3 | -| Inlay hints — parameter names | ✓ | ✓ | ✓ | P1 | 3 | -| Inlay hints — lambda return types | ✓ | ✗ | ✓ | P2 | 3 | -| Regex syntax highlighting in strings | ✓ | ✗ | ✓ | P2 | 4 | -| Date/time format string validation | ✗ | ✗ | ✓ | P3 | 5 | - -### 9.2 Navigation - -| Feature | VS | CDK | R | Priority | Phase | -|---|---|---|---|---|---| -| Go to definition | ✓ | ✓ | ✓ | P0 | 2 | -| Go to declaration | ✓ | ✓ | ✓ | P0 | 2 | -| Go to type definition | ✓ | ✓ | ✓ | P0 | 2 | -| Go to implementation | ✓ | ✓ | ✓ | P0 | 2 | -| Go to base member | ✓ | ✗ | ✓ | P1 | 3 | -| Find all references | ✓ | ✓ | ✓ | P0 | 2 | -| Find usages (advanced, grouped) | ✓ | ✗ | ✓ | P1 | 3 | -| Workspace symbol search | ✓ | ✓ | ✓ | P0 | 2 | -| Document symbol outline | ✓ | ✓ | ✓ | P0 | 1 | -| Call hierarchy (incoming) | ✓ | ✓ | ✓ | P1 | 3 | -| Call hierarchy (outgoing) | ✓ | ✓ | ✓ | P1 | 3 | -| Type hierarchy (supertypes) | ✓ | ✗ | ✓ | P1 | 3 | -| Type hierarchy (subtypes) | ✓ | ✗ | ✓ | P1 | 3 | -| Navigate to decompiled source | ✓ | ✓ | ✓ | P1 | 3 | -| Navigate to source generator output | ✓ | ✓ | ✗ | P2 | 4 | -| Navigate to metadata as source | ✓ | ✓ | ✓ | P1 | 3 | -| Go to related files | ✓ | ✗ | ✓ | P2 | 4 | -| Breadcrumb / scope bar | ✓ | ✓ | ✓ | P1 | 3 | -| Structural navigation (next/prev member) | ✓ | ✗ | ✓ | P2 | 4 | - -### 9.3 Diagnostics & Analysis - -See [DIAGNOSTICS-SPEC.md](DIAGNOSTICS-SPEC.md) § Competitive Analysis for the full feature comparison table. Key change from this document: **solution-wide analysis is now P0 (Phase 2), default enabled** — not P1/Phase 4. SharpLsp-owned monorepo static analyzers are specified separately in [DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md](DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md). - -### 9.4 Code Actions & Refactoring - -| Feature | VS | CDK | R | Priority | Phase | -|---|---|---|---|---|---| -| All Roslyn built-in code fixes | ✓ | ✓ | ✓ | P0 | 3 | -| All Roslyn built-in refactorings | ✓ | ✓ | ✓ | P0 | 3 | -| Extract method | ✓ | ✓ | ✓ | P0 | 3 | -| Extract variable / constant / field | ✓ | ✓ | ✓ | P0 | 3 | -| Extract interface | ✓ | ✓ | ✓ | P1 | 3 | -| Extract superclass | ✓ | ✗ | ✓ | P2 | 4 | -| Inline variable / method / constant | ✓ | ✓ | ✓ | P1 | 3 | -| Rename symbol (all code elements and references) | ✓ | ✓ | ✓ | P0 | 2 | -| Rename file to match type | ✓ | ✓ | ✓ | P1 | 3 | -| Move type to file | ✓ | ✓ | ✓ | P1 | 3 | -| Move type to namespace | ✓ | ✗ | ✓ | P2 | 4 | -| Safe delete | ✓ | ✗ | ✓ | P2 | 4 | -| Change signature | ✓ | ✓ | ✓ | P2 | 4 | -| Introduce parameter | ✓ | ✓ | ✓ | P2 | 4 | -| Generate constructor | ✓ | ✓ | ✓ | P0 | 3 | -| Generate equals / GetHashCode | ✓ | ✓ | ✓ | P1 | 3 | -| Generate interface implementation | ✓ | ✓ | ✓ | P0 | 3 | -| Generate overrides | ✓ | ✓ | ✓ | P1 | 3 | -| Generate property from field | ✓ | ✓ | ✓ | P1 | 3 | -| Add using / open directive | ✓ | ✓ | ✓ | P0 | 3 | -| Organize usings / opens | ✓ | ✓ | ✓ | P0 | 3 | -| Convert between expression forms | ✓ | ✓ | ✓ | P1 | 3 | -| Surround with (try, if, using, etc.) | ✓ | ✗ | ✓ | P1 | 3 | -| Convert to LINQ / from LINQ | ✓ | ✓ | ✓ | P2 | 4 | -| Convert string concatenation ↔ interpolation | ✓ | ✓ | ✓ | P1 | 3 | -| Convert var ↔ explicit type | ✓ | ✓ | ✓ | P1 | 3 | -| Invert if | ✓ | ✓ | ✓ | P1 | 3 | -| Convert method group ↔ lambda | ✓ | ✓ | ✓ | P1 | 3 | -| Pull members up / push members down | ✓ | ✗ | ✓ | P2 | 4 | -| Convert class to record (C#) | ✓ | ✓ | ✓ | P2 | 4 | -| Convert anonymous type to class/record | ✓ | ✓ | ✓ | P2 | 4 | -| F#: Generate match cases from DU | ✗ | ✗ | ✗ | P1 | 3 | -| F#: Generate record field stubs | ✗ | ✗ | ✗ | P1 | 3 | -| F#: Convert pipe ↔ nested function calls | ✗ | ✗ | ✗ | P1 | 4 | -| F#: Convert to/from computation expression | ✗ | ✗ | ✗ | P2 | 4 | - -### 9.5 Formatting & Style +## [SHARPLSP-TODO] Complete Feature List + +Priorities: P0 = launch blocker, P1 = fast follow, P2 = parity, P3 = later. + +### [SHARPLSP-TODO-INTELLIGENCE] Code Intelligence + +| Feature | Priority | Phase | +| --- | --- | --- | +| Auto-completion with full semantic context | P0 | 2 | +| Completion with import suggestions | P0 | 2 | +| AI-powered completion ranking | P3 | 5 | +| Snippet completion | P0 | 2 | +| Override member completion | P1 | 3 | +| Postfix completion templates | P2 | 4 | +| Hover / Quick Info (see [HOVER-SPEC.md](HOVER-SPEC.md)) | P0 | 2 | +| Signature help / parameter info | P0 | 2 | +| Inlay hints — type inference | P1 | 3 | +| Inlay hints — parameter names | P1 | 3 | +| Inlay hints — lambda return types | P2 | 3 | +| Regex syntax highlighting in strings | P2 | 4 | +| Date/time format string validation | P3 | 5 | + +### [SHARPLSP-TODO-NAVIGATION] Navigation + +| Feature | Priority | Phase | +| --- | --- | --- | +| Go to definition | P0 | 2 | +| Go to declaration | P0 | 2 | +| Go to type definition | P0 | 2 | +| Go to implementation | P0 | 2 | +| Go to base member | P1 | 3 | +| Find all references | P0 | 2 | +| Find usages (advanced, grouped) | P1 | 3 | +| Workspace symbol search | P0 | 2 | +| Document symbol outline | P0 | 1 | +| Call hierarchy (incoming) | P1 | 3 | +| Call hierarchy (outgoing) | P1 | 3 | +| Type hierarchy (supertypes) | P1 | 3 | +| Type hierarchy (subtypes) | P1 | 3 | +| Navigate to decompiled source | P1 | 3 | +| Navigate to source generator output | P2 | 4 | +| Navigate to metadata as source | P1 | 3 | +| Go to related files | P2 | 4 | +| Breadcrumb / scope bar | P1 | 3 | +| Structural navigation (next/prev member) | P2 | 4 | + +### [SHARPLSP-TODO-DIAGNOSTICS] Diagnostics and Analysis + +[DIAGNOSTICS-SPEC.md](DIAGNOSTICS-SPEC.md) defines default-enabled P0 solution-wide analysis. SharpLsp-owned monorepo analyzers are specified in [DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md](DIAGNOSTICS-STATIC-ANALYZERS-SPEC.md). + +### [SHARPLSP-TODO-REFACTORING] Code Actions and Refactoring + +| Feature | Priority | Phase | +| --- | --- | --- | +| All Roslyn built-in code fixes | P0 | 3 | +| All Roslyn built-in refactorings | P0 | 3 | +| Extract method | P0 | 3 | +| Extract variable / constant / field | P0 | 3 | +| Extract interface | P1 | 3 | +| Extract superclass | P2 | 4 | +| Inline variable / method / constant | P1 | 3 | +| Rename symbol (all code elements and references) | P0 | 2 | +| Rename file to match type | P1 | 3 | +| Move type to file | P1 | 3 | +| Move type to namespace | P2 | 4 | +| Safe delete | P2 | 4 | +| Change signature | P2 | 4 | +| Introduce parameter | P2 | 4 | +| Generate constructor | P0 | 3 | +| Generate equals / GetHashCode | P1 | 3 | +| Generate interface implementation | P0 | 3 | +| Generate overrides | P1 | 3 | +| Generate property from field | P1 | 3 | +| Add using / open directive | P0 | 3 | +| Organize usings / opens | P0 | 3 | +| Convert between expression forms | P1 | 3 | +| Surround with (try, if, using, etc.) | P1 | 3 | +| Convert to LINQ / from LINQ | P2 | 4 | +| Convert string concatenation ↔ interpolation | P1 | 3 | +| Convert var ↔ explicit type | P1 | 3 | +| Invert if | P1 | 3 | +| Convert method group ↔ lambda | P1 | 3 | +| Pull members up / push members down | P2 | 4 | +| Convert class to record (C#) | P2 | 4 | +| Convert anonymous type to class/record | P2 | 4 | +| F#: Generate match cases from DU | P1 | 3 | +| F#: Generate record field stubs | P1 | 3 | +| F#: Convert pipe ↔ nested function calls | P1 | 4 | +| F#: Convert to/from computation expression | P2 | 4 | + +### [SHARPLSP-TODO-FORMATTING] Formatting and Style SharpLsp does **not** provide formatting. Use [CSharpier](https://csharpier.com/) for C# and [Fantomas](https://github.com/fsprojects/fantomas) (via [Ionide](https://ionide.io/)) for F#. -### 9.6 Semantic Highlighting & Visual Features +### [SHARPLSP-TODO-HIGHLIGHTING] Semantic Highlighting and Visual Features -| Feature | VS | CDK | R | Priority | Phase | -|---|---|---|---|---|---| -| Full semantic tokens | ✓ | ✓ | ✓ | P0 | 2 | -| Delta semantic tokens | ✓ | ✓ | ✓ | P1 | 3 | -| Folding ranges (tree-sitter) | ✓ | ✓ | ✓ | P0 | 1 | -| Selection ranges (tree-sitter) | ✓ | ✓ | ✓ | P0 | 1 | -| Linked editing ranges | ✓ | ✓ | ✓ | P1 | 1 | -| Color information (CSS in Razor) | ✓ | ✗ | ✓ | P3 | 5 | +| Feature | Priority | Phase | +| --- | --- | --- | +| Full semantic tokens | P0 | 2 | +| Delta semantic tokens | P1 | 3 | +| Folding ranges (tree-sitter) | P0 | 1 | +| Selection ranges (tree-sitter) | P0 | 1 | +| Linked editing ranges | P1 | 1 | +| Color information (CSS in Razor) | P3 | 5 | -### 9.7 Debugging & Testing +### [SHARPLSP-TODO-DEBUGGING] Debugging and Testing > Full debugging feature parity details: [DEBUGGING-SPEC.md](./DEBUGGING-SPEC.md) -| Feature | VS | CDK | R | Priority | Phase | -|---|---|---|---|---|---| -| Launch/attach .NET process | ✓ | ✓ | ✓ | P1 | 4 | -| Breakpoints (line, conditional, logpoint) | ✓ | ✓ | ✓ | P1 | 4 | -| Step in/out/over | ✓ | ✓ | ✓ | P1 | 4 | -| Variable inspection | ✓ | ✓ | ✓ | P1 | 4 | -| Watch expressions | ✓ | ✓ | ✓ | P2 | 4 | -| Call stack navigation | ✓ | ✓ | ✓ | P1 | 4 | -| Async logical call stack | ✓ | ✓ | ✓ | P1 | 4 | -| Exception breakpoints | ✓ | ✓ | ✓ | P2 | 4 | -| Data breakpoints | ✓ | ✗ | ✓ | P2 | 5 | -| Return value display | ✓ | ✗ | ✓ | P2 | 5 | -| Hot reload (method body edits) | ✓ | ✓ | ✓ | P2 | 4 | -| Full expression eval (LINQ, lambdas) | ✓ | ✓ | ✓ | P1 | 5 | -| Remote debugging (SSH) | ✓ | ✓ | ✓ | P2 | 5 | -| Multi-process / compound launch | ✓ | ✗ | ✓ | P2 | 4 | -| Test discovery (xUnit/NUnit/MSTest) | ✓ | ✓ | ✓ | P1 | 4 | -| Test discovery (Expecto/FsCheck) | ✗ | ✗ | ✗ | P1 | 4 | -| Run/debug individual test | ✓ | ✓ | ✓ | P1 | 4 | -| Test result inline display | ✓ | ✓ | ✓ | P2 | 4 | -| Continuous testing | ✓ | ✗ | ✓ | P3 | 5 | -| Code coverage overlay | ✓ | ✗ | ✓ | P3 | 5 | - -### 9.8 Workspace & Project Management - -| Feature | VS | CDK | R | Priority | Phase | -|---|---|---|---|---|---| -| Solution/project loading | ✓ | ✓ | ✓ | P0 | 2 | -| SDK-style project support | ✓ | ✓ | ✓ | P0 | 2 | -| Legacy .csproj/.fsproj support | ✓ | ✓ | ✓ | P1 | 3 | -| Multi-targeting support | ✓ | ✓ | ✓ | P1 | 3 | -| Central Package Management | ✓ | ✓ | ✓ | P1 | 3 | -| Project dependency visualization | ✓ | ✗ | ✓ | P2 | 4 | -| NuGet package search & install | ✓ | ✗ | ✓ | P2 | 4 | -| NuGet package update suggestions | ✓ | ✗ | ✓ | P2 | 4 | -| Add/remove project reference | ✓ | ✓ | ✓ | P2 | 4 | -| File watching & auto-reload | ✓ | ✓ | ✓ | P0 | 2 | -| Configuration via sharplsp.toml | ✗ | ✗ | ✗ | P0 | 1 | -| Bundled required sidecars in VSIX | ✓ | ✗ | ✓ | P0 | 1 | - -### 9.9 F#-Specific Features - -| Feature | VS | CDK | R | Priority | Phase | -|---|---|---|---|---|---| -| Pipeline type hints | ✓ | ✗ | ✓ | P1 | 3 | -| Signature file generation (.fsi) | ✓ | ✗ | ✓ | P1 | 4 | -| Union case generation | ✗ | ✗ | ✗ | P1 | 3 | -| Record stub generation | ✗ | ✗ | ✗ | P1 | 3 | -| Computation expression completions | ✓ | ✗ | ✓ | P1 | 3 | -| Type provider navigation | ✓ | ✗ | ✓ | P2 | 4 | -| F# Interactive (FSI) integration | ✓ | ✗ | ✓ | P2 | 4 | -| File ordering awareness & reorder | ✓ | ✗ | ✓ | P1 | 4 | -| Open statement management | ✓ | ✗ | ✓ | P0 | 3 | -| Fantomas integration | ✗ | ✗ | ✗ | P0 | 3 | -| FSharpLint integration | ✗ | ✗ | ✗ | P1 | 4 | -| FSharp.Analyzers.SDK support | ✗ | ✗ | ✗ | P1 | 4 | - -### 9.10 Features That Set SharpLsp Apart - -These are features no single tool offers today. This is where SharpLsp moves beyond parity and aims to set the bar: - -| Feature | VS | CDK | R | Priority | Phase | -|---|---|---|---|---|---| -| Unified C# + F# in one LSP server | ✗ | ✗ | ✓* | P0 | 2 | -| True editor-agnostic (10+ editors) | ✗ | ✗ | ✗ | P0 | 1 | -| Cross-language go-to-definition (C#↔F#) | ✗ | ✗ | ✓* | P2 | 4 | -| Cross-language find references (C#↔F#) | ✗ | ✗ | ✓* | P2 | 4 | -| Zero-config, zero-license instant setup | ✗ | ✗ | ✗ | P0 | 1 | -| Sub-millisecond syntax features (Rust+TS) | ✗ | ✗ | ✗ | P0 | 1 | -| Architecture analysis & visualization | ✗ | ✗ | ✓ | P3 | 5 | -| AI-assisted code actions via MCP | ✗ | ✗ | ✗ | P3 | 5 | -| Database-aware string analysis (SQL) | ✗ | ✗ | ✓ | P3 | 5 | -| Open governance & community-driven | ✗ | ✗ | ✗ | P0 | 1 | - -*\* Rider supports both C# and F# but via proprietary code, not LSP, and not available to any other editor.* - -## 10. Success Metrics +| Feature | Priority | Phase | +| --- | --- | --- | +| Launch/attach .NET process | P1 | 4 | +| Breakpoints (line, conditional, logpoint) | P1 | 4 | +| Step in/out/over | P1 | 4 | +| Variable inspection | P1 | 4 | +| Watch expressions | P2 | 4 | +| Call stack navigation | P1 | 4 | +| Async logical call stack | P1 | 4 | +| Exception breakpoints | P2 | 4 | +| Data breakpoints | P2 | 5 | +| Return value display | P2 | 5 | +| Hot reload (method body edits) | P2 | 4 | +| Full expression eval (LINQ, lambdas) | P1 | 5 | +| Remote debugging (SSH) | P2 | 5 | +| Multi-process / compound launch | P2 | 4 | +| Test discovery (xUnit/NUnit/MSTest) | P1 | 4 | +| Test discovery (Expecto/FsCheck) | P1 | 4 | +| Run/debug individual test | P1 | 4 | +| Test result inline display | P2 | 4 | +| Continuous testing | P3 | 5 | +| Code coverage overlay | P3 | 5 | + +### [SHARPLSP-TODO-WORKSPACE] Workspace and Project Management + +| Feature | Priority | Phase | +| --- | --- | --- | +| Solution/project loading | P0 | 2 | +| SDK-style project support | P0 | 2 | +| Legacy .csproj/.fsproj support | P1 | 3 | +| Multi-targeting support | P1 | 3 | +| Central Package Management | P1 | 3 | +| Project dependency visualization | P2 | 4 | +| NuGet package search & install | P2 | 4 | +| NuGet package update suggestions | P2 | 4 | +| Add/remove project reference | P2 | 4 | +| File watching & auto-reload | P0 | 2 | +| Configuration via sharplsp.toml | P0 | 1 | +| Bundled required sidecars in VSIX | P0 | 1 | + +### [SHARPLSP-TODO-FSHARP] F#-Specific Features + +| Feature | Priority | Phase | +| --- | --- | --- | +| Pipeline type hints | P1 | 3 | +| Signature file generation (.fsi) | P1 | 4 | +| Union case generation | P1 | 3 | +| Record stub generation | P1 | 3 | +| Computation expression completions | P1 | 3 | +| Type provider navigation | P2 | 4 | +| F# Interactive (FSI) integration | P2 | 4 | +| File ordering awareness & reorder | P1 | 4 | +| Open statement management | P0 | 3 | +| Fantomas integration | P0 | 3 | +| FSharpLint integration | P1 | 4 | +| FSharp.Analyzers.SDK support | P1 | 4 | + +### [SHARPLSP-TODO-DIFFERENTIATORS] Differentiating Features + +| Feature | Priority | Phase | +| --- | --- | --- | +| Unified C# + F# in one LSP server | P0 | 2 | +| True editor-agnostic (10+ editors) | P0 | 1 | +| Cross-language go-to-definition (C#↔F#) | P2 | 4 | +| Cross-language find references (C#↔F#) | P2 | 4 | +| Zero-config, zero-license instant setup | P0 | 1 | +| Sub-millisecond syntax features (Rust+TS) | P0 | 1 | +| Architecture analysis & visualization | P3 | 5 | +| AI-assisted code actions via MCP | P3 | 5 | +| Database-aware string analysis (SQL) | P3 | 5 | +| Open governance & community-driven | P0 | 1 | + + +## [SHARPLSP-SUCCESS] Success Metrics | Milestone | Criteria | Target Date | |---|---|---| @@ -798,35 +736,8 @@ These are features no single tool offers today. This is where SharpLsp moves bey | Community adoption | 1,000+ GitHub stars, 100+ daily active users | Month 24 | | Feature leadership | Features no other tool has (cross-language nav, AI actions, architecture analysis) | Month 24+ | -## 11. Distribution - -SharpLsp is distributed as per-platform VSIXs with the `sharplsp` binary and both -required sidecars bundled inside each one. Installing the VS Code extension is all -a user needs. - -- **`sharplsp`** — bundled inside each per-platform VSIX (`bin//sharplsp[.exe]`). Also available via Homebrew (macOS/Linux) and Scoop (Windows) for users who want it on PATH. -- **`sharplsp-sidecar-csharp`** — bundled inside every VSIX at `bin/all/sharplsp-sidecar-csharp`. -- **`sharplsp-sidecar-fsharp`** — bundled inside every VSIX at `bin/all/sharplsp-sidecar-fsharp`. - -Binary resolution is handled by `@nimblesite/shipwright-vscode`. The bundled -binary is the default resolution source. The `sharplsp.lspPath` setting -overrides it for advanced users. - -**.NET 10 runtime acquisition.** The C# and F# sidecars are framework-dependent -.NET 10 assemblies. SharpLsp does NOT bundle a runtime. Instead, the VS Code -extension declares `ms-dotnettools.vscode-dotnet-runtime` (Microsoft's .NET -Install Tool) as an `extensionDependencies` entry, then calls `dotnet.acquire` -on activation to obtain a per-user .NET 10 runtime. Acquisition shows a -non-interactive progress notification + status-bar indicator; the user is -informed but never asked to do anything. See -[DISTRIBUTION-SPEC.md `[DIST-RUNTIME-ACQUIRE]`](DISTRIBUTION-SPEC.md#dist-runtime-acquire). - -See [DISTRIBUTION-SPEC.md](DISTRIBUTION-SPEC.md) for the full distribution -specification including version invariants, release workflow, and the -editor extension contract. - ---- +## [SHARPLSP-DISTRIBUTION] Distribution -**END OF SPECIFICATION** +Per-platform VSIX paths and binary resolution are specified by [SHARPLSP-ARCHITECTURE-BINARIES] and [SHARPLSP-ARCHITECTURE-EXTENSIONS]. [DISTRIBUTION-SPEC.md](DISTRIBUTION-SPEC.md) is normative for version invariants, packaging, release workflow, and editor activation. -*SharpLsp: Because .NET developers deserve better.* +Under `[DIST-RUNTIME-ACQUIRE]`, the VS Code extension declares `ms-dotnettools.vscode-dotnet-runtime` as an `extensionDependencies` entry and calls `dotnet.acquire` for a per-user .NET 10 runtime. Acquisition MUST show non-interactive progress and a status-bar indicator. diff --git a/docs/specs/SIDECAR-LIFECYCLE-SPEC.md b/docs/specs/SIDECAR-LIFECYCLE-SPEC.md new file mode 100644 index 00000000..3e5ba83a --- /dev/null +++ b/docs/specs/SIDECAR-LIFECYCLE-SPEC.md @@ -0,0 +1,414 @@ +# Sidecar Lifecycle and IPC Reliability Specification `[SIDECAR-LIFECYCLE]` + +**Status:** Normative · **Applies to:** Rust host and C#/.NET/F# sidecars · **Plan:** [SIDECAR-LIFECYCLE-PLAN.md](../plans/SIDECAR-LIFECYCLE-PLAN.md) + +**MUST**, **MUST NOT**, **SHOULD**, and **MAY** are normative. Code and tests cite the most specific stable ID. + +## Objective `[SIDECAR-LIFECYCLE-OBJECTIVE]` + +SharpLsp runs C# and F# semantic engines in independent .NET processes. The lifecycle MUST resolve a spawnable artifact, create an isolated IPC endpoint, establish a correlated session, restore desired workspace state, distinguish slow work from failure, and terminate the complete process tree. + +The lifecycle subsystem MUST make that sequence one state machine. A request, heartbeat, process exit, startup timeout, editor shutdown, and parent-death event MUST all be serialized through that same owner. No caller may independently spawn, reconnect, back off, or kill a sidecar. + +### Scope `[SIDECAR-LIFECYCLE-SCOPE]` + +This specification covers: + +- executable resolution and fallback; +- per-spawn endpoints and the pre-IPC `READY` handshake; +- supervisor state, generation fencing, request admission, and backoff; +- framing, correlation, notifications, cancellation, and timeouts; +- health, state rehydration, shutdown, parent-death handling, containment, security, observability, budgets, and acceptance tests. + +The contract is identical for Roslyn and FCS unless stated otherwise. Each has its own supervisor, endpoint generation, backoff counter, and containment scope. + +### Non-goals `[SIDECAR-LIFECYCLE-NONGOALS]` + +Out of scope: Roslyn/FCS feature behavior, feature-owned MessagePack payloads, LSP client restart policy, editor binary acquisition, remote IPC, and cross-user trust. The v1 driver admits one host request at a time while receiving interleaved notifications. + +## Ownership and State `[SIDECAR-ARCHITECTURE]` + +### Component Ownership `[SIDECAR-ARCHITECTURE-OWNERSHIP]` + +| Component | Sole responsibilities | MUST NOT own | +|---|---|---| +| `SidecarManager` facade | Stable API used by LSP features; converts supervisor results into typed errors | Child handles, endpoint cleanup, transport reads, backoff sleeps | +| Supervisor task | State transitions, generation number, launch candidates, process containment, bootstrap, backoff, shutdown | Feature-specific MessagePack payload logic | +| Connection driver task | Sole ownership of one `FramedTransport`; frame reads/writes; active request ID; notification dispatch | Process spawning, retry policy, workspace selection | +| Rust session state | Desired workspace target, analyzer configuration, and authoritative open-document snapshots | Roslyn/FCS semantic state | +| Shared .NET `SidecarHost` | Listener, handshake, sequential dispatch, response flush, parent watchdog, local process containment | Host retry/backoff policy | +| C#/F# engines | Language-specific handlers and semantic state | IPC lifecycle or process ownership | + +There MUST be exactly one supervisor task and at most one connection driver per language. Public methods communicate with them through bounded channels and await `Result` values. They MUST NOT hold a mutex across process spawn, IPC, sleep, or user-code awaits. + +### Supervisor State Model `[SIDECAR-STATE-MODEL]` + +```mermaid +flowchart LR + Stopped --> Resolving --> Spawning --> AwaitingReady --> Connecting --> Bootstrapping --> Ready + Ready --> Stopping --> Stopped + Resolving --> Backoff + Spawning --> Backoff + AwaitingReady --> Backoff + Connecting --> Backoff + Bootstrapping --> Backoff + Ready --> Backoff + Backoff --> Resolving +``` + +| State | Required owned resources | Request behavior | +|---|---|---| +| `Stopped` | No child, transport, listener endpoint, or retry timer | First eligible operation starts resolution | +| `Resolving` | Candidate list for the next generation | Concurrent callers join the same readiness waiter | +| `Spawning` | Generation, candidate, endpoint lease, containment scope, child | Concurrent callers continue waiting; no second spawn | +| `AwaitingReady` | Running child and capped stdout/stderr collectors | Only handshake/process/timeout events are accepted | +| `Connecting` | Validated handshake and effective endpoint | Connection retry is bounded; no feature request is written | +| `Bootstrapping` | Connection driver plus desired session snapshot | Internal bootstrap requests only | +| `Ready` | Child, containment, connection driver, completed bootstrap | Feature requests are queued in bounded arrival order | +| `Backoff` | Failure record and monotonic `retry_not_before` | Calls fail promptly with retry metadata; they do not spawn | +| `Stopping` | Resources being drained or terminated | New calls fail as shutting down; queued calls are cancelled | + +`Ready` means semantically usable. The stdout record advances `AwaitingReady` to `Connecting`; only successful bootstrap reaches `Ready`. + +### Transition Rules `[SIDECAR-STATE-TRANSITIONS]` + +1. Only the supervisor mutates state. +2. Every transition records `from`, `to`, language, generation, attempt, reason, and elapsed time. +3. Startup, connect, bootstrap, protocol, request-timeout, process-exit, and shutdown failures all return to the supervisor; they never perform a private restart. +4. A process or connection event carries its generation. An event for any older generation is logged at debug level and ignored. +5. A child is reaped before its generation is discarded. A new generation MUST NOT reuse the old child handle, connection driver, endpoint, or endpoint nonce. +6. `ensure_ready` is coalesced: N concurrent callers produce one spawn/bootstrap sequence and N completion results. +7. The C# supervisor cannot transition or reset the F# supervisor, and vice versa. + +### Generation Fencing `[SIDECAR-STATE-GENERATION]` + +Before each spawn, the supervisor assigns a monotonically increasing `u64` generation; zero means “not started”. Launch arguments, `READY`, driver/exit events, logs, and bootstrap completion carry it. + +Generation fencing MUST prevent stale work from publishing state. A late exit, response, timeout, health tick, or bootstrap result from N MUST NOT affect N+1. + +## Resolution and Startup `[SIDECAR-STARTUP]` + +### Launch Candidates `[SIDECAR-STARTUP-RESOLUTION]` + +The language-specific environment override in [SHARPLSP-ARCHITECTURE-EXTENSIONS-SIDECAR-ENV] is authoritative and MUST be evaluated before every other launch source. + +Each generation resolves typed `LaunchCandidate` values. Resolution MUST return the absolute executable passed to `CreateProcess`/`exec`, not a bare name. + +| Priority | Source | Accepted form | Failure policy | +|---|---|---|---| +| 1 | `SHARPLSP_CSHARP_SIDECAR_PATH` / `SHARPLSP_FSHARP_SIDECAR_PATH` | Absolute native apphost, or `.dll` explicitly paired with absolute `dotnet` | Explicit override is authoritative; invalid or unspawnable is a visible hard failure | +| 2 | Shipwright-resolved bundled/installed artifact | Absolute native apphost or framework-dependent `.dll` | Continue only when the candidate is absent or mechanically unspawnable | +| 3 | `PATH` | Absolute native executable discovered by platform rules | Continue to the next source on invalid format or spawn failure | +| 4 | Development output | Prebuilt apphost, or `dotnet ` | Final fallback; missing build output is a resolution failure | + +On Windows, a direct candidate MUST be a real `.exe`. `.cmd`, `.bat`, PowerShell scripts, and extensionless command shims MUST NOT be selected or invoked through a shell. A `.dll` is valid only as an argument to a resolved `dotnet.exe`. On Unix, a direct candidate MUST be a regular file with an executable mode. All platforms reject directories and inaccessible files. + +`dotnet run` MUST NOT be a candidate because its intermediary breaks direct-child termination and may rebuild during requests. Launch prebuilt development output as an apphost or `dotnet `. + +Mechanical failure may advance to the next non-explicit candidate. Listener, handshake, protocol-version, or initialization failures terminate the generation and MUST NOT silently try another binary. Backoff starts after the candidate chain is exhausted. + +### Spawn Contract `[SIDECAR-STARTUP-SPAWN]` + +The host launches a sidecar with explicit arguments equivalent to: + +```text + --endpoint --parent-pid --generation --protocol 1 +``` + +The sidecar MUST validate all arguments before binding. The production host MUST always supply the parent PID. The child inherits only the intended environment (including `DOTNET_ROOT`), has stdin closed, has stdout and stderr piped, and is created without a visible console window on Windows. + +Before `READY`, the sidecar MUST install parent-death watching and containment, initialize file logging, create the listener, and obtain its effective endpoint. Degradable engine initialization per [DIST-SDK-DISCOVERY] MUST NOT bypass this setup. + +### Endpoint Allocation and Ownership `[SIDECAR-STARTUP-ENDPOINT]` + +Each spawn gets a new endpoint keyed by language, host PID, generation, and at least 64 OS-CSPRNG bits. A workspace hash MAY aid diagnostics but MUST NOT provide uniqueness. + +Recommended shapes are: + +```text +Windows: \\.\pipe\sharplsp---- +Unix: /slsp----.sock +``` + +Requirements: + +- Two hosts opening the same workspace MUST never intentionally share an endpoint. +- A restart MUST allocate a different endpoint from the failed generation so an orphan cannot block or impersonate the replacement. +- The Windows listener uses `PipeOptions.CurrentUserOnly` and one server instance. +- The Unix socket is created in an owner-only directory where possible and has mode `0600`. +- Neither host nor sidecar may delete an arbitrary pre-existing socket before bind. A random collision is treated as bind failure and retried with a fresh generation/nonce. +- The listener removes only its owned Unix socket. Age-clean stale paths only inside the validated SharpLsp runtime directory; never unlink one merely to reuse its name. +- The requested path stays below the common 107-byte Unix limit where possible. If the listener must relocate it, that relocation is authoritative and is reported by the handshake. + +The host logs an endpoint fingerprint, not the full workspace-derived value, at normal levels. + +### Versioned Readiness Handshake `[SIDECAR-STARTUP-HANDSHAKE]` + +After the listener is bound, the sidecar writes and flushes exactly one UTF-8 line to stdout: + +```text +READY:{"protocol":1,"generation":42,"pid":1234,"endpoint":""} +``` + +The JSON object has these required fields: + +| Field | Type | Rule | +|---|---|---| +| `protocol` | unsigned integer | Must equal the host's requested protocol version | +| `generation` | unsigned integer | Must equal the launch generation | +| `pid` | unsigned integer | Actual sidecar process PID, used for diagnostics and containment verification | +| `endpoint` | string | Exact bound endpoint; it may differ from the requested Unix path | + +The host rejects malformed JSON, an unknown/missing protocol, generation mismatch, zero PID, wrong-platform endpoints, and endpoints outside the lease. Within 30 seconds, a valid handshake must win against child exit, stdout EOF, and timeout; every losing child is terminated and reaped. + +The host then retries connection only for transient listener-visibility errors (`not found` or Windows `ERROR_PIPE_BUSY`) with bounded exponential delays from 25ms to 250ms for at most 2 seconds. Other connect errors fail immediately. A successful connection consumes the endpoint lease. + +During a one-release migration the host MAY accept legacy `READY:` only from a binary that has already passed the exact version check. New sidecars MUST emit the versioned record. + +### Startup Failure Contract `[SIDECAR-STARTUP-FAILURE]` + +Any failure before `READY` MUST: + +1. write the full exception and structured context to the sidecar rolling file; +2. write and flush at most one sanitized `FATAL:` line to stderr containing a stable failure category, concise reason, and log directory; +3. return a non-zero process exit code; and +4. dispose any listener and owned Unix path. + +The host continuously drains stdout/stderr, forwards allowed lines to structured logs, and retains their final 16KiB each. It reaps the child and reports exit status, category, launch source, and log path. Raw stacks, ANSI controls, source text, and unbounded output MUST NOT reach the editor panel. + +Startup failures return `Result`; startup MUST NOT `panic`, `unwrap`, or report success. `FATAL:` is the exception to [DIST-CLEAN-OUTPUT]'s no-sidecar-stderr rule. + +## Process Lifetime and Containment `[SIDECAR-PROCESS]` + +### Parent-death Watcher `[SIDECAR-PROCESS-PARENT]` + +The sidecar installs the watcher before listener creation and `READY`: + +- On Windows it opens a waitable handle to `--parent-pid` and exits when that exact process object is signalled. +- On Unix, because the production launch is direct, it verifies the supplied PID is its parent and watches for reparenting/parent disappearance. +- Detection latency MUST be at most one second. +- If the parent is already gone or cannot be validated, startup fails before `READY`. +- Normal supervisor shutdown wins over the watcher and follows [SIDECAR-SHUTDOWN-PROTOCOL]. + +Parent death terminates descendants, disposes the listener, and exits without awaiting IPC. `AcceptStreamAsync` MUST remain cancellable. + +### Descendant Containment `[SIDECAR-PROCESS-TREE]` + +| Platform | Required containment | +|---|---| +| Windows | Before engine child processes can start, the sidecar creates a Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, assigns itself to it, and retains the safe job handle for process lifetime. Sidecar exit therefore terminates Roslyn BuildHost, MSBuild, and other descendants. Failure to establish the job is a pre-READY fatal error. | +| Linux/macOS | The host launches the sidecar as leader of a dedicated process group. Planned hard termination signals the group, and the parent-death path terminates that group before exit. The host process is never in the sidecar group. | + +Production launch MUST be direct, so the Rust `Child` PID and handshake PID identify the same sidecar process. No termination path may enumerate by executable name, kill VS Code, or target a PID/process group that does not belong to the current generation. + +### Exit Detection and Reaping `[SIDECAR-PROCESS-EXIT]` + +A generation-scoped watcher reports every exit. Zero exit after acknowledged shutdown reaches `Stopped`; any other active-state exit enters backoff. The child is always reaped, including after timeout or hard kill; dropping its handle is only a safety net. + +## IPC Session `[SIDECAR-IPC]` + +### Frame and Envelope Contract `[SIDECAR-IPC-FRAMING]` + +IPC remains a 4-byte little-endian unsigned payload length followed by a MessagePack envelope. Both sides reject frames above 64MiB before allocation. EOF between the length prefix and payload is a terminal truncated-frame error, not a clean end-of-stream. + +The envelope fields remain: + +| Field | Request | Response | Notification | +|---|---|---|---| +| `id` | Required non-zero `u32` | Required and equal to request | Null | +| `method` | Required non-empty string | Null | Required non-empty string | +| `payload` | MessagePack bytes | MessagePack bytes | MessagePack bytes | +| `error` | Null | Null or one error string | Null | + +An envelope that matches none or more than one of these shapes is a protocol fault. + +### Single Transport Owner `[SIDECAR-IPC-DRIVER]` + +Only the connection driver reads from or writes to `FramedTransport`. Callers submit bounded commands containing method, payload, response deadline, and a completion channel. The driver: + +1. writes at most one host request at a time; +2. continuously reads frames while that request is active; +3. dispatches sidecar notifications without mistaking them for the active response; +4. completes the active request only for its exact response ID; and +5. reports EOF, I/O, framing, decode, correlation, and deadline faults to the supervisor. + +The command queue has a finite capacity. Saturation returns a typed busy error; it MUST NOT allocate an unbounded backlog. Workspace mutations and semantic reads retain arrival order. + +### Response Correlation `[SIDECAR-IPC-CORRELATION]` + +Request IDs are monotonically allocated within a generation and are never zero. A response ID must equal the active request ID. A missing ID, duplicate response, response for an unknown/old ID, or mismatch is a protocol fault: the driver fails the active request, stops admitting writes, drops the transport, and asks the supervisor to terminate/back off that generation. The suspect frame MUST NOT be handed to a later caller. + +The same validation applies to `ping`, bootstrap, and `shutdown` responses. + +### Cancellation and Response Budgets `[SIDECAR-IPC-TIMEOUT]` + +This section expands the existing [SHARPLSP-ARCHITECTURE-SIDECARS-TIMEOUT] rule: + +- `workspace/open` has a 600-second response budget. +- Every other ordinary request has a 120-second response budget unless a more specific feature spec defines a shorter budget. +- Idle health `ping` has a 2-second response budget. +- The deadline begins when the frame is written, not while the command waits behind another request. +- Cancellation before the first byte is written removes the command without affecting the session. +- Cancellation after write sends the protocol cancellation notification when supported, then drains and discards the matching response. The next request is not written until that response is drained. +- If a written request cannot be drained before its response budget, the connection is poisoned and the generation is terminated. Late frames can therefore never desynchronize the next caller. + +No request is automatically replayed after an ambiguous post-write failure. Read-only feature owners may explicitly retry against the next ready generation; mutations rely on the Rust VFS/session replay contract rather than guessing whether the sidecar applied the old request. + +### Health and Activity `[SIDECAR-HEALTH-ACTIVITY]` + +The connection driver owns health checks. + +- No ping is sent during `Resolving` through `Bootstrapping`, `Backoff`, or `Stopping`. +- While an ordinary request is active and within its response budget, the sidecar is **busy**, not unhealthy. The request's own deadline detects a stall. +- When `Ready` and idle, the driver sends a ping after 5 seconds without successful frame activity. +- A matching pong resets idle activity. A ping timeout, mismatched ID, process exit, or transport fault is unhealthy and terminates the generation. +- Merely observing a held lock or queued request is never proof of liveness. +- At most one health timer exists per supervisor, including across eager/lazy workspace paths. + +### Managed Message Loop Failures `[SIDECAR-IPC-MESSAGE-LOOP]` + +In the .NET host, EOF between messages ends the session normally. `IOException`, `ObjectDisposedException`, truncated frames, and write failures are terminal. Malformed MessagePack or invalid envelope shape may produce one correlated protocol error when safe; repeated decode/dispatch failures are bounded and then terminate non-zero. The loop MUST NOT catch a persistent transport exception and immediately retry the same broken stream. + +The failure counter resets only after a complete valid message/response cycle. Terminal exit disposes the listener/transport and allows process containment to clean descendants. + +## Failure, Backoff, and Recovery `[SIDECAR-RECOVERY]` + +### Failure Taxonomy `[SIDECAR-RECOVERY-FAILURES]` + +The stable failure categories are `resolution`, `spawn`, `pre-ready-exit`, `ready-timeout`, `listener-bind`, `handshake`, `connect`, `bootstrap`, `protocol`, `request-timeout`, `health`, `process-exit`, and `shutdown-timeout`; platform details remain diagnostic context. + +All startup, runtime, health, and protocol failures advance the same per-language backoff sequence. + +### Backoff Algorithm `[SIDECAR-RECOVERY-BACKOFF]` + +The base sequence is 1s, 2s, 4s, 8s, 16s, then 30s maximum. Each delay receives bounded ±20% jitter and is represented by a monotonic `retry_not_before` timestamp. Calls during the window fail promptly with failure category and remaining retry duration; they do not sleep and do not launch another process. + +Backoff resets to 1s only after 60 seconds continuously in `Ready` or an explicit full LSP session restart. A single ping or response does not reset a flapping process. A user-initiated retry command MAY bypass the current timer once but MUST NOT create concurrent attempts. + +### Bootstrap and Rehydration `[SIDECAR-RECOVERY-REHYDRATE]` + +Per language, the Rust host retains the authoritative desired session state: + +- selected workspace/solution or project-less root-file target; +- current analyzer/configuration payload; +- latest open-document URI, language, version, and full text from the VFS; and +- subscriptions required for sidecar notifications. + +Every new connection is bootstrapped in this order: + +1. `workspace/open` for the current target, if one exists; +2. `analyzers/configure` and other deterministic session configuration; +3. replay of latest open documents owned by that language in stable URI order; and +4. registration/activation of notification consumers. + +Only then is the generation `Ready`; failure enters backoff. Eager, lazy/project-less, second-language, explicit-solution, and recovery paths MUST use this bootstrap. Generation and readiness are salsa inputs whose changes invalidate dependent queries and trigger feature refresh/retry, including [DIAG-PUSH-GATE]. Sidecars and lifecycle code MUST NOT keep semantic result caches. + +### Degraded Behavior `[SIDECAR-RECOVERY-DEGRADED]` + +During startup/backoff, Rust syntax-only features remain available. Semantic requests receive a typed `SidecarUnavailable` result containing language, failure category, and retry-after duration. A feature MAY serve a last-known-good salsa query result with explicit stale provenance; no editor-local, handler-local, lifecycle-local, or sidecar-local cache is permitted. + +The editor receives at most one rate-limited, plain-language notification per failure episode, with a `Show Log` action. Repeated LSP requests during the same backoff window do not produce repeated toasts or one process per request. Recovery to `Ready` emits one structured recovery event and refreshes affected editor state. + +## Shutdown `[SIDECAR-SHUTDOWN]` + +### Sidecar Acknowledgement Ordering `[SIDECAR-SHUTDOWN-ACK]` + +The `shutdown` handler MUST serialize an `ok` response without cancelling the token needed to write it. The message loop writes and flushes the response using a bounded write token; only after the flush succeeds does it cancel dispatch, close the listener/transport, and exit zero. Cancellation from the handler before the acknowledgement write is forbidden. + +### Host Shutdown Protocol `[SIDECAR-SHUTDOWN-PROTOCOL]` + +For each language, the supervisor: + +1. transitions to `Stopping`, rejects new commands, and cancels commands not yet written; +2. sends one correlated `shutdown` request if a connection exists; +3. waits up to 1 second for the matching acknowledgement; +4. after acknowledgement, closes IPC and waits for clean process exit within the remaining 5-second graceful-shutdown budget; +5. on missing acknowledgement, timeout, or non-exit, terminates the current generation's contained process tree; and +6. reaps the direct child, disposes the containment handle, and removes only owned endpoints. + +Shutdown is idempotent. Calling it in `Stopped` succeeds. Host teardown waits for both language supervisors concurrently, and one stuck language cannot prevent hard cleanup of the other. + +## Observability and Security `[SIDECAR-OPERATIONS]` + +### Structured Lifecycle Logs `[SIDECAR-OBSERVABILITY]` + +Lifecycle logs include `language`, `generation`, `state_from`, `state_to`, `attempt`, `launch_source`, `pid`, `endpoint_fingerprint`, `request_id`, `method`, `failure_category`, `elapsed_ms`, and `retry_after_ms` where applicable. Routine requests and pings are debug-level; transitions, recovery, and graceful shutdown are information-level; failures are warning/error-level once per event. + +The host and sidecar use their existing structured logging systems. Source text, MessagePack payloads, environment secrets, raw workspace paths at normal log levels, and unbounded exception repetition are forbidden. The error surfaced for pre-READY exit always names the sidecar log directory. + +### Local IPC Security `[SIDECAR-SECURITY]` + +- Endpoint nonces come from an OS CSPRNG and are not derived solely from public workspace data. +- Unix runtime directories and sockets are owner-only; Windows pipes are current-user-only. +- No launch candidate is passed through a shell, so workspace or path text cannot become shell syntax. +- Handshake endpoints are validated against the current lease before connection. +- Frame limits apply before allocation on both sides. +- Endpoint cleanup is confined to validated, owned paths. +- IPC remains unauthenticated same-user local transport; it MUST NOT bind TCP or a remotely reachable endpoint as a silent fallback. + +## Budgets and Resource Bounds `[SIDECAR-PERFORMANCE]` + +| Operation/resource | Required bound | +|---|---| +| Pre-READY startup | 30s maximum | +| READY-to-connect retry | 2s maximum | +| Idle ping cadence / response | 5s / 2s | +| Ordinary request / `workspace/open` | 120s / 600s | +| Graceful shutdown before hard termination | 5s | +| Parent-death detection | 1s maximum | +| Frame payload | 64MiB maximum | +| Captured startup stdout/stderr tail | 16KiB each | +| Backoff | 1s exponential to 30s, ±20% jitter | +| Supervisor and connection command queues | Finite, with typed saturation failure | + +No error path may spin without await/backoff, leak a child/descendant, create an unbounded task per request, or accumulate an unbounded output/command buffer. + +## Compatibility and Integration `[SIDECAR-COMPATIBILITY]` + +The versioned handshake is the only intended startup-protocol change. MessagePack framing and existing feature DTOs remain compatible. Host and sidecars ship together and are exact-version verified by the distribution layer; the optional legacy READY parser is temporary migration support, not a permanent second protocol. + +`SHARPLSP-SPEC.md`, [DIST-CLEAN-OUTPUT], [DIST-CI-WIN-TRANSPORT], [SCRIPT-ROUTE-HEALTH], and [SHARPLSP-ARCHITECTURE-SIDECARS-TIMEOUT] remain compatible summaries; this document is the normative detailed lifecycle contract when a summary is ambiguous. + +## Implementation Anchors `[SIDECAR-IMPLEMENTATION]` + +| Contract | Implementation | Verification | +|---|---|---| +| Resolution, spawn, request budgets, health, and shutdown | [`manager.rs`](../../src/sharplsp/src/sidecar/manager.rs) | [`manager.rs` tests](../../src/sharplsp/src/sidecar/manager.rs) and [SIDECAR-TESTING] | +| MessagePack envelope | [`protocol.rs`](../../src/sharplsp/src/sidecar/protocol.rs) | [`protocol.rs` tests](../../src/sharplsp/src/sidecar/protocol.rs) | +| Rust framing and endpoint transport | [`transport.rs`](../../src/sharplsp/src/sidecar/transport.rs) | [`transport.rs` tests](../../src/sharplsp/src/sidecar/transport.rs) | +| Managed framing and dispatch | [`FramedTransport.cs`](../../src/sidecars/SharpLsp.Sidecar.Common/Ipc/FramedTransport.cs), [`IpcConnection.cs`](../../src/sidecars/SharpLsp.Sidecar.Common/Ipc/IpcConnection.cs), [`MessageRouter.cs`](../../src/sidecars/SharpLsp.Sidecar.Common/Ipc/MessageRouter.cs) | [`IpcConnectionTests.cs`](../../src/sidecars/SharpLsp.Sidecar.Common.Tests/IpcConnectionTests.cs) | +| Managed listener, readiness, parent watch, and shutdown | [`SidecarHost.cs`](../../src/sidecars/SharpLsp.Sidecar.Common/SidecarHost.cs) | [`SidecarHostEndToEndTests.cs`](../../src/sidecars/SharpLsp.Sidecar.Common.Tests/SidecarHostEndToEndTests.cs) | +| Workspace open and analyzer bootstrap | [`src/sharplsp/src/main.rs`](../../src/sharplsp/src/main.rs) | Release host/sidecar suites required by [SIDECAR-TESTING] | + +## End-to-end Acceptance `[SIDECAR-TESTING]` + +Acceptance tests MUST use real processes, platform IPC, files, and published sidecars or a separate fixture built on production `SidecarHost`. In-memory transports, mocked process APIs, sleep-only assertions, and production test branches are prohibited. + +Required scenarios: + +1. Two real SharpLsp hosts open the same workspace and both complete C# and F# semantic requests; their endpoints and PIDs differ and neither steals the other's socket. +2. A real sidecar listener bind failure emits one `FATAL` diagnostic, exits non-zero, and produces a host error with exit status and log path. +3. Repeated pre-READY failure produces one spawn attempt per backoff window, not one per semantic request; recovery succeeds when the real artifact becomes available. +4. A long Unix endpoint connects using the effective path advertised in the versioned READY record. +5. Windows PATH resolution skips `.cmd`, `.bat`, and extensionless shims and launches the next valid absolute candidate. +6. A response with the wrong ID poisons the generation; it is never returned to the current or next caller. A sidecar notification arriving before a valid response is dispatched and the response still reaches the correct caller. +7. A request within its budget is not killed by health monitoring. An idle unresponsive sidecar and a request beyond its deadline are terminated and restarted. +8. Persistent transport/decode failure exits the .NET message loop within a bounded time and does not flood logs or consume a CPU core indefinitely. +9. `shutdown` returns the matching acknowledgement before the sidecar exits; normal shutdown does not require the hard-kill path. +10. On Windows, killing the host and hard-killing a wedged sidecar remove the sidecar and a real child helper/BuildHost, after which the named pipe can be rebound. On Unix, the equivalent process group has no surviving members or socket. +11. Killing and restarting a sidecar while documents are open replays workspace, configuration, and latest VFS text; the next semantic result reflects the latest edit for both C# and F#. +12. The full Windows VSIX lifecycle chunk and Linux/macOS host-sidecar suites exercise the release artifacts, not only the shared transport library. + +## Issue Traceability `[SIDECAR-TRACEABILITY]` + +| Issue | Root failure | Normative requirements | Closure evidence | +|---|---|---|---| +| #150 | Listener failure exits cleanly and is invisible | [SIDECAR-STARTUP-FAILURE], [SIDECAR-OBSERVABILITY] | Non-zero real-process test; one fatal line; host exit-status/log-path assertion | +| #151 | Workspace-derived endpoints collide/steal | [SIDECAR-STARTUP-ENDPOINT], [SIDECAR-STARTUP-HANDSHAKE] | Concurrent-host tests on Windows and Unix | +| #152 | Pre-READY failures bypass crash backoff | [SIDECAR-STATE-TRANSITIONS], [SIDECAR-RECOVERY-BACKOFF] | Spawn-count/backoff/recovery process test | +| #153 | Persistent transport exception hot-loops | [SIDECAR-IPC-MESSAGE-LOOP], [SIDECAR-PROCESS-EXIT] | Broken-stream/decode-storm process exits within bound | +| #154 | READY reports requested rather than bound path | [SIDECAR-STARTUP-HANDSHAKE] | Overlong Unix endpoint connects through advertised effective path | +| #163 | Windows direct-child kill leaves descendants/orphans | [SIDECAR-PROCESS-PARENT], [SIDECAR-PROCESS-TREE] | Host-death and hard-kill descendant tests on Windows | +| #164 | Response IDs unchecked; health check races lock | [SIDECAR-IPC-CORRELATION], [SIDECAR-HEALTH-ACTIVITY] | Wrong-ID poison test and long-request/idle-stall health tests | +| #167 | PATH accepts shims `CreateProcess` cannot run | [SIDECAR-STARTUP-RESOLUTION] | Windows real-PATH fallback test | +| #172 | Shutdown cancels token before ack write | [SIDECAR-SHUTDOWN-ACK], [SIDECAR-SHUTDOWN-PROTOCOL] | Matching ack observed before zero process exit | diff --git a/docs/specs/SOLUTION-EXPLORER-SPEC.md b/docs/specs/SOLUTION-EXPLORER-SPEC.md index ccfbc8eb..6cb126ea 100644 --- a/docs/specs/SOLUTION-EXPLORER-SPEC.md +++ b/docs/specs/SOLUTION-EXPLORER-SPEC.md @@ -1,16 +1,10 @@ -# Solution Explorer Specification +# Solution Explorer Specification `[SE-SOLUTION-EXPLORER]` -**Status:** Active -**Owner:** SharpLsp LSP -**Last Updated:** 2026-04-26 - -## Overview +## Overview `[SE-OVERVIEW]` The Solution Explorer is a VS Code tree view that displays the full code hierarchy of a .NET solution: solutions, projects, namespaces, types, and members. It accepts legacy `.sln` and XML `.slnx` solution files. It is powered by a custom LSP request (`sharplsp/workspaceSymbols`) backed by the sidecar `solution/read` model, tree-sitter parsing in the Rust host for C#, and the FCS sidecar's `documentSymbol` for F# ([SE-FSHARP-SYMBOLS]). -See [LSP-ARCHITECTURE-SPEC.md](specs/LSP-ARCHITECTURE-SPEC.md) for shared LSP architecture. - -## Architecture +## Architecture `[SE-ARCHITECTURE]` ``` VS Code Tree View @@ -31,32 +25,18 @@ Per-file symbols are sourced by language, never by a single parser: | Language | Source | Rationale | |----------|--------|-----------| -| C# (`.cs`) | tree-sitter parsing in the Rust host | A C# grammar is integrated in the host. | -| F# (`.fs`) | FCS sidecar `textDocument/documentSymbol` ([FS-DOCSYMBOL]) | The host has **no** F# tree-sitter grammar, so a tree-sitter-only path silently drops every `.fs` file (issue #119). F# is a first-class language — its files and symbols MUST appear under an `.fsproj` exactly as `.cs` files appear under a `.csproj`. | +| C# (`.cs`) | tree-sitter parsing in the Rust host | The host owns the C# grammar. | +| F# (`.fs`) | FCS sidecar `textDocument/documentSymbol` | The host has no F# grammar; every `.fs` file and its symbols MUST appear under an `.fsproj` exactly as C# does under `.csproj`. | -The F# path reuses the **same** sidecar `documentSymbol` request that powers the editor outline, mapping the nested FCS symbols (module, namespace, type, DU case, member) into the shared `FileSymbol`/`SymbolNode` tree model using each symbol's full range. The F# sidecar must be threaded into `workspace_symbols::handle`; when it is unavailable the project's `.fs` files contribute no symbols rather than failing the whole request. +The F# path reuses the sidecar `documentSymbol` request that powers the editor outline. It maps nested FCS modules, namespaces, types, DU cases, and members into the shared `FileSymbol`/`SymbolNode` tree using full ranges. `workspace_symbols::handle` receives the F# sidecar; when unavailable, `.fs` files contribute no symbols without failing the request. ### Live-Buffer Path Identity [SE-LIVE-BUFFER] -`sharplsp/workspaceSymbols` MUST parse the latest open-buffer text, including -unsaved and rapid successive edits. Disk content is used only when no open VFS -document denotes the source file. - -The editor URI and the project model can use different native paths for the same -file. In particular, Windows runners can send an 8.3 path such as -`C:\Users\RUNNER~1\...`, while the sidecar reports the expanded -`C:\Users\runneradmin\...` path. The VFS therefore resolves and caches the -editor path when the document opens, then compares both the original URI path -and that canonical path during native-path lookup. Canonicalizing only the -project-model path is insufficient because it leaves the editor's aliased path -unchanged and incorrectly falls back to stale disk text. +`sharplsp/workspaceSymbols` MUST parse the latest open VFS text, including unsaved successive edits; disk content is used only when no open document denotes the source file. -Path comparison also ignores Windows verbatim prefixes and casing differences. -The coarse VS Code explorer tests prove that the tree reflects an unsaved rename -and the final value in a burst of renames; the VFS alias regression test covers -the reverse-alias lookup independently of hosted-runner path spelling. +Editor URIs and project models can name one file differently: for example, Windows can supply `C:\Users\RUNNER~1\...` while the sidecar supplies `C:\Users\runneradmin\...`. On open, the VFS stores the resolved editor path as document state, then native-path lookup compares both the original URI path and its canonical path. Comparison ignores Windows verbatim prefixes and casing. The VS Code explorer tests cover unsaved and burst edits; VFS regression tests cover reverse-alias lookup. Implementations: `src/sharplsp/src/vfs.rs`, `src/sharplsp/src/workspace_symbols.rs`, and `src/editors/vscode/src/test/suite/solution-explorer.test.ts`. -### Request: `sharplsp/workspaceSymbols` +### Request: `sharplsp/workspaceSymbols` `[SE-WORKSPACE-SYMBOLS-REQUEST]` **Params:** ```json @@ -107,7 +87,7 @@ the reverse-alias lookup independently of hosted-runner path spelling. } ``` -### Symbol Kinds +### Symbol Kinds `[SE-SYMBOL-KINDS]` | Kind | Tree-sitter Node | Icon | Theme Color | |------|-----------------|------|-------------| @@ -125,14 +105,14 @@ the reverse-alias lookup independently of hosted-runner path spelling. | Function | `delegate_declaration` | `symbol-method` | `symbolIcon.functionForeground` | | Constant | — | `symbol-constant` | `symbolIcon.constantForeground` | -### Special Node Icons +### Special Node Icons `[SE-SYMBOL-ICONS]` | Node | Icon | Color | |------|------|-------| | Solution (.sln/.slnx) | `package` | `terminal.ansiGreen` | | Project (.csproj/.fsproj) | `project` | `terminal.ansiCyan` | -### Access Modifier Extraction +### Access Modifier Extraction `[SE-SYMBOL-ACCESS]` The `access` field is extracted from tree-sitter `modifier` child nodes. Recognized values: @@ -145,7 +125,7 @@ The `access` field is extracted from tree-sitter `modifier` child nodes. Recogni When no access modifier is present, `access` is `null`. -## Tree Hierarchy +## Tree Hierarchy `[SE-TREE]` ``` Solution (SharpLsp.Sidecars.sln) @@ -161,15 +141,15 @@ Solution (SharpLsp.Sidecars.sln) └── Method (HandleAsync) ``` -### File-Scoped Namespace Handling +### File-Scoped Namespace Handling `[SE-TREE-FILE-NAMESPACE]` `tree-sitter-c-sharp` 0.23 emits `file_scoped_namespace_declaration` without nesting subsequent type declarations as children. The Rust host detects this pattern and reparents root-level types into the single file-scoped namespace. -### Namespace Merging +### Namespace Merging `[SE-TREE-NAMESPACE-MERGE]` Symbols from multiple files sharing the same namespace within a project are merged into a single namespace node. -## Sort Order +## Sort Order `[SE-SORT]` Three sort modes are available, cycled via a toolbar button: @@ -179,7 +159,7 @@ Three sort modes are available, cycled via a toolbar button: | Alphabetical | A-Z by symbol name at every level | `$(case-sensitive)` | | Accessibility | Grouped by access modifier, then alphabetical | `$(shield)` | -### Accessibility Sort Priority +### Accessibility Sort Priority `[SE-SORT-ACCESS]` | Priority | Access Level | |----------|-------------| @@ -193,17 +173,17 @@ Three sort modes are available, cycled via a toolbar button: Within each access group, symbols are sorted alphabetically. -### Sort Scope +### Sort Scope `[SE-SORT-SCOPE]` - Sorting applies recursively to namespace children, type children, and nested members - Project order within a solution is preserved (follows `.sln` or `.slnx` declaration order) -- Sorting is client-side only — the LSP response is cached and re-sorted without a new request +- Sorting is client-side only — the current LSP response remains in shared reactive state and is re-sorted without a new request -### Context Key +### Context Key `[SE-SORT-CONTEXT]` The current sort order is exposed via VS Code context key `sharplsp.sortOrder` (values: `natural`, `alphabetical`, `accessibility`). This controls which toolbar icon is visible. -## Commands +## Commands `[SE-COMMANDS]` | Command | Title | Icon | When | |---------|-------|------|------| @@ -215,27 +195,21 @@ The current sort order is exposed via VS Code context key `sharplsp.sortOrder` ( All three sort commands cycle to the next sort mode. -## Retry Logic - -The workspace symbols request retries up to 3 times with a 2-second delay when: -- The LSP client is not yet running -- A transient error occurs (disposed connection, etc.) - -## Hover / Quick Info +## Retry Logic `[SE-REQUEST-RETRY]` -Symbol nodes in the Solution Explorer support hover tooltips showing the same rich Markdown documentation as the editor hover. This reuses the shared hover pipeline — the same sidecar hover handler and Markdown rendering code powers both surfaces. +The workspace symbols request retries up to three times with a two-second delay when the LSP client is unavailable or the connection fails transiently. -See [HOVER-SPEC.md](HOVER-SPEC.md) for the full hover specification, including symbol resolution, XML doc rendering, and caching strategy. +## Hover / Quick Info `[SE-HOVER]` -When the user hovers over a symbol node in the tree view, the extension sends a `textDocument/hover` request for that symbol's declaration position. The response is displayed as a VS Code tree item tooltip using `MarkdownString`. +On symbol hover, the extension sends `textDocument/hover` at the declaration position and renders the response as a tree-item `MarkdownString`, reusing the editor pipeline specified by [HOVER-SPEC.md](HOVER-SPEC.md). -## Context Menus +## Context Menus `[SE-CONTEXT-MENUS]` -Symbol nodes in the Solution Explorer expose context menu actions via `view/item/context` contribution points. Context menus are scoped by `contextValue` so that only relevant actions appear for each node type. +`view/item/context` contributions are scoped by each node's `contextValue`. -### Sort Members +### Sort Members `[SE-CONTEXT-SORT-MEMBERS]` -Right-clicking a type node (Class, Struct, Interface, Enum, Record) shows a **Sort Members** action that reorders the members of that type in the source file. +**Sort Members** reorders source members for Class, Struct, Interface, Enum, and Record nodes. | Property | Value | |----------|-------| @@ -244,11 +218,9 @@ Right-clicking a type node (Class, Struct, Interface, Enum, Record) shows a **So | When | `view == sharplsp.solutionExplorer && viewItem =~ /^symbol\.(class\|struct\|interface\|enum\|record)$/` | | Group | `1_modification` | -#### Sort Hierarchy +#### Sort Hierarchy `[SE-CONTEXT-SORT-HIERARCHY]` -The default sort hierarchy is **Accessibility → Category → Alphabetical**: - -1. **Accessibility** — members are grouped by access modifier using the same priority table as [Accessibility Sort Priority](#accessibility-sort-priority) +1. **Accessibility** — members are grouped by access modifier using [SE-SORT-ACCESS] 2. **Category** — within each accessibility group, members are grouped by kind: | Priority | Category | @@ -271,7 +243,7 @@ The default sort hierarchy is **Accessibility → Category → Alphabetical**: 3. **Alphabetical** — within each category group, members are sorted A-Z by name -#### Settings +#### Settings `[SE-CONTEXT-SORT-SETTINGS]` The sort hierarchy is configurable via the `sharplsp.memberSortOrder` setting: @@ -314,9 +286,9 @@ The sort hierarchy is configurable via the `sharplsp.memberSortOrder` setting: | `sharplsp.memberSortOrder.accessibilityOrder` | `string[]` | See above | Access modifier priority (first = highest) | | `sharplsp.memberSortOrder.categoryOrder` | `string[]` | See above | Member kind priority (first = highest) | -#### Implementation +#### Implementation `[SE-CONTEXT-SORT-IMPLEMENTATION]` -Sort Members is a **source-editing action** — it modifies the source file, not just the tree view. The flow: +Sort Members edits the source file: 1. User right-clicks a type node → selects "Sort Members" 2. Extension reads the type's `range` from the symbol data @@ -327,9 +299,9 @@ Sort Members is a **source-editing action** — it modifies the source file, not The tree view auto-refreshes after the edit (existing `onDidChangeTextDocument` listener). -### Copy Qualified Name +### Copy Qualified Name `[SE-CONTEXT-COPY-QUALIFIED]` -Right-clicking any symbol node shows a **Copy Qualified Name** action that copies the fully-qualified name (`Namespace.Type.Member`) to the clipboard. +**Copy Qualified Name** copies `Namespace.Type.Member` for any symbol node. | Property | Value | |----------|-------| @@ -340,9 +312,9 @@ Right-clicking any symbol node shows a **Copy Qualified Name** action that copie The qualified name is built by walking the tree from the node to the root, collecting namespace and type names. -### Copy Name +### Copy Name `[SE-CONTEXT-COPY-NAME]` -Right-clicking any symbol, project, or solution node shows a **Copy Name** action that copies the unqualified name to the clipboard. +**Copy Name** copies the unqualified name of a symbol, project, or solution. | Property | Value | |----------|-------| @@ -351,9 +323,9 @@ Right-clicking any symbol, project, or solution node shows a **Copy Name** actio | When | `view == sharplsp.solutionExplorer && viewItem =~ /^(symbol\.\|solution\|project)/ ` | | Group | `9_cutcopypaste` | -### Reveal in File Explorer +### Reveal in File Explorer `[SE-CONTEXT-REVEAL]` -Right-clicking a symbol node shows a **Reveal in File Explorer** action that reveals the file containing the symbol in the VS Code file explorer. +**Reveal in File Explorer** reveals a symbol's source file in VS Code's file explorer. | Property | Value | |----------|-------| @@ -362,9 +334,9 @@ Right-clicking a symbol node shows a **Reveal in File Explorer** action that rev | When | `view == sharplsp.solutionExplorer && viewItem =~ /^symbol\./ ` | | Group | `3_open` | -### Collapse All Children +### Collapse All Children `[SE-CONTEXT-COLLAPSE]` -Right-clicking any collapsible node shows a **Collapse All Children** action that collapses all descendant nodes. +**Collapse All Children** collapses every descendant of a collapsible node. | Property | Value | |----------|-------| @@ -373,13 +345,11 @@ Right-clicking any collapsible node shows a **Collapse All Children** action tha | When | `view == sharplsp.solutionExplorer` | | Group | `inline` | -## Build, Run, and Debug Actions - -The Solution Explorer provides direct access to common .NET CLI operations through context menus. +## Build, Run, and Debug Actions `[SE-ACTIONS]` -### Build and Rebuild +### Build and Rebuild `[SE-ACTIONS-BUILD]` -Right-clicking a solution or project node shows **Build** and **Rebuild** actions. +Solution and project nodes expose **Build** and **Rebuild**. | Property | Value | |----------|-------| @@ -401,9 +371,9 @@ Right-clicking a solution or project node shows **Build** and **Rebuild** action - Output appears in VS Code terminal - Progress notification shown during build -### Run and Debug +### Run and Debug `[SE-ACTIONS-RUN-DEBUG]` -Right-clicking a project node shows **Run** and **Debug** actions. +Project nodes expose **Run** and **Debug**. | Property | Value | |----------|-------| @@ -428,9 +398,7 @@ Right-clicking a project node shows **Run** and **Debug** actions. - Uses the `sharplsp` debug configuration type - Attaches debugger to the running process -### Configure Extra Arguments - -Users can configure extra arguments for dotnet commands via context menu or settings. +### Configure Extra Arguments `[SE-ACTIONS-ARGS]` | Property | Value | |----------|-------| @@ -450,7 +418,7 @@ Users can configure extra arguments for dotnet commands via context menu or sett - Per-project args stored in workspace state: `sharplsp.buildArgs.${projectPath}` and `sharplsp.runArgs.${projectPath}` - Global defaults configured via settings: - `sharplsp.build.extraArgs` — default args for all build operations - - `sharplsp.run.extraArgs` — default args for all run operations + - `sharplsp.run.extraArgs` — default args for all run operations - `sharplsp.test.extraArgs` — default args for test operations **Argument Precedence:** @@ -458,11 +426,11 @@ Users can configure extra arguments for dotnet commands via context menu or sett 2. Global setting `sharplsp.*.extraArgs` 3. No extra args (lowest priority) -## Solution Management +## Solution Management `[SE-SOLUTION]` -### Add Project to Solution +### Add Project to Solution `[SE-SOLUTION-ADD]` -Right-clicking a `.csproj` or `.fsproj` file in the VS Code file explorer shows **Add to Solution** when a solution is loaded. +When a solution is loaded, `.csproj` and `.fsproj` files expose **Add to Solution**. | Property | Value | |----------|-------| @@ -476,9 +444,9 @@ Right-clicking a `.csproj` or `.fsproj` file in the VS Code file explorer shows - Refreshes Solution Explorer after adding - Shows error if no solution is loaded -### Remove Project from Solution +### Remove Project from Solution `[SE-SOLUTION-REMOVE]` -Right-clicking a project node in the Solution Explorer shows **Remove from Solution**. +Project nodes expose **Remove from Solution**. | Property | Value | |----------|-------| @@ -492,9 +460,9 @@ Right-clicking a project node in the Solution Explorer shows **Remove from Solut - Runs `dotnet sln remove ` - Refreshes Solution Explorer after removing -### Context Value Mapping +### Context Value Mapping `[SE-CONTEXT-VALUES]` -To support scoped context menus, symbol nodes set `contextValue` based on their kind: +Nodes set `contextValue` by kind: | Symbol Kind | contextValue | |-------------|-------------| @@ -518,24 +486,15 @@ To support scoped context menus, symbol nodes set `contextValue` based on their | Project Reference | `projectReference` | | Dependency Folder | `dependencyFolder` | -## Navigation +## Navigation `[SE-NAVIGATION]` Clicking a symbol node opens the file and navigates to the symbol's declaration position. ## Active Editor Synchronization `[SE-ACTIVE-EDITOR-SYNC]` -The Solution Explorer MUST stay synchronized with the active text editor. When a -C# or F# document becomes active — opened, focused, or navigated to (Go to -Definition, Quick Open, tab switch) — the tree MUST reveal that document's node: -expand its ancestors, scroll it into view, and **select (highlight)** it. Example: -focusing `FSharpRename.fs` in the editor expands the tree to it and highlights it. -Switching the active editor re-syncs the selection to the new document. This -mirrors VS Code's built-in File Explorer `explorer.autoReveal` behaviour. +When a C# or F# document becomes active through open, focus, navigation, Quick Open, or tab switch, the tree MUST expand its ancestors, reveal its node, and select it without stealing focus. This editor-to-tree behavior is the inverse of [SE-CONTEXT-REVEAL]. -This is the inverse of [Reveal in File Explorer](#reveal-in-file-explorer) -(tree → editor); here the direction is **editor → tree**. - -### Requirements +### Requirements `[SE-ACTIVE-EDITOR-SYNC-REQUIREMENTS]` | # | Requirement | |---|-------------| @@ -546,14 +505,13 @@ This is the inverse of [Reveal in File Explorer](#reveal-in-file-explorer) | 5 | A setting (mirroring `explorer.autoReveal`, default **on**) MUST gate the behaviour so users can disable it. | | 6 | Revealing MUST NOT steal editor focus (`focus: false`) and MUST be a no-op when the active document has no corresponding node (e.g. files outside the loaded solution). | -Tracked in [issue #118](https://github.com/Nimblesite/SharpLsp/issues/118). - -## Key Files +## Key Files `[SE-FILES]` | File | Purpose | |------|---------| -| `editors/vscode/src/tree.ts` | Tree data provider, node construction, sorting | -| `editors/vscode/src/extension.ts` | Command registration, tree view creation | -| `editors/vscode/src/constants.ts` | Command and view ID constants | -| `editors/vscode/package.json` | VS Code contribution points | -| `src/workspace_symbols.rs` | Rust handler: sidecar solution model routing, tree-sitter symbol extraction | +| [tree.ts](../../src/editors/vscode/src/tree.ts) | Tree data provider, node construction, sorting | +| [extension.ts](../../src/editors/vscode/src/extension.ts) | Command registration, tree view creation | +| [constants.ts](../../src/editors/vscode/src/constants.ts) | Command and view ID constants | +| [package.json](../../src/editors/vscode/package.json) | VS Code contribution points | +| [workspace_symbols.rs](../../src/sharplsp/src/workspace_symbols.rs) | Rust handler: sidecar solution model routing, tree-sitter symbol extraction | +| [solution-explorer.test.ts](../../src/editors/vscode/src/test/suite/solution-explorer.test.ts) | Coarse tree, command, reactivity, and live-buffer coverage | diff --git a/docs/specs/VSCODE-REACTIVITY-SPEC.md b/docs/specs/VSCODE-REACTIVITY-SPEC.md index b99cc3e4..3ae7b8e4 100644 --- a/docs/specs/VSCODE-REACTIVITY-SPEC.md +++ b/docs/specs/VSCODE-REACTIVITY-SPEC.md @@ -1,22 +1,18 @@ -# VSCode Extension Reactivity Spec +# VSCode Extension Reactivity Spec `[VSCODE-REACTIVITY]` -**Status:** active -**Owner:** VSCode extension (`editors/vscode/src/`) -**Invariant (CLAUDE.md):** _"All screens MUST BE 100% reactive. If underlying data changes, the screen must be listening and update accordingly."_ +**Status:** active · **Owner:** VSCode extension (`src/editors/vscode/src/`) · **Invariant (CLAUDE.md):** _"All screens MUST BE 100% reactive. If underlying data changes, the screen must be listening and update accordingly."_ --- -## 1. Goal +## Goal `[VSCODE-REACTIVITY-GOAL]` -Every UI surface in the SharpLsp VSCode extension — webview panels, tree views, status bars, code lenses — must be a **pure projection of reactive state**. When the underlying data changes (whether by user action, LSP notification, file-system event, or another tool editing files on disk), **every surface reading that data must update automatically**, with no explicit refresh call from the user or from Claude. +Every webview, tree view, status bar, and code lens MUST be a projection of reactive state. A user action, LSP notification, file-system event, or external disk edit MUST update every dependent surface automatically; correctness MUST NOT depend on Refresh, reopening a panel, or changing focus. -A UI surface that requires the user to click Refresh, reopen a panel, or toggle focus to see current data is **broken** and must be fixed. +## Signal Primitives `[VSCODE-REACTIVITY-SIGNALS]` -## 2. Signal Primitives +The extension uses the in-repo `Signal` primitive in [`signals.ts`](../../src/editors/vscode/src/signals.ts); no external signal library is introduced. -The extension uses a single in-repo reactive primitive: the `Signal` class in [editors/vscode/src/signals.ts](../../editors/vscode/src/signals.ts). No external dependency (Preact Signals, alien-signals, SolidJS) is introduced — the native primitive is sufficient and keeps the bundle small. - -### Signal +### Signal `[VSCODE-REACTIVITY-SIGNALS-VALUE]` ```ts class Signal { @@ -27,7 +23,7 @@ class Signal { } ``` -### effect(fn) +### effect(fn) `[VSCODE-REACTIVITY-SIGNALS-EFFECT]` ```ts function effect(fn: () => void): () => void @@ -37,27 +33,28 @@ Runs `fn` once, tracks every `Signal.value` read during the call, and re-runs `f Use `effect()` for UI rendering code that reads multiple signals. Use `subscribe()` for imperative side-effects driven by a single signal. -## 3. Source-of-Truth Signals +## Source-of-Truth Signals `[VSCODE-REACTIVITY-STATE]` The extension maintains these **global signals** (module-level exports). Every UI surface that needs the data reads it from these, never from a local cache. | Signal | Module | Purpose | |--------|--------|---------| -| `client` | [state.ts](../../editors/vscode/src/state.ts) | Active LSP LanguageClient | -| `solutionPath` | [state.ts](../../editors/vscode/src/state.ts) | Absolute path of the loaded `.sln` or `.slnx` file | -| `symbolsState` | [state.ts](../../editors/vscode/src/state.ts) | `empty \| loaded \| error` union of workspace symbols | -| `sortOrder` | [state.ts](../../editors/vscode/src/state.ts) | Solution Explorer sort cycle | -| `projectDependencies` | [project-deps-store.ts](../../editors/vscode/src/project-deps-store.ts) | `Map` — PackageReferences & ProjectReferences per csproj/fsproj | +| `client` | [state.ts](../../src/editors/vscode/src/state.ts) | Active LSP LanguageClient | +| `solutionPath` | [state.ts](../../src/editors/vscode/src/state.ts) | Absolute path of the loaded `.sln` or `.slnx` file | +| `dotnetPath` | [state.ts](../../src/editors/vscode/src/state.ts) | Resolved .NET executable path | +| `symbolsState` | [state.ts](../../src/editors/vscode/src/state.ts) | `empty \| loaded \| error` union of workspace symbols | +| `sortOrder` | [state.ts](../../src/editors/vscode/src/state.ts) | Solution Explorer sort cycle | +| `projectDependencies` | [project-deps-store.ts](../../src/editors/vscode/src/project-deps-store.ts) | Authoritative `Map` state for PackageReferences and ProjectReferences; not memoization | -New source-of-truth state must be added to one of these modules (or a new peer module). It **must not** be shadowed by a local field in a UI component — UI components read signals directly. +New source-of-truth state MUST live in one of these modules or a peer store and MUST NOT be shadowed in a UI field. Derived flags and version strings, including package installation state, MUST be computed from live signals during rendering rather than stored in selection snapshots. -## 4. File-System Watchers Drive Derived State +## File-System Watchers Drive Derived State `[VSCODE-REACTIVITY-WATCHERS]` -State derived from files on disk is refreshed by a `vscode.workspace.createFileSystemWatcher` whose change events write to the corresponding signal. There is **no polling**, and the user never has to trigger a refresh manually. +State derived from disk is refreshed by [`project-deps-store.ts`](../../src/editors/vscode/src/project-deps-store.ts) watchers whose events write to the corresponding signal. A 250 ms mtime guard MAY cover missed events for already tracked projects; it MUST NOT replace event-driven updates or require manual refresh. -### Project-dependencies watcher +### Project-dependencies Watcher `[VSCODE-REACTIVITY-WATCHERS-PROJECTS]` -Registered once during `activate()` by [project-deps-store.ts](../../editors/vscode/src/project-deps-store.ts) on the glob: +Registered once during `activate()` by [project-deps-store.ts](../../src/editors/vscode/src/project-deps-store.ts) on the glob: ``` **/{*.csproj,*.fsproj,Directory.Packages.props} @@ -68,36 +65,45 @@ Registered once during `activate()` by [project-deps-store.ts](../../editors/vsc - `onDidDelete` → remove the entry - Directory.Packages.props changes → rescan every tracked project -### Contract: after any external csproj/fsproj write, every surface that reads `projectDependencies` re-renders within ~200 ms (debounce + VSCode FSW latency). +### Update Latency `[VSCODE-REACTIVITY-WATCHERS-LATENCY]` + +After an external `.csproj` or `.fsproj` write, every surface that reads `projectDependencies` MUST re-render within approximately 200 ms on the watcher path, including the 150 ms debounce, or within 400 ms when the 250 ms mtime guard detects a missed event. -## 5. UI Surfaces and Their Subscriptions +## UI Surfaces and Their Subscriptions `[VSCODE-REACTIVITY-SURFACES]` -### 5.1 Solution Explorer tree — [tree.ts](../../editors/vscode/src/tree.ts) +### Solution Explorer Tree `[VSCODE-REACTIVITY-SURFACES-TREE]` + +Implementation: [`tree.ts`](../../src/editors/vscode/src/tree.ts). `SolutionExplorerProvider` subscribes to: + - `symbolsState` → full rebuild - `sortOrder` → full rebuild - `projectDependencies` → full rebuild -The tree's Dependencies → Packages node reads the parsed package list from `projectDependencies.value.get(projectPath)`. **It does NOT call `parseProjectDependencies` directly.** The file watcher is the only code path that calls the parser. +The tree's Dependencies → Packages node reads `projectDependencies.value.get(projectPath)` and MUST NOT call `parseProjectDependencies` directly. Parsing and signal updates belong to the store's watcher, mtime-guard, and explicit rescan paths. -### 5.2 NuGet Browser panel — [nuget-browser.ts](../../editors/vscode/src/nuget-browser.ts) +### NuGet Browser Panel `[VSCODE-REACTIVITY-SURFACES-NUGET]` + +Implementation: [`nuget-browser.ts`](../../src/editors/vscode/src/nuget-browser.ts). `NuGetBrowserPanel` subscribes to: + - `projectDependencies` → reload installed packages via LSP (picks up external csproj edits) -The Install/Remove button label is driven by the csproj content as surfaced through `projectDependencies` plus the LSP's `sharplsp/nuget/installed` response. Editing the csproj on disk must flip the button without any user action. +The Install/Remove button label is driven by the csproj content as surfaced through `projectDependencies` plus the LSP's `sharplsp/nuget/installed` response. Editing the csproj on disk MUST flip the button without any user action. -## 6. DRY: one renderer, one icon +## Shared Rendering `[VSCODE-REACTIVITY-RENDERING]` -Identical visual elements must be rendered by a **single function**. Specifically: +Identical visual elements MUST use one renderer: - Every package row (Browse tab, Installed tab, details panel header) uses the same icon box structure, with the same `packageIconImg(pkg)` helper rendering the iconUrl `` overlay. Duplicated inline HTML for the same visual element is forbidden. - When a surface needs the same data shape as another (e.g. the Installed tab rendering the same row as Browse), the data is hydrated into the common shape (`NuGetSearchResult`) and passed to the single renderer. -## 7. Required Tests (non-negotiable) +## Required Tests `[VSCODE-REACTIVITY-TESTING]` + +Every reactive surface MUST have an end-to-end test that: -Every reactive surface must have an e2e test that: 1. Opens the surface with a known initial state. 2. Mutates the underlying source (file on disk, LSP state, etc.) _without calling any refresh API_. 3. Polls the surface and asserts the new state appears within a timeout. @@ -106,17 +112,8 @@ Current coverage: | Surface | Test | File | |---------|------|------| -| NuGet panel — Remove → Install on csproj edit | `panel reacts to external csproj edit (package removed)` | [nuget-browser.test.ts](../../editors/vscode/src/test/suite/nuget-browser.test.ts) | -| NuGet panel — Install → Remove on csproj edit | `panel reacts to external csproj edit (package added)` | [nuget-browser.test.ts](../../editors/vscode/src/test/suite/nuget-browser.test.ts) | -| NuGet details panel icon | `details panel renders package icon image when iconUrl present` | [nuget-browser.test.ts](../../editors/vscode/src/test/suite/nuget-browser.test.ts) | -| NuGet installed tab icons (DRY) | `installed tab renders icons (no DRY violation)` | [nuget-browser.test.ts](../../editors/vscode/src/test/suite/nuget-browser.test.ts) | -| Solution Explorer packages node | `Dependencies → Packages tree reacts to external csproj edit` | [solution-explorer.test.ts](../../editors/vscode/src/test/suite/solution-explorer.test.ts) | - -## 8. Anti-patterns (illegal) - -- **Caching data that has a reactive source** in a local field. If `projectDependencies` has the data, read it directly every render. -- **Calling a parser or disk read from a UI component.** Only the watcher/store module does that. -- **Exposing a manual Refresh button** as the primary way to sync state. Refresh buttons may exist as a user escape hatch; they must not be the load-bearing update mechanism. -- **Duplicated inline HTML for the same visual element.** Extract a helper. -- **Diverging representations** of the same data (e.g. a bespoke installed-row renderer alongside the main package-row renderer). -- **Snapshotting derived state into a stored object.** Example: storing `selectedPackage` with an `isInstalled` boolean baked in at selection time. The snapshot becomes stale the moment the underlying data changes. **Always derive boolean flags, version strings, and other derived fields from the live source-of-truth signal at render time.** The renderer is the only correct place to compute "is this package currently installed" — never the selection handler. +| NuGet panel — Remove → Install on csproj edit | `panel reacts to external csproj edit (package removed)` | [nuget-browser.test.ts](../../src/editors/vscode/src/test/suite/nuget-browser.test.ts) | +| NuGet panel — Install → Remove on csproj edit | `panel reacts to external csproj edit (package added)` | [nuget-browser.test.ts](../../src/editors/vscode/src/test/suite/nuget-browser.test.ts) | +| NuGet details panel icon | `details panel renders package icon image when iconUrl present` | [nuget-browser.test.ts](../../src/editors/vscode/src/test/suite/nuget-browser.test.ts) | +| NuGet installed tab icons (DRY) | `installed tab renders icons (no DRY violation)` | [nuget-browser.test.ts](../../src/editors/vscode/src/test/suite/nuget-browser.test.ts) | +| Solution Explorer packages node | `Dependencies → Packages tree reacts to external csproj edit` | [solution-explorer.test.ts](../../src/editors/vscode/src/test/suite/solution-explorer.test.ts) | diff --git a/editors/rider/src/main/resources/icons/forge.svg b/editors/rider/src/main/resources/icons/forge.svg deleted file mode 120000 index f6304b78..00000000 --- a/editors/rider/src/main/resources/icons/forge.svg +++ /dev/null @@ -1 +0,0 @@ -../../../../../../docs/designs/logo/vsix-activity-bar.svg \ No newline at end of file diff --git a/editors/vscode/.eslint.js b/editors/vscode/.eslint.js deleted file mode 100644 index 8065c235..00000000 --- a/editors/vscode/.eslint.js +++ /dev/null @@ -1,64 +0,0 @@ -const { masterRules, testOverrides } = require('../eslint-rules.cjs'); - -/** @type {import('eslint').Linter.Config} */ -module.exports = { - root: true, - ignorePatterns: ['out/', 'coverage/', '.eslintrc.js', 'scripts/', 'playwright.config.ts'], - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 2022, - sourceType: 'module', - project: './tsconfig.json', - }, - plugins: ['@typescript-eslint'], - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/strict-type-checked', - 'plugin:@typescript-eslint/stylistic-type-checked', - ], - rules: { - ...masterRules, - // Project-specific: VSIX uses interfaces for VSCode API compat - '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], - // Project-specific: VSIX uses function declarations for hoisting - 'func-style': ['error', 'declaration'], - // Project-specific: VSIX enforces tighter limits - 'max-lines': ['error', 300], - 'max-params': ['error', 3], - // Project-specific: class methods that implement TreeDataProvider - 'class-methods-use-this': ['error', { exceptMethods: ['getTreeItem'] }], - // Project-specific: enforce readonly parameters with VSIX-specific allow list - '@typescript-eslint/prefer-readonly-parameter-types': ['error', { - treatMethodsAsReadonly: true, - allow: [ - 'AbortController', - 'AbortSignal', - 'AgentTreeItem', - 'AgentsTreeProvider', - 'Buffer', - 'ChildProcess', - 'Error', - 'ExtensionContext', - 'LockTreeItem', - 'LocksTreeProvider', - 'MarkdownString', - 'MessageTreeItem', - 'MessagesTreeProvider', - 'ReadableStream', - 'ReadableStreamDefaultReader', - 'ReadableStreamReadResult', - 'Response', - 'StoreManager', - 'TreeItem', - 'Uint8Array', - 'WebviewPanel', - ], - }], - }, - overrides: [ - { - files: ['*.test.ts', '*.spec.ts'], - rules: testOverrides, - }, - ], -}; diff --git a/editors/vscode/src/test/suite/fsharp-lsp-codefixes.test.ts b/editors/vscode/src/test/suite/fsharp-lsp-codefixes.test.ts deleted file mode 100644 index 32e194c1..00000000 --- a/editors/vscode/src/test/suite/fsharp-lsp-codefixes.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import * as assert from 'node:assert/strict'; -import * as fs from 'node:fs'; -import * as vscode from 'vscode'; -import { closeAllEditors, pollUntilResult } from './test-helpers'; -import { - FSHARP_COLD_TIMEOUT_MS, - fsharpFixturePath, - openFSharpFixture, - positionOf, -} from './fsharp-helpers'; - -/** - * End-to-end coverage for the F# analyzer-backed code fixes (FSAC parity) against - * the REAL release LSP + FCS sidecar: - * - "Remove unused open" (SLSPF0102 → deletes the `open` line) - * - "Simplify name" (SLSPF0103 → strips the redundant qualifier prefix) - * - * `CodeFixes.fs` is a dedicated fixture (an unused `open System.Text` plus a - * redundantly-qualified `System.DateTime`). Each test drives several real user - * interactions (open → request code actions → inspect the resolved edit → apply → - * re-inspect) with many assertions per interaction. The fixture is snapshotted and - * restored on disk in teardown; edits are applied in-memory only. - */ - -const CODEFIX_FILE = 'CodeFixes.fs'; -const ORIGINAL = fs.readFileSync(fsharpFixturePath(CODEFIX_FILE), 'utf8'); - -function restoreFixture(): void { - fs.writeFileSync(fsharpFixturePath(CODEFIX_FILE), ORIGINAL, 'utf8'); -} - -/** Revert any in-memory edits so disk and the editor model agree for the next test. */ -async function revertDirtyEditors(): Promise { - for (const editor of vscode.window.visibleTextEditors) { - if (editor.document.isDirty) { - await vscode.window.showTextDocument(editor.document); - await vscode.commands.executeCommand('workbench.action.files.revert'); - } - } -} - -/** Extract a diagnostic's code as a plain string (handles string | number | object). */ -function codeOf(diagnostic: vscode.Diagnostic): string { - const code = diagnostic.code; - if (code === undefined || code === null) { - return ''; - } - if (typeof code === 'object') { - return String((code as { value: string | number }).value); - } - return String(code); -} - -/** - * Poll the code-action provider with `itemResolveCount` set so the returned - * actions carry their resolved `edit` (the LSP server defers edits to - * `codeAction/resolve`). - * - * A slow first request (cold FCS start; the interface-stub analysis is async) - * can exceed the poll interval, so the next poll cancels the in-flight request - * and `executeCodeActionProvider` rejects with "Canceled". That is a harness - * race, not a failure — swallow it and let the next (warm) poll succeed. - */ -async function resolvedQuickFixes( - uri: vscode.Uri, - range: vscode.Range, - predicate: (actions: vscode.CodeAction[]) => boolean, -): Promise { - return pollUntilResult( - async () => { - try { - return ( - (await vscode.commands.executeCommand( - 'vscode.executeCodeActionProvider', - uri, - range, - vscode.CodeActionKind.QuickFix.value, - 40, - )) ?? [] - ); - } catch { - return []; - } - }, - predicate, - FSHARP_COLD_TIMEOUT_MS, - 3_000, - ); -} - -suite('F# LSP — Code Fixes (FSAC parity)', () => { - suiteTeardown(async () => { - restoreFixture(); - await revertDirtyEditors(); - await closeAllEditors(); - }); - - teardown(async () => { - restoreFixture(); - await revertDirtyEditors(); - await closeAllEditors(); - }); - - test('"Remove unused open" is offered, resolves to an edit, and deletes the open line', async function () { - this.timeout(FSHARP_COLD_TIMEOUT_MS + 45_000); - - // Interaction 1 — open the fixture and request quick fixes on the unused open. - const { doc, uri } = await openFSharpFixture(CODEFIX_FILE); - const openLine = positionOf(doc, 'open System.Text').line; - const lineRange = doc.lineAt(openLine).range; - - const actions = await resolvedQuickFixes(uri, lineRange, (acts) => - acts.some((a) => a.title === 'Remove unused open'), - ); - - // Assertions on the offered action. - const remove = actions.find((a) => a.title === 'Remove unused open'); - assert.ok(remove, 'a "Remove unused open" quick fix must be offered on the unused open'); - assert.strictEqual( - remove?.kind?.value, - vscode.CodeActionKind.QuickFix.value, - 'the action must be a QuickFix', - ); - assert.ok(remove?.edit, 'the action must resolve to a WorkspaceEdit (codeAction/resolve)'); - - // Assertions on the resolved edit shape — one deletion spanning the whole line. - const edits = remove.edit.get(uri); - assert.strictEqual(edits.length, 1, 'remove-unused-open must be a single text edit'); - assert.strictEqual(edits[0]?.newText, '', 'the edit must be a deletion (empty new text)'); - assert.strictEqual(edits[0]?.range.start.line, openLine, 'deletion starts on the open line'); - assert.strictEqual(edits[0]?.range.start.character, 0, 'deletion starts at column 0'); - assert.strictEqual( - edits[0]?.range.end.line, - openLine + 1, - 'deletion ends at the start of the next line (removes the whole line)', - ); - - // Interaction 2 — apply the fix and assert the document transformation. - assert.ok(doc.getText().includes('open System.Text'), 'precondition: the unused open exists'); - const applied = await vscode.workspace.applyEdit(remove.edit); - assert.ok(applied, 'applyEdit must succeed'); - - const after = doc.getText(); - assert.ok(!after.includes('open System.Text'), 'the unused open must be removed'); - assert.ok(after.includes('open System\n'), 'the still-used `open System` must remain'); - assert.ok(after.includes('DateTime.Now'), 'unrelated code must be untouched'); - }); - - test('"Simplify name" is offered, resolves to an edit, and strips the redundant qualifier', async function () { - this.timeout(FSHARP_COLD_TIMEOUT_MS + 45_000); - - // Interaction 1 — open and request quick fixes on the redundantly-qualified name. - const { doc, uri } = await openFSharpFixture(CODEFIX_FILE); - const namePos = positionOf(doc, 'System.DateTime.MinValue'); - const nameRange = new vscode.Range(namePos, namePos.translate(0, 'System.DateTime'.length)); - - const actions = await resolvedQuickFixes(uri, nameRange, (acts) => - acts.some((a) => a.title === 'Simplify name'), - ); - - const simplify = actions.find((a) => a.title === 'Simplify name'); - assert.ok(simplify, 'a "Simplify name" quick fix must be offered on the redundant qualifier'); - assert.strictEqual(simplify?.kind?.value, vscode.CodeActionKind.QuickFix.value); - assert.ok(simplify?.edit, 'the action must resolve to a WorkspaceEdit'); - - // The resolved edit deletes exactly the redundant `System.` prefix. - const edits = simplify.edit.get(uri); - assert.strictEqual(edits.length, 1, 'simplify-name must be a single text edit'); - assert.strictEqual(edits[0]?.newText, '', 'the edit must delete the redundant prefix'); - assert.strictEqual( - doc.getText(edits[0].range), - 'System.', - 'the deleted span must be exactly the redundant `System.` qualifier', - ); - - // Interaction 2 — apply the fix and assert the simplification. - assert.ok(doc.getText().includes('System.DateTime.MinValue'), 'precondition holds'); - await vscode.workspace.applyEdit(simplify.edit); - const after = doc.getText(); - assert.ok( - after.includes('let minimum = DateTime.MinValue'), - 'name must simplify to DateTime.MinValue', - ); - assert.ok( - !after.includes('System.DateTime.MinValue'), - 'the redundant System. qualifier must be gone', - ); - }); - - test('the analyzer hints (SLSPF0102 unused open, SLSPF0103 simplify) surface as diagnostics', async function () { - this.timeout(FSHARP_COLD_TIMEOUT_MS + 45_000); - - // Interaction — open the fixture and wait for both analyzer hints to publish. - const { doc, uri } = await openFSharpFixture(CODEFIX_FILE); - const diagnostics = await pollUntilResult( - async () => vscode.languages.getDiagnostics(uri), - (diags) => - diags.some((d) => codeOf(d) === 'SLSPF0102') && - diags.some((d) => codeOf(d) === 'SLSPF0103'), - FSHARP_COLD_TIMEOUT_MS, - 2_000, - ); - - const unusedOpen = diagnostics.filter((d) => codeOf(d) === 'SLSPF0102'); - const simplify = diagnostics.filter((d) => codeOf(d) === 'SLSPF0103'); - - assert.ok(unusedOpen.length >= 1, 'the unused-open hint (SLSPF0102) must be reported'); - assert.ok(simplify.length >= 1, 'the simplify-name hint (SLSPF0103) must be reported'); - assert.ok( - unusedOpen.every((d) => d.severity === vscode.DiagnosticSeverity.Hint), - 'unused-open findings must be Hint severity', - ); - assert.ok( - simplify.every((d) => d.severity === vscode.DiagnosticSeverity.Hint), - 'simplify-name findings must be Hint severity', - ); - - // The unused-open hint must point at the `open System.Text` line. - const openLine = positionOf(doc, 'open System.Text').line; - assert.ok( - unusedOpen.some((d) => d.range.start.line === openLine), - 'the unused-open hint must mark the `open System.Text` line', - ); - }); -}); - -/** - * "Implement interface" — completes the F# stub-generation trio (union / record / - * interface) via FCS `InterfaceStubGenerator` ([FS-CODEFIX-INTERFACESTUB]). - * `Implement.fs` declares `interface IShape` on `Square` without implementing any - * member; the quick fix generates stubs for `Area` and `Name`. - */ -const IMPL_FILE = 'Implement.fs'; -const IMPL_ORIGINAL = fs.readFileSync(fsharpFixturePath(IMPL_FILE), 'utf8'); - -function restoreImplFixture(): void { - fs.writeFileSync(fsharpFixturePath(IMPL_FILE), IMPL_ORIGINAL, 'utf8'); -} - -suite('F# LSP — Implement Interface (FSAC parity)', () => { - suiteTeardown(async () => { - restoreImplFixture(); - await revertDirtyEditors(); - await closeAllEditors(); - }); - - teardown(async () => { - restoreImplFixture(); - await revertDirtyEditors(); - await closeAllEditors(); - }); - - test('"Implement interface" generates stubs for the unimplemented members', async function () { - this.timeout(FSHARP_COLD_TIMEOUT_MS + 45_000); - - // Interaction 1 — open the fixture and request quick fixes on the interface. - const { doc, uri } = await openFSharpFixture(IMPL_FILE); - const ifacePos = positionOf(doc, 'interface IShape', 'interface '.length); - const range = new vscode.Range(ifacePos, ifacePos.translate(0, 'IShape'.length)); - - const actions = await resolvedQuickFixes(uri, range, (acts) => - acts.some((a) => a.title === 'Implement interface'), - ); - - const impl = actions.find((a) => a.title === 'Implement interface'); - assert.ok(impl, 'must offer an "Implement interface" quick fix on the unimplemented interface'); - assert.strictEqual(impl?.kind?.value, vscode.CodeActionKind.QuickFix.value); - assert.ok(impl?.edit, 'the action must resolve to a WorkspaceEdit'); - - // The resolved edit must generate stubs covering both interface members. - const edits = impl.edit.get(uri); - assert.strictEqual(edits.length, 1, 'implement-interface must be a single insertion'); - assert.match(edits[0]?.newText ?? '', /member/, 'the stub must contain member declarations'); - assert.match(edits[0]?.newText ?? '', /Area/, 'the stub must implement Area'); - assert.match(edits[0]?.newText ?? '', /Name/, 'the stub must implement Name'); - - // Interaction 2 — apply the fix and assert the document gains both members. - const before = doc.getText(); - await vscode.workspace.applyEdit(impl.edit); - const after = doc.getText(); - assert.ok(after.length > before.length, 'applying the stub must add text'); - assert.ok( - after.includes('Area') && after.includes('Name') && after.includes('member'), - 'the document must now contain stub implementations for both members', - ); - }); -}); diff --git a/editors/vscode/src/test/suite/fsharp-lsp-intelligence.test.ts b/editors/vscode/src/test/suite/fsharp-lsp-intelligence.test.ts deleted file mode 100644 index 266d43b5..00000000 --- a/editors/vscode/src/test/suite/fsharp-lsp-intelligence.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import * as assert from 'node:assert/strict'; -import * as vscode from 'vscode'; -import { closeAllEditors, pollUntilResult } from './test-helpers'; -import { FSHARP_COLD_TIMEOUT_MS, openFSharpFixture, positionOf } from './fsharp-helpers'; - -/** - * Blanket end-to-end coverage for F# code-intelligence features: - * completion, signature help, rename, inlay hints, and code actions. - * - * These run against the REAL release-built LSP + FCS sidecar and the static F# - * fixture project. Several of these features are not yet implemented in the F# - * sidecar — those tests are EXPECTED to fail until the corresponding feature is - * built (drive each via /fix-bug). F# is a first-class citizen; it must reach - * and exceed C# parity. - */ - -suite('F# LSP — Completion', () => { - suiteTeardown(closeAllEditors); - teardown(closeAllEditors); - - test('member completion after `.` on a class instance', async function () { - this.timeout(FSHARP_COLD_TIMEOUT_MS + 30_000); - const usage = await openFSharpFixture('Usage.fs'); - // Cursor immediately after `greeter.` in `greeter.Greet alice.Name`. - const position = positionOf(usage.doc, 'greeter.Greet', 'greeter.'.length); - const labels = await pollCompletionLabels(usage.uri, position, (set) => set.has('Greet')); - assert.ok(labels.has('Greet'), 'completion after greeter. must include the Greet member'); - }); - - test('member completion after `.` on a record value', async function () { - this.timeout(FSHARP_COLD_TIMEOUT_MS + 30_000); - const usage = await openFSharpFixture('Usage.fs'); - const position = positionOf(usage.doc, 'alice.Name', 'alice.'.length); - const labels = await pollCompletionLabels( - usage.uri, - position, - (set) => set.has('Name') && set.has('Age'), - ); - assert.ok(labels.has('Name'), 'record completion must include Name'); - assert.ok(labels.has('Age'), 'record completion must include Age'); - }); - - test('module-qualified completion after `.`', async function () { - this.timeout(FSHARP_COLD_TIMEOUT_MS + 30_000); - const usage = await openFSharpFixture('Usage.fs'); - const position = positionOf(usage.doc, 'Geometry.totalArea shapes', 'Geometry.'.length); - const labels = await pollCompletionLabels( - usage.uri, - position, - (set) => set.has('totalArea') && set.has('area'), - ); - assert.ok(labels.has('area'), 'module completion must include area'); - assert.ok(labels.has('totalArea'), 'module completion must include totalArea'); - assert.ok(labels.has('describeParity'), 'module completion must include describeParity'); - }); - - test('completion items carry concrete F# symbol kinds', async function () { - this.timeout(FSHARP_COLD_TIMEOUT_MS + 30_000); - const usage = await openFSharpFixture('Usage.fs'); - const position = positionOf(usage.doc, 'alice.Name', 'alice.'.length); - const list = await pollCompletion(usage.uri, position, (l) => - l.items.some((i) => i.label.toString() === 'Name'), - ); - const name = list.items.find((i) => i.label.toString() === 'Name'); - assert.ok(name, 'Name completion item must be present'); - assert.strictEqual( - name?.kind, - vscode.CompletionItemKind.Field, - 'record field completion must be reported as a Field', - ); - }); -}); - -suite('F# LSP — Signature Help', () => { - suiteTeardown(closeAllEditors); - teardown(closeAllEditors); - - test('signature help inside a constructor call', async function () { - this.timeout(FSHARP_COLD_TIMEOUT_MS + 15_000); - const usage = await openFSharpFixture('Usage.fs'); - // Inside `Greeter("Hello")` — just after the opening paren. - const position = positionOf(usage.doc, 'Greeter("Hello")', 'Greeter('.length); - const help = await pollUntilResult( - async () => - (await vscode.commands.executeCommand( - 'vscode.executeSignatureHelpProvider', - usage.uri, - position, - '(', - )) ?? new vscode.SignatureHelp(), - (h) => h.signatures.length > 0, - FSHARP_COLD_TIMEOUT_MS, - 2_000, - ); - assert.ok( - help.signatures.length > 0, - 'signature help must surface at least one signature for the Greeter constructor', - ); - }); -}); - -suite('F# LSP — Rename', () => { - suiteTeardown(closeAllEditors); - teardown(closeAllEditors); - - test('rename a function updates the declaration and every use site', async function () { - this.timeout(FSHARP_COLD_TIMEOUT_MS + 30_000); - const library = await openFSharpFixture('Library.fs'); - const position = positionOf(library.doc, 'let area', 'let '.length); - const edit = await pollUntilResult( - async () => - (await vscode.commands.executeCommand( - 'vscode.executeDocumentRenameProvider', - library.uri, - position, - 'computeArea', - )) ?? new vscode.WorkspaceEdit(), - (e) => e.size > 0, - FSHARP_COLD_TIMEOUT_MS, - 2_000, - ); - assert.ok(edit.size > 0, 'rename must produce a workspace edit'); - const libEdits = edit.get(library.uri); - assert.ok( - libEdits.length >= 2, - `rename must touch the declaration and the use site (got ${libEdits.length} edits)`, - ); - assert.ok( - libEdits.every((e) => e.newText === 'computeArea'), - 'every rename edit must insert the new name', - ); - }); -}); - -suite('F# LSP — Inlay Hints', () => { - suiteTeardown(closeAllEditors); - teardown(closeAllEditors); - - test('type inlay hints appear on unannotated let bindings', async function () { - this.timeout(FSHARP_COLD_TIMEOUT_MS + 15_000); - const usage = await openFSharpFixture('Usage.fs'); - const fullRange = new vscode.Range( - new vscode.Position(0, 0), - new vscode.Position(usage.doc.lineCount, 0), - ); - const hints = await pollUntilResult( - async () => - (await vscode.commands.executeCommand( - 'vscode.executeInlayHintProvider', - usage.uri, - fullRange, - )) ?? [], - (items) => items.length >= 1, - FSHARP_COLD_TIMEOUT_MS, - 2_000, - ); - assert.ok(hints.length >= 1, `Usage.fs must surface ≥1 inlay hint, got ${hints.length}`); - const labels = hints.map(inlayLabel).join(' '); - assert.match(labels, /Greeter|float|string|int/, 'inlay hints must reveal inferred types'); - }); -}); - -suite('F# LSP — Code Actions', () => { - suiteTeardown(closeAllEditors); - teardown(closeAllEditors); - - test('offers a fix to ignore an implicitly-discarded result', async function () { - this.timeout(FSHARP_COLD_TIMEOUT_MS + 15_000); - // FS0020: result of an expression implicitly ignored. - const usage = await openFSharpFixture('Usage.fs'); - // Use the `parity` line region — request actions broadly across the file. - const range = new vscode.Range( - positionOf(usage.doc, 'let parity'), - positionOf(usage.doc, 'let parity').translate(0, 5), - ); - const actions = await pollUntilResult( - async () => - (await vscode.commands.executeCommand( - 'vscode.executeCodeActionProvider', - usage.uri, - range, - )) ?? [], - () => true, - 30_000, - 2_000, - ); - // This assertion documents the current behaviour: code actions must be a - // real array (the provider responds). Specific fixes are validated by the - // diagnostics-driven suite once FS0020 fixtures exist. - assert.ok(Array.isArray(actions), 'code action provider must respond with an array'); - }); -}); - -// ── Local helpers ───────────────────────────────────────────────── - -async function pollCompletion( - uri: vscode.Uri, - position: vscode.Position, - predicate: (list: vscode.CompletionList) => boolean, - timeoutMs: number = FSHARP_COLD_TIMEOUT_MS, -): Promise { - return pollUntilResult( - async () => - (await vscode.commands.executeCommand( - 'vscode.executeCompletionItemProvider', - uri, - position, - '.', - )) ?? new vscode.CompletionList(), - predicate, - timeoutMs, - 2_000, - ); -} - -async function pollCompletionLabels( - uri: vscode.Uri, - position: vscode.Position, - predicate: (labels: Set) => boolean, - timeoutMs: number = FSHARP_COLD_TIMEOUT_MS, -): Promise> { - const list = await pollCompletion( - uri, - position, - (l) => predicate(new Set(l.items.map((i) => i.label.toString()))), - timeoutMs, - ); - return new Set(list.items.map((i) => i.label.toString())); -} - -function inlayLabel(hint: vscode.InlayHint): string { - if (typeof hint.label === 'string') { - return hint.label; - } - return hint.label.map((part) => part.value).join(''); -} diff --git a/editors/vscode/test-fixtures/workspace/TestFixtures.sln b/editors/vscode/test-fixtures/workspace/TestFixtures.sln deleted file mode 100644 index 08d59bf3..00000000 --- a/editors/vscode/test-fixtures/workspace/TestFixtures.sln +++ /dev/null @@ -1,5 +0,0 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestFixtures", "TestFixtures.csproj", "{00000000-0000-0000-0000-000000000001}" -EndProject -Global -EndGlobal diff --git a/editors/vscode/test-fixtures/workspace/TestFixtures.slnx b/editors/vscode/test-fixtures/workspace/TestFixtures.slnx deleted file mode 100644 index b9b32715..00000000 --- a/editors/vscode/test-fixtures/workspace/TestFixtures.slnx +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/editors/vscode/test-fixtures/workspace/fsharp/Implement.fs b/editors/vscode/test-fixtures/workspace/fsharp/Implement.fs deleted file mode 100644 index a283c663..00000000 --- a/editors/vscode/test-fixtures/workspace/fsharp/Implement.fs +++ /dev/null @@ -1,12 +0,0 @@ -module FSharpFixtures.Implement - -/// An interface with two unimplemented members, used by the "Implement interface" -/// code-action test ([FS-CODEFIX-INTERFACESTUB]). -type IShape = - abstract member Area: unit -> float - abstract member Name: string - -/// Declares the interface but implements none of its members (FS0366). The -/// "Implement interface" quick fix generates stubs for Area and Name. -type Square() = - interface IShape diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh deleted file mode 100755 index 0ed31576..00000000 --- a/scripts/check-coverage.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -# agent-pmo:2efd847 -# check-coverage.sh — enforce and ratchet a coverage threshold -# -# Usage: check-coverage.sh -# -# Reads the threshold from coverage-thresholds.json for the given project key. -# - If actual < (threshold - TOLERANCE) → exit 1 (hard fail) -# - If actual > threshold → update the threshold (ratchet up) and exit 0 -# - Otherwise → exit 0 (within tolerance, no change) -# -# A 1pp tolerance is always applied when comparing against the stored -# threshold to absorb llvm-cov rounding / instrumentation noise across -# platforms. When ratcheting UP we also subtract 1pp from the rounded -# actual so the baseline we persist sits comfortably above the noise -# floor on the next run. -set -euo pipefail - -PROJECT="$1" -ACTUAL="$2" -THRESHOLDS="coverage-thresholds.json" -# Absolute percentage-point tolerance applied to every project. Must -# match the amount subtracted when ratcheting up (see below). -TOLERANCE=1 - -if [ ! -f "$THRESHOLDS" ]; then - echo "ERROR: $THRESHOLDS not found" >&2 - exit 1 -fi - -THRESHOLD=$(jq -r --arg p "$PROJECT" '.[$p].line_percent // empty' "$THRESHOLDS") -if [ -z "$THRESHOLD" ]; then - echo "ERROR: no threshold found for project '$PROJECT' in $THRESHOLDS" >&2 - exit 1 -fi - -# Guard: threshold must NEVER decrease from the baseline. -# On feature branches, use the merge-base with main so intermediate -# ratchets don't block subsequent commits with slightly lower coverage. -BASELINE_REF="HEAD" -MERGE_BASE=$(git merge-base HEAD main 2>/dev/null || true) -if [ -n "$MERGE_BASE" ] && [ "$(git rev-parse HEAD)" != "$MERGE_BASE" ]; then - BASELINE_REF="$MERGE_BASE" -fi -COMMITTED_THRESHOLD=$(git show "$BASELINE_REF":"$THRESHOLDS" 2>/dev/null | jq -r --arg p "$PROJECT" '.[$p].line_percent // empty' 2>/dev/null || true) -if [ -n "$COMMITTED_THRESHOLD" ]; then - REGRESSED=$(echo "$THRESHOLD < $COMMITTED_THRESHOLD" | bc -l) - if [ "$REGRESSED" -eq 1 ]; then - echo "FAIL: [$PROJECT] threshold was lowered from ${COMMITTED_THRESHOLD}% to ${THRESHOLD}% — coverage thresholds must NEVER decrease" >&2 - exit 1 - fi -fi - -EFFECTIVE_THRESHOLD=$(echo "$THRESHOLD - $TOLERANCE" | bc -l) -echo "[$PROJECT] coverage: ${ACTUAL}% (threshold: ${THRESHOLD}%, effective: ${EFFECTIVE_THRESHOLD}% with ${TOLERANCE}pp tolerance)" - -BELOW=$(echo "$ACTUAL < $EFFECTIVE_THRESHOLD" | bc -l) -if [ "$BELOW" -eq 1 ]; then - echo "FAIL: [$PROJECT] coverage ${ACTUAL}% dropped below effective threshold ${EFFECTIVE_THRESHOLD}% (stored: ${THRESHOLD}%)" >&2 - exit 1 -fi - -ABOVE=$(echo "$ACTUAL > $THRESHOLD" | bc -l) -if [ "$ABOVE" -eq 1 ]; then - # Ratchet to `actual - tolerance` (floored to 2dp) so the new baseline - # already bakes in the tolerance and cannot be undermined by a noisy - # follow-up run on a different platform. - NEW_THRESHOLD=$(echo "$ACTUAL" | jq -n --argjson tol "$TOLERANCE" 'input - $tol | . * 100 | floor | . / 100') - # Never ratchet DOWN the committed value even if (actual - tolerance) - # would do so: the committed threshold is the contractual floor. - RATCHET_OK=$(echo "$NEW_THRESHOLD > $THRESHOLD" | bc -l) - if [ "$RATCHET_OK" -eq 1 ]; then - echo "[$PROJECT] coverage improved! Ratcheting threshold: ${THRESHOLD}% → ${NEW_THRESHOLD}% (actual ${ACTUAL}% − ${TOLERANCE}pp)" - jq --arg p "$PROJECT" --argjson new "$NEW_THRESHOLD" '.[$p].line_percent = $new' "$THRESHOLDS" > tmp-thresholds.json - mv tmp-thresholds.json "$THRESHOLDS" - fi -fi diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/SidecarExtrasCoverageEndToEndTests.cs b/sidecars/SharpLsp.Sidecar.CSharp.Tests/SidecarExtrasCoverageEndToEndTests.cs deleted file mode 100644 index 4ce3e02a..00000000 --- a/sidecars/SharpLsp.Sidecar.CSharp.Tests/SidecarExtrasCoverageEndToEndTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -using MessagePack; - -#pragma warning disable CA1307 // StringComparison for Assert.Contains -#pragma warning disable CA1515 // Types can be internal -#pragma warning disable IDE0058 // Expression value is never used - -namespace SharpLsp.Sidecar.CSharp.Tests; - -/// -/// Coarse E2E tests for handler paths the broad suites leave uncovered, driven -/// through the real sidecar socket via . -/// Currently: the analyzers/configure SUCCESS path — every other suite -/// only sends this handler malformed bytes (the error arm), so the acknowledge -/// arm (deserialize → WorkspaceManager.ConfigureAnalyzers → "ok") was -/// never exercised. -/// -[System.Diagnostics.CodeAnalysis.SuppressMessage( - "Reliability", - "CA2007:Consider calling ConfigureAwait on the awaited task", - Justification = "xUnit test methods run on the synchronization-context-free test pool" -)] -public sealed class SidecarExtrasCoverageEndToEndTests(CSharpSidecarFixture fixture) - : IClassFixture -{ - [Theory] - [InlineData(true, true)] - [InlineData(false, false)] - [InlineData(true, false)] - public async Task ConfigureAnalyzers_with_valid_request_acknowledges( - bool deadCode, - bool monorepo - ) - { - var response = await fixture.SendAsync( - "analyzers/configure", - MessagePackSerializer.Serialize( - new AnalyzerConfigRequest { DeadCode = deadCode, Monorepo = monorepo } - ) - ); - - Assert.Null(response.Error); - Assert.Equal("ok", MessagePackSerializer.Deserialize(response.Payload)); - - // The sidecar stays healthy after reconfiguring analyzers. - var ping = await fixture.SendAsync("ping", []); - Assert.Null(ping.Error); - Assert.Equal("pong", MessagePackSerializer.Deserialize(ping.Payload)); - } -} diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs b/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs deleted file mode 100644 index ad575b85..00000000 --- a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs +++ /dev/null @@ -1,371 +0,0 @@ -using System.Collections.Concurrent; -using System.Collections.Immutable; -using System.Reflection; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CodeActions; -using Microsoft.CodeAnalysis.CodeFixes; -using Microsoft.CodeAnalysis.CodeRefactorings; -using Microsoft.CodeAnalysis.Text; -using Serilog; - -namespace SharpLsp.Sidecar.CSharp.Workspace; - -/// -/// Discovers Roslyn code fix and refactoring providers via reflection, -/// enumerates available code actions for a range, and resolves them to edits. -/// -internal sealed class CodeActionResolver -{ - private static readonly Lazy> CachedFixProviders = new( - LoadFixProviders - ); - - private static readonly Lazy< - ImmutableArray - > CachedRefactoringProviders = new(LoadRefactoringProviders); - - private readonly ConcurrentDictionary _pendingActions = new(); - private int _nextId; - - /// - /// Get available code actions (fixes + refactorings) for a document range. - /// Caches the underlying CodeAction objects for subsequent resolve calls. - /// - public async Task> GetCodeActionsAsync( - Document document, - TextSpan span, - CancellationToken ct - ) - { - var items = new List(); - await CollectCodeFixesAsync(document, span, items, ct).ConfigureAwait(false); - await CollectRefactoringsAsync(document, span, items, ct).ConfigureAwait(false); - return items; - } - - /// - /// Resolve a previously cached code action by ID, returning workspace edits. - /// - public async Task ResolveAsync( - int actionId, - Solution originalSolution, - CancellationToken ct - ) - { - if (!_pendingActions.TryRemove(actionId, out var codeAction)) - { - return null; - } - - var operations = await codeAction.GetOperationsAsync(ct).ConfigureAwait(false); - var applyOp = operations.OfType().FirstOrDefault(); - return applyOp is null - ? new WorkspaceEditResult() - : await BuildWorkspaceEditAsync(originalSolution, applyOp.ChangedSolution, ct) - .ConfigureAwait(false); - } - - private async Task CollectCodeFixesAsync( - Document document, - TextSpan span, - List items, - CancellationToken ct - ) - { - var model = await document.GetSemanticModelAsync(ct).ConfigureAwait(false); - if (model is null) - { - return; - } - - var diagnostics = model - .GetDiagnostics(span, ct) - .Where(d => d.Severity != DiagnosticSeverity.Hidden) - .ToImmutableArray(); - - if (diagnostics.IsEmpty) - { - return; - } - - var diagById = diagnostics - .GroupBy(d => d.Id) - .ToDictionary(g => g.Key, g => g.ToImmutableArray()); - - foreach (var provider in CachedFixProviders.Value) - { - ct.ThrowIfCancellationRequested(); - await TryRegisterFixesAsync(provider, document, diagById, items, ct) - .ConfigureAwait(false); - } - } - - private async Task TryRegisterFixesAsync( - CodeFixProvider provider, - Document document, - Dictionary> diagById, - List items, - CancellationToken ct - ) - { - foreach (var fixableId in provider.FixableDiagnosticIds) - { - if (!diagById.TryGetValue(fixableId, out var matchingDiags)) - { - continue; - } - - foreach (var diag in matchingDiags) - { - try - { - var context = new CodeFixContext( - document, - diag, - (action, _) => CacheAndAdd(action, "quickfix", items), - ct - ); - await provider.RegisterCodeFixesAsync(context).ConfigureAwait(false); - } - catch (Exception ex) - { - Log.Debug( - ex, - "[CodeAction] Fix provider {Provider} failed", - provider.GetType().Name - ); - } - } - } - } - - private async Task CollectRefactoringsAsync( - Document document, - TextSpan span, - List items, - CancellationToken ct - ) - { - foreach (var provider in CachedRefactoringProviders.Value) - { - ct.ThrowIfCancellationRequested(); - try - { - var context = new CodeRefactoringContext( - document, - span, - action => CacheAndAdd(action, "refactor", items), - ct - ); - await provider.ComputeRefactoringsAsync(context).ConfigureAwait(false); - } - catch (Exception ex) - { - Log.Debug( - ex, - "[CodeAction] Refactoring provider {Provider} failed", - provider.GetType().Name - ); - } - } - } - - private void CacheAndAdd(CodeAction action, string kind, List items) - { - // Flatten nested actions (e.g. "Fix all occurrences in..."). - if (action.NestedActions.Length > 0) - { - foreach (var nested in action.NestedActions) - { - CacheAndAdd(nested, kind, items); - } - - return; - } - - var id = Interlocked.Increment(ref _nextId); - _pendingActions[id] = action; - items.Add( - new CodeActionItem - { - Id = id, - Title = action.Title, - Kind = kind, - IsPreferred = action.Priority == CodeActionPriority.High, - } - ); - } - - private static async Task BuildWorkspaceEditAsync( - Solution oldSolution, - Solution newSolution, - CancellationToken ct - ) - { - var result = new WorkspaceEditResult(); - var changes = newSolution.GetChanges(oldSolution); - - foreach (var projectChange in changes.GetProjectChanges()) - { - await CollectChangedDocumentsAsync(oldSolution, newSolution, projectChange, result, ct) - .ConfigureAwait(false); - await CollectAddedDocumentsAsync(newSolution, projectChange, result, ct) - .ConfigureAwait(false); - } - - return result; - } - - private static async Task CollectChangedDocumentsAsync( - Solution oldSolution, - Solution newSolution, - ProjectChanges projectChange, - WorkspaceEditResult result, - CancellationToken ct - ) - { - foreach (var docId in projectChange.GetChangedDocuments()) - { - var oldDoc = oldSolution.GetDocument(docId); - var newDoc = newSolution.GetDocument(docId); - if (oldDoc is null || newDoc is null) - { - continue; - } - - var edits = await DocumentText - .ComputeEditsAsync(oldDoc, newDoc, ct) - .ConfigureAwait(false); - if (edits.Count > 0 && newDoc.FilePath is not null) - { - result.DocumentChanges.Add( - new DocumentEditResult { FilePath = newDoc.FilePath, Edits = edits } - ); - } - } - } - - private static async Task CollectAddedDocumentsAsync( - Solution newSolution, - ProjectChanges projectChange, - WorkspaceEditResult result, - CancellationToken ct - ) - { - foreach (var docId in projectChange.GetAddedDocuments()) - { - var newDoc = newSolution.GetDocument(docId); - if (newDoc?.FilePath is null) - { - continue; - } - - var text = await newDoc.GetTextAsync(ct).ConfigureAwait(false); - result.DocumentChanges.Add( - new DocumentEditResult - { - FilePath = newDoc.FilePath, - Edits = - [ - new TextEditResult - { - StartLine = 0, - StartCharacter = 0, - EndLine = 0, - EndCharacter = 0, - NewText = text.ToString(), - }, - ], - } - ); - } - } - - private static ImmutableArray LoadFixProviders() - { - return DiscoverProviders(); - } - - private static ImmutableArray LoadRefactoringProviders() - { - return DiscoverProviders(); - } - - private static ImmutableArray DiscoverProviders() - where T : class - { - var providers = new List(); - foreach (var assembly in GetFeatureAssemblies()) - { - CollectProvidersFromAssembly(assembly, providers); - } - - Log.Debug( - "[CodeAction] Discovered {Count} {ProviderType} providers", - providers.Count, - typeof(T).Name - ); - return [.. providers]; - } - - private static void CollectProvidersFromAssembly(Assembly assembly, List providers) - where T : class - { - try - { - foreach (var type in assembly.DefinedTypes) - { - TryInstantiateProvider(type, providers); - } - } - catch (ReflectionTypeLoadException ex) - { - // Assembly has unresolvable types — skip it, noting why (file log only). - Log.Debug( - ex, - "[CodeAction] Skipped assembly {Assembly} (unresolvable types)", - assembly.GetName().Name - ); - } - } - - private static void TryInstantiateProvider( - System.Reflection.TypeInfo type, - List providers - ) - where T : class - { - if (type.IsAbstract || type.IsInterface || !typeof(T).IsAssignableFrom(type)) - { - return; - } - - try - { - if (Activator.CreateInstance(type) is T provider) - { - providers.Add(provider); - } - } - catch - { - // Some providers need MEF dependencies — skip them. - } - } - - private static Assembly[] GetFeatureAssemblies() - { - try - { - return - [ - Assembly.Load("Microsoft.CodeAnalysis.Features"), - Assembly.Load("Microsoft.CodeAnalysis.CSharp.Features"), - ]; - } - catch - { - return []; - } - } -} diff --git a/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs b/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs deleted file mode 100644 index 646f69c0..00000000 --- a/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs +++ /dev/null @@ -1,103 +0,0 @@ -/// References and document highlights for the F# sidecar. -/// References are project-wide ([FS-REFS-PROJECT]); highlights stay file-local. -module SharpLsp.Sidecar.FSharp.FSharpReferences - -open FSharp.Compiler.CodeAnalysis -open FSharp.Compiler.Symbols -open Serilog - -/// Result type for document highlights: location + read/write kind. -type HighlightLocation = - { FilePath: string - StartLine: int - StartCharacter: int - EndLine: int - EndCharacter: int - Kind: int } - -/// Check whether an FSharpSymbolUse represents a write (definition or pattern). -let private isWriteUse (su: FSharpSymbolUse) = - su.IsFromDefinition || su.IsFromPattern - -/// Resolve the symbol at a position and return all of its uses across the -/// loaded project. Falls back to current-file uses if the project check is -/// unavailable. Shared by references ([FS-REFS-PROJECT]), rename, and code lens. -let getProjectUsages - (state: FSharpWorkspace.FSharpWorkspaceState) - (filePath: string) - (line: int) - (character: int) - = - task { - try - let! fileCheck = FSharpWorkspace.checkFile state filePath - match fileCheck with - | None -> return [||] - | Some(checkResults, source) -> - match FSharpWorkspace.getSymbolUse checkResults source line character with - | None -> return [||] - | Some symbolUse -> - let! proj = FSharpWorkspace.checkProject state - match proj with - | Some projResults -> return projResults.GetUsesOfSymbol(symbolUse.Symbol) - | None -> return checkResults.GetUsesOfSymbolInFile(symbolUse.Symbol) - with ex -> - Log.Debug(ex, "[F# ProjectUsages] failed") - return [||] - } - -/// Find all references to the symbol at a position (project-wide). -let getReferences - (state: FSharpWorkspace.FSharpWorkspaceState) - (filePath: string) - (line: int) - (character: int) - (includeDeclaration: bool) - = - task { - let! uses = getProjectUsages state filePath line character - return - uses - |> Array.choose (fun (su: FSharpSymbolUse) -> - if not includeDeclaration && su.IsFromDefinition then None - else FSharpWorkspace.rangeToLocation su.Range) - |> Array.toList - } - -/// Find document highlights for the symbol at a position (current file only). -let getDocumentHighlights - (state: FSharpWorkspace.FSharpWorkspaceState) - (filePath: string) - (line: int) - (character: int) - = - task { - try - let! result = FSharpWorkspace.checkFile state filePath - match result with - | None -> return [] - | Some(checkResults, source) -> - match FSharpWorkspace.getSymbolUse checkResults source line character with - | None -> return [] - | Some symbolUse -> - let usesInFile = - checkResults.GetUsesOfSymbolInFile(symbolUse.Symbol) - return - usesInFile - |> Array.choose (fun (su: FSharpSymbolUse) -> - let r = su.Range - if r.FileName = "" then None - else - let kind = if isWriteUse su then 3 else 2 - Some - { FilePath = r.FileName - StartLine = r.StartLine - 1 - StartCharacter = r.StartColumn - EndLine = r.EndLine - 1 - EndCharacter = r.EndColumn - Kind = kind }) - |> Array.toList - with ex -> - Log.Debug(ex, "[F# DocumentHighlight] failed") - return [] - } diff --git a/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs b/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs deleted file mode 100644 index 1a397a51..00000000 --- a/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs +++ /dev/null @@ -1,115 +0,0 @@ -/// Rename + prepare-rename for the F# sidecar via FCS, project-wide. -/// Implements [FS-RENAME-PREPARE] / [FS-RENAME-APPLY]. -module SharpLsp.Sidecar.FSharp.FSharpRename - -open FSharp.Compiler.CodeAnalysis -open FSharp.Compiler.EditorServices -open FSharp.Compiler.Symbols -open FSharp.Compiler.Tokenization -open Serilog - -/// Prepare-rename result: the identifier token range + the symbol's current name. -type PrepareRename = - { StartLine: int - StartCharacter: int - EndLine: int - EndCharacter: int - Placeholder: string } - -/// A symbol is renameable only if it is declared in the project's own sources -/// (not the BCL / FSharp.Core / a NuGet dependency) and is not a namespace. -let private canRename (state: FSharpWorkspace.FSharpWorkspaceState) (symbol: FSharpSymbol) = - match symbol with - | :? FSharpEntity as ent when ent.IsNamespace -> false - | _ -> FSharpWorkspace.isSymbolInProject state symbol - -/// Pure prepare-rename computation over an already-checked file. Kept separate -/// from the `task` so the async wrapper has a single bind + single return — the -/// shape FCS can compile to a static state machine (avoids FS3511). -let private computePrepare - (state: FSharpWorkspace.FSharpWorkspaceState) - (checkResults: FSharpCheckFileResults) - (source: string) - (line: int) - (character: int) - : PrepareRename option = - match FSharpWorkspace.getSymbolUse checkResults source line character with - | Some su when canRename state su.Symbol -> - let lines = source.Split('\n') - let lineText = lines[line] - match QuickParse.GetCompleteIdentifierIsland true lineText character with - | Some(name, endCol, _) -> - Some - { StartLine = line - StartCharacter = max 0 (endCol - name.Length) - EndLine = line - EndCharacter = endCol - Placeholder = su.Symbol.DisplayName } - | None -> None - | _ -> None - -/// Check whether the symbol at a position can be renamed, returning its token range. -let prepareRename - (state: FSharpWorkspace.FSharpWorkspaceState) - (filePath: string) - (line: int) - (character: int) - = - task { - try - let! fileCheck = FSharpWorkspace.checkFile state filePath - return - fileCheck - |> Option.bind (fun (checkResults, source) -> - computePrepare state checkResults source line character) - with ex -> - Log.Debug(ex, "[F# PrepareRename] failed") - return None - } - -/// Build a replacement edit for one use of the symbol. Only the trailing -/// identifier segment is rewritten, so qualified uses (`Module.name`) keep the -/// qualifier and only `name` is replaced. -let private editForUse - (newName: string) - (displayName: string) - (su: FSharpSymbolUse) - : FSharpCodeActions.RawEdit option = - let r = su.Range - if r.FileName = "" then - None - else - Some - { FilePath = r.FileName - StartLine = r.EndLine - 1 - StartCharacter = max 0 (r.EndColumn - displayName.Length) - EndLine = r.EndLine - 1 - EndCharacter = r.EndColumn - NewText = newName } - -/// Rename the symbol at a position to `newName` across the whole project. -/// Returns the flat list of edits; the handler groups them per document. -let rename - (state: FSharpWorkspace.FSharpWorkspaceState) - (filePath: string) - (line: int) - (character: int) - (newName: string) - = - task { - try - let! fileCheck = FSharpWorkspace.checkFile state filePath - match fileCheck with - | None -> return [] - | Some(checkResults, source) -> - match FSharpWorkspace.getSymbolUse checkResults source line character with - | Some su when not (canRename state su.Symbol) -> return [] - | Some su -> - let! uses = FSharpReferences.getProjectUsages state filePath line character - let displayName = su.Symbol.DisplayName - return uses |> Array.choose (editForUse newName displayName) |> Array.toList - | None -> return [] - with ex -> - Log.Debug(ex, "[F# Rename] failed") - return [] - } diff --git a/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspace.fs b/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspace.fs deleted file mode 100644 index cf2a84c3..00000000 --- a/sidecars/SharpLsp.Sidecar.FSharp/FSharpWorkspace.fs +++ /dev/null @@ -1,720 +0,0 @@ -/// Manages the F# workspace: project loading and semantic queries via FCS. -module SharpLsp.Sidecar.FSharp.FSharpWorkspace - -open System -open System.Collections.Concurrent -open System.IO -open System.Reflection -open System.Threading -open System.Xml.Linq -open FSharp.Compiler.CodeAnalysis -open FSharp.Compiler.EditorServices -open FSharp.Compiler.Symbols -open FSharp.Compiler.Text -open FSharp.Compiler.Tokenization -open Serilog -open SharpLsp.Sidecar.Common -open SharpLsp.Sidecar.Common.Solutions -open SharpLsp.Sidecar.FSharp.Hover - -/// Definition result: file path + start line/col + end line/col (0-based). -type DefinitionLocation = - { FilePath: string - Line: int - Character: int - EndLine: int - EndCharacter: int } - -/// Workspace state holding the FSharpChecker and loaded project options. -[] -type FSharpWorkspaceState = - { Checker: FSharpChecker - mutable ProjectOptions: FSharpProjectOptions option - mutable IsLoaded: bool - /// In-memory document buffers keyed by absolute file path, kept current by - /// LSP `textDocument/didChange`. Per-file analyses read from here so hover, - /// completion, etc. reflect unsaved edits instead of stale on-disk text. - /// [FS-DIDCHANGE-OVERLAY] - Overlays: ConcurrentDictionary } - -/// Overlay keys compare case-insensitively on Windows, where hosts vary the -/// path's spelling (VS Code lowercases the drive letter while FCS and MSBuild -/// report it uppercase); elsewhere Ordinal respects case-sensitive -/// filesystems. [FS-DIDCHANGE-OVERLAY] -let private overlayComparer: StringComparer = - if OperatingSystem.IsWindows() then StringComparer.OrdinalIgnoreCase - else StringComparer.Ordinal - -/// Canonical overlay key via the shared `NativePaths` normalization: collapses -/// separator, relative-segment, and Windows extended-length (`\\?\`) spellings -/// so the didChange writer and every reader agree on one identity per file. -/// [FS-DIDCHANGE-OVERLAY] -let private overlayKey (filePath: string) : string = - NativePaths.NormalizeFullPath filePath - -/// Create a new workspace with an FSharpChecker. -let create () : FSharpWorkspaceState = - let checker = FSharpChecker.Create(keepAssemblyContents = true) - { Checker = checker - ProjectOptions = None - IsLoaded = false - Overlays = ConcurrentDictionary(overlayComparer) } - -/// Record the editor's in-memory buffer for a file (LSP didChange/didOpen). -/// Per-file FCS analyses then resolve positions against the live buffer rather -/// than the on-disk file, restoring parity with the C# sidecar. Keys are -/// canonicalized, so any spelling of the path the host sends on later requests -/// finds the buffer. [FS-DIDCHANGE-OVERLAY] -let applyDidChange (state: FSharpWorkspaceState) (filePath: string) (newText: string) = - state.Overlays[overlayKey filePath] <- newText - -/// Read a file's current source: the in-memory overlay when the editor has an -/// open buffer for it, otherwise the on-disk contents. [FS-DIDCHANGE-OVERLAY] -let internal readSource (state: FSharpWorkspaceState) (filePath: string) : string = - match state.Overlays.TryGetValue(overlayKey filePath) with - | true, text -> text - | _ -> File.ReadAllText filePath - -/// Resolve a request path onto the project's own spelling of the same file. -/// Hosts vary the spelling (VS Code lowercases the drive letter) and FCS -/// filename comparisons are case-sensitive: checking a file under a spelling -/// that differs from `ProjectOptions.SourceFiles` yields symbols whose -/// declaration ranges never match any project-wide use, so references, -/// rename, and code lens silently return nothing while single-file analyses -/// keep working. [FS-REFS-PROJECT] [GitHub #110] -let internal projectFilePath (state: FSharpWorkspaceState) (filePath: string) : string = - let normalized = NativePaths.NormalizeFullPath filePath - match state.ProjectOptions with - | Some options -> - options.SourceFiles - |> Array.tryFind (fun sourceFile -> NativePaths.AreEqual(sourceFile, normalized)) - |> Option.defaultValue normalized - | None -> normalized - -/// One FCS parse+check pass against explicit `options` — the single raw call -/// every per-file analysis funnels through, so overlay-aware source resolution -/// (the check reads the live didChange buffer, not stale on-disk text) and the -/// `ParseAndCheckFileInProject` invocation live in exactly one place instead of -/// being copy-pasted across hover, completion, diagnostics, and file-order -/// analysis. [FS-DIDCHANGE-OVERLAY] -let internal parseAndCheckOnce - (state: FSharpWorkspaceState) - (filePath: string) - (options: FSharpProjectOptions) - = - task { - let source = readSource state filePath - - let! parseResults, checkAnswer = - state.Checker.ParseAndCheckFileInProject( - filePath, 0, SourceText.ofString source, options) - - return parseResults, checkAnswer, source - } - -/// Interpret an FCS answer, logging parse diagnostics on `Aborted` so every -/// consumer keeps the diagnostic trail the old inline call sites had. -let private interpretAnswer - (parseResults: FSharpParseFileResults) - (checkAnswer: FSharpCheckFileAnswer) - (source: string) - = - match checkAnswer with - | FSharpCheckFileAnswer.Succeeded checkResults -> Some(parseResults, checkResults, source) - | FSharpCheckFileAnswer.Aborted -> - let diags = - parseResults.Diagnostics - |> Array.map (fun d -> $"{d.Severity}: {d.Message}") - |> String.concat "; " - - Log.Debug("[F# Check] aborted; parse diagnostics: {Diagnostics}", diags) - None - -/// Parse and check a file, returning parse results, check results, and the -/// source that was checked. The canonical per-file analysis entry point; -/// `checkFile` is the parse-less view. The check reads the live didChange -/// overlay, so a reverted or freshly edited buffer is always type-checked as -/// its newest text. [FS-DIDCHANGE-OVERLAY] -let internal checkFileWithParse (state: FSharpWorkspaceState) (filePath: string) = - task { - if not state.IsLoaded then - return None - else - let filePath = projectFilePath state filePath - - let! parseResults, checkAnswer, source = - parseAndCheckOnce state filePath state.ProjectOptions.Value - - return interpretAnswer parseResults checkAnswer source - } - -/// Parse and check a file, returning check results + source if successful. -let internal checkFile (state: FSharpWorkspaceState) (filePath: string) = - task { - let! result = checkFileWithParse state filePath - return result |> Option.map (fun (_parse, check, source) -> (check, source)) - } - -/// Parse an .fsproj file to extract Compile Include entries. -let internal parseFsprojSourceFiles (fsprojPath: string) : string array = - let doc = XDocument.Load(fsprojPath) - let projDir = Path.GetDirectoryName(fsprojPath) |> string - doc.Descendants(XName.Get("Compile")) - |> Seq.choose (fun el -> - match el.Attribute(XName.Get("Include")) |> Option.ofObj with - | None -> None - | Some attr -> Some(Path.GetFullPath(Path.Combine(projDir, string attr.Value)))) - |> Seq.toArray - -let private isFsprojPath (path: string) = - path.EndsWith(".fsproj", StringComparison.OrdinalIgnoreCase) - -let private isSolutionPath (path: string) = - path.EndsWith(".sln", StringComparison.OrdinalIgnoreCase) - || path.EndsWith(".slnx", StringComparison.OrdinalIgnoreCase) - -let private outcomeError (result: Outcome.Result) = - result.Match((fun _ -> String.Empty), (fun err -> err)) - -let private outcomeValue (result: Outcome.Result) : SolutionFileModel = - result.Match((fun value -> value), (fun err -> invalidOp err)) - -let private fsprojFilesFromSolution (path: string) (ct: CancellationToken) = - task { - let! readResult = SolutionFileReader.ReadAsync(path, ct) - if readResult.IsError then - return Error(outcomeError readResult) - else - let model = outcomeValue readResult - let fsprojs = - model.Projects - |> Seq.filter (fun (project: SolutionProjectEntry) -> isFsprojPath project.Path) - |> Seq.map (fun (project: SolutionProjectEntry) -> project.Path) - |> Seq.toArray - return Ok fsprojs - } - -let private discoverFsprojFiles (path: string) (ct: CancellationToken) = - task { - let fullPath = Path.GetFullPath(path) - if File.Exists(fullPath) && isFsprojPath fullPath then - return Ok [| fullPath |] - elif File.Exists(fullPath) && isSolutionPath fullPath then - return! fsprojFilesFromSolution fullPath ct - elif Directory.Exists(fullPath) then - return Ok(Directory.GetFiles(fullPath, "*.fsproj", SearchOption.AllDirectories)) - else - return Error $"Path does not exist: {path}" - } - -/// Build the shared compiler options for a netcore F# check: `--noframework`, -/// the managed framework reference assemblies from the runtime dir, and -/// FSharp.Core. Reused by both project loading and unused-package analysis. -let internal frameworkReferenceArgs () : string array = - // The runtime dir contains both managed and native DLLs (e.g. clretwrc.dll, - // coreclr.dll); skip native DLLs since FCS rejects them with "bad cli header". - let runtimeDir = Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory() - let isManagedAssembly (path: string) = - try - AssemblyName.GetAssemblyName(path) |> ignore - true - with _ -> false - let frameworkRefs = - Directory.GetFiles(runtimeDir, "*.dll") - |> Array.filter isManagedAssembly - |> Array.map (fun dll -> $"-r:{dll}") - // FSharp.Core is loaded by the sidecar itself; use that path — it's - // guaranteed to exist and be ABI-compatible with FCS. - let fsharpCorePath = typeof.Assembly.Location - let fsharpCoreRef = - if String.IsNullOrEmpty(fsharpCorePath) || not (File.Exists fsharpCorePath) then - [||] - else - [| $"-r:{fsharpCorePath}" |] - [| yield "--noframework" - yield "--targetprofile:netcore" - yield! frameworkRefs - yield! fsharpCoreRef |] - -/// Build the persistent FCS project options for an .fsproj: framework reference -/// assemblies + the project's restored NuGet package references ([FSharpAssets]) -/// + the project's compile sources. Including the package references is what -/// keeps a building project free of false unresolved-`open` / unknown-type -/// diagnostics — without them FCS cannot resolve any external reference and -/// flags every `open`/type as an error even though the project compiles (#120). -/// Shared with the unused-package analysis so the compiler sees one reference -/// set across diagnostics, hover, and usage. -let internal buildProjectOptions (state: FSharpWorkspaceState) (fsprojPath: string) : FSharpProjectOptions = - let sourceFiles = parseFsprojSourceFiles fsprojPath - - let packageRefs = - FSharpAssets.parseAssets fsprojPath - |> Option.map (snd >> FSharpAssets.packageReferenceArgs) - |> Option.defaultValue [||] - - // Cross-language project references: FCS does not resolve a `` - // to a C# project, so its types (e.g. a C#-defined class used from F#) stay - // unresolved and cross-language go-to-definition finds nothing. Wire each - // referenced C# project's built output DLL as a `-r:` reference so FCS - // resolves the symbol; FSharpMetadataNavigator then decompiles it to a - // navigable location. [DEFINITION-CROSSLANG] - let projectRefArgs = - ProjectReferences.ReadReferencedProjects(fsprojPath) - |> Seq.filter (fun proj -> proj.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) - |> Seq.choose (fun proj -> - ProjectReferences.FindOutputAssembly(proj) - |> Option.ofObj - |> Option.map (fun dll -> $"-r:{dll}")) - |> Seq.toArray - - let otherOptions = - Array.concat [ frameworkReferenceArgs (); packageRefs; projectRefArgs ] - // GetProjectOptionsFromCommandLineArgs deliberately returns SourceFiles = - // [||] (sources stay buried in OtherOptions), but project-wide analyses — - // ParseAndCheckProject for references/rename/code lens, and the - // isSymbolInProject rename gate — read options.SourceFiles. Leaving it - // empty makes every cross-file query silently return nothing, so populate - // it explicitly from the parsed compile items. [FS-REFS-PROJECT] - let options = - state.Checker.GetProjectOptionsFromCommandLineArgs( - fsprojPath, Array.append otherOptions sourceFiles) - { options with SourceFiles = sourceFiles } - -let private loadFirstProject (state: FSharpWorkspaceState) (fsprojFiles: string array) = - if fsprojFiles.Length = 0 then - Error "No .fsproj found" - else - try - let fsprojPath = Array.head fsprojFiles - if fsprojFiles.Length > 1 then - Log.Debug("F# workspace found {Count} projects; loading {Path}", fsprojFiles.Length, fsprojPath) - let options = buildProjectOptions state fsprojPath - state.ProjectOptions <- Some options - state.IsLoaded <- true - let fileList = String.Join(", ", options.SourceFiles |> Array.map Path.GetFileName) - Log.Debug("F# workspace loaded from {Path} with files: [{Files}]", fsprojPath, fileList) - Ok() - with ex -> - Error ex.Message - -let private isScriptPath (path: string) = - path.EndsWith(".fsx", StringComparison.OrdinalIgnoreCase) - || path.EndsWith(".fsscript", StringComparison.OrdinalIgnoreCase) - -/// Load an F# script (`.fsx`/`.fsscript`). -/// -/// FCS resolves `#r`, `#r "nuget:"`, `#I` and the `#load` closure itself, so no directive -/// parsing happens here — reimplementing it would duplicate the compiler. `useFsiAuxLib` -/// is what makes the `fsi` object (and therefore `fsi.CommandLineArgs`) bind. -/// Implements [FSX-OPTIONS]. -let private loadScript (state: FSharpWorkspaceState) (scriptPath: string) (ct: CancellationToken) = - task { - let text = - match state.Overlays.TryGetValue(overlayKey scriptPath) with - | true, overlay -> overlay - | _ -> File.ReadAllText scriptPath - - // A script open in an editor defines INTERACTIVE and EDITING; COMPILED is not - // defined. Getting this wrong greys out live `#if INTERACTIVE` blocks as dead - // code while they are live at run time. Implements [FSX-SYMBOLS]. - let otherFlags = [| "--define:INTERACTIVE"; "--define:EDITING" |] - - let computation = - state.Checker.GetProjectOptionsFromScript( - scriptPath, - SourceText.ofString text, - otherFlags = otherFlags, - useFsiAuxLib = true, - useSdkRefs = true, - assumeDotNetFramework = false) - - let! options, diagnostics = Async.StartAsTask(computation, cancellationToken = ct) - - for diagnostic in diagnostics do - Log.Debug("F# script option diagnostic for {Path}: {Message}", scriptPath, diagnostic.Message) - - state.ProjectOptions <- Some options - state.IsLoaded <- true - let fileList = String.Join(", ", options.SourceFiles |> Array.map Path.GetFileName) - Log.Debug("F# script loaded from {Path} with files: [{Files}]", scriptPath, fileList) - return Ok() - } - -/// Load a project from a path, explicit solution, or workspace directory. -let loadProjectWithCancellation - (state: FSharpWorkspaceState) - (path: string) - (ct: CancellationToken) - = - task { - try - // A script is self-describing and never belongs to an .fsproj, so it is - // dispatched before project discovery. Implements [SCRIPT-DETECT]. - if File.Exists(path) && isScriptPath path then - return! loadScript state (Path.GetFullPath path) ct - else - - let! discovered = discoverFsprojFiles path ct - match discovered with - | Error msg -> - Log.Debug("F# workspace diagnostic: {Message}", msg) - return Error msg - | Ok fsprojFiles -> - match loadFirstProject state fsprojFiles with - | Ok () -> return Ok() - | Error msg -> - Log.Debug("F# workspace load failed: {Message}", msg) - return Error msg - with ex -> - Log.Debug(ex, "F# workspace load failed") - return Error ex.Message - } - -/// Load a project from a path (finds .fsproj). -let loadProject (state: FSharpWorkspaceState) (path: string) = - loadProjectWithCancellation state path CancellationToken.None - -/// Extract hover from FSharpCheckFileResults. -let private extractToolTip - (checkResults: FSharpCheckFileResults) - (source: string) - (line: int) - (character: int) - : (string * int * int * int * int) option = - let lines = source.Split('\n') - if line >= lines.Length then - None - else - let lineText = lines[line] - // FCS uses 1-based lines. - let fcsLine = line + 1 - - // Find the identifier at the position. - let island = - QuickParse.GetCompleteIdentifierIsland true lineText character - - match island with - | None -> None - | Some(name, endCol, _) -> - let names = [ name ] - // GetToolTip expects colAtEndOfNames, not start position. - let tip = - checkResults.GetToolTip( - fcsLine, endCol, lineText, names, FSharpTokenTag.Identifier) - - match FSharpHoverBuilder.renderToolTip tip with - | Some markdown -> - Some(markdown, line, character, line, character + name.Length) - | None -> None - -/// Get hover information at a position in an F# file. -let getHover - (state: FSharpWorkspaceState) - (filePath: string) - (line: int) - (character: int) - = - task { - try - // Funnel through the canonical overlay-aware check. [FS-DIDCHANGE-OVERLAY] - let! checkData = checkFileWithParse state filePath - - return - checkData - |> Option.bind (fun (_parse, checkResults, source) -> - extractToolTip checkResults source line character) - with ex -> - Log.Debug(ex, "[F# Hover] failed") - return None - } - -// ── Definition ─────────────────────────────────────────────────── - -/// Check the whole loaded project (used for project-wide symbol queries: -/// references, rename, code lens, call hierarchy). FCS caches results keyed by -/// the project options, so repeat calls are cheap. -let internal checkProject (state: FSharpWorkspaceState) = - task { - if not state.IsLoaded then - return None - else - let! results = state.Checker.ParseAndCheckProject(state.ProjectOptions.Value) - return Some results - } - -/// Whether a symbol is declared inside the loaded project's own source files -/// (renameable), as opposed to the BCL / FSharp.Core / a NuGet dependency. -let internal isSymbolInProject - (state: FSharpWorkspaceState) - (symbol: FSharpSymbol) - : bool = - match symbol.DeclarationLocation, state.ProjectOptions with - | Some range, Some options when range.FileName <> "" -> - // Path identity via the shared helper: tolerant of casing and Windows - // extended-length (`\\?\`) spellings. [GitHub #110] - let target = NativePaths.NormalizeFullPath range.FileName - let inSourceFiles = - options.SourceFiles - |> Array.exists (fun file -> NativePaths.AreEqual(file, target)) - // Fall back to an on-disk F# source check: BCL / FSharp.Core / NuGet - // symbols have no source declaration, so this stays false for them. - let isSourceOnDisk = - (target.EndsWith(".fs", StringComparison.OrdinalIgnoreCase) - || target.EndsWith(".fsi", StringComparison.OrdinalIgnoreCase)) - && File.Exists(target) - inSourceFiles || isSourceOnDisk - | _ -> false - -// ── Shared helpers ────────────────────────────────────────────── - -/// Convert an FCS Range to a DefinitionLocation (1-based → 0-based). -let rangeToLocation (r: FSharp.Compiler.Text.Range) = - if r.FileName = "" then None - else - Some - { FilePath = r.FileName - Line = r.StartLine - 1 - Character = r.StartColumn - EndLine = r.EndLine - 1 - EndCharacter = r.EndColumn } - -/// Get the symbol use at a given 0-based position. -let internal getSymbolUse - (checkResults: FSharpCheckFileResults) - (source: string) - (line: int) - (character: int) - = - let lines = source.Split('\n') - if line >= lines.Length then None - else - let lineText = lines[line] - let fcsLine = line + 1 - let island = - QuickParse.GetCompleteIdentifierIsland true lineText character - match island with - | None -> None - | Some(name, endCol, _) -> - checkResults.GetSymbolUseAtLocation( - fcsLine, endCol, lineText, [ name ]) - -/// Extract the type entity from an FSharpType. -let private getTypeEntity (ty: FSharpType) = - if ty.HasTypeDefinition then Some ty.TypeDefinition - else None - -/// Metadata-as-source location for an external symbol (BCL / NuGet / a -/// referenced C# project). Decompiles the containing type and locates the -/// declaration. [DEFINITION-CROSSLANG] -let private metadataLocation (symbolUse: FSharpSymbolUse option) : DefinitionLocation option = - symbolUse - |> Option.bind (fun su -> FSharpMetadataNavigator.tryResolve su.Symbol) - |> Option.map (fun (filePath, startLine, startCol, endLine, endCol) -> - { FilePath = filePath - Line = startLine - Character = startCol - EndLine = endLine - EndCharacter = endCol }) - -/// FCS GetDeclarationLocation fallback (can follow into signature files) using -/// the identifier island's end column. -let private declarationLocationFallback - (checkResults: FSharpCheckFileResults) - (source: string) - (line: int) - (character: int) - : DefinitionLocation option = - let lines = source.Split('\n') - if line >= lines.Length then - None - else - let lineText = lines[line] - match QuickParse.GetCompleteIdentifierIsland true lineText character with - | None -> None - | Some(name, endCol, _) -> - match checkResults.GetDeclarationLocation(line + 1, endCol, lineText, [ name ]) with - | FindDeclResult.DeclFound declRange -> rangeToLocation declRange - | FindDeclResult.DeclNotFound _ - | FindDeclResult.ExternalDecl _ -> None - -/// Extract the declaration location for the symbol at a position. -/// Prefers the resolved FSharpSymbol's own source declaration — robust for -/// qualified names (Module.member), record fields, DU cases, and cross-file -/// symbols — then decompiled metadata-as-source for external symbols (a C# -/// project across the language boundary), and finally FCS GetDeclarationLocation. -let private extractDefinition - (checkResults: FSharpCheckFileResults) - (source: string) - (line: int) - (character: int) - : DefinitionLocation option = - let symbolUse = getSymbolUse checkResults source line character - let fromSource = - symbolUse - |> Option.bind (fun su -> su.Symbol.DeclarationLocation) - |> Option.bind rangeToLocation - fromSource - |> Option.orElseWith (fun () -> metadataLocation symbolUse) - |> Option.orElseWith (fun () -> declarationLocationFallback checkResults source line character) - -/// Get definition location at a position in an F# file. -let getDefinition - (state: FSharpWorkspaceState) - (filePath: string) - (line: int) - (character: int) - = - task { - try - let! result = checkFile state filePath - match result with - | Some(checkResults, source) -> - return extractDefinition checkResults source line character - | None -> - return None - with ex -> - Log.Debug(ex, "[F# Definition] failed") - return None - } - -// ── Type Definition ───────────────────────────────────────────── - -/// Extract type definition location from a symbol use. -let private extractTypeDefinition - (checkResults: FSharpCheckFileResults) - (source: string) - (line: int) - (character: int) - : DefinitionLocation option = - match getSymbolUse checkResults source line character with - | None -> None - | Some su -> - let typeEntity = - match su.Symbol with - | :? FSharpMemberOrFunctionOrValue as mfv -> - mfv.FullType |> getTypeEntity - | :? FSharpField as field -> - field.FieldType |> getTypeEntity - | :? FSharpEntity as ent -> Some ent - | _ -> None - match typeEntity with - | Some ent -> rangeToLocation ent.DeclarationLocation - | None -> None - -/// Get the type definition location at a position. -let getTypeDefinition - (state: FSharpWorkspaceState) - (filePath: string) - (line: int) - (character: int) - = - task { - try - let! result = checkFile state filePath - match result with - | Some(checkResults, source) -> - return extractTypeDefinition checkResults source line character - | None -> return None - with ex -> - Log.Debug(ex, "[F# TypeDefinition] failed") - return None - } - -// ── Declaration ───────────────────────────────────────────────── - -/// Find the interface or base member declaration for an override. -let private findBaseMember - (mfv: FSharpMemberOrFunctionOrValue) - : DefinitionLocation option = - if not mfv.IsOverrideOrExplicitInterfaceImplementation then - None - else - match mfv.DeclaringEntity with - | Some ent -> - let baseLoc = - ent.AllInterfaces - |> Seq.tryPick (fun iface -> - if not iface.HasTypeDefinition then None - else - iface.TypeDefinition.MembersFunctionsAndValues - |> Seq.tryFind (fun m -> - m.DisplayName = mfv.DisplayName) - |> Option.bind (fun m -> - rangeToLocation m.DeclarationLocation)) - baseLoc - | None -> None - -/// Extract declaration location (base/interface for overrides). -let private extractDeclaration - (checkResults: FSharpCheckFileResults) - (source: string) - (line: int) - (character: int) - : DefinitionLocation option = - match getSymbolUse checkResults source line character with - | None -> None - | Some su -> - match su.Symbol with - | :? FSharpMemberOrFunctionOrValue as mfv -> - match findBaseMember mfv with - | Some loc -> Some loc - | None -> rangeToLocation mfv.DeclarationLocation - | _ -> - extractDefinition checkResults source line character - -/// Get the declaration location at a position. -let getDeclaration - (state: FSharpWorkspaceState) - (filePath: string) - (line: int) - (character: int) - = - task { - try - let! result = checkFile state filePath - match result with - | Some(checkResults, source) -> - return extractDeclaration checkResults source line character - | None -> return None - with ex -> - Log.Debug(ex, "[F# Declaration] failed") - return None - } - -// ── Implementation ────────────────────────────────────────────── - -/// Extract implementations (fallback: symbol's own location). -let private extractImplementations - (checkResults: FSharpCheckFileResults) - (source: string) - (line: int) - (character: int) - : DefinitionLocation list = - match getSymbolUse checkResults source line character with - | None -> [] - | Some su -> - match su.Symbol.DeclarationLocation with - | Some declRange -> - match rangeToLocation declRange with - | Some loc -> [ loc ] - | None -> [] - | None -> [] - -/// Get implementation locations at a position. -let getImplementations - (state: FSharpWorkspaceState) - (filePath: string) - (line: int) - (character: int) - = - task { - try - let! result = checkFile state filePath - match result with - | Some(checkResults, source) -> - return extractImplementations checkResults source line character - | None -> return [] - with ex -> - Log.Debug(ex, "[F# Implementation] failed") - return [] - } diff --git a/editors/eslint-rules.cjs b/src/editors/eslint-rules.cjs similarity index 100% rename from editors/eslint-rules.cjs rename to src/editors/eslint-rules.cjs diff --git a/editors/rider/.gitignore b/src/editors/rider/.gitignore similarity index 100% rename from editors/rider/.gitignore rename to src/editors/rider/.gitignore diff --git a/editors/rider/build.gradle.kts b/src/editors/rider/build.gradle.kts similarity index 100% rename from editors/rider/build.gradle.kts rename to src/editors/rider/build.gradle.kts diff --git a/editors/rider/gradle.properties b/src/editors/rider/gradle.properties similarity index 100% rename from editors/rider/gradle.properties rename to src/editors/rider/gradle.properties diff --git a/editors/rider/gradle/wrapper/gradle-wrapper.jar b/src/editors/rider/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from editors/rider/gradle/wrapper/gradle-wrapper.jar rename to src/editors/rider/gradle/wrapper/gradle-wrapper.jar diff --git a/editors/rider/gradle/wrapper/gradle-wrapper.properties b/src/editors/rider/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from editors/rider/gradle/wrapper/gradle-wrapper.properties rename to src/editors/rider/gradle/wrapper/gradle-wrapper.properties diff --git a/editors/rider/gradlew b/src/editors/rider/gradlew similarity index 100% rename from editors/rider/gradlew rename to src/editors/rider/gradlew diff --git a/editors/rider/gradlew.bat b/src/editors/rider/gradlew.bat similarity index 100% rename from editors/rider/gradlew.bat rename to src/editors/rider/gradlew.bat diff --git a/editors/rider/settings.gradle.kts b/src/editors/rider/settings.gradle.kts similarity index 100% rename from editors/rider/settings.gradle.kts rename to src/editors/rider/settings.gradle.kts diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLsp4jServer.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLsp4jServer.kt similarity index 97% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLsp4jServer.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLsp4jServer.kt index 8baf14c4..60716460 100644 --- a/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLsp4jServer.kt +++ b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLsp4jServer.kt @@ -9,12 +9,13 @@ import java.util.concurrent.CompletableFuture * * JetBrains's LSP API lets us override `LspServerDescriptor.lsp4jServerClass` * with a subinterface of [LanguageServer] that adds `@JsonRequest` methods. - * Method names match exactly what the Rust host in `src/main.rs` routes + * Method names match exactly what the Rust host in `src/sharplsp/src/main.rs` routes * under `handle_custom_request()`. * * All DTO fields are camelCase to match the Rust wire format — no Gson * `@SerializedName` needed because the names already line up, and we'd * rather not take a transitive dependency on a specific Gson version. + * Implements [RIDER-LSP-INTERFACE]. */ interface ForgeLsp4jServer : LanguageServer { @JsonRequest("forge/workspaceSymbols") diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLspServerDescriptor.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLspServerDescriptor.kt similarity index 97% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLspServerDescriptor.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLspServerDescriptor.kt index f4c00888..7ca77592 100644 --- a/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLspServerDescriptor.kt +++ b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLspServerDescriptor.kt @@ -16,6 +16,7 @@ import java.nio.file.Paths * One descriptor instance per project. The platform keys servers by * `presentableName` equality, so we include the project's basePath to * guarantee one server per project. + * Implements [RIDER-LSP-DESCRIPTOR]. */ class ForgeLspServerDescriptor( project: Project, @@ -55,7 +56,7 @@ class ForgeLspServerDescriptor( * Resolve the `forge-lsp` binary path. * * Priority (matches the VS Code extension in - * `editors/vscode/src/install.ts`): + * `src/editors/vscode/src/install.ts`): * 1. `forge.server.path` project setting * 2. `~/.local/bin/forge-lsp` * 3. Anything on $PATH (best-effort via `which`) diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLspServerSupportProvider.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLspServerSupportProvider.kt similarity index 96% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLspServerSupportProvider.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLspServerSupportProvider.kt index 9f7ed320..50461b46 100644 --- a/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLspServerSupportProvider.kt +++ b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/lsp/ForgeLspServerSupportProvider.kt @@ -10,6 +10,7 @@ import com.intellij.platform.lsp.api.LspServerSupportProvider * * Registered via the `com.intellij.platform.lsp.serverSupportProvider` * extension point in `plugin.xml`. + * Implements [RIDER-LSP-PROVIDER]. */ class ForgeLspServerSupportProvider : LspServerSupportProvider { override fun fileOpened( diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/settings/ForgeSettings.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/settings/ForgeSettings.kt similarity index 97% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/settings/ForgeSettings.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/settings/ForgeSettings.kt index f0219db8..968e4f20 100644 --- a/editors/rider/src/main/kotlin/com/forgelsp/rider/settings/ForgeSettings.kt +++ b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/settings/ForgeSettings.kt @@ -14,6 +14,7 @@ import com.intellij.openapi.components.Storage * - `logLevel` — env var passed as RUST_LOG to forge-lsp. * - `autoLoadSolution` — whether to send `forge/loadSolution` on project * open if we can find a single .sln or .slnx in the project root. + * Implements [RIDER-SETTINGS]. */ @Service(Service.Level.PROJECT) @State( diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/settings/ForgeSettingsConfigurable.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/settings/ForgeSettingsConfigurable.kt similarity index 100% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/settings/ForgeSettingsConfigurable.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/settings/ForgeSettingsConfigurable.kt diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/ForgeSolutionToolWindow.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/ForgeSolutionToolWindow.kt similarity index 99% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/ForgeSolutionToolWindow.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/ForgeSolutionToolWindow.kt index a971bbf1..465179a5 100644 --- a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/ForgeSolutionToolWindow.kt +++ b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/ForgeSolutionToolWindow.kt @@ -41,6 +41,7 @@ import javax.swing.tree.TreePath * [DefaultMutableTreeNode] whose `userObject` is always a * [ForgeTreeNode] from the `nodes` package — the node objects encode * their own rendering, icons, and child-loading logic. + * Implements [RIDER-SOLUTION], [RIDER-SOLUTION-ASYNC], and [RIDER-SOLUTION-REFRESH]. */ class ForgeSolutionToolWindow( private val project: Project, diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/ForgeSolutionToolWindowFactory.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/ForgeSolutionToolWindowFactory.kt similarity index 100% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/ForgeSolutionToolWindowFactory.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/ForgeSolutionToolWindowFactory.kt diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/ForgeTreeActions.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/ForgeTreeActions.kt similarity index 100% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/ForgeTreeActions.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/ForgeTreeActions.kt diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/DependenciesNode.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/DependenciesNode.kt similarity index 100% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/DependenciesNode.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/DependenciesNode.kt diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/ForgeTreeNode.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/ForgeTreeNode.kt similarity index 100% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/ForgeTreeNode.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/ForgeTreeNode.kt diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/LeafNodes.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/LeafNodes.kt similarity index 100% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/LeafNodes.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/LeafNodes.kt diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/LspBridge.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/LspBridge.kt similarity index 100% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/LspBridge.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/LspBridge.kt diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/ProjectTreeNode.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/ProjectTreeNode.kt similarity index 100% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/ProjectTreeNode.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/ProjectTreeNode.kt diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/SolutionRootNode.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/SolutionRootNode.kt similarity index 100% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/SolutionRootNode.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/SolutionRootNode.kt diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/SourceNode.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/SourceNode.kt similarity index 100% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/SourceNode.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nodes/SourceNode.kt diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/ForgeNuGetBrowserPanel.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/ForgeNuGetBrowserPanel.kt similarity index 99% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/ForgeNuGetBrowserPanel.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/ForgeNuGetBrowserPanel.kt index f79e0909..0252ed1f 100644 --- a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/ForgeNuGetBrowserPanel.kt +++ b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/ForgeNuGetBrowserPanel.kt @@ -48,7 +48,7 @@ import javax.swing.border.EmptyBorder /** * Main UI for the Forge NuGet browser. Visual parity with the VS Code - * webview in `editors/vscode/src/nuget-browser/`. + * webview in `src/editors/vscode/src/nuget-browser/`. * * Layout: * diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/ForgeNuGetToolWindowFactory.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/ForgeNuGetToolWindowFactory.kt similarity index 100% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/ForgeNuGetToolWindowFactory.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/ForgeNuGetToolWindowFactory.kt diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/NuGetColors.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/NuGetColors.kt similarity index 100% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/NuGetColors.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/NuGetColors.kt diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/NuGetState.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/NuGetState.kt similarity index 100% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/NuGetState.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/NuGetState.kt diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/PackageCardRenderer.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/PackageCardRenderer.kt similarity index 100% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/PackageCardRenderer.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/PackageCardRenderer.kt diff --git a/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/PackageDetailsPanel.kt b/src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/PackageDetailsPanel.kt similarity index 100% rename from editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/PackageDetailsPanel.kt rename to src/editors/rider/src/main/kotlin/com/forgelsp/rider/toolwindow/nuget/PackageDetailsPanel.kt diff --git a/editors/rider/src/main/resources/META-INF/plugin.xml b/src/editors/rider/src/main/resources/META-INF/plugin.xml similarity index 100% rename from editors/rider/src/main/resources/META-INF/plugin.xml rename to src/editors/rider/src/main/resources/META-INF/plugin.xml diff --git a/src/editors/rider/src/main/resources/icons/forge.svg b/src/editors/rider/src/main/resources/icons/forge.svg new file mode 120000 index 00000000..f7d2ba6c --- /dev/null +++ b/src/editors/rider/src/main/resources/icons/forge.svg @@ -0,0 +1 @@ +../../../../../../../docs/designs/logo/vsix-activity-bar.svg \ No newline at end of file diff --git a/editors/vscode/.prettierrc.json b/src/editors/vscode/.prettierrc.json similarity index 100% rename from editors/vscode/.prettierrc.json rename to src/editors/vscode/.prettierrc.json diff --git a/editors/vscode/.vscode-test.mjs b/src/editors/vscode/.vscode-test.mjs similarity index 95% rename from editors/vscode/.vscode-test.mjs rename to src/editors/vscode/.vscode-test.mjs index 4ea79362..2c8ab2c4 100644 --- a/editors/vscode/.vscode-test.mjs +++ b/src/editors/vscode/.vscode-test.mjs @@ -6,7 +6,7 @@ import path from 'node:path'; // dir, not the repo-relative `.vscode-test/`. VS Code's main IPC handle is a // Unix domain socket (`/-main.sock`); on macOS/Linux the // `sun_path` limit is ~104 chars, so a deep checkout path (e.g. -// `~/Documents/Code/SharpLsp/editors/vscode/.vscode-test/...`) overflows it and +// `~/Documents/Code/SharpLsp/src/editors/vscode/.vscode-test/...`) overflows it and // the host dies at startup with `listen EINVAL` before any test runs. The OS // temp dir keeps the socket path well under the limit (and Windows uses named // pipes, so it's unaffected either way). Overridable via the env var. @@ -20,7 +20,7 @@ export default defineConfig({ extensionDevelopmentPath: '.', workspaceFolder: 'test-fixtures/workspace', // The extension declares ms-dotnettools.vscode-dotnet-runtime as an - // extensionDependency ([DIST-RUNTIME-ACQUIRE] / [SWR-IDE-DOTNET-RUNTIME]). + // extensionDependency ([DIST-RUNTIME-ACQUIRE]). // VS Code refuses to activate SharpLsp unless that dependency is installed // AND enabled in the test host. Installing it into the isolated test // extensions dir replaces the previous '--disable-extensions' flag, which diff --git a/editors/vscode/LICENSE b/src/editors/vscode/LICENSE similarity index 100% rename from editors/vscode/LICENSE rename to src/editors/vscode/LICENSE diff --git a/editors/vscode/README.ja.md b/src/editors/vscode/README.ja.md similarity index 100% rename from editors/vscode/README.ja.md rename to src/editors/vscode/README.ja.md diff --git a/editors/vscode/README.md b/src/editors/vscode/README.md similarity index 100% rename from editors/vscode/README.md rename to src/editors/vscode/README.md diff --git a/editors/vscode/README.zh-cn.md b/src/editors/vscode/README.zh-cn.md similarity index 100% rename from editors/vscode/README.zh-cn.md rename to src/editors/vscode/README.zh-cn.md diff --git a/editors/vscode/clean-out.mjs b/src/editors/vscode/clean-out.mjs similarity index 100% rename from editors/vscode/clean-out.mjs rename to src/editors/vscode/clean-out.mjs diff --git a/editors/vscode/esbuild.mjs b/src/editors/vscode/esbuild.mjs similarity index 100% rename from editors/vscode/esbuild.mjs rename to src/editors/vscode/esbuild.mjs diff --git a/editors/vscode/eslint.config.mjs b/src/editors/vscode/eslint.config.mjs similarity index 72% rename from editors/vscode/eslint.config.mjs rename to src/editors/vscode/eslint.config.mjs index 041f05fd..149e0a1c 100644 --- a/editors/vscode/eslint.config.mjs +++ b/src/editors/vscode/eslint.config.mjs @@ -28,13 +28,16 @@ export default tseslint.config( // ── 2. Strict null checks — no accidental undefined ──────────── '@typescript-eslint/no-non-null-assertion': 'error', - '@typescript-eslint/strict-boolean-expressions': ['error', { - allowNullableBoolean: false, - allowNullableString: false, - allowNullableNumber: false, - allowNullableObject: false, - allowAny: false, - }], + '@typescript-eslint/strict-boolean-expressions': [ + 'error', + { + allowNullableBoolean: false, + allowNullableString: false, + allowNullableNumber: false, + allowNullableObject: false, + allowAny: false, + }, + ], // ── 3. Exhaustive switches — catch missing enum cases ────────── '@typescript-eslint/switch-exhaustiveness-check': 'error', @@ -45,31 +48,40 @@ export default tseslint.config( 'no-void': ['error', { allowAsStatement: true }], // ── 5. No unused variables — dead code is a bug vector ───────── - '@typescript-eslint/no-unused-vars': ['error', { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - }], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], // ── 6. Prefer const — immutability by default ────────────────── 'prefer-const': 'error', // ── 7. Explicit return types — contracts must be clear ───────── - '@typescript-eslint/explicit-function-return-type': ['error', { - allowExpressions: true, - allowTypedFunctionExpressions: true, - allowHigherOrderFunctions: true, - }], + '@typescript-eslint/explicit-function-return-type': [ + 'error', + { + allowExpressions: true, + allowTypedFunctionExpressions: true, + allowHigherOrderFunctions: true, + }, + ], // ── 8. No shadow — inner vars must not hide outer vars ───────── '@typescript-eslint/no-shadow': 'error', 'no-shadow': 'off', // ── 9. Consistent type imports — enforce `import type` ───────── - '@typescript-eslint/consistent-type-imports': ['error', { - prefer: 'type-imports', - fixStyle: 'inline-type-imports', - }], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + fixStyle: 'inline-type-imports', + }, + ], // ── 10. No require — ESM only ───────────────────────────────── '@typescript-eslint/no-require-imports': 'error', @@ -84,18 +96,24 @@ export default tseslint.config( '@typescript-eslint/promise-function-async': 'error', // ── 14. Explicit visibility — no implicit public on class members ──── - '@typescript-eslint/explicit-member-accessibility': ['error', { - accessibility: 'explicit', - overrides: { constructors: 'no-public' }, - }], + '@typescript-eslint/explicit-member-accessibility': [ + 'error', + { + accessibility: 'explicit', + overrides: { constructors: 'no-public' }, + }, + ], // ── 15. No deprecated — flag usage of deprecated APIs immediately ──── '@typescript-eslint/no-deprecated': 'error', // ── 16. No type assertions — casting is illegal ───────────────── - '@typescript-eslint/consistent-type-assertions': ['error', { - assertionStyle: 'never', - }], + '@typescript-eslint/consistent-type-assertions': [ + 'error', + { + assertionStyle: 'never', + }, + ], // Conflicts with no-non-null-assertion — disable the weaker rule. '@typescript-eslint/non-nullable-type-assertion-style': 'off', @@ -107,7 +125,7 @@ export default tseslint.config( 'no-console': 'error', // ── Bonus rules ──────────────────────────────────────────────── - 'eqeqeq': ['error', 'always'], + eqeqeq: ['error', 'always'], 'no-param-reassign': 'error', '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], }, @@ -126,11 +144,14 @@ export default tseslint.config( '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }], - '@typescript-eslint/no-unused-vars': ['error', { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - }], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], '@typescript-eslint/require-await': 'off', '@typescript-eslint/dot-notation': 'off', '@typescript-eslint/use-unknown-in-catch-callback-variable': 'off', @@ -142,6 +163,18 @@ export default tseslint.config( }, }, { - ignores: ['out/', 'dist/', 'coverage/', 'node_modules/', '**/*.mjs', '**/*.cjs', '**/*.js'], + ignores: [ + 'out/', + 'dist/', + 'coverage/', + 'node_modules/', + // The VS Code build the test host downloads. Not our source, and it only + // exists on a machine that has run the VSIX suite — without this, `make + // lint` passes in CI and fails locally on thousands of vendored .d.ts. + '.vscode-test/', + '**/*.mjs', + '**/*.cjs', + '**/*.js', + ], }, ); diff --git a/editors/vscode/icons/sharplsp-activity.svg b/src/editors/vscode/icons/sharplsp-activity.svg similarity index 100% rename from editors/vscode/icons/sharplsp-activity.svg rename to src/editors/vscode/icons/sharplsp-activity.svg diff --git a/editors/vscode/icons/sharplsp.png b/src/editors/vscode/icons/sharplsp.png similarity index 100% rename from editors/vscode/icons/sharplsp.png rename to src/editors/vscode/icons/sharplsp.png diff --git a/editors/vscode/icons/sharplsp.svg b/src/editors/vscode/icons/sharplsp.svg similarity index 100% rename from editors/vscode/icons/sharplsp.svg rename to src/editors/vscode/icons/sharplsp.svg diff --git a/editors/vscode/language-configuration/csharp.json b/src/editors/vscode/language-configuration/csharp.json similarity index 100% rename from editors/vscode/language-configuration/csharp.json rename to src/editors/vscode/language-configuration/csharp.json diff --git a/editors/vscode/language-configuration/fsharp.json b/src/editors/vscode/language-configuration/fsharp.json similarity index 100% rename from editors/vscode/language-configuration/fsharp.json rename to src/editors/vscode/language-configuration/fsharp.json diff --git a/editors/vscode/package-lock.json b/src/editors/vscode/package-lock.json similarity index 98% rename from editors/vscode/package-lock.json rename to src/editors/vscode/package-lock.json index 6e37789f..dc992a52 100644 --- a/editors/vscode/package-lock.json +++ b/src/editors/vscode/package-lock.json @@ -762,9 +762,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -1661,9 +1661,9 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -1791,9 +1791,9 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -1958,9 +1958,9 @@ } }, "node_modules/@vscode/test-cli/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -2321,9 +2321,9 @@ } }, "node_modules/@vscode/vsce/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -2559,6 +2559,15 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -3493,9 +3502,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -3684,9 +3693,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -4035,9 +4044,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -5094,16 +5103,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimatch/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -5134,9 +5133,9 @@ "optional": true }, "node_modules/mocha": { - "version": "11.7.6", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz", - "integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==", + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.8.0.tgz", + "integrity": "sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==", "dev": true, "license": "MIT", "dependencies": { @@ -6843,9 +6842,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -7143,9 +7142,9 @@ } }, "node_modules/typescript-eslint/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -7186,9 +7185,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -7311,15 +7310,6 @@ "vscode": "^1.82.0" } }, - "node_modules/vscode-languageclient/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/vscode-languageclient/node_modules/minimatch": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", diff --git a/editors/vscode/package.json b/src/editors/vscode/package.json similarity index 97% rename from editors/vscode/package.json rename to src/editors/vscode/package.json index 36817148..4276d34a 100644 --- a/editors/vscode/package.json +++ b/src/editors/vscode/package.json @@ -1032,15 +1032,19 @@ "build": "node esbuild.mjs", "watch": "node esbuild.mjs --watch", "typecheck": "tsc --noEmit", - "lint": "eslint src/ && tsc --noEmit", + "lint": "node ../../../tools/npm/run-sequential.mjs lint:eslint typecheck", "lint:eslint": "eslint src/", - "compile-tests": "npm run clean && tsc -p ./", - "pretest": "node ../../scripts/resolve-symlink-stubs.mjs icons && npm run compile-tests && npm run build", + "compile-tests": "node ../../../tools/npm/run-sequential.mjs clean compile-tests:typescript", + "compile-tests:typescript": "tsc -p ./", + "prepare:test-assets": "node ../../../tools/vsix/resolve-symlink-stubs.mjs icons", + "pretest": "node ../../../tools/npm/run-sequential.mjs prepare:test-assets prepare:test-fixtures compile-tests build", "test": "vscode-test --coverage", "test:run": "node out/test/runTest.js", "package": "vsce package --no-dependencies", - "vscode:prepublish": "node ../../scripts/resolve-symlink-stubs.mjs icons && node esbuild.mjs --production", - "clean": "node clean-out.mjs" + "build:production": "node esbuild.mjs --production", + "vscode:prepublish": "node ../../../tools/npm/run-sequential.mjs prepare:test-assets build:production", + "clean": "node clean-out.mjs", + "prepare:test-fixtures": "node ../../../tools/vsix/build-test-fixtures.mjs" }, "dependencies": { "@nimblesite/shipwright-vscode": "^0.10.0", @@ -1077,7 +1081,7 @@ ], "overrides": { "serialize-javascript": "7.0.5", - "brace-expansion@^2.0.0": "2.0.3", - "brace-expansion@^5.0.0": "5.0.8" + "brace-expansion@^2.0.0": "2.1.4", + "brace-expansion@^5.0.0": "5.0.9" } } diff --git a/editors/vscode/package.nls.ja.json b/src/editors/vscode/package.nls.ja.json similarity index 100% rename from editors/vscode/package.nls.ja.json rename to src/editors/vscode/package.nls.ja.json diff --git a/editors/vscode/package.nls.json b/src/editors/vscode/package.nls.json similarity index 100% rename from editors/vscode/package.nls.json rename to src/editors/vscode/package.nls.json diff --git a/editors/vscode/package.nls.zh-cn.json b/src/editors/vscode/package.nls.zh-cn.json similarity index 100% rename from editors/vscode/package.nls.zh-cn.json rename to src/editors/vscode/package.nls.zh-cn.json diff --git a/editors/vscode/shipwright.json b/src/editors/vscode/shipwright.json similarity index 100% rename from editors/vscode/shipwright.json rename to src/editors/vscode/shipwright.json diff --git a/editors/vscode/src/build.ts b/src/editors/vscode/src/build.ts similarity index 100% rename from editors/vscode/src/build.ts rename to src/editors/vscode/src/build.ts diff --git a/editors/vscode/src/channel-guard.ts b/src/editors/vscode/src/channel-guard.ts similarity index 100% rename from editors/vscode/src/channel-guard.ts rename to src/editors/vscode/src/channel-guard.ts diff --git a/editors/vscode/src/client.ts b/src/editors/vscode/src/client.ts similarity index 96% rename from editors/vscode/src/client.ts rename to src/editors/vscode/src/client.ts index eff9797f..163fb69a 100644 --- a/editors/vscode/src/client.ts +++ b/src/editors/vscode/src/client.ts @@ -222,9 +222,17 @@ function resolveServerPath(context: ExtensionContext): string | undefined { return legacyBundled; } - // Dev fallback: look for a Cargo debug build two levels above the extension dir. - // Extension lives at /editors/vscode, so ../../target/debug/ is the repo build. - const devBuild = path.join(context.extensionPath, '..', '..', 'target', 'debug', binaryName); + // Dev fallback: look for a Cargo debug build three levels above the extension dir. + // Extension lives at /src/editors/vscode, so ../../../target/debug/ is the repo build. + const devBuild = path.join( + context.extensionPath, + '..', + '..', + '..', + 'target', + 'debug', + binaryName, + ); if (fs.existsSync(devBuild)) { return devBuild; } diff --git a/editors/vscode/src/config.ts b/src/editors/vscode/src/config.ts similarity index 100% rename from editors/vscode/src/config.ts rename to src/editors/vscode/src/config.ts diff --git a/editors/vscode/src/constants.ts b/src/editors/vscode/src/constants.ts similarity index 100% rename from editors/vscode/src/constants.ts rename to src/editors/vscode/src/constants.ts diff --git a/editors/vscode/src/debug.ts b/src/editors/vscode/src/debug.ts similarity index 98% rename from editors/vscode/src/debug.ts rename to src/editors/vscode/src/debug.ts index 5947df31..16ae4bbc 100644 --- a/editors/vscode/src/debug.ts +++ b/src/editors/vscode/src/debug.ts @@ -18,6 +18,7 @@ interface LaunchSettings { /** * Provides automatic launch configurations by discovering .csproj files * and integrating launchSettings.json profiles. + * Spec: [DEBUG-FEATURES-LAUNCH]. */ export class SharpLspLaunchProvider implements vscode.DebugConfigurationProvider { public resolveDebugConfiguration( @@ -105,6 +106,7 @@ export class SharpLspLaunchProvider implements vscode.DebugConfigurationProvider * Spawns netcoredbg as the debug adapter process. The debugger is bundled in the * VSIX ([DIST-DEBUGGER-BUNDLE]); `extensionPath` lets the resolver prefer that * bundled copy over a user-installed one. + * Spec: [DEBUG-ADAPTER-NETCOREDBG], [DEBUG-ARCHITECTURE-NETCOREDBG]. */ export class SharpLspDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory { constructor(private readonly extensionPath?: string) {} @@ -209,7 +211,7 @@ function findNetcoredbg(extensionPath?: string): string | undefined { * Platform-aware netcoredbg search paths, most-preferred first. When * `extensionPath` is supplied, the VSIX-bundled binary * (`bin//netcoredbg/netcoredbg[.exe]`, staged by - * `scripts/fetch-netcoredbg.sh`) is preferred over any user-installed copy. + * `tools/vsix/fetch-netcoredbg.sh`) is preferred over any user-installed copy. */ export function getNetcoredbgCandidates(extensionPath?: string): string[] { const home = process.env.HOME ?? process.env.USERPROFILE ?? ''; diff --git a/editors/vscode/src/dependencies.ts b/src/editors/vscode/src/dependencies.ts similarity index 100% rename from editors/vscode/src/dependencies.ts rename to src/editors/vscode/src/dependencies.ts diff --git a/editors/vscode/src/dotnetRuntime.ts b/src/editors/vscode/src/dotnetRuntime.ts similarity index 100% rename from editors/vscode/src/dotnetRuntime.ts rename to src/editors/vscode/src/dotnetRuntime.ts diff --git a/editors/vscode/src/extension.ts b/src/editors/vscode/src/extension.ts similarity index 99% rename from editors/vscode/src/extension.ts rename to src/editors/vscode/src/extension.ts index a5a280c2..f30f8884 100644 --- a/editors/vscode/src/extension.ts +++ b/src/editors/vscode/src/extension.ts @@ -1,3 +1,4 @@ +/** Implements [SE-COMMANDS], [SE-ACTIONS], [SE-SOLUTION], and [SE-NAVIGATION]. */ import * as path from 'node:path'; import * as vscode from 'vscode'; import { type ExtensionContext, commands, window, workspace } from 'vscode'; @@ -204,7 +205,7 @@ async function activateInner(context: ExtensionContext): Promise { log.traceInfo(`project-deps-store: node watcher error for ${projectPath}: ${err.message}`); diff --git a/editors/vscode/src/result.ts b/src/editors/vscode/src/result.ts similarity index 100% rename from editors/vscode/src/result.ts rename to src/editors/vscode/src/result.ts diff --git a/editors/vscode/src/scaffolding.ts b/src/editors/vscode/src/scaffolding.ts similarity index 100% rename from editors/vscode/src/scaffolding.ts rename to src/editors/vscode/src/scaffolding.ts diff --git a/editors/vscode/src/signals.ts b/src/editors/vscode/src/signals.ts similarity index 100% rename from editors/vscode/src/signals.ts rename to src/editors/vscode/src/signals.ts diff --git a/editors/vscode/src/solution.ts b/src/editors/vscode/src/solution.ts similarity index 100% rename from editors/vscode/src/solution.ts rename to src/editors/vscode/src/solution.ts diff --git a/editors/vscode/src/state.ts b/src/editors/vscode/src/state.ts similarity index 100% rename from editors/vscode/src/state.ts rename to src/editors/vscode/src/state.ts diff --git a/editors/vscode/src/status.ts b/src/editors/vscode/src/status.ts similarity index 100% rename from editors/vscode/src/status.ts rename to src/editors/vscode/src/status.ts diff --git a/editors/vscode/src/test-coverage.ts b/src/editors/vscode/src/test-coverage.ts similarity index 100% rename from editors/vscode/src/test-coverage.ts rename to src/editors/vscode/src/test-coverage.ts diff --git a/editors/vscode/src/test-discovery.ts b/src/editors/vscode/src/test-discovery.ts similarity index 100% rename from editors/vscode/src/test-discovery.ts rename to src/editors/vscode/src/test-discovery.ts diff --git a/editors/vscode/src/test-lens.ts b/src/editors/vscode/src/test-lens.ts similarity index 100% rename from editors/vscode/src/test-lens.ts rename to src/editors/vscode/src/test-lens.ts diff --git a/editors/vscode/src/test/runTest.ts b/src/editors/vscode/src/test/runTest.ts similarity index 100% rename from editors/vscode/src/test/runTest.ts rename to src/editors/vscode/src/test/runTest.ts diff --git a/editors/vscode/src/test/suite/00-vsix-dev-binary-staging.test.ts b/src/editors/vscode/src/test/suite/00-vsix-dev-binary-staging.test.ts similarity index 100% rename from editors/vscode/src/test/suite/00-vsix-dev-binary-staging.test.ts rename to src/editors/vscode/src/test/suite/00-vsix-dev-binary-staging.test.ts diff --git a/editors/vscode/src/test/suite/bundled-binary.test.ts b/src/editors/vscode/src/test/suite/bundled-binary.test.ts similarity index 94% rename from editors/vscode/src/test/suite/bundled-binary.test.ts rename to src/editors/vscode/src/test/suite/bundled-binary.test.ts index 76b91674..cd434cdd 100644 --- a/editors/vscode/src/test/suite/bundled-binary.test.ts +++ b/src/editors/vscode/src/test/suite/bundled-binary.test.ts @@ -1,3 +1,4 @@ +// End-to-end bundle verification for [BINARY-VERIFY]. import * as assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -58,7 +59,7 @@ suite('Bundled binary resolution', () => { // Implements [DIST-VSIX-ASSET-INTEGRITY]. The icon files are tracked as // symlinks into docs/designs/logo/; on checkouts without core.symlinks Git // materializes them as text stubs, which vsce would package as broken icons. -// `npm run pretest` resolves the stubs (scripts/resolve-symlink-stubs.mjs) +// `npm run pretest` resolves the stubs (tools/vsix/resolve-symlink-stubs.mjs) // before this suite runs, so a failure here means the resolver regressed or // was unwired. suite('[DIST-VSIX-ASSET-INTEGRITY] packaged icon assets', () => { @@ -80,7 +81,7 @@ suite('[DIST-VSIX-ASSET-INTEGRITY] packaged icon assets', () => { assert.ok( !looksLikeStub, `${name} is a symlink text stub — packaging would ship a broken icon. ` + - 'Run `node scripts/resolve-symlink-stubs.mjs editors/vscode/icons` ' + + 'Run `node tools/vsix/resolve-symlink-stubs.mjs src/editors/vscode/icons` ' + '(auto-run by npm pretest / vscode:prepublish).', ); if (name.endsWith('.png')) { @@ -108,7 +109,7 @@ function sanitizedEnv(): NodeJS.ProcessEnv { } function sidecarPathEntries(extensionPath: string): string[] { - const repoRoot = path.resolve(extensionPath, '..', '..'); + const repoRoot = path.resolve(extensionPath, '..', '..', '..'); return [ path.join(repoRoot, 'target', 'sidecar-csharp'), path.join(repoRoot, 'target', 'sidecar-fsharp'), diff --git a/editors/vscode/src/test/suite/bundled-sidecars.test.ts b/src/editors/vscode/src/test/suite/bundled-sidecars.test.ts similarity index 100% rename from editors/vscode/src/test/suite/bundled-sidecars.test.ts rename to src/editors/vscode/src/test/suite/bundled-sidecars.test.ts diff --git a/editors/vscode/src/test/suite/completions-visible.test.ts b/src/editors/vscode/src/test/suite/completions-visible.test.ts similarity index 100% rename from editors/vscode/src/test/suite/completions-visible.test.ts rename to src/editors/vscode/src/test/suite/completions-visible.test.ts diff --git a/editors/vscode/src/test/suite/context-menus.test.ts b/src/editors/vscode/src/test/suite/context-menus.test.ts similarity index 97% rename from editors/vscode/src/test/suite/context-menus.test.ts rename to src/editors/vscode/src/test/suite/context-menus.test.ts index 7e590005..20967d46 100644 --- a/editors/vscode/src/test/suite/context-menus.test.ts +++ b/src/editors/vscode/src/test/suite/context-menus.test.ts @@ -24,6 +24,7 @@ import { teardownLspTestSuite, waitForDocumentSymbols, } from './test-helpers'; +import { installUiStubs } from './ui-stubs'; // ── Shared interfaces ───────────────────────────────────────────── @@ -1896,11 +1897,15 @@ suite('Package Maintenance — Unused (LSP e2e)', () => { assert.ok(resp, 'unused must resolve — the Roslyn GetUsedAssemblyReferences pipeline ran'); assert.strictEqual(resp.projectPath, projectPath, 'projectPath is echoed back exactly'); assert.ok(Array.isArray(resp.unused), 'unused is an array'); - // TestFixtures declares no → nothing to flag. - assert.strictEqual( - resp.unused.length, - 0, - 'a project with no direct refs has no unused packages', + // TestFixtures declares exactly one direct — Serilog — and + // no source compiled into the project references it, so detection must flag + // precisely that package. Asserting the identity (not just a count) is what + // proves GetUsedAssemblyReferences actually ran: an empty result would also + // be produced by a pipeline that silently returned nothing. + assert.deepEqual( + resp.unused.map((pkg) => ({ id: pkg.id, version: pkg.version })), + [{ id: 'Serilog', version: '4.4.0' }], + 'the declared-but-unreferenced package is flagged, with its declared version', ); for (const pkg of resp.unused) { assert.strictEqual(typeof pkg.id, 'string', 'each unused id is a string'); @@ -1925,16 +1930,43 @@ suite('Package Maintenance — Unused (LSP e2e)', () => { const lsp = getPkgLspClient(); const projectPath = fixtureProjectPath(); const projectNode = { contextValue: 'project', projectFilePath: projectPath, children: [] }; + const before = fs.readFileSync(projectPath, 'utf8'); - await assert.doesNotReject(async () => { - await vscode.commands.executeCommand('sharplsp.removeUnusedPackages', projectNode); - }, 'the command must complete against the real LSP'); + // The fixture has a genuinely unused package, so the command reaches its + // modal confirmation. Dismiss it (the stub cancels by default) and assert the + // destructive path stayed shut — a checked-in fixture must survive the run. + const ui = installUiStubs(); + try { + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand('sharplsp.removeUnusedPackages', projectNode); + }, 'the command must complete against the real LSP'); + + assert.strictEqual(ui.log.warningMessages.length, 1, 'exactly one confirmation was shown'); + const prompt = ui.log.warningMessages[0] ?? ''; + assert.ok( + prompt.includes('Remove 1 unused package'), + `the prompt states the removal count, got: ${prompt}`, + ); + assert.ok(prompt.includes('Serilog'), 'the prompt names the package it would remove'); + assert.ok(prompt.includes('TestFixtures.csproj'), 'the prompt names the owning project'); + } finally { + ui.restore(); + } + + assert.strictEqual( + fs.readFileSync(projectPath, 'utf8'), + before, + 'cancelling the confirmation must leave the project file byte-identical', + ); // The detection truth the command relied on, asserted directly over the LSP. const resp = await lsp.sendRequest('sharplsp/nuget/unused', { projectPath }); assert.strictEqual(resp.projectPath, projectPath, 'projectPath echoed'); - assert.ok(Array.isArray(resp.unused), 'unused is an array'); - assert.strictEqual(resp.unused.length, 0, 'fixture project has nothing to remove'); + assert.deepEqual( + resp.unused.map((pkg) => pkg.id), + ['Serilog'], + 'the package the command offered to remove is still declared after cancelling', + ); }); test('consolidatePackages command runs end-to-end through the real LSP', async function () { diff --git a/editors/vscode/src/test/suite/coverage-extension-workflows.test.ts b/src/editors/vscode/src/test/suite/coverage-extension-workflows.test.ts similarity index 100% rename from editors/vscode/src/test/suite/coverage-extension-workflows.test.ts rename to src/editors/vscode/src/test/suite/coverage-extension-workflows.test.ts diff --git a/src/editors/vscode/src/test/suite/csharp-refactor-test-kit.ts b/src/editors/vscode/src/test/suite/csharp-refactor-test-kit.ts new file mode 100644 index 00000000..cb7cd028 --- /dev/null +++ b/src/editors/vscode/src/test/suite/csharp-refactor-test-kit.ts @@ -0,0 +1,515 @@ +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { + applyWorkspaceEdit, + replaceDocumentText, + revertDocument, + runEditorHistory, + sendRealLspRequest, + waitForCodeActions, + waitForMatchingDiagnostics, + waitForResolvedCodeActions, + type OpenFixture, + type WorkspaceEditSnapshot, +} from './refactor-test-helpers'; + +export interface RawCodeAction { + readonly title: string; + readonly kind?: string; + readonly isPreferred?: boolean; + readonly data?: { readonly id?: number; readonly uri?: string }; + readonly edit?: unknown; +} + +interface RawDiagnostic { + readonly range: { + readonly start: { readonly line: number; readonly character: number }; + readonly end: { readonly line: number; readonly character: number }; + }; + readonly severity?: number; + readonly code?: string | number | { readonly value?: string | number }; + readonly message: string; +} + +interface RawDiagnosticReport { + readonly kind: string; + readonly items: readonly RawDiagnostic[]; +} + +export interface OccurrenceExpectation { + readonly fragment: string; + readonly count: number; +} + +export interface ActionLifecycleCase { + readonly label: string; + readonly source: string; + readonly snippet: string; + readonly focus: string; + readonly title: string; + readonly kind: string; + readonly options?: readonly string[]; + readonly outsideSnippet?: string; + readonly postApplySnippet?: string; + readonly postApplyFocus?: string; + readonly presentAfter: readonly string[]; + readonly absentAfter: readonly string[]; + readonly patternsAfter?: readonly RegExp[]; + readonly occurrencesAfter?: readonly OccurrenceExpectation[]; + readonly diagnosticCode?: string; + readonly mustDisappear?: boolean; + readonly requeryTitleCount?: number; + readonly skipOutsideRange?: boolean; + /** + * Query with a collapsed caret at the focus start instead of a selection. + * Roslyn gates several member-level providers (Generate Equals, argument + * wrapping) on a caret; a selection suppresses them exactly as it does in VS. + */ + readonly caretOnly?: boolean; +} + +export function codeOf(diagnostic: vscode.Diagnostic): string { + const code = diagnostic.code; + if (typeof code === 'object' && code !== null) return String(code.value); + return code === undefined ? '' : String(code); +} + +function rawCodeOf(diagnostic: RawDiagnostic): string { + const code = diagnostic.code; + if (typeof code === 'object' && code !== null) return String(code.value ?? ''); + return code === undefined ? '' : String(code); +} + +function errorSignatures(diagnostics: readonly RawDiagnostic[]): string[] { + return diagnostics + .filter((diagnostic) => diagnostic.severity === vscode.DiagnosticSeverity.Error + 1) + .map((diagnostic) => `${rawCodeOf(diagnostic)}\u001f${diagnostic.message}`) + .sort(); +} + +function nthIndex(source: string, needle: string, occurrence: number): number { + let index = -1; + for (let count = 0; count <= occurrence; count += 1) { + index = source.indexOf(needle, index + 1); + if (index < 0) break; + } + assert.notStrictEqual(index, -1, `missing occurrence ${occurrence} of ${needle}`); + return index; +} + +export function positionOf( + document: vscode.TextDocument, + snippet: string, + focus: string = snippet, + occurrence = 0, +): vscode.Position { + const snippetIndex = nthIndex(document.getText(), snippet, occurrence); + const focusIndex = snippet.indexOf(focus); + assert.notStrictEqual(focusIndex, -1, `missing focus ${focus} in ${snippet}`); + return document.positionAt(snippetIndex + focusIndex); +} + +export function rangeOf( + document: vscode.TextDocument, + snippet: string, + focus: string = snippet, + occurrence = 0, +): vscode.Range { + const start = positionOf(document, snippet, focus, occurrence); + return new vscode.Range(start, start.translate(0, focus.length)); +} + +export function rangeAfterAction( + fixture: OpenFixture, + original: vscode.Range, + snippet?: string, + focus?: string, +): vscode.Range { + return snippet ? rangeOf(fixture.document, snippet, focus ?? snippet) : original; +} + +function toLspPosition(position: vscode.Position): { + readonly line: number; + readonly character: number; +} { + return { line: position.line, character: position.character }; +} + +export async function rawCodeActions( + uri: vscode.Uri, + range: vscode.Range, +): Promise { + return rawCodeActionsBetween(uri, range.start, range.end); +} + +async function rawCodeActionsBetween( + uri: vscode.Uri, + start: vscode.Position, + end: vscode.Position, +): Promise { + const result = await sendRealLspRequest('textDocument/codeAction', { + textDocument: { uri: uri.toString() }, + range: { start: toLspPosition(start), end: toLspPosition(end) }, + context: { diagnostics: [] }, + }); + assert.ok(Array.isArray(result), 'the real LSP must return a code-action array'); + return result; +} + +async function pullDiagnostics(uri: vscode.Uri): Promise { + const report = await sendRealLspRequest('textDocument/diagnostic', { + textDocument: { uri: uri.toString() }, + }); + assert.strictEqual(report.kind, 'full'); + assert.ok(Array.isArray(report.items), 'the real LSP must return diagnostic items'); + for (const diagnostic of report.items) assertDiagnosticShape(diagnostic); + return report.items; +} + +function assertDiagnosticShape(diagnostic: RawDiagnostic): void { + assert.ok(diagnostic.message.length > 0, 'every diagnostic must have a message'); + assert.ok(diagnostic.range.start.line >= 0 && diagnostic.range.start.character >= 0); + assert.ok(diagnostic.range.end.line >= diagnostic.range.start.line); + if (diagnostic.range.end.line === diagnostic.range.start.line) + assert.ok(diagnostic.range.end.character >= diagnostic.range.start.character); + if (diagnostic.severity !== undefined) + assert.ok(diagnostic.severity >= 1 && diagnostic.severity <= 4); +} + +export function onlyAction( + actions: readonly vscode.CodeAction[], + title: string, +): vscode.CodeAction { + const matches = actions.filter((action) => action.title === title); + assert.strictEqual(matches.length, 1, `expected exactly one action titled ${title}`); + const action = matches[0]; + assert.ok(action, `missing action titled ${title}`); + return action; +} + +export function assertRawActionData(actions: readonly RawCodeAction[], uri: vscode.Uri): void { + const ids = actions.map((action) => action.data?.id); + assert.ok(ids.every((id) => Number.isInteger(id) && (id ?? 0) > 0)); + assert.strictEqual(new Set(ids).size, ids.length, 'every action data id must be unique'); + assert.ok(actions.every((action) => action.data?.uri === uri.toString())); + assert.ok(actions.every((action) => typeof action.isPreferred === 'boolean')); +} + +export function assertFreshActionDataIds( + after: readonly RawCodeAction[], + before: readonly RawCodeAction[], +): void { + const oldIds = new Set(before.map((action) => action.data?.id)); + assert.ok( + after.every((action) => !oldIds.has(action.data?.id)), + 'requery must mint fresh data ids', + ); +} + +export function assertSingleDocumentEdit( + snapshots: readonly WorkspaceEditSnapshot[], + fixture: OpenFixture, +): void { + assert.strictEqual(snapshots.length, 1, 'a local action must change exactly one document'); + assert.strictEqual(snapshots[0]?.uri.toString(), fixture.uri.toString()); + assert.ok((snapshots[0]?.edits.length ?? 0) >= 1); + assert.ok((snapshots[0]?.replacedText.length ?? 0) >= 1); +} + +export function assertRawTitles( + actions: readonly RawCodeAction[], + titles: readonly string[], + kind: string, +): void { + const offered = actions.map((action) => `${action.kind}::${action.title}`).join(' | '); + for (const title of titles) { + const matches = actions.filter((action) => action.title === title); + assert.strictEqual( + matches.length, + 1, + `expected exactly one raw action titled ${title}; offered: ${offered === '' ? '(none)' : offered}`, + ); + assert.strictEqual(matches[0]?.kind, kind, `wrong kind for ${title}`); + assert.strictEqual(matches[0]?.edit, undefined, `${title} must initially be unresolved`); + } +} + +export function assertFragments( + source: string, + present: readonly string[], + absent: readonly string[], +): void { + for (const fragment of present) assert.ok(source.includes(fragment), fragment); + for (const fragment of absent) assert.ok(!source.includes(fragment), fragment); +} + +function vscodeKind(value: string): vscode.CodeActionKind { + switch (value) { + case 'refactor.extract': + return vscode.CodeActionKind.RefactorExtract; + case 'refactor.inline': + return vscode.CodeActionKind.RefactorInline; + case 'refactor.rewrite': + return vscode.CodeActionKind.RefactorRewrite; + case 'source.organizeImports': + return vscode.CodeActionKind.SourceOrganizeImports; + case 'quickfix': + return vscode.CodeActionKind.QuickFix; + default: + return vscode.CodeActionKind.Refactor; + } +} + +async function assertRequiredDiagnostic( + fixture: OpenFixture, + actionCase: ActionLifecycleCase, +): Promise { + if (actionCase.diagnosticCode === undefined) return; + const diagnostics = await waitForMatchingDiagnostics(fixture.uri, (items) => + items.some((item) => codeOf(item) === actionCase.diagnosticCode), + ); + const matches = diagnostics.filter((item) => codeOf(item) === actionCase.diagnosticCode); + assert.ok(matches.length >= 1, `missing ${actionCase.diagnosticCode}`); + assert.ok(matches.every((item) => item.message.length > 0)); + assert.ok(matches.every((item) => !item.range.isEmpty)); +} + +async function captureErrorBaseline(fixture: OpenFixture): Promise { + return errorSignatures(await pullDiagnostics(fixture.uri)); +} + +async function assertNoNewErrors(fixture: OpenFixture, baseline: readonly string[]): Promise { + const current = errorSignatures(await pullDiagnostics(fixture.uri)); + const newErrors = current.filter((signature) => !baseline.includes(signature)); + assert.deepStrictEqual(newErrors, [], 'the refactor must not introduce diagnostics errors'); +} + +async function assertBaselineRestored( + fixture: OpenFixture, + baseline: readonly string[], +): Promise { + assert.deepStrictEqual(errorSignatures(await pullDiagnostics(fixture.uri)), baseline); +} + +function middleOf(document: vscode.TextDocument, range: vscode.Range): vscode.Position { + const start = document.offsetAt(range.start); + const end = document.offsetAt(range.end); + return document.positionAt(start + Math.floor((end - start) / 2)); +} + +async function assertRawProbe( + fixture: OpenFixture, + actionCase: ActionLifecycleCase, + start: vscode.Position, + end: vscode.Position, +): Promise { + const actions = await rawCodeActionsBetween(fixture.uri, start, end); + assertRawActionData(actions, fixture.uri); + assert.ok(actions.every((action) => action.title.length > 0)); + assert.ok(actions.every((action) => action.edit === undefined)); + assert.ok(actions.filter((action) => action.title === actionCase.title).length <= 1); +} + +async function assertCaretProviderProbe( + fixture: OpenFixture, + actionCase: ActionLifecycleCase, + position: vscode.Position, +): Promise { + const actions = await waitForCodeActions({ + uri: fixture.uri, + range: new vscode.Range(position, position), + kind: vscodeKind(actionCase.kind), + predicate: () => true, + }); + assert.ok(actions.every((action) => action.title.length > 0)); +} + +async function assertBoundaryRanges( + fixture: OpenFixture, + actionCase: ActionLifecycleCase, + range: vscode.Range, +): Promise { + const middle = middleOf(fixture.document, range); + await assertRawProbe(fixture, actionCase, range.start, range.start); + await assertRawProbe(fixture, actionCase, middle, middle); + await assertRawProbe(fixture, actionCase, range.end, range.end); + await assertRawProbe(fixture, actionCase, range.end, range.start); + await assertCaretProviderProbe(fixture, actionCase, range.start); + await assertCaretProviderProbe(fixture, actionCase, range.end); +} + +async function assertOutsideActionRange( + fixture: OpenFixture, + actionCase: ActionLifecycleCase, +): Promise { + if (actionCase.skipOutsideRange) return; + const range = rangeOf(fixture.document, actionCase.outsideSnippet ?? 'namespace'); + const raw = await rawCodeActions(fixture.uri, range); + assert.ok(!raw.some((action) => action.title === actionCase.title)); + const actions = await waitForCodeActions({ + uri: fixture.uri, + range, + kind: vscodeKind(actionCase.kind), + predicate: () => true, + }); + assert.ok(!actions.some((action) => action.title === actionCase.title)); +} + +function discoveryRange( + document: vscode.TextDocument, + actionCase: ActionLifecycleCase, +): vscode.Range { + const range = rangeOf(document, actionCase.snippet, actionCase.focus); + return actionCase.caretOnly ? new vscode.Range(range.start, range.start) : range; +} + +async function discoverAction( + fixture: OpenFixture, + actionCase: ActionLifecycleCase, +): Promise<{ readonly range: vscode.Range; readonly raw: RawCodeAction[] }> { + const range = discoveryRange(fixture.document, actionCase); + const actions = await waitForCodeActions({ + uri: fixture.uri, + range, + kind: vscodeKind(actionCase.kind), + predicate: (items) => items.some((item) => item.title === actionCase.title), + }); + onlyAction(actions, actionCase.title); + const raw = await rawCodeActions(fixture.uri, range); + assertRawTitles(raw, actionCase.options ?? [actionCase.title], actionCase.kind); + assertRawActionData(raw, fixture.uri); + return { range, raw }; +} + +async function resolveAction( + fixture: OpenFixture, + actionCase: ActionLifecycleCase, + range: vscode.Range, +): Promise { + const actions = await waitForResolvedCodeActions({ + uri: fixture.uri, + range, + kind: vscodeKind(actionCase.kind), + predicate: (items) => items.some((item) => item.title === actionCase.title && item.edit), + }); + for (const title of actionCase.options ?? [actionCase.title]) onlyAction(actions, title); + const action = onlyAction(actions, actionCase.title); + assert.strictEqual(action.kind?.value, actionCase.kind); + assert.ok(action.edit, `${actionCase.title} must resolve to a WorkspaceEdit`); + return action.edit; +} + +async function applyAction( + fixture: OpenFixture, + actionCase: ActionLifecycleCase, + edit: vscode.WorkspaceEdit, +): Promise { + const version = fixture.document.version; + const snapshots = await applyWorkspaceEdit(edit); + assertSingleDocumentEdit(snapshots, fixture); + assert.ok(fixture.document.version > version); + assert.ok(fixture.document.isDirty); + const source = fixture.document.getText(); + assertFragments(source, actionCase.presentAfter, actionCase.absentAfter); + for (const pattern of actionCase.patternsAfter ?? []) assert.match(source, pattern); + for (const expected of actionCase.occurrencesAfter ?? []) { + assert.strictEqual(source.split(expected.fragment).length - 1, expected.count); + } + return source; +} + +async function assertActionRequery( + fixture: OpenFixture, + actionCase: ActionLifecycleCase, + originalRange: vscode.Range, + before: readonly RawCodeAction[], +): Promise { + const range = rangeAfterAction( + fixture, + originalRange, + actionCase.postApplySnippet, + actionCase.postApplyFocus, + ); + const after = await rawCodeActions(fixture.uri, range); + assertRawActionData(after, fixture.uri); + assertFreshActionDataIds(after, before); + const matches = after.filter((action) => action.title === actionCase.title); + if (actionCase.mustDisappear) assert.strictEqual(matches.length, 0); + if (actionCase.requeryTitleCount !== undefined) + assert.strictEqual(matches.length, actionCase.requeryTitleCount); + return after; +} + +async function undoToSource( + fixture: OpenFixture, + actionCase: ActionLifecycleCase, + before: readonly RawCodeAction[], +): Promise>> { + await runEditorHistory(fixture.document, 'undo', actionCase.source); + const discovered = await discoverAction(fixture, actionCase); + assertFreshActionDataIds(discovered.raw, before); + return discovered; +} + +async function assertRedo( + fixture: OpenFixture, + actionCase: ActionLifecycleCase, + transformed: string, + discovered: Awaited>, + baseline: readonly string[], +): Promise { + await runEditorHistory(fixture.document, 'redo', transformed); + const after = await assertActionRequery(fixture, actionCase, discovered.range, discovered.raw); + await assertNoNewErrors(fixture, baseline); + return after; +} + +async function retryAction( + fixture: OpenFixture, + actionCase: ActionLifecycleCase, + discovered: Awaited>, + transformed: string, +): Promise { + const edit = await resolveAction(fixture, actionCase, discovered.range); + assert.strictEqual(await applyAction(fixture, actionCase, edit), transformed); + await assertActionRequery(fixture, actionCase, discovered.range, discovered.raw); +} + +async function prepareSource( + fixture: OpenFixture, + actionCase: ActionLifecycleCase, +): Promise { + await replaceDocumentText(fixture.document, actionCase.source); + await assertRequiredDiagnostic(fixture, actionCase); + const baseline = await captureErrorBaseline(fixture); + await assertOutsideActionRange(fixture, actionCase); + return baseline; +} + +async function restoreCommitted(fixture: OpenFixture, committedText: string): Promise { + await revertDocument(fixture.document); + assert.strictEqual(fixture.document.getText(), committedText); + assert.ok(!fixture.document.isDirty); +} + +export async function exerciseCodeAction( + fixture: OpenFixture, + committedText: string, + actionCase: ActionLifecycleCase, +): Promise { + const baseline = await prepareSource(fixture, actionCase); + const discovered = await discoverAction(fixture, actionCase); + await assertBoundaryRanges(fixture, actionCase, discovered.range); + const edit = await resolveAction(fixture, actionCase, discovered.range); + const transformed = await applyAction(fixture, actionCase, edit); + await assertActionRequery(fixture, actionCase, discovered.range, discovered.raw); + await assertNoNewErrors(fixture, baseline); + const afterUndo = await undoToSource(fixture, actionCase, discovered.raw); + await assertBaselineRestored(fixture, baseline); + const afterRedo = await assertRedo(fixture, actionCase, transformed, afterUndo, baseline); + const retry = await undoToSource(fixture, actionCase, afterRedo); + await assertBaselineRestored(fixture, baseline); + await retryAction(fixture, actionCase, retry, transformed); + await assertNoNewErrors(fixture, baseline); + await restoreCommitted(fixture, committedText); +} diff --git a/src/editors/vscode/src/test/suite/csharp-rename-test-kit.ts b/src/editors/vscode/src/test/suite/csharp-rename-test-kit.ts new file mode 100644 index 00000000..7b87e050 --- /dev/null +++ b/src/editors/vscode/src/test/suite/csharp-rename-test-kit.ts @@ -0,0 +1,351 @@ +// Shared real-LSP rename driver for [RENAME-TESTS] and [RENAME-COVERAGE]. +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { positionOf, rangeOf } from './csharp-refactor-test-kit'; +import { + applyWorkspaceEdit, + openFixtureDocument, + preparedRenameAt, + revertDocument, + sendRealLspRequest, + type LspPosition, + type LspRange, + type OpenFixture, + type PrepareRenameResult, + type WorkspaceEditSnapshot, +} from './refactor-test-helpers'; +import { LSP_RESPONSE_TIMEOUT_MS, pollUntilResult } from './test-helpers'; + +export type RenameFixtureKey = 'symbols' | 'usage' | 'edge'; + +export interface RenameFixtureSet { + readonly symbols: OpenFixture; + readonly usage: OpenFixture; + readonly edge: OpenFixture; + readonly baselines: Readonly>; +} + +export interface RenameCase { + readonly label: string; + readonly fixture: RenameFixtureKey; + readonly snippet: string; + readonly focus?: string; + readonly occurrence?: number; + readonly oldName: string; + readonly newName: string; + readonly editCount: number; + readonly files: readonly RenameFixtureKey[]; + readonly after?: Readonly>>; +} + +export type { LspPosition, LspRange, PrepareRenameResult } from './refactor-test-helpers'; + +export interface RawRenameDocumentEdit { + readonly textDocument: { readonly uri: string; readonly version: number | null }; + readonly edits: readonly { readonly range: LspRange; readonly newText: string }[]; +} + +export interface RawRenameEdit { + readonly documentChanges?: readonly RawRenameDocumentEdit[]; +} + +export async function openRenameFixtures(): Promise { + const symbols = await openFixtureDocument('RenameSymbols.cs'); + const usage = await openFixtureDocument('RenameUsage.cs'); + const edge = await openFixtureDocument('RenameEdge.cs'); + return { + symbols, + usage, + edge, + baselines: { + symbols: symbols.document.getText(), + usage: usage.document.getText(), + edge: edge.document.getText(), + }, + }; +} + +export function fixtureOf(fixtures: RenameFixtureSet, key: RenameFixtureKey): OpenFixture { + return fixtures[key]; +} + +function lspPosition(position: vscode.Position): LspPosition { + return { line: position.line, character: position.character }; +} + +function lspRange(range: vscode.Range): LspRange { + return { start: lspPosition(range.start), end: lspPosition(range.end) }; +} + +export async function prepareAt( + uri: vscode.Uri, + position: vscode.Position, +): Promise { + return preparedRenameAt(uri, position); +} + +export async function waitForPrepare( + uri: vscode.Uri, + position: vscode.Position, + placeholder: string, +): Promise { + const result = await pollUntilResult( + async () => prepareAt(uri, position), + (item) => item?.placeholder === placeholder, + LSP_RESPONSE_TIMEOUT_MS, + 250, + ); + assert.ok(result, `prepareRename must return ${placeholder}`); + return result; +} + +function assertPrepareResult( + result: PrepareRenameResult | null, + range: vscode.Range, + placeholder: string, +): void { + assert.ok(result, `prepareRename must allow ${placeholder}`); + assert.strictEqual(result.placeholder, placeholder); + assert.deepStrictEqual(result.range, lspRange(range)); + assert.strictEqual(result.range.end.character - result.range.start.character, placeholder.length); +} + +async function assertPrepareCoverage( + document: vscode.TextDocument, + renameCase: RenameCase, +): Promise { + const range = rangeOf( + document, + renameCase.snippet, + renameCase.focus ?? renameCase.oldName, + renameCase.occurrence, + ); + const positions = [ + range.start, + range.start.translate(0, Math.floor(renameCase.oldName.length / 2)), + range.end.translate(0, -1), + ]; + for (const position of positions) { + assertPrepareResult(await prepareAt(document.uri, position), range, renameCase.oldName); + } + return range; +} + +export async function rawRenameAt( + uri: vscode.Uri, + position: vscode.Position, + newName: string, +): Promise { + return sendRealLspRequest('textDocument/rename', { + textDocument: { uri: uri.toString() }, + position: lspPosition(position), + newName, + }); +} + +export async function providerRename( + uri: vscode.Uri, + position: vscode.Position, + newName: string, +): Promise { + return vscode.commands.executeCommand( + 'vscode.executeDocumentRenameProvider', + uri, + position, + newName, + ); +} + +function expectedUris(fixtures: RenameFixtureSet, keys: readonly RenameFixtureKey[]): string[] { + return keys.map((key) => fixtureOf(fixtures, key).uri.toString()).sort(); +} + +function expectedPaths(fixtures: RenameFixtureSet, keys: readonly RenameFixtureKey[]): string[] { + return keys.map((key) => fixtureOf(fixtures, key).uri.fsPath.toLowerCase()).sort(); +} + +function assertRawEdit( + edit: RawRenameEdit | null, + fixtures: RenameFixtureSet, + renameCase: RenameCase, +): void { + assert.ok(edit?.documentChanges, 'raw textDocument/rename must return documentChanges'); + const changes = edit.documentChanges; + const paths = changes + .map((item) => vscode.Uri.parse(item.textDocument.uri).fsPath.toLowerCase()) + .sort(); + assert.deepStrictEqual(paths, expectedPaths(fixtures, renameCase.files)); + assert.ok(changes.every((item) => item.textDocument.version === null)); + const edits = changes.flatMap((item) => item.edits); + assert.strictEqual(edits.length, renameCase.editCount); + assert.ok(edits.every((item) => item.newText === renameCase.newName)); +} + +function assertSnapshotFiles( + snapshots: readonly WorkspaceEditSnapshot[], + fixtures: RenameFixtureSet, + keys: readonly RenameFixtureKey[], +): void { + assert.deepStrictEqual( + snapshots.map((item) => item.uri.toString()).sort(), + expectedUris(fixtures, keys), + ); + assert.ok(snapshots.every((item) => item.document !== undefined)); +} + +function assertTokenEdits( + snapshots: readonly WorkspaceEditSnapshot[], + oldName: string, + newName: string, + editCount: number, +): void { + const edits = snapshots.flatMap((item) => item.edits); + const replaced = snapshots.flatMap((item) => item.replacedText); + assert.strictEqual( + edits.length, + editCount, + 'rename must produce one granular edit per occurrence', + ); + assert.ok(edits.every((item) => item.newText === newName)); + assert.ok(edits.every((item) => !item.range.isEmpty && item.range.isSingleLine)); + assert.ok( + replaced.every((text) => text === oldName), + `every replaced token must be ${oldName}`, + ); +} + +function captureVersions(fixtures: RenameFixtureSet): ReadonlyMap { + const openFixtures = [fixtures.symbols, fixtures.usage, fixtures.edge]; + return new Map(openFixtures.map((fixture) => [fixture.uri.toString(), fixture.document.version])); +} + +function assertAppliedVersions( + snapshots: readonly WorkspaceEditSnapshot[], + versions: ReadonlyMap, +): void { + for (const snapshot of snapshots) { + assert.ok(snapshot.document, `rename target must already be open: ${snapshot.uri.fsPath}`); + assert.ok(snapshot.document.version > (versions.get(snapshot.uri.toString()) ?? -1)); + assert.ok(snapshot.document.isDirty, 'applied rename must leave every changed document dirty'); + } +} + +function assertLiteralAndCommentSentinels(fixtures: RenameFixtureSet): void { + const symbols = fixtures.symbols.document.getText(); + assert.ok(symbols.includes('"RenameClass RenameMethod renameLocal"')); + assert.ok( + symbols.includes('// RenameClass RenameMethod renameLocal must remain untouched in comments.'), + ); + const edge = fixtures.edge.document.getText(); + if (fixtures.baselines.edge.includes('"PartialRenameTarget PartialMember"')) { + assert.ok(edge.includes('"PartialRenameTarget PartialMember"')); + assert.ok( + edge.includes('// PartialRenameTarget and PartialMember stay unchanged in this comment.'), + ); + } +} + +function assertCaseFragments(fixtures: RenameFixtureSet, renameCase: RenameCase): void { + for (const key of ['symbols', 'usage', 'edge'] as const) { + const source = fixtureOf(fixtures, key).document.getText(); + for (const fragment of renameCase.after?.[key] ?? []) + assert.ok(source.includes(fragment), fragment); + } +} + +async function applyAndAssert( + edit: vscode.WorkspaceEdit, + fixtures: RenameFixtureSet, + renameCase: RenameCase, +): Promise { + const versions = captureVersions(fixtures); + const snapshots = await applyWorkspaceEdit(edit); + assertSnapshotFiles(snapshots, fixtures, renameCase.files); + assertTokenEdits(snapshots, renameCase.oldName, renameCase.newName, renameCase.editCount); + assertAppliedVersions(snapshots, versions); + assertLiteralAndCommentSentinels(fixtures); + assertCaseFragments(fixtures, renameCase); + return snapshots; +} + +async function assertRenamedPrepare( + document: vscode.TextDocument, + originalRange: vscode.Range, + newName: string, +): Promise { + const result = await waitForPrepare(document.uri, originalRange.start, newName); + const expected = new vscode.Range( + originalRange.start, + originalRange.start.translate(0, newName.length), + ); + assertPrepareResult(result, expected, newName); +} + +async function obtainRenameEdit( + fixture: OpenFixture, + range: vscode.Range, + renameCase: RenameCase, + fixtures: RenameFixtureSet, +): Promise { + assertRawEdit( + await rawRenameAt(fixture.uri, range.start, renameCase.newName), + fixtures, + renameCase, + ); + const edit = await providerRename(fixture.uri, range.start, renameCase.newName); + assert.ok(edit, `VS Code rename provider must return an edit for ${renameCase.label}`); + assert.strictEqual(edit.size, renameCase.files.length); + return edit; +} + +async function reverseRename( + fixture: OpenFixture, + range: vscode.Range, + renameCase: RenameCase, + fixtures: RenameFixtureSet, +): Promise { + const edit = await providerRename(fixture.uri, range.start, renameCase.oldName); + assert.ok(edit, `reverse rename must return an edit for ${renameCase.label}`); + const snapshots = await applyWorkspaceEdit(edit); + assertSnapshotFiles(snapshots, fixtures, renameCase.files); + assertTokenEdits(snapshots, renameCase.newName, renameCase.oldName, renameCase.editCount); +} + +export async function revertRenameFixtures(fixtures: RenameFixtureSet): Promise { + for (const key of ['symbols', 'usage', 'edge'] as const) { + const fixture = fixtureOf(fixtures, key); + await revertDocument(fixture.document); + assert.strictEqual(fixture.document.getText(), fixtures.baselines[key]); + assert.ok(!fixture.document.isDirty); + } +} + +export async function exerciseRename( + fixtures: RenameFixtureSet, + renameCase: RenameCase, + revertToDisk = true, +): Promise { + const fixture = fixtureOf(fixtures, renameCase.fixture); + const range = await assertPrepareCoverage(fixture.document, renameCase); + const edit = await obtainRenameEdit(fixture, range, renameCase, fixtures); + await applyAndAssert(edit, fixtures, renameCase); + await assertRenamedPrepare(fixture.document, range, renameCase.newName); + await reverseRename(fixture, range, renameCase, fixtures); + for (const key of ['symbols', 'usage', 'edge'] as const) { + assert.strictEqual(fixtureOf(fixtures, key).document.getText(), fixtures.baselines[key]); + } + if (revertToDisk) await revertRenameFixtures(fixtures); +} + +export function positionForCase( + fixtures: RenameFixtureSet, + renameCase: RenameCase, +): vscode.Position { + const document = fixtureOf(fixtures, renameCase.fixture).document; + return positionOf( + document, + renameCase.snippet, + renameCase.focus ?? renameCase.oldName, + renameCase.occurrence, + ); +} diff --git a/editors/vscode/src/test/suite/debug-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-e2e.test.ts similarity index 99% rename from editors/vscode/src/test/suite/debug-e2e.test.ts rename to src/editors/vscode/src/test/suite/debug-e2e.test.ts index c9f275b9..e75fb9fd 100644 --- a/editors/vscode/src/test/suite/debug-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-e2e.test.ts @@ -20,6 +20,7 @@ import { closeAllEditors, comparablePath } from './test-helpers'; import { removeDirRecursive } from './test-helpers.js'; // ───────────────────────────────────────────────────────────────────────────── +// Spec coverage: [DEBUG-FEATURES-LAUNCH], [DEBUG-ARCHITECTURE-NETCOREDBG]. // COARSE end-to-end tests for the debug subsystem (src/debug.ts). // // These drive the REAL extension surface — the registered `sharplsp.debugProgram` @@ -225,7 +226,7 @@ suite('Debug E2E — exported helpers inside real flows', () => { assert.strictEqual( withBundle[0], path.join(extPath, 'bin', `${process.platform}-${process.arch}`, 'netcoredbg', exe), - 'the bundled netcoredbg (staged by scripts/fetch-netcoredbg.sh) is preferred over PATH copies', + 'the bundled netcoredbg (staged by tools/vsix/fetch-netcoredbg.sh) is preferred over PATH copies', ); // The remaining five are the no-extensionPath list, order preserved. assert.deepStrictEqual(withBundle.slice(1), getNetcoredbgCandidates()); diff --git a/editors/vscode/src/test/suite/diagnostics.test.ts b/src/editors/vscode/src/test/suite/diagnostics.test.ts similarity index 100% rename from editors/vscode/src/test/suite/diagnostics.test.ts rename to src/editors/vscode/src/test/suite/diagnostics.test.ts diff --git a/editors/vscode/src/test/suite/extension.test.ts b/src/editors/vscode/src/test/suite/extension.test.ts similarity index 100% rename from editors/vscode/src/test/suite/extension.test.ts rename to src/editors/vscode/src/test/suite/extension.test.ts diff --git a/editors/vscode/src/test/suite/fsharp-helpers.ts b/src/editors/vscode/src/test/suite/fsharp-helpers.ts similarity index 100% rename from editors/vscode/src/test/suite/fsharp-helpers.ts rename to src/editors/vscode/src/test/suite/fsharp-helpers.ts diff --git a/src/editors/vscode/src/test/suite/fsharp-lsp-codefix-basics.test.ts b/src/editors/vscode/src/test/suite/fsharp-lsp-codefix-basics.test.ts new file mode 100644 index 00000000..0c0d7909 --- /dev/null +++ b/src/editors/vscode/src/test/suite/fsharp-lsp-codefix-basics.test.ts @@ -0,0 +1,291 @@ +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { + IGNORE_SOURCE, + MATCH_FIX_SOURCE, + OPEN_SCENARIOS, + UNUSED_VALUE_SOURCE, + falseOpenSource, + type CodeFixScenario, +} from './fsharp-refactor-fixtures'; +import { + FSHARP_REFACTOR_TIMEOUT_MS, + applyAction, + assertInsertion, + assertNoAction, + assertQuickFix, + assertReplacement, + diagnosticCode, + diagnosticGone, + diagnosticWithCode, + openOverlay, + quickFixes, + resolvedQuickFixes, + singleEdit, + tokenRange, + undoAction, + uniqueAction, +} from './fsharp-refactor-test-kit'; +import { activateRealSharpLsp, revertDocument } from './refactor-test-helpers'; +import { closeAllEditors } from './test-helpers'; + +// Full real-LSP lifecycle coverage for [ANALYZERS-FSAC-PARITY]. No mocked providers. +const TARGET_FILE = 'fsharp/DiagnosticsTarget.fs'; +const FALSE_OPEN_CASES: readonly (readonly [string, string])[] = [ + ['MyPath', "Add 'open System.IO'"], + ['MyFile', "Add 'open System.IO'"], + ['MyDirectory', "Add 'open System.IO'"], + ['MyTask', "Add 'open System.Threading.Tasks'"], + ['MyAsync', "Add 'open System.Threading.Tasks'"], + ['MyRegex', "Add 'open System.Text.RegularExpressions'"], + ['MyList', "Add 'open System.Collections.Generic'"], + ['map', "Add 'open System.Collections.Generic'"], + ['filter', "Add 'open System.Collections.Generic'"], + ['fold', "Add 'open System.Collections.Generic'"], +]; + +interface BasicFixSpec extends CodeFixScenario { + readonly occurrence?: number; + readonly beforeText?: string; + readonly postTarget?: string; + readonly preferred: boolean; + readonly expected: string; + readonly editText: string; + readonly insertion: boolean; +} + +suite('F# real LSP — diagnostic quick fixes', defineBasicFixSuite); + +function defineBasicFixSuite(): void { + suiteSetup(activateRealSharpLsp); + teardown(closeAllEditors); + suiteTeardown(closeAllEditors); + registerOpenTests(); + registerFalseOpenTests(); + registerBasicDiagnosticTests(); +} + +function registerOpenTests(): void { + for (const scenario of OPEN_SCENARIOS) { + test(`adds the ${scenario.name} namespace in valid F# module position`, async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS * 2); + await runBasicFix(openSpec(scenario)); + }); + } +} + +function registerFalseOpenTests(): void { + for (const [name, title] of FALSE_OPEN_CASES) { + test(`rejects non-fixing namespace heuristic for ${name}`, async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS); + await assertFalseOpen(name, title); + }); + } +} + +function registerBasicDiagnosticTests(): void { + for (const spec of basicSpecs()) { + test(`${spec.title} survives list, resolve, apply, recheck, and undo`, async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS * 2); + await runBasicFix(spec); + }); + } +} + +async function assertFalseOpen(name: string, title: string): Promise { + const source = falseOpenSource(name); + const fixture = await openOverlay(TARGET_FILE, source); + try { + const range = tokenRange(fixture.document, name); + const diagnostics = await diagnosticWithCode(fixture.uri, 'FS0039'); + assertDiagnostic(diagnostics, range, 'FS0039'); + assertNoAction(await quickFixes(fixture.uri, range), title); + assert.strictEqual(fixture.document.getText(), source); + assert.ok(fixture.document.isDirty); + } finally { + await revertDocument(fixture.document); + } +} + +function openSpec(scenario: CodeFixScenario): BasicFixSpec { + return { + ...scenario, + preferred: false, + expected: scenario.source.replace('\n\nlet', `\n\n${scenario.replacement}let`), + editText: scenario.replacement, + insertion: true, + }; +} + +function basicSpecs(): readonly BasicFixSpec[] { + return [unusedSpec(), ignoreSpec(), wildcardSpec(), redundantSpec()]; +} + +function unusedSpec(): BasicFixSpec { + return { + name: 'unused value', + source: UNUSED_VALUE_SOURCE, + target: 'unusedValue', + title: "Prefix 'unusedValue' with _", + diagnostic: 'FS1182', + replacement: '_unusedValue', + preferred: true, + expected: UNUSED_VALUE_SOURCE.replace('unusedValue', '_unusedValue'), + editText: '_unusedValue', + insertion: false, + }; +} + +function ignoreSpec(): BasicFixSpec { + return { + name: 'ignored result', + source: IGNORE_SOURCE, + target: '1 + 1', + title: "Add '|> ignore'", + diagnostic: 'FS0020', + replacement: ' |> ignore', + preferred: true, + expected: IGNORE_SOURCE.replace('1 + 1', '1 + 1 |> ignore'), + editText: ' |> ignore', + insertion: true, + }; +} + +function wildcardSpec(): BasicFixSpec { + const added = ' | A -> 1\n | _ -> failwith "Unhandled case"\n'; + return { + name: 'incomplete match', + source: MATCH_FIX_SOURCE, + target: 'match shape with', + title: "Add wildcard case '| _ ->'", + diagnostic: 'FS0025', + replacement: added, + preferred: false, + expected: MATCH_FIX_SOURCE.replace(' | A -> 1\n', added), + editText: ' | _ -> failwith "Unhandled case"\n', + insertion: true, + }; +} + +function redundantSpec(): BasicFixSpec { + return { + name: 'redundant case', + source: MATCH_FIX_SOURCE, + target: '| A -> 300', + occurrence: 0, + title: 'Remove redundant pattern case', + diagnostic: 'FS0026', + replacement: '', + preferred: false, + expected: MATCH_FIX_SOURCE.replace(' | A -> 300\n', ''), + editText: '', + insertion: false, + beforeText: ' | A -> 300\n', + postTarget: 'redundant shape', + }; +} + +async function runBasicFix(spec: BasicFixSpec): Promise { + const fixture = await openOverlay(TARGET_FILE, spec.source); + try { + const range = tokenRange(fixture.document, spec.target, spec.occurrence); + const action = await inspectAction(fixture, range, spec); + inspectEdit(fixture.document, fixture.uri, action, spec); + await applyAndRecheck(fixture, action, spec); + await undoAndRequery(fixture, spec); + } finally { + await revertDocument(fixture.document); + } +} + +async function inspectAction( + fixture: Awaited>, + range: vscode.Range, + spec: BasicFixSpec, +): Promise { + const diagnostics = await diagnosticWithCode(fixture.uri, spec.diagnostic); + assertDiagnostic(diagnostics, range, spec.diagnostic); + const listed = await quickFixes(fixture.uri, range); + const raw = uniqueAction(listed, spec.title); + assertListedAction(raw, spec); + await assertOutsideRange(fixture, spec.title); + const resolved = await resolvedQuickFixes(fixture.uri, range, spec.title); + const action = uniqueAction(resolved, spec.title); + assertQuickFix(action, spec.title, spec.preferred); + return action; +} + +function assertDiagnostic( + diagnostics: readonly vscode.Diagnostic[], + range: vscode.Range, + code: string, +): void { + const matches = diagnostics.filter((item) => diagnosticCode(item) === code); + assert.ok(matches.length >= 1, `${code} must be published`); + assert.ok(matches.some((item) => item.range.intersection(range) !== undefined)); + assert.ok(matches.every((item) => item.message.trim().length > 0)); + assert.ok(matches.every((item) => item.source === 'sharplsp-fsharp')); +} + +function assertListedAction(action: vscode.CodeAction, spec: BasicFixSpec): void { + assert.strictEqual(action.title, spec.title); + assert.strictEqual(action.kind?.value, vscode.CodeActionKind.QuickFix.value); + assert.strictEqual(action.isPreferred, spec.preferred); + assert.strictEqual(action.edit, undefined, 'listed action must remain unresolved'); + assert.strictEqual(action.command, undefined, 'quick fix must use an edit, not a command'); +} + +async function assertOutsideRange( + fixture: Awaited>, + title: string, +): Promise { + const outside = tokenRange(fixture.document, 'sentinel'); + const actions = await quickFixes(fixture.uri, outside); + assertNoAction(actions, title); + assert.ok( + actions.every((action) => action.kind?.contains(vscode.CodeActionKind.QuickFix) ?? true), + ); +} + +function inspectEdit( + document: vscode.TextDocument, + uri: vscode.Uri, + action: vscode.CodeAction, + spec: BasicFixSpec, +): void { + const edit = singleEdit(action, uri); + if (spec.insertion) assertInsertion(edit, spec.editText); + else assertReplacement(document, edit, spec.beforeText ?? spec.target, spec.editText); + if (spec.title.startsWith("Add 'open")) assert.strictEqual(edit.range.start.line, 2); +} + +async function applyAndRecheck( + fixture: Awaited>, + action: vscode.CodeAction, + spec: BasicFixSpec, +): Promise { + const version = fixture.document.version; + const snapshots = await applyAction(action); + assert.strictEqual(snapshots.length, 1); + assert.ok(fixture.document.version > version); + assert.strictEqual(fixture.document.getText(), spec.expected); + assert.ok(fixture.document.getText().includes('sentinel')); + assert.ok(fixture.document.isDirty); + await diagnosticGone(fixture.uri, spec.diagnostic); + const actions = await quickFixes( + fixture.uri, + tokenRange(fixture.document, spec.postTarget ?? spec.target), + ); + assertNoAction(actions, spec.title); +} + +async function undoAndRequery( + fixture: Awaited>, + spec: BasicFixSpec, +): Promise { + await undoAction(fixture.document, spec.source); + await diagnosticWithCode(fixture.uri, spec.diagnostic); + const range = tokenRange(fixture.document, spec.target, spec.occurrence); + const actions = await resolvedQuickFixes(fixture.uri, range, spec.title); + assertQuickFix(uniqueAction(actions, spec.title), spec.title, spec.preferred); +} diff --git a/src/editors/vscode/src/test/suite/fsharp-lsp-codefix-conversions.test.ts b/src/editors/vscode/src/test/suite/fsharp-lsp-codefix-conversions.test.ts new file mode 100644 index 00000000..2f0a8c8b --- /dev/null +++ b/src/editors/vscode/src/test/suite/fsharp-lsp-codefix-conversions.test.ts @@ -0,0 +1,237 @@ +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { + CONVERSION_SCENARIOS, + IMPLICIT_CONVERSION_SCENARIOS, + UNSUPPORTED_CONVERSION_SOURCE, + type CodeFixScenario, +} from './fsharp-refactor-fixtures'; +import { + FSHARP_REFACTOR_TIMEOUT_MS, + applyAction, + assertNoAction, + assertQuickFix, + assertReplacement, + diagnosticCode, + diagnosticGone, + diagnosticWithCode, + openOverlay, + quickFixes, + requestPrepareRename, + resolvedQuickFixes, + singleEdit, + tokenRange, + undoAction, + uniqueAction, +} from './fsharp-refactor-test-kit'; +import { activateRealSharpLsp, revertDocument } from './refactor-test-helpers'; +import { closeAllEditors } from './test-helpers'; + +// Every supported FS0001 conversion direction through the real LSP. [ANALYZERS-FSAC-PARITY] +const TARGET_FILE = 'fsharp/DiagnosticsTarget.fs'; + +suite('F# real LSP — type-conversion quick fixes', defineConversionSuite); + +function defineConversionSuite(): void { + suiteSetup(activateRealSharpLsp); + teardown(closeAllEditors); + suiteTeardown(closeAllEditors); + registerConversionTests(); + registerImplicitConversionTests(); + registerUnsupportedConversionTest(); +} + +function registerConversionTests(): void { + for (const scenario of CONVERSION_SCENARIOS) { + test(`${scenario.name}: exact conversion survives full edit lifecycle`, async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS * 2); + await runConversion(scenario); + }); + } +} + +function registerImplicitConversionTests(): void { + for (const scenario of IMPLICIT_CONVERSION_SCENARIOS) { + test(`${scenario.name}: implicit widening stays action-free across every token position`, async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS * 2); + await runImplicitConversion(scenario); + }); + } +} + +async function runImplicitConversion(scenario: CodeFixScenario): Promise { + const fixture = await openOverlay(TARGET_FILE, scenario.source); + try { + const version = fixture.document.version; + const range = tokenRange(fixture.document, scenario.target, scenario.occurrence); + const diagnostics = await diagnosticGone(fixture.uri, scenario.diagnostic); + assert.ok(diagnostics.every((item) => diagnosticCode(item) !== scenario.diagnostic)); + assert.ok(diagnostics.every((item) => item.severity !== vscode.DiagnosticSeverity.Error)); + await assertPrepareAcrossRange(fixture.uri, range, scenario.target); + await assertNoImplicitConversionActions(fixture, range, scenario); + assert.strictEqual(fixture.document.version, version); + assert.strictEqual(fixture.document.getText(), scenario.source); + assert.ok(fixture.document.isDirty); + } finally { + await revertDocument(fixture.document); + assert.ok(!fixture.document.isDirty); + } +} + +async function assertPrepareAcrossRange( + uri: vscode.Uri, + range: vscode.Range, + placeholder: string, +): Promise { + assert.ok(range.isSingleLine && !range.isEmpty); + for (let offset = 0; offset < range.end.character - range.start.character; offset += 1) { + const prepare = await requestPrepareRename(uri, range.start.translate(0, offset)); + assert.ok(prepare); + assert.strictEqual(prepare.placeholder, placeholder); + assert.strictEqual(prepare.range.start.line, range.start.line); + assert.strictEqual(prepare.range.start.character, range.start.character); + assert.strictEqual(prepare.range.end.line, range.end.line); + assert.strictEqual(prepare.range.end.character, range.end.character); + } +} + +async function assertNoImplicitConversionActions( + fixture: Awaited>, + range: vscode.Range, + scenario: CodeFixScenario, +): Promise { + const atDeclaration = await quickFixes( + fixture.uri, + tokenRange(fixture.document, scenario.target), + ); + const atUse = await quickFixes(fixture.uri, range); + const outside = await quickFixes(fixture.uri, tokenRange(fixture.document, 'sentinel')); + for (const actions of [atDeclaration, atUse, outside]) { + assertNoAction(actions, scenario.title); + assert.ok(!actions.some((action) => action.title.startsWith('Convert to'))); + } + assert.ok(atUse.every((action) => action.edit === undefined || action.edit.size > 0)); +} + +function registerUnsupportedConversionTest(): void { + test('unsupported bool-from-int mismatch offers no conversion action', async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS); + const fixture = await openOverlay(TARGET_FILE, UNSUPPORTED_CONVERSION_SOURCE); + try { + const diagnostics = await diagnosticWithCode(fixture.uri, 'FS0001'); + assert.ok(diagnostics.some((item) => diagnosticCode(item) === 'FS0001')); + const range = tokenRange(fixture.document, '1'); + const actions = await quickFixes(fixture.uri, range); + assert.ok(!actions.some((action) => action.title.startsWith('Convert to'))); + assert.strictEqual(fixture.document.getText(), UNSUPPORTED_CONVERSION_SOURCE); + assert.ok(fixture.document.isDirty); + } finally { + await revertDocument(fixture.document); + } + }); +} + +async function runConversion(scenario: CodeFixScenario): Promise { + const fixture = await openOverlay(TARGET_FILE, scenario.source); + try { + const range = tokenRange(fixture.document, scenario.target, scenario.occurrence); + const action = await inspectConversion(fixture, range, scenario); + assertConversionEdit(fixture.document, fixture.uri, action, scenario); + await applyConversion(fixture, action, scenario); + await undoConversion(fixture, scenario); + } finally { + await revertDocument(fixture.document); + } +} + +async function inspectConversion( + fixture: Awaited>, + range: vscode.Range, + scenario: CodeFixScenario, +): Promise { + const diagnostics = await diagnosticWithCode(fixture.uri, scenario.diagnostic); + assertConversionDiagnostic(diagnostics, range); + const raw = uniqueAction(await quickFixes(fixture.uri, range), scenario.title); + assertRawConversion(raw, scenario.title); + const outside = await quickFixes(fixture.uri, tokenRange(fixture.document, 'sentinel')); + assertNoAction(outside, scenario.title); + const resolved = await resolvedQuickFixes(fixture.uri, range, scenario.title); + const action = uniqueAction(resolved, scenario.title); + assertQuickFix(action, scenario.title, false); + return action; +} + +function assertConversionDiagnostic( + diagnostics: readonly vscode.Diagnostic[], + range: vscode.Range, +): void { + const mismatches = diagnostics.filter((item) => diagnosticCode(item) === 'FS0001'); + assert.ok(mismatches.length >= 1); + assert.ok(mismatches.some((item) => item.range.intersection(range) !== undefined)); + assert.ok(mismatches.every((item) => item.severity === vscode.DiagnosticSeverity.Error)); + assert.ok(mismatches.every((item) => item.source === 'sharplsp-fsharp')); + assert.ok(mismatches.every((item) => /type/i.test(item.message))); +} + +function assertRawConversion(action: vscode.CodeAction, title: string): void { + assert.strictEqual(action.title, title); + assert.strictEqual(action.kind?.value, vscode.CodeActionKind.QuickFix.value); + assert.strictEqual(action.isPreferred, false); + assert.strictEqual(action.edit, undefined); + assert.strictEqual(action.command, undefined); +} + +function assertConversionEdit( + document: vscode.TextDocument, + uri: vscode.Uri, + action: vscode.CodeAction, + scenario: CodeFixScenario, +): void { + assert.strictEqual(action.edit?.size, 1); + const edit = singleEdit(action, uri); + assertReplacement(document, edit, scenario.target, scenario.replacement); + assert.strictEqual(edit.range.start.line, 4); + assert.ok(edit.range.end.character > edit.range.start.character); +} + +async function applyConversion( + fixture: Awaited>, + action: vscode.CodeAction, + scenario: CodeFixScenario, +): Promise { + const version = fixture.document.version; + const snapshots = await applyAction(action); + assert.strictEqual(snapshots.length, 1); + assert.strictEqual(snapshots[0]?.uri.toString(), fixture.uri.toString()); + assert.strictEqual(snapshots[0]?.replacedText[0], scenario.target); + assert.ok(fixture.document.version > version); + assert.strictEqual(fixture.document.getText(), expectedSource(scenario)); + assert.ok(fixture.document.getText().includes('let sentinel = 48')); + assert.ok(fixture.document.isDirty); + await assertConversionClean(fixture.uri, scenario.diagnostic); + const actions = await quickFixes(fixture.uri, tokenRange(fixture.document, scenario.replacement)); + assertNoAction(actions, scenario.title); +} + +async function assertConversionClean(uri: vscode.Uri, diagnostic: string): Promise { + const diagnostics = await diagnosticGone(uri, diagnostic); + assert.ok( + diagnostics.every((item) => item.severity !== vscode.DiagnosticSeverity.Error), + 'conversion must leave the real F# document free of compiler errors', + ); +} + +async function undoConversion( + fixture: Awaited>, + scenario: CodeFixScenario, +): Promise { + await undoAction(fixture.document, scenario.source); + await diagnosticWithCode(fixture.uri, scenario.diagnostic); + const range = tokenRange(fixture.document, scenario.target, scenario.occurrence); + const actions = await resolvedQuickFixes(fixture.uri, range, scenario.title); + assertQuickFix(uniqueAction(actions, scenario.title), scenario.title, false); +} + +function expectedSource(scenario: CodeFixScenario): string { + return scenario.source.replace(`accept ${scenario.target}\n`, `accept ${scenario.replacement}\n`); +} diff --git a/src/editors/vscode/src/test/suite/fsharp-lsp-codefix-generation.test.ts b/src/editors/vscode/src/test/suite/fsharp-lsp-codefix-generation.test.ts new file mode 100644 index 00000000..97821929 --- /dev/null +++ b/src/editors/vscode/src/test/suite/fsharp-lsp-codefix-generation.test.ts @@ -0,0 +1,480 @@ +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { + COMPLETE_INTERFACE_SOURCE, + COMPLETE_RECORD_SOURCE, + EMPTY_INTERFACE_SOURCE, + EMPTY_INTERFACE_WITH_SOURCE, + EXHAUSTIVE_UNION_SOURCE, + GENERIC_INTERFACE_SOURCE, + MATCH_BANG_SOURCE, + NESTED_GENERIC_INTERFACE_SOURCE, + OBJECT_EXPRESSION_INTERFACE_SOURCE, + PARTIAL_INTERFACE_SOURCE, + RECORD_COPY_UPDATE_SOURCE, + RECORD_SOURCE, + UNION_SOURCE, + WILDCARD_UNION_SOURCE, +} from './fsharp-refactor-fixtures'; +import { + FSHARP_REFACTOR_TIMEOUT_MS, + applyAction, + assertInsertion, + assertNoAction, + assertQuickFix, + diagnosticCode, + diagnosticGone, + diagnosticWithCode, + openOverlay, + quickFixes, + resolvedQuickFixes, + singleEdit, + tokenRange, + undoAction, + uniqueAction, +} from './fsharp-refactor-test-kit'; +import { activateRealSharpLsp, revertDocument } from './refactor-test-helpers'; +import { closeAllEditors, comparableText } from './test-helpers'; + +// Union, record, and interface generators through shipped FCS. [ANALYZERS-FSAC-PARITY] +const TARGET_FILE = 'fsharp/DiagnosticsTarget.fs'; + +interface GenerationSpec { + readonly name: string; + readonly source: string; + readonly target: string; + readonly postTarget?: string; + readonly occurrence?: number; + readonly title: string; + readonly diagnostic: string; + readonly editText?: string; + readonly editPosition?: readonly [line: number, character: number]; + readonly editPrefix?: string; + readonly expectedFragments: readonly string[]; + readonly preservedFragments?: readonly string[]; + readonly absentFragments: readonly string[]; +} + +suite('F# real LSP — generated refactors', () => { + suiteSetup(activateRealSharpLsp); + teardown(closeAllEditors); + suiteTeardown(closeAllEditors); + + for (const spec of generationSpecs()) { + test(`${spec.name} survives list, resolve, apply, recheck, and undo`, async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS * 2); + await runGeneration(spec); + }); + } + + for (const spec of completeSpecs()) { + test(`${spec.name} offers no generation refactor when already complete`, async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS); + await assertComplete(spec); + }); + } +}); + +function generationSpecs(): readonly GenerationSpec[] { + return [ + unionSpec(), + matchBangSpec(), + recordSpec(), + interfaceSpec(), + genericInterfaceSpec(), + nestedGenericInterfaceSpec(), + objectExpressionInterfaceSpec(), + emptyInterfaceWithSpec(), + emptyInterfaceSpec(), + ]; +} + +function unionSpec(): GenerationSpec { + return { + name: 'three missing DU cases', + source: UNION_SOURCE, + target: 'match payload with', + title: 'Generate 3 missing union case(s)', + diagnostic: 'FS0025', + editText: unionStub(), + expectedFragments: ['| Empty ->', '| One _ ->', '| Many(_, _) ->'], + absentFragments: ['| Anchor _ ->'], + }; +} + +function matchBangSpec(): GenerationSpec { + return { + name: 'missing match! case', + source: MATCH_BANG_SOURCE, + target: 'match! pending with', + title: 'Generate 1 missing union case(s)', + diagnostic: 'FS0025', + // `match!` arms sit in a value-producing CE, so the stub must `return`. + editText: ' | Second -> return failwith "todo"\n', + expectedFragments: ['| Second ->'], + preservedFragments: ['| First ->'], + absentFragments: ['| Second _ ->'], + }; +} + +function recordSpec(): GenerationSpec { + return { + name: 'twelve typed record defaults', + source: RECORD_SOURCE, + target: '{ Keep = 1 }', + postTarget: 'let value', + title: 'Generate 12 missing record field(s)', + diagnostic: 'FS0764', + editText: recordStub(), + expectedFragments: recordFragments(), + absentFragments: ['Keep = 0'], + }; +} + +function interfaceSpec(): GenerationSpec { + return interfaceGenerationSpec( + 'only unimplemented interface member', + PARTIAL_INTERFACE_SOURCE, + [8, 32], + '\n member _.Area', + ['member _.Name = "square"'], + ); +} + +function genericInterfaceSpec(): GenerationSpec { + return { + name: 'closed generic interface preserves substituted type parameters', + source: GENERIC_INTERFACE_SOURCE, + target: 'IBox', + title: 'Implement interface', + diagnostic: 'FS0366', + editPosition: [8, 32], + editPrefix: '\n member _.Map', + expectedFragments: ['member _.Map', 'string', 'Not implemented yet'], + preservedFragments: ['member _.Value = "ready"'], + absentFragments: ['member _.Value = failwith', "member _.Map(arg1: 'T)"], + }; +} + +function nestedGenericInterfaceSpec(): GenerationSpec { + return { + name: 'nested generic interface resolves the outer interface symbol', + source: NESTED_GENERIC_INTERFACE_SOURCE, + target: 'IWrapper', + title: 'Implement interface', + diagnostic: 'FS0366', + editPosition: [9, 35], + editPrefix: '\n member _.Wrap', + expectedFragments: ['member _.Wrap', 'IOther', 'Not implemented yet'], + absentFragments: ['member _.Code'], + }; +} + +function objectExpressionInterfaceSpec(): GenerationSpec { + return interfaceGenerationSpec( + 'partial object expression keeps its existing member and closing brace', + OBJECT_EXPRESSION_INTERFACE_SOURCE, + [8, 31], + '\n member _.Area', + ['member _.Name = "shape"', '}'], + ); +} + +function emptyInterfaceWithSpec(): GenerationSpec { + return interfaceGenerationSpec( + 'empty interface declaration with existing with keyword', + EMPTY_INTERFACE_WITH_SOURCE, + [6, 25], + '\n member _.Area', + ); +} + +function emptyInterfaceSpec(): GenerationSpec { + return interfaceGenerationSpec( + 'empty interface declaration without with keyword', + EMPTY_INTERFACE_SOURCE, + [6, 20], + ' with\n member _.Area', + ); +} + +function interfaceGenerationSpec( + name: string, + source: string, + editPosition: readonly [number, number], + editPrefix: string, + preservedFragments: readonly string[] = [], +): GenerationSpec { + return { + name, + source, + target: 'IShape', + occurrence: 1, + title: 'Implement interface', + diagnostic: 'FS0366', + editPosition, + editPrefix, + expectedFragments: ['member _.Area', 'Not implemented yet'], + preservedFragments, + absentFragments: ['member _.Name = failwith'], + }; +} + +function completeSpecs(): readonly GenerationSpec[] { + return [ + completeUnionSpec(), + wildcardUnionSpec(), + completeRecordSpec(), + recordCopyUpdateSpec(), + completeInterfaceSpec(), + ]; +} + +function completeUnionSpec(): GenerationSpec { + return { + name: 'exhaustive DU', + source: EXHAUSTIVE_UNION_SOURCE, + target: 'match value with', + title: 'Generate 1 missing union case(s)', + diagnostic: 'FS0025', + expectedFragments: [], + absentFragments: [], + }; +} + +function completeRecordSpec(): GenerationSpec { + return { + name: 'complete record', + source: COMPLETE_RECORD_SOURCE, + target: '{ X = 1; Y = 2 }', + title: 'Generate 1 missing record field(s)', + diagnostic: 'FS0764', + expectedFragments: [], + absentFragments: [], + }; +} + +function wildcardUnionSpec(): GenerationSpec { + return { + name: 'wildcard-covered DU match', + source: WILDCARD_UNION_SOURCE, + target: 'match value with', + title: 'Generate 3 missing union case(s)', + diagnostic: 'FS0025', + expectedFragments: [], + absentFragments: [], + }; +} + +function recordCopyUpdateSpec(): GenerationSpec { + return { + name: 'record copy-and-update expression', + source: RECORD_COPY_UPDATE_SOURCE, + target: '{ point with X = 3 }', + title: 'Generate 1 missing record field(s)', + diagnostic: 'FS0764', + expectedFragments: [], + absentFragments: [], + }; +} + +function completeInterfaceSpec(): GenerationSpec { + return { + name: 'complete interface', + source: COMPLETE_INTERFACE_SOURCE, + target: 'IShape', + occurrence: 1, + title: 'Implement interface', + diagnostic: 'FS0366', + expectedFragments: [], + absentFragments: [], + }; +} + +function unionStub(): string { + return [ + ' | Empty -> failwith "todo"', + ' | One _ -> failwith "todo"', + ' | Many(_, _) -> failwith "todo"', + '', + ].join('\n'); +} + +function recordStub(): string { + return ( + '; Text = ""; Number = 0; Number32 = 0; Number64 = 0; Float = 0; ' + + 'Double = 0; Money = 0; Flag = false; Maybe = None; Items = []; ' + + 'Values = [||]; Other = Unchecked.defaultof' + ); +} + +function recordFragments(): readonly string[] { + return [ + 'Text = ""', + 'Number = 0', + 'Number32 = 0', + 'Number64 = 0', + 'Float = 0', + 'Double = 0', + 'Money = 0', + 'Flag = false', + 'Maybe = None', + 'Items = []', + 'Values = [||]', + 'Other = Unchecked.defaultof', + ]; +} + +async function runGeneration(spec: GenerationSpec): Promise { + const fixture = await openOverlay(TARGET_FILE, spec.source); + try { + const range = tokenRange(fixture.document, spec.target, spec.occurrence); + const action = await inspectGeneration(fixture, range, spec); + inspectGenerationEdit(fixture.uri, action, spec); + await applyGeneration(fixture, action, spec); + await undoGeneration(fixture, spec); + } finally { + await revertDocument(fixture.document); + } +} + +async function inspectGeneration( + fixture: Awaited>, + range: vscode.Range, + spec: GenerationSpec, +): Promise { + const diagnostics = await diagnosticWithCode(fixture.uri, spec.diagnostic); + assertGenerationDiagnostic(diagnostics, range, spec.diagnostic); + const raw = uniqueAction(await quickFixes(fixture.uri, range), spec.title); + assertRawGeneration(raw, spec.title); + const outside = await quickFixes(fixture.uri, tokenRange(fixture.document, 'sentinel')); + assertNoAction(outside, spec.title); + const resolved = await resolvedQuickFixes(fixture.uri, range, spec.title); + const action = uniqueAction(resolved, spec.title); + assertQuickFix(action, spec.title, true); + return action; +} + +function assertGenerationDiagnostic( + diagnostics: readonly vscode.Diagnostic[], + range: vscode.Range, + code: string, +): void { + const matches = diagnostics.filter((item) => diagnosticCode(item) === code); + assert.ok(matches.length >= 1, `${code} must drive the generator`); + assert.ok(matches.some((item) => item.range.intersection(range) !== undefined)); + assert.ok(matches.every((item) => item.message.trim().length > 0)); + assert.ok(matches.every((item) => item.source === 'sharplsp-fsharp')); +} + +function assertRawGeneration(action: vscode.CodeAction, title: string): void { + assert.strictEqual(action.title, title); + assert.strictEqual(action.kind?.value, vscode.CodeActionKind.QuickFix.value); + assert.strictEqual(action.isPreferred, true); + assert.strictEqual(action.edit, undefined); + assert.strictEqual(action.command, undefined); +} + +function inspectGenerationEdit( + uri: vscode.Uri, + action: vscode.CodeAction, + spec: GenerationSpec, +): void { + assert.strictEqual(action.edit?.size, 1); + const edit = singleEdit(action, uri); + assert.ok(edit.range.isEmpty); + if (spec.editText !== undefined) assertInsertion(edit, spec.editText); + if (spec.editPosition !== undefined) assertEditPosition(edit.range, spec.editPosition); + if (spec.editPrefix !== undefined) + // The stub adopts the document's own line endings, so compare EOL-insensitively. + assert.ok( + comparableText(edit.newText).startsWith(comparableText(spec.editPrefix)), + `generated text must start with ${JSON.stringify(spec.editPrefix)}; got ${JSON.stringify(edit.newText)}`, + ); + for (const fragment of spec.expectedFragments) + assert.ok( + edit.newText.includes(fragment), + `generated text must contain ${JSON.stringify(fragment)}; got ${JSON.stringify(edit.newText)}`, + ); + for (const fragment of spec.preservedFragments ?? []) assert.ok(!edit.newText.includes(fragment)); + for (const fragment of spec.absentFragments) assert.ok(!edit.newText.includes(fragment)); +} + +function assertEditPosition( + range: vscode.Range, + expected: readonly [line: number, character: number], +): void { + assert.strictEqual(range.start.line, expected[0]); + assert.strictEqual(range.start.character, expected[1]); + assert.strictEqual(range.end.line, expected[0]); + assert.strictEqual(range.end.character, expected[1]); +} + +async function applyGeneration( + fixture: Awaited>, + action: vscode.CodeAction, + spec: GenerationSpec, +): Promise { + const beforeVersion = fixture.document.version; + const snapshots = await applyAction(action); + assert.strictEqual(snapshots.length, 1); + assert.strictEqual(snapshots[0]?.uri.toString(), fixture.uri.toString()); + assert.ok(fixture.document.version > beforeVersion); + assert.ok(fixture.document.isDirty); + assertGeneratedDocument(fixture.document, spec); + await assertAllErrorsGone(fixture.uri, spec.diagnostic); + const actions = await quickFixes( + fixture.uri, + tokenRange(fixture.document, spec.postTarget ?? spec.target, spec.occurrence), + ); + assertNoAction(actions, spec.title); +} + +async function assertAllErrorsGone(uri: vscode.Uri, diagnostic: string): Promise { + const diagnostics = await diagnosticGone(uri, diagnostic); + const errors = diagnostics.filter((item) => item.severity === vscode.DiagnosticSeverity.Error); + assert.deepStrictEqual( + errors.map((item) => `${item.range.start.line}:${diagnosticCode(item)} ${item.message}`), + [], + 'generation must leave the real F# document free of compiler errors', + ); +} + +function assertGeneratedDocument(document: vscode.TextDocument, spec: GenerationSpec): void { + const text = document.getText(); + for (const fragment of spec.expectedFragments) assert.ok(text.includes(fragment)); + for (const fragment of spec.preservedFragments ?? []) assert.ok(text.includes(fragment)); + for (const fragment of spec.absentFragments) assert.ok(!text.includes(fragment)); + assert.ok(text.includes('sentinel')); +} + +async function undoGeneration( + fixture: Awaited>, + spec: GenerationSpec, +): Promise { + await undoAction(fixture.document, spec.source); + await diagnosticWithCode(fixture.uri, spec.diagnostic); + const range = tokenRange(fixture.document, spec.target, spec.occurrence); + const actions = await resolvedQuickFixes(fixture.uri, range, spec.title); + assertQuickFix(uniqueAction(actions, spec.title), spec.title, true); +} + +async function assertComplete(spec: GenerationSpec): Promise { + const fixture = await openOverlay(TARGET_FILE, spec.source); + try { + const range = tokenRange(fixture.document, spec.target, spec.occurrence); + await assertAllErrorsGone(fixture.uri, spec.diagnostic); + const actions = await quickFixes(fixture.uri, range); + assertNoAction(actions, spec.title); + assert.ok(!actions.some((action) => action.title.startsWith('Generate '))); + assert.ok( + !vscode.languages + .getDiagnostics(fixture.uri) + .some((item) => diagnosticCode(item) === spec.diagnostic), + ); + assert.strictEqual(fixture.document.getText(), spec.source); + assert.ok(fixture.document.isDirty); + } finally { + await revertDocument(fixture.document); + } +} diff --git a/src/editors/vscode/src/test/suite/fsharp-lsp-codefixes.test.ts b/src/editors/vscode/src/test/suite/fsharp-lsp-codefixes.test.ts new file mode 100644 index 00000000..ab2ae455 --- /dev/null +++ b/src/editors/vscode/src/test/suite/fsharp-lsp-codefixes.test.ts @@ -0,0 +1,249 @@ +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as vscode from 'vscode'; +import { + FSHARP_COLD_TIMEOUT_MS, + fsharpFixturePath, + openFSharpFixture, + positionOf, +} from './fsharp-helpers'; +import { + activateRealSharpLsp, + replaceDocumentText, + waitForCodeActions, + waitForResolvedCodeActions, +} from './refactor-test-helpers'; +import { closeAllEditors, pollUntilResult } from './test-helpers'; + +// Real-LSP analyzer fixes for [ANALYZERS-FSAC-PARITY] and [ANALYZERS-FSAC-CODEFIX-INTERFACE-STUB]. +const CODEFIX_FILE = 'CodeFixes.fs'; +const ORIGINAL = fs.readFileSync(fsharpFixturePath(CODEFIX_FILE), 'utf8'); +const IMPL_FILE = 'Implement.fs'; +const IMPL_ORIGINAL = fs.readFileSync(fsharpFixturePath(IMPL_FILE), 'utf8'); +const IMPL_INCOMPLETE = `module FSharpFixtures.Implement + +type IShape = + abstract member Area: unit -> float + abstract member Name: string + +type Square() = + interface IShape +`; + +interface Fixture { + readonly doc: vscode.TextDocument; + readonly uri: vscode.Uri; +} + +suite('F# LSP — Code Fixes (FSAC parity)', defineAnalyzerFixSuite); +suite('F# LSP — Implement Interface (FSAC parity)', defineInterfaceFixSuite); + +function defineAnalyzerFixSuite(): void { + suiteSetup(activateRealSharpLsp); + suiteTeardown(cleanupAnalyzerFixture); + teardown(cleanupAnalyzerFixture); + test('remove unused open lists, resolves, and deletes exactly one line', runRemoveTest); + test('simplify name lists, resolves, and deletes only the qualifier', runSimplifyTest); + test('both analyzer hints publish exact codes, severities, and ranges', runHintTest); +} + +function defineInterfaceFixSuite(): void { + suiteSetup(activateRealSharpLsp); + suiteTeardown(cleanupInterfaceFixture); + teardown(cleanupInterfaceFixture); + test('implement interface resolves and inserts both missing members', runInterfaceTest); +} + +async function runRemoveTest(this: Mocha.Context): Promise { + this.timeout(FSHARP_COLD_TIMEOUT_MS + 45_000); + const fixture = await openFSharpFixture(CODEFIX_FILE); + const openLine = positionOf(fixture.doc, 'open System.Text').line; + const range = fixture.doc.lineAt(openLine).range; + const action = await inspectAction(fixture.uri, range, 'Remove unused open', false); + assertRemoveEdit(action, fixture.uri, openLine); + await applyRemove(fixture, action); +} + +async function runSimplifyTest(this: Mocha.Context): Promise { + this.timeout(FSHARP_COLD_TIMEOUT_MS + 45_000); + const fixture = await openFSharpFixture(CODEFIX_FILE); + const start = positionOf(fixture.doc, 'System.DateTime.MinValue'); + const range = new vscode.Range(start, start.translate(0, 'System.DateTime'.length)); + const action = await inspectAction(fixture.uri, range, 'Simplify name', false); + assertSimplifyEdit(fixture, action); + await applySimplify(fixture, action); +} + +async function runHintTest(this: Mocha.Context): Promise { + this.timeout(FSHARP_COLD_TIMEOUT_MS + 45_000); + const fixture = await openFSharpFixture(CODEFIX_FILE); + const diagnostics = await pollUntilResult( + async () => vscode.languages.getDiagnostics(fixture.uri), + hasBothAnalyzerHints, + FSHARP_COLD_TIMEOUT_MS, + 2_000, + ); + assertAnalyzerHints(fixture.doc, diagnostics); +} + +async function runInterfaceTest(this: Mocha.Context): Promise { + this.timeout(FSHARP_COLD_TIMEOUT_MS + 45_000); + const fixture = await openFSharpFixture(IMPL_FILE); + await replaceDocumentText(fixture.doc, IMPL_INCOMPLETE); + assert.ok(fixture.doc.isDirty, 'incomplete interface must be an unsaved overlay'); + const start = positionOf(fixture.doc, 'interface IShape', 'interface '.length); + const range = new vscode.Range(start, start.translate(0, 'IShape'.length)); + const action = await inspectAction(fixture.uri, range, 'Implement interface', true); + assertInterfaceEdit(action, fixture.uri); + await applyInterface(fixture, action); +} + +async function inspectAction( + uri: vscode.Uri, + range: vscode.Range, + title: string, + preferred: boolean, +): Promise { + const query = { + uri, + range, + kind: vscode.CodeActionKind.QuickFix, + predicate: (items: vscode.CodeAction[]) => items.some((item) => item.title === title), + timeoutMs: FSHARP_COLD_TIMEOUT_MS, + }; + const raw = unique(await waitForCodeActions(query), title); + assertActionMetadata(raw, title, preferred, false); + const resolved = unique(await waitForResolvedCodeActions(query), title); + assertActionMetadata(resolved, title, preferred, true); + return resolved; +} + +function unique(actions: readonly vscode.CodeAction[], title: string): vscode.CodeAction { + const matches = actions.filter((item) => item.title === title); + assert.strictEqual(matches.length, 1, `expected one ${title}`); + const action = matches[0]; + assert.ok(action); + return action; +} + +function assertActionMetadata( + action: vscode.CodeAction, + title: string, + preferred: boolean, + resolved: boolean, +): void { + assert.strictEqual(action.title, title); + assert.strictEqual(action.kind?.value, vscode.CodeActionKind.QuickFix.value); + assert.strictEqual(action.isPreferred, preferred); + if (resolved) assert.ok(action.edit); + else assert.strictEqual(action.edit, undefined); +} + +function assertRemoveEdit(action: vscode.CodeAction, uri: vscode.Uri, line: number): void { + assert.ok(action.edit); + const edits = action.edit.get(uri); + assert.strictEqual(edits.length, 1); + assert.strictEqual(edits[0]?.newText, ''); + assert.strictEqual(edits[0]?.range.start.line, line); + assert.strictEqual(edits[0]?.range.start.character, 0); + assert.strictEqual(edits[0]?.range.end.line, line + 1); + assert.strictEqual(edits[0]?.range.end.character, 0); +} + +async function applyRemove(fixture: Fixture, action: vscode.CodeAction): Promise { + assert.ok(action.edit); + assert.ok(fixture.doc.getText().includes('open System.Text')); + assert.ok(await vscode.workspace.applyEdit(action.edit)); + const after = fixture.doc.getText(); + assert.ok(!after.includes('open System.Text')); + assert.ok(after.includes('open System\n')); + assert.ok(after.includes('DateTime.Now')); +} + +function assertSimplifyEdit(fixture: Fixture, action: vscode.CodeAction): void { + assert.ok(action.edit); + const edits = action.edit.get(fixture.uri); + assert.strictEqual(edits.length, 1); + assert.strictEqual(edits[0]?.newText, ''); + assert.strictEqual(fixture.doc.getText(edits[0]?.range), 'System.'); + assert.ok(!edits[0]?.range.isEmpty); +} + +async function applySimplify(fixture: Fixture, action: vscode.CodeAction): Promise { + assert.ok(action.edit); + assert.ok(fixture.doc.getText().includes('System.DateTime.MinValue')); + assert.ok(await vscode.workspace.applyEdit(action.edit)); + const after = fixture.doc.getText(); + assert.ok(after.includes('let minimum = DateTime.MinValue')); + assert.ok(!after.includes('System.DateTime.MinValue')); +} + +function hasBothAnalyzerHints(diagnostics: readonly vscode.Diagnostic[]): boolean { + return ( + diagnostics.some((item) => codeOf(item) === 'SLSPF0102') && + diagnostics.some((item) => codeOf(item) === 'SLSPF0103') + ); +} + +function assertAnalyzerHints( + doc: vscode.TextDocument, + diagnostics: readonly vscode.Diagnostic[], +): void { + const unused = diagnostics.filter((item) => codeOf(item) === 'SLSPF0102'); + const simplify = diagnostics.filter((item) => codeOf(item) === 'SLSPF0103'); + assert.ok(unused.length >= 1); + assert.ok(simplify.length >= 1); + assert.ok(unused.every((item) => item.severity === vscode.DiagnosticSeverity.Hint)); + assert.ok(simplify.every((item) => item.severity === vscode.DiagnosticSeverity.Hint)); + const line = positionOf(doc, 'open System.Text').line; + assert.ok(unused.some((item) => item.range.start.line === line)); +} + +function assertInterfaceEdit(action: vscode.CodeAction, uri: vscode.Uri): void { + assert.ok(action.edit); + const edits = action.edit.get(uri); + assert.strictEqual(edits.length, 1); + const text = edits[0]?.newText ?? ''; + assert.match(text, /member/); + assert.match(text, /Area/); + assert.match(text, /Name/); + assert.ok(edits[0]?.range.isEmpty); +} + +async function applyInterface(fixture: Fixture, action: vscode.CodeAction): Promise { + assert.ok(action.edit); + const before = fixture.doc.getText(); + assert.ok(await vscode.workspace.applyEdit(action.edit)); + const after = fixture.doc.getText(); + assert.ok(after.length > before.length); + assert.ok(after.includes('Area')); + assert.ok(after.includes('Name')); + assert.ok(after.includes('member')); +} + +function codeOf(diagnostic: vscode.Diagnostic): string { + const code = diagnostic.code; + if (code === undefined || code === null) return ''; + if (typeof code === 'object') return String(code.value); + return String(code); +} + +async function cleanupAnalyzerFixture(): Promise { + fs.writeFileSync(fsharpFixturePath(CODEFIX_FILE), ORIGINAL, 'utf8'); + await revertDirtyEditors(); + await closeAllEditors(); +} + +async function cleanupInterfaceFixture(): Promise { + fs.writeFileSync(fsharpFixturePath(IMPL_FILE), IMPL_ORIGINAL, 'utf8'); + await revertDirtyEditors(); + await closeAllEditors(); +} + +async function revertDirtyEditors(): Promise { + for (const editor of vscode.window.visibleTextEditors) { + if (!editor.document.isDirty) continue; + await vscode.window.showTextDocument(editor.document); + await vscode.commands.executeCommand('workbench.action.files.revert'); + } +} diff --git a/src/editors/vscode/src/test/suite/fsharp-lsp-cross-language-rename.test.ts b/src/editors/vscode/src/test/suite/fsharp-lsp-cross-language-rename.test.ts new file mode 100644 index 00000000..ba8b2b88 --- /dev/null +++ b/src/editors/vscode/src/test/suite/fsharp-lsp-cross-language-rename.test.ts @@ -0,0 +1,459 @@ +import * as assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { readFile, stat } from 'node:fs/promises'; +import * as path from 'node:path'; +import { promisify } from 'node:util'; +import * as vscode from 'vscode'; +import { + FSHARP_REFACTOR_TIMEOUT_MS, + changedFileNames, + editCount, + requestPrepareRename, + requestRename, + tokenRange, +} from './fsharp-refactor-test-kit'; +import { + activateRealSharpLsp, + applyWorkspaceEdit, + assertWorkspaceEditSafe, + openFixtureDocument, + waitForMatchingDiagnostics, + workspaceFixturePath, + type OpenFixture, +} from './refactor-test-helpers'; +import { closeAllEditors } from './test-helpers'; + +const execFileAsync = promisify(execFile); +const FIXTURE_SOLUTION = workspaceFixturePath('TestFixtures.slnx'); +const RESTART_COMMAND = 'sharplsp.restartServer'; + +// Both foreign-sidecar directions are mandatory. [RENAME-CROSSLANGUAGE] +interface CrossRenameSpec { + readonly name: string; + readonly originFile: string; + readonly foreignFile: string; + readonly target: string; + readonly newName: string; + readonly expectedFiles: readonly string[]; + readonly originOccurrences: number; + readonly foreignOccurrences: number; +} + +interface CrossRenameFixture { + readonly origin: OpenFixture; + readonly foreign: OpenFixture; + readonly originalOrigin: string; + readonly originalForeign: string; +} + +interface BuildArtifact { + readonly source: string; + readonly output: string; +} + +suite('Real LSP - cross-language rename', () => { + suiteSetup(async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS * 2); + await rebuildAndRestartRealLsp(); + }); + teardown(closeAllEditors); + suiteTeardown(closeAllEditors); + + for (const spec of crossLanguageSpecs()) { + test(`${spec.name} edits both languages, applies, and reverses`, async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS * 5); + await runCrossLanguageRename(spec); + }); + } +}); + +function crossLanguageSpecs(): readonly CrossRenameSpec[] { + return [csharpOriginSpec(), fsharpOriginSpec(), csharpMemberSpec(), fsharpMemberSpec()]; +} + +function csharpOriginSpec(): CrossRenameSpec { + return { + name: 'C# origin -> F# reference', + originFile: 'CrossLanguageCSharp.cs', + foreignFile: 'fsharp/CrossLanguage.fs', + target: 'CSharpOrigin', + newName: 'BridgedCSharpType', + expectedFiles: ['CrossLanguage.fs', 'CrossLanguageCSharp.cs'], + originOccurrences: 2, + foreignOccurrences: 1, + }; +} + +function csharpMemberSpec(): CrossRenameSpec { + return { + name: 'C# member -> F# reference', + originFile: 'CrossLanguageCSharp.cs', + foreignFile: 'fsharp/CrossLanguage.fs', + target: 'CSharpValue', + newName: 'BridgedCSharpMember', + expectedFiles: ['CrossLanguage.fs', 'CrossLanguageCSharp.cs'], + originOccurrences: 2, + foreignOccurrences: 1, + }; +} + +function fsharpMemberSpec(): CrossRenameSpec { + return { + name: 'F# member -> C# reference', + originFile: 'fsharp/CrossLanguage.fs', + foreignFile: 'crosslanguage/FSharpConsumer.cs', + target: 'FSharpValue', + newName: 'BridgedFSharpMember', + expectedFiles: ['CrossLanguage.fs', 'FSharpConsumer.cs'], + originOccurrences: 2, + foreignOccurrences: 1, + }; +} + +function fsharpOriginSpec(): CrossRenameSpec { + return { + name: 'F# origin -> C# reference', + originFile: 'fsharp/CrossLanguage.fs', + foreignFile: 'crosslanguage/FSharpConsumer.cs', + target: 'FSharpOrigin', + newName: 'BridgedFSharpType', + expectedFiles: ['CrossLanguage.fs', 'FSharpConsumer.cs'], + originOccurrences: 3, + foreignOccurrences: 1, + }; +} + +async function runCrossLanguageRename(spec: CrossRenameSpec): Promise { + const fixture = await openCrossRenameFixture(spec); + let completed = false; + try { + await runCrossLanguageLifecycle(fixture, spec); + completed = true; + } finally { + if (!completed) await restoreOriginalFixture(fixture); + } +} + +async function openCrossRenameFixture(spec: CrossRenameSpec): Promise { + const origin = await openFixtureDocument(spec.originFile); + const foreign = await openFixtureDocument(spec.foreignFile); + return { + origin, + foreign, + originalOrigin: origin.document.getText(), + originalForeign: foreign.document.getText(), + }; +} + +async function runCrossLanguageLifecycle( + fixture: CrossRenameFixture, + spec: CrossRenameSpec, +): Promise { + assertInitialSources(fixture.origin, fixture.foreign, spec); + const range = tokenRange(fixture.origin.document, spec.target); + await assertPrepare(fixture.origin.uri, range, spec.target); + const edit = await requestRename( + fixture.origin.uri, + range.start.translate(0, 1), + spec.newName, + FSHARP_REFACTOR_TIMEOUT_MS, + ); + await assertCrossLanguageEdit(edit, spec.target, spec.newName, spec); + await applyCrossLanguageEdit(fixture, edit, spec); + await reverseCrossLanguageEdit(fixture, spec); +} + +function assertInitialSources( + origin: OpenFixture, + foreign: OpenFixture, + spec: CrossRenameSpec, +): void { + assert.ok(!origin.document.isDirty); + assert.ok(!foreign.document.isDirty); + assert.strictEqual(count(origin.document.getText(), spec.target), spec.originOccurrences); + assert.strictEqual(count(foreign.document.getText(), spec.target), spec.foreignOccurrences); + assert.strictEqual(count(origin.document.getText(), spec.newName), 0); + assert.strictEqual(count(foreign.document.getText(), spec.newName), 1); + assert.notStrictEqual(origin.uri.toString(), foreign.uri.toString()); +} + +async function assertPrepare( + uri: vscode.Uri, + range: vscode.Range, + placeholder: string, +): Promise { + const prepare = await requestPrepareRename(uri, range.start.translate(0, 1)); + assert.ok(prepare); + assert.strictEqual(prepare.placeholder, placeholder); + assert.strictEqual(prepare.range.start.line, range.start.line); + assert.strictEqual(prepare.range.start.character, range.start.character); + assert.strictEqual(prepare.range.end.line, range.end.line); + assert.strictEqual(prepare.range.end.character, range.end.character); +} + +async function assertCrossLanguageEdit( + edit: vscode.WorkspaceEdit, + oldName: string, + newName: string, + spec: CrossRenameSpec, +): Promise { + const expectedCount = spec.originOccurrences + spec.foreignOccurrences; + const files = changedFileNames(edit); + assert.strictEqual( + edit.size, + 2, + `both language documents must be present; files=${JSON.stringify(files)} edits=${editCount(edit)}`, + ); + assert.strictEqual(editCount(edit), expectedCount); + assert.deepStrictEqual(files.sort(), [...spec.expectedFiles].sort()); + const snapshots = await assertWorkspaceEditSafe(edit); + assert.strictEqual(snapshots.length, 2); + assert.ok(snapshots.every((item) => item.edits.length >= 1)); + assertSnapshotTexts(snapshots, oldName, newName); +} + +function assertSnapshotTexts( + snapshots: Awaited>, + oldName: string, + newName: string, +): void { + const replaced = snapshots.flatMap((item) => item.replacedText); + const inserted = snapshots.flatMap((item) => item.edits).map((item) => item.newText); + assert.ok( + replaced.every((text) => text === oldName), + `replaced ${JSON.stringify(replaced)}`, + ); + assert.ok( + inserted.every((text) => text === newName), + `inserted ${JSON.stringify(inserted)}`, + ); +} + +async function applyCrossLanguageEdit( + fixture: CrossRenameFixture, + edit: vscode.WorkspaceEdit, + spec: CrossRenameSpec, +): Promise { + const originVersion = fixture.origin.document.version; + const foreignVersion = fixture.foreign.document.version; + await applyWorkspaceEdit(edit); + assert.ok(fixture.origin.document.version > originVersion); + assert.ok(fixture.foreign.document.version > foreignVersion); + assertAppliedTexts(fixture, spec); + await persistFixtureState(fixture); +} + +function assertAppliedTexts(fixture: CrossRenameFixture, spec: CrossRenameSpec): void { + assert.strictEqual( + fixture.origin.document.getText(), + fixture.originalOrigin.replaceAll(spec.target, spec.newName), + ); + assert.strictEqual( + fixture.foreign.document.getText(), + fixture.originalForeign.replaceAll(spec.target, spec.newName), + ); + assert.ok(fixture.origin.document.isDirty); + assert.ok(fixture.foreign.document.isDirty); + assert.strictEqual( + count(fixture.origin.document.getText(), spec.newName), + spec.originOccurrences, + ); + assert.strictEqual(count(fixture.foreign.document.getText(), spec.newName), 2); +} + +async function reverseCrossLanguageEdit( + fixture: CrossRenameFixture, + spec: CrossRenameSpec, +): Promise { + const reverse = await requestReverseEdit(fixture, spec); + await assertCrossLanguageEdit(reverse, spec.newName, spec.target, spec); + const originVersion = fixture.origin.document.version; + const foreignVersion = fixture.foreign.document.version; + await applyWorkspaceEdit(reverse); + assert.ok(fixture.origin.document.version > originVersion); + assert.ok(fixture.foreign.document.version > foreignVersion); + assertOriginalTexts(fixture, spec); + await assertOriginalPrepare(fixture, spec); + await persistFixtureState(fixture); + await assertOriginalPrepare(fixture, spec); +} + +async function requestReverseEdit( + fixture: CrossRenameFixture, + spec: CrossRenameSpec, +): Promise { + const range = tokenRange(fixture.origin.document, spec.newName); + await assertPrepare(fixture.origin.uri, range, spec.newName); + return requestRename( + fixture.origin.uri, + range.start.translate(0, 1), + spec.target, + FSHARP_REFACTOR_TIMEOUT_MS, + ); +} + +async function assertOriginalPrepare( + fixture: CrossRenameFixture, + spec: CrossRenameSpec, +): Promise { + await assertPrepare( + fixture.origin.uri, + tokenRange(fixture.origin.document, spec.target), + spec.target, + ); +} + +function assertOriginalTexts(fixture: CrossRenameFixture, spec: CrossRenameSpec): void { + assert.strictEqual(fixture.origin.document.getText(), fixture.originalOrigin); + assert.strictEqual(fixture.foreign.document.getText(), fixture.originalForeign); + assert.ok(fixture.origin.document.isDirty); + assert.ok(fixture.foreign.document.isDirty); + assert.strictEqual(count(fixture.origin.document.getText(), spec.newName), 0); + assert.strictEqual(count(fixture.foreign.document.getText(), spec.newName), 1); +} + +async function assertNoErrors(uri: vscode.Uri): Promise { + try { + const diagnostics = await waitForMatchingDiagnostics( + uri, + (items) => items.every((item) => item.severity !== vscode.DiagnosticSeverity.Error), + FSHARP_REFACTOR_TIMEOUT_MS, + ); + assert.ok(diagnostics.every((item) => item.severity !== vscode.DiagnosticSeverity.Error)); + } catch (error: unknown) { + assert.fail(`diagnostics for ${uri.fsPath}: ${diagnosticSummary(uri)}; wait=${String(error)}`); + } +} + +function diagnosticSummary(uri: vscode.Uri): string { + const diagnostics = vscode.languages.getDiagnostics(uri); + if (diagnostics.length === 0) return ''; + return diagnostics + .map((item) => { + const start = `${item.range.start.line}:${item.range.start.character}`; + return `[${item.severity}/${String(item.code ?? 'none')}@${start}] ${item.message}`; + }) + .join(' | '); +} + +async function persistFixtureState(fixture: CrossRenameFixture): Promise { + const expectedOrigin = fixture.origin.document.getText(); + const expectedForeign = fixture.foreign.document.getText(); + await saveFixtureDocuments(fixture); + await assertFixtureDisk(fixture, expectedOrigin, expectedForeign); + await rebuildAndRestartRealLsp(); + assert.strictEqual(fixture.origin.document.getText(), expectedOrigin); + assert.strictEqual(fixture.foreign.document.getText(), expectedForeign); + assert.ok(!fixture.origin.document.isDirty); + assert.ok(!fixture.foreign.document.isDirty); + await assertNoErrors(fixture.origin.uri); + await assertNoErrors(fixture.foreign.uri); +} + +async function saveFixtureDocuments(fixture: CrossRenameFixture): Promise { + const originVersion = fixture.origin.document.version; + const foreignVersion = fixture.foreign.document.version; + assert.strictEqual(fixture.origin.uri.scheme, 'file'); + assert.strictEqual(fixture.foreign.uri.scheme, 'file'); + assert.ok(await fixture.origin.document.save(), 'origin document must save'); + assert.ok(await fixture.foreign.document.save(), 'foreign document must save'); + assert.strictEqual(fixture.origin.document.version, originVersion); + assert.strictEqual(fixture.foreign.document.version, foreignVersion); + assert.ok(!fixture.origin.document.isDirty); + assert.ok(!fixture.foreign.document.isDirty); +} + +async function assertFixtureDisk( + fixture: CrossRenameFixture, + expectedOrigin: string, + expectedForeign: string, +): Promise { + const [originDisk, foreignDisk] = await Promise.all([ + readFile(fixture.origin.uri.fsPath, 'utf8'), + readFile(fixture.foreign.uri.fsPath, 'utf8'), + ]); + assert.strictEqual(originDisk, expectedOrigin); + assert.strictEqual(foreignDisk, expectedForeign); +} + +async function rebuildAndRestartRealLsp(): Promise { + await buildCrossLanguageFixtures(); + const client = await activateRealSharpLsp(); + const commands = await vscode.commands.getCommands(true); + assert.ok(commands.includes(RESTART_COMMAND), `${RESTART_COMMAND} must be registered`); + await vscode.commands.executeCommand(RESTART_COMMAND); + const restarted = await activateRealSharpLsp(); + assert.strictEqual(restarted, client, 'restart must leave the real LanguageClient running'); +} + +async function buildCrossLanguageFixtures(): Promise { + const result = await execFileAsync( + 'dotnet', + ['build', FIXTURE_SOLUTION, '--configuration', 'Debug', '--nologo', '--verbosity', 'minimal'], + { cwd: path.dirname(FIXTURE_SOLUTION), encoding: 'utf8', timeout: FSHARP_REFACTOR_TIMEOUT_MS }, + ); + assert.strictEqual(typeof result.stdout, 'string'); + assert.strictEqual(typeof result.stderr, 'string'); + assert.ok(result.stdout.trim().length > 0, 'real fixture build must report output'); + await Promise.all(buildArtifacts().map(assertArtifactFresh)); +} + +function buildArtifacts(): readonly BuildArtifact[] { + return [ + { + source: workspaceFixturePath('CrossLanguageCSharp.cs'), + output: workspaceFixturePath('bin/Debug/net10.0/TestFixtures.dll'), + }, + { + source: workspaceFixturePath('fsharp/CrossLanguage.fs'), + output: workspaceFixturePath('fsharp/bin/Debug/net10.0/FSharpFixtures.dll'), + }, + { + source: workspaceFixturePath('crosslanguage/FSharpConsumer.cs'), + output: workspaceFixturePath('crosslanguage/bin/Debug/net10.0/CSharpConsumer.dll'), + }, + ]; +} + +async function assertArtifactFresh(artifact: BuildArtifact): Promise { + const [sourceStats, outputStats] = await Promise.all([ + stat(artifact.source), + stat(artifact.output), + ]); + assert.ok(sourceStats.isFile(), `${artifact.source} must be a source file`); + assert.ok(outputStats.isFile(), `${artifact.output} must be a real assembly`); + assert.ok(outputStats.size > 0, `${artifact.output} must not be empty`); + assert.ok( + outputStats.mtimeMs >= sourceStats.mtimeMs, + `${artifact.output} is stale relative to ${artifact.source}`, + ); +} + +async function restoreOriginalFixture(fixture: CrossRenameFixture): Promise { + const restoration = new vscode.WorkspaceEdit(); + restoration.replace( + fixture.origin.uri, + fullDocumentRange(fixture.origin.document), + fixture.originalOrigin, + ); + restoration.replace( + fixture.foreign.uri, + fullDocumentRange(fixture.foreign.document), + fixture.originalForeign, + ); + await applyWorkspaceEdit(restoration); + assert.strictEqual(fixture.origin.document.getText(), fixture.originalOrigin); + assert.strictEqual(fixture.foreign.document.getText(), fixture.originalForeign); + await persistFixtureState(fixture); +} + +function fullDocumentRange(document: vscode.TextDocument): vscode.Range { + return new vscode.Range( + new vscode.Position(0, 0), + document.positionAt(document.getText().length), + ); +} + +function count(source: string, needle: string): number { + return source.split(needle).length - 1; +} diff --git a/editors/vscode/src/test/suite/fsharp-lsp-diagnostics.test.ts b/src/editors/vscode/src/test/suite/fsharp-lsp-diagnostics.test.ts similarity index 100% rename from editors/vscode/src/test/suite/fsharp-lsp-diagnostics.test.ts rename to src/editors/vscode/src/test/suite/fsharp-lsp-diagnostics.test.ts diff --git a/editors/vscode/src/test/suite/fsharp-lsp-hierarchy.test.ts b/src/editors/vscode/src/test/suite/fsharp-lsp-hierarchy.test.ts similarity index 100% rename from editors/vscode/src/test/suite/fsharp-lsp-hierarchy.test.ts rename to src/editors/vscode/src/test/suite/fsharp-lsp-hierarchy.test.ts diff --git a/src/editors/vscode/src/test/suite/fsharp-lsp-intelligence.test.ts b/src/editors/vscode/src/test/suite/fsharp-lsp-intelligence.test.ts new file mode 100644 index 00000000..113e708b --- /dev/null +++ b/src/editors/vscode/src/test/suite/fsharp-lsp-intelligence.test.ts @@ -0,0 +1,317 @@ +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { closeAllEditors, pollUntilResult } from './test-helpers'; +import { FSHARP_COLD_TIMEOUT_MS, openFSharpFixture, positionOf } from './fsharp-helpers'; +import { IGNORE_SOURCE } from './fsharp-refactor-fixtures'; +import { + FSHARP_REFACTOR_TIMEOUT_MS, + applyAction, + assertInsertion, + assertNoAction, + assertQuickFix, + diagnosticGone, + diagnosticWithCode, + openOverlay, + quickFixes, + resolvedQuickFixes, + singleEdit, + tokenRange, + undoAction, + uniqueAction, +} from './fsharp-refactor-test-kit'; +import { activateRealSharpLsp, revertDocument } from './refactor-test-helpers'; + +/** + * Blanket end-to-end coverage for F# code-intelligence features: + * completion, signature help, rename, inlay hints, and code actions. + * + * These run against the REAL release-built LSP + FCS sidecar and the static F# + * fixture project. Several of these features are not yet implemented in the F# + * sidecar — those tests are EXPECTED to fail until the corresponding feature is + * built (drive each via /fix-bug). F# is a first-class citizen; it must reach + * and exceed C# parity. + */ + +suite('F# LSP — Completion', defineCompletionSuite); + +function defineCompletionSuite(): void { + suiteTeardown(closeAllEditors); + teardown(closeAllEditors); + test('member completion after `.` on a class instance', verifyClassCompletion); + test('member completion after `.` on a record value', verifyRecordCompletion); + test('module-qualified completion after `.`', verifyModuleCompletion); + test('completion items carry concrete F# symbol kinds', verifyCompletionKind); +} + +suite('F# LSP — Signature Help', defineSignatureSuite); + +function defineSignatureSuite(): void { + suiteTeardown(closeAllEditors); + teardown(closeAllEditors); + test('signature help inside a constructor call', verifySignatureHelp); +} + +suite('F# LSP — Rename', defineRenameSuite); + +function defineRenameSuite(): void { + suiteTeardown(closeAllEditors); + teardown(closeAllEditors); + test('rename a function updates the declaration and every use site', verifySimpleRename); +} + +suite('F# LSP — Inlay Hints', defineInlaySuite); + +function defineInlaySuite(): void { + suiteTeardown(closeAllEditors); + teardown(closeAllEditors); + test('type inlay hints appear on unannotated let bindings', verifyInlayHints); +} + +suite('F# LSP — Code Actions', defineCodeActionSuite); + +function defineCodeActionSuite(): void { + suiteSetup(activateRealSharpLsp); + suiteTeardown(closeAllEditors); + teardown(closeAllEditors); + test('offers a fix to ignore an implicitly-discarded result', verifyIgnoreCodeActionTest); +} + +// ── Local helpers ───────────────────────────────────────────────── + +async function verifyClassCompletion(this: Mocha.Context): Promise { + this.timeout(FSHARP_COLD_TIMEOUT_MS + 30_000); + const usage = await openFSharpFixture('Usage.fs'); + const position = positionOf(usage.doc, 'greeter.Greet', 'greeter.'.length); + const labels = await pollCompletionLabels(usage.uri, position, (set) => set.has('Greet')); + assert.ok(labels.has('Greet'), 'completion after greeter. must include the Greet member'); +} + +async function verifyRecordCompletion(this: Mocha.Context): Promise { + this.timeout(FSHARP_COLD_TIMEOUT_MS + 30_000); + const usage = await openFSharpFixture('Usage.fs'); + const position = positionOf(usage.doc, 'alice.Name', 'alice.'.length); + const labels = await pollCompletionLabels( + usage.uri, + position, + (set) => set.has('Name') && set.has('Age'), + ); + assert.ok(labels.has('Name'), 'record completion must include Name'); + assert.ok(labels.has('Age'), 'record completion must include Age'); +} + +async function verifyModuleCompletion(this: Mocha.Context): Promise { + this.timeout(FSHARP_COLD_TIMEOUT_MS + 30_000); + const usage = await openFSharpFixture('Usage.fs'); + const position = positionOf(usage.doc, 'Geometry.totalArea shapes', 'Geometry.'.length); + const labels = await pollCompletionLabels( + usage.uri, + position, + (set) => set.has('totalArea') && set.has('area'), + ); + assert.ok(labels.has('area'), 'module completion must include area'); + assert.ok(labels.has('totalArea'), 'module completion must include totalArea'); + assert.ok(labels.has('describeParity'), 'module completion must include describeParity'); +} + +async function verifyCompletionKind(this: Mocha.Context): Promise { + this.timeout(FSHARP_COLD_TIMEOUT_MS + 30_000); + const usage = await openFSharpFixture('Usage.fs'); + const position = positionOf(usage.doc, 'alice.Name', 'alice.'.length); + const list = await pollCompletion(usage.uri, position, completionHasName); + const name = list.items.find((item) => item.label.toString() === 'Name'); + assert.ok(name, 'Name completion item must be present'); + assert.strictEqual( + name.kind, + vscode.CompletionItemKind.Field, + 'record field completion must be reported as a Field', + ); +} + +function completionHasName(list: vscode.CompletionList): boolean { + return list.items.some((item) => item.label.toString() === 'Name'); +} + +async function verifySignatureHelp(this: Mocha.Context): Promise { + this.timeout(FSHARP_COLD_TIMEOUT_MS + 15_000); + const usage = await openFSharpFixture('Usage.fs'); + const position = positionOf(usage.doc, 'Greeter("Hello")', 'Greeter('.length); + const help = await requestSignatureHelp(usage.uri, position); + assert.ok( + help.signatures.length > 0, + 'signature help must surface at least one signature for the Greeter constructor', + ); +} + +async function requestSignatureHelp( + uri: vscode.Uri, + position: vscode.Position, +): Promise { + return pollUntilResult( + async () => + (await vscode.commands.executeCommand( + 'vscode.executeSignatureHelpProvider', + uri, + position, + '(', + )) ?? new vscode.SignatureHelp(), + (help) => help.signatures.length > 0, + FSHARP_COLD_TIMEOUT_MS, + 2_000, + ); +} + +async function verifySimpleRename(this: Mocha.Context): Promise { + this.timeout(FSHARP_COLD_TIMEOUT_MS + 30_000); + const library = await openFSharpFixture('Library.fs'); + const position = positionOf(library.doc, 'let area', 'let '.length); + const edit = await requestRenameEdit(library.uri, position); + assert.ok(edit.size > 0, 'rename must produce a workspace edit'); + const libEdits = edit.get(library.uri); + assert.ok( + libEdits.length >= 2, + `rename must touch the declaration and the use site (got ${libEdits.length} edits)`, + ); + assert.ok(libEdits.every(isComputeAreaEdit), 'every rename edit must insert the new name'); +} + +function isComputeAreaEdit(edit: vscode.TextEdit): boolean { + return edit.newText === 'computeArea'; +} + +async function requestRenameEdit( + uri: vscode.Uri, + position: vscode.Position, +): Promise { + return pollUntilResult( + async () => + (await vscode.commands.executeCommand( + 'vscode.executeDocumentRenameProvider', + uri, + position, + 'computeArea', + )) ?? new vscode.WorkspaceEdit(), + (edit) => edit.size > 0, + FSHARP_COLD_TIMEOUT_MS, + 2_000, + ); +} + +async function verifyInlayHints(this: Mocha.Context): Promise { + this.timeout(FSHARP_COLD_TIMEOUT_MS + 15_000); + const usage = await openFSharpFixture('Usage.fs'); + const range = new vscode.Range(0, 0, usage.doc.lineCount, 0); + const hints = await requestInlayHints(usage.uri, range); + assert.ok(hints.length >= 1, `Usage.fs must surface ≥1 inlay hint, got ${hints.length}`); + const labels = hints.map(inlayLabel).join(' '); + assert.match(labels, /Greeter|float|string|int/, 'inlay hints must reveal inferred types'); +} + +async function requestInlayHints( + uri: vscode.Uri, + range: vscode.Range, +): Promise { + return pollUntilResult( + async () => + (await vscode.commands.executeCommand( + 'vscode.executeInlayHintProvider', + uri, + range, + )) ?? [], + (items) => items.length >= 1, + FSHARP_COLD_TIMEOUT_MS, + 2_000, + ); +} + +async function verifyIgnoreCodeActionTest(this: Mocha.Context): Promise { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS * 2); + await verifyIgnoreCodeAction(); +} + +async function pollCompletion( + uri: vscode.Uri, + position: vscode.Position, + predicate: (list: vscode.CompletionList) => boolean, + timeoutMs: number = FSHARP_COLD_TIMEOUT_MS, +): Promise { + return pollUntilResult( + async () => + (await vscode.commands.executeCommand( + 'vscode.executeCompletionItemProvider', + uri, + position, + '.', + )) ?? new vscode.CompletionList(), + predicate, + timeoutMs, + 2_000, + ); +} + +async function pollCompletionLabels( + uri: vscode.Uri, + position: vscode.Position, + predicate: (labels: Set) => boolean, + timeoutMs: number = FSHARP_COLD_TIMEOUT_MS, +): Promise> { + const list = await pollCompletion( + uri, + position, + (l) => predicate(new Set(l.items.map((i) => i.label.toString()))), + timeoutMs, + ); + return new Set(list.items.map((i) => i.label.toString())); +} + +function inlayLabel(hint: vscode.InlayHint): string { + if (typeof hint.label === 'string') { + return hint.label; + } + return hint.label.map((part) => part.value).join(''); +} + +async function verifyIgnoreCodeAction(): Promise { + const fixture = await openOverlay('fsharp/DiagnosticsTarget.fs', IGNORE_SOURCE); + try { + const range = tokenRange(fixture.document, '1 + 1'); + const action = await inspectIgnoreAction(fixture.uri, range); + assertInsertion(singleEdit(action, fixture.uri), ' |> ignore'); + await applyIgnoreAction(fixture, action); + await undoAction(fixture.document, IGNORE_SOURCE); + const replay = await resolvedQuickFixes(fixture.uri, range, "Add '|> ignore'"); + assertQuickFix(uniqueAction(replay, "Add '|> ignore'"), "Add '|> ignore'", true); + } finally { + await revertDocument(fixture.document); + } +} + +async function inspectIgnoreAction( + uri: vscode.Uri, + range: vscode.Range, +): Promise { + const diagnostics = await diagnosticWithCode(uri, 'FS0020'); + assert.ok(diagnostics.some((item) => item.range.intersection(range) !== undefined)); + const raw = uniqueAction(await quickFixes(uri, range), "Add '|> ignore'"); + assert.strictEqual(raw.edit, undefined); + assert.strictEqual(raw.kind?.value, vscode.CodeActionKind.QuickFix.value); + assert.strictEqual(raw.isPreferred, true); + const resolved = await resolvedQuickFixes(uri, range, "Add '|> ignore'"); + const action = uniqueAction(resolved, "Add '|> ignore'"); + assertQuickFix(action, "Add '|> ignore'", true); + return action; +} + +async function applyIgnoreAction( + fixture: Awaited>, + action: vscode.CodeAction, +): Promise { + const version = fixture.document.version; + const snapshots = await applyAction(action); + assert.strictEqual(snapshots.length, 1); + assert.ok(fixture.document.version > version); + assert.strictEqual(fixture.document.getText(), IGNORE_SOURCE.replace('1 + 1', '1 + 1 |> ignore')); + assert.ok(fixture.document.isDirty); + await diagnosticGone(fixture.uri, 'FS0020'); + const actions = await quickFixes(fixture.uri, tokenRange(fixture.document, '1 + 1')); + assertNoAction(actions, "Add '|> ignore'"); +} diff --git a/editors/vscode/src/test/suite/fsharp-lsp-navigation.test.ts b/src/editors/vscode/src/test/suite/fsharp-lsp-navigation.test.ts similarity index 100% rename from editors/vscode/src/test/suite/fsharp-lsp-navigation.test.ts rename to src/editors/vscode/src/test/suite/fsharp-lsp-navigation.test.ts diff --git a/src/editors/vscode/src/test/suite/fsharp-lsp-rename-edge.test.ts b/src/editors/vscode/src/test/suite/fsharp-lsp-rename-edge.test.ts new file mode 100644 index 00000000..0802dde9 --- /dev/null +++ b/src/editors/vscode/src/test/suite/fsharp-lsp-rename-edge.test.ts @@ -0,0 +1,426 @@ +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { + RENAME_EDGE_SOURCE, + RENAME_DECLARATIONS_SOURCE, + RENAME_NAMESPACE_SOURCE, + RENAME_NAMESPACE_USAGE_SOURCE, + RENAME_USAGES_SOURCE, +} from './fsharp-rename-fixtures'; +import { + FSHARP_REFACTOR_TIMEOUT_MS, + changedFileNames, + editCount, + openOverlay, + requestPrepareRename, + requestRename, + tokenRange, + undoAction, +} from './fsharp-refactor-test-kit'; +import { + activateRealSharpLsp, + applyWorkspaceEdit, + assertWorkspaceEditSafe, + revertDocument, + waitForMatchingDiagnostics, +} from './refactor-test-helpers'; +import { closeAllEditors } from './test-helpers'; + +// Real-LSP rejection/live-overlay boundaries. [RENAME-FSHARP-PREPARE] [RENAME-FSHARP-APPLY] +const TARGET_FILE = 'fsharp/RenameEdge.fs'; +const DECLARATIONS_FILE = 'fsharp/RenameDeclarations.fs'; +const USAGES_FILE = 'fsharp/RenameUsages.fs'; +const NAMESPACE_FILE = 'fsharp/RenameNamespace.fs'; +const NAMESPACE_USAGE_FILE = 'fsharp/RenameNamespaceUsage.fs'; +const VALID_NAMES = ['renamedName', "renamedName'", '``renamed value``'] as const; +const INVALID_NAMES = ['', '1bad', 'bad-name', 'two words', 'let', 'value.with.dot'] as const; +type OpenOverlay = Awaited>; + +suite('F# real LSP — rename edge cases', defineRenameEdgeSuite); + +function defineRenameEdgeSuite(): void { + suiteSetup(activateRealSharpLsp); + teardown(closeAllEditors); + suiteTeardown(closeAllEditors); + registerValidNameTests(); + registerInvalidNameTests(); + registerMetadataTests(); + registerRenameBoundaryTests(); +} + +function registerValidNameTests(): void { + for (const newName of VALID_NAMES) { + test(`unsaved overlay renames to ${JSON.stringify(newName)} and undoes cleanly`, async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS * 2); + await runUnsavedRename(newName); + }); + } +} + +function registerInvalidNameTests(): void { + for (const invalidName of INVALID_NAMES) { + test(`rejects invalid F# identifier ${JSON.stringify(invalidName)}`, async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS); + await assertInvalidName(invalidName); + }); + } +} + +function registerMetadataTests(): void { + for (const metadataName of ['System', 'String', 'Empty'] as const) { + test(`rejects external metadata symbol ${metadataName}`, async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS); + await assertMetadataRejected(metadataName); + }); + } +} + +function registerRenameBoundaryTests(): void { + test('rejects prepare and rename on whitespace, literals, comments, and strings', async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS); + await assertTriviaRejected(); + }); + test('renames an indexer and keeps .[i] call sites compiling via DefaultMember', async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS * 2); + await assertIndexerRename(); + }); + + test('renames a namespace across files and reverses the rename', async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS * 2); + await assertNamespaceRename(); + }); +} + +async function runUnsavedRename(newName: string): Promise { + const fixture = await openOverlay(TARGET_FILE, RENAME_EDGE_SOURCE); + try { + const range = tokenRange(fixture.document, 'unsavedName'); + await assertPrepareAtEveryTokenPosition(fixture.uri, range, 'unsavedName'); + const edit = await requestRename( + fixture.uri, + range.start.translate(0, 1), + newName, + FSHARP_REFACTOR_TIMEOUT_MS, + ); + await assertUnsavedEdit(edit, fixture.uri, 'unsavedName', newName); + await applyUnsavedEdit(fixture, edit, newName); + await undoUnsavedEdit(fixture, newName); + } finally { + await revertDocument(fixture.document); + } +} + +async function assertPrepareAtEveryTokenPosition( + uri: vscode.Uri, + range: vscode.Range, + placeholder: string, +): Promise { + assert.ok(range.isSingleLine && !range.isEmpty); + for (let offset = 0; offset < range.end.character - range.start.character; offset += 1) { + const position = range.start.translate(0, offset); + const prepare = await requestPrepareRename(uri, position); + assert.ok(prepare); + assert.strictEqual(prepare.placeholder, placeholder); + assert.strictEqual(prepare.range.start.line, range.start.line); + assert.strictEqual(prepare.range.start.character, range.start.character); + assert.strictEqual(prepare.range.end.line, range.end.line); + assert.strictEqual(prepare.range.end.character, range.end.character); + } +} + +async function assertUnsavedEdit( + edit: vscode.WorkspaceEdit, + uri: vscode.Uri, + oldName: string, + newName: string, +): Promise { + assert.strictEqual(edit.size, 1); + assert.strictEqual(editCount(edit), 2); + assert.strictEqual(edit.get(uri).length, 2); + const snapshots = await assertWorkspaceEditSafe(edit); + assert.strictEqual(snapshots.length, 1); + assert.deepStrictEqual(snapshots[0]?.replacedText, [oldName, oldName]); + assert.ok(snapshots[0]?.edits.every((item) => item.newText === newName)); + assert.ok(snapshots[0]?.edits.every((item) => !item.range.isEmpty && item.range.isSingleLine)); +} + +async function applyUnsavedEdit( + fixture: Awaited>, + edit: vscode.WorkspaceEdit, + newName: string, +): Promise { + const version = fixture.document.version; + await applyWorkspaceEdit(edit); + assert.ok(fixture.document.version > version); + assert.strictEqual(fixture.document.getText(), renamedEdgeSource(newName)); + assert.ok(fixture.document.getText().includes('// unsavedName in a comment')); + assert.ok(fixture.document.getText().includes('let stringValue = "unsavedName"')); + assert.ok(fixture.document.isDirty); + await assertNoErrors(fixture.uri); + const renamedRange = tokenRange(fixture.document, newName); + await assertPrepareAtEveryTokenPosition(fixture.uri, renamedRange, newName); + await assertReverseRenameAtBoundaries(fixture.uri, renamedRange, newName); +} + +async function assertReverseRenameAtBoundaries( + uri: vscode.Uri, + range: vscode.Range, + currentName: string, +): Promise { + const positions = [ + range.start, + range.start.translate(0, 1), + range.end.translate(0, -2), + range.end.translate(0, -1), + ]; + for (const position of positions) { + const reverse = await requestRename(uri, position, 'unsavedName', FSHARP_REFACTOR_TIMEOUT_MS); + await assertUnsavedEdit(reverse, uri, currentName, 'unsavedName'); + } +} + +async function undoUnsavedEdit( + fixture: Awaited>, + newName: string, +): Promise { + await undoAction(fixture.document, RENAME_EDGE_SOURCE); + const range = tokenRange(fixture.document, 'unsavedName'); + const replay = await requestRename( + fixture.uri, + range.start.translate(0, 1), + newName, + FSHARP_REFACTOR_TIMEOUT_MS, + ); + await assertUnsavedEdit(replay, fixture.uri, 'unsavedName', newName); + assert.strictEqual(fixture.document.getText(), RENAME_EDGE_SOURCE); +} + +function renamedEdgeSource(newName: string): string { + return RENAME_EDGE_SOURCE.replace('let unsavedName value', `let ${newName} value`).replace( + '= unsavedName 2', + `= ${newName} 2`, + ); +} + +async function assertInvalidName(invalidName: string): Promise { + const fixture = await openOverlay(TARGET_FILE, RENAME_EDGE_SOURCE); + try { + const range = tokenRange(fixture.document, 'unsavedName'); + const prepare = await requestPrepareRename(fixture.uri, range.start.translate(0, 1)); + assert.ok(prepare, 'the source symbol itself must remain renameable'); + const beforeVersion = fixture.document.version; + await assertInvalidRenameError(fixture.uri, range.start, invalidName); + assert.strictEqual(fixture.document.version, beforeVersion); + assert.strictEqual(fixture.document.getText(), RENAME_EDGE_SOURCE); + assert.ok(fixture.document.isDirty); + } finally { + await revertDocument(fixture.document); + } +} + +async function assertTriviaRejected(): Promise { + const fixture = await openOverlay(TARGET_FILE, RENAME_EDGE_SOURCE); + try { + const positions = triviaPositions(fixture.document); + for (const position of positions) { + assert.strictEqual(await requestPrepareRename(fixture.uri, position), null); + const result = await executeRenameWithoutEdit(fixture.uri, position, 'renamedTrivia'); + assert.ok(result === undefined || result.size === 0); + } + assert.strictEqual(fixture.document.getText(), RENAME_EDGE_SOURCE); + assert.ok(fixture.document.isDirty); + } finally { + await revertDocument(fixture.document); + } +} + +function triviaPositions(document: vscode.TextDocument): readonly vscode.Position[] { + return [ + new vscode.Position(1, 0), + tokenRange(document, '2').start, + tokenRange(document, 'unsavedName', 2).start.translate(0, 1), + tokenRange(document, 'unsavedName', 3).start.translate(0, 1), + ]; +} + +async function assertMetadataRejected(name: string): Promise { + const fixture = await openOverlay(TARGET_FILE, RENAME_EDGE_SOURCE); + try { + const range = tokenRange(fixture.document, name); + assert.strictEqual(await requestPrepareRename(fixture.uri, range.start.translate(0, 1)), null); + const result = await executeRenameWithoutEdit(fixture.uri, range.start, `Renamed${name}`); + assert.ok(result === undefined || result.size === 0); + assert.strictEqual(fixture.document.getText(), RENAME_EDGE_SOURCE); + assert.ok(fixture.document.isDirty); + } finally { + await revertDocument(fixture.document); + } +} + +// An `x.[i]` call site carries no `Item` token, so renaming the member alone would +// break it. The rename must also write DefaultMember metadata for the new name — +// the usages file staying error-free is the proof it still binds. +async function assertIndexerRename(): Promise { + const declarations = await openOverlay(DECLARATIONS_FILE, RENAME_DECLARATIONS_SOURCE); + const usages = await openOverlay(USAGES_FILE, RENAME_USAGES_SOURCE); + try { + await runIndexerLifecycle(declarations, usages); + } finally { + await revertDocument(usages.document); + await revertDocument(declarations.document); + } +} + +async function runIndexerLifecycle(declarations: OpenOverlay, usages: OpenOverlay): Promise { + const range = tokenRange(declarations.document, 'Item'); + await assertPrepareAtEveryTokenPosition(declarations.uri, range, 'Item'); + const edit = await requestRename( + declarations.uri, + range.start.translate(0, 1), + 'Lookup', + FSHARP_REFACTOR_TIMEOUT_MS, + ); + assert.deepStrictEqual(changedFileNames(edit).sort(), ['RenameDeclarations.fs']); + assert.strictEqual(editCount(edit), 2, 'the member rename and its DefaultMember metadata'); + await applyWorkspaceEdit(edit); + assert.strictEqual(declarations.document.getText(), renamedIndexerSource()); + assert.strictEqual(usages.document.getText(), RENAME_USAGES_SOURCE); + await assertNoErrors(declarations.uri); + await assertNoErrors(usages.uri); + await undoAction(declarations.document, RENAME_DECLARATIONS_SOURCE); + await assertPrepareAtEveryTokenPosition(declarations.uri, range, 'Item'); +} + +function renamedIndexerSource(): string { + return RENAME_DECLARATIONS_SOURCE.replace( + 'type IndexerThing() =', + '[]\ntype IndexerThing() =', + ).replace('member _.Item with', 'member _.Lookup with'); +} + +async function assertNamespaceRename(): Promise { + const definition = await openOverlay(NAMESPACE_FILE, RENAME_NAMESPACE_SOURCE); + const usage = await openOverlay(NAMESPACE_USAGE_FILE, RENAME_NAMESPACE_USAGE_SOURCE); + try { + await runNamespaceLifecycle(definition, usage); + } finally { + await revertDocument(usage.document); + await revertDocument(definition.document); + } +} + +async function runNamespaceLifecycle(definition: OpenOverlay, usage: OpenOverlay): Promise { + const range = tokenRange(definition.document, 'RenameNamespace'); + await assertPrepareAtEveryTokenPosition(definition.uri, range, 'RenameNamespace'); + const edit = await requestRename( + definition.uri, + range.start.translate(0, 1), + 'RenamedNamespace', + FSHARP_REFACTOR_TIMEOUT_MS, + ); + await assertNamespaceEdit(edit, 'RenameNamespace', 'RenamedNamespace'); + await applyWorkspaceEdit(edit); + assertNamespaceTexts(definition.document, usage.document, 'RenamedNamespace'); + await assertNoErrors(definition.uri); + await assertNoErrors(usage.uri); + await reverseNamespaceRename(definition, usage); +} + +async function assertNamespaceEdit( + edit: vscode.WorkspaceEdit, + oldName: string, + newName: string, +): Promise { + assert.strictEqual(edit.size, 2); + assert.strictEqual(editCount(edit), 2); + assert.deepStrictEqual(changedFileNames(edit).sort(), [ + 'RenameNamespace.fs', + 'RenameNamespaceUsage.fs', + ]); + const snapshots = await assertWorkspaceEditSafe(edit); + assert.strictEqual(snapshots.length, 2); + assert.ok(snapshots.flatMap((item) => item.replacedText).every((text) => text === oldName)); + assert.ok(snapshots.flatMap((item) => item.edits).every((item) => item.newText === newName)); +} + +function assertNamespaceTexts( + definition: vscode.TextDocument, + usage: vscode.TextDocument, + name: string, +): void { + assert.strictEqual( + definition.getText(), + RENAME_NAMESPACE_SOURCE.replace('RenameNamespace', name), + ); + assert.strictEqual( + usage.getText(), + RENAME_NAMESPACE_USAGE_SOURCE.replace('RenameNamespace', name), + ); + assert.ok(definition.isDirty); + assert.ok(usage.isDirty); +} + +async function reverseNamespaceRename( + definition: Awaited>, + usage: Awaited>, +): Promise { + const range = tokenRange(definition.document, 'RenamedNamespace'); + await assertPrepareAtEveryTokenPosition(definition.uri, range, 'RenamedNamespace'); + const reverse = await requestRename( + definition.uri, + range.start.translate(0, 1), + 'RenameNamespace', + FSHARP_REFACTOR_TIMEOUT_MS, + ); + await assertNamespaceEdit(reverse, 'RenamedNamespace', 'RenameNamespace'); + await applyWorkspaceEdit(reverse); + assertNamespaceTexts(definition.document, usage.document, 'RenameNamespace'); + const restored = tokenRange(definition.document, 'RenameNamespace'); + await assertPrepareAtEveryTokenPosition(definition.uri, restored, 'RenameNamespace'); +} + +async function executeRenameWithoutEdit( + uri: vscode.Uri, + position: vscode.Position, + newName: string, +): Promise { + try { + return await vscode.commands.executeCommand( + 'vscode.executeDocumentRenameProvider', + uri, + position, + newName, + ); + } catch (error: unknown) { + // VS Code resolves the rename location before editing, so a server that + // refuses prepareRename surfaces the editor's own refusal, not "No result". + assert.match(String(error), /No result|can't be renamed/i); + return undefined; + } +} + +async function assertInvalidRenameError( + uri: vscode.Uri, + position: vscode.Position, + newName: string, +): Promise { + await assert.rejects( + async () => + vscode.commands.executeCommand( + 'vscode.executeDocumentRenameProvider', + uri, + position, + newName, + ), + /Invalid F# rename name:/, + ); +} + +async function assertNoErrors(uri: vscode.Uri): Promise { + const diagnostics = await waitForMatchingDiagnostics( + uri, + (items) => items.every((item) => item.severity !== vscode.DiagnosticSeverity.Error), + FSHARP_REFACTOR_TIMEOUT_MS, + ); + assert.ok(diagnostics.every((item) => item.severity !== vscode.DiagnosticSeverity.Error)); +} diff --git a/src/editors/vscode/src/test/suite/fsharp-lsp-rename-symbols.test.ts b/src/editors/vscode/src/test/suite/fsharp-lsp-rename-symbols.test.ts new file mode 100644 index 00000000..36b5a10f --- /dev/null +++ b/src/editors/vscode/src/test/suite/fsharp-lsp-rename-symbols.test.ts @@ -0,0 +1,299 @@ +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { + RENAME_DECLARATIONS_SOURCE, + RENAME_SCENARIOS, + RENAME_SENTINEL, + RENAME_USAGES_SOURCE, + type RenameScenario, +} from './fsharp-rename-fixtures'; +import { + FSHARP_REFACTOR_TIMEOUT_MS, + changedFileNames, + countOccurrences, + editCount, + openOverlay, + requestPrepareRename, + requestRename, + semanticTokenRange, + undoAction, +} from './fsharp-refactor-test-kit'; +import { + activateRealSharpLsp, + applyWorkspaceEdit, + assertWorkspaceEditSafe, + revertDocument, + type WorkspaceEditSnapshot, + waitForMatchingDiagnostics, +} from './refactor-test-helpers'; +import { closeAllEditors } from './test-helpers'; + +// Project-wide matrix through the shipped client. [RENAME-FSHARP-PREPARE] [RENAME-FSHARP-APPLY] +const DECLARATIONS_FILE = 'fsharp/RenameDeclarations.fs'; +const USAGES_FILE = 'fsharp/RenameUsages.fs'; + +interface RenameFixture { + readonly declarations: Awaited>; + readonly usages: Awaited>; +} + +suite('F# real LSP — rename every symbol category', () => { + suiteSetup(activateRealSharpLsp); + teardown(closeAllEditors); + suiteTeardown(closeAllEditors); + + for (const scenario of RENAME_SCENARIOS) { + test(`${scenario.name}: prepare, multi-edit apply, recheck, and undo`, async function () { + this.timeout(FSHARP_REFACTOR_TIMEOUT_MS * 3); + await runRename(scenario); + }); + } +}); + +async function runRename(scenario: RenameScenario): Promise { + const fixture = await openRenameFixture(); + try { + const range = await scenarioRange(fixture.declarations, scenario); + const position = interiorPosition(range); + await assertPrepare(fixture.declarations.uri, range, position, scenario.target); + const edit = await requestRename( + fixture.declarations.uri, + position, + scenario.newName, + FSHARP_REFACTOR_TIMEOUT_MS, + ); + const snapshots = await inspectRenameEdit(edit, scenario); + await applyAndVerify(fixture, edit, snapshots, scenario); + await undoAndRequery(fixture, scenario); + } finally { + await cleanupRenameFixture(fixture); + } +} + +async function openRenameFixture(): Promise { + const declarations = await openOverlay(DECLARATIONS_FILE, RENAME_DECLARATIONS_SOURCE); + const usages = await openOverlay(USAGES_FILE, RENAME_USAGES_SOURCE); + assert.ok(declarations.document.isDirty); + assert.ok(usages.document.isDirty); + assert.notStrictEqual(declarations.uri.toString(), usages.uri.toString()); + return { declarations, usages }; +} + +function interiorPosition(range: vscode.Range): vscode.Position { + const width = range.end.character - range.start.character; + return range.start.translate(0, Math.min(1, Math.max(0, width - 1))); +} + +async function scenarioRange( + fixture: Awaited>, + scenario: RenameScenario, +): Promise { + return semanticTokenRange( + fixture.uri, + fixture.document, + scenario.target, + scenario.targetOccurrence ?? 0, + ); +} + +async function assertPrepare( + uri: vscode.Uri, + expected: vscode.Range, + position: vscode.Position, + placeholder: string, +): Promise { + const prepare = await requestPrepareRename(uri, position); + assert.ok(prepare, `${placeholder} must support prepareRename`); + assert.strictEqual(prepare.placeholder, placeholder); + assert.strictEqual(prepare.range.start.line, expected.start.line); + assert.strictEqual(prepare.range.start.character, expected.start.character); + assert.strictEqual(prepare.range.end.line, expected.end.line); + assert.strictEqual(prepare.range.end.character, expected.end.character); +} + +async function inspectRenameEdit( + edit: vscode.WorkspaceEdit, + scenario: RenameScenario, +): Promise { + assert.strictEqual(editCount(edit), scenario.minimumEdits, 'rename edit count must be exact'); + assert.strictEqual(edit.size, scenario.crossFile ? 2 : 1); + assert.deepStrictEqual(changedFileNames(edit).sort(), expectedFiles(scenario)); + const snapshots = await assertWorkspaceEditSafe(edit); + assert.strictEqual(snapshots.length, scenario.crossFile ? 2 : 1); + assert.ok(snapshots.every((snapshot) => snapshot.edits.length > 0)); + assertSnapshotContents(snapshots, scenario); + return snapshots; +} + +function assertSnapshotContents( + snapshots: Awaited>, + scenario: RenameScenario, +): void { + const edits = snapshots.flatMap((snapshot) => snapshot.edits); + const replaced = snapshots.flatMap((snapshot) => snapshot.replacedText); + assert.strictEqual(edits.length, scenario.minimumEdits); + assert.strictEqual(replaced.length, scenario.minimumEdits); + assert.ok(edits.every((textEdit) => textEdit.newText === scenario.newName)); + assert.ok(edits.every((textEdit) => !textEdit.range.isEmpty && textEdit.range.isSingleLine)); + assert.ok(replaced.every((text) => text === scenario.target)); +} + +function expectedFiles(scenario: RenameScenario): string[] { + return scenario.crossFile + ? ['RenameDeclarations.fs', 'RenameUsages.fs'] + : ['RenameDeclarations.fs']; +} + +async function applyAndVerify( + fixture: RenameFixture, + edit: vscode.WorkspaceEdit, + before: readonly WorkspaceEditSnapshot[], + scenario: RenameScenario, +): Promise { + const declarationVersion = fixture.declarations.document.version; + const usageVersion = fixture.usages.document.version; + const expected = new Map( + before.map((snapshot) => [snapshot.uri.toString(), editedText(snapshot)]), + ); + const snapshots = await applyWorkspaceEdit(edit); + assert.strictEqual(snapshots.length, scenario.crossFile ? 2 : 1); + assert.ok(fixture.declarations.document.version > declarationVersion); + assertUsageVersion(fixture, usageVersion, scenario.crossFile); + assertAppliedText(before, expected, scenario); + assertRenamedSentinels(fixture); + await assertNoCompilerErrors(fixture); + await assertRenamedPrepare(fixture, scenario); +} + +function editedText(snapshot: WorkspaceEditSnapshot): string { + assert.ok(snapshot.document, `rename target must be open: ${snapshot.uri.fsPath}`); + const document = snapshot.document; + const edits = snapshot.edits + .map((edit) => ({ + start: document.offsetAt(edit.range.start), + end: document.offsetAt(edit.range.end), + newText: edit.newText, + })) + .sort((left, right) => right.start - left.start); + return edits.reduce( + (text, edit) => text.slice(0, edit.start) + edit.newText + text.slice(edit.end), + snapshot.textBefore, + ); +} + +function assertAppliedText( + before: readonly WorkspaceEditSnapshot[], + expected: ReadonlyMap, + scenario: RenameScenario, +): void { + for (const snapshot of before) { + assert.ok(snapshot.document); + const after = snapshot.document.getText(); + assert.strictEqual(after, expected.get(snapshot.uri.toString())); + assert.notStrictEqual(after, snapshot.textBefore); + assertOccurrenceDeltas(snapshot, after, scenario); + } +} + +function assertOccurrenceDeltas( + snapshot: WorkspaceEditSnapshot, + after: string, + scenario: RenameScenario, +): void { + const beforeCode = sourceWithoutSentinels(snapshot.textBefore); + const afterCode = sourceWithoutSentinels(after); + for (const needle of [scenario.newName, scenario.target]) { + const expected = snapshot.edits.length * occurrenceDelta(scenario, needle); + assert.strictEqual( + countOccurrences(afterCode, needle) - countOccurrences(beforeCode, needle), + expected, + ); + } +} + +function occurrenceDelta(scenario: RenameScenario, needle: string): number { + return countOccurrences(scenario.newName, needle) - countOccurrences(scenario.target, needle); +} + +async function assertRenamedPrepare( + fixture: RenameFixture, + scenario: RenameScenario, +): Promise { + const newRange = await semanticTokenRange( + fixture.declarations.uri, + fixture.declarations.document, + scenario.newName, + ); + await assertPrepare( + fixture.declarations.uri, + newRange, + interiorPosition(newRange), + scenario.newName, + ); +} + +function assertUsageVersion(fixture: RenameFixture, before: number, changed: boolean): void { + if (changed) assert.ok(fixture.usages.document.version > before); + else assert.strictEqual(fixture.usages.document.version, before); + assert.ok(fixture.declarations.document.isDirty); + assert.ok(fixture.usages.document.isDirty); +} + +function assertRenamedSentinels(fixture: RenameFixture): void { + assertSentinels(fixture.declarations.document.getText()); + assertSentinels(fixture.usages.document.getText()); +} + +function sourceWithoutSentinels(source: string): string { + return source + .split('\n') + .filter((line) => !line.trimStart().startsWith('//') && !line.includes('let textSentinel =')) + .join('\n'); +} + +function assertSentinels(source: string): void { + assert.ok(source.includes(`// ${RENAME_SENTINEL}`)); + assert.ok(source.includes(`let textSentinel = "${RENAME_SENTINEL}"`)); + assert.strictEqual(countOccurrences(source, RENAME_SENTINEL), 2); +} + +async function assertNoCompilerErrors(fixture: RenameFixture): Promise { + const noErrors = (items: readonly vscode.Diagnostic[]): boolean => + items.every((item) => item.severity !== vscode.DiagnosticSeverity.Error); + const declarations = await waitForMatchingDiagnostics( + fixture.declarations.uri, + noErrors, + FSHARP_REFACTOR_TIMEOUT_MS, + ); + const usages = await waitForMatchingDiagnostics( + fixture.usages.uri, + noErrors, + FSHARP_REFACTOR_TIMEOUT_MS, + ); + assert.ok(noErrors(declarations)); + assert.ok(noErrors(usages)); +} + +async function undoAndRequery(fixture: RenameFixture, scenario: RenameScenario): Promise { + await undoAction(fixture.declarations.document, RENAME_DECLARATIONS_SOURCE); + assert.strictEqual(fixture.usages.document.getText(), RENAME_USAGES_SOURCE); + assert.ok(fixture.usages.document.isDirty); + const range = await scenarioRange(fixture.declarations, scenario); + const position = interiorPosition(range); + await assertPrepare(fixture.declarations.uri, range, position, scenario.target); + const replay = await requestRename( + fixture.declarations.uri, + position, + scenario.newName, + FSHARP_REFACTOR_TIMEOUT_MS, + ); + assert.strictEqual(editCount(replay), scenario.minimumEdits); + assert.deepStrictEqual(changedFileNames(replay).sort(), expectedFiles(scenario)); +} + +async function cleanupRenameFixture(fixture: RenameFixture): Promise { + await revertDocument(fixture.usages.document); + await revertDocument(fixture.declarations.document); + assert.ok(!fixture.usages.document.isDirty); + assert.ok(!fixture.declarations.document.isDirty); +} diff --git a/editors/vscode/src/test/suite/fsharp-lsp-syntax.test.ts b/src/editors/vscode/src/test/suite/fsharp-lsp-syntax.test.ts similarity index 100% rename from editors/vscode/src/test/suite/fsharp-lsp-syntax.test.ts rename to src/editors/vscode/src/test/suite/fsharp-lsp-syntax.test.ts diff --git a/editors/vscode/src/test/suite/fsharp-lsp-workspace-symbol.test.ts b/src/editors/vscode/src/test/suite/fsharp-lsp-workspace-symbol.test.ts similarity index 99% rename from editors/vscode/src/test/suite/fsharp-lsp-workspace-symbol.test.ts rename to src/editors/vscode/src/test/suite/fsharp-lsp-workspace-symbol.test.ts index 970f8d56..62b73f9e 100644 --- a/editors/vscode/src/test/suite/fsharp-lsp-workspace-symbol.test.ts +++ b/src/editors/vscode/src/test/suite/fsharp-lsp-workspace-symbol.test.ts @@ -11,7 +11,7 @@ import { FSHARP_COLD_TIMEOUT_MS, openFSharpFixture } from './fsharp-helpers'; * sidecar's document symbols and merged into the standard workspace-symbol * response. The search covers OPEN documents, so each test opens the relevant F# * fixtures first, then drives several `executeWorkspaceSymbolProvider` queries with - * many assertions per query. [FS-WORKSPACE-SYMBOL] + * many assertions per query. [SHARPLSP-FEATURES-NAVIGATION] */ // VS Code numeric SymbolKind values (LSP enum). diff --git a/src/editors/vscode/src/test/suite/fsharp-refactor-fixtures.ts b/src/editors/vscode/src/test/suite/fsharp-refactor-fixtures.ts new file mode 100644 index 00000000..894479da --- /dev/null +++ b/src/editors/vscode/src/test/suite/fsharp-refactor-fixtures.ts @@ -0,0 +1,347 @@ +// Real-project overlay fixtures for [ANALYZERS-FSAC-PARITY] and [RENAME-TESTS]. + +export interface CodeFixScenario { + readonly name: string; + readonly source: string; + readonly target: string; + readonly title: string; + readonly diagnostic: string; + readonly replacement: string; + readonly occurrence?: number; +} + +export const OPEN_SCENARIOS: readonly CodeFixScenario[] = [ + { + name: 'System.IO Path', + source: + 'module FSharpFixtures.RefactorOpen\n\nlet value = Path.GetTempPath()\nlet sentinel = 41\n', + target: 'Path.GetTempPath', + title: "Add 'open System.IO'", + diagnostic: 'FS0039', + replacement: 'open System.IO\n', + }, + { + name: 'System.IO File', + source: + 'module FSharpFixtures.RefactorOpen\n\nlet value = File.Exists("item")\nlet sentinel = 41\n', + target: 'File.Exists', + title: "Add 'open System.IO'", + diagnostic: 'FS0039', + replacement: 'open System.IO\n', + }, + { + name: 'System.IO Directory', + source: + 'module FSharpFixtures.RefactorOpen\n\nlet value = Directory.GetCurrentDirectory()\nlet sentinel = 41\n', + target: 'Directory.GetCurrentDirectory', + title: "Add 'open System.IO'", + diagnostic: 'FS0039', + replacement: 'open System.IO\n', + }, + { + name: 'regular expressions', + source: + 'module FSharpFixtures.RefactorOpen\n\nlet value = Regex.IsMatch("abc", "a")\nlet sentinel = 42\n', + target: 'Regex.IsMatch', + title: "Add 'open System.Text.RegularExpressions'", + diagnostic: 'FS0039', + replacement: 'open System.Text.RegularExpressions\n', + }, + { + name: 'tasks', + source: 'module FSharpFixtures.RefactorOpen\n\nlet value = Task.Delay(1)\nlet sentinel = 43\n', + target: 'Task.Delay', + title: "Add 'open System.Threading.Tasks'", + diagnostic: 'FS0039', + replacement: 'open System.Threading.Tasks\n', + }, + { + name: 'generic Dictionary', + source: + 'module FSharpFixtures.RefactorOpen\n\nlet value = Dictionary()\nlet sentinel = 44\n', + target: 'Dictionary', + title: "Add 'open System.Collections.Generic'", + diagnostic: 'FS0039', + replacement: 'open System.Collections.Generic\n', + }, +]; + +export function falseOpenSource(name: string): string { + return `module FSharpFixtures.RefactorOpen\n\nlet value = ${name} 1\nlet sentinel = "heuristic-negative"\n`; +} + +export const UNUSED_VALUE_SOURCE = + 'module FSharpFixtures.RefactorUnused\n\nlet run () =\n let unusedValue = 42\n ()\n\nlet sentinel = 45\n'; + +export const IGNORE_SOURCE = + 'module FSharpFixtures.RefactorIgnore\n\nlet run () =\n 1 + 1\n ()\n\nlet sentinel = 46\n'; + +export const MATCH_FIX_SOURCE = `module FSharpFixtures.RefactorMatch + +type Shape = A | B + +let incomplete shape = + match shape with + | A -> 1 + +let later shape = + match shape with + | A -> 10 + | B -> 20 + +let redundant shape = + match shape with + | A -> 100 + | B -> 200 + | A -> 300 + +let sentinel = 47 +`; + +type ConversionInput = readonly [string, string, string, string, string, string]; + +const CONVERSION_INPUTS: readonly ConversionInput[] = [ + ['float from int', 'float', 'int', '1', "Convert to float using 'float'", '(float actualValue)'], + [ + 'float from decimal', + 'float', + 'decimal', + '1M', + "Convert to float using 'float'", + '(float actualValue)', + ], + ['int from float', 'int', 'float', '1.0', "Convert to int using 'int'", '(int actualValue)'], + [ + 'string from int', + 'string', + 'int', + '1', + "Convert to string using 'string'", + '(string actualValue)', + ], + [ + 'float32 from float', + 'float32', + 'float', + '1.0', + "Convert to float32 using 'float32'", + '(float32 actualValue)', + ], + [ + 'float from float32', + 'float', + 'float32', + '1.0f', + "Convert to float using 'float'", + '(float actualValue)', + ], + ['int64 from int', 'int64', 'int', '1', "Convert to int64 using 'int64'", '(int64 actualValue)'], + [ + 'int64 from float', + 'int64', + 'float', + '1.0', + "Convert to int64 using 'int64'", + '(int64 actualValue)', + ], + ['int from int64', 'int', 'int64', '1L', "Convert to int using 'int'", '(int actualValue)'], +]; + +const IMPLICIT_CONVERSION_NAMES = new Set(['float from int', 'int64 from int']); + +function conversionScenario([ + name, + expected, + actualType, + literal, + title, + replacement, +]: ConversionInput): CodeFixScenario { + return { + name, + source: `module FSharpFixtures.RefactorConversion\n\nlet accept (value: ${expected}) = value\nlet actualValue: ${actualType} = ${literal}\nlet value = accept actualValue\nlet sentinel = 48\n`, + target: 'actualValue', + title, + diagnostic: 'FS0001', + replacement, + occurrence: 1, + }; +} + +export const CONVERSION_SCENARIOS: readonly CodeFixScenario[] = CONVERSION_INPUTS.filter( + ([name]) => !IMPLICIT_CONVERSION_NAMES.has(name), +).map(conversionScenario); + +export const IMPLICIT_CONVERSION_SCENARIOS: readonly CodeFixScenario[] = CONVERSION_INPUTS.filter( + ([name]) => IMPLICIT_CONVERSION_NAMES.has(name), +).map(conversionScenario); + +export const UNSUPPORTED_CONVERSION_SOURCE = + 'module FSharpFixtures.RefactorConversion\n\nlet value : bool = 1\nlet sentinel = 49\n'; + +export const UNION_SOURCE = `module FSharpFixtures.RefactorUnion + +type Payload = + | Anchor + | Empty + | One of int + | Many of int * string + +let render payload = + match payload with + | Anchor -> "anchor" + +let sentinel = "union-sentinel" +`; + +export const MATCH_BANG_SOURCE = `module FSharpFixtures.RefactorMatchBang + +open System.Threading.Tasks + +type Choice = First | Second + +let choose (pending: Task) = task { + match! pending with + | First -> return 1 +} + +let sentinel = 50 +`; + +export const EXHAUSTIVE_UNION_SOURCE = `module FSharpFixtures.RefactorUnion + +type Choice = First | Second +let choose value = + match value with + | First -> 1 + | Second -> 2 +`; + +export const RECORD_SOURCE = `module FSharpFixtures.RefactorRecord + +type Defaults = + { Keep: int + Text: string + Number: int + Number32: int32 + Number64: int64 + Float: float + Double: double + Money: decimal + Flag: bool + Maybe: int option + Items: int list + Values: int array + Other: System.Guid } + +let value: Defaults = { Keep = 1 } +let sentinel = 51 +`; + +export const COMPLETE_RECORD_SOURCE = `module FSharpFixtures.RefactorRecord + +type Point = { X: int; Y: int } +let point: Point = { X = 1; Y = 2 } +`; + +export const RECORD_COPY_UPDATE_SOURCE = `module FSharpFixtures.RefactorRecord + +type Point = { X: int; Y: int } +let point: Point = { X = 1; Y = 2 } +let updated = { point with X = 3 } +let sentinel = 56 +`; + +export const WILDCARD_UNION_SOURCE = `module FSharpFixtures.RefactorUnion + +type Choice = First | Second | Third +let choose value = + match value with + | _ -> 0 +let sentinel = 57 +`; + +export const PARTIAL_INTERFACE_SOURCE = `module FSharpFixtures.RefactorInterface + +type IShape = + abstract member Area: unit -> float + abstract member Name: string + +type Square() = + interface IShape with + member _.Name = "square" + +let sentinel = 52 +`; + +export const GENERIC_INTERFACE_SOURCE = `module FSharpFixtures.RefactorInterface + +type IBox<'T> = + abstract member Value: 'T + abstract member Map: 'T -> 'T + +type StringBox() = + interface IBox with + member _.Value = "ready" + +let sentinel = 53 +`; + +export const NESTED_GENERIC_INTERFACE_SOURCE = `module FSharpFixtures.RefactorInterface + +type IOther = + abstract member Code: string + +type IWrapper<'T> = + abstract member Wrap: 'T -> 'T + +type Wrapper() = + interface IWrapper with + +let sentinel = 54 +`; + +export const OBJECT_EXPRESSION_INTERFACE_SOURCE = `module FSharpFixtures.RefactorInterface + +type IShape = + abstract member Area: unit -> float + abstract member Name: string + +let shape = + { new IShape with + member _.Name = "shape" } + +let sentinel = 55 +`; + +export const EMPTY_INTERFACE_WITH_SOURCE = `module FSharpFixtures.RefactorInterface + +type IShape = + abstract member Area: unit -> float + +type Square() = + interface IShape with + +let sentinel = 52 +`; + +export const EMPTY_INTERFACE_SOURCE = `module FSharpFixtures.RefactorInterface + +type IShape = + abstract member Area: unit -> float + +type Square() = + interface IShape + +let sentinel = 52 +`; + +export const COMPLETE_INTERFACE_SOURCE = `module FSharpFixtures.RefactorInterface + +type IShape = + abstract member Area: unit -> float + +type Square() = + interface IShape with + member _.Area() = 1.0 +`; diff --git a/src/editors/vscode/src/test/suite/fsharp-refactor-test-kit.ts b/src/editors/vscode/src/test/suite/fsharp-refactor-test-kit.ts new file mode 100644 index 00000000..a0851362 --- /dev/null +++ b/src/editors/vscode/src/test/suite/fsharp-refactor-test-kit.ts @@ -0,0 +1,241 @@ +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { + applyWorkspaceEdit, + openFixtureDocument, + preparedRenameAt, + replaceDocumentText, + waitForCodeActions, + waitForMatchingDiagnostics, + waitForResolvedCodeActions, + type OpenFixture, + type PrepareRenameResult, + type WorkspaceEditSnapshot, +} from './refactor-test-helpers'; +import { pollUntilResult } from './test-helpers'; + +// Assertion helpers shared by the real-LSP F# suites. [ANALYZERS-FSAC-PARITY] + +export type { PrepareRenameResult } from './refactor-test-helpers'; + +export const FSHARP_REFACTOR_TIMEOUT_MS = 120_000; + +export async function openOverlay(relativePath: string, source: string): Promise { + const fixture = await openFixtureDocument(relativePath); + await replaceDocumentText(fixture.document, source); + assert.strictEqual(fixture.document.getText(), source); + assert.ok(fixture.document.isDirty, 'overlay fixture must remain unsaved'); + return fixture; +} + +export async function diagnosticWithCode( + uri: vscode.Uri, + code: string, +): Promise { + return waitForMatchingDiagnostics( + uri, + (diagnostics) => diagnostics.some((diagnostic) => diagnosticCode(diagnostic) === code), + FSHARP_REFACTOR_TIMEOUT_MS, + ); +} + +export async function diagnosticGone(uri: vscode.Uri, code: string): Promise { + return waitForMatchingDiagnostics( + uri, + (diagnostics) => diagnostics.every((diagnostic) => diagnosticCode(diagnostic) !== code), + FSHARP_REFACTOR_TIMEOUT_MS, + ); +} + +export async function quickFixes( + uri: vscode.Uri, + range: vscode.Range, +): Promise { + return waitForCodeActions({ + uri, + range, + kind: vscode.CodeActionKind.QuickFix, + predicate: () => true, + timeoutMs: FSHARP_REFACTOR_TIMEOUT_MS, + }); +} + +export async function resolvedQuickFixes( + uri: vscode.Uri, + range: vscode.Range, + title: string, +): Promise { + return waitForResolvedCodeActions({ + uri, + range, + kind: vscode.CodeActionKind.QuickFix, + predicate: (actions) => actions.some((action) => action.title === title), + timeoutMs: FSHARP_REFACTOR_TIMEOUT_MS, + }); +} + +export async function applyAction(action: vscode.CodeAction): Promise { + assert.ok(action.edit, `${action.title} must have an edit before application`); + return applyWorkspaceEdit(action.edit); +} + +export async function undoAction( + document: vscode.TextDocument, + expectedText: string, +): Promise { + const appliedVersion = document.version; + await vscode.window.showTextDocument(document, { preview: false }); + await vscode.commands.executeCommand('undo'); + assert.ok(document.version > appliedVersion, 'undo must advance the document version'); + assert.strictEqual(document.getText(), expectedText, 'undo must restore the overlay source'); + assert.ok(document.isDirty, 'undo must leave the original unsaved overlay active'); +} + +export function tokenRange( + document: vscode.TextDocument, + needle: string, + occurrence = 0, +): vscode.Range { + let index = -1; + for (let current = 0; current <= occurrence; current += 1) { + index = document.getText().indexOf(needle, index + 1); + } + assert.ok(index >= 0, `fixture must contain occurrence ${occurrence} of ${needle}`); + const start = document.positionAt(index); + return new vscode.Range(start, document.positionAt(index + needle.length)); +} + +function candidateRanges(document: vscode.TextDocument, needle: string): vscode.Range[] { + const text = document.getText(); + const ranges: vscode.Range[] = []; + let index = text.indexOf(needle); + while (index >= 0) { + ranges.push( + new vscode.Range(document.positionAt(index), document.positionAt(index + needle.length)), + ); + index = text.indexOf(needle, index + 1); + } + return ranges; +} + +function sameRange(left: vscode.Range, right: PrepareRenameResult['range']): boolean { + return ( + left.start.line === right.start.line && + left.start.character === right.start.character && + left.end.line === right.end.line && + left.end.character === right.end.character + ); +} + +export async function semanticTokenRange( + uri: vscode.Uri, + document: vscode.TextDocument, + needle: string, + occurrence = 0, +): Promise { + const matches: vscode.Range[] = []; + for (const candidate of candidateRanges(document, needle)) { + const prepare = await requestPrepareRename(uri, candidate.start); + if (prepare?.placeholder === needle && sameRange(candidate, prepare.range)) + matches.push(candidate); + } + const match = matches[occurrence]; + assert.ok(match, `missing semantic occurrence ${occurrence} of ${needle}`); + return match; +} + +export function uniqueAction( + actions: readonly vscode.CodeAction[], + title: string, +): vscode.CodeAction { + const matches = actions.filter((action) => action.title === title); + assert.strictEqual(matches.length, 1, `expected one ${title}; got ${matches.length}`); + const action = matches[0]; + assert.ok(action, `${title} must exist`); + return action; +} + +export function assertQuickFix(action: vscode.CodeAction, title: string, preferred: boolean): void { + assert.strictEqual(action.title, title); + assert.strictEqual(action.kind?.value, vscode.CodeActionKind.QuickFix.value); + assert.strictEqual(action.isPreferred, preferred); + assert.ok(action.edit, `${title} must resolve to a WorkspaceEdit`); +} + +export function singleEdit(action: vscode.CodeAction, uri: vscode.Uri): vscode.TextEdit { + assert.ok(action.edit, `${action.title} must have a resolved edit`); + const edits = action.edit.get(uri); + assert.strictEqual(edits.length, 1, `${action.title} must produce one edit`); + const edit = edits[0]; + assert.ok(edit, `${action.title} edit must exist`); + return edit; +} + +export function assertInsertion(edit: vscode.TextEdit, expected: string): void { + assert.strictEqual(edit.newText, expected); + assert.ok(edit.range.isEmpty, 'insertion must have a zero-width range'); + assert.strictEqual(edit.range.start.line, edit.range.end.line); + assert.strictEqual(edit.range.start.character, edit.range.end.character); +} + +export function assertReplacement( + document: vscode.TextDocument, + edit: vscode.TextEdit, + before: string, + after: string, +): void { + assert.strictEqual(document.getText(edit.range), before); + assert.strictEqual(edit.newText, after); + assert.ok(!edit.range.isEmpty, 'replacement must cover source text'); +} + +export function assertNoAction(actions: readonly vscode.CodeAction[], title: string): void { + assert.ok(!actions.some((action) => action.title === title), `${title} must not be offered`); +} + +export async function requestRename( + uri: vscode.Uri, + position: vscode.Position, + newName: string, + timeoutMs: number, +): Promise { + return pollUntilResult( + async () => + (await vscode.commands.executeCommand( + 'vscode.executeDocumentRenameProvider', + uri, + position, + newName, + )) ?? new vscode.WorkspaceEdit(), + (edit) => edit.size > 0, + timeoutMs, + 2_000, + ); +} + +export async function requestPrepareRename( + uri: vscode.Uri, + position: vscode.Position, +): Promise { + return preparedRenameAt(uri, position); +} + +export function editCount(edit: vscode.WorkspaceEdit): number { + return edit.entries().reduce((total, [, edits]) => total + edits.length, 0); +} + +export function changedFileNames(edit: vscode.WorkspaceEdit): string[] { + return edit.entries().map(([uri]) => uri.path.split('/').at(-1) ?? uri.path); +} + +export function diagnosticCode(diagnostic: vscode.Diagnostic): string { + const code = diagnostic.code; + if (typeof code === 'object' && code !== null) { + return String(code.value); + } + return code === undefined ? '' : String(code); +} + +export function countOccurrences(text: string, needle: string): number { + return text.split(needle).length - 1; +} diff --git a/src/editors/vscode/src/test/suite/fsharp-rename-fixtures.ts b/src/editors/vscode/src/test/suite/fsharp-rename-fixtures.ts new file mode 100644 index 00000000..070051c1 --- /dev/null +++ b/src/editors/vscode/src/test/suite/fsharp-rename-fixtures.ts @@ -0,0 +1,173 @@ +// Exhaustive real-project rename overlays for [RENAME-FSHARP-PREPARE]/[RENAME-FSHARP-APPLY]. + +export interface RenameScenario { + readonly name: string; + readonly target: string; + readonly targetOccurrence?: number; + readonly newName: string; + readonly minimumEdits: number; + readonly crossFile: boolean; +} + +type RenameInput = readonly [string, string, string, number, boolean]; + +export const RENAME_SENTINEL = + 'NestedModule IService Choice Status RecordThing Field CaseOne Ready Alias ClassThing ' + + 'Property Method ModuleAlias StructThing ObjectModelThing Changed Item moduleValue ' + + 'functionName parameter localValue localFunction lambdaParameter firstValue T Positive .+.'; + +export const RENAME_DECLARATIONS_SOURCE = `module FSharpFixtures.RenameDeclarations + +// Compiled real-project symbols for [RENAME-FSHARP-PREPARE] and [RENAME-FSHARP-APPLY]. +module NestedModule = + let moduleMember = 10 + +let nestedUse = NestedModule.moduleMember + +module ModuleAlias = NestedModule +let moduleAliasUse = ModuleAlias.moduleMember + +type IService = + abstract member Execute: int -> int + +type Service() = + interface IService with + member _.Execute value = value + +type RecordThing = { Field: int } +type Choice = CaseOne of int | CaseTwo +type Status = Ready = 0 | Busy = 1 +type Alias = RecordThing + +[] +type StructThing = { StructValue: int } + +type ObjectModelThing(initial: int) = + member val Current = initial with get, set + +type EventSource() = + let changed = Event() + [] + member _.Changed = changed.Publish + member _.Raise value = changed.Trigger value + +type IndexerThing() = + member _.Item with get(index: int) = index + +type ClassThing(seed: int) = + member _.Property = seed + member _.Method(parameter: int) = + let localName = parameter + seed + localName + +let moduleValue = 3 +let functionName parameter = + let localValue = parameter + moduleValue + localValue + +let withLocalFunction value = + let localFunction input = input + value + localFunction 1 + +let lambdaResult = [ 1 ] |> List.map (fun lambdaParameter -> lambdaParameter + 1) +let tupleFunction (firstValue, secondValue) = firstValue + secondValue + +let identity<'T> (item: 'T) : 'T = item +let (|Positive|NonPositive|) number = if number > 0 then Positive else NonPositive +let positiveHere = match 1 with | Positive -> true | NonPositive -> false +let inline (.+.) left right = left + right + +// ${RENAME_SENTINEL} +let textSentinel = "${RENAME_SENTINEL}" +`; + +export const RENAME_USAGES_SOURCE = `module FSharpFixtures.RenameUsages + +// Cross-file uses for [RENAME-FSHARP-APPLY]. +open FSharpFixtures.RenameDeclarations + +let nestedValue = NestedModule.moduleMember +let service: IService = Service() +let structValue: StructThing = { StructValue = 1 } +let objectModel = ObjectModelThing(1) +let eventSource = EventSource() +let eventSubscription = eventSource.Changed.Subscribe(fun _ -> ()) +let indexerValue = IndexerThing().[0] +let recordValue: RecordThing = { Field = 1 } +let copied = { recordValue with Field = 2 } +let readField = recordValue.Field +let choose: Choice = CaseOne 3 +let matchChoice (value: Choice) = match value with | CaseOne number -> number | CaseTwo -> 0 +let status: Status = Status.Ready +let aliasValue: Alias = recordValue +let instance = ClassThing(4) +let propertyValue = instance.Property +let methodValue = instance.Method(5) +let moduleCopy = moduleValue +let functionValue = functionName 6 +let genericValue = identity "value" +let activeValue = match 1 with | Positive -> true | NonPositive -> false +let operatorValue = 1 .+. 2 + +// ${RENAME_SENTINEL} +let textSentinel = "${RENAME_SENTINEL}" +`; + +export const RENAME_SCENARIOS: readonly RenameScenario[] = [ + rename(['nested module', 'NestedModule', 'RenamedModule', 4, true]), + rename(['module abbreviation', 'ModuleAlias', 'RenamedAlias', 2, false]), + rename(['interface type', 'IService', 'IRenamedService', 3, true]), + rename(['union type', 'Choice', 'Selection', 3, true]), + rename(['enum type', 'Status', 'State', 3, true]), + rename(['record type', 'RecordThing', 'RenamedRecord', 3, true]), + rename(['record field', 'Field', 'Amount', 4, true]), + rename(['union case', 'CaseOne', 'PrimaryCase', 3, true]), + rename(['enum case', 'Ready', 'Available', 2, true]), + // Occurrences are counted over renameable tokens, so the substrings inside + // `ModuleAlias` and the sentinel comment do not shift this index. + rename(['type alias', 'Alias', 'RecordAlias', 2, true]), + rename(['class', 'ClassThing', 'RenamedClass', 2, true]), + rename(['struct type', 'StructThing', 'RenamedStruct', 2, true]), + rename(['object-model type', 'ObjectModelThing', 'RenamedObject', 2, true]), + rename(['property', 'Property', 'Result', 2, true]), + rename(['member', 'Method', 'Calculate', 2, true]), + rename(['CLI event', 'Changed', 'Updated', 2, true]), + rename(['module value', 'moduleValue', 'sharedValue', 3, true]), + rename(['module function', 'functionName', 'computeValue', 2, true]), + rename(['parameter', 'parameter', 'input', 2, false]), + rename(['local binding', 'localValue', 'resultValue', 2, false]), + rename(['local function', 'localFunction', 'localCompute', 2, false]), + rename(['lambda parameter', 'lambdaParameter', 'mappedValue', 2, false]), + rename(['pattern parameter', 'firstValue', 'leftValue', 2, false]), + rename(['generic type parameter', "'T", "'U", 3, false]), + rename(['active pattern case', 'Positive', 'AboveZero', 4, true]), + rename(['custom operator', '.+.', '.*.', 2, true]), +]; + +function rename([name, target, newName, minimumEdits, crossFile]: RenameInput): RenameScenario { + return { name, target, newName, minimumEdits, crossFile }; +} + +export const RENAME_EDGE_SOURCE = `module FSharpFixtures.RenameEdge + +// Live overlay intentionally differs from the saved baseline. [RENAME-FSHARP-APPLY] +let unsavedName value = value + 1 +let useUnsaved = unsavedName 2 +let metadataValue = System.String.Empty +// unsavedName in a comment must remain unchanged. +let stringValue = "unsavedName" +`; + +export const RENAME_NAMESPACE_SOURCE = `namespace FSharpFixtures.RenameNamespace + +// Namespace is external to rename; the owned type is not. [RENAME-FSHARP-PREPARE] +type PublicType = { Value: int } +`; + +export const RENAME_NAMESPACE_USAGE_SOURCE = `module FSharpFixtures.NamespaceConsumer + +// Cross-file namespace use for [RENAME-FSHARP-APPLY]. +open FSharpFixtures.RenameNamespace + +let item: PublicType = { Value = 1 } +`; diff --git a/editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts b/src/editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts similarity index 100% rename from editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts rename to src/editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts diff --git a/editors/vscode/src/test/suite/hover.test.ts b/src/editors/vscode/src/test/suite/hover.test.ts similarity index 100% rename from editors/vscode/src/test/suite/hover.test.ts rename to src/editors/vscode/src/test/suite/hover.test.ts diff --git a/editors/vscode/src/test/suite/index.ts b/src/editors/vscode/src/test/suite/index.ts similarity index 100% rename from editors/vscode/src/test/suite/index.ts rename to src/editors/vscode/src/test/suite/index.ts diff --git a/editors/vscode/src/test/suite/lifecycle-e2e.test.ts b/src/editors/vscode/src/test/suite/lifecycle-e2e.test.ts similarity index 100% rename from editors/vscode/src/test/suite/lifecycle-e2e.test.ts rename to src/editors/vscode/src/test/suite/lifecycle-e2e.test.ts diff --git a/editors/vscode/src/test/suite/lsp-document-sync.test.ts b/src/editors/vscode/src/test/suite/lsp-document-sync.test.ts similarity index 100% rename from editors/vscode/src/test/suite/lsp-document-sync.test.ts rename to src/editors/vscode/src/test/suite/lsp-document-sync.test.ts diff --git a/editors/vscode/src/test/suite/lsp-integration.test.ts b/src/editors/vscode/src/test/suite/lsp-integration.test.ts similarity index 100% rename from editors/vscode/src/test/suite/lsp-integration.test.ts rename to src/editors/vscode/src/test/suite/lsp-integration.test.ts diff --git a/editors/vscode/src/test/suite/lsp-lifecycle.test.ts b/src/editors/vscode/src/test/suite/lsp-lifecycle.test.ts similarity index 83% rename from editors/vscode/src/test/suite/lsp-lifecycle.test.ts rename to src/editors/vscode/src/test/suite/lsp-lifecycle.test.ts index 14d98425..ba6aba2a 100644 --- a/editors/vscode/src/test/suite/lsp-lifecycle.test.ts +++ b/src/editors/vscode/src/test/suite/lsp-lifecycle.test.ts @@ -1,5 +1,5 @@ import * as assert from 'node:assert/strict'; -import { execSync } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; import * as vscode from 'vscode'; import { EXTENSION_ID, @@ -168,10 +168,6 @@ suite('LSP Lifecycle', () => { // closes. This test SIGKILLs the real server process and asserts the client // recovers on its own — WITHOUT any `restartServer` command. test('unexpected SIGKILL of the server auto-recovers without manual restart', async function () { - if (process.platform === 'win32') { - // Relies on POSIX `ps`; the e2e host runs on macOS/Linux. - this.skip(); - } this.timeout(90_000); // Resolve the exact staged server binary the extension launched. Matching @@ -318,29 +314,71 @@ suite('LSP Lifecycle', () => { // ── Helpers ────────────────────────────────────────────────────── /** - * SIGKILL every running language-server process launched from `binaryPath` and - * return how many were killed. Matches the exact executable path so it targets - * the test host's own server only — the sidecars run as `sharplsp-sidecar-csharp` - * / `-fsharp` or `dotnet` (distinct executables) and are left alone, as is any - * `sharplsp` a developer is running from a different location. POSIX-only (`ps`). + * `[pid, executablePath]` for every running process, on every host platform. + * + * `ps` prints the full command line, so the executable is its first + * whitespace-delimited field. Windows has no `ps`; `Get-CimInstance + * Win32_Process` is the supported replacement for the removed `wmic` and yields + * the executable path on its own — which matters, because Windows program paths + * routinely contain spaces and could not be recovered by splitting a command + * line. Processes whose path cannot be read (privileged, or exited mid-query) + * project to a bare pid and are skipped by the no-separator check. */ -function killLspServerProcesses(binaryPath: string): number { - const listing = execSync('ps -ax -o pid=,command=', { encoding: 'utf8' }); - let killed = 0; +function runningProcesses(): [number, string][] { + const listing = + process.platform === 'win32' + ? execFileSync( + 'powershell', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ExecutablePath)" }', + ], + { encoding: 'utf8' }, + ) + : execSync('ps -ax -o pid=,command=', { encoding: 'utf8' }); + + const processes: [number, string][] = []; for (const line of listing.split('\n')) { const trimmed = line.trim(); const firstSpace = trimmed.indexOf(' '); if (firstSpace < 0) continue; const pid = Number.parseInt(trimmed.slice(0, firstSpace), 10); - const command = trimmed.slice(firstSpace + 1); - const executable = command.split(' ')[0] ?? ''; - if (!Number.isNaN(pid) && executable === binaryPath) { - try { - process.kill(pid, 'SIGKILL'); - killed += 1; - } catch { - // Process already exited between listing and kill — fine. - } + if (Number.isNaN(pid)) continue; + const rest = trimmed.slice(firstSpace + 1); + processes.push([pid, process.platform === 'win32' ? rest : (rest.split(' ')[0] ?? '')]); + } + return processes; +} + +/** Compare executable paths using the host filesystem's case sensitivity. */ +function isSameExecutable(candidate: string, target: string): boolean { + return process.platform === 'win32' + ? candidate.toLowerCase() === target.toLowerCase() + : candidate === target; +} + +/** + * Force-kill every running language-server process launched from `binaryPath` + * and return how many were killed. Matches the exact executable path so it + * targets the test host's own server only — the sidecars run as + * `sharplsp-sidecar-csharp` / `-fsharp` or `dotnet` (distinct executables) and + * are left alone, as is any `sharplsp` a developer is running from a different + * location. + */ +function killLspServerProcesses(binaryPath: string): number { + let killed = 0; + for (const [pid, executable] of runningProcesses()) { + if (!isSameExecutable(executable, binaryPath)) continue; + try { + // Node maps SIGKILL to TerminateProcess on Windows: uncatchable there too, + // so the server dies without a chance to shut down cleanly — which is + // exactly the #8 scenario being reproduced. + process.kill(pid, 'SIGKILL'); + killed += 1; + } catch { + // Process already exited between listing and kill — fine. } } return killed; diff --git a/src/editors/vscode/src/test/suite/lsp-refactor-core-fixtures.ts b/src/editors/vscode/src/test/suite/lsp-refactor-core-fixtures.ts new file mode 100644 index 00000000..4f83d75a --- /dev/null +++ b/src/editors/vscode/src/test/suite/lsp-refactor-core-fixtures.ts @@ -0,0 +1,109 @@ +// Shared real-project overlays for the [SHARPLSP-FEATURES-REFACTORING] core lifecycle matrix. + +export const EXPRESSION_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; + +public class RefactorTarget +{ + private readonly int _seed; + public RefactorTarget(int seed) => _seed = seed; + + public int Compute(int input) + { + return input * 2 + input * 2 + _seed; // expression-refactor-sentinel + } +} +`; + +export const INLINE_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; + +public class RefactorTarget +{ + private readonly int _seed; + public RefactorTarget(int seed) => _seed = seed; + + public int Compute(int input) + { + var doubled = input * 2; + return doubled + _seed; // inline-sentinel + } +} +`; + +export const FIELD_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; + +public class RefactorTarget +{ + public int EncapsulateTarget; + public int Read() => EncapsulateTarget; // field-refactor-sentinel +} +`; + +export const PROPERTY_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; + +public class RefactorTarget +{ + public int AutoProperty { get; set; } + public int Read() => AutoProperty; // property-refactor-sentinel +} +`; + +export const IF_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; + +public class RefactorTarget +{ + public int Invertible(int input) + { + if (input > 0) + { + return input; + } + + return -input; // condition-refactor-sentinel + } +} +`; + +export const PARAMETER_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; + +public class RefactorTarget +{ + public int Compute(int input) + { + return input * 2 + 1; // introduce-parameter-sentinel + } + + public int Invoke() => Compute(3); +} +`; + +export const EXPRESSION_OPTIONS = [ + "Introduce local for 'input * 2'", + "Introduce local for all occurrences of 'input * 2'", + 'Extract method', + 'Extract local function', +] as const; + +export const FIELD_OPTIONS = [ + "Encapsulate field: 'EncapsulateTarget' (and use property)", + "Encapsulate field: 'EncapsulateTarget' (but still use field)", + "Generate constructor 'RefactorTarget(int encapsulateTarget)'", +] as const; + +export const PROPERTY_OPTIONS = [ + "Replace 'AutoProperty' with methods", + "Generate constructor 'RefactorTarget(int autoProperty)'", + 'Convert to full property', + "Convert to 'field' property", +] as const; + +export const IF_OPTIONS = [ + 'Invert if', + "Convert to 'switch' statement", + "Convert to 'switch' expression", +] as const; + +export const PARAMETER_OPTIONS = [ + 'and update call sites directly', + 'into extracted method to invoke at call sites', + 'into new overload', +] as const; diff --git a/src/editors/vscode/src/test/suite/lsp-refactor-core.test.ts b/src/editors/vscode/src/test/suite/lsp-refactor-core.test.ts new file mode 100644 index 00000000..db8b2042 --- /dev/null +++ b/src/editors/vscode/src/test/suite/lsp-refactor-core.test.ts @@ -0,0 +1,437 @@ +// Exhaustive real-LSP Roslyn refactor matrix for [SHARPLSP-FEATURES-REFACTORING]. +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { + assertFragments, + assertFreshActionDataIds, + assertRawActionData, + assertRawTitles, + assertSingleDocumentEdit, + onlyAction, + rangeAfterAction, + rangeOf, + rawCodeActions, + type RawCodeAction, +} from './csharp-refactor-test-kit'; +import { + activateRealSharpLsp, + applyWorkspaceEdit, + openFixtureDocument, + replaceDocumentText, + revertDocument, + waitForCodeActions, + waitForResolvedCodeActions, + type OpenFixture, +} from './refactor-test-helpers'; +import { + EXPRESSION_OPTIONS, + EXPRESSION_SOURCE, + FIELD_OPTIONS, + FIELD_SOURCE, + IF_OPTIONS, + IF_SOURCE, + INLINE_SOURCE, + PARAMETER_OPTIONS, + PARAMETER_SOURCE, + PROPERTY_OPTIONS, + PROPERTY_SOURCE, +} from './lsp-refactor-core-fixtures'; + +const FILE = 'RefactorCore.cs'; +const TEST_TIMEOUT_MS = 180_000; + +interface RefactorScenario { + readonly label: string; + readonly source: string; + readonly snippet: string; + readonly focus: string; + readonly title: string; + readonly kind: string; + readonly options: readonly string[]; + readonly presentAfter: readonly string[]; + readonly absentAfter: readonly string[]; + readonly patternsAfter?: readonly RegExp[]; + readonly mustDisappear?: boolean; + readonly postApplySnippet?: string; + readonly postApplyFocus?: string; + readonly requeryTitleCount?: number; +} + +function vscodeKind(value: string): vscode.CodeActionKind { + switch (value) { + case 'refactor.extract': + return vscode.CodeActionKind.RefactorExtract; + case 'refactor.inline': + return vscode.CodeActionKind.RefactorInline; + case 'refactor.rewrite': + return vscode.CodeActionKind.RefactorRewrite; + default: + return vscode.CodeActionKind.Refactor; + } +} + +const SCENARIOS: readonly RefactorScenario[] = [ + { + label: 'introduce one local', + source: EXPRESSION_SOURCE, + snippet: 'input * 2', + focus: 'input * 2', + title: "Introduce local for 'input * 2'", + kind: 'refactor.extract', + options: EXPRESSION_OPTIONS, + presentAfter: ['expression-refactor-sentinel'], + absentAfter: [], + patternsAfter: [/var \w+ = input \* 2;/], + }, + { + label: 'introduce local for all occurrences', + source: EXPRESSION_SOURCE, + snippet: 'input * 2', + focus: 'input * 2', + title: "Introduce local for all occurrences of 'input * 2'", + kind: 'refactor.extract', + options: EXPRESSION_OPTIONS, + presentAfter: ['expression-refactor-sentinel'], + absentAfter: ['input * 2 + input * 2'], + patternsAfter: [/var \w+ = input \* 2;/], + }, + { + label: 'extract method', + source: EXPRESSION_SOURCE, + snippet: 'input * 2', + focus: 'input * 2', + title: 'Extract method', + kind: 'refactor.extract', + options: EXPRESSION_OPTIONS, + presentAfter: ['expression-refactor-sentinel'], + absentAfter: [], + patternsAfter: [/private static int \w+\(int input\)/], + }, + { + label: 'extract local function', + source: EXPRESSION_SOURCE, + snippet: 'input * 2', + focus: 'input * 2', + title: 'Extract local function', + kind: 'refactor.extract', + options: EXPRESSION_OPTIONS, + presentAfter: ['expression-refactor-sentinel'], + absentAfter: [], + patternsAfter: [/static int \w+\(int input\)/], + }, + { + label: 'inline temporary', + source: INLINE_SOURCE, + snippet: 'var doubled = input * 2', + focus: 'doubled', + title: 'Inline temporary variable', + kind: 'refactor.inline', + options: ['Inline temporary variable'], + presentAfter: ['return input * 2 + _seed;', 'inline-sentinel'], + absentAfter: ['var doubled'], + mustDisappear: true, + postApplySnippet: 'input * 2 + _seed', + postApplyFocus: 'input * 2', + }, + { + label: 'encapsulate and redirect uses', + source: FIELD_SOURCE, + snippet: 'public int EncapsulateTarget;', + focus: 'EncapsulateTarget', + title: FIELD_OPTIONS[0], + kind: 'refactor.rewrite', + options: FIELD_OPTIONS, + presentAfter: ['field-refactor-sentinel', 'Read() => EncapsulateTarget'], + absentAfter: [], + patternsAfter: [/private int \w*encapsulateTarget/i, /public int EncapsulateTarget\s*\{/], + mustDisappear: true, + }, + { + label: 'encapsulate while retaining field uses', + source: FIELD_SOURCE, + snippet: 'public int EncapsulateTarget;', + focus: 'EncapsulateTarget', + title: FIELD_OPTIONS[1], + kind: 'refactor.rewrite', + options: FIELD_OPTIONS, + presentAfter: ['field-refactor-sentinel'], + absentAfter: ['public int EncapsulateTarget;'], + patternsAfter: [ + /private int _?encapsulateTarget;/i, + /public int EncapsulateTarget\s*\{\s*get => _?encapsulateTarget;/i, + /Read\(\) => _?encapsulateTarget/i, + ], + mustDisappear: true, + }, + { + label: 'generate constructor from field', + source: FIELD_SOURCE, + snippet: 'public int EncapsulateTarget;', + focus: 'EncapsulateTarget', + title: FIELD_OPTIONS[2], + kind: 'refactor.rewrite', + options: FIELD_OPTIONS, + presentAfter: [ + 'RefactorTarget(int encapsulateTarget)', + 'EncapsulateTarget = encapsulateTarget', + ], + absentAfter: [], + mustDisappear: true, + }, + { + label: 'convert auto property to full property', + source: PROPERTY_SOURCE, + snippet: 'AutoProperty { get; set; }', + focus: 'AutoProperty', + title: 'Convert to full property', + kind: 'refactor.rewrite', + options: PROPERTY_OPTIONS, + presentAfter: ['property-refactor-sentinel'], + absentAfter: ['AutoProperty { get; set; }'], + patternsAfter: [/private int _?autoProperty/i, /public int AutoProperty\s*\{\s*get/], + mustDisappear: true, + }, + { + label: 'convert auto property to field-backed accessors', + source: PROPERTY_SOURCE, + snippet: 'AutoProperty { get; set; }', + focus: 'AutoProperty', + title: "Convert to 'field' property", + kind: 'refactor.rewrite', + options: PROPERTY_OPTIONS, + presentAfter: ['property-refactor-sentinel', 'field'], + absentAfter: ['AutoProperty { get; set; }'], + mustDisappear: true, + }, + { + label: 'replace property with methods', + source: PROPERTY_SOURCE, + snippet: 'AutoProperty { get; set; }', + focus: 'AutoProperty', + title: "Replace 'AutoProperty' with methods", + kind: 'refactor.rewrite', + options: PROPERTY_OPTIONS, + presentAfter: ['GetAutoProperty', 'SetAutoProperty', 'property-refactor-sentinel'], + absentAfter: ['AutoProperty { get; set; }'], + mustDisappear: true, + }, + { + label: 'generate constructor from property', + source: PROPERTY_SOURCE, + snippet: 'AutoProperty { get; set; }', + focus: 'AutoProperty', + title: "Generate constructor 'RefactorTarget(int autoProperty)'", + kind: 'refactor.rewrite', + options: PROPERTY_OPTIONS, + presentAfter: ['RefactorTarget(int autoProperty)', 'AutoProperty = autoProperty'], + absentAfter: [], + mustDisappear: true, + }, + { + label: 'invert condition', + source: IF_SOURCE, + snippet: 'if (input > 0)', + focus: 'if', + title: 'Invert if', + kind: 'refactor.rewrite', + options: IF_OPTIONS, + presentAfter: ['condition-refactor-sentinel'], + absentAfter: ['if (input > 0)'], + patternsAfter: [/if \(input <= 0\)|if \(!\(input > 0\)\)/], + postApplySnippet: 'if', + requeryTitleCount: 1, + }, + { + label: 'convert condition to switch statement', + source: IF_SOURCE, + snippet: 'if (input > 0)', + focus: 'if', + title: "Convert to 'switch' statement", + kind: 'refactor.rewrite', + options: IF_OPTIONS, + presentAfter: ['switch', 'condition-refactor-sentinel'], + absentAfter: ['if (input > 0)'], + mustDisappear: true, + }, + { + label: 'convert condition to switch expression', + source: IF_SOURCE, + snippet: 'if (input > 0)', + focus: 'if', + title: "Convert to 'switch' expression", + kind: 'refactor.rewrite', + options: IF_OPTIONS, + presentAfter: ['switch', 'condition-refactor-sentinel'], + absentAfter: ['if (input > 0)'], + mustDisappear: true, + }, + { + label: 'introduce parameter and update call sites directly', + source: PARAMETER_SOURCE, + snippet: 'input * 2', + focus: 'input * 2', + title: PARAMETER_OPTIONS[0], + kind: 'refactor.rewrite', + options: PARAMETER_OPTIONS, + presentAfter: ['introduce-parameter-sentinel'], + absentAfter: [], + patternsAfter: [/Compute\(int input, int \w+\)/, /Compute\(3, 3 \* 2\)/], + }, + { + label: 'introduce parameter through an extracted call-site method', + source: PARAMETER_SOURCE, + snippet: 'input * 2', + focus: 'input * 2', + title: PARAMETER_OPTIONS[1], + kind: 'refactor.rewrite', + options: PARAMETER_OPTIONS, + presentAfter: ['introduce-parameter-sentinel'], + absentAfter: [], + patternsAfter: [/Compute\(int input, int \w+\)/, /Compute\(3, \w+\(3\)\)/], + }, + { + label: 'introduce parameter through a compatibility overload', + source: PARAMETER_SOURCE, + snippet: 'input * 2', + focus: 'input * 2', + title: PARAMETER_OPTIONS[2], + kind: 'refactor.rewrite', + options: PARAMETER_OPTIONS, + presentAfter: ['introduce-parameter-sentinel'], + absentAfter: [], + patternsAfter: [ + /Compute\(int input, int \w+\)/, + /Compute\(int input\)[\s\S]*Compute\(input, input \* 2\)/, + ], + }, +]; + +async function assertOutsideRange(fixture: OpenFixture, scenario: RefactorScenario): Promise { + const range = rangeOf(fixture.document, 'namespace'); + const raw = await rawCodeActions(fixture.uri, range); + assert.ok(!raw.some((action) => action.title === scenario.title)); + const actions = await waitForCodeActions({ + uri: fixture.uri, + range, + kind: vscodeKind(scenario.kind), + predicate: () => true, + }); + assert.ok(!actions.some((action) => action.title === scenario.title)); +} + +async function discover( + fixture: OpenFixture, + scenario: RefactorScenario, +): Promise<{ readonly range: vscode.Range; readonly raw: RawCodeAction[] }> { + const range = rangeOf(fixture.document, scenario.snippet, scenario.focus); + const actions = await waitForCodeActions({ + uri: fixture.uri, + range, + kind: vscodeKind(scenario.kind), + predicate: (items) => items.some((item) => item.title === scenario.title), + }); + onlyAction(actions, scenario.title); + const raw = await rawCodeActions(fixture.uri, range); + assertRawTitles(raw, scenario.options, scenario.kind); + assertRawActionData(raw, fixture.uri); + return { range, raw }; +} + +async function resolve( + fixture: OpenFixture, + scenario: RefactorScenario, + range: vscode.Range, +): Promise { + const actions = await waitForResolvedCodeActions({ + uri: fixture.uri, + range, + kind: vscodeKind(scenario.kind), + predicate: (items) => items.some((item) => item.title === scenario.title && item.edit), + }); + for (const title of scenario.options) onlyAction(actions, title); + const selected = onlyAction(actions, scenario.title); + assert.strictEqual(selected.kind?.value, scenario.kind); + assert.ok(selected.edit, `${scenario.title} must resolve to an edit`); + return selected.edit; +} + +function assertMutation( + fixture: OpenFixture, + scenario: RefactorScenario, + previousVersion: number, +): void { + const source = fixture.document.getText(); + assertFragments(source, scenario.presentAfter, scenario.absentAfter); + for (const pattern of scenario.patternsAfter ?? []) assert.match(source, pattern); + assert.ok(fixture.document.version > previousVersion); + assert.ok(fixture.document.isDirty); +} + +async function assertRequery( + fixture: OpenFixture, + scenario: RefactorScenario, + range: vscode.Range, + before: readonly RawCodeAction[], +): Promise { + const requeryRange = rangeAfterAction( + fixture, + range, + scenario.postApplySnippet, + scenario.postApplyFocus, + ); + const after = await rawCodeActions(fixture.uri, requeryRange); + assertRawActionData(after, fixture.uri); + assertFreshActionDataIds(after, before); + if (scenario.mustDisappear) assert.ok(!after.some((action) => action.title === scenario.title)); + if (scenario.requeryTitleCount !== undefined) { + assert.strictEqual( + after.filter((action) => action.title === scenario.title).length, + scenario.requeryTitleCount, + ); + } +} + +async function runScenario( + fixture: OpenFixture, + committedText: string, + scenario: RefactorScenario, +): Promise { + await replaceDocumentText(fixture.document, scenario.source); + await assertOutsideRange(fixture, scenario); + const discovered = await discover(fixture, scenario); + const edit = await resolve(fixture, scenario, discovered.range); + const version = fixture.document.version; + assertSingleDocumentEdit(await applyWorkspaceEdit(edit), fixture); + assertMutation(fixture, scenario, version); + await assertRequery(fixture, scenario, discovered.range, discovered.raw); + await revertDocument(fixture.document); + assert.strictEqual(fixture.document.getText(), committedText); + assert.ok(!fixture.document.isDirty); +} + +function registerCoreTests(getFixture: () => OpenFixture, getCommittedText: () => string): void { + for (const scenario of SCENARIOS) { + test(`${scenario.label}: list, resolve, apply, requery, and revert`, async function () { + this.timeout(TEST_TIMEOUT_MS); + await runScenario(getFixture(), getCommittedText(), scenario); + }); + } +} + +suite('C# real LSP - Roslyn refactor families', () => { + let fixture: OpenFixture; + let committedText = ''; + + suiteSetup(async function () { + this.timeout(TEST_TIMEOUT_MS); + await activateRealSharpLsp(); + fixture = await openFixtureDocument(FILE); + committedText = fixture.document.getText(); + }); + + teardown(async () => revertDocument(fixture.document)); + registerCoreTests( + () => fixture, + () => committedText, + ); +}); diff --git a/src/editors/vscode/src/test/suite/lsp-refactor-organize-imports.test.ts b/src/editors/vscode/src/test/suite/lsp-refactor-organize-imports.test.ts new file mode 100644 index 00000000..497ab2f6 --- /dev/null +++ b/src/editors/vscode/src/test/suite/lsp-refactor-organize-imports.test.ts @@ -0,0 +1,90 @@ +// Real-LSP lifecycle for the [SHARPLSP-FEATURES-REFACTORING] organize-imports capability. +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { + assertFreshActionDataIds, + assertRawActionData, + assertRawTitles, + assertSingleDocumentEdit, + onlyAction, + rangeOf, + rawCodeActions, + type RawCodeAction, +} from './csharp-refactor-test-kit'; +import { + activateRealSharpLsp, + applyWorkspaceEdit, + openFixtureDocument, + replaceDocumentText, + revertDocument, + waitForResolvedCodeActions, + type OpenFixture, +} from './refactor-test-helpers'; + +const FILE = 'RefactorCore.cs'; +const TEST_TIMEOUT_MS = 180_000; +const TITLE = 'Sort Usings'; +const SOURCE = 'using System.Text;\nusing System;\nnamespace SharpLsp.TestFixtures.Refactors;\n'; + +suite('C# real LSP - organize imports', () => { + let fixture: OpenFixture; + let committedText = ''; + + suiteSetup(async function () { + this.timeout(TEST_TIMEOUT_MS); + await activateRealSharpLsp(); + fixture = await openFixtureDocument(FILE); + committedText = fixture.document.getText(); + }); + + teardown(async () => revertDocument(fixture.document)); + + test('advertised action is listed, resolved, applied, requeried, and reverted', async function () { + this.timeout(TEST_TIMEOUT_MS); + await runOrganizeImports(fixture, committedText); + }); +}); + +async function discover( + fixture: OpenFixture, + range: vscode.Range, +): Promise<{ readonly action: vscode.CodeAction; readonly raw: RawCodeAction[] }> { + const raw = await rawCodeActions(fixture.uri, range); + assertRawTitles(raw, [TITLE], 'source.organizeImports'); + assertRawActionData(raw, fixture.uri); + const actions = await waitForResolvedCodeActions({ + uri: fixture.uri, + range, + kind: vscode.CodeActionKind.SourceOrganizeImports, + predicate: (items) => items.some((item) => item.title === TITLE && item.edit), + }); + const action = onlyAction(actions, TITLE); + assert.ok(action.edit); + assert.strictEqual(action.kind?.value, 'source.organizeImports'); + return { action, raw }; +} + +function assertApplied(fixture: OpenFixture, previousVersion: number): void { + assert.ok(fixture.document.version > previousVersion); + assert.ok(fixture.document.isDirty); + const source = fixture.document.getText(); + assert.ok(source.startsWith('using System;\nusing System.Text;')); + assert.ok(source.endsWith('namespace SharpLsp.TestFixtures.Refactors;\n')); + assert.ok(!source.startsWith('using System.Text;\nusing System;')); +} + +async function runOrganizeImports(fixture: OpenFixture, committedText: string): Promise { + await replaceDocumentText(fixture.document, SOURCE); + const range = rangeOf(fixture.document, 'using System.Text;'); + const discovered = await discover(fixture, range); + const version = fixture.document.version; + const snapshots = await applyWorkspaceEdit(discovered.action.edit!); + assertSingleDocumentEdit(snapshots, fixture); + assertApplied(fixture, version); + const after = await rawCodeActions(fixture.uri, rangeOf(fixture.document, 'using System;')); + assertRawActionData(after, fixture.uri); + assertFreshActionDataIds(after, discovered.raw); + await revertDocument(fixture.document); + assert.strictEqual(fixture.document.getText(), committedText); + assert.ok(!fixture.document.isDirty); +} diff --git a/src/editors/vscode/src/test/suite/lsp-refactor-quickfixes.test.ts b/src/editors/vscode/src/test/suite/lsp-refactor-quickfixes.test.ts new file mode 100644 index 00000000..f96c9fa8 --- /dev/null +++ b/src/editors/vscode/src/test/suite/lsp-refactor-quickfixes.test.ts @@ -0,0 +1,275 @@ +// Real release-LSP coverage for [SHARPLSP-FEATURES-REFACTORING]. +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { + assertFragments, + assertRawActionData, + assertRawTitles, + codeOf, + onlyAction, + rangeOf, + rawCodeActions, +} from './csharp-refactor-test-kit'; +import { + activateRealSharpLsp, + applyWorkspaceEdit, + openFixtureDocument, + replaceDocumentText, + revertDocument, + waitForCodeActions, + waitForMatchingDiagnostics, + waitForResolvedCodeActions, + type OpenFixture, + type WorkspaceEditSnapshot, +} from './refactor-test-helpers'; + +const FILE = 'RefactorQuickFixes.cs'; +const TEST_TIMEOUT_MS = 180_000; + +interface QuickFixScenario { + readonly label: string; + readonly source: string; + readonly snippet: string; + readonly focus: string; + readonly diagnosticCode: string; + readonly title: string; + readonly options: readonly string[]; + readonly presentAfter: readonly string[]; + readonly absentAfter: readonly string[]; +} + +const UNUSED_LOCAL = `namespace SharpLsp.TestFixtures.Refactors; + +public sealed class QuickFixTarget +{ + public int Compute(int input) + { + var unusedValue = 42; + return input + 1; // unused-local-sentinel + } +} +`; + +const ADD_USING = `namespace SharpLsp.TestFixtures.Refactors; + +public sealed class QuickFixTarget +{ + public string Build() + { + var builder = new StringBuilder(); + builder.Append("add-using-sentinel"); + return builder.ToString(); + } +} +`; + +const GENERATE_METHOD = `namespace SharpLsp.TestFixtures.Refactors; + +public sealed class QuickFixTarget +{ + public int Existing(int value) => value + 1; + + public int Compute(int input) + { + return MissingOperation(input) + Existing(input); // generate-method-sentinel + } +} +`; + +const IMPLEMENT_INTERFACE = `namespace SharpLsp.TestFixtures.Refactors; + +public interface IQuickContract +{ + int Compute(int input); + string Name { get; } +} + +public sealed class QuickFixTarget : IQuickContract +{ + public int Existing(int value) => value + 1; // implement-interface-sentinel +} +`; + +const SCENARIOS: readonly QuickFixScenario[] = [ + { + label: 'unused local removal', + source: UNUSED_LOCAL, + snippet: 'var unusedValue = 42;', + focus: 'unusedValue', + diagnosticCode: 'CS0219', + title: 'Remove unused variable', + options: ['Remove unused variable'], + presentAfter: ['return input + 1;', 'unused-local-sentinel'], + absentAfter: ['unusedValue'], + }, + { + label: 'missing namespace import', + source: ADD_USING, + snippet: 'new StringBuilder()', + focus: 'StringBuilder', + diagnosticCode: 'CS0246', + title: 'using System.Text;', + options: ['System.Text.StringBuilder', 'using System.Text;'], + presentAfter: ['using System.Text;', 'new StringBuilder()', 'add-using-sentinel'], + absentAfter: [], + }, + { + label: 'missing method generation', + source: GENERATE_METHOD, + snippet: 'MissingOperation(input)', + focus: 'MissingOperation', + diagnosticCode: 'CS0103', + title: "Generate method 'MissingOperation'", + options: ["Generate method 'MissingOperation'"], + presentAfter: ['MissingOperation(int input)', 'throw new', 'generate-method-sentinel'], + absentAfter: [], + }, + { + label: 'interface implementation', + source: IMPLEMENT_INTERFACE, + snippet: 'QuickFixTarget : IQuickContract', + focus: 'IQuickContract', + diagnosticCode: 'CS0535', + title: 'Implement interface', + options: ['Implement interface', 'Implement all members explicitly'], + presentAfter: [ + 'public int Compute(int input)', + 'public string Name', + 'implement-interface-sentinel', + ], + absentAfter: [], + }, +]; + +async function assertNegativeRange( + fixture: OpenFixture, + scenario: QuickFixScenario, +): Promise { + const outside = rangeOf(fixture.document, 'namespace', 'namespace'); + const raw = await rawCodeActions(fixture.uri, outside); + assert.ok(!raw.some((action) => action.title === scenario.title)); + const actions = await waitForCodeActions({ + uri: fixture.uri, + range: outside, + kind: vscode.CodeActionKind.QuickFix, + predicate: () => true, + }); + assert.ok(!actions.some((action) => action.title === scenario.title)); +} + +async function assertDiagnostic(fixture: OpenFixture, scenario: QuickFixScenario): Promise { + const diagnostics = await waitForMatchingDiagnostics(fixture.uri, (items) => + items.some((item) => codeOf(item) === scenario.diagnosticCode), + ); + const matches = diagnostics.filter((item) => codeOf(item) === scenario.diagnosticCode); + assert.ok(matches.length >= 1, `missing ${scenario.diagnosticCode}`); + assert.ok(matches.every((item) => item.message.length > 0)); + assert.ok(matches.every((item) => !item.range.isEmpty)); +} + +async function discoverInside( + fixture: OpenFixture, + scenario: QuickFixScenario, +): Promise { + const range = rangeOf(fixture.document, scenario.snippet, scenario.focus); + const actions = await waitForCodeActions({ + uri: fixture.uri, + range, + kind: vscode.CodeActionKind.QuickFix, + predicate: (items) => items.some((item) => item.title === scenario.title), + }); + onlyAction(actions, scenario.title); + const raw = await rawCodeActions(fixture.uri, range); + assertRawTitles(raw, scenario.options, 'quickfix'); + assertRawActionData(raw, fixture.uri); + return range; +} + +async function resolveEdit( + fixture: OpenFixture, + scenario: QuickFixScenario, + range: vscode.Range, +): Promise { + const actions = await waitForResolvedCodeActions({ + uri: fixture.uri, + range, + kind: vscode.CodeActionKind.QuickFix, + predicate: (items) => items.some((item) => item.title === scenario.title && item.edit), + }); + for (const title of scenario.options) onlyAction(actions, title); + const selected = onlyAction(actions, scenario.title); + assert.strictEqual(selected.kind?.value, vscode.CodeActionKind.QuickFix.value); + assert.ok(selected.edit, `${scenario.title} must resolve to a WorkspaceEdit`); + return selected.edit; +} + +function assertSnapshots(snapshots: readonly WorkspaceEditSnapshot[], fixture: OpenFixture): void { + assert.strictEqual(snapshots.length, 1, 'selected quick fix must edit one document'); + assert.strictEqual(snapshots[0]?.uri.toString(), fixture.uri.toString()); + assert.ok((snapshots[0]?.edits.length ?? 0) >= 1); + assert.ok((snapshots[0]?.textBefore.length ?? 0) > 0); +} + +function assertMutation( + fixture: OpenFixture, + scenario: QuickFixScenario, + previousVersion: number, +): void { + const after = fixture.document.getText(); + assertFragments(after, scenario.presentAfter, scenario.absentAfter); + assert.ok(fixture.document.version > previousVersion, 'apply must advance the document version'); + assert.ok(fixture.document.isDirty, 'the applied user edit must remain dirty until reverted'); +} + +async function assertNoLongerOffered( + fixture: OpenFixture, + scenario: QuickFixScenario, + range: vscode.Range, +): Promise { + await waitForMatchingDiagnostics(fixture.uri, (items) => + items.every((item) => codeOf(item) !== scenario.diagnosticCode), + ); + const raw = await rawCodeActions(fixture.uri, range); + assert.ok(!raw.some((action) => action.title === scenario.title)); +} + +async function runScenario( + fixture: OpenFixture, + committedText: string, + scenario: QuickFixScenario, +): Promise { + await replaceDocumentText(fixture.document, scenario.source); + assert.ok(fixture.document.isDirty); + await assertDiagnostic(fixture, scenario); + await assertNegativeRange(fixture, scenario); + const range = await discoverInside(fixture, scenario); + const edit = await resolveEdit(fixture, scenario, range); + const version = fixture.document.version; + assertSnapshots(await applyWorkspaceEdit(edit), fixture); + assertMutation(fixture, scenario, version); + await assertNoLongerOffered(fixture, scenario, range); + await revertDocument(fixture.document); + assert.strictEqual(fixture.document.getText(), committedText); + assert.ok(!fixture.document.isDirty); +} + +suite('C# real LSP - compiler quick fixes', () => { + let fixture: OpenFixture; + let committedText = ''; + + suiteSetup(async function () { + this.timeout(TEST_TIMEOUT_MS); + await activateRealSharpLsp(); + fixture = await openFixtureDocument(FILE); + committedText = fixture.document.getText(); + }); + + teardown(async () => revertDocument(fixture.document)); + + for (const scenario of SCENARIOS) { + test(`${scenario.label}: list, resolve, apply, requery, and revert`, async function () { + this.timeout(TEST_TIMEOUT_MS); + await runScenario(fixture, committedText, scenario); + }); + } +}); diff --git a/src/editors/vscode/src/test/suite/lsp-refactor-rewrite-matrix.test.ts b/src/editors/vscode/src/test/suite/lsp-refactor-rewrite-matrix.test.ts new file mode 100644 index 00000000..239dd7a2 --- /dev/null +++ b/src/editors/vscode/src/test/suite/lsp-refactor-rewrite-matrix.test.ts @@ -0,0 +1,469 @@ +// Full-lifecycle real-LSP matrix for remaining [SHARPLSP-FEATURES-REFACTORING] families. +import { exerciseCodeAction, type ActionLifecycleCase } from './csharp-refactor-test-kit'; +import { + activateRealSharpLsp, + openFixtureDocument, + revertDocument, + type OpenFixture, +} from './refactor-test-helpers'; + +const TEST_TIMEOUT_MS = 180_000; + +const CONSTANT_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class ConstantTarget +{ + public int Compute() => 1 + 2; // constant-sentinel +} +`; + +const TYPE_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class TypeTarget +{ + public int Compute() { int value = 1; return value; } // type-sentinel +} +`; + +const VAR_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class VarTarget +{ + public int Compute() { var value = 1; return value; } // var-sentinel +} +`; + +const METHOD_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class MethodTarget +{ + public int Compute(int value) { return value + 1; } // method-sentinel +} +`; + +const LOOP_SOURCE = `using System.Collections.Generic; +namespace SharpLsp.TestFixtures.Refactors; +public class LoopTarget +{ + public int Sum(List values) { var total = 0; foreach (var value in values) { total += value; } return total; } // loop-sentinel +} +`; + +const FOR_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class ForTarget +{ + public int Sum() { var total = 0; for (var index = 0; index < 10; index++) { total += index; } return total; } // for-sentinel +} +`; + +const STRING_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class StringTarget +{ + public string Join(string left, string right) => left + "-" + right; // string-sentinel +} +`; + +const INLINE_METHOD_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class InlineMethodTarget +{ + private int Double(int value) => value * 2; + public int Compute() => Double(3); // inline-method-sentinel +} +`; + +const EQUALITY_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class EqualityTarget +{ + public int X; + public string Name = "equality-sentinel"; +} +`; + +const RECORD_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +// record-sentinel +public class RecordTarget +{ + public int X { get; } + public RecordTarget(int x) { X = x; } +} +`; + +const NAMESPACE_SOURCE = `namespace SharpLsp.TestFixtures.Refactors +{ + public class NamespaceTarget { } // namespace-sentinel +} +`; + +const BLOCK_METHOD_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class BlockMethodTarget +{ + public int Compute(int value) => value + 1; // block-method-sentinel +} +`; + +const MERGE_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class MergeTarget +{ + public int Compute() { int value; value = 1; return value; } // merge-sentinel +} +`; + +const INLINE_DECLARATION_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class InlineDeclarationTarget +{ + public bool Parse(string text) { int value; return int.TryParse(text, out value); } // inline-declaration-sentinel +} +`; + +const WRAP_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class WrapTarget +{ + private int Add(int first, int second) => first + second; + public int Compute() => Add(1, 2); // wrap-sentinel +} +`; + +const LINQ_SOURCE = `using System.Collections.Generic; +namespace SharpLsp.TestFixtures.Refactors; +public class LinqTarget +{ + public List SelectPositive(List values) { var result = new List(); foreach (var value in values) { if (value > 0) result.Add(value * 2); } return result; } // linq-sentinel +} +`; + +const FIELD_SOURCE = `using System; +namespace SharpLsp.TestFixtures.Refactors; +public class IntroduceFieldTarget +{ + public IntroduceFieldTarget() { Console.WriteLine(DateTime.Now.Year); } // field-sentinel +} +`; + +const OVERRIDE_SOURCE = `using System; +namespace SharpLsp.TestFixtures.Refactors; +public abstract class OverrideBase +{ + public abstract int Compute(int value); + public abstract string Name { get; } + public abstract int this[int index] { get; } + public abstract event EventHandler? Changed; +} +public class OverrideTarget : OverrideBase { } // override-sentinel +`; + +const COMPARISON_SOURCE = `using System; +namespace SharpLsp.TestFixtures.Refactors; +public class ComparisonTarget : IComparable +{ + public int X; + public int CompareTo(ComparisonTarget? other) => X.CompareTo(other?.X); +} +`; + +const NULL_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class NullTarget +{ + public string Name { get; } + public NullTarget(string name) { Name = name; } // null-check-sentinel +} +`; + +const CONSTANT_OPTIONS = [ + "Introduce constant for '1 + 2'", + "Introduce constant for all occurrences of '1 + 2'", + "Introduce local constant for '1 + 2'", + "Introduce local constant for all occurrences of '1 + 2'", +] as const; + +const CASES: readonly ActionLifecycleCase[] = [ + { + label: 'introduce class constant', + source: CONSTANT_SOURCE, + snippet: '1 + 2', + focus: '1 + 2', + title: CONSTANT_OPTIONS[0], + kind: 'refactor.extract', + options: CONSTANT_OPTIONS, + presentAfter: ['constant-sentinel'], + absentAfter: [], + patternsAfter: [/const int \w+ = 1 \+ 2;/], + }, + { + label: 'introduce local constant', + source: CONSTANT_SOURCE, + snippet: '1 + 2', + focus: '1 + 2', + title: CONSTANT_OPTIONS[2], + kind: 'refactor.extract', + options: CONSTANT_OPTIONS, + presentAfter: ['constant-sentinel'], + absentAfter: [], + patternsAfter: [/const int \w+ = 1 \+ 2;/], + }, + { + label: 'explicit type to var', + source: TYPE_SOURCE, + snippet: 'int value = 1', + focus: 'int', + title: "use 'var' instead of explicit type", + kind: 'refactor.rewrite', + presentAfter: ['var value = 1', 'type-sentinel'], + absentAfter: ['int value = 1'], + }, + { + label: 'var to explicit type', + source: VAR_SOURCE, + snippet: 'var value = 1', + focus: 'var', + title: 'Use explicit type', + kind: 'refactor.rewrite', + presentAfter: ['int value = 1', 'var-sentinel'], + absentAfter: ['var value = 1'], + }, + { + label: 'method to expression body', + source: METHOD_SOURCE, + snippet: 'Compute(int value)', + focus: 'Compute', + title: 'Use expression body for method', + kind: 'refactor.rewrite', + presentAfter: ['=> value + 1;', 'method-sentinel'], + absentAfter: ['{ return value + 1; }'], + }, + { + label: 'foreach to for', + source: LOOP_SOURCE, + snippet: 'foreach (var value in values)', + focus: 'foreach', + title: "Convert to 'for'", + kind: 'refactor.rewrite', + presentAfter: ['for (', 'loop-sentinel'], + absentAfter: ['foreach ('], + }, + { + label: 'reverse for loop', + source: FOR_SOURCE, + snippet: 'for (var index = 0;', + focus: 'for', + title: "Reverse 'for' statement", + kind: 'refactor.rewrite', + presentAfter: ['for-sentinel'], + absentAfter: ['index = 0; index < 10; index++'], + patternsAfter: [/index = 10 - 1; index >= 0; index--/], + }, + { + label: 'concatenation to interpolated string', + source: STRING_SOURCE, + snippet: 'left + "-" + right', + focus: 'left + "-" + right', + title: 'Convert to interpolated string', + kind: 'refactor.rewrite', + presentAfter: ['$"{left}-{right}"', 'string-sentinel'], + absentAfter: ['left + "-" + right'], + }, + { + label: 'inline and remove method', + source: INLINE_METHOD_SOURCE, + snippet: 'Double(3)', + focus: 'Double(3)', + title: "Inline 'Double(int value)'", + kind: 'refactor.inline', + options: ["Inline 'Double(int value)'", "Inline and keep 'Double(int value)'"], + postApplySnippet: '3 * 2', + presentAfter: ['3 * 2', 'inline-method-sentinel'], + absentAfter: ['private int Double'], + }, + { + label: 'inline and retain method', + source: INLINE_METHOD_SOURCE, + snippet: 'Double(3)', + focus: 'Double(3)', + title: "Inline and keep 'Double(int value)'", + kind: 'refactor.inline', + options: ["Inline 'Double(int value)'", "Inline and keep 'Double(int value)'"], + presentAfter: ['private int Double', '3 * 2', 'inline-method-sentinel'], + absentAfter: [], + }, + { + label: 'generate Equals', + source: EQUALITY_SOURCE, + snippet: 'public int X;\n public string Name', + focus: 'public int X;\n public string Name', + title: 'Generate Equals(...)', + kind: 'refactor.rewrite', + caretOnly: true, + options: ['Generate Equals(...)', 'Generate Equals and GetHashCode'], + presentAfter: ['override bool Equals', 'equality-sentinel'], + absentAfter: [], + }, + { + label: 'generate Equals and GetHashCode', + source: EQUALITY_SOURCE, + snippet: 'public int X;\n public string Name', + focus: 'public int X;\n public string Name', + title: 'Generate Equals and GetHashCode', + kind: 'refactor.rewrite', + caretOnly: true, + options: ['Generate Equals(...)', 'Generate Equals and GetHashCode'], + presentAfter: ['override bool Equals', 'override int GetHashCode', 'equality-sentinel'], + absentAfter: [], + }, + { + label: 'class to positional record', + source: RECORD_SOURCE, + snippet: 'class RecordTarget', + focus: 'RecordTarget', + title: 'Convert to positional record', + kind: 'refactor.rewrite', + presentAfter: ['record RecordTarget(', 'record-sentinel'], + absentAfter: ['class RecordTarget'], + }, + { + label: 'block to file-scoped namespace', + source: NAMESPACE_SOURCE, + snippet: 'namespace SharpLsp', + focus: 'namespace', + outsideSnippet: 'class NamespaceTarget', + title: 'Convert to file-scoped namespace', + kind: 'refactor.rewrite', + // This rewrite converts the whole file, so Roslyn offers it at every + // position inside the namespace; there is no meaningful "outside" range. + skipOutsideRange: true, + presentAfter: ['namespace SharpLsp.TestFixtures.Refactors;', 'namespace-sentinel'], + absentAfter: ['namespace SharpLsp.TestFixtures.Refactors\n{'], + }, + { + // Roslyn only offers the expression-wrapping rewrite here: converting an + // expression body back to a block body is IDE0022, and this fixture project + // disables the IDE analyzers, so no such quickfix exists to assert. + label: 'wrap expression-bodied member', + source: BLOCK_METHOD_SOURCE, + snippet: 'Compute(int value) => value + 1;', + focus: 'value + 1', + title: 'Wrap expression', + kind: 'refactor.rewrite', + caretOnly: true, + presentAfter: ['block-method-sentinel'], + absentAfter: ['=> value + 1;'], + patternsAfter: [/=>\s*value\s*\+\s*1;/], + }, + { + label: 'merge declaration and assignment', + source: MERGE_SOURCE, + snippet: 'int value; value = 1;', + focus: 'int value; value = 1;', + title: 'Merge declaration and assignment', + kind: 'refactor.rewrite', + presentAfter: ['int value = 1;', 'merge-sentinel'], + absentAfter: ['int value; value = 1;'], + }, + { + // 'Inline variable declaration' is IDE0018, which this analyzer-disabled + // fixture never reports; the extract family is what Roslyn really offers on + // an out-argument call, so that is what this case proves end to end. + label: 'extract local function from an out-argument call', + source: INLINE_DECLARATION_SOURCE, + snippet: 'int.TryParse(text, out value)', + focus: 'int.TryParse(text, out value)', + title: 'Extract local function', + kind: 'refactor.extract', + presentAfter: ['inline-declaration-sentinel'], + absentAfter: [], + }, + { + label: 'wrap arguments', + source: WRAP_SOURCE, + snippet: 'Add(1, 2)', + focus: '1, 2', + title: 'Indent all arguments', + kind: 'refactor.rewrite', + caretOnly: true, + presentAfter: ['wrap-sentinel'], + absentAfter: ['Add(1, 2)'], + patternsAfter: [/Add\(\s*1,\s*2\s*\)/], + }, + { + label: 'imperative loop to LINQ', + source: LINQ_SOURCE, + snippet: 'foreach (var value in values)', + focus: 'foreach', + title: 'Convert to LINQ (call form)', + kind: 'refactor.rewrite', + options: ['Convert to LINQ', 'Convert to LINQ (call form)'], + presentAfter: ['.Where(', '.Select(', 'linq-sentinel'], + absentAfter: ['foreach ('], + }, + { + // Roslyn offers 'Introduce local', never 'Introduce field', for an + // expression inside a constructor body. The constructor-level rewrite it + // does offer is the expression body conversion, proven here end to end. + label: 'constructor to expression body', + source: FIELD_SOURCE, + snippet: 'DateTime.Now.Year', + focus: 'DateTime.Now.Year', + title: 'Use expression body for constructor', + kind: 'refactor.rewrite', + caretOnly: true, + presentAfter: ['=> Console.WriteLine(DateTime.Now.Year);', 'field-sentinel'], + absentAfter: ['{ Console.WriteLine(DateTime.Now.Year); }'], + }, + { + label: 'generate overrides', + source: OVERRIDE_SOURCE, + snippet: 'class OverrideTarget', + focus: 'OverrideTarget', + title: 'Generate overrides...', + kind: 'refactor.rewrite', + presentAfter: [ + 'override int Compute', + 'override string Name', + 'override int this[int index]', + 'override event EventHandler? Changed', + 'override-sentinel', + ], + absentAfter: ['OverrideTarget : OverrideBase { }'], + patternsAfter: [ + /override int Compute\(int value\)/, + /override string Name\s*\{\s*get/, + /override int this\[int index\]\s*\{\s*get/, + /override event EventHandler\? Changed\s*\{\s*add[\s\S]*remove/, + ], + }, + { + label: 'generate comparison operators', + source: COMPARISON_SOURCE, + snippet: 'class ComparisonTarget', + focus: 'ComparisonTarget', + title: 'Generate comparison operators', + kind: 'refactor.rewrite', + presentAfter: ['operator <', 'operator >'], + absentAfter: [], + }, + { + label: 'add constructor null checks', + source: NULL_SOURCE, + snippet: 'NullTarget(string name)', + focus: 'name', + title: 'Add null check', + kind: 'refactor.rewrite', + presentAfter: ['ArgumentNullException', 'null-check-sentinel'], + absentAfter: [], + }, +]; + +suite('C# real LSP - extended Roslyn rewrite families', () => { + let fixture: OpenFixture; + let committedText = ''; + + suiteSetup(async function () { + this.timeout(TEST_TIMEOUT_MS); + await activateRealSharpLsp(); + fixture = await openFixtureDocument('RefactorCore.cs'); + committedText = fixture.document.getText(); + }); + + teardown(async () => revertDocument(fixture.document)); + + for (const actionCase of CASES) { + test(`${actionCase.label}: list, resolve, apply, requery, and revert`, async function () { + this.timeout(TEST_TIMEOUT_MS); + await exerciseCodeAction(fixture, committedText, actionCase); + }); + } +}); diff --git a/src/editors/vscode/src/test/suite/lsp-rename-edge.test.ts b/src/editors/vscode/src/test/suite/lsp-rename-edge.test.ts new file mode 100644 index 00000000..16fb37bf --- /dev/null +++ b/src/editors/vscode/src/test/suite/lsp-rename-edge.test.ts @@ -0,0 +1,321 @@ +// Adversarial real-LSP rename coverage for [RENAME-TESTS] and [RENAME-COVERAGE]. +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { positionOf, rangeOf } from './csharp-refactor-test-kit'; +import { + exerciseRename, + fixtureOf, + openRenameFixtures, + prepareAt, + providerRename, + rawRenameAt, + revertRenameFixtures, + waitForPrepare, + type RenameCase, + type RenameFixtureSet, +} from './csharp-rename-test-kit'; +import { + activateRealSharpLsp, + replaceDocumentText, + type OpenFixture, +} from './refactor-test-helpers'; + +const TEST_TIMEOUT_MS = 180_000; +const EDGE_ONLY = ['edge'] as const; + +interface Outcome { + readonly value?: T; + readonly error?: unknown; +} + +const PARTIAL_TYPE: RenameCase = { + label: 'partial type declarations', + fixture: 'edge', + snippet: 'public partial class PartialRenameTarget', + oldName: 'PartialRenameTarget', + newName: 'RenamedPartialTarget', + editCount: 2, + files: EDGE_ONLY, +}; + +const PARTIAL_MEMBER: RenameCase = { + label: 'partial member declaration and cross-part read', + fixture: 'edge', + snippet: 'public int PartialMember', + oldName: 'PartialMember', + newName: 'RenamedPartialMember', + editCount: 2, + files: EDGE_ONLY, +}; + +const CASE_ONLY: RenameCase = { + ...PARTIAL_MEMBER, + label: 'case-only identifier change', + newName: 'partialMember', +}; + +const UNICODE: RenameCase = { + ...PARTIAL_MEMBER, + label: 'Unicode identifier change', + newName: 'Μέλος', +}; + +const ESCAPED_KEYWORD: RenameCase = { + ...PARTIAL_MEMBER, + label: 'escaped-keyword identifier change', + newName: '@class', +}; + +const POSITIVE_CASES = [PARTIAL_TYPE, PARTIAL_MEMBER, CASE_ONLY, UNICODE, ESCAPED_KEYWORD]; +const INVALID_NAMES = ['', ' ', '123member', 'two words', 'bad-name', '.', 'class'] as const; +const OVERLAY_CASE: RenameCase = { + label: 'unsaved overlay local', + fixture: 'edge', + snippet: 'var overlayLocal', + oldName: 'overlayLocal', + newName: 'renamedOverlayLocal', + editCount: 2, + files: EDGE_ONLY, +}; + +const OVERLAY_SOURCE = `namespace SharpLsp.TestFixtures.RenameCoverage; + +public sealed class OverlayTarget +{ + public int Compute(int overlayParameter) + { + var overlayLocal = overlayParameter + 1; + return overlayLocal; // unsaved-overlay-sentinel + } +} +`; + +async function capture(operation: Promise): Promise> { + try { + return { value: await operation }; + } catch (error: unknown) { + return { error }; + } +} + +function assertRejectedOrEmpty(outcome: Outcome, label: string): void { + const rejected = Object.hasOwn(outcome, 'error'); + const empty = Object.hasOwn(outcome, 'value') && outcome.value == null; + assert.ok(rejected || empty, `${label} must reject or return no edit`); +} + +function assertNoResultOrEmpty(outcome: Outcome): void { + if (Object.hasOwn(outcome, 'error')) { + assert.match(String(outcome.error), /No result/i); + } else { + assert.ok(outcome.value == null, 'same-name provider rename must return no edit'); + } +} + +function assertCleanBaselines(fixtures: RenameFixtureSet): void { + for (const key of ['symbols', 'usage', 'edge'] as const) { + const fixture = fixtureOf(fixtures, key); + assert.strictEqual(fixture.document.getText(), fixtures.baselines[key]); + assert.ok(!fixture.document.isDirty); + } +} + +async function assertNoRenameAt( + fixture: OpenFixture, + position: vscode.Position, + label: string, +): Promise { + assert.strictEqual(await prepareAt(fixture.uri, position), null, `${label} prepare must be null`); + const raw = await capture(rawRenameAt(fixture.uri, position, 'ShouldNotRename')); + assertRejectedOrEmpty(raw, `${label} raw rename`); + const provider = await capture(providerRename(fixture.uri, position, 'ShouldNotRename')); + assertRejectedOrEmpty(provider, `${label} VS Code rename`); + assert.ok(!fixture.document.isDirty); +} + +async function assertInvalidName(fixtures: RenameFixtureSet, invalidName: string): Promise { + const fixture = fixtures.edge; + const position = positionOf(fixture.document, 'public int PartialMember', 'PartialMember'); + const prepared = await prepareAt(fixture.uri, position); + assert.strictEqual(prepared?.placeholder, 'PartialMember'); + assertRejectedOrEmpty( + await capture(rawRenameAt(fixture.uri, position, invalidName)), + 'raw invalid name', + ); + assertRejectedOrEmpty( + await capture(providerRename(fixture.uri, position, invalidName)), + 'provider invalid name', + ); + assertCleanBaselines(fixtures); +} + +async function assertSameNameIsNoOp(fixtures: RenameFixtureSet): Promise { + const fixture = fixtures.edge; + const position = positionOf(fixture.document, 'public int PartialMember', 'PartialMember'); + const prepared = await prepareAt(fixture.uri, position); + assert.strictEqual(prepared?.placeholder, 'PartialMember'); + assert.strictEqual(await rawRenameAt(fixture.uri, position, 'PartialMember'), null); + assertNoResultOrEmpty(await capture(providerRename(fixture.uri, position, 'PartialMember'))); + assertCleanBaselines(fixtures); +} + +async function assertConflictRejected(fixtures: RenameFixtureSet): Promise { + const fixture = fixtures.edge; + const position = positionOf(fixture.document, 'public int PartialMember', 'PartialMember'); + assertRejectedOrEmpty( + await capture(rawRenameAt(fixture.uri, position, 'UsePartialMember')), + 'conflicting member rename', + ); + assertRejectedOrEmpty( + await capture(providerRename(fixture.uri, position, 'UsePartialMember')), + 'conflicting provider rename', + ); + assertCleanBaselines(fixtures); +} + +async function assertUnsavedOverlay(fixtures: RenameFixtureSet): Promise { + await replaceDocumentText(fixtures.edge.document, OVERLAY_SOURCE); + const position = positionOf(fixtures.edge.document, 'var overlayLocal', 'overlayLocal'); + assert.strictEqual( + (await waitForPrepare(fixtures.edge.uri, position, 'overlayLocal')).placeholder, + 'overlayLocal', + ); + const overlayFixtures: RenameFixtureSet = { + ...fixtures, + baselines: { ...fixtures.baselines, edge: OVERLAY_SOURCE }, + }; + await exerciseRename(overlayFixtures, OVERLAY_CASE, false); + assert.ok(fixtures.edge.document.isDirty); + assert.ok(fixtures.edge.document.getText().includes('unsaved-overlay-sentinel')); + await revertRenameFixtures(fixtures); +} + +async function assertTokenBoundaries(fixtures: RenameFixtureSet): Promise { + const range = rangeOf(fixtures.edge.document, 'public int PartialMember', 'PartialMember'); + await assertNoRenameAt(fixtures.edge, range.end, 'token end'); + await assertNoRenameAt(fixtures.edge, new vscode.Position(range.start.line, 0), 'line trivia'); + assertCleanBaselines(fixtures); +} + +async function assertTriviaPositions(fixtures: RenameFixtureSet): Promise { + const document = fixtures.edge.document; + const comment = positionOf(document, '// PartialRenameTarget and PartialMember', 'PartialMember'); + const literal = positionOf(document, '"PartialRenameTarget PartialMember"', 'PartialMember'); + const blank = new vscode.Position(positionOf(document, 'public partial class').line - 1, 0); + await assertNoRenameAt(fixtures.edge, comment, 'comment'); + await assertNoRenameAt(fixtures.edge, literal, 'string'); + await assertNoRenameAt(fixtures.edge, blank, 'blank line'); +} + +async function assertMetadataRejected(fixtures: RenameFixtureSet): Promise { + const position = positionOf(fixtures.edge.document, 'Console.ReadLine()', 'Console'); + await assertNoRenameAt(fixtures.edge, position, 'metadata symbol'); + assertCleanBaselines(fixtures); +} + +async function assertIndexerRejected(fixtures: RenameFixtureSet): Promise { + const position = positionOf(fixtures.symbols.document, 'this[int indexParameter]', 'this'); + await assertNoRenameAt(fixtures.symbols, position, 'indexer keyword'); + assertCleanBaselines(fixtures); +} + +async function assertOperatorRejected(fixtures: RenameFixtureSet): Promise { + const document = fixtures.symbols.document; + const keyword = positionOf(document, 'operator +', 'operator'); + const punctuation = positionOf(document, 'operator +', '+'); + await assertNoRenameAt(fixtures.symbols, keyword, 'operator keyword'); + await assertNoRenameAt(fixtures.symbols, punctuation, 'operator punctuation'); + assertCleanBaselines(fixtures); +} + +async function assertConversionRejected(fixtures: RenameFixtureSet): Promise { + const document = fixtures.symbols.document; + const keyword = positionOf(document, 'explicit operator int', 'operator'); + const targetType = positionOf(document, 'explicit operator int', 'int'); + await assertNoRenameAt(fixtures.symbols, keyword, 'conversion operator keyword'); + await assertNoRenameAt(fixtures.symbols, targetType, 'conversion target type'); + assertCleanBaselines(fixtures); +} + +async function assertOutOfRangeRejected(fixtures: RenameFixtureSet): Promise { + const position = new vscode.Position(99_999, 99_999); + const outcome = await capture(prepareAt(fixtures.edge.uri, position)); + assert.ok(Object.hasOwn(outcome, 'error'), 'out-of-range prepareRename must reject'); + assertCleanBaselines(fixtures); +} + +function registerPositiveTests(getFixtures: () => RenameFixtureSet): void { + for (const renameCase of POSITIVE_CASES) { + test(`${renameCase.label}: exact edits, apply, reverse, and revert`, async function () { + this.timeout(TEST_TIMEOUT_MS); + await exerciseRename(getFixtures(), renameCase); + }); + } +} + +function registerBoundaryTests(getFixtures: () => RenameFixtureSet): void { + const cases: readonly [string, (fixtures: RenameFixtureSet) => Promise][] = [ + ['token-end and token-before positions are not renameable', assertTokenBoundaries], + ['comments, strings, and blank-line trivia are never renameable', assertTriviaPositions], + ['metadata symbols cannot produce source rename edits', assertMetadataRejected], + ['the C# indexer this keyword is not a renameable identifier', assertIndexerRejected], + [ + 'operator declarations reject keyword and punctuation rename positions', + assertOperatorRejected, + ], + [ + 'conversion-operator type tokens cannot masquerade as renameable declarations', + assertConversionRejected, + ], + ]; + for (const [label, operation] of cases) + test(label, async function () { + this.timeout(TEST_TIMEOUT_MS); + await operation(getFixtures()); + }); +} + +function registerInvalidNameTests(getFixtures: () => RenameFixtureSet): void { + for (const invalidName of INVALID_NAMES) { + test(`invalid new name ${JSON.stringify(invalidName)} is rejected without edits`, async function () { + this.timeout(TEST_TIMEOUT_MS); + await assertInvalidName(getFixtures(), invalidName); + }); + } + test('renaming to the current name is an exact no-op', async function () { + this.timeout(TEST_TIMEOUT_MS); + await assertSameNameIsNoOp(getFixtures()); + }); + test('conflicting member names are rejected without corrupting the project', async function () { + this.timeout(TEST_TIMEOUT_MS); + await assertConflictRejected(getFixtures()); + }); +} + +function registerOverlayTests(getFixtures: () => RenameFixtureSet): void { + test('out-of-range positions reject instead of crashing or editing', async function () { + this.timeout(TEST_TIMEOUT_MS); + await assertOutOfRangeRejected(getFixtures()); + }); + test('unsaved overlay symbols prepare, rename, reverse, and revert through the real LSP', async function () { + this.timeout(TEST_TIMEOUT_MS); + await assertUnsavedOverlay(getFixtures()); + }); +} + +suite('C# real LSP - rename boundaries, rejection, and overlays [RENAME-TESTS]', () => { + let fixtures: RenameFixtureSet; + + suiteSetup(async function () { + this.timeout(TEST_TIMEOUT_MS); + await activateRealSharpLsp(); + fixtures = await openRenameFixtures(); + }); + + teardown(async () => revertRenameFixtures(fixtures)); + suiteTeardown(async () => revertRenameFixtures(fixtures)); + registerPositiveTests(() => fixtures); + registerBoundaryTests(() => fixtures); + registerInvalidNameTests(() => fixtures); + registerOverlayTests(() => fixtures); +}); diff --git a/src/editors/vscode/src/test/suite/lsp-rename-symbols.test.ts b/src/editors/vscode/src/test/suite/lsp-rename-symbols.test.ts new file mode 100644 index 00000000..a342a7d1 --- /dev/null +++ b/src/editors/vscode/src/test/suite/lsp-rename-symbols.test.ts @@ -0,0 +1,385 @@ +// Real release-LSP matrix for every feasible C# [RENAME-COVERAGE] category. +import { + exerciseRename, + openRenameFixtures, + revertRenameFixtures, + type RenameCase, + type RenameFixtureSet, +} from './csharp-rename-test-kit'; +import { activateRealSharpLsp } from './refactor-test-helpers'; + +const TEST_TIMEOUT_MS = 180_000; +const SYMBOLS_ONLY = ['symbols'] as const; +const SYMBOLS_AND_USAGE = ['symbols', 'usage'] as const; +const ALL_FILES = ['symbols', 'usage', 'edge'] as const; + +const CASES: readonly RenameCase[] = [ + { + label: 'class declaration and constructor-through-type references', + fixture: 'symbols', + snippet: 'public class RenameClass', + oldName: 'RenameClass', + newName: 'RenamedClass', + editCount: 3, + files: SYMBOLS_AND_USAGE, + }, + { + label: 'struct declaration, constructor, and construction', + fixture: 'symbols', + snippet: 'public readonly struct RenameStruct', + oldName: 'RenameStruct', + newName: 'RenamedStruct', + editCount: 3, + files: SYMBOLS_AND_USAGE, + }, + { + label: 'interface declaration, implementation, and typed use', + fixture: 'symbols', + snippet: 'public interface IRenameContract', + oldName: 'IRenameContract', + newName: 'IRenamedContract', + editCount: 3, + files: SYMBOLS_AND_USAGE, + }, + { + label: 'record declaration and construction', + fixture: 'symbols', + snippet: 'public record RenameRecord', + oldName: 'RenameRecord', + newName: 'RenamedRecord', + editCount: 2, + files: SYMBOLS_AND_USAGE, + }, + { + label: 'delegate type declaration and use', + fixture: 'symbols', + snippet: 'public delegate int RenameDelegate', + oldName: 'RenameDelegate', + newName: 'RenamedDelegate', + editCount: 2, + files: SYMBOLS_AND_USAGE, + }, + { + label: 'enum type declaration and use', + fixture: 'symbols', + snippet: 'public enum RenameEnum', + oldName: 'RenameEnum', + newName: 'RenamedEnum', + editCount: 2, + files: SYMBOLS_AND_USAGE, + }, + { + label: 'enum member declaration and qualified use', + fixture: 'symbols', + snippet: 'FirstMember,', + oldName: 'FirstMember', + newName: 'RenamedMember', + editCount: 2, + files: SYMBOLS_AND_USAGE, + }, + { + label: 'record primary-constructor property', + fixture: 'symbols', + snippet: 'RenameRecord(int RecordComponent)', + oldName: 'RecordComponent', + newName: 'RenamedComponent', + editCount: 2, + files: SYMBOLS_AND_USAGE, + }, + { + label: 'ordinary method declaration and invocation', + fixture: 'symbols', + snippet: 'public int RenameMethod(int methodParameter)', + oldName: 'RenameMethod', + newName: 'RenamedMethod', + editCount: 2, + files: SYMBOLS_AND_USAGE, + }, + { + label: 'interface method, implementation, and call', + fixture: 'symbols', + snippet: 'TContract Transform', + oldName: 'Transform', + newName: 'TransformRenamed', + editCount: 3, + files: SYMBOLS_AND_USAGE, + }, + { + label: 'base method, XML cref, override, nameof, and virtual calls', + fixture: 'symbols', + snippet: 'abstract int VirtualMember', + oldName: 'VirtualMember', + newName: 'RenamedVirtualMember', + editCount: 6, + files: SYMBOLS_AND_USAGE, + after: { + symbols: [ + 'cref="RenamedVirtualMember"', + 'override int RenamedVirtualMember', + 'nameof(RenamedVirtualMember)', + 'value.RenamedVirtualMember(0)', + ], + usage: ['baseValue.RenamedVirtualMember(2)'], + }, + }, + { + label: 'interface member, explicit implementation, and interface call', + fixture: 'symbols', + snippet: 'int ExplicitMember(int value);', + oldName: 'ExplicitMember', + newName: 'RenamedExplicitMember', + editCount: 3, + files: SYMBOLS_AND_USAGE, + after: { + symbols: ['IExplicitRenameContract.RenamedExplicitMember'], + usage: ['explicitValue.RenamedExplicitMember(3)'], + }, + }, + { + label: 'local-function declaration and call', + fixture: 'symbols', + snippet: 'int RenameLocalFunction(int localFunctionParameter)', + oldName: 'RenameLocalFunction', + newName: 'RenamedLocalFunction', + editCount: 2, + files: SYMBOLS_ONLY, + }, + { + label: 'property declaration, write, and read', + fixture: 'symbols', + snippet: 'public int RenameProperty { get; set; }', + oldName: 'RenameProperty', + newName: 'RenamedProperty', + editCount: 3, + files: SYMBOLS_AND_USAGE, + }, + { + label: 'private field declaration and all accesses', + fixture: 'symbols', + snippet: 'private int _renameField;', + oldName: '_renameField', + newName: '_renamedField', + editCount: 4, + files: SYMBOLS_ONLY, + }, + { + label: 'constant declaration and all reads', + fixture: 'symbols', + snippet: 'const int RenameConstant', + oldName: 'RenameConstant', + newName: 'RenamedConstant', + editCount: 3, + files: SYMBOLS_ONLY, + }, + { + label: 'event declaration, raise, and subscription', + fixture: 'symbols', + snippet: 'event EventHandler? RenameEvent', + oldName: 'RenameEvent', + newName: 'RenamedEvent', + editCount: 3, + files: SYMBOLS_AND_USAGE, + }, + { + label: 'ordinary local declaration and read', + fixture: 'symbols', + snippet: 'var renameLocal =', + oldName: 'renameLocal', + newName: 'renamedLocal', + editCount: 2, + files: SYMBOLS_ONLY, + }, + { + label: 'foreach variable declaration and read', + fixture: 'symbols', + snippet: 'var foreachValue in', + oldName: 'foreachValue', + newName: 'renamedForeachValue', + editCount: 2, + files: SYMBOLS_ONLY, + }, + { + label: 'catch variable declaration and read', + fixture: 'symbols', + snippet: 'InvalidOperationException catchError', + oldName: 'catchError', + newName: 'renamedCatchError', + editCount: 2, + files: SYMBOLS_ONLY, + }, + { + label: 'using variable declaration and read', + fixture: 'symbols', + snippet: 'using var usingResource', + oldName: 'usingResource', + newName: 'renamedResource', + editCount: 2, + files: SYMBOLS_ONLY, + }, + { + label: 'left deconstruction variable', + fixture: 'symbols', + snippet: '(deconstructedLeft, deconstructedRight)', + focus: 'deconstructedLeft', + oldName: 'deconstructedLeft', + newName: 'renamedLeft', + editCount: 2, + files: SYMBOLS_ONLY, + }, + { + label: 'right deconstruction variable', + fixture: 'symbols', + snippet: '(deconstructedLeft, deconstructedRight)', + focus: 'deconstructedRight', + oldName: 'deconstructedRight', + newName: 'renamedRight', + editCount: 2, + files: SYMBOLS_ONLY, + }, + { + label: 'pattern source local', + fixture: 'symbols', + snippet: 'object patternSource', + oldName: 'patternSource', + newName: 'renamedPatternSource', + editCount: 2, + files: SYMBOLS_ONLY, + }, + { + label: 'pattern variable declaration and read', + fixture: 'symbols', + snippet: 'is int patternValue', + oldName: 'patternValue', + newName: 'renamedPatternValue', + editCount: 2, + files: SYMBOLS_ONLY, + }, + { + label: 'ordinary method parameter', + fixture: 'symbols', + snippet: 'RenameMethod(int methodParameter)', + oldName: 'methodParameter', + newName: 'renamedMethodParameter', + editCount: 2, + files: SYMBOLS_ONLY, + }, + { + label: 'constructor parameter', + fixture: 'symbols', + snippet: 'RenameClass(TType constructorParameter)', + oldName: 'constructorParameter', + newName: 'renamedConstructorParameter', + editCount: 2, + files: SYMBOLS_ONLY, + }, + { + label: 'indexer parameter declaration and accessors', + fixture: 'symbols', + snippet: 'this[int indexParameter]', + oldName: 'indexParameter', + newName: 'renamedIndex', + editCount: 3, + files: SYMBOLS_ONLY, + }, + { + label: 'local-function parameter', + fixture: 'symbols', + snippet: 'RenameLocalFunction(int localFunctionParameter)', + oldName: 'localFunctionParameter', + newName: 'renamedLocalParameter', + editCount: 2, + files: SYMBOLS_ONLY, + }, + { + label: 'lambda parameter declaration and body', + fixture: 'symbols', + snippet: 'lambdaParameter =>', + oldName: 'lambdaParameter', + newName: 'renamedLambdaParameter', + editCount: 2, + files: SYMBOLS_ONLY, + }, + { + label: 'delegate signature parameter', + fixture: 'symbols', + snippet: 'RenameDelegate(int delegateParameter)', + oldName: 'delegateParameter', + newName: 'renamedDelegateParameter', + editCount: 1, + files: SYMBOLS_ONLY, + }, + { + label: 'class generic type parameter', + fixture: 'symbols', + snippet: 'RenameClass', + oldName: 'TType', + newName: 'TTypeRenamed', + editCount: 6, + files: SYMBOLS_ONLY, + }, + { + label: 'interface generic type parameter', + fixture: 'symbols', + snippet: 'IRenameContract', + oldName: 'TContract', + newName: 'TContractRenamed', + editCount: 4, + files: SYMBOLS_ONLY, + }, + { + label: 'method generic type parameter', + fixture: 'symbols', + snippet: 'Transform', + oldName: 'TMethod', + newName: 'TMethodRenamed', + editCount: 2, + files: SYMBOLS_ONLY, + }, + { + label: 'using alias declaration and use', + fixture: 'symbols', + snippet: 'using ResourceAlias =', + oldName: 'ResourceAlias', + newName: 'RenamedResourceAlias', + editCount: 2, + files: SYMBOLS_ONLY, + }, + { + label: 'constructor token initiates the containing type rename', + fixture: 'symbols', + snippet: 'public RenameClass(TType constructorParameter)', + oldName: 'RenameClass', + newName: 'ConstructorRenamedClass', + editCount: 3, + files: SYMBOLS_AND_USAGE, + }, + { + label: 'namespace segment across every C# fixture document', + fixture: 'symbols', + snippet: 'namespace SharpLsp.TestFixtures.RenameCoverage', + oldName: 'RenameCoverage', + newName: 'RenamedCoverage', + editCount: 3, + files: ALL_FILES, + }, +]; + +suite('C# real LSP - exhaustive symbol rename matrix [RENAME-TESTS]', () => { + let fixtures: RenameFixtureSet; + + suiteSetup(async function () { + this.timeout(TEST_TIMEOUT_MS); + await activateRealSharpLsp(); + fixtures = await openRenameFixtures(); + }); + + teardown(async () => revertRenameFixtures(fixtures)); + suiteTeardown(async () => revertRenameFixtures(fixtures)); + + for (const renameCase of CASES) { + test(`${renameCase.label}: prepare, edit, apply, requery, reverse, revert`, async function () { + this.timeout(TEST_TIMEOUT_MS); + await exerciseRename(fixtures, renameCase); + }); + } +}); diff --git a/editors/vscode/src/test/suite/nuget-browser.test.ts b/src/editors/vscode/src/test/suite/nuget-browser.test.ts similarity index 99% rename from editors/vscode/src/test/suite/nuget-browser.test.ts rename to src/editors/vscode/src/test/suite/nuget-browser.test.ts index 70971b21..fab1435f 100644 --- a/editors/vscode/src/test/suite/nuget-browser.test.ts +++ b/src/editors/vscode/src/test/suite/nuget-browser.test.ts @@ -20,9 +20,9 @@ interface SharpLspApiForNuGetTests { /** Absolute path to the NuGetTest fixture project (has Newtonsoft.Json installed). */ function nugetTestProjectPath(): string { - // __dirname: editors/vscode/out/test/suite/ → repo root is 5 levels up. - const repoRoot = path.resolve(__dirname, '../../../../..'); - return path.join(repoRoot, 'tests', 'fixtures', 'NuGetTest', 'NuGetTest.csproj'); + // __dirname: src/editors/vscode/out/test/suite/ → /src is 5 levels up. + const sourceRoot = path.resolve(__dirname, '../../../../..'); + return path.join(sourceRoot, 'sharplsp', 'tests', 'fixtures', 'NuGetTest', 'NuGetTest.csproj'); } suite('NuGet Browser', () => { @@ -362,7 +362,7 @@ suite('NuGet Browser', () => { /** * REGRESSION: The mockup includes chrome (activity bar, status bar) that * belongs to VS Code itself, not the webview panel. None of those - * elements may appear in the rendered HTML. See docs/designs/DESIGN.md § 0. + * elements may appear in the rendered HTML. See [NUGET-WEBVIEW-DESIGN]. */ test('rendered HTML does not include VS Code chrome (regression)', async function () { this.timeout(30_000); @@ -793,7 +793,7 @@ suite('NuGet Browser', () => { assert.ok( !detailsAfter.includes('uninstallPackage'), 'After csproj removes PackageReference, details panel MUST NOT render Remove button. ' + - 'Snapshot-vs-live-derivation regression — see VSCODE-REACTIVITY-SPEC.md §8.', + 'Snapshot-vs-live-derivation regression — see [VSCODE-REACTIVITY-STATE].', ); // selectedPackage is intentionally NOT cleared by an external edit diff --git a/editors/vscode/src/test/suite/nuget-deps-e2e.test.ts b/src/editors/vscode/src/test/suite/nuget-deps-e2e.test.ts similarity index 100% rename from editors/vscode/src/test/suite/nuget-deps-e2e.test.ts rename to src/editors/vscode/src/test/suite/nuget-deps-e2e.test.ts diff --git a/editors/vscode/src/test/suite/profiler-e2e.test.ts b/src/editors/vscode/src/test/suite/profiler-e2e.test.ts similarity index 100% rename from editors/vscode/src/test/suite/profiler-e2e.test.ts rename to src/editors/vscode/src/test/suite/profiler-e2e.test.ts diff --git a/editors/vscode/src/test/suite/profiler.test.ts b/src/editors/vscode/src/test/suite/profiler.test.ts similarity index 100% rename from editors/vscode/src/test/suite/profiler.test.ts rename to src/editors/vscode/src/test/suite/profiler.test.ts diff --git a/editors/vscode/src/test/suite/project-deps-watcher-e2e.test.ts b/src/editors/vscode/src/test/suite/project-deps-watcher-e2e.test.ts similarity index 97% rename from editors/vscode/src/test/suite/project-deps-watcher-e2e.test.ts rename to src/editors/vscode/src/test/suite/project-deps-watcher-e2e.test.ts index bfb9bd88..9e2e7436 100644 --- a/editors/vscode/src/test/suite/project-deps-watcher-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/project-deps-watcher-e2e.test.ts @@ -1,4 +1,4 @@ -// Covers VSCODE-REACTIVITY-SPEC §4 (project-dependencies watcher). +// Covers [VSCODE-REACTIVITY-WATCHERS] (project-dependencies watcher). // // Reproduces the Windows crash where deleting a tracked project's directory // fired the node FSWatcher's async 'error' event (EPERM) with no listener diff --git a/editors/vscode/src/test/suite/real-repo-fluentvalidation.test.ts b/src/editors/vscode/src/test/suite/real-repo-fluentvalidation.test.ts similarity index 85% rename from editors/vscode/src/test/suite/real-repo-fluentvalidation.test.ts rename to src/editors/vscode/src/test/suite/real-repo-fluentvalidation.test.ts index ccde4c35..ec37bd83 100644 --- a/editors/vscode/src/test/suite/real-repo-fluentvalidation.test.ts +++ b/src/editors/vscode/src/test/suite/real-repo-fluentvalidation.test.ts @@ -7,6 +7,7 @@ import * as assert from 'node:assert/strict'; import * as path from 'node:path'; import * as vscode from 'vscode'; +import { codeOf } from './csharp-refactor-test-kit'; import { hoverText } from './fsharp-helpers'; import { FLUENT_VALIDATION, @@ -15,7 +16,6 @@ import { assertServerResourceBounds, ensureRepoReady, completionLabel, - firstError, firstLocation, fixtureSolutionPath, loadSolutionInServer, @@ -23,14 +23,15 @@ import { positionOf, sampleServerProcesses, selectionDepth, - waitForErrorsCleared, + waitForError, + waitForErrorBaseline, + waitForStableErrorBaseline, waitForSemanticReady, } from './real-repo-helpers'; import { closeAllEditors, flattenSymbolNames, pollUntilResult, - waitForDiagnostics, waitForDocumentSymbols, waitForFoldingRanges, waitForHoverResult, @@ -194,24 +195,49 @@ suite('Real repo stress — FluentValidation (C#)', () => { }); test('diagnostics round-trip: a broken generic constraint surfaces and clears', async function () { - this.timeout(180_000); + // Four sequential waits of 120s each: the budget has to exceed their sum, + // or mocha kills the test first and reports an opaque timeout instead of + // whichever stage actually stalled. + this.timeout(600_000); const { doc, uri, editor } = await openRepoFile(repoDir, IVALIDATOR_CS); await waitForDocumentSymbols(uri, 120_000); + // Whatever the server settles on for this file IS the baseline — the test is + // the round trip, not the count. Pinning a number here encodes how much of + // the solution the server currently resolves rather than a property of the + // pinned source, and an unreachable pin makes the wait unsatisfiable rather + // than merely wrong. FluentValidation 12.1.1 is a released library, so a + // correctly resolved IValidator.cs legitimately reports no errors at all. + const baseline = await waitForStableErrorBaseline(uri, 120_000); + const pristineText = doc.getText(); + const pristineVersion = doc.version; const insertAt = positionOf(doc, 'public interface IValidator {'); + const probe = 'file class __SharpLspBad { string S = 42; }\n'; const applied = await editor.edit((edit) => { - edit.insert(insertAt, 'file class __SharpLspBad { string S = 42; }\n'); + edit.insert(insertAt, probe); }); assert.ok(applied, 'error-inducing edit must apply'); + assert.ok(doc.version > pristineVersion, 'error edit must advance the version'); + assert.ok(doc.getText().includes('__SharpLspBad')); + const insertedVersion = doc.version; try { - const diagnostics = await waitForDiagnostics(uri, 120_000); - const error = firstError(diagnostics, 'bad field initializer'); - assert.ok(error.message.length > 0, 'diagnostic must carry a message'); - assertSaneRange(doc, error.range, 'error diagnostic'); + const error = await waitForError( + uri, + 120_000, + (item) => codeOf(item) === 'CS0029' && item.range.start.line === insertAt.line, + ); + assert.strictEqual(codeOf(error), 'CS0029'); + assert.strictEqual(error.source, 'sharplsp-csharp'); + assert.ok(error.message.includes("'int'")); + assert.ok(error.message.includes("'string'")); + assertSaneRange(doc, error.range, 'injected CS0029'); } finally { await vscode.commands.executeCommand('undo'); } - await waitForErrorsCleared(uri, 120_000); + assert.ok(doc.version > insertedVersion, 'undo must advance the document version'); + assert.strictEqual(doc.getText(), pristineText, 'undo must restore the exact source'); + assert.ok(!doc.getText().includes('__SharpLspBad')); + await waitForErrorBaseline(uri, baseline, 120_000); }); test('structure + rename dry-run: folding, selections, and a safe local rename plan', async function () { diff --git a/editors/vscode/src/test/suite/real-repo-fstoolkit.test.ts b/src/editors/vscode/src/test/suite/real-repo-fstoolkit.test.ts similarity index 99% rename from editors/vscode/src/test/suite/real-repo-fstoolkit.test.ts rename to src/editors/vscode/src/test/suite/real-repo-fstoolkit.test.ts index 5966f7b0..1ab1e117 100644 --- a/editors/vscode/src/test/suite/real-repo-fstoolkit.test.ts +++ b/src/editors/vscode/src/test/suite/real-repo-fstoolkit.test.ts @@ -188,7 +188,7 @@ suite('Real repo stress — FsToolkit.ErrorHandling (F#)', () => { // clear them. Fixed in FSharpAssets ([PKG-ASSETS-FS]) by filtering the // filename component. The investigation also hardened the push pipeline // ([DIAG-PUSH-GATE]) and funneled every F# per-file analysis through one - // canonical overlay-aware check ([FS-DIDCHANGE-OVERLAY]). + // canonical overlay-aware check ([HOVER-FSHARP-OVERLAY]). test('diagnostics round-trip: an F# type error surfaces and clears', async function () { this.timeout(420_000); const { doc, uri, editor } = await openRepoFile(repoDir, RESULT_FS); diff --git a/editors/vscode/src/test/suite/real-repo-helpers.ts b/src/editors/vscode/src/test/suite/real-repo-helpers.ts similarity index 62% rename from editors/vscode/src/test/suite/real-repo-helpers.ts rename to src/editors/vscode/src/test/suite/real-repo-helpers.ts index e829ef0e..9efbebe9 100644 --- a/editors/vscode/src/test/suite/real-repo-helpers.ts +++ b/src/editors/vscode/src/test/suite/real-repo-helpers.ts @@ -1,6 +1,6 @@ // Shared harness for the real-world repository e2e stress suites. // -// Clones pinned tags of real, popular .NET repos into /real-world-fixtures/ +// Clones pinned tags of real, popular .NET repos into /src/fixtures/real-world/ // (gitignored — never committed), restores them once, and exposes interaction // + resource-sampling helpers. Tests drive REAL solutions through the REAL // extension host and assert on the LSP results and on the server processes' @@ -11,9 +11,15 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as vscode from 'vscode'; import { EXTENSION_ID, removeDirRecursive, waitForHoverResult } from './test-helpers'; +export { + assertCpuSettles, + assertServerResourceBounds, + sampleServerProcesses, + type ProcessSample, +} from './real-repo-process-helpers'; export interface RealRepoSpec { - /** Directory name under real-world-fixtures/. */ + /** Directory name under src/fixtures/real-world/. */ name: string; url: string; /** Pinned tag — keeps anchors deterministic across runs. */ @@ -43,9 +49,9 @@ export const FSTOOLKIT: RealRepoSpec = { sln: 'FsToolkit.ErrorHandling.sln', }; -/** /real-world-fixtures — out/test/suite is five levels down. */ +/** /src/fixtures/real-world — out/test/suite is five levels below src. */ export function realWorldFixturesRoot(): string { - return path.resolve(__dirname, '..', '..', '..', '..', '..', 'real-world-fixtures'); + return path.resolve(__dirname, '..', '..', '..', '..', '..', 'fixtures', 'real-world'); } const RESTORED_MARKER = '.sharplsp-restored'; @@ -193,98 +199,6 @@ export function assertSaneRange( // ── Server process sampling (memory / CPU stress assertions) ────── -export interface ProcessSample { - pid: number; - name: string; - rssBytes: number; - cpuSeconds: number; - commandLine: string; -} - -/** - * Sample every SharpLsp server process: the Rust host binary plus the C#/F# - * sidecars (matched by name or command line, however they were spawned). - * Read-only: never signals or kills anything. - */ -export function sampleServerProcesses(): ProcessSample[] { - const all = process.platform === 'win32' ? sampleWindows() : samplePosix(); - return all.filter( - (proc) => - proc.name.toLowerCase().includes('sharplsp') || - proc.commandLine.toLowerCase().includes('sharplsp'), - ); -} - -interface Win32ProcessRow { - ProcessId: number; - Name: string; - CommandLine: string | null; - WorkingSetSize: number; - UserModeTime: number; - KernelModeTime: number; -} - -function sampleWindows(): ProcessSample[] { - const script = - "Get-CimInstance Win32_Process -Filter \"Name LIKE 'sharplsp%' OR Name='dotnet.exe'\" | " + - 'Select-Object ProcessId,Name,CommandLine,WorkingSetSize,UserModeTime,KernelModeTime | ConvertTo-Json -Compress'; - const raw = execFileSync( - 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-Command', script], - { - encoding: 'utf8', - timeout: 30_000, - }, - ).trim(); - if (raw.length === 0) return []; - const parsed = JSON.parse(raw) as Win32ProcessRow | Win32ProcessRow[]; - const rows = Array.isArray(parsed) ? parsed : [parsed]; - return rows.map((row) => ({ - pid: row.ProcessId, - name: row.Name, - rssBytes: row.WorkingSetSize, - // Win32_Process times are in 100ns units. - cpuSeconds: (row.UserModeTime + row.KernelModeTime) / 1e7, - commandLine: row.CommandLine ?? '', - })); -} - -function samplePosix(): ProcessSample[] { - const raw = execFileSync('ps', ['-eo', 'pid=,rss=,time=,args='], { - encoding: 'utf8', - timeout: 30_000, - }); - return raw - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => { - const match = /^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line); - const [, pid, rssKb, time, args] = match ?? []; - if (pid === undefined || rssKb === undefined || time === undefined || args === undefined) { - return undefined; - } - return { - pid: Number(pid), - name: path.basename(args.split(' ')[0] ?? ''), - rssBytes: Number(rssKb) * 1024, - cpuSeconds: parsePsTime(time), - commandLine: args, - }; - }) - .filter((sample): sample is ProcessSample => sample !== undefined); -} - -/** Parse ps TIME ([[dd-]hh:]mm:ss) into seconds. */ -function parsePsTime(time: string): number { - const dashIndex = time.indexOf('-'); - const days = dashIndex >= 0 ? Number(time.slice(0, dashIndex)) : 0; - const clock = dashIndex >= 0 ? time.slice(dashIndex + 1) : time; - const parts = clock.split(':').map(Number).reverse(); - const [seconds = 0, minutes = 0, hours = 0] = parts; - return days * 86_400 + hours * 3_600 + minutes * 60 + seconds; -} - // ── Shared assertion helpers (used identically by every suite) ───── /** CompletionItem.label is string | CompletionItemLabel — normalize to text. */ @@ -320,20 +234,97 @@ export function firstError(diagnostics: vscode.Diagnostic[], label: string): vsc * lint), so waiting for *any* diagnostic returns long before the semantic * check of an injected error completes — the wait must be severity-aware. */ -export async function waitForError(uri: vscode.Uri, timeoutMs: number): Promise { +export async function waitForError( + uri: vscode.Uri, + timeoutMs: number, + predicate: (diagnostic: vscode.Diagnostic) => boolean = () => true, +): Promise { const currentError = (): vscode.Diagnostic | undefined => vscode.languages .getDiagnostics(uri) - .find((d) => d.severity === vscode.DiagnosticSeverity.Error); + .find((item) => item.severity === vscode.DiagnosticSeverity.Error && predicate(item)); const deadline = Date.now() + timeoutMs; while (currentError() === undefined && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 500)); } const error = currentError(); - assert.ok(error, 'an Error diagnostic must surface for the broken buffer'); + assert.ok(error, 'the requested Error diagnostic must surface'); return error; } +function errorDiagnosticKeys(uri: vscode.Uri): string[] { + return vscode.languages + .getDiagnostics(uri) + .filter((item) => item.severity === vscode.DiagnosticSeverity.Error) + .map((item) => { + const code = + typeof item.code === 'object' && item.code !== null ? item.code.value : item.code; + return JSON.stringify([ + item.source ?? '', + String(code ?? ''), + item.message, + item.range.start.line, + item.range.start.character, + item.range.end.line, + item.range.end.character, + ]); + }) + .sort(); +} + +/** + * Wait until the file's error diagnostics stop changing for 2s. + * + * `minimumErrors` guards against returning a baseline the language server has + * not finished populating. Set it only when the file genuinely must report that + * many errors — a value the source cannot reach makes this unsatisfiable, and + * the loop will burn the whole timeout waiting for a count that never arrives. + * The failure therefore reports what it actually observed, so a wrong + * expectation is distinguishable from a slow one. + */ +export async function waitForStableErrorBaseline( + uri: vscode.Uri, + timeoutMs: number, + minimumErrors = 0, +): Promise { + const deadline = Date.now() + timeoutMs; + let previous = errorDiagnosticKeys(uri); + let stableSince = Date.now(); + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 500)); + const current = errorDiagnosticKeys(uri); + if (JSON.stringify(current) !== JSON.stringify(previous)) { + previous = current; + stableSince = Date.now(); + } else if (current.length >= minimumErrors && Date.now() - stableSince >= 2_000) { + return current; + } + } + const settled = errorDiagnosticKeys(uri); + assert.fail( + `Error diagnostic baseline never stabilized: wanted at least ${String(minimumErrors)} ` + + `error(s) unchanged for 2s within ${String(timeoutMs)}ms, but settled on ${String(settled.length)}: ` + + JSON.stringify(settled), + ); +} + +export async function waitForErrorBaseline( + uri: vscode.Uri, + expected: readonly string[], + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (JSON.stringify(errorDiagnosticKeys(uri)) === JSON.stringify(expected)) return; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + assert.deepStrictEqual( + errorDiagnosticKeys(uri), + expected, + 'Error diagnostics must return to their exact pre-edit baseline', + ); +} + /** * Wait until a document carries zero Error diagnostics, then assert it. * Real-world files may legitimately keep warnings/hints — asserting on a @@ -350,59 +341,3 @@ export async function waitForErrorsCleared(uri: vscode.Uri, timeoutMs: number): } assert.strictEqual(currentErrors().length, 0, 'Error diagnostics must clear after the revert'); } - -const HOST_RSS_MAX_BYTES = 2 * 1024 ** 3; // Rust host: 2 GiB is already pathological. -const SIDECAR_RSS_MAX_BYTES = 4 * 1024 ** 3; // Roslyn/FCS on a medium repo stays well under 4 GiB. -const MAX_SIDECARS_PER_LANGUAGE = 2; // >2 of one language = the orphaned-process leak (#133). - -/** - * Assert the server fleet is alive and within resource bounds. Bounds are - * deliberately generous — they exist to catch runaway leaks and process - * storms, not to flake on GC timing. - */ -export function assertServerResourceBounds(samples: ProcessSample[]): void { - assert.ok(samples.length >= 1, 'at least one SharpLsp server process must be running'); - for (const proc of samples) { - const isHost = - proc.name.toLowerCase().startsWith('sharplsp') && !proc.commandLine.includes('sidecar'); - const cap = isHost ? HOST_RSS_MAX_BYTES : SIDECAR_RSS_MAX_BYTES; - const mib = Math.round(proc.rssBytes / 1024 ** 2); - assert.ok( - proc.rssBytes < cap, - `${proc.name} (pid ${proc.pid.toString()}) rss ${mib.toString()} MiB exceeds ${Math.round(cap / 1024 ** 2).toString()} MiB cap`, - ); - assert.ok(proc.cpuSeconds >= 0, `${proc.name} cpu time must be readable`); - } - for (const language of ['sidecar-csharp', 'sidecar-fsharp']) { - const count = samples.filter((proc) => proc.commandLine.includes(language)).length; - assert.ok( - count <= MAX_SIDECARS_PER_LANGUAGE, - `${count.toString()} ${language} processes running — process leak (expected <= ${MAX_SIDECARS_PER_LANGUAGE.toString()})`, - ); - } -} - -/** - * Assert CPU settles after a burst. Background analysis (solution-wide - * diagnostics sweeps, FCS checks) legitimately runs hot right after a storm, - * so "settles" means SOME `windowMs` window stays under `maxCpuSeconds` - * within a minute — only a permanently pegged fleet (runaway loop) fails. - */ -export async function assertCpuSettles(windowMs: number, maxCpuSeconds: number): Promise { - const attempts = 12; - let lastDelta = 0; - for (let attempt = 0; attempt < attempts; attempt += 1) { - const before = totalCpuSeconds(sampleServerProcesses()); - await new Promise((resolve) => setTimeout(resolve, windowMs)); - lastDelta = totalCpuSeconds(sampleServerProcesses()) - before; - if (lastDelta < maxCpuSeconds) return; - } - assert.fail( - `server fleet never settled: still burning ${lastDelta.toFixed(1)} cpu-seconds per ` + - `${windowMs.toString()}ms window after ${attempts.toString()} windows (cap ${maxCpuSeconds.toString()}s)`, - ); -} - -function totalCpuSeconds(samples: ProcessSample[]): number { - return samples.reduce((sum, proc) => sum + proc.cpuSeconds, 0); -} diff --git a/src/editors/vscode/src/test/suite/real-repo-process-helpers.ts b/src/editors/vscode/src/test/suite/real-repo-process-helpers.ts new file mode 100644 index 00000000..0bc063c5 --- /dev/null +++ b/src/editors/vscode/src/test/suite/real-repo-process-helpers.ts @@ -0,0 +1,214 @@ +// Process-tree resource assertions for the real-repository extension-host suites. +import * as assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import * as path from 'node:path'; + +export interface ProcessSample { + pid: number; + parentPid: number; + name: string; + rssBytes: number; + cpuSeconds: number; + commandLine: string; +} + +interface Win32ProcessRow { + ProcessId: number; + ParentProcessId: number; + Name: string; + CommandLine: string | null; + WorkingSetSize: number; + UserModeTime: number; + KernelModeTime: number; +} + +const WINDOWS_PROCESS_QUERY = + 'Get-CimInstance Win32_Process | ' + + 'Select-Object ProcessId,ParentProcessId,Name,CommandLine,' + + 'WorkingSetSize,UserModeTime,KernelModeTime | ConvertTo-Json -Compress'; + +const ownedProcessIdentities = new Map(); + +/** Sample the SharpLsp fleet started by this host, retaining any surviving orphans. */ +export function sampleServerProcesses(rootPid = process.pid): ProcessSample[] { + const all = process.platform === 'win32' ? sampleWindows() : samplePosix(); + const descendants = descendantPids(all, rootPid); + pruneOwnedProcesses(all); + rememberOwnedProcesses(all, descendants); + return all.filter((sample) => ownedProcessIdentities.get(sample.pid) === processIdentity(sample)); +} + +function pruneOwnedProcesses(samples: readonly ProcessSample[]): void { + const live = new Map(samples.map((sample) => [sample.pid, sample])); + for (const [pid, identity] of ownedProcessIdentities) { + const sample = live.get(pid); + if ( + sample === undefined || + !isSharpLspProcess(sample) || + processIdentity(sample) !== identity + ) { + ownedProcessIdentities.delete(pid); + } + } +} + +function rememberOwnedProcesses( + samples: readonly ProcessSample[], + descendants: ReadonlySet, +): void { + for (const sample of samples) { + if (descendants.has(sample.pid) && isSharpLspProcess(sample)) { + ownedProcessIdentities.set(sample.pid, processIdentity(sample)); + } + } +} + +function processIdentity(sample: ProcessSample): string { + return `${sample.name}\u001f${sample.commandLine}`; +} + +function sampleWindows(): ProcessSample[] { + const raw = execFileSync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_PROCESS_QUERY], + { encoding: 'utf8', timeout: 30_000 }, + ).trim(); + if (raw.length === 0) return []; + const parsed = JSON.parse(raw) as Win32ProcessRow | Win32ProcessRow[]; + const rows = Array.isArray(parsed) ? parsed : [parsed]; + return rows.map(windowsProcessSample); +} + +function windowsProcessSample(row: Win32ProcessRow): ProcessSample { + return { + pid: row.ProcessId, + parentPid: row.ParentProcessId, + name: row.Name, + rssBytes: row.WorkingSetSize, + cpuSeconds: (row.UserModeTime + row.KernelModeTime) / 1e7, + commandLine: row.CommandLine ?? '', + }; +} + +function samplePosix(): ProcessSample[] { + const raw = execFileSync('ps', ['-eo', 'pid=,ppid=,rss=,time=,args='], { + encoding: 'utf8', + timeout: 30_000, + }); + return raw + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map(parsePosixSample) + .filter((sample): sample is ProcessSample => sample !== undefined); +} + +function parsePosixSample(line: string): ProcessSample | undefined { + const match = /^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line); + const [, pid, parentPid, rssKb, time, args] = match ?? []; + if ( + pid === undefined || + parentPid === undefined || + rssKb === undefined || + time === undefined || + args === undefined + ) + return undefined; + return { + pid: Number(pid), + parentPid: Number(parentPid), + name: path.basename(args.split(' ')[0] ?? ''), + rssBytes: Number(rssKb) * 1024, + cpuSeconds: parsePsTime(time), + commandLine: args, + }; +} + +function descendantPids(samples: readonly ProcessSample[], rootPid: number): Set { + const descendants = new Set([rootPid]); + let changed = true; + while (changed) { + changed = false; + for (const sample of samples) { + if (!descendants.has(sample.parentPid) || descendants.has(sample.pid)) continue; + descendants.add(sample.pid); + changed = true; + } + } + return descendants; +} + +function isSharpLspProcess(sample: ProcessSample): boolean { + const name = sample.name.toLowerCase(); + return ( + name.startsWith('sharplsp') || + ((name === 'dotnet' || name === 'dotnet.exe') && sidecarLanguage(sample) !== undefined) + ); +} + +function sidecarLanguage(sample: ProcessSample): 'csharp' | 'fsharp' | undefined { + const processText = `${sample.name} ${sample.commandLine}`.toLowerCase(); + if (processText.includes('sidecar-csharp') || processText.includes('sidecar.csharp')) { + return 'csharp'; + } + if (processText.includes('sidecar-fsharp') || processText.includes('sidecar.fsharp')) { + return 'fsharp'; + } + return undefined; +} + +function parsePsTime(time: string): number { + const dashIndex = time.indexOf('-'); + const days = dashIndex >= 0 ? Number(time.slice(0, dashIndex)) : 0; + const clock = dashIndex >= 0 ? time.slice(dashIndex + 1) : time; + const parts = clock.split(':').map(Number).reverse(); + const [seconds = 0, minutes = 0, hours = 0] = parts; + return days * 86_400 + hours * 3_600 + minutes * 60 + seconds; +} + +const HOST_RSS_MAX_BYTES = 2 * 1024 ** 3; +const SIDECAR_RSS_MAX_BYTES = 4 * 1024 ** 3; +const MAX_SIDECARS_PER_LANGUAGE = 2; + +export function assertServerResourceBounds(samples: ProcessSample[]): void { + assert.ok(samples.length >= 1, 'at least one SharpLsp server process must be running'); + for (const sample of samples) assertProcessResourceBound(sample); + for (const language of ['csharp', 'fsharp'] as const) { + const count = samples.filter((sample) => sidecarLanguage(sample) === language).length; + assert.ok( + count <= MAX_SIDECARS_PER_LANGUAGE, + `${count.toString()} ${language} sidecars running - expected <= ${MAX_SIDECARS_PER_LANGUAGE.toString()}`, + ); + } +} + +function assertProcessResourceBound(sample: ProcessSample): void { + const isHost = + sample.name.toLowerCase().startsWith('sharplsp') && !sample.commandLine.includes('sidecar'); + const cap = isHost ? HOST_RSS_MAX_BYTES : SIDECAR_RSS_MAX_BYTES; + const mib = Math.round(sample.rssBytes / 1024 ** 2); + assert.ok( + sample.rssBytes < cap, + `${sample.name} (pid ${sample.pid.toString()}) rss ${mib.toString()} MiB exceeds cap`, + ); + assert.ok(sample.cpuSeconds >= 0, `${sample.name} cpu time must be readable`); +} + +export async function assertCpuSettles(windowMs: number, maxCpuSeconds: number): Promise { + const attempts = 12; + let lastDelta = 0; + for (let attempt = 0; attempt < attempts; attempt += 1) { + const before = totalCpuSeconds(sampleServerProcesses()); + await new Promise((resolve) => setTimeout(resolve, windowMs)); + lastDelta = totalCpuSeconds(sampleServerProcesses()) - before; + if (lastDelta < maxCpuSeconds) return; + } + assert.fail( + `server fleet never settled: ${lastDelta.toFixed(1)} cpu-seconds per ` + + `${windowMs.toString()}ms window; cap ${maxCpuSeconds.toString()}s`, + ); +} + +function totalCpuSeconds(samples: ProcessSample[]): number { + return samples.reduce((sum, sample) => sum + sample.cpuSeconds, 0); +} diff --git a/editors/vscode/src/test/suite/real-repo-serilog.test.ts b/src/editors/vscode/src/test/suite/real-repo-serilog.test.ts similarity index 100% rename from editors/vscode/src/test/suite/real-repo-serilog.test.ts rename to src/editors/vscode/src/test/suite/real-repo-serilog.test.ts diff --git a/src/editors/vscode/src/test/suite/refactor-test-helpers.ts b/src/editors/vscode/src/test/suite/refactor-test-helpers.ts new file mode 100644 index 00000000..883a117b --- /dev/null +++ b/src/editors/vscode/src/test/suite/refactor-test-helpers.ts @@ -0,0 +1,394 @@ +// Shared real-LSP interaction harness for [SHARPLSP-FEATURES-REFACTORING]. +import * as assert from 'node:assert/strict'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import { State, type LanguageClient } from 'vscode-languageclient/node'; +import { + EXTENSION_ID, + LSP_RESPONSE_TIMEOUT_MS, + SERVER_START_TIMEOUT_MS, + comparableText, + pollUntilResult, +} from './test-helpers'; + +const FIXTURE_ROOT = path.resolve(__dirname, '../../../test-fixtures/workspace'); +const RESOLVE_COUNT = 1_000; +const POLL_INTERVAL_MS = 1_000; + +interface SharpLspApi { + readonly getLspClient: () => LanguageClient | undefined; +} + +export interface CodeActionQuery { + readonly uri: vscode.Uri; + readonly range: vscode.Range; + readonly predicate: (actions: vscode.CodeAction[]) => boolean; + readonly kind?: vscode.CodeActionKind; + readonly timeoutMs?: number; +} + +export interface OpenFixture { + readonly document: vscode.TextDocument; + readonly editor: vscode.TextEditor; + readonly uri: vscode.Uri; +} + +export interface LspPosition { + readonly line: number; + readonly character: number; +} + +export interface LspRange { + readonly start: LspPosition; + readonly end: LspPosition; +} + +/** Wire shape returned by textDocument/prepareRename. */ +export interface PrepareRenameResult { + readonly range: LspRange; + readonly placeholder: string; +} + +/** Shape VS Code's own `vscode.prepareRename` command resolves to. */ +export interface UiPrepareRename { + readonly range: vscode.Range; + readonly placeholder: string; +} + +export interface WorkspaceEditSnapshot { + readonly uri: vscode.Uri; + readonly document?: vscode.TextDocument; + readonly edits: readonly vscode.TextEdit[]; + readonly textBefore: string; + readonly replacedText: readonly string[]; +} + +/** Activate the shipped extension and prove its real LanguageClient is running. */ +export async function activateRealSharpLsp(): Promise { + const extension = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(extension, `${EXTENSION_ID} must be installed in the real VS Code test host`); + const api = (await extension.activate()) as SharpLspApi; + const client = api.getLspClient(); + assert.ok(client, 'SharpLsp activation must expose a real LanguageClient'); + const state = await waitForRunningState(client); + assert.strictEqual(state, State.Running, 'the real SharpLsp LanguageClient must be running'); + return client; +} + +async function waitForRunningState(client: LanguageClient): Promise { + return pollUntilResult( + async () => client.state, + (state) => state === State.Running, + SERVER_START_TIMEOUT_MS, + 250, + ); +} + +/** Send a request through the activated real LanguageClient. */ +export async function sendRealLspRequest(method: string, params: unknown): Promise { + const client = await activateRealSharpLsp(); + return client.sendRequest(method, params); +} + +/** Drive VS Code's real F2 prepare-rename command; undefined when the editor refuses. */ +export async function uiPrepareRename( + uri: vscode.Uri, + position: vscode.Position, +): Promise { + try { + return await vscode.commands.executeCommand( + 'vscode.prepareRename', + uri, + position, + ); + } catch (error: unknown) { + assert.ok(String(error).length > 0, 'a refused rename must carry an explanatory error'); + return undefined; + } +} + +/** + * Ask the real server for prepareRename and prove VS Code's own F2 UI command agrees. + * Both language kits share this so the protocol and the editor can never drift apart. + */ +export async function preparedRenameAt( + uri: vscode.Uri, + position: vscode.Position, +): Promise { + const raw = await sendRealLspRequest('textDocument/prepareRename', { + textDocument: { uri: uri.toString() }, + position: { line: position.line, character: position.character }, + }); + assertUiAgreesWithProtocol(await uiPrepareRename(uri, position), raw, uri); + return raw; +} + +function assertUiAgreesWithProtocol( + ui: UiPrepareRename | undefined, + raw: PrepareRenameResult | null, + uri: vscode.Uri, +): void { + const where = uri.fsPath; + if (raw === null) { + assert.strictEqual( + ui, + undefined, + `VS Code must also refuse F2 where the server does: ${where}`, + ); + return; + } + assert.ok(ui, `VS Code F2 must allow the rename the server allows: ${where}`); + assert.strictEqual( + ui.placeholder, + raw.placeholder, + `F2 placeholder must match protocol: ${where}`, + ); + assert.deepStrictEqual( + { start: lspPosition(ui.range.start), end: lspPosition(ui.range.end) }, + raw.range, + `F2 rename range must match protocol: ${where}`, + ); +} + +function lspPosition(position: vscode.Position): LspPosition { + return { line: position.line, character: position.character }; +} + +/** Poll the real VS Code code-action provider without resolving returned actions. */ +export async function waitForCodeActions(query: CodeActionQuery): Promise { + return waitForActions(query, undefined); +} + +/** Poll the real provider and force VS Code to drive codeAction/resolve. */ +export async function waitForResolvedCodeActions( + query: CodeActionQuery, +): Promise { + return waitForActions(query, RESOLVE_COUNT); +} + +async function waitForActions( + query: CodeActionQuery, + resolveCount: number | undefined, +): Promise { + const actions = await pollUntilResult( + async () => requestCodeActions(query, resolveCount), + query.predicate, + query.timeoutMs ?? LSP_RESPONSE_TIMEOUT_MS, + POLL_INTERVAL_MS, + ); + assert.ok( + query.predicate(actions), + `code actions never became ready for ${query.uri.fsPath}; offered: ${describeActions(actions)}`, + ); + return actions; +} + +/** Name every action the server really offered, so a miss says what it got instead. */ +function describeActions(actions: readonly vscode.CodeAction[]): string { + if (actions.length === 0) return '(none)'; + return actions.map((action) => `${action.kind?.value ?? 'nokind'}::${action.title}`).join(' | '); +} + +async function requestCodeActions( + query: CodeActionQuery, + resolveCount: number | undefined, +): Promise { + return ( + (await vscode.commands.executeCommand( + 'vscode.executeCodeActionProvider', + query.uri, + query.range, + query.kind?.value, + resolveCount, + )) ?? [] + ); +} + +/** Poll diagnostics and fail explicitly when the requested state never appears. */ +export async function waitForMatchingDiagnostics( + uri: vscode.Uri, + predicate: (diagnostics: vscode.Diagnostic[]) => boolean, + timeoutMs: number = LSP_RESPONSE_TIMEOUT_MS, +): Promise { + const diagnostics = await pollUntilResult( + async () => vscode.languages.getDiagnostics(uri), + predicate, + timeoutMs, + POLL_INTERVAL_MS, + ); + assert.ok( + predicate(diagnostics), + `diagnostics never reached the requested state for ${uri.fsPath}`, + ); + return diagnostics; +} + +/** Validate every text edit, returning the exact pre-application source snapshots. */ +export async function assertWorkspaceEditSafe( + workspaceEdit: vscode.WorkspaceEdit, +): Promise { + assert.ok(workspaceEdit.size > 0, 'WorkspaceEdit must affect at least one resource'); + const entries = workspaceEdit.entries(); + assert.ok(entries.length > 0, 'WorkspaceEdit must contain inspectable text edits'); + return Promise.all(entries.map(async (entry) => snapshotEntry(entry))); +} + +async function snapshotEntry([uri, edits]: [ + vscode.Uri, + vscode.TextEdit[], +]): Promise { + assert.strictEqual( + uri.scheme, + 'file', + `WorkspaceEdit target must be a file URI: ${uri.toString()}`, + ); + assert.ok(edits.length > 0, `WorkspaceEdit entry must contain edits: ${uri.fsPath}`); + const document = await openExistingDocument(uri); + if (document === undefined) return snapshotNewFile(uri, edits); + assertEditsSafe(document, edits); + return snapshotExistingFile(uri, document, edits); +} + +async function openExistingDocument(uri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.stat(uri); + return await vscode.workspace.openTextDocument(uri); + } catch (error: unknown) { + if (error instanceof vscode.FileSystemError && error.code === 'FileNotFound') return undefined; + throw error; + } +} + +function snapshotNewFile(uri: vscode.Uri, edits: vscode.TextEdit[]): WorkspaceEditSnapshot { + const origin = new vscode.Position(0, 0); + assert.ok( + edits.every((edit) => edit.range.start.isEqual(origin) && edit.range.end.isEqual(origin)), + `edits for a new file must insert at 0:0: ${uri.fsPath}`, + ); + return { uri, edits, textBefore: '', replacedText: edits.map(() => '') }; +} + +function snapshotExistingFile( + uri: vscode.Uri, + document: vscode.TextDocument, + edits: vscode.TextEdit[], +): WorkspaceEditSnapshot { + return { + uri, + document, + edits, + textBefore: document.getText(), + replacedText: edits.map((edit) => document.getText(edit.range)), + }; +} + +function assertEditsSafe(document: vscode.TextDocument, edits: vscode.TextEdit[]): void { + const documentEnd = document.positionAt(document.getText().length); + for (const edit of edits) assertRangeSafe(edit.range, documentEnd, document.uri); + const ordered = [...edits].sort(compareEdits); + for (let index = 1; index < ordered.length; index += 1) { + assertSeparatedEdits(ordered[index - 1], ordered[index], document.uri); + } +} + +function assertRangeSafe(range: vscode.Range, documentEnd: vscode.Position, uri: vscode.Uri): void { + assert.ok(range.start.isBeforeOrEqual(range.end), `edit range is reversed: ${uri.fsPath}`); + assert.ok(range.end.isBeforeOrEqual(documentEnd), `edit range exceeds document: ${uri.fsPath}`); +} + +function compareEdits(left: vscode.TextEdit, right: vscode.TextEdit): number { + return left.range.start.compareTo(right.range.start); +} + +function assertSeparatedEdits( + previous: vscode.TextEdit | undefined, + current: vscode.TextEdit | undefined, + uri: vscode.Uri, +): void { + assert.ok(previous && current, `edit ordering must be complete: ${uri.fsPath}`); + assert.ok( + previous.range.end.isBeforeOrEqual(current.range.start) && + !previous.range.isEqual(current.range), + `WorkspaceEdit contains overlapping edits: ${uri.fsPath}`, + ); +} + +/** Validate and apply a WorkspaceEdit through VS Code's real workspace model. */ +export async function applyWorkspaceEdit( + workspaceEdit: vscode.WorkspaceEdit, +): Promise { + const snapshots = await assertWorkspaceEditSafe(workspaceEdit); + const applied = await vscode.workspace.applyEdit(workspaceEdit); + assert.ok(applied, 'VS Code must apply the resolved WorkspaceEdit'); + return snapshots; +} + +/** Open a committed real-project fixture in a visible editor. */ +export async function openFixtureDocument(relativePath: string): Promise { + const uri = vscode.Uri.file(workspaceFixturePath(relativePath)); + const document = await vscode.workspace.openTextDocument(uri); + const editor = await vscode.window.showTextDocument(document, { preview: false }); + return { document, editor, uri }; +} + +export function workspaceFixturePath(relativePath: string): string { + return path.join(FIXTURE_ROOT, relativePath); +} + +/** Replace the entire live buffer and assert the VFS-visible document changed. */ +export async function replaceDocumentText( + document: vscode.TextDocument, + text: string, +): Promise { + const editor = await vscode.window.showTextDocument(document, { preview: false }); + const previousVersion = document.version; + const applied = await editor.edit((builder) => { + builder.replace(fullDocumentRange(document), text); + }); + assert.ok(applied, `document replacement must apply: ${document.uri.fsPath}`); + assert.ok(document.version > previousVersion, 'document replacement must advance its version'); + assert.strictEqual(comparableText(document.getText()), comparableText(text)); + return editor; +} + +/** Drive VS Code's real undo stack and wait until the live buffer reaches the expected text. */ +export async function runEditorHistory( + document: vscode.TextDocument, + command: 'undo' | 'redo', + expectedText: string, +): Promise { + await vscode.window.showTextDocument(document, { preview: false }); + const previousVersion = document.version; + await assert.doesNotReject(async () => vscode.commands.executeCommand(command)); + await waitForDocumentText(document, expectedText); + assert.ok(document.version > previousVersion, `${command} must advance the document version`); + assert.ok(document.isDirty, `${command} must preserve the unsaved source overlay`); +} + +async function waitForDocumentText( + document: vscode.TextDocument, + expectedText: string, +): Promise { + const text = await pollUntilResult( + async () => document.getText(), + (candidate) => comparableText(candidate) === comparableText(expectedText), + LSP_RESPONSE_TIMEOUT_MS, + 100, + ); + assert.strictEqual(comparableText(text), comparableText(expectedText)); +} + +function fullDocumentRange(document: vscode.TextDocument): vscode.Range { + return new vscode.Range( + new vscode.Position(0, 0), + document.positionAt(document.getText().length), + ); +} + +/** Revert a dirty file through the editor, restoring the committed fixture. */ +export async function revertDocument(document: vscode.TextDocument): Promise { + if (!document.isDirty) return; + await vscode.window.showTextDocument(document, { preview: false }); + await vscode.commands.executeCommand('workbench.action.files.revert'); + assert.ok(!document.isDirty, `document must be clean after revert: ${document.uri.fsPath}`); +} diff --git a/editors/vscode/src/test/suite/scaffolding-e2e.test.ts b/src/editors/vscode/src/test/suite/scaffolding-e2e.test.ts similarity index 100% rename from editors/vscode/src/test/suite/scaffolding-e2e.test.ts rename to src/editors/vscode/src/test/suite/scaffolding-e2e.test.ts diff --git a/editors/vscode/src/test/suite/scaffolding.test.ts b/src/editors/vscode/src/test/suite/scaffolding.test.ts similarity index 100% rename from editors/vscode/src/test/suite/scaffolding.test.ts rename to src/editors/vscode/src/test/suite/scaffolding.test.ts diff --git a/editors/vscode/src/test/suite/screenshot-watcher.mjs b/src/editors/vscode/src/test/suite/screenshot-watcher.mjs similarity index 100% rename from editors/vscode/src/test/suite/screenshot-watcher.mjs rename to src/editors/vscode/src/test/suite/screenshot-watcher.mjs diff --git a/editors/vscode/src/test/suite/solution-explorer.test.ts b/src/editors/vscode/src/test/suite/solution-explorer.test.ts similarity index 100% rename from editors/vscode/src/test/suite/solution-explorer.test.ts rename to src/editors/vscode/src/test/suite/solution-explorer.test.ts diff --git a/src/editors/vscode/src/test/suite/sort-members-assertions.ts b/src/editors/vscode/src/test/suite/sort-members-assertions.ts new file mode 100644 index 00000000..8c280757 --- /dev/null +++ b/src/editors/vscode/src/test/suite/sort-members-assertions.ts @@ -0,0 +1,148 @@ +// Assertion library for real [SE-CONTEXT-SORT-MEMBERS] interactions. +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; + +export const CLASS_ANCHORS: Readonly> = { + Alpha: 'public string Alpha()', + AlphaConstant: 'public const int AlphaConstant', + Beta: 'public string Beta()', + Omega: 'public string Omega', + SortMembersCommand: 'public SortMembersCommand()', + Zebra: 'private string Zebra()', + _zeta: 'private readonly int _zeta', +}; + +interface LspRange { + readonly start: { readonly line: number; readonly character: number }; + readonly end: { readonly line: number; readonly character: number }; +} + +interface TreeNodeShape { + readonly children: readonly TreeNodeShape[]; + readonly sortName: string; + readonly symbolUri?: string; + readonly symbolRange?: LspRange; + readonly parent?: TreeNodeShape; +} + +export function assertClassRange(document: vscode.TextDocument, range: LspRange): void { + assert.strictEqual(range.start.line, 2, 'class range starts on the declaration line'); + assert.strictEqual(range.start.character, 0, 'class range starts before its public modifier'); + assert.ok(range.end.line > range.start.line, 'class range spans its body'); + assert.ok(range.end.line < document.lineCount, 'class range ends inside the live document'); + assert.ok(range.end.character >= 1, 'class range includes its closing brace'); + const text = document.getText(toRange(range)); + assert.ok(text.startsWith('public sealed class SortMembersCommand')); + assert.ok(text.includes(CLASS_ANCHORS.AlphaConstant ?? 'missing-anchor')); + assert.ok(text.includes(CLASS_ANCHORS.Zebra ?? 'missing-anchor')); + assert.ok(text.trimEnd().endsWith('}')); +} + +export function assertTreeChildren(node: TreeNodeShape, expected: readonly string[]): void { + const names = node.children.map((child) => child.sortName).sort(); + assert.strictEqual(node.children.length, expected.length); + assert.deepStrictEqual(names, [...expected].sort()); + for (const child of node.children) { + assert.strictEqual(child.parent, node, `${child.sortName} keeps its parent link`); + assert.strictEqual(child.symbolUri, node.symbolUri, `${child.sortName} keeps the fixture URI`); + assert.ok(child.symbolRange, `${child.sortName} keeps a real symbol range`); + } +} + +export function assertOrderedSymbols( + previous: vscode.DocumentSymbol | undefined, + current: vscode.DocumentSymbol | undefined, +): void { + assert.ok(previous && current, 'ordered symbol entries must be present'); + assert.ok(previous.range.start.isBefore(current.range.start)); +} + +export function assertChildSymbol( + document: vscode.TextDocument, + symbol: vscode.DocumentSymbol, +): void { + assert.ok(symbol.name.length > 0, 'member symbol has a name'); + assert.ok( + symbol.range.contains(symbol.selectionRange), + `${symbol.name} selection stays in range`, + ); + assert.strictEqual(document.getText(symbol.selectionRange), symbol.name); + assert.ok(symbol.range.start.isBeforeOrEqual(symbol.range.end)); + assert.ok(symbol.selectionRange.start.isBeforeOrEqual(symbol.selectionRange.end)); + assert.ok(symbol.range.end.line < document.lineCount); +} + +export function assertAnchoredOrder( + text: string, + expected: readonly string[], + anchors: Readonly> = CLASS_ANCHORS, +): void { + let previous = -1; + for (const name of expected) { + const anchor = anchors[name]; + assert.ok(anchor, `${name} must have an assertion anchor`); + const current = text.indexOf(anchor); + assert.ok(current >= 0, `${name} declaration must remain present`); + assert.ok(current > previous, `${name} must follow the preceding sorted member`); + previous = current; + } +} + +export function assertDecorations(text: string): void { + const comment = text.indexOf('helper must travel with its attribute.'); + const attribute = text.indexOf('[System.Obsolete("private-helper")]'); + const zebra = text.indexOf(CLASS_ANCHORS.Zebra ?? 'missing-anchor'); + const documentation = text.indexOf('/// Second public method.'); + const beta = text.indexOf(CLASS_ANCHORS.Beta ?? 'missing-anchor'); + assert.ok(comment >= 0 && attribute >= 0 && zebra >= 0); + assert.ok(documentation >= 0 && beta >= 0); + assert.ok(comment < attribute && attribute < zebra, 'comment and attribute travel with Zebra'); + assert.ok(documentation < beta, 'documentation travels with Beta'); + assert.strictEqual(occurrences(text, '[System.Obsolete("private-helper")]'), 1); + assert.strictEqual(occurrences(text, '/// Second public method.'), 1); +} + +export function assertBodySentinels(text: string): void { + for (const value of ['ALPHA', 'BETA']) { + assert.ok(text.includes(`return "${value}";`), `${value} body must survive`); + assert.strictEqual(occurrences(text, `return "${value}";`), 1); + } + const zebra = text.includes('LIVE-ZEBRA') ? 'LIVE-ZEBRA' : 'ZEBRA'; + assert.ok(text.includes(`return "${zebra}";`), `${zebra} body must survive`); + assert.strictEqual(occurrences(text, `return "${zebra}";`), 1); + assert.ok(text.includes('= "OMEGA";')); + assert.ok(text.includes('_zeta = 7;') || text.includes('_zeta = 99;')); + assert.ok(text.includes('AlphaConstant = 1;')); +} + +export function assertBlankLineBetween(text: string, left: string, right: string): void { + const leftIndex = text.indexOf(CLASS_ANCHORS[left] ?? 'missing-anchor'); + const rightIndex = text.indexOf(CLASS_ANCHORS[right] ?? 'missing-anchor'); + assert.ok(leftIndex >= 0 && rightIndex >= 0); + assert.ok(leftIndex < rightIndex, `${left} must precede ${right}`); + const between = text.slice(leftIndex, rightIndex); + assert.match(between, /\r?\n\s*\r?\n/, `${left}/${right} groups need a blank separator`); +} + +export function assertLiveSentinels(text: string): void { + assert.ok(text.includes('return "LIVE-ZEBRA";')); + assert.ok(text.includes('_zeta = 99;')); + assert.ok(text.includes('Unsaved helper must travel')); + assert.ok(!text.includes('return "ZEBRA";')); + assert.ok(!text.includes('_zeta = 7;')); + assert.strictEqual(occurrences(text, 'LIVE-ZEBRA'), 1); + assert.strictEqual(occurrences(text, '_zeta = 99'), 1); +} + +function occurrences(text: string, needle: string): number { + return text.split(needle).length - 1; +} + +function toRange(range: LspRange): vscode.Range { + return new vscode.Range( + range.start.line, + range.start.character, + range.end.line, + range.end.character, + ); +} diff --git a/src/editors/vscode/src/test/suite/sort-members-command-e2e.test.ts b/src/editors/vscode/src/test/suite/sort-members-command-e2e.test.ts new file mode 100644 index 00000000..a1ad04fd --- /dev/null +++ b/src/editors/vscode/src/test/suite/sort-members-command-e2e.test.ts @@ -0,0 +1,232 @@ +// Implements [SE-CONTEXT-SORT-MEMBERS], [SE-CONTEXT-SORT-HIERARCHY], +// [SE-CONTEXT-SORT-SETTINGS], [SE-CONTEXT-SORT-IMPLEMENTATION], and +// [SHARPLSP-FEATURES-REFACTORING] through the shipped command and real LSP. +import * as assert from 'node:assert/strict'; +import { + type SortPolicy, + type SurfaceCase, + assertBlankLineBetween, + assertInitialState, + assertLiveSentinels, + assertNoOp, + assertNonTypeRejected, + buildLiveText, + cleanupSortHarness, + configureSort, + exerciseTypeSurface, + fixtureDocument, + fixtureText, + initializeSortHarness, + installLiveBuffer, + prepareSortCase, + redoSort, + sortAndObserve, + undoCleanSort, + undoLiveSort, +} from './sort-members-test-kit'; + +const DEFAULT_ACCESS = [ + 'public', + 'protected internal', + 'internal', + 'protected', + 'private protected', + 'private', +] as const; +const DEFAULT_CATEGORIES = [ + 'constant', + 'field', + 'constructor', + 'finalizer', + 'delegate', + 'event', + 'enum', + 'interface', + 'property', + 'indexer', + 'operator', + 'method', + 'struct', + 'class', + 'record', +] as const; +const DEFAULT_POLICY: SortPolicy = { + hierarchy: ['accessibility', 'category', 'alphabetical'], + accessibilityOrder: DEFAULT_ACCESS, + categoryOrder: DEFAULT_CATEGORIES, +}; +const CATEGORY_FIRST_POLICY: SortPolicy = { + ...DEFAULT_POLICY, + hierarchy: ['category', 'accessibility', 'alphabetical'], +}; +const REVERSED_POLICY: SortPolicy = { + hierarchy: ['accessibility', 'category', 'alphabetical'], + accessibilityOrder: [...DEFAULT_ACCESS].reverse(), + categoryOrder: [...DEFAULT_CATEGORIES].reverse(), +}; +const ALPHABETICAL_FIRST_POLICY: SortPolicy = { + ...DEFAULT_POLICY, + hierarchy: ['alphabetical', 'category', 'accessibility'], +}; + +const INITIAL_ORDER = [ + 'Zebra', + 'Beta', + 'Omega', + '_zeta', + 'AlphaConstant', + 'SortMembersCommand', + 'Alpha', +] as const; +const DEFAULT_ORDER = [ + 'AlphaConstant', + 'SortMembersCommand', + 'Omega', + 'Alpha', + 'Beta', + '_zeta', + 'Zebra', +] as const; +const CATEGORY_FIRST_ORDER = [ + 'AlphaConstant', + '_zeta', + 'SortMembersCommand', + 'Omega', + 'Alpha', + 'Beta', + 'Zebra', +] as const; +const REVERSED_ORDER = [ + 'Zebra', + '_zeta', + 'Alpha', + 'Beta', + 'Omega', + 'SortMembersCommand', + 'AlphaConstant', +] as const; +const ALPHABETICAL_ORDER = [ + '_zeta', + 'Alpha', + 'AlphaConstant', + 'Beta', + 'Omega', + 'SortMembersCommand', + 'Zebra', +] as const; + +const STRUCT_SURFACE: SurfaceCase = { + typeName: 'SortMembersStruct', + kinds: ['Struct'], + contexts: ['symbol.struct'], + initial: ['Zebra', 'Alpha'], + expected: ['Alpha', 'Zebra'], + anchors: { Alpha: 'public int Alpha;', Zebra: 'public void Zebra()' }, +}; +const INTERFACE_SURFACE: SurfaceCase = { + typeName: 'ISortMembers', + kinds: ['Interface'], + contexts: ['symbol.interface'], + initial: ['Zebra', 'Alpha'], + expected: ['Alpha', 'Zebra'], + anchors: { Alpha: 'int Alpha { get; }', Zebra: 'void Zebra();' }, +}; +const ENUM_SURFACE: SurfaceCase = { + typeName: 'SortMembersEnum', + kinds: ['Enum'], + contexts: ['symbol.enum'], + initial: ['Zebra', 'Alpha', 'Middle'], + expected: ['Alpha', 'Middle', 'Zebra'], + anchors: { Alpha: 'Alpha', Middle: 'Middle', Zebra: 'Zebra' }, + validateSorted: assertSortedEnum, +}; +const RECORD_SURFACE: SurfaceCase = { + typeName: 'SortMembersRecord', + kinds: ['Class', 'Record'], + contexts: ['symbol.class', 'symbol.record'], + initial: ['Zebra', 'Beta', 'Alpha'], + expected: ['Alpha', 'Beta', 'Zebra'], + anchors: { + Alpha: 'public int Alpha { get; init; }', + Beta: 'public void Beta()', + Zebra: 'private void Zebra()', + }, +}; +const TYPE_SURFACES = [STRUCT_SURFACE, INTERFACE_SURFACE, ENUM_SURFACE, RECORD_SURFACE]; + +suite('Sort Members command E2E — real explorer node and real LSP', function () { + this.timeout(180_000); + suiteSetup(async () => initializeSortHarness(INITIAL_ORDER)); + setup(prepareSortCase); + teardown(cleanupSortHarness); + suiteTeardown(cleanupSortHarness); + test('default policy, no-op re-entry, undo, and redo', runDefaultScenario); + test('category-first hierarchy moves private fields ahead of public methods', runCategoryFirst); + test('reversed accessibility and category lists still alphabetize ties', runReversedPolicy); + test('alphabetical-first policy sorts the unsaved VFS buffer, then undo/redo', runLiveBuffer); + test('class/struct/interface/enum/record boundaries and non-type rejection', runTypeSurfaces); +}); + +async function runDefaultScenario(): Promise { + await configureSort(DEFAULT_POLICY); + await assertInitialState(INITIAL_ORDER); + const outcome = await sortAndObserve(DEFAULT_ORDER); + assertBlankLineBetween(outcome.afterText, 'Beta', '_zeta'); + await assertNoOp(outcome, DEFAULT_ORDER); + await undoCleanSort(outcome, INITIAL_ORDER); + await redoSort(outcome, DEFAULT_ORDER); +} + +async function runCategoryFirst(): Promise { + await configureSort(CATEGORY_FIRST_POLICY); + await assertInitialState(INITIAL_ORDER); + const outcome = await sortAndObserve(CATEGORY_FIRST_ORDER); + assertBlankLineBetween(outcome.afterText, 'AlphaConstant', '_zeta'); + assertBlankLineBetween(outcome.afterText, '_zeta', 'SortMembersCommand'); + assert.ok(outcome.afterText.indexOf('_zeta') < outcome.afterText.indexOf('Alpha()')); + await undoCleanSort(outcome, INITIAL_ORDER); + await redoSort(outcome, CATEGORY_FIRST_ORDER); +} + +async function runReversedPolicy(): Promise { + await configureSort(REVERSED_POLICY); + await assertInitialState(INITIAL_ORDER); + const outcome = await sortAndObserve(REVERSED_ORDER); + assertBlankLineBetween(outcome.afterText, 'Zebra', '_zeta'); + assertBlankLineBetween(outcome.afterText, '_zeta', 'Alpha'); + assert.ok(outcome.afterText.indexOf('Alpha()') < outcome.afterText.indexOf('Beta()')); + assert.ok( + outcome.afterText.indexOf('SortMembersCommand()') < outcome.afterText.indexOf('AlphaConstant'), + ); + await assertNoOp(outcome, REVERSED_ORDER); +} + +async function runLiveBuffer(): Promise { + await configureSort(ALPHABETICAL_FIRST_POLICY); + await assertInitialState(INITIAL_ORDER); + const liveText = buildLiveText(); + await installLiveBuffer(liveText, INITIAL_ORDER); + const outcome = await sortAndObserve(ALPHABETICAL_ORDER); + assertLiveSentinels(outcome.afterText); + await undoLiveSort(outcome, liveText, INITIAL_ORDER); + await redoSort(outcome, ALPHABETICAL_ORDER); + assertLiveSentinels(fixtureDocument().getText()); +} + +async function runTypeSurfaces(): Promise { + await configureSort(DEFAULT_POLICY); + await assertInitialState(INITIAL_ORDER); + for (const surface of TYPE_SURFACES) { + await exerciseTypeSurface(surface); + await assertInitialState(INITIAL_ORDER); + } + await assertNonTypeRejected(INITIAL_ORDER, 'Alpha'); + assert.strictEqual(fixtureDocument().getText(), fixtureText()); + assert.ok(!fixtureDocument().isDirty, 'type-boundary rejection leaves the fixture clean'); +} + +function assertSortedEnum(text: string): void { + assert.match(text, /Alpha,\s+Middle,\s+Zebra/); + assert.strictEqual(text.match(/,/g)?.length, 2, 'three enum members require two separators'); + assert.ok(!text.includes('Zebra,'), 'the original non-trailing-comma style is preserved'); +} diff --git a/src/editors/vscode/src/test/suite/sort-members-test-kit.ts b/src/editors/vscode/src/test/suite/sort-members-test-kit.ts new file mode 100644 index 00000000..1a192270 --- /dev/null +++ b/src/editors/vscode/src/test/suite/sort-members-test-kit.ts @@ -0,0 +1,470 @@ +// Real command/LSP harness for [SE-CONTEXT-SORT-IMPLEMENTATION]. No providers are mocked. +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { + activateRealSharpLsp, + openFixtureDocument, + replaceDocumentText, + revertDocument, + workspaceFixturePath, +} from './refactor-test-helpers'; +import { + EXTENSION_ID, + LSP_RESPONSE_TIMEOUT_MS, + closeAllEditors, + pollUntilResult, +} from './test-helpers'; +import { + CLASS_ANCHORS, + assertAnchoredOrder, + assertBlankLineBetween, + assertBodySentinels, + assertChildSymbol, + assertClassRange, + assertDecorations, + assertLiveSentinels, + assertOrderedSymbols, + assertTreeChildren, +} from './sort-members-assertions'; +import type { + ExplorerProvider, + ExtensionApi, + LspRange, + SavedSettings, + SortOutcome, + SortPolicy, + SurfaceCase, + TreeNode, +} from './sort-members-types'; + +export type { SortOutcome, SortPolicy, SurfaceCase } from './sort-members-types'; + +export { assertBlankLineBetween, assertLiveSentinels }; + +const COMMAND = 'sharplsp.sortMembers'; +const FIXTURE_FILE = 'SortMembersCommand.cs'; +const FIXTURE_SOLUTION = workspaceFixturePath('TestFixtures.sln'); +const CLASS_NAME = 'SortMembersCommand'; +const MEMBER_TIMEOUT_MS = LSP_RESPONSE_TIMEOUT_MS + 30_000; + +let document: vscode.TextDocument; +let provider: ExplorerProvider; +let originalText: string; +let savedSettings: SavedSettings; + +export async function initializeSortHarness(initialOrder: readonly string[]): Promise { + const client = await activateRealSharpLsp(); + assert.ok(client, 'the command suite must activate a real LanguageClient'); + ({ document } = await openFixtureDocument(FIXTURE_FILE)); + originalText = document.getText(); + savedSettings = captureSettings(); + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand('sharplsp.openSolution', FIXTURE_SOLUTION); + }); + provider = getProvider(); + assert.strictEqual(provider, getProvider(), 'the activated extension owns one live explorer'); + assertClassNodeContract(await refreshNode(CLASS_NAME), initialOrder); +} + +export async function prepareSortCase(): Promise { + ({ document } = await openFixtureDocument(FIXTURE_FILE)); + await restoreSettings(); + await revertDocument(document); + assert.strictEqual(document.getText(), originalText, 'every case starts from the disk fixture'); + assert.ok(!document.isDirty, 'every case starts with a clean editor'); +} + +export async function cleanupSortHarness(): Promise { + await restoreSettings(); + await revertDocument(document); + await closeAllEditors(); +} + +export function fixtureDocument(): vscode.TextDocument { + assert.ok(document, 'Sort Members fixture document must be initialized'); + return document; +} + +export function fixtureText(): string { + assert.ok(originalText.length > 0, 'Sort Members fixture text must be initialized'); + return originalText; +} + +function getProvider(): ExplorerProvider { + const extension = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(extension, `${EXTENSION_ID} must be installed in the VSIX host`); + assert.ok(extension.isActive, `${EXTENSION_ID} must be active before reading its API`); + assert.ok(extension.exports.explorerProvider, 'the real extension must export explorerProvider'); + assert.ok(extension.exports.getLspClient(), 'the exported API must retain the real LSP client'); + return extension.exports.explorerProvider; +} + +function sortConfiguration(): vscode.WorkspaceConfiguration { + return vscode.workspace.getConfiguration('sharplsp.memberSortOrder', document.uri); +} + +function captureSettings(): SavedSettings { + const config = sortConfiguration(); + return { + hierarchy: config.inspect('hierarchy')?.workspaceValue, + accessibilityOrder: config.inspect('accessibilityOrder')?.workspaceValue, + categoryOrder: config.inspect('categoryOrder')?.workspaceValue, + }; +} + +async function restoreSettings(): Promise { + const config = sortConfiguration(); + await config.update('hierarchy', savedSettings.hierarchy, vscode.ConfigurationTarget.Workspace); + await config.update( + 'accessibilityOrder', + savedSettings.accessibilityOrder, + vscode.ConfigurationTarget.Workspace, + ); + await config.update( + 'categoryOrder', + savedSettings.categoryOrder, + vscode.ConfigurationTarget.Workspace, + ); +} + +export async function configureSort(policy: SortPolicy): Promise { + const config = sortConfiguration(); + await config.update('hierarchy', policy.hierarchy, vscode.ConfigurationTarget.Workspace); + await config.update( + 'accessibilityOrder', + policy.accessibilityOrder, + vscode.ConfigurationTarget.Workspace, + ); + await config.update('categoryOrder', policy.categoryOrder, vscode.ConfigurationTarget.Workspace); + assertConfigured(policy); +} + +function assertConfigured(policy: SortPolicy): void { + const config = sortConfiguration(); + assert.deepStrictEqual(config.get('hierarchy'), [...policy.hierarchy]); + assert.deepStrictEqual(config.get('accessibilityOrder'), [...policy.accessibilityOrder]); + assert.deepStrictEqual(config.get('categoryOrder'), [...policy.categoryOrder]); + assert.strictEqual(config.get('hierarchy')?.length, 3); + assert.ok((config.get('accessibilityOrder')?.length ?? 0) >= 2); + assert.ok((config.get('categoryOrder')?.length ?? 0) >= 4); +} + +async function refreshNode(name: string): Promise { + await provider.refresh(); + const node = await pollUntilResult( + async () => findNode(provider.getChildren(), name), + (candidate) => candidate !== undefined, + MEMBER_TIMEOUT_MS, + 1_000, + ); + assert.ok(node, `the live explorer must expose ${name}`); + return node; +} + +function findNode(nodes: TreeNode[] | undefined, name: string): TreeNode | undefined { + if (nodes === undefined) return undefined; + for (const node of nodes) { + if (node.sortName === name) return node; + const child = findNode(node.children, name); + if (child !== undefined) return child; + } + return undefined; +} + +function assertClassNodeContract(node: TreeNode, expected: readonly string[]): void { + assertCommonNode(node, CLASS_NAME); + assert.strictEqual(node.symbolKind, 'Class'); + assert.strictEqual(node.contextValue, 'symbol.class'); + assert.ok(node.symbolRange, 'class node must have a server-provided range'); + assertClassRange(document, node.symbolRange); + assertTreeChildren(node, expected); +} + +function assertCommonNode(node: TreeNode, name: string): void { + assert.strictEqual(node.nodeType, 'symbol'); + assert.strictEqual(node.sortName, name); + assert.ok(nodeLabel(node).includes(name)); + assert.ok(node.parent, `${name} must retain its explorer parent`); + assert.ok(node.symbolUri, `${name} must have a file URI`); + assert.strictEqual( + vscode.Uri.parse(node.symbolUri).fsPath.toLowerCase(), + document.uri.fsPath.toLowerCase(), + ); +} + +function nodeLabel(node: TreeNode): string { + return typeof node.label === 'string' ? node.label : (node.label?.label ?? ''); +} + +function toRange(range: LspRange): vscode.Range { + return new vscode.Range( + range.start.line, + range.start.character, + range.end.line, + range.end.character, + ); +} + +async function requestTypeSymbol(name: string): Promise { + const roots = + (await vscode.commands.executeCommand( + 'vscode.executeDocumentSymbolProvider', + document.uri, + )) ?? []; + return findDocumentSymbol(roots, name); +} + +function findDocumentSymbol( + symbols: readonly vscode.DocumentSymbol[], + name: string, +): vscode.DocumentSymbol | undefined { + for (const symbol of symbols) { + if (symbol.name === name) return symbol; + const child = findDocumentSymbol(symbol.children, name); + if (child !== undefined) return child; + } + return undefined; +} + +async function waitForTypeOrder( + typeName: string, + expected: readonly string[], +): Promise { + const symbol = await pollUntilResult( + async () => requestTypeSymbol(typeName), + (candidate) => hasMemberOrder(candidate, expected), + MEMBER_TIMEOUT_MS, + 500, + ); + assert.ok(symbol, `the real document-symbol provider must return ${typeName}`); + assertSymbolContract(symbol, typeName, expected); + return symbol; +} + +function hasMemberOrder( + symbol: vscode.DocumentSymbol | undefined, + expected: readonly string[], +): boolean { + if (symbol === undefined) return false; + return symbol.children.map((child) => child.name).join('|') === expected.join('|'); +} + +function assertSymbolContract( + symbol: vscode.DocumentSymbol, + typeName: string, + expected: readonly string[], +): void { + const actual = symbol.children.map((child) => child.name); + assert.strictEqual(symbol.name, typeName); + assert.strictEqual( + symbol.children.length, + expected.length, + `${typeName} expected ${expected.join(', ')}; received ${actual.join(', ')}`, + ); + assert.deepStrictEqual(actual, expected, `${typeName} members must follow source order`); + for (const child of symbol.children) assertChildSymbol(document, child); + for (let index = 1; index < symbol.children.length; index += 1) { + assertOrderedSymbols(symbol.children[index - 1], symbol.children[index]); + } +} + +async function executeSortCommand(node: TreeNode): Promise { + const commands = await vscode.commands.getCommands(true); + assert.ok(commands.includes(COMMAND), `${COMMAND} must be registered by the real extension`); + assert.strictEqual( + vscode.window.activeTextEditor?.document.uri.toString(), + document.uri.toString(), + ); + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand(COMMAND, node); + }); +} + +export async function assertInitialState(initial: readonly string[]): Promise { + await waitForTypeOrder(CLASS_NAME, initial); + assert.strictEqual(document.getText(), originalText); + assert.ok(!document.isDirty); + assertAnchoredOrder(document.getText(), initial, CLASS_ANCHORS); + assertDecorations(document.getText()); + assertBodySentinels(document.getText()); +} + +export async function sortAndObserve(expected: readonly string[]): Promise { + const node = await refreshNode(CLASS_NAME); + assertClassNodeContract(node, expected); + const beforeText = document.getText(); + const beforeVersion = document.version; + await executeSortCommand(node); + await waitForTypeOrder(CLASS_NAME, expected); + const afterText = document.getText(); + const afterVersion = document.version; + assert.ok(afterVersion > beforeVersion, 'sorting advances the live document version'); + assert.ok(document.isDirty, 'sorting changes the open buffer without saving it'); + assert.notStrictEqual(afterText, beforeText, 'an unsorted buffer must change'); + assertAnchoredOrder(afterText, expected, CLASS_ANCHORS); + assertDecorations(afterText); + assertBodySentinels(afterText); + return { beforeText, beforeVersion, afterText, afterVersion }; +} + +export async function assertNoOp(outcome: SortOutcome, expected: readonly string[]): Promise { + const node = await refreshNode(CLASS_NAME); + assertClassNodeContract(node, expected); + const beforeVersion = document.version; + await executeSortCommand(node); + assert.strictEqual( + document.version, + beforeVersion, + 'already-sorted command is version-idempotent', + ); + assert.strictEqual( + document.getText(), + outcome.afterText, + 'already-sorted command is text-idempotent', + ); + assert.strictEqual(document.version, outcome.afterVersion); + assert.ok(document.isDirty, 'no-op sorting does not silently save the buffer'); + await waitForTypeOrder(CLASS_NAME, expected); +} + +export async function undoCleanSort( + outcome: SortOutcome, + initial: readonly string[], +): Promise { + const beforeVersion = document.version; + await runEditorCommand('undo'); + await waitForText(outcome.beforeText); + assert.ok(document.version > beforeVersion, 'undo advances the document version'); + assert.strictEqual(document.getText(), originalText); + assert.ok(!document.isDirty, 'undo returns the originally clean fixture to clean state'); + await waitForTypeOrder(CLASS_NAME, initial); + assertAnchoredOrder(document.getText(), initial, CLASS_ANCHORS); + assertDecorations(document.getText()); +} + +export async function redoSort(outcome: SortOutcome, expected: readonly string[]): Promise { + const beforeVersion = document.version; + await runEditorCommand('redo'); + await waitForText(outcome.afterText); + assert.ok(document.version > beforeVersion, 'redo advances the document version'); + assert.strictEqual(document.getText(), outcome.afterText); + assert.ok(document.isDirty, 'redo restores the unsaved sorted edit'); + await waitForTypeOrder(CLASS_NAME, expected); + assertAnchoredOrder(document.getText(), expected, CLASS_ANCHORS); + assertDecorations(document.getText()); +} + +async function runEditorCommand(command: 'undo' | 'redo'): Promise { + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand(command); + }); +} + +async function waitForText(expected: string): Promise { + const text = await pollUntilResult( + async () => document.getText(), + (candidate) => candidate === expected, + LSP_RESPONSE_TIMEOUT_MS, + 250, + ); + assert.strictEqual(text, expected, 'the editor must reach the requested undo/redo text'); +} + +export function buildLiveText(): string { + return originalText + .replace('Private helper must travel', 'Unsaved helper must travel') + .replace('return "ZEBRA";', 'return "LIVE-ZEBRA";') + .replace('_zeta = 7;', '_zeta = 99;'); +} + +export async function installLiveBuffer( + liveText: string, + initial: readonly string[], +): Promise { + await replaceDocumentText(document, liveText); + assert.notStrictEqual(liveText, originalText); + assert.strictEqual(document.getText(), liveText); + assert.ok(document.isDirty, 'the user-edited VFS buffer must be dirty'); + assert.ok(!originalText.includes('LIVE-ZEBRA')); + assert.ok(!originalText.includes('_zeta = 99')); + assert.ok(document.getText().includes('Unsaved helper must travel')); + await waitForTypeOrder(CLASS_NAME, initial); + assertClassNodeContract(await refreshNode(CLASS_NAME), initial); +} + +export async function undoLiveSort( + outcome: SortOutcome, + liveText: string, + initial: readonly string[], +): Promise { + const beforeVersion = document.version; + await runEditorCommand('undo'); + await waitForText(liveText); + assert.ok(document.version > beforeVersion); + assert.strictEqual(document.getText(), outcome.beforeText); + assert.ok(document.isDirty, 'undoing sort retains the earlier unsaved user edit'); + await waitForTypeOrder(CLASS_NAME, initial); + assertAnchoredOrder(document.getText(), initial, CLASS_ANCHORS); + assertLiveSentinels(document.getText()); +} + +export async function exerciseTypeSurface(surface: SurfaceCase): Promise { + await waitForTypeOrder(surface.typeName, surface.initial); + const node = await refreshNode(surface.typeName); + assertSurfaceNode(node, surface); + const beforeText = document.getText(); + const beforeVersion = document.version; + await executeSortCommand(node); + const symbol = await waitForTypeOrder(surface.typeName, surface.expected); + assert.ok(document.version > beforeVersion, `${surface.typeName} sort advances its version`); + assert.ok(document.isDirty, `${surface.typeName} sort leaves an unsaved edit`); + assert.notStrictEqual(document.getText(), beforeText); + const sortedText = document.getText(symbol.range); + assertAnchoredOrder(sortedText, surface.expected, surface.anchors); + surface.validateSorted?.(sortedText); + await undoSurface(beforeText, surface); +} + +function assertSurfaceNode(node: TreeNode, surface: SurfaceCase): void { + assertCommonNode(node, surface.typeName); + assert.ok(surface.kinds.includes(node.symbolKind ?? ''), `${surface.typeName} has a type kind`); + assert.ok( + surface.contexts.includes(node.contextValue ?? ''), + `${surface.typeName} has a type context`, + ); + assert.ok(node.symbolRange, `${surface.typeName} must have a real type range`); + const text = document.getText(toRange(node.symbolRange)); + assert.ok(text.includes(surface.typeName)); + assertAnchoredOrder(text, surface.initial, surface.anchors); + assertTreeChildren(node, surface.initial); +} + +async function undoSurface(beforeText: string, surface: SurfaceCase): Promise { + const beforeVersion = document.version; + await runEditorCommand('undo'); + await waitForText(beforeText); + assert.ok(document.version > beforeVersion, `${surface.typeName} undo advances its version`); + assert.strictEqual(document.getText(), originalText); + assert.ok(!document.isDirty, `${surface.typeName} undo restores a clean fixture`); + await waitForTypeOrder(surface.typeName, surface.initial); +} + +export async function assertNonTypeRejected( + initial: readonly string[], + methodName: string, +): Promise { + await waitForTypeOrder(CLASS_NAME, initial); + const classNode = await refreshNode(CLASS_NAME); + const method = classNode.children.find((child) => child.sortName === methodName); + assert.ok(method, `the live tree must expose method ${methodName}`); + assert.strictEqual(method.symbolKind, 'Method'); + assert.strictEqual(method.contextValue, 'symbol.method'); + assert.ok(method.symbolUri && method.symbolRange, 'method node must carry its real URI/range'); + const beforeText = document.getText(); + const beforeVersion = document.version; + await executeSortCommand(method); + assert.strictEqual(document.getText(), beforeText, 'a non-type range must not be edited'); + assert.strictEqual(document.version, beforeVersion, 'a rejected range must not change version'); + assert.ok(!document.isDirty, 'a rejected range must not dirty the document'); + await waitForTypeOrder(CLASS_NAME, initial); +} diff --git a/src/editors/vscode/src/test/suite/sort-members-types.ts b/src/editors/vscode/src/test/suite/sort-members-types.ts new file mode 100644 index 00000000..f7f06807 --- /dev/null +++ b/src/editors/vscode/src/test/suite/sort-members-types.ts @@ -0,0 +1,62 @@ +// Shared live explorer contracts for [SE-CONTEXT-SORT-IMPLEMENTATION]. +import type * as vscode from 'vscode'; +import type { LanguageClient } from 'vscode-languageclient/node'; + +export interface LspPosition { + readonly line: number; + readonly character: number; +} + +export interface LspRange { + readonly start: LspPosition; + readonly end: LspPosition; +} + +export interface TreeNode extends vscode.TreeItem { + readonly nodeType: string; + readonly children: TreeNode[]; + readonly sortName: string; + readonly symbolKind?: string; + readonly symbolUri?: string; + readonly symbolRange?: LspRange; + readonly parent?: TreeNode; +} + +export interface ExplorerProvider { + refresh(): Promise; + getChildren(element?: TreeNode): TreeNode[] | undefined; +} + +export interface ExtensionApi { + readonly explorerProvider: ExplorerProvider; + readonly getLspClient: () => LanguageClient | undefined; +} + +export interface SavedSettings { + readonly hierarchy: string[] | undefined; + readonly accessibilityOrder: string[] | undefined; + readonly categoryOrder: string[] | undefined; +} + +export interface SortPolicy { + readonly hierarchy: readonly string[]; + readonly accessibilityOrder: readonly string[]; + readonly categoryOrder: readonly string[]; +} + +export interface SortOutcome { + readonly beforeText: string; + readonly beforeVersion: number; + readonly afterText: string; + readonly afterVersion: number; +} + +export interface SurfaceCase { + readonly typeName: string; + readonly kinds: readonly string[]; + readonly contexts: readonly string[]; + readonly initial: readonly string[]; + readonly expected: readonly string[]; + readonly anchors: Readonly>; + readonly validateSorted?: (text: string) => void; +} diff --git a/editors/vscode/src/test/suite/test-explorer-e2e.test.ts b/src/editors/vscode/src/test/suite/test-explorer-e2e.test.ts similarity index 100% rename from editors/vscode/src/test/suite/test-explorer-e2e.test.ts rename to src/editors/vscode/src/test/suite/test-explorer-e2e.test.ts diff --git a/editors/vscode/src/test/suite/test-helpers.ts b/src/editors/vscode/src/test/suite/test-helpers.ts similarity index 99% rename from editors/vscode/src/test/suite/test-helpers.ts rename to src/editors/vscode/src/test/suite/test-helpers.ts index 9d3ffaa6..0fe27359 100644 --- a/editors/vscode/src/test/suite/test-helpers.ts +++ b/src/editors/vscode/src/test/suite/test-helpers.ts @@ -62,7 +62,7 @@ export function findSharpLspBinary(): string | undefined { const binaryName = exeName('sharplsp'); const platform = detectRuntimePlatform(); - // __dirname at runtime: editors/vscode/out/test/suite/ + // __dirname at runtime: src/editors/vscode/out/test/suite/ const extensionRoot = path.resolve(__dirname, '../../..'); const bundled = path.join(extensionRoot, 'bin', platform, binaryName); diff --git a/editors/vscode/src/test/suite/testing-lens-e2e.test.ts b/src/editors/vscode/src/test/suite/testing-lens-e2e.test.ts similarity index 100% rename from editors/vscode/src/test/suite/testing-lens-e2e.test.ts rename to src/editors/vscode/src/test/suite/testing-lens-e2e.test.ts diff --git a/editors/vscode/src/test/suite/tree-config-e2e.test.ts b/src/editors/vscode/src/test/suite/tree-config-e2e.test.ts similarity index 100% rename from editors/vscode/src/test/suite/tree-config-e2e.test.ts rename to src/editors/vscode/src/test/suite/tree-config-e2e.test.ts diff --git a/editors/vscode/src/test/suite/ui-stubs.ts b/src/editors/vscode/src/test/suite/ui-stubs.ts similarity index 100% rename from editors/vscode/src/test/suite/ui-stubs.ts rename to src/editors/vscode/src/test/suite/ui-stubs.ts diff --git a/editors/vscode/src/testing.ts b/src/editors/vscode/src/testing.ts similarity index 100% rename from editors/vscode/src/testing.ts rename to src/editors/vscode/src/testing.ts diff --git a/editors/vscode/src/tree-tooltip.ts b/src/editors/vscode/src/tree-tooltip.ts similarity index 100% rename from editors/vscode/src/tree-tooltip.ts rename to src/editors/vscode/src/tree-tooltip.ts diff --git a/editors/vscode/src/tree.ts b/src/editors/vscode/src/tree.ts similarity index 99% rename from editors/vscode/src/tree.ts rename to src/editors/vscode/src/tree.ts index b7a29ec4..bb057ca2 100644 --- a/editors/vscode/src/tree.ts +++ b/src/editors/vscode/src/tree.ts @@ -1,3 +1,4 @@ +/** Implements [SE-TREE], [SE-SORT], [SE-HOVER], and [SE-CONTEXT-MENUS]. */ import * as path from 'node:path'; import { type CancellationToken, diff --git a/editors/vscode/src/utils.ts b/src/editors/vscode/src/utils.ts similarity index 100% rename from editors/vscode/src/utils.ts rename to src/editors/vscode/src/utils.ts diff --git a/editors/vscode/test-chunks.json b/src/editors/vscode/test-chunks.json similarity index 86% rename from editors/vscode/test-chunks.json rename to src/editors/vscode/test-chunks.json index f4a16c07..08014201 100644 --- a/editors/vscode/test-chunks.json +++ b/src/editors/vscode/test-chunks.json @@ -1,8 +1,10 @@ { - "description": "[DIST-CI-WIN-VSIX] Feature chunks of the VS Code end-to-end suite. Each chunk is one Windows CI job running a slice of the SAME suite through the real LSP inside the real extension host. Single source of truth: the Makefile (_test-vsix-win), the CI matrix, and the coverage guard all read this file. Consumed via scripts/vsix-test-chunks.mjs.", + "description": "[DIST-CI-WIN-VSIX] Feature chunks of the VS Code end-to-end suite. Each chunk is one Windows CI job running a slice of the SAME suite through the real LSP inside the real extension host. Single source of truth: the Makefile (_test-vsix-win), the CI matrix, and the coverage guard all read this file. Consumed via tools/vsix/vsix-test-chunks.mjs.", "shared": { "description": "Prepended to every chunk. Asserts the bundled host + sidecars were staged before the extension host started, so a staging regression fails as itself instead of as a wall of LSP timeouts.", - "files": ["00-vsix-dev-binary-staging.test.js"] + "files": [ + "00-vsix-dev-binary-staging.test.js" + ] }, "chunks": { "lifecycle": { @@ -23,12 +25,20 @@ "diagnostics.test.js", "lsp-integration.test.js", "lsp-document-sync.test.js", - "lsp-lifecycle.test.js" + "lsp-lifecycle.test.js", + "lsp-refactor-quickfixes.test.js", + "lsp-refactor-organize-imports.test.js", + "lsp-refactor-core.test.js", + "lsp-refactor-rewrite-matrix.test.js", + "lsp-rename-symbols.test.js", + "lsp-rename-edge.test.js" ] }, "fsharp": { "description": "F# is a first-class citizen, so its whole LSP surface is gated on Windows: navigation, intelligence, syntax, diagnostics, code fixes, hierarchy, workspace symbol.", - "files": ["fsharp-lsp-*.test.js"] + "files": [ + "fsharp-lsp-*.test.js" + ] }, "debug": { "description": "Debugging end to end (launch/attach config resolution, netcoredbg adapter factory, sharplsp.debugProgram against real projects) plus Test Explorer discovery/run/debug and the test-status CodeLens.", @@ -52,7 +62,8 @@ "solution-explorer.test.js", "tree-config-e2e.test.js", "context-menus.test.js", - "project-deps-watcher-e2e.test.js" + "project-deps-watcher-e2e.test.js", + "sort-members-command-e2e.test.js" ] }, "packages": { diff --git a/editors/vscode/test-cli-runner.cjs b/src/editors/vscode/test-cli-runner.cjs similarity index 100% rename from editors/vscode/test-cli-runner.cjs rename to src/editors/vscode/test-cli-runner.cjs diff --git a/editors/vscode/test-fixtures/workspace/.vscode/settings.json b/src/editors/vscode/test-fixtures/workspace/.vscode/settings.json similarity index 100% rename from editors/vscode/test-fixtures/workspace/.vscode/settings.json rename to src/editors/vscode/test-fixtures/workspace/.vscode/settings.json diff --git a/editors/vscode/test-fixtures/workspace/Calculator.cs b/src/editors/vscode/test-fixtures/workspace/Calculator.cs similarity index 100% rename from editors/vscode/test-fixtures/workspace/Calculator.cs rename to src/editors/vscode/test-fixtures/workspace/Calculator.cs diff --git a/editors/vscode/test-fixtures/workspace/CompletionShot.cs b/src/editors/vscode/test-fixtures/workspace/CompletionShot.cs similarity index 70% rename from editors/vscode/test-fixtures/workspace/CompletionShot.cs rename to src/editors/vscode/test-fixtures/workspace/CompletionShot.cs index efeb3946..7a59ad24 100644 --- a/editors/vscode/test-fixtures/workspace/CompletionShot.cs +++ b/src/editors/vscode/test-fixtures/workspace/CompletionShot.cs @@ -9,7 +9,8 @@ public class Calculator public int Use() { var total = Add(1, 2); - return this. + return this._count; } } } +// Cursor immediately after `this.` above drives [SHARPLSP-FEATURES-INTELLIGENCE]. diff --git a/src/editors/vscode/test-fixtures/workspace/CrossLanguageCSharp.cs b/src/editors/vscode/test-fixtures/workspace/CrossLanguageCSharp.cs new file mode 100644 index 00000000..56933e8b --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/CrossLanguageCSharp.cs @@ -0,0 +1,9 @@ +namespace CrossLanguageFixtures; + +/// Real C# origin for [RENAME-CROSSLANGUAGE]. +public sealed class CSharpOrigin +{ + public CSharpOrigin(int value) => CSharpValue = value; + + public int CSharpValue { get; } +} diff --git a/editors/vscode/test-fixtures/workspace/DiagTarget.cs b/src/editors/vscode/test-fixtures/workspace/DiagTarget.cs similarity index 100% rename from editors/vscode/test-fixtures/workspace/DiagTarget.cs rename to src/editors/vscode/test-fixtures/workspace/DiagTarget.cs diff --git a/editors/vscode/test-fixtures/workspace/Empty.cs b/src/editors/vscode/test-fixtures/workspace/Empty.cs similarity index 100% rename from editors/vscode/test-fixtures/workspace/Empty.cs rename to src/editors/vscode/test-fixtures/workspace/Empty.cs diff --git a/editors/vscode/test-fixtures/workspace/Greeter.fs b/src/editors/vscode/test-fixtures/workspace/Greeter.fs similarity index 100% rename from editors/vscode/test-fixtures/workspace/Greeter.fs rename to src/editors/vscode/test-fixtures/workspace/Greeter.fs diff --git a/editors/vscode/test-fixtures/workspace/HoverEdit.cs b/src/editors/vscode/test-fixtures/workspace/HoverEdit.cs similarity index 100% rename from editors/vscode/test-fixtures/workspace/HoverEdit.cs rename to src/editors/vscode/test-fixtures/workspace/HoverEdit.cs diff --git a/editors/vscode/test-fixtures/workspace/HoverKinds.cs b/src/editors/vscode/test-fixtures/workspace/HoverKinds.cs similarity index 100% rename from editors/vscode/test-fixtures/workspace/HoverKinds.cs rename to src/editors/vscode/test-fixtures/workspace/HoverKinds.cs diff --git a/editors/vscode/test-fixtures/workspace/HoverMulti.cs b/src/editors/vscode/test-fixtures/workspace/HoverMulti.cs similarity index 100% rename from editors/vscode/test-fixtures/workspace/HoverMulti.cs rename to src/editors/vscode/test-fixtures/workspace/HoverMulti.cs diff --git a/editors/vscode/test-fixtures/workspace/HoverObsolete.cs b/src/editors/vscode/test-fixtures/workspace/HoverObsolete.cs similarity index 100% rename from editors/vscode/test-fixtures/workspace/HoverObsolete.cs rename to src/editors/vscode/test-fixtures/workspace/HoverObsolete.cs diff --git a/editors/vscode/test-fixtures/workspace/HoverRange.cs b/src/editors/vscode/test-fixtures/workspace/HoverRange.cs similarity index 100% rename from editors/vscode/test-fixtures/workspace/HoverRange.cs rename to src/editors/vscode/test-fixtures/workspace/HoverRange.cs diff --git a/editors/vscode/test-fixtures/workspace/HoverReject.cs b/src/editors/vscode/test-fixtures/workspace/HoverReject.cs similarity index 100% rename from editors/vscode/test-fixtures/workspace/HoverReject.cs rename to src/editors/vscode/test-fixtures/workspace/HoverReject.cs diff --git a/editors/vscode/test-fixtures/workspace/HoverVar.cs b/src/editors/vscode/test-fixtures/workspace/HoverVar.cs similarity index 100% rename from editors/vscode/test-fixtures/workspace/HoverVar.cs rename to src/editors/vscode/test-fixtures/workspace/HoverVar.cs diff --git a/editors/vscode/test-fixtures/workspace/HoverXmlDoc.cs b/src/editors/vscode/test-fixtures/workspace/HoverXmlDoc.cs similarity index 100% rename from editors/vscode/test-fixtures/workspace/HoverXmlDoc.cs rename to src/editors/vscode/test-fixtures/workspace/HoverXmlDoc.cs diff --git a/editors/vscode/test-fixtures/workspace/Nested.cs b/src/editors/vscode/test-fixtures/workspace/Nested.cs similarity index 100% rename from editors/vscode/test-fixtures/workspace/Nested.cs rename to src/editors/vscode/test-fixtures/workspace/Nested.cs diff --git a/editors/vscode/test-fixtures/workspace/Refactor.cs b/src/editors/vscode/test-fixtures/workspace/Refactor.cs similarity index 100% rename from editors/vscode/test-fixtures/workspace/Refactor.cs rename to src/editors/vscode/test-fixtures/workspace/Refactor.cs diff --git a/src/editors/vscode/test-fixtures/workspace/RefactorCore.cs b/src/editors/vscode/test-fixtures/workspace/RefactorCore.cs new file mode 100644 index 00000000..4580e8f6 --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/RefactorCore.cs @@ -0,0 +1,28 @@ +namespace SharpLsp.TestFixtures.Refactors; + +public class RefactorTarget +{ + private readonly int _seed; + + public RefactorTarget(int seed) => _seed = seed; + + public int AutoProperty { get; set; } + + public int EncapsulateTarget; + + public int Compute(int input) + { + var doubled = input * 2; + return doubled + _seed; + } + + public int Invertible(int input) + { + if (input > 0) + { + return input; + } + + return -input; + } +} diff --git a/src/editors/vscode/test-fixtures/workspace/RefactorQuickFixes.cs b/src/editors/vscode/test-fixtures/workspace/RefactorQuickFixes.cs new file mode 100644 index 00000000..f26864c7 --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/RefactorQuickFixes.cs @@ -0,0 +1,17 @@ +namespace SharpLsp.TestFixtures.Refactors; + +public interface IQuickContract +{ + int Compute(int input); + + string Name { get; } +} + +public sealed class QuickFixTarget : IQuickContract +{ + public string Name => "quick-fix-sentinel"; + + public int Compute(int input) => input * 2; + + public int Existing(int value) => value + 1; +} diff --git a/src/editors/vscode/test-fixtures/workspace/RenameEdge.cs b/src/editors/vscode/test-fixtures/workspace/RenameEdge.cs new file mode 100644 index 00000000..272e40ad --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/RenameEdge.cs @@ -0,0 +1,19 @@ +using System; + +namespace SharpLsp.TestFixtures.RenameCoverage; + +public partial class PartialRenameTarget +{ + public int PartialMember { get; set; } + + public string MetadataCall(string input) => Console.ReadLine() ?? input; + + public string RenameStringSentinel => "PartialRenameTarget PartialMember"; + + // PartialRenameTarget and PartialMember stay unchanged in this comment. +} + +public partial class PartialRenameTarget +{ + public int UsePartialMember() => PartialMember; +} diff --git a/src/editors/vscode/test-fixtures/workspace/RenameSymbols.cs b/src/editors/vscode/test-fixtures/workspace/RenameSymbols.cs new file mode 100644 index 00000000..f675f2de --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/RenameSymbols.cs @@ -0,0 +1,119 @@ +using System; +using ResourceAlias = System.IO.MemoryStream; + +namespace SharpLsp.TestFixtures.RenameCoverage; + +public delegate int RenameDelegate(int delegateParameter); + +public interface IRenameContract +{ + TContract ContractValue { get; } + + TContract Transform(TContract methodParameter, TMethod genericParameter); +} + +public readonly struct RenameStruct +{ + public RenameStruct(int value) => Value = value; + + public int Value { get; } +} + +public record RenameRecord(int RecordComponent); + +public enum RenameEnum +{ + FirstMember, + SecondMember, +} + +public abstract class RenameBase +{ + /// Calls through the base contract. + public abstract int VirtualMember(int value); +} + +public interface IExplicitRenameContract +{ + int ExplicitMember(int value); +} + +public sealed class RenameDerived : RenameBase, IExplicitRenameContract +{ + public override int VirtualMember(int value) => value + 1; + + int IExplicitRenameContract.ExplicitMember(int value) => value + 2; + + public string DescribeMember() => nameof(VirtualMember); + + public static RenameDerived operator +(RenameDerived left, RenameDerived right) => left; + + public static explicit operator int(RenameDerived value) => value.VirtualMember(0); +} + +public class RenameClass : IRenameContract +{ + public const int RenameConstant = 5; + private int _renameField; + + public RenameClass(TType constructorParameter) => ContractValue = constructorParameter; + + public event EventHandler? RenameEvent; + + public TType ContractValue { get; } + + public int RenameProperty { get; set; } + + public int this[int indexParameter] + { + get => _renameField + indexParameter; + set => _renameField = value - indexParameter; + } + + public TType Transform(TType methodParameter, TMethod genericParameter) + { + _ = genericParameter; + return methodParameter; + } + + public int RenameMethod(int methodParameter) + { + int RenameLocalFunction(int localFunctionParameter) => localFunctionParameter + 1; + var renameLocal = RenameLocalFunction(methodParameter); + Func lambda = lambdaParameter => lambdaParameter + RenameConstant; + return lambda(renameLocal) + _renameField; + } + + public int ExerciseLocals(int methodParameter) + { + var total = 0; + foreach (var foreachValue in new[] { methodParameter, RenameConstant }) + { + total += foreachValue; + } + + try + { + throw new InvalidOperationException("rename-catch-sentinel"); + } + catch (InvalidOperationException catchError) + { + total += catchError.Message.Length; + } + + using var usingResource = new ResourceAlias(); + var (deconstructedLeft, deconstructedRight) = (methodParameter, total); + object patternSource = deconstructedLeft; + if (patternSource is int patternValue) + { + total += patternValue; + } + + return total + deconstructedRight + (int)usingResource.Length; + } + + public void RaiseEvent() => RenameEvent?.Invoke(this, EventArgs.Empty); + + public const string LiteralSentinel = "RenameClass RenameMethod renameLocal"; + // RenameClass RenameMethod renameLocal must remain untouched in comments. +} diff --git a/src/editors/vscode/test-fixtures/workspace/RenameUsage.cs b/src/editors/vscode/test-fixtures/workspace/RenameUsage.cs new file mode 100644 index 00000000..a72b2249 --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/RenameUsage.cs @@ -0,0 +1,34 @@ +using System; + +namespace SharpLsp.TestFixtures.RenameCoverage; + +public static class RenameUsage +{ + public static int Exercise() + { + var derived = new RenameDerived(); + RenameBase baseValue = derived; + IExplicitRenameContract explicitValue = derived; + var operatorValue = derived + derived; + var conversionValue = (int)operatorValue; + var hierarchyValue = baseValue.VirtualMember(2) + explicitValue.ExplicitMember(3); + var renameClass = new RenameClass(3); + IRenameContract contract = renameClass; + renameClass.RenameEvent += HandleEvent; + renameClass.RenameProperty = 7; + renameClass[1] = renameClass.RenameProperty; + var renameRecord = new RenameRecord(renameClass.RenameMethod(2)); + var renameStruct = new RenameStruct(renameRecord.RecordComponent); + RenameDelegate renameDelegate = delegateValue => delegateValue + renameStruct.Value; + var renameEnum = RenameEnum.FirstMember; + return renameDelegate(contract.Transform(renameClass[1], renameEnum)) + + conversionValue + + hierarchyValue; + } + + private static void HandleEvent(object? sender, EventArgs eventArgs) + { + _ = sender; + _ = eventArgs; + } +} diff --git a/src/editors/vscode/test-fixtures/workspace/SortMembersCommand.cs b/src/editors/vscode/test-fixtures/workspace/SortMembersCommand.cs new file mode 100644 index 00000000..03a861db --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/SortMembersCommand.cs @@ -0,0 +1,68 @@ +namespace RefactorFixtures; + +public sealed class SortMembersCommand +{ + // Private helper must travel with its attribute. + [System.Obsolete("private-helper")] + private string Zebra() + { + return "ZEBRA"; + } + + /// Second public method. + public string Beta() + { + return "BETA"; + } + + public string Omega { get; set; } = "OMEGA"; + + private readonly int _zeta = 7; + + public const int AlphaConstant = 1; + + public SortMembersCommand() + { + } + + public string Alpha() + { + return "ALPHA"; + } +} + +public struct SortMembersStruct +{ + public void Zebra() + { + } + + public int Alpha; +} + +public interface ISortMembers +{ + void Zebra(); + + int Alpha { get; } +} + +public enum SortMembersEnum +{ + Zebra, + Alpha, + Middle +} + +public record SortMembersRecord +{ + private void Zebra() + { + } + + public void Beta() + { + } + + public int Alpha { get; init; } +} diff --git a/editors/vscode/test-fixtures/workspace/TestFixtures.csproj b/src/editors/vscode/test-fixtures/workspace/TestFixtures.csproj similarity index 90% rename from editors/vscode/test-fixtures/workspace/TestFixtures.csproj rename to src/editors/vscode/test-fixtures/workspace/TestFixtures.csproj index 9324d91e..6662c5ce 100644 --- a/editors/vscode/test-fixtures/workspace/TestFixtures.csproj +++ b/src/editors/vscode/test-fixtures/workspace/TestFixtures.csproj @@ -23,6 +23,10 @@
+ + + + diff --git a/src/editors/vscode/test-fixtures/workspace/TestFixtures.sln b/src/editors/vscode/test-fixtures/workspace/TestFixtures.sln new file mode 100644 index 00000000..5495961b --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/TestFixtures.sln @@ -0,0 +1,9 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestFixtures", "TestFixtures.csproj", "{00000000-0000-0000-0000-000000000001}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FSharpFixtures", "fsharp\FSharpFixtures.fsproj", "{00000000-0000-0000-0000-000000000002}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpConsumer", "crosslanguage\CSharpConsumer.csproj", "{00000000-0000-0000-0000-000000000003}" +EndProject +Global +EndGlobal diff --git a/src/editors/vscode/test-fixtures/workspace/TestFixtures.slnx b/src/editors/vscode/test-fixtures/workspace/TestFixtures.slnx new file mode 100644 index 00000000..31e61923 --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/TestFixtures.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/src/editors/vscode/test-fixtures/workspace/crosslanguage/CSharpConsumer.csproj b/src/editors/vscode/test-fixtures/workspace/crosslanguage/CSharpConsumer.csproj new file mode 100644 index 00000000..52a1d27f --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/crosslanguage/CSharpConsumer.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + false + false + false + + + + + diff --git a/src/editors/vscode/test-fixtures/workspace/crosslanguage/FSharpConsumer.cs b/src/editors/vscode/test-fixtures/workspace/crosslanguage/FSharpConsumer.cs new file mode 100644 index 00000000..15787bad --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/crosslanguage/FSharpConsumer.cs @@ -0,0 +1,16 @@ +using FSharpFixtures.CrossLanguage; + +namespace CSharpConsumer; + +/// Real C# consumer for [RENAME-CROSSLANGUAGE]. +public static class FSharpConsumer +{ + // A same-name field must never be swept into request-local foreign recovery. + public const string BridgedFSharpType = "UNCHANGED-CSHARP-SENTINEL"; + public const string BridgedFSharpMember = "UNCHANGED-CSHARP-MEMBER-SENTINEL"; + + public static int Read(FSharpOrigin origin) + { + return origin.FSharpValue; + } +} diff --git a/editors/vscode/test-fixtures/workspace/fsharp/CodeFixes.fs b/src/editors/vscode/test-fixtures/workspace/fsharp/CodeFixes.fs similarity index 100% rename from editors/vscode/test-fixtures/workspace/fsharp/CodeFixes.fs rename to src/editors/vscode/test-fixtures/workspace/fsharp/CodeFixes.fs diff --git a/src/editors/vscode/test-fixtures/workspace/fsharp/CrossLanguage.fs b/src/editors/vscode/test-fixtures/workspace/fsharp/CrossLanguage.fs new file mode 100644 index 00000000..52571a84 --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/fsharp/CrossLanguage.fs @@ -0,0 +1,17 @@ +namespace FSharpFixtures.CrossLanguage + +// Both directions of the real mixed-project contract. [RENAME-CROSSLANGUAGE] +open CrossLanguageFixtures + +type FSharpOrigin(value: int) = + member _.FSharpValue = value + +module Usage = + let readCSharp (origin: CSharpOrigin) = origin.CSharpValue + let readFSharp (origin: FSharpOrigin) = origin.FSharpValue + let makeFSharp value = FSharpOrigin(value) + +module Unrelated = + // A same-name value must never be swept into request-local foreign recovery. + let BridgedCSharpType = "UNCHANGED-FSHARP-SENTINEL" + let BridgedCSharpMember = "UNCHANGED-FSHARP-MEMBER-SENTINEL" diff --git a/editors/vscode/test-fixtures/workspace/fsharp/DiagnosticsTarget.fs b/src/editors/vscode/test-fixtures/workspace/fsharp/DiagnosticsTarget.fs similarity index 100% rename from editors/vscode/test-fixtures/workspace/fsharp/DiagnosticsTarget.fs rename to src/editors/vscode/test-fixtures/workspace/fsharp/DiagnosticsTarget.fs diff --git a/editors/vscode/test-fixtures/workspace/fsharp/Domain.fs b/src/editors/vscode/test-fixtures/workspace/fsharp/Domain.fs similarity index 100% rename from editors/vscode/test-fixtures/workspace/fsharp/Domain.fs rename to src/editors/vscode/test-fixtures/workspace/fsharp/Domain.fs diff --git a/editors/vscode/test-fixtures/workspace/fsharp/FSharpFixtures.fsproj b/src/editors/vscode/test-fixtures/workspace/fsharp/FSharpFixtures.fsproj similarity index 66% rename from editors/vscode/test-fixtures/workspace/fsharp/FSharpFixtures.fsproj rename to src/editors/vscode/test-fixtures/workspace/fsharp/FSharpFixtures.fsproj index b83c1b28..e22012cb 100644 --- a/editors/vscode/test-fixtures/workspace/fsharp/FSharpFixtures.fsproj +++ b/src/editors/vscode/test-fixtures/workspace/fsharp/FSharpFixtures.fsproj @@ -10,23 +10,28 @@ break sidecar project cracking until Ionide.ProjInfo lands. File order matters in F#: Domain (types) → Library (logic) → Usage - (cross-file references) → CodeFixes (unused open + redundant qualifier, for - the analyzer-backed code-fix tests) → DiagnosticsTarget (overwritten on disk - by the diagnostics tests). Do NOT reorder. + (cross-file references) → CodeFixes/Implement → RenameDeclarations → + RenameUsages → rename edge fixtures → DiagnosticsTarget. Do NOT reorder. --> net10.0 + + 8.0 Library false + 5 + $(OtherFlags) --warnon:1182 false false false - $(NoWarn);FS0025 + @@ -38,12 +43,22 @@ + + + + + + + + + + diff --git a/src/editors/vscode/test-fixtures/workspace/fsharp/Implement.fs b/src/editors/vscode/test-fixtures/workspace/fsharp/Implement.fs new file mode 100644 index 00000000..cf9d8b83 --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/fsharp/Implement.fs @@ -0,0 +1,12 @@ +module FSharpFixtures.Implement + +/// Valid baseline; the real-LSP test injects an incomplete live overlay. +/// [ANALYZERS-FSAC-CODEFIX-INTERFACE-STUB] +type IShape = + abstract member Area: unit -> float + abstract member Name: string + +type Square() = + interface IShape with + member _.Area() = 1.0 + member _.Name = "square" diff --git a/editors/vscode/test-fixtures/workspace/fsharp/Library.fs b/src/editors/vscode/test-fixtures/workspace/fsharp/Library.fs similarity index 100% rename from editors/vscode/test-fixtures/workspace/fsharp/Library.fs rename to src/editors/vscode/test-fixtures/workspace/fsharp/Library.fs diff --git a/src/editors/vscode/test-fixtures/workspace/fsharp/RenameDeclarations.fs b/src/editors/vscode/test-fixtures/workspace/fsharp/RenameDeclarations.fs new file mode 100644 index 00000000..c2bfccd4 --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/fsharp/RenameDeclarations.fs @@ -0,0 +1,63 @@ +module FSharpFixtures.RenameDeclarations + +// Compiled real-project symbols for [RENAME-FSHARP-PREPARE] and [RENAME-FSHARP-APPLY]. +module NestedModule = + let moduleMember = 10 + +let nestedUse = NestedModule.moduleMember + +module ModuleAlias = NestedModule +let moduleAliasUse = ModuleAlias.moduleMember + +type IService = + abstract member Execute: int -> int + +type Service() = + interface IService with + member _.Execute value = value + +type RecordThing = { Field: int } +type Choice = CaseOne of int | CaseTwo +type Status = Ready = 0 | Busy = 1 +type Alias = RecordThing + +[] +type StructThing = { StructValue: int } + +type ObjectModelThing(initial: int) = + member val Current = initial with get, set + +type EventSource() = + let changed = Event() + [] + member _.Changed = changed.Publish + member _.Raise value = changed.Trigger value + +type IndexerThing() = + member _.Item with get(index: int) = index + +type ClassThing(seed: int) = + member _.Property = seed + member _.Method(parameter: int) = + let localName = parameter + seed + localName + +let moduleValue = 3 +let functionName parameter = + let localValue = parameter + moduleValue + localValue + +let withLocalFunction value = + let localFunction input = input + value + localFunction 1 + +let lambdaResult = [ 1 ] |> List.map (fun lambdaParameter -> lambdaParameter + 1) +let tupleFunction (firstValue, secondValue) = firstValue + secondValue + +let identity<'T> (item: 'T) : 'T = item +let (|Positive|NonPositive|) number = if number > 0 then Positive else NonPositive +let positiveHere = match 1 with | Positive -> true | NonPositive -> false +let inline (.+.) left right = left + right + +// NestedModule IService Choice Status RecordThing Field CaseOne Ready Alias ClassThing Property Method ModuleAlias StructThing ObjectModelThing Changed Item moduleValue functionName parameter localValue localFunction lambdaParameter firstValue T Positive .+. +let textSentinel = "NestedModule IService Choice Status RecordThing Field CaseOne Ready Alias ClassThing Property Method ModuleAlias StructThing ObjectModelThing Changed Item moduleValue functionName parameter localValue localFunction lambdaParameter firstValue T Positive .+." diff --git a/src/editors/vscode/test-fixtures/workspace/fsharp/RenameEdge.fs b/src/editors/vscode/test-fixtures/workspace/fsharp/RenameEdge.fs new file mode 100644 index 00000000..af4e4f69 --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/fsharp/RenameEdge.fs @@ -0,0 +1,8 @@ +module FSharpFixtures.RenameEdge + +// Saved baseline intentionally differs from the live overlay. [RENAME-FSHARP-APPLY] +let savedName value = value + 1 +let useSaved = savedName 2 +let metadataValue = System.String.Empty +// savedName in a comment must remain unchanged. +let stringValue = "savedName" diff --git a/src/editors/vscode/test-fixtures/workspace/fsharp/RenameNamespace.fs b/src/editors/vscode/test-fixtures/workspace/fsharp/RenameNamespace.fs new file mode 100644 index 00000000..8a467545 --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/fsharp/RenameNamespace.fs @@ -0,0 +1,4 @@ +namespace FSharpFixtures.RenameNamespace + +// Namespace is external to rename; the owned type is not. [RENAME-FSHARP-PREPARE] +type PublicType = { Value: int } diff --git a/src/editors/vscode/test-fixtures/workspace/fsharp/RenameNamespaceUsage.fs b/src/editors/vscode/test-fixtures/workspace/fsharp/RenameNamespaceUsage.fs new file mode 100644 index 00000000..d956f8c6 --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/fsharp/RenameNamespaceUsage.fs @@ -0,0 +1,6 @@ +module FSharpFixtures.NamespaceConsumer + +// Cross-file namespace use for [RENAME-FSHARP-APPLY]. +open FSharpFixtures.RenameNamespace + +let item: PublicType = { Value = 1 } diff --git a/src/editors/vscode/test-fixtures/workspace/fsharp/RenameUsages.fs b/src/editors/vscode/test-fixtures/workspace/fsharp/RenameUsages.fs new file mode 100644 index 00000000..5512c612 --- /dev/null +++ b/src/editors/vscode/test-fixtures/workspace/fsharp/RenameUsages.fs @@ -0,0 +1,30 @@ +module FSharpFixtures.RenameUsages + +// Cross-file uses for [RENAME-FSHARP-APPLY]. +open FSharpFixtures.RenameDeclarations + +let nestedValue = NestedModule.moduleMember +let service: IService = Service() +let structValue: StructThing = { StructValue = 1 } +let objectModel = ObjectModelThing(1) +let eventSource = EventSource() +let eventSubscription = eventSource.Changed.Subscribe(fun _ -> ()) +let indexerValue = IndexerThing().[0] +let recordValue: RecordThing = { Field = 1 } +let copied = { recordValue with Field = 2 } +let readField = recordValue.Field +let choose: Choice = CaseOne 3 +let matchChoice (value: Choice) = match value with | CaseOne number -> number | CaseTwo -> 0 +let status: Status = Status.Ready +let aliasValue: Alias = recordValue +let instance = ClassThing(4) +let propertyValue = instance.Property +let methodValue = instance.Method(5) +let moduleCopy = moduleValue +let functionValue = functionName 6 +let genericValue = identity "value" +let activeValue = match 1 with | Positive -> true | NonPositive -> false +let operatorValue = 1 .+. 2 + +// NestedModule IService Choice Status RecordThing Field CaseOne Ready Alias ClassThing Property Method ModuleAlias StructThing ObjectModelThing Changed Item moduleValue functionName parameter localValue localFunction lambdaParameter firstValue T Positive .+. +let textSentinel = "NestedModule IService Choice Status RecordThing Field CaseOne Ready Alias ClassThing Property Method ModuleAlias StructThing ObjectModelThing Changed Item moduleValue functionName parameter localValue localFunction lambdaParameter firstValue T Positive .+." diff --git a/editors/vscode/test-fixtures/workspace/fsharp/Usage.fs b/src/editors/vscode/test-fixtures/workspace/fsharp/Usage.fs similarity index 100% rename from editors/vscode/test-fixtures/workspace/fsharp/Usage.fs rename to src/editors/vscode/test-fixtures/workspace/fsharp/Usage.fs diff --git a/editors/vscode/tsconfig.json b/src/editors/vscode/tsconfig.json similarity index 100% rename from editors/vscode/tsconfig.json rename to src/editors/vscode/tsconfig.json diff --git a/editors/zed/Cargo.lock b/src/editors/zed/Cargo.lock similarity index 100% rename from editors/zed/Cargo.lock rename to src/editors/zed/Cargo.lock diff --git a/editors/zed/Cargo.toml b/src/editors/zed/Cargo.toml similarity index 100% rename from editors/zed/Cargo.toml rename to src/editors/zed/Cargo.toml diff --git a/editors/zed/extension.toml b/src/editors/zed/extension.toml similarity index 100% rename from editors/zed/extension.toml rename to src/editors/zed/extension.toml diff --git a/editors/zed/src/lib.rs b/src/editors/zed/src/lib.rs similarity index 100% rename from editors/zed/src/lib.rs rename to src/editors/zed/src/lib.rs diff --git a/editors/zed/src/project.rs b/src/editors/zed/src/project.rs similarity index 100% rename from editors/zed/src/project.rs rename to src/editors/zed/src/project.rs diff --git a/editors/zed/src/solution.rs b/src/editors/zed/src/solution.rs similarity index 100% rename from editors/zed/src/solution.rs rename to src/editors/zed/src/solution.rs diff --git a/editors/zed/src/tree.rs b/src/editors/zed/src/tree.rs similarity index 100% rename from editors/zed/src/tree.rs rename to src/editors/zed/src/tree.rs diff --git a/examples/Test.csproj b/src/examples/Test.csproj similarity index 100% rename from examples/Test.csproj rename to src/examples/Test.csproj diff --git a/examples/Test.sln b/src/examples/Test.sln similarity index 100% rename from examples/Test.sln rename to src/examples/Test.sln diff --git a/sharplsp.example.toml b/src/examples/config/sharplsp.example.toml similarity index 100% rename from sharplsp.example.toml rename to src/examples/config/sharplsp.example.toml diff --git a/examples/test.cs b/src/examples/test.cs similarity index 100% rename from examples/test.cs rename to src/examples/test.cs diff --git a/src/sharplsp/Cargo.toml b/src/sharplsp/Cargo.toml new file mode 100644 index 00000000..ab213c96 --- /dev/null +++ b/src/sharplsp/Cargo.toml @@ -0,0 +1,70 @@ +[package] +name = "sharplsp" +build = "build/build.rs" +version = { workspace = true } +edition = { workspace = true } +description = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } +readme = { workspace = true } + +[features] +# Sequestered formatting module (Roslyn + Fantomas). Disabled by default. +# Use CSharpier for C# and Fantomas via Ionide for F# instead. +formatting = [] + +[dependencies] +# Version dispatch — Shipwright binary version contract [DIST-VERSION-OUTPUT] +shipwright = "0.10" +shipwright-manifest = "0.10" +# Implements the executable packaging contract [BINARY-RUST]. + +# LSP +lsp-server = "0.9" +lsp-types = "0.97" + +# Tree-sitter +tree-sitter = "0.26" +tree-sitter-c-sharp = "0.23" + +# Async runtime +crossbeam-channel = "0.5" +tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "io-util", "time", "process", "sync", "macros", "fs"] } + + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "1.1" +rmp-serde = "1" +serde_bytes = "0.11" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-appender = "0.2" + +# Error handling +anyhow = "1" + +# HTTP client (NuGet API) +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +# Utilities +dashmap = "6" +# RFC 8089 file:// URI ↔ path conversion (Windows drive letters, percent-decoding) +url = "2" +# Percent-decoding for file URIs with no native path representation (url's own dep) +percent-encoding = "2" + +[dev-dependencies] +tempfile = "3" +wait-timeout = "0.2" +serde_json = "1" + +[lints] +workspace = true + +[target."cfg(windows)".dependencies] +sysinfo = { version = "0.39.6", default-features = false, features = ["system"] } diff --git a/build.rs b/src/sharplsp/build/build.rs similarity index 100% rename from build.rs rename to src/sharplsp/build/build.rs diff --git a/src/call_hierarchy.rs b/src/sharplsp/src/call_hierarchy.rs similarity index 100% rename from src/call_hierarchy.rs rename to src/sharplsp/src/call_hierarchy.rs diff --git a/src/code_actions.rs b/src/sharplsp/src/code_actions.rs similarity index 100% rename from src/code_actions.rs rename to src/sharplsp/src/code_actions.rs diff --git a/src/code_lens.rs b/src/sharplsp/src/code_lens.rs similarity index 100% rename from src/code_lens.rs rename to src/sharplsp/src/code_lens.rs diff --git a/src/config.rs b/src/sharplsp/src/config.rs similarity index 99% rename from src/config.rs rename to src/sharplsp/src/config.rs index 4aec8902..2d11a29c 100644 --- a/src/config.rs +++ b/src/sharplsp/src/config.rs @@ -96,7 +96,7 @@ impl CSharpConfig { /// /// Falls back to the root — restoring plain auto-discovery — when unset, or /// when the configured path names no existing file. Implements - /// [WORKSPACE-SOLUTION-PATH]. + /// [SHARPLSP-ARCHITECTURE-PROJECTS-SOLUTION-PATH]. pub fn open_target(&self, workspace_root: &Path) -> PathBuf { let configured = self.solution_path.trim(); if configured.is_empty() { @@ -290,7 +290,7 @@ project_filter = ["MyApp.Core", "MyApp.Api"] /// root. Sending the root leaves the sidecar to rediscover, and in a root /// holding several solutions that discovery is ambiguous and loads nothing — /// no hover, no completions, no diagnostics. Implements - /// [WORKSPACE-SOLUTION-PATH]. + /// [SHARPLSP-ARCHITECTURE-PROJECTS-SOLUTION-PATH]. #[test] fn test_relative_solution_path_is_opened_not_workspace_root() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/diagnostics.rs b/src/sharplsp/src/diagnostics.rs similarity index 97% rename from src/diagnostics.rs rename to src/sharplsp/src/diagnostics.rs index a94b163b..4b0a8fc6 100644 --- a/src/diagnostics.rs +++ b/src/sharplsp/src/diagnostics.rs @@ -161,6 +161,26 @@ fn source_tag_for_uri(uri: &Uri) -> String { } } +/// Determine the diagnostic source tag from a native document path. +fn source_tag_for_path(file_path: &str) -> &'static str { + let Some(extension) = std::path::Path::new(file_path) + .extension() + .and_then(|extension| extension.to_str()) + else { + return "sharplsp-csharp"; + }; + + if extension.eq_ignore_ascii_case("fs") + || extension.eq_ignore_ascii_case("fsx") + || extension.eq_ignore_ascii_case("fsi") + || extension.eq_ignore_ascii_case("fsscript") + { + "sharplsp-fsharp" + } else { + "sharplsp-csharp" + } +} + /// Spawn a background task to fetch solution-wide diagnostics. /// /// Results are published incrementally — one notification per file — @@ -236,12 +256,7 @@ async fn verify_error_files( tokio::time::sleep(std::time::Duration::from_secs(1)).await; for file_path in error_files { - let source_tag = match std::path::Path::new(file_path.as_str()).extension() { - Some(ext) if ext.eq_ignore_ascii_case("fs") || ext.eq_ignore_ascii_case("fsx") => { - "sharplsp-fsharp" - } - _ => "sharplsp-csharp", - }; + let source_tag = source_tag_for_path(file_path); // Skip the disk-resync step for documents the editor has open. The // VFS holds the live, possibly-unsaved text — overwriting the sidecar @@ -329,7 +344,7 @@ pub async fn fetch_from_sidecar( sidecar: &SidecarManager, file_path: &str, ) -> Result> { - fetch(sidecar, file_path, "sharplsp-csharp").await + fetch(sidecar, file_path, source_tag_for_path(file_path)).await } /// Fetch diagnostics from the sidecar for a single file. diff --git a/src/document_symbols.rs b/src/sharplsp/src/document_symbols.rs similarity index 97% rename from src/document_symbols.rs rename to src/sharplsp/src/document_symbols.rs index 1a3c6499..030b1b62 100644 --- a/src/document_symbols.rs +++ b/src/sharplsp/src/document_symbols.rs @@ -3,8 +3,8 @@ //! C# document symbols are answered syntactically by tree-sitter in //! [`crate::handlers::handle_document_symbols`]. F# has no tree-sitter grammar //! in the host, so F# symbols are sourced from the sidecar's FCS navigation -//! items and mapped here into nested LSP [`DocumentSymbol`]s. Implements -//! [FS-DOCSYMBOL]. +//! items and mapped here into nested LSP [`DocumentSymbol`]s. The extraction +//! contract is `[SE-FSHARP-SYMBOLS]`. use std::sync::Arc; @@ -25,7 +25,7 @@ pub fn handle_fsharp( // F# has no host tree-sitter grammar; symbols are only available when the // FCS sidecar is running. With no sidecar (F# disabled / no workspace root) // the request cannot be served — surface that as an error rather than a - // misleading empty outline. [FS-DOCSYMBOL] + // misleading empty outline. [SE-FSHARP-SYMBOLS] let Some(sidecar) = sidecar else { anyhow::bail!("F# sidecar unavailable; cannot compute document symbols"); }; @@ -33,7 +33,7 @@ pub fn handle_fsharp( let file_path = crate::semantic::uri_to_path(¶ms.text_document.uri)?; // A sidecar/parse failure yields an empty outline rather than a hard error — - // a transient outline gap is preferable to a failed request. [FS-DOCSYMBOL] + // a transient outline gap is preferable to a failed request. [SE-FSHARP-SYMBOLS] let items = match fetch_fsharp_document_symbols(runtime, sidecar, file_path) { Ok(items) => items, Err(err) => { @@ -54,7 +54,7 @@ pub fn handle_fsharp( /// Shared by the `textDocument/documentSymbol` outline and the Solution /// Explorer's `sharplsp/workspaceSymbols` tree, so F# files contribute the same /// FCS-sourced symbols in both — the host has no F# tree-sitter grammar. -/// [FS-DOCSYMBOL] +/// `[SE-FSHARP-SYMBOLS]` pub(crate) fn fetch_fsharp_document_symbols( runtime: &tokio::runtime::Runtime, sidecar: &Arc, @@ -106,7 +106,7 @@ fn map_symbol(item: &SidecarDocumentSymbol) -> DocumentSymbol { /// editor's `workspace/symbol` (Go to Symbol in Workspace / Ctrl-T) search reaches /// F# symbols. The host has no F# tree-sitter grammar, so — like the outline and /// the Solution Explorer — these come from the FCS sidecar. Unfiltered; the caller -/// applies the query match. [FS-WORKSPACE-SYMBOL] +/// applies the query match. `[SHARPLSP-FEATURES-NAVIGATION]` pub(crate) fn fsharp_workspace_symbols( runtime: &tokio::runtime::Runtime, sidecar: &Arc, @@ -174,7 +174,7 @@ fn parse_document_symbol_kind(kind: &str) -> SymbolKind { /// A nested document symbol returned by the sidecar. Deserialized from a /// positional `MessagePack` array matching the sidecar's `DocumentSymbolResult`. /// `pub(crate)` so the Solution Explorer (`workspace_symbols`) can map the same -/// FCS symbols into its tree model. [FS-DOCSYMBOL] +/// FCS symbols into its tree model. `[SE-FSHARP-SYMBOLS]` #[derive(serde::Deserialize)] pub(crate) struct SidecarDocumentSymbol { /// Display name of the symbol. diff --git a/src/formatting.rs b/src/sharplsp/src/formatting.rs similarity index 75% rename from src/formatting.rs rename to src/sharplsp/src/formatting.rs index 16cd2aec..7a2500bc 100644 --- a/src/formatting.rs +++ b/src/sharplsp/src/formatting.rs @@ -1,4 +1,17 @@ //! Formatting handlers (`textDocument/formatting`, `rangeFormatting`, `onTypeFormatting`). +//! +//! Sequestered: this module only compiles under `--features formatting`, which is +//! off by default and never enabled by CI or any shipped build. `main.rs` advertises +//! no formatting capabilities and never routes to these handlers, so every item here +//! is unreachable by construction. `CSharpier` (C#) and Fantomas via Ionide (F#) are the +//! recommended formatters — see `docs/formatting/README.md`. + +// Justification for the allow (CLAUDE.md requires one): the module is dead *by design*, +// kept compiling behind a feature flag so it stays refactor-safe until formatting is +// revived. Without this, `cargo clippy --all-features` fails on items that are +// deliberately never called. Delete this attribute the moment `main.rs` wires the +// handlers up. +#![allow(dead_code)] use std::sync::Arc; @@ -106,23 +119,35 @@ pub fn handle_on_type_formatting( // ── Wire types ──────────────────────────────────────────────────── +/// Whole-document formatting request sent to the sidecar. #[derive(serde::Serialize)] struct SidecarFileReq { + /// Absolute path of the document to format. file_path: String, } +/// Range formatting request sent to the sidecar. #[derive(serde::Serialize)] struct SidecarRangeReq { + /// Absolute path of the document to format. file_path: String, + /// Zero-based line of the range start. start_line: u32, + /// Zero-based UTF-16 column of the range start. start_character: u32, + /// Zero-based line of the range end. end_line: u32, + /// Zero-based UTF-16 column of the range end. end_character: u32, } +/// On-type formatting request sent to the sidecar. #[derive(serde::Serialize)] struct SidecarPositionReq { + /// Absolute path of the document to format. file_path: String, + /// Zero-based line of the trigger position. line: u32, + /// Zero-based UTF-16 column of the trigger position. character: u32, } diff --git a/src/handlers.rs b/src/sharplsp/src/handlers.rs similarity index 100% rename from src/handlers.rs rename to src/sharplsp/src/handlers.rs diff --git a/src/inlay_hints.rs b/src/sharplsp/src/inlay_hints.rs similarity index 100% rename from src/inlay_hints.rs rename to src/sharplsp/src/inlay_hints.rs diff --git a/src/main.rs b/src/sharplsp/src/main.rs similarity index 98% rename from src/main.rs rename to src/sharplsp/src/main.rs index 348863ae..4a65d8a5 100644 --- a/src/main.rs +++ b/src/sharplsp/src/main.rs @@ -82,7 +82,7 @@ fn error_code_i32(code: lsp_server::ErrorCode) -> i32 { } fn main() -> ExitCode { - // Implements [SWR-VERSION-RUST] — Shipwright binary version contract. + // Implements [DIST-VERSION-OUTPUT] — Shipwright binary version contract. let spec = shipwright::VersionSpec { name: "sharplsp", version: env!("CARGO_PKG_VERSION"), @@ -231,7 +231,7 @@ fn run_server() -> Result<()> { // deferred until the first didOpen notification. // The C# sidecar opens the configured solution when `csharp.solution_path` // names one; a root holding several solutions is otherwise ambiguous and - // loads nothing. Implements [WORKSPACE-SOLUTION-PATH]. + // loads nothing. Implements [SHARPLSP-ARCHITECTURE-PROJECTS-SOLUTION-PATH]. let csharp_open_root = workspace_root .as_deref() .map(|root| sharplsp_config.csharp.open_target(root)); @@ -385,7 +385,7 @@ async fn open_workspace(sidecar: &SidecarManager, root: &str) -> Result<()> { /// Push analyzer flags from `[analyzers]` in `sharplsp.toml` to a sidecar via /// `analyzers/configure`. Sidecars that do not register the method ignore it; any /// failure is logged and never aborts startup. The flags drive the monorepo -/// dead-code analyzer ([FS-ANALYZER-DEADCODE]) in both the F# and C# sidecars. +/// dead-code analyzer ([ANALYZERS-DEADCODE-SEVERITY]) in both the F# and C# sidecars. async fn configure_analyzers(sidecar: &SidecarManager, analyzers: &config::AnalyzersConfig) { #[derive(serde::Serialize)] struct ConfigureRequest { @@ -461,7 +461,7 @@ fn opened_document_path(notif: &Notification) -> Option { /// Map a document to the sidecar that owns its language. Implements [SCRIPT-DETECT]. /// /// `.fsi` is deliberately absent: a signature file with no owning project has no meaningful -/// semantic closure and is served syntax-only by the host. Implements [FSX-FSI]. +/// semantic closure and is served syntax-only by the host. Implements [SCRIPT-FSX-FSI]. fn sidecar_for_path<'a>( file_path: &str, csharp_sidecar: Option<&'a Arc>, @@ -627,7 +627,7 @@ fn handle_request( let result = match req.method.as_str() { // Syntax-only (tree-sitter, Rust) for C#; F# has no host grammar, so its - // symbols come from the sidecar's FCS navigation items. [FS-DOCSYMBOL] + // symbols come from the sidecar's FCS items. [SHARPLSP-FEATURES-NAVIGATION] DocumentSymbolRequest::METHOD => { if is_fsharp_request(&req) { let sidecar = pick_sidecar(&req, csharp_sidecar, fsharp_sidecar); @@ -676,7 +676,8 @@ fn handle_request( fsharp_sidecar, ), // Formatting intentionally disabled — use CSharpier (C#) / Fantomas via Ionide (F#). - // Handler code is sequestered in src/formatting.rs (see docs/formatting/README.md). + // Handler code is sequestered in src/sharplsp/src/formatting.rs + // (see docs/formatting/README.md). // Semantic tokens SemanticTokensFullRequest::METHOD => { let sidecar = pick_sidecar(&req, csharp_sidecar, fsharp_sidecar); @@ -695,7 +696,7 @@ fn handle_request( let sidecar = pick_sidecar(&req, csharp_sidecar, fsharp_sidecar); inlay_hints::handle_inlay_hint(req, runtime, sidecar, vfs) } - // Signature help [FS-SIGHELP] + // Signature help [SHARPLSP-FEATURES-INTELLIGENCE] SignatureHelpRequest::METHOD => { let sidecar = pick_sidecar(&req, csharp_sidecar, fsharp_sidecar); signature_help::handle(req, runtime, sidecar) @@ -705,14 +706,15 @@ fn handle_request( let sidecar = pick_sidecar(&req, csharp_sidecar, fsharp_sidecar); code_lens::handle_code_lens(req, runtime, sidecar) } - // Rename — Implements [RENAME-PREPARE] and [RENAME-APPLY] + // Rename — [RENAME-PREPARE], [RENAME-APPLY], [RENAME-CROSSLANGUAGE]. PrepareRenameRequest::METHOD => { let sidecar = pick_sidecar(&req, csharp_sidecar, fsharp_sidecar); semantic::handle_prepare_rename(req, runtime, sidecar) } Rename::METHOD => { - let sidecar = pick_sidecar(&req, csharp_sidecar, fsharp_sidecar); - semantic::handle_rename(req, runtime, sidecar) + let (primary, fallback) = + pick_sidecar_with_fallback(&req, csharp_sidecar, fsharp_sidecar); + semantic::handle_rename(req, runtime, primary, fallback) } // Call hierarchy CallHierarchyPrepare::METHOD => { @@ -1065,7 +1067,7 @@ fn handle_workspace_symbols( /// Standard `workspace/symbol` handler. C#/syntax files are matched by tree-sitter /// over the VFS; F# files are matched via the FCS sidecar's document symbols, since -/// the host has no F# tree-sitter grammar. [FS-WORKSPACE-SYMBOL] +/// the host has no F# tree-sitter grammar. `[SHARPLSP-FEATURES-NAVIGATION]` fn handle_standard_workspace_symbol( req: Request, parsers: &TsParsers, @@ -1098,7 +1100,7 @@ fn handle_standard_workspace_symbol( /// Append the FCS-sourced workspace symbols matching `query` for one open F# /// document. No sidecar / unresolvable path / sidecar error → contributes -/// nothing, never fails the whole search. [FS-WORKSPACE-SYMBOL] +/// nothing, never fails the whole search. `[SHARPLSP-FEATURES-NAVIGATION]` fn collect_fsharp_ws_symbols( uri: &Uri, runtime: &tokio::runtime::Runtime, diff --git a/src/nav_cache.rs b/src/sharplsp/src/nav_cache.rs similarity index 100% rename from src/nav_cache.rs rename to src/sharplsp/src/nav_cache.rs diff --git a/src/nuget/cache.rs b/src/sharplsp/src/nuget/cache.rs similarity index 100% rename from src/nuget/cache.rs rename to src/sharplsp/src/nuget/cache.rs diff --git a/src/nuget/cli.rs b/src/sharplsp/src/nuget/cli.rs similarity index 100% rename from src/nuget/cli.rs rename to src/sharplsp/src/nuget/cli.rs diff --git a/src/nuget/consolidate.rs b/src/sharplsp/src/nuget/consolidate.rs similarity index 100% rename from src/nuget/consolidate.rs rename to src/sharplsp/src/nuget/consolidate.rs diff --git a/src/nuget/edit.rs b/src/sharplsp/src/nuget/edit.rs similarity index 100% rename from src/nuget/edit.rs rename to src/sharplsp/src/nuget/edit.rs diff --git a/src/nuget/handlers.rs b/src/sharplsp/src/nuget/handlers.rs similarity index 99% rename from src/nuget/handlers.rs rename to src/sharplsp/src/nuget/handlers.rs index 19a946e2..dba1b015 100644 --- a/src/nuget/handlers.rs +++ b/src/sharplsp/src/nuget/handlers.rs @@ -1,4 +1,4 @@ -//! LSP custom request handlers for `sharplsp/nuget/*` operations. +//! LSP custom request handlers for `sharplsp/nuget/*` operations. Implements [NUGET-REQUESTS]. //! //! All handlers follow: deserialize params -> delegate -> serialize result. diff --git a/src/nuget/mod.rs b/src/sharplsp/src/nuget/mod.rs similarity index 100% rename from src/nuget/mod.rs rename to src/sharplsp/src/nuget/mod.rs diff --git a/src/nuget/parse.rs b/src/sharplsp/src/nuget/parse.rs similarity index 100% rename from src/nuget/parse.rs rename to src/sharplsp/src/nuget/parse.rs diff --git a/src/nuget/search.rs b/src/sharplsp/src/nuget/search.rs similarity index 100% rename from src/nuget/search.rs rename to src/sharplsp/src/nuget/search.rs diff --git a/src/nuget/targets.rs b/src/sharplsp/src/nuget/targets.rs similarity index 100% rename from src/nuget/targets.rs rename to src/sharplsp/src/nuget/targets.rs diff --git a/src/nuget/types.rs b/src/sharplsp/src/nuget/types.rs similarity index 100% rename from src/nuget/types.rs rename to src/sharplsp/src/nuget/types.rs diff --git a/src/nuget/unused.rs b/src/sharplsp/src/nuget/unused.rs similarity index 100% rename from src/nuget/unused.rs rename to src/sharplsp/src/nuget/unused.rs diff --git a/src/postfix_completion.rs b/src/sharplsp/src/postfix_completion.rs similarity index 100% rename from src/postfix_completion.rs rename to src/sharplsp/src/postfix_completion.rs diff --git a/src/profiler/child_process.rs b/src/sharplsp/src/profiler/child_process.rs similarity index 100% rename from src/profiler/child_process.rs rename to src/sharplsp/src/profiler/child_process.rs diff --git a/src/profiler/counters.rs b/src/sharplsp/src/profiler/counters.rs similarity index 100% rename from src/profiler/counters.rs rename to src/sharplsp/src/profiler/counters.rs diff --git a/src/profiler/diagnostics_port.rs b/src/sharplsp/src/profiler/diagnostics_port.rs similarity index 100% rename from src/profiler/diagnostics_port.rs rename to src/sharplsp/src/profiler/diagnostics_port.rs diff --git a/src/profiler/dump.rs b/src/sharplsp/src/profiler/dump.rs similarity index 100% rename from src/profiler/dump.rs rename to src/sharplsp/src/profiler/dump.rs diff --git a/src/profiler/dump_cmd.rs b/src/sharplsp/src/profiler/dump_cmd.rs similarity index 100% rename from src/profiler/dump_cmd.rs rename to src/sharplsp/src/profiler/dump_cmd.rs diff --git a/src/profiler/handlers.rs b/src/sharplsp/src/profiler/handlers.rs similarity index 98% rename from src/profiler/handlers.rs rename to src/sharplsp/src/profiler/handlers.rs index 16f26f51..5a4f84c1 100644 --- a/src/profiler/handlers.rs +++ b/src/sharplsp/src/profiler/handlers.rs @@ -1,4 +1,4 @@ -//! LSP custom request handlers for profiler operations. +//! LSP custom request handlers for profiler operations. Implements [PROFILER-PROTOCOL]. //! //! All handlers follow the pattern: deserialize params → delegate to module → serialize result. diff --git a/src/profiler/heap_analysis.rs b/src/sharplsp/src/profiler/heap_analysis.rs similarity index 100% rename from src/profiler/heap_analysis.rs rename to src/sharplsp/src/profiler/heap_analysis.rs diff --git a/src/profiler/heap_diff.rs b/src/sharplsp/src/profiler/heap_diff.rs similarity index 100% rename from src/profiler/heap_diff.rs rename to src/sharplsp/src/profiler/heap_diff.rs diff --git a/src/profiler/mod.rs b/src/sharplsp/src/profiler/mod.rs similarity index 100% rename from src/profiler/mod.rs rename to src/sharplsp/src/profiler/mod.rs diff --git a/src/profiler/object_graph.rs b/src/sharplsp/src/profiler/object_graph.rs similarity index 99% rename from src/profiler/object_graph.rs rename to src/sharplsp/src/profiler/object_graph.rs index ec7ee93e..1460e183 100644 --- a/src/profiler/object_graph.rs +++ b/src/sharplsp/src/profiler/object_graph.rs @@ -1,6 +1,8 @@ //! Object retention graph — build a reference graph from a managed heap dump //! using `dumpobj`, `gcroot`, and `objsize` commands via `dotnet-dump analyze`. +//! Implements [PROFILER-GRAPH-BUILD]. + use std::collections::{HashMap, HashSet, VecDeque}; use anyhow::{Context, Result}; diff --git a/src/profiler/object_inspection.rs b/src/sharplsp/src/profiler/object_inspection.rs similarity index 100% rename from src/profiler/object_inspection.rs rename to src/sharplsp/src/profiler/object_inspection.rs diff --git a/src/profiler/process_list.rs b/src/sharplsp/src/profiler/process_list.rs similarity index 100% rename from src/profiler/process_list.rs rename to src/sharplsp/src/profiler/process_list.rs diff --git a/src/profiler/process_list/windows_native.rs b/src/sharplsp/src/profiler/process_list/windows_native.rs similarity index 100% rename from src/profiler/process_list/windows_native.rs rename to src/sharplsp/src/profiler/process_list/windows_native.rs diff --git a/src/profiler/session.rs b/src/sharplsp/src/profiler/session.rs similarity index 99% rename from src/profiler/session.rs rename to src/sharplsp/src/profiler/session.rs index ec3d7a6d..b796f253 100644 --- a/src/profiler/session.rs +++ b/src/sharplsp/src/profiler/session.rs @@ -1,5 +1,7 @@ //! Profiler session management — tracks active trace and counter sessions. +//! Implements [PROFILER-SESSIONS-LIFECYCLE]. + use std::process::Child; use std::sync::atomic::{AtomicU32, Ordering}; use std::time::Instant; diff --git a/src/profiler/test_support.rs b/src/sharplsp/src/profiler/test_support.rs similarity index 100% rename from src/profiler/test_support.rs rename to src/sharplsp/src/profiler/test_support.rs diff --git a/src/profiler/tool_discovery.rs b/src/sharplsp/src/profiler/tool_discovery.rs similarity index 100% rename from src/profiler/tool_discovery.rs rename to src/sharplsp/src/profiler/tool_discovery.rs diff --git a/src/profiler/trace.rs b/src/sharplsp/src/profiler/trace.rs similarity index 100% rename from src/profiler/trace.rs rename to src/sharplsp/src/profiler/trace.rs diff --git a/src/pull_diagnostics.rs b/src/sharplsp/src/pull_diagnostics.rs similarity index 97% rename from src/pull_diagnostics.rs rename to src/sharplsp/src/pull_diagnostics.rs index 5114c374..97aed377 100644 --- a/src/pull_diagnostics.rs +++ b/src/sharplsp/src/pull_diagnostics.rs @@ -1,4 +1,4 @@ -//! Pull diagnostics handlers (LSP 3.17). +//! Pull diagnostics handlers (LSP 3.17). Implements [DIAG-LSP-PULL]. //! //! Implements `textDocument/diagnostic` and `workspace/diagnostic` — the //! request-based (pull) model that VS Code's web client (`code serve-web`) diff --git a/src/semantic.rs b/src/sharplsp/src/semantic.rs similarity index 63% rename from src/semantic.rs rename to src/sharplsp/src/semantic.rs index 50e1b17e..81a437aa 100644 --- a/src/semantic.rs +++ b/src/sharplsp/src/semantic.rs @@ -1,4 +1,5 @@ //! Semantic request handlers routed through the .NET sidecar. +//! Navigation requests implement [DEFINITION-ROUTING]. //! //! Each handler serializes the LSP params into a sidecar request, //! forwards it via the `SidecarManager`, and translates the response @@ -67,7 +68,7 @@ pub fn handle_completion( insert_text: item.insert_text, // A textEdit makes acceptance REPLACE the identifier span // at the caret instead of appending it (GitHub #178). - // Implements [COMPLETION-EDIT-REPLACE]. + // Implements [SHARPLSP-FEATURES-INTELLIGENCE-COMPLETION-EDIT]. text_edit: item .text_edit .as_ref() @@ -138,7 +139,7 @@ pub fn handle_completion_resolve( Ok(serde_json::to_value(item)?) } -/// Handle `textDocument/hover` via the C# sidecar, with caching. +/// Handle `textDocument/hover` via the language sidecar, with caching. [HOVER-ROUTING] pub fn handle_hover( req: Request, vfs: &crate::vfs::Vfs, @@ -306,7 +307,7 @@ pub fn handle_implementation( Ok(value) } -/// Handle `textDocument/references` with caching and cross-language fallback. +/// Handle `textDocument/references` with caching and cross-language fallback. [REFERENCES-ROUTING] pub fn handle_references( req: Request, vfs: &crate::vfs::Vfs, @@ -825,7 +826,7 @@ struct SidecarCompletionItem { index: i32, /// Edit that replaces the identifier span at the caret so acceptance does /// not append the member name to the trigger text (GitHub #178). - /// Implements [COMPLETION-EDIT-REPLACE]. + /// Implements [SHARPLSP-FEATURES-INTELLIGENCE-COMPLETION-EDIT]. text_edit: Option, } @@ -965,75 +966,355 @@ pub fn handle_prepare_rename( Ok(serde_json::to_value(response)?) } -// Implements [RENAME-APPLY] +// Implements [RENAME-APPLY] and [RENAME-CROSSLANGUAGE]. /// Handle `textDocument/rename` via the sidecar. pub fn handle_rename( req: Request, runtime: &tokio::runtime::Runtime, sidecar: Option<&Arc>, + fallback: Option<&Arc>, ) -> Result { let Some(sidecar) = sidecar else { return Ok(serde_json::Value::Null); }; - let params: RenameParams = serde_json::from_value(req.params)?; - let file_path = uri_to_path(¶ms.text_document_position.text_document.uri)?; - let request = SidecarRenameRequest { - file_path, + let request = sidecar_rename_request(params)?; + let result = request_workspace_edit(runtime, sidecar, "textDocument/rename", &request)?; + let result = if result.document_changes.is_empty() { + result + } else { + add_foreign_rename(runtime, sidecar, fallback, &request, result) + }; + workspace_edit_value(result) +} + +/// Convert LSP rename parameters into the sidecar's compact wire request. +fn sidecar_rename_request(params: RenameParams) -> Result { + Ok(SidecarRenameRequest { + file_path: uri_to_path(¶ms.text_document_position.text_document.uri)?, line: params.text_document_position.position.line, character: params.text_document_position.position.character, new_name: params.new_name, + }) +} + +/// Append edits produced by the sidecar that owns the other language. +/// +/// Enrichment is **best-effort**: the primary sidecar has already produced a +/// correct rename, so a crashed, restarting or wedged fallback sidecar must never +/// discard it. Any fault is logged and the primary edits are returned unchanged. +fn add_foreign_rename( + runtime: &tokio::runtime::Runtime, + primary: &Arc, + fallback: Option<&Arc>, + request: &SidecarRenameRequest, + result: SidecarWorkspaceEditResult, +) -> SidecarWorkspaceEditResult { + match foreign_rename_edits(runtime, primary, fallback, request) { + Ok(Some(foreign)) => merge_or_keep(result, foreign), + Ok(None) => result, + Err(err) => { + warn!("Cross-language rename enrichment failed, keeping primary edits: {err:#}"); + result + } + } +} + +/// Merge foreign edits in, falling back to the primary edits if the merge conflicts. +fn merge_or_keep( + result: SidecarWorkspaceEditResult, + foreign: SidecarWorkspaceEditResult, +) -> SidecarWorkspaceEditResult { + let primary = result.clone(); + match merge_workspace_edits(result, foreign) { + Ok(merged) => merged, + Err(err) => { + warn!("Cross-language rename merge conflicted, keeping primary edits: {err:#}"); + primary + } + } +} + +/// Ask the other-language sidecar for its edits, if it has a workspace that could hold any. +fn foreign_rename_edits( + runtime: &tokio::runtime::Runtime, + primary: &Arc, + fallback: Option<&Arc>, + request: &SidecarRenameRequest, +) -> Result> { + let Some(fallback) = fallback else { + return Ok(None); + }; + if !sidecar_workspace_loaded(runtime, fallback)? { + return Ok(None); + } + Ok(Some(request_foreign_rename( + runtime, primary, fallback, request, + )?)) +} + +/// Check whether the other-language sidecar has a project that can contain references. +fn sidecar_workspace_loaded( + runtime: &tokio::runtime::Runtime, + sidecar: &Arc, +) -> Result { + let bytes = runtime.block_on(sidecar.request("workspace/status", Vec::new()))?; + let status: String = rmp_serde::from_slice(&bytes)?; + match status.as_str() { + "loaded" => Ok(true), + "not_loaded" => Ok(false), + other => anyhow::bail!("unexpected sidecar workspace status: {other}"), + } +} + +/// Resolve the primary symbol identity and ask the fallback for its references. +fn request_foreign_rename( + runtime: &tokio::runtime::Runtime, + primary: &Arc, + fallback: &Arc, + request: &SidecarRenameRequest, +) -> Result { + let identity = request_rename_identity(runtime, primary, request)?; + if !identity.found || identity.assembly_name.is_empty() || identity.xml_doc_sig.is_empty() { + return Ok(empty_workspace_edit()); + } + let foreign = SidecarForeignRenameRequest::new(identity, request.new_name.clone()); + request_workspace_edit(runtime, fallback, "workspace/renameForeign", &foreign) +} + +/// Ask the owning sidecar for a portable assembly + XML-doc symbol identity. +fn request_rename_identity( + runtime: &tokio::runtime::Runtime, + sidecar: &Arc, + rename: &SidecarRenameRequest, +) -> Result { + let request = SidecarPositionReq { + file_path: rename.file_path.clone(), + line: rename.line, + character: rename.character, }; let payload = rmp_serde::to_vec(&request)?; - let response_bytes = runtime.block_on(sidecar.request("textDocument/rename", payload))?; - let result: SidecarWorkspaceEditResult = rmp_serde::from_slice(&response_bytes)?; + let bytes = runtime.block_on(sidecar.request("textDocument/renameIdentity", payload))?; + Ok(rmp_serde::from_slice(&bytes)?) +} + +/// Send a sidecar request whose response uses the workspace-edit wire shape. +fn request_workspace_edit( + runtime: &tokio::runtime::Runtime, + sidecar: &Arc, + method: &str, + request: &T, +) -> Result { + let payload = rmp_serde::to_vec(request)?; + let bytes = runtime.block_on(sidecar.request(method, payload))?; + Ok(rmp_serde::from_slice(&bytes)?) +} + +/// Construct an empty sidecar workspace edit. +fn empty_workspace_edit() -> SidecarWorkspaceEditResult { + SidecarWorkspaceEditResult { + document_changes: Vec::new(), + } +} - let document_changes: Vec = result +/// Convert the merged sidecar result into an LSP workspace edit or `null`. +fn workspace_edit_value(result: SidecarWorkspaceEditResult) -> Result { + let result = canonicalize_workspace_edit(result)?; + // One unconvertible document path must not sink an otherwise valid rename — + // skip it and keep every edit we can represent. + let edits: Vec<_> = result .document_changes .into_iter() - .filter_map(|doc_edit| { - let uri = crate::utils::path_to_lsp_uri(&doc_edit.file_path).ok()?; - let edits: Vec> = doc_edit - .edits - .into_iter() - .map(|e| { - OneOf::Left(TextEdit { - range: Range { - start: Position { - line: e.start_line, - character: e.start_character, - }, - end: Position { - line: e.end_line, - character: e.end_character, - }, - }, - new_text: e.new_text, - }) - }) - .collect(); - Some(lsp_types::TextDocumentEdit { - text_document: lsp_types::OptionalVersionedTextDocumentIdentifier { - uri, - version: None, - }, - edits, - }) + .filter_map(|change| match lsp_document_edit(change) { + Ok(edit) => Some(edit), + Err(err) => { + warn!("Skipping rename edit for an unconvertible document path: {err:#}"); + None + } }) .collect(); - - if document_changes.is_empty() { + if edits.is_empty() { return Ok(serde_json::Value::Null); } - let workspace_edit = WorkspaceEdit { - document_changes: Some(lsp_types::DocumentChanges::Edits(document_changes)), + document_changes: Some(lsp_types::DocumentChanges::Edits(edits)), ..WorkspaceEdit::default() }; Ok(serde_json::to_value(workspace_edit)?) } +/// Convert one sidecar document edit into its LSP representation. +fn lsp_document_edit(edit: SidecarDocumentEditResult) -> Result { + let uri = crate::utils::path_to_lsp_uri(&edit.file_path)?; + let edits = edit.edits.into_iter().map(lsp_rename_edit).collect(); + Ok(lsp_types::TextDocumentEdit { + text_document: lsp_types::OptionalVersionedTextDocumentIdentifier { uri, version: None }, + edits, + }) +} + +/// Convert one sidecar rename replacement into an unannotated LSP text edit. +fn lsp_rename_edit(edit: SidecarTextEditResult) -> OneOf { + OneOf::Left(TextEdit { + range: Range { + start: Position { + line: edit.start_line, + character: edit.start_character, + }, + end: Position { + line: edit.end_line, + character: edit.end_character, + }, + }, + new_text: edit.new_text, + }) +} + +/// Merge fallback documents into the primary result and stabilize their order. +fn merge_workspace_edits( + mut result: SidecarWorkspaceEditResult, + foreign: SidecarWorkspaceEditResult, +) -> Result { + result.document_changes.extend(foreign.document_changes); + canonicalize_workspace_edit(result) +} + +/// Merge duplicate document entries, normalize edit order, and reject conflicts. +fn canonicalize_workspace_edit( + result: SidecarWorkspaceEditResult, +) -> Result { + let mut canonical = empty_workspace_edit(); + for edit in result.document_changes { + merge_document_edit(&mut canonical, edit); + } + canonical + .document_changes + .sort_by_key(|edit| normalized_rename_path(&edit.file_path)); + validate_workspace_edits(&canonical)?; + Ok(canonical) +} + +/// Reject invalid, overlapping, or conflicting edits in every document. +fn validate_workspace_edits(result: &SidecarWorkspaceEditResult) -> Result<()> { + for document in &result.document_changes { + validate_rename_edits(&document.file_path, &document.edits)?; + } + Ok(()) +} + +/// Reject invalid or overlapping replacement ranges within one document. +fn validate_rename_edits(path: &str, edits: &[SidecarTextEditResult]) -> Result<()> { + for edit in edits { + if rename_edit_start(edit) > rename_edit_end(edit) { + anyhow::bail!("invalid rename range returned for {path}"); + } + } + let overlaps = edits.windows(2).any(|pair| match pair { + [left, right] => rename_edits_overlap(left, right), + _ => false, + }); + if overlaps { + anyhow::bail!("overlapping rename edits returned for {path}"); + } + Ok(()) +} + +/// Test whether two half-open edit ranges conflict, including point insertions. +fn rename_edits_overlap(left: &SidecarTextEditResult, right: &SidecarTextEditResult) -> bool { + let (left_start, left_end) = (rename_edit_start(left), rename_edit_end(left)); + let (right_start, right_end) = (rename_edit_start(right), rename_edit_end(right)); + if left_start == left_end { + return (right_start == right_end && left_start == right_start) + || (right_start <= left_start && left_start < right_end); + } + if right_start == right_end { + return left_start <= right_start && right_start < left_end; + } + left_start < right_end && right_start < left_end +} + +/// Return an edit's zero-based inclusive start position. +fn rename_edit_start(edit: &SidecarTextEditResult) -> (u32, u32) { + (edit.start_line, edit.start_character) +} + +/// Return an edit's zero-based exclusive end position. +fn rename_edit_end(edit: &SidecarTextEditResult) -> (u32, u32) { + (edit.end_line, edit.end_character) +} + +/// Merge one normalized document into the accumulated result. +fn merge_document_edit( + result: &mut SidecarWorkspaceEditResult, + mut incoming: SidecarDocumentEditResult, +) { + let key = normalized_rename_path(&incoming.file_path); + let existing = result + .document_changes + .iter_mut() + .find(|edit| normalized_rename_path(&edit.file_path) == key); + if let Some(existing) = existing { + existing.edits.append(&mut incoming.edits); + normalize_rename_edits(&mut existing.edits); + } else { + normalize_rename_edits(&mut incoming.edits); + result.document_changes.push(incoming); + } +} + +/// Sort and remove duplicate range/replacement tuples. +fn normalize_rename_edits(edits: &mut Vec) { + edits.sort_by(compare_rename_edits); + edits.dedup_by(|right, left| same_rename_edit(left, right)); +} + +/// Compare rename edits in deterministic source order. +fn compare_rename_edits( + left: &SidecarTextEditResult, + right: &SidecarTextEditResult, +) -> std::cmp::Ordering { + rename_edit_key(left).cmp(&rename_edit_key(right)) +} + +/// Return the fields that uniquely identify a rename replacement. +fn rename_edit_key(edit: &SidecarTextEditResult) -> (u32, u32, u32, u32, &str) { + ( + edit.start_line, + edit.start_character, + edit.end_line, + edit.end_character, + edit.new_text.as_str(), + ) +} + +/// Test whether two replacements target the same range with the same text. +fn same_rename_edit(left: &SidecarTextEditResult, right: &SidecarTextEditResult) -> bool { + rename_edit_key(left) == rename_edit_key(right) +} + +/// Canonicalize a path into a stable cross-sidecar merge key. +fn normalized_rename_path(path: &str) -> String { + let canonical = std::fs::canonicalize(path).map_or_else( + |_| path.to_string(), + |value| value.to_string_lossy().into_owned(), + ); + let normalized = strip_rename_verbatim(&canonical).replace('\\', "/"); + if cfg!(windows) { + normalized.to_ascii_lowercase() + } else { + normalized + } +} + +/// Remove the Windows verbatim prefix while preserving UNC semantics. +fn strip_rename_verbatim(path: &str) -> String { + if let Some(rest) = path.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{rest}"); + } + path.strip_prefix(r"\\?\").unwrap_or(path).to_string() +} + /// Sidecar request to rename a symbol. #[derive(serde::Serialize)] struct SidecarRenameRequest { @@ -1047,6 +1328,39 @@ struct SidecarRenameRequest { new_name: String, } +/// Portable metadata identity returned by the symbol's owning sidecar. +#[derive(serde::Deserialize)] +struct SidecarRenameIdentityResult { + /// Whether the source position resolved to a cross-language-visible symbol. + found: bool, + /// Simple name of the assembly that owns the symbol. + assembly_name: String, + /// Standard .NET XML documentation signature for the symbol. + xml_doc_sig: String, +} + +/// Request for references to a metadata symbol owned by the other sidecar. +#[derive(serde::Serialize)] +struct SidecarForeignRenameRequest { + /// Simple name of the symbol's owning assembly. + assembly_name: String, + /// Standard .NET XML documentation signature for the symbol. + xml_doc_sig: String, + /// Replacement identifier requested by the editor. + new_name: String, +} + +impl SidecarForeignRenameRequest { + /// Build the fallback request from the primary sidecar's identity. + fn new(identity: SidecarRenameIdentityResult, new_name: String) -> Self { + Self { + assembly_name: identity.assembly_name, + xml_doc_sig: identity.xml_doc_sig, + new_name, + } + } +} + /// Sidecar response indicating whether a symbol is renameable. #[derive(serde::Deserialize)] struct SidecarPrepareRenameResult { @@ -1065,7 +1379,7 @@ struct SidecarPrepareRenameResult { } /// A single text replacement from the sidecar. -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] struct SidecarTextEditResult { /// Start line of the range to replace. start_line: u32, @@ -1080,7 +1394,7 @@ struct SidecarTextEditResult { } /// Edits to a single document from the sidecar. -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] struct SidecarDocumentEditResult { /// Absolute path to the file. file_path: String, @@ -1089,8 +1403,175 @@ struct SidecarDocumentEditResult { } /// A workspace-wide set of edits from the sidecar rename operation. -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] struct SidecarWorkspaceEditResult { /// Per-document edits. document_changes: Vec, } + +#[cfg(test)] +mod rename_merge_tests { + use super::*; + + #[test] + fn cross_language_merge_normalizes_paths_and_deduplicates_edits() -> Result<()> { + let duplicate = rename_edit(1, 3, 15, "Renamed"); + let primary = workspace_edit("folder\\Origin.cs", vec![duplicate]); + let foreign = SidecarWorkspaceEditResult { + document_changes: vec![ + document_edit("folder/Origin.cs", vec![rename_edit(1, 3, 15, "Renamed")]), + document_edit("folder/Foreign.fs", vec![rename_edit(2, 8, 14, "Renamed")]), + ], + }; + let merged = merge_workspace_edits(primary, foreign)?; + assert_merged_documents(&merged) + } + + #[test] + fn cross_language_merge_rejects_conflicts_and_accepts_boundaries() -> Result<()> { + assert_overlap_conflicts()?; + assert_insertion_conflict(); + assert_invalid_and_adjacent_ranges() + } + + /// A document whose path cannot be turned into a URI must not sink the whole + /// rename — the remaining documents still have to reach the editor. + #[test] + fn unconvertible_document_path_is_skipped_not_fatal() -> Result<()> { + let absolute = if cfg!(windows) { + r"C:\repo\Good.cs" + } else { + "/repo/Good.cs" + }; + let result = SidecarWorkspaceEditResult { + document_changes: vec![ + document_edit("relative/Bad.cs", vec![rename_edit(0, 0, 3, "New")]), + document_edit(absolute, vec![rename_edit(1, 0, 3, "New")]), + ], + }; + + let rendered = workspace_edit_value(result)?.to_string(); + + assert!( + rendered.contains("Good.cs"), + "the convertible document must survive: {rendered}" + ); + assert!( + !rendered.contains("Bad.cs"), + "the unconvertible document must be skipped: {rendered}" + ); + Ok(()) + } + + fn assert_overlap_conflicts() -> Result<()> { + let Err(overlap) = merge_workspace_edits( + workspace_edit("Origin.cs", vec![rename_edit(1, 3, 15, "One")]), + workspace_edit("Origin.cs", vec![rename_edit(1, 8, 18, "Two")]), + ) else { + anyhow::bail!("overlapping edits must fail"); + }; + assert!(overlap.to_string().contains("overlapping rename edits")); + assert!(overlap.to_string().contains("Origin.cs")); + let conflict = merge_workspace_edits( + workspace_edit("Origin.cs", vec![rename_edit(1, 3, 15, "One")]), + workspace_edit("Origin.cs", vec![rename_edit(1, 3, 15, "Two")]), + ); + assert!( + conflict.is_err(), + "same range with different text must fail" + ); + Ok(()) + } + + fn assert_insertion_conflict() { + let insertion = merge_workspace_edits( + workspace_edit("Origin.cs", vec![rename_edit(1, 3, 15, "One")]), + workspace_edit("Origin.cs", vec![rename_edit(1, 8, 8, "Two")]), + ); + assert!(insertion.is_err(), "an insertion inside a range must fail"); + } + + #[test] + fn cross_language_wire_contract_uses_the_declared_key_order() -> Result<()> { + let bytes = rmp_serde::to_vec(&(true, "Assembly", "T:Example.Symbol"))?; + let identity: SidecarRenameIdentityResult = rmp_serde::from_slice(&bytes)?; + assert!(identity.found); + assert_eq!(identity.assembly_name, "Assembly"); + assert_eq!(identity.xml_doc_sig, "T:Example.Symbol"); + let request = SidecarForeignRenameRequest::new(identity, "Renamed".to_string()); + let bytes = rmp_serde::to_vec(&request)?; + let tuple: (String, String, String) = rmp_serde::from_slice(&bytes)?; + assert_eq!( + tuple, + ( + "Assembly".into(), + "T:Example.Symbol".into(), + "Renamed".into() + ) + ); + Ok(()) + } + + fn workspace_edit(path: &str, edits: Vec) -> SidecarWorkspaceEditResult { + SidecarWorkspaceEditResult { + document_changes: vec![document_edit(path, edits)], + } + } + + fn document_edit(path: &str, edits: Vec) -> SidecarDocumentEditResult { + SidecarDocumentEditResult { + file_path: path.to_string(), + edits, + } + } + + fn rename_edit(line: u32, start: u32, end: u32, new_text: &str) -> SidecarTextEditResult { + SidecarTextEditResult { + start_line: line, + start_character: start, + end_line: line, + end_character: end, + new_text: new_text.to_string(), + } + } + + fn assert_invalid_and_adjacent_ranges() -> Result<()> { + let invalid = workspace_edit("Origin.cs", vec![rename_edit(2, 9, 4, "Invalid")]); + let Err(invalid) = canonicalize_workspace_edit(invalid) else { + anyhow::bail!("reversed ranges must fail"); + }; + assert!(invalid.to_string().contains("invalid rename range")); + let adjacent = merge_workspace_edits( + workspace_edit("Origin.cs", vec![rename_edit(1, 3, 8, "One")]), + workspace_edit("Origin.cs", vec![rename_edit(1, 8, 15, "Two")]), + )?; + assert_adjacent_edits(&adjacent) + } + + fn assert_merged_documents(merged: &SidecarWorkspaceEditResult) -> Result<()> { + let [foreign, origin] = merged.document_changes.as_slice() else { + anyhow::bail!("merge must return the two language documents"); + }; + let [origin_edit] = origin.edits.as_slice() else { + anyhow::bail!("the deduplicated origin must contain one edit"); + }; + assert_eq!(foreign.file_path, "folder/Foreign.fs"); + assert_eq!(foreign.edits.len(), 1); + assert_eq!(origin_edit.start_character, 3); + assert_eq!(origin_edit.end_character, 15); + assert_eq!(origin_edit.new_text, "Renamed"); + Ok(()) + } + + fn assert_adjacent_edits(adjacent: &SidecarWorkspaceEditResult) -> Result<()> { + let [document] = adjacent.document_changes.as_slice() else { + anyhow::bail!("adjacent edits must remain in one document"); + }; + let [first, second] = document.edits.as_slice() else { + anyhow::bail!("both adjacent edits must be preserved"); + }; + assert_eq!(first.new_text, "One"); + assert_eq!(second.new_text, "Two"); + Ok(()) + } +} diff --git a/src/semantic_tokens.rs b/src/sharplsp/src/semantic_tokens.rs similarity index 100% rename from src/semantic_tokens.rs rename to src/sharplsp/src/semantic_tokens.rs diff --git a/src/sidecar/manager.rs b/src/sharplsp/src/sidecar/manager.rs similarity index 95% rename from src/sidecar/manager.rs rename to src/sharplsp/src/sidecar/manager.rs index a741cfcf..3d9bd945 100644 --- a/src/sidecar/manager.rs +++ b/src/sharplsp/src/sidecar/manager.rs @@ -14,23 +14,23 @@ use tracing::{debug, error, info, warn}; use super::protocol::Envelope; use super::transport::FramedTransport; -/// Maximum backoff delay for crash recovery. +/// Maximum backoff delay for crash recovery. `[SIDECAR-RECOVERY-BACKOFF]` const MAX_BACKOFF: Duration = Duration::from_secs(30); /// Initial backoff delay. const INITIAL_BACKOFF: Duration = Duration::from_secs(1); -/// Health ping interval. +/// Health ping interval. `[SIDECAR-HEALTH-ACTIVITY]` const PING_INTERVAL: Duration = Duration::from_secs(5); /// Ping response timeout. const PING_TIMEOUT: Duration = Duration::from_secs(2); -/// How long to wait for the sidecar READY signal. +/// How long to wait for the sidecar READY signal. `[SIDECAR-STARTUP-HANDSHAKE]` const READY_TIMEOUT: Duration = Duration::from_secs(30); /// Response budget for ordinary sidecar requests. Anything slower is wedged, -/// not busy — see [SIDECAR-REQUEST-TIMEOUT]. +/// not busy — see `[SIDECAR-IPC-TIMEOUT]`. const REQUEST_TIMEOUT: Duration = Duration::from_mins(2); /// Response budget for `workspace/open`, which legitimately runs a full /// `MSBuild` design-time build (minutes on a cold `NuGet` cache). const WORKSPACE_OPEN_TIMEOUT: Duration = Duration::from_mins(10); -/// Manages a single sidecar process (C# or F#). +/// Legacy facade pending `[SIDECAR-ARCHITECTURE-OWNERSHIP]`. pub struct SidecarManager { /// Display name for logging. name: String, @@ -44,7 +44,8 @@ pub struct SidecarManager { child: Mutex>, /// The IPC transport. transport: Mutex>, - /// Request ID counter. + /// Monotonic request ID. + // TODO [SIDECAR-IPC-CORRELATION]: validate every response ID before accepting it. next_id: AtomicU32, /// Current backoff duration for crash recovery. backoff: Mutex, @@ -169,7 +170,7 @@ impl SidecarManager { /// Bounded by a per-method budget: without one, a wedged sidecar handler /// blocks the LSP main loop forever — the health monitor deliberately /// skips pinging while a request holds the transport, so recovery would - /// never fire. Implements [SIDECAR-REQUEST-TIMEOUT]. + /// never fire. See `[SIDECAR-IPC-TIMEOUT]`. pub async fn request(&self, method: &str, payload: Vec) -> Result> { self.request_with_budget(method, payload, request_budget(method)) .await @@ -219,7 +220,7 @@ impl SidecarManager { "Sidecar response" ); - // Reset backoff on successful communication. + // TODO [SIDECAR-RECOVERY-BACKOFF]: reset only after 60 seconds continuously Ready. *self.backoff.lock().await = INITIAL_BACKOFF; Ok(response.payload) @@ -228,7 +229,7 @@ impl SidecarManager { /// Tear down the connection after a request timeout. The late response /// would otherwise be handed to the next caller and desync the framed /// protocol, so the transport is dropped and the process killed — the - /// next request respawns a clean sidecar. [SIDECAR-REQUEST-TIMEOUT] + /// next request respawns a clean sidecar. `[SIDECAR-IPC-TIMEOUT]` async fn fail_timed_out_request( &self, transport: &mut Option, @@ -256,10 +257,11 @@ impl SidecarManager { /// Spawn the sidecar process and connect via Unix socket. /// Returns the child process and transport for the caller to store. + /// TODO `[SIDECAR-STARTUP-SPAWN]`: pass endpoint, parent PID, generation, and protocol. async fn spawn_process(&self) -> Result<(Child, FramedTransport)> { info!(sidecar = %self.name, "Spawning sidecar"); - // Clean up stale socket (Unix only). + // TODO [SIDECAR-STARTUP-ENDPOINT]: never unlink a pre-existing unowned socket. #[cfg(unix)] let _ = tokio::fs::remove_file(&self.socket_path).await; @@ -290,7 +292,7 @@ impl SidecarManager { Ok((child, transport)) } - /// Wait for the READY signal on the sidecar's stdout. + /// TODO `[SIDECAR-STARTUP-HANDSHAKE]`: parse and validate versioned READY JSON. async fn wait_for_ready(&self, child: &mut Child) -> Result { let stdout = child.stdout.take().context("no stdout")?; let mut reader = tokio::io::BufReader::new(stdout); @@ -336,7 +338,7 @@ impl SidecarManager { /// Routed through [`SidecarManager::request_with_budget`] so a timed-out /// ping poisons the transport instead of abandoning a pending response /// mid-stream — an outer timeout that merely drops the read future leaves - /// a stale frame for the next caller. [SIDECAR-REQUEST-TIMEOUT] + /// a stale frame for the next caller. `[SIDECAR-IPC-TIMEOUT]` pub async fn health_check(&self) -> Result<()> { let ping_payload = rmp_serde::to_vec("ping")?; match self @@ -384,7 +386,7 @@ impl SidecarManager { health_loop(self).await } - /// Gracefully shut down the sidecar. + /// TODO `[SIDECAR-SHUTDOWN-PROTOCOL]`: validate the acknowledgement and await clean exit before hard kill. pub async fn shutdown(&self) { info!(sidecar = %self.name, "Shutting down sidecar"); if let Ok(mut transport_guard) = self.transport.try_lock() { @@ -421,7 +423,7 @@ impl SidecarManager { /// Response budget for a sidecar method. `workspace/open` legitimately runs a /// full `MSBuild` design-time build; anything else past two minutes is wedged. -/// [SIDECAR-REQUEST-TIMEOUT] +/// `[SIDECAR-IPC-TIMEOUT]` fn request_budget(method: &str) -> Duration { if method == "workspace/open" { WORKSPACE_OPEN_TIMEOUT @@ -468,8 +470,9 @@ async fn health_loop(sidecar: Arc) -> ! { /// Kept as a transitional fallback while the distribution spec /// migrates away from bundled sidecars; see /// `docs/specs/BINARY-DEPLOYMENT.md`. -/// 3. Dev build via `dotnet run --project sidecars/` +/// 3. Dev build via `dotnet run --project src/sidecars/` /// (CWD = repo root). +// TODO [SIDECAR-STARTUP-RESOLUTION]: validate absolute candidates, reject shims, and remove dotnet run. fn sidecar_launch( tool_command: &str, subdir: &str, @@ -481,7 +484,7 @@ fn sidecar_launch( subdir, name, socket_path, "Resolving sidecar launch command" ); - // [SIDECAR-RESOLVE-ENV]: env var override takes absolute priority. + // [SHARPLSP-ARCHITECTURE-EXTENSIONS-SIDECAR-ENV]: env var override takes absolute priority. if let Some(exe) = env_var_sidecar_override(subdir) { info!(exe = %exe.display(), source = "env-var", "Sidecar resolved"); return ( @@ -510,7 +513,7 @@ fn sidecar_launch( vec![ "run".to_string(), "--project".to_string(), - format!("sidecars/{name}"), + format!("src/sidecars/{name}"), "--".to_string(), socket_path.to_string(), ], @@ -632,6 +635,7 @@ static IPC_SEQUENCE: AtomicU64 = AtomicU64::new(0); /// (constant length, so it never trips the Unix socket-path limit) while making /// it unique across hosts (pid) and within a host (counter). It is computed once /// per manager and reused across restarts, so a restart keeps its own endpoint. +// TODO [SIDECAR-STARTUP-ENDPOINT]: generate a fresh OS-CSPRNG nonce for every spawn generation. fn unique_endpoint_token(workspace_root: &Path) -> String { let sequence = IPC_SEQUENCE.fetch_add(1, Ordering::Relaxed); let key = format!( @@ -849,7 +853,7 @@ mod tests { vec![ "run".to_string(), "--project".to_string(), - format!("sidecars/{name}"), + format!("src/sidecars/{name}"), "--".to_string(), "/tmp/sharplsp-test.sock".to_string(), ] @@ -930,7 +934,7 @@ mod tests { let exe = dir.join("SharpLsp.Sidecar.CSharp"); fs::write(&exe, b"").unwrap(); - // Implements [SIDECAR-RESOLVE-ENV]: SHARPLSP_CSHARP_SIDECAR_PATH overrides all other resolution. + // Implements [SHARPLSP-ARCHITECTURE-EXTENSIONS-SIDECAR-ENV]: SHARPLSP_CSHARP_SIDECAR_PATH overrides all other resolution. std::env::set_var("SHARPLSP_CSHARP_SIDECAR_PATH", exe.to_str().unwrap()); let (command, args) = sidecar_launch( @@ -954,7 +958,7 @@ mod tests { /// A wedged sidecar (accepts the request, never answers) must fail the /// request within its budget and poison the transport so the next request /// respawns a clean process — not hang the LSP main loop forever. - /// Implements [SIDECAR-REQUEST-TIMEOUT]. + /// Implements [SHARPLSP-ARCHITECTURE-SIDECARS-TIMEOUT]. #[tokio::test] async fn request_times_out_and_poisons_the_transport() { let manager = SidecarManager::new("test", "unused-command", vec![], "unused-endpoint"); diff --git a/src/sidecar/mod.rs b/src/sharplsp/src/sidecar/mod.rs similarity index 100% rename from src/sidecar/mod.rs rename to src/sharplsp/src/sidecar/mod.rs diff --git a/src/sidecar/protocol.rs b/src/sharplsp/src/sidecar/protocol.rs similarity index 87% rename from src/sidecar/protocol.rs rename to src/sharplsp/src/sidecar/protocol.rs index 371209a8..e28ca2a3 100644 --- a/src/sidecar/protocol.rs +++ b/src/sharplsp/src/sidecar/protocol.rs @@ -1,8 +1,8 @@ -//! `MessagePack` wire protocol types matching the .NET `Envelope` contract. +//! `MessagePack` wire types for `[SIDECAR-IPC-FRAMING]`. use serde::{Deserialize, Serialize}; -/// Wire envelope for sidecar IPC. +/// Wire envelope for sidecar IPC. `[SIDECAR-IPC-FRAMING]` /// Matches `SharpLsp.Sidecar.Common.Messages.Envelope` (`MessagePack` keyed). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Envelope { @@ -17,6 +17,7 @@ pub struct Envelope { pub error: Option, } +// TODO [SIDECAR-IPC-FRAMING]: validate exactly one request, response, or notification shape. impl Envelope { /// Create a request envelope. pub fn request(id: u32, method: &str, payload: Vec) -> Self { @@ -35,6 +36,7 @@ impl Envelope { reason = "test code — panics are the correct failure mode" )] mod tests { + // Contract tests for [SIDECAR-IPC-FRAMING]. use super::*; #[test] diff --git a/src/sidecar/transport.rs b/src/sharplsp/src/sidecar/transport.rs similarity index 95% rename from src/sidecar/transport.rs rename to src/sharplsp/src/sidecar/transport.rs index 5549dea2..c338bdcf 100644 --- a/src/sidecar/transport.rs +++ b/src/sharplsp/src/sidecar/transport.rs @@ -1,6 +1,6 @@ //! Framed async transport over Unix domain sockets (Unix) or named pipes (Windows). //! -//! Frame format: 4-byte little-endian length prefix + `MessagePack` payload. +//! `[SIDECAR-IPC-FRAMING]`: 4-byte little-endian length + `MessagePack` payload. use anyhow::{Context, Result}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; @@ -8,7 +8,7 @@ use tracing::trace; use super::protocol::Envelope; -/// Maximum accepted frame payload size (64 MiB). +/// Maximum accepted frame payload size (64 MiB). `[SIDECAR-IPC-FRAMING]` /// /// The host↔sidecar peers are same-user processes, so this is a /// robustness/DoS guard rather than a trust boundary: it stops a corrupt or @@ -64,6 +64,7 @@ impl FramedTransport { /// Read one framed envelope. Returns `None` at EOF. pub async fn read_envelope(&mut self) -> Result> { let mut len_buf = [0u8; 4]; + // TODO [SIDECAR-IPC-FRAMING]: distinguish clean EOF from a truncated length prefix. match self.reader.read_exact(&mut len_buf).await { Ok(_) => {} Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), @@ -119,6 +120,7 @@ impl FramedTransport { ) )] mod tests { + // Framing and payload-bound coverage: [SIDECAR-IPC-FRAMING]. #[cfg(unix)] use super::*; #[cfg(unix)] diff --git a/src/signature_help.rs b/src/sharplsp/src/signature_help.rs similarity index 98% rename from src/signature_help.rs rename to src/sharplsp/src/signature_help.rs index 6bbc346d..848fb1f8 100644 --- a/src/signature_help.rs +++ b/src/sharplsp/src/signature_help.rs @@ -3,7 +3,8 @@ //! Routed to the language sidecar, which resolves the enclosing method or //! constructor call and returns its overloads. The F# sidecar (FCS //! `GetMethods`) implements this; C# requests for which the sidecar has no -//! handler resolve to null. Implements [FS-SIGHELP]. +//! handler resolve to null. This is the signature-help portion of +//! `[SHARPLSP-FEATURES-INTELLIGENCE]`. use std::sync::Arc; diff --git a/src/sort_members.rs b/src/sharplsp/src/sort_members.rs similarity index 97% rename from src/sort_members.rs rename to src/sharplsp/src/sort_members.rs index b5928b3f..2b395c74 100644 --- a/src/sort_members.rs +++ b/src/sharplsp/src/sort_members.rs @@ -210,7 +210,6 @@ fn collect_members(type_node: Node<'_>, source: &[u8]) -> Vec { if let Some(category) = member_category(&child, source) { let name = extract_member_name(&child, source).unwrap_or_default(); let access = extract_access_modifiers(&child, source); - // Find leading trivia (comments/attributes) by looking at // the gap between previous sibling's end and this node's start. let trivia_byte = leading_trivia_start(&child, &parent); @@ -479,6 +478,13 @@ fn build_edits( // Insert blank lines between different accessibility/category groups. let mut new_text = String::new(); let mut prev_member: Option<&MemberInfo> = None; + let separator = members.iter().find_map(|member| { + source_bytes + .get(member.end_byte) + .copied() + .filter(|byte| *byte == b',') + .map(char::from) + }); for (sorted_pos, &original_index) in sorted_indices.iter().enumerate() { let member = members.iter().find(|m| m.index == original_index); @@ -501,6 +507,7 @@ fn build_edits( } new_text.push_str(&member_text); + append_separator(&mut new_text, separator, sorted_pos, sorted_indices.len()); if sorted_pos < sorted_indices.len() - 1 && !member_text.ends_with('\n') { new_text.push('\n'); @@ -524,6 +531,16 @@ fn build_edits( }] } +/// Add punctuation that belongs between declarations rather than to either AST node. +fn append_separator(text: &mut String, separator: Option, position: usize, total: usize) { + if position + 1 >= total { + return; + } + if let Some(separator) = separator { + text.push(separator); + } +} + /// Convert a byte offset to (line, column) in the source. fn byte_to_position(source: &[u8], byte_offset: usize) -> (u32, u32) { let mut line: u32 = 0; diff --git a/src/syntax.rs b/src/sharplsp/src/syntax.rs similarity index 100% rename from src/syntax.rs rename to src/sharplsp/src/syntax.rs diff --git a/src/tree_sitter_parse.rs b/src/sharplsp/src/tree_sitter_parse.rs similarity index 100% rename from src/tree_sitter_parse.rs rename to src/sharplsp/src/tree_sitter_parse.rs diff --git a/src/type_hierarchy.rs b/src/sharplsp/src/type_hierarchy.rs similarity index 100% rename from src/type_hierarchy.rs rename to src/sharplsp/src/type_hierarchy.rs diff --git a/src/utils.rs b/src/sharplsp/src/utils.rs similarity index 100% rename from src/utils.rs rename to src/sharplsp/src/utils.rs diff --git a/src/vfs.rs b/src/sharplsp/src/vfs.rs similarity index 100% rename from src/vfs.rs rename to src/sharplsp/src/vfs.rs diff --git a/src/workspace_symbols.rs b/src/sharplsp/src/workspace_symbols.rs similarity index 99% rename from src/workspace_symbols.rs rename to src/sharplsp/src/workspace_symbols.rs index c8c04a87..4dd38674 100644 --- a/src/workspace_symbols.rs +++ b/src/sharplsp/src/workspace_symbols.rs @@ -1,4 +1,5 @@ //! Custom `sharplsp/workspaceSymbols` request handler. +//! Implements [SE-WORKSPACE-SYMBOLS-REQUEST], [SE-SYMBOL-KINDS], and [SE-FSHARP-SYMBOLS]. //! //! Walks all `.cs` / `.fs` files discovered via `.csproj` / `.fsproj` files //! referenced by a `.sln` or `.slnx`, parses each with tree-sitter, and returns the @@ -404,7 +405,7 @@ struct ProjectInfo { /// `.cs` files are parsed syntactically with tree-sitter; `.fs` files have no /// host grammar, so their symbols are sourced from the FCS sidecar's /// documentSymbol — the same path the editor outline uses — so F# projects show -/// their files and symbols exactly like C# projects (#119). [FS-DOCSYMBOL] +/// their files and symbols exactly like C# projects (#119). `[SE-FSHARP-SYMBOLS]` fn build_project_node( project: &ProjectInfo, parsers: &TsParsers, @@ -448,7 +449,7 @@ fn file_symbols( } /// Extract an F# file's symbols via the FCS sidecar's documentSymbol, mapping -/// the result into the Solution Explorer's symbol model. [FS-DOCSYMBOL] +/// the result into the Solution Explorer's symbol model. `[SE-FSHARP-SYMBOLS]` fn parse_fsharp_file_symbols( file_path: &str, runtime: &tokio::runtime::Runtime, diff --git a/tests/build_deps_file_e2e.rs b/src/sharplsp/tests/build_deps_file_e2e.rs similarity index 91% rename from tests/build_deps_file_e2e.rs rename to src/sharplsp/tests/build_deps_file_e2e.rs index 39d4da7a..eae59f39 100644 --- a/tests/build_deps_file_e2e.rs +++ b/src/sharplsp/tests/build_deps_file_e2e.rs @@ -1,4 +1,4 @@ -//! Build-configuration e2e test for [BUILD-DEPSFILE-LOCK] (GitHub issue #111). +//! Build-configuration e2e test for [DIST-CI-DOTNET-DEPSFILE] (GitHub issue #111). //! //! `SharpLsp.Sidecar.Common` is a *referenced-only* class library: it is consumed //! by the executable sidecars (`SharpLsp.Sidecar.CSharp`) and the test project, @@ -28,7 +28,7 @@ use std::process::Command; /// Absolute path to the real Common library project. fn common_csproj() -> String { let manifest = env!("CARGO_MANIFEST_DIR"); - format!("{manifest}/sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj") + format!("{manifest}/../sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj") } /// Evaluate a single `MSBuild` property on a project, returning its trimmed value. @@ -57,7 +57,7 @@ fn evaluate_property(csproj: &str, property: &str) -> String { .to_owned() } -/// [BUILD-DEPSFILE-LOCK] The Common library must NOT generate a `deps.json`. +/// [DIST-CI-DOTNET-DEPSFILE] The Common library must NOT generate a `deps.json`. /// /// Pre-fix this evaluates to `true` (the SDK default), so the lock-prone /// `deps.json` is emitted and this test fails. Post-fix it evaluates to `false`. diff --git a/tests/e2e_modules/call_hierarchy_tests.rs b/src/sharplsp/tests/e2e_modules/call_hierarchy_tests.rs similarity index 100% rename from tests/e2e_modules/call_hierarchy_tests.rs rename to src/sharplsp/tests/e2e_modules/call_hierarchy_tests.rs diff --git a/tests/e2e_modules/code_actions_tests.rs b/src/sharplsp/tests/e2e_modules/code_actions_tests.rs similarity index 100% rename from tests/e2e_modules/code_actions_tests.rs rename to src/sharplsp/tests/e2e_modules/code_actions_tests.rs diff --git a/tests/e2e_modules/coverage_boost.rs b/src/sharplsp/tests/e2e_modules/coverage_boost.rs similarity index 100% rename from tests/e2e_modules/coverage_boost.rs rename to src/sharplsp/tests/e2e_modules/coverage_boost.rs diff --git a/tests/e2e_modules/coverage_boost2.rs b/src/sharplsp/tests/e2e_modules/coverage_boost2.rs similarity index 100% rename from tests/e2e_modules/coverage_boost2.rs rename to src/sharplsp/tests/e2e_modules/coverage_boost2.rs diff --git a/tests/e2e_modules/coverage_boost3.rs b/src/sharplsp/tests/e2e_modules/coverage_boost3.rs similarity index 100% rename from tests/e2e_modules/coverage_boost3.rs rename to src/sharplsp/tests/e2e_modules/coverage_boost3.rs diff --git a/tests/e2e_modules/definition.rs b/src/sharplsp/tests/e2e_modules/definition.rs similarity index 100% rename from tests/e2e_modules/definition.rs rename to src/sharplsp/tests/e2e_modules/definition.rs diff --git a/tests/e2e_modules/definition_cross_language.rs b/src/sharplsp/tests/e2e_modules/definition_cross_language.rs similarity index 100% rename from tests/e2e_modules/definition_cross_language.rs rename to src/sharplsp/tests/e2e_modules/definition_cross_language.rs diff --git a/tests/e2e_modules/definition_full_stack.rs b/src/sharplsp/tests/e2e_modules/definition_full_stack.rs similarity index 100% rename from tests/e2e_modules/definition_full_stack.rs rename to src/sharplsp/tests/e2e_modules/definition_full_stack.rs diff --git a/tests/e2e_modules/definition_no_sidecar.rs b/src/sharplsp/tests/e2e_modules/definition_no_sidecar.rs similarity index 100% rename from tests/e2e_modules/definition_no_sidecar.rs rename to src/sharplsp/tests/e2e_modules/definition_no_sidecar.rs diff --git a/tests/e2e_modules/diagnostics.rs b/src/sharplsp/tests/e2e_modules/diagnostics.rs similarity index 100% rename from tests/e2e_modules/diagnostics.rs rename to src/sharplsp/tests/e2e_modules/diagnostics.rs diff --git a/tests/e2e_modules/diagnostics_full_stack.rs b/src/sharplsp/tests/e2e_modules/diagnostics_full_stack.rs similarity index 100% rename from tests/e2e_modules/diagnostics_full_stack.rs rename to src/sharplsp/tests/e2e_modules/diagnostics_full_stack.rs diff --git a/tests/e2e_modules/document_sync.rs b/src/sharplsp/tests/e2e_modules/document_sync.rs similarity index 100% rename from tests/e2e_modules/document_sync.rs rename to src/sharplsp/tests/e2e_modules/document_sync.rs diff --git a/tests/e2e_modules/fixtures.rs b/src/sharplsp/tests/e2e_modules/fixtures.rs similarity index 100% rename from tests/e2e_modules/fixtures.rs rename to src/sharplsp/tests/e2e_modules/fixtures.rs diff --git a/tests/e2e_modules/fixtures_cross_language.rs b/src/sharplsp/tests/e2e_modules/fixtures_cross_language.rs similarity index 100% rename from tests/e2e_modules/fixtures_cross_language.rs rename to src/sharplsp/tests/e2e_modules/fixtures_cross_language.rs diff --git a/tests/e2e_modules/fixtures_medium.rs b/src/sharplsp/tests/e2e_modules/fixtures_medium.rs similarity index 100% rename from tests/e2e_modules/fixtures_medium.rs rename to src/sharplsp/tests/e2e_modules/fixtures_medium.rs diff --git a/tests/e2e_modules/folding.rs b/src/sharplsp/tests/e2e_modules/folding.rs similarity index 100% rename from tests/e2e_modules/folding.rs rename to src/sharplsp/tests/e2e_modules/folding.rs diff --git a/tests/e2e_modules/fsharp.rs b/src/sharplsp/tests/e2e_modules/fsharp.rs similarity index 99% rename from tests/e2e_modules/fsharp.rs rename to src/sharplsp/tests/e2e_modules/fsharp.rs index 652bf1a6..596292a8 100644 --- a/tests/e2e_modules/fsharp.rs +++ b/src/sharplsp/tests/e2e_modules/fsharp.rs @@ -258,7 +258,7 @@ fn test_full_stack_fsharp_hover_xml_docs() { // the file from DISK. As soon as the editor buffer diverged from disk, F# hover // resolved the editor's line/char against stale on-disk text and returned the // wrong symbol (or null). C# already honored the in-memory buffer; this restores -// F# to parity. [FS-DIDCHANGE-OVERLAY] +// F# to parity. [HOVER-FSHARP-OVERLAY] #[test] fn test_full_stack_fsharp_hover_reflects_live_edit() { let (_tmp, file_uri, mut client) = ready_fsharp_client(); @@ -610,7 +610,7 @@ fn test_full_stack_fsharp_language_surface() { // ── F# Workspace Symbol (Full-Stack) ──────────────────────────── // The editor's "Go to Symbol in Workspace" (Ctrl-T) must reach F# symbols. The // host has no F# tree-sitter grammar, so the standard `workspace/symbol` handler -// routes F# files to the FCS sidecar's document symbols. [FS-WORKSPACE-SYMBOL] +// routes F# files to the FCS sidecar's document symbols. [SHARPLSP-FEATURES-NAVIGATION] #[test] fn test_full_stack_fsharp_workspace_symbol() { diff --git a/tests/e2e_modules/full_stack.rs b/src/sharplsp/tests/e2e_modules/full_stack.rs similarity index 100% rename from tests/e2e_modules/full_stack.rs rename to src/sharplsp/tests/e2e_modules/full_stack.rs diff --git a/tests/e2e_modules/full_stack_features.rs b/src/sharplsp/tests/e2e_modules/full_stack_features.rs similarity index 100% rename from tests/e2e_modules/full_stack_features.rs rename to src/sharplsp/tests/e2e_modules/full_stack_features.rs diff --git a/tests/e2e_modules/full_stack_hierarchy.rs b/src/sharplsp/tests/e2e_modules/full_stack_hierarchy.rs similarity index 100% rename from tests/e2e_modules/full_stack_hierarchy.rs rename to src/sharplsp/tests/e2e_modules/full_stack_hierarchy.rs diff --git a/tests/e2e_modules/full_stack_semantic.rs b/src/sharplsp/tests/e2e_modules/full_stack_semantic.rs similarity index 99% rename from tests/e2e_modules/full_stack_semantic.rs rename to src/sharplsp/tests/e2e_modules/full_stack_semantic.rs index 240984e0..1501d9d0 100644 --- a/tests/e2e_modules/full_stack_semantic.rs +++ b/src/sharplsp/tests/e2e_modules/full_stack_semantic.rs @@ -56,7 +56,7 @@ fn test_full_stack_completion_returns_items() { /// Regression test for GitHub #178: member completion after `.` must return a /// `textEdit` that REPLACES the identifier at the caret, not a bare `insertText` /// the client appends. Accepting `Name` at `calc.|Name` must yield `calc.Name`, -/// never `calc.NameName`. Implements [COMPLETION-EDIT-REPLACE]. +/// never `calc.NameName`. Implements [SHARPLSP-FEATURES-INTELLIGENCE-COMPLETION-EDIT]. #[test] fn test_member_completion_replaces_identifier_not_appends_issue_178() { require_dotnet(); diff --git a/tests/e2e_modules/hover.rs b/src/sharplsp/tests/e2e_modules/hover.rs similarity index 100% rename from tests/e2e_modules/hover.rs rename to src/sharplsp/tests/e2e_modules/hover.rs diff --git a/tests/e2e_modules/inlay_hints_tests.rs b/src/sharplsp/tests/e2e_modules/inlay_hints_tests.rs similarity index 100% rename from tests/e2e_modules/inlay_hints_tests.rs rename to src/sharplsp/tests/e2e_modules/inlay_hints_tests.rs diff --git a/tests/e2e_modules/lifecycle.rs b/src/sharplsp/tests/e2e_modules/lifecycle.rs similarity index 100% rename from tests/e2e_modules/lifecycle.rs rename to src/sharplsp/tests/e2e_modules/lifecycle.rs diff --git a/tests/e2e_modules/logging.rs b/src/sharplsp/tests/e2e_modules/logging.rs similarity index 100% rename from tests/e2e_modules/logging.rs rename to src/sharplsp/tests/e2e_modules/logging.rs diff --git a/tests/e2e_modules/lsp_features.rs b/src/sharplsp/tests/e2e_modules/lsp_features.rs similarity index 100% rename from tests/e2e_modules/lsp_features.rs rename to src/sharplsp/tests/e2e_modules/lsp_features.rs diff --git a/tests/e2e_modules/mod.rs b/src/sharplsp/tests/e2e_modules/mod.rs similarity index 100% rename from tests/e2e_modules/mod.rs rename to src/sharplsp/tests/e2e_modules/mod.rs diff --git a/tests/e2e_modules/multi_solution.rs b/src/sharplsp/tests/e2e_modules/multi_solution.rs similarity index 96% rename from tests/e2e_modules/multi_solution.rs rename to src/sharplsp/tests/e2e_modules/multi_solution.rs index e402dda3..494ea78a 100644 --- a/tests/e2e_modules/multi_solution.rs +++ b/src/sharplsp/tests/e2e_modules/multi_solution.rs @@ -5,7 +5,7 @@ use super::*; // A workspace root holding more than one `.sln`/`.slnx` is ambiguous: the C# // sidecar's recursive discovery deliberately refuses to guess which one to // load. `sharplsp.toml`'s `csharp.solution_path` is the documented way to -// resolve that ambiguity. Implements [WORKSPACE-SOLUTION-PATH]. +// resolve that ambiguity. Implements [SHARPLSP-ARCHITECTURE-PROJECTS-SOLUTION-PATH]. /// Two solutions in sibling subdirectories — the shape of every real monorepo, /// and of the `SharpLsp` repo itself. `sharplsp.toml` names the one to load. @@ -91,7 +91,7 @@ EndGlobal"# /// files, refuses to pick one, and the C# sidecar reports /// `No .sln, .slnx, or .csproj found at or under ''` — no solution loads /// and every semantic request returns null. Implements -/// [WORKSPACE-SOLUTION-PATH]. +/// [SHARPLSP-ARCHITECTURE-PROJECTS-SOLUTION-PATH]. #[test] fn test_full_stack_hover_uses_configured_solution_path_in_multi_solution_root() { require_dotnet(); diff --git a/tests/e2e_modules/nav_helpers.rs b/src/sharplsp/tests/e2e_modules/nav_helpers.rs similarity index 100% rename from tests/e2e_modules/nav_helpers.rs rename to src/sharplsp/tests/e2e_modules/nav_helpers.rs diff --git a/tests/e2e_modules/nuget_unused_full_stack.rs b/src/sharplsp/tests/e2e_modules/nuget_unused_full_stack.rs similarity index 100% rename from tests/e2e_modules/nuget_unused_full_stack.rs rename to src/sharplsp/tests/e2e_modules/nuget_unused_full_stack.rs diff --git a/tests/e2e_modules/profiler.rs b/src/sharplsp/tests/e2e_modules/profiler.rs similarity index 99% rename from tests/e2e_modules/profiler.rs rename to src/sharplsp/tests/e2e_modules/profiler.rs index fa0a2db7..5fd4e025 100644 --- a/tests/e2e_modules/profiler.rs +++ b/src/sharplsp/tests/e2e_modules/profiler.rs @@ -422,7 +422,8 @@ fn has_dotnet_trace() -> bool { } fn locate_nettrace_sample() -> Option { - let profiles_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".sharplsp/profiles"); + let profiles_dir = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.sharplsp/profiles"); let entries = std::fs::read_dir(profiles_dir).ok()?; for entry in entries.flatten() { let path = entry.path(); diff --git a/tests/e2e_modules/profiler_dump_analysis_full_stack.rs b/src/sharplsp/tests/e2e_modules/profiler_dump_analysis_full_stack.rs similarity index 87% rename from tests/e2e_modules/profiler_dump_analysis_full_stack.rs rename to src/sharplsp/tests/e2e_modules/profiler_dump_analysis_full_stack.rs index 686846c5..15e93e78 100644 --- a/tests/e2e_modules/profiler_dump_analysis_full_stack.rs +++ b/src/sharplsp/tests/e2e_modules/profiler_dump_analysis_full_stack.rs @@ -1,5 +1,5 @@ -//! Full-stack e2e for the dump-analysis pipeline (PROFILER-SPEC §4.2.1, §4.5, -//! §5.1, §5A): object retention graphs, GC roots, object inspection, and heap +//! Full-stack e2e for the dump-analysis pipeline ([PROFILER-TRACE-CONVERSION], +//! [PROFILER-PROTOCOL-DUMP-ANALYZE], [PROFILER-LEAKS-WORKFLOW], [PROFILER-GRAPH]): object retention graphs, GC roots, object inspection, and heap //! snapshot diffing — all against REAL heap dumps of a live .NET process, plus //! trace conversion of a REAL `.nettrace` capture. No mocks anywhere. @@ -77,6 +77,34 @@ fn is_hex_address(token: &str) -> bool { token.len() >= 8 && token.chars().all(|c| c.is_ascii_hexdigit()) } +/// Collect baseline heap dumps until `StringBuilder` instances are live, and +/// return that dump's path together with one instance address. +/// +/// `start_profiler_session` returns as soon as the target process and the LSP +/// client are up; it does not wait for the target to reach its allocation loop. +/// A dump taken in the first moments of process start can therefore legitimately +/// precede the loop's first iteration and contain no instances at all. Retrying +/// removes that start-up race without weakening anything: if the hotspot never +/// appears, the final attempt still fails on the same requirement. +fn baseline_with_hotspot(client: &mut LspClient, pid: u32, dir: &Path) -> (String, String) { + const HOTSPOT: &str = "System.Text.StringBuilder"; + + for attempt in 0..4 { + let dump = collect_heap_dump(client, pid, dir, &format!("baseline-{attempt}.dmp")); + if let Some(address) = harvest_heap_address(&dump, HOTSPOT) { + return (dump, address); + } + std::thread::sleep(Duration::from_secs(1)); + } + + let dump = collect_heap_dump(client, pid, dir, "baseline.dmp"); + let address = harvest_heap_address(&dump, HOTSPOT).expect( + "baseline heap dump must contain StringBuilder instances \ + (ProfileTarget allocates them constantly)", + ); + (dump, address) +} + /// Collect a heap dump of `pid` through the LSP and return the dump path. fn collect_heap_dump(client: &mut LspClient, pid: u32, dir: &Path, file_name: &str) -> String { let dump_path = dir.join(file_name).to_string_lossy().to_string(); @@ -95,7 +123,7 @@ fn collect_heap_dump(client: &mut LspClient, pid: u32, dir: &Path, file_name: &s dump_path } -/// PROFILER-SPEC §5A + §4.5 + §5.1 — the complete memory-analysis workflow a +/// [PROFILER-GRAPH] [PROFILER-PROTOCOL-DUMP-ANALYZE] [PROFILER-LEAKS-WORKFLOW] — the complete memory-analysis workflow a /// user actually performs: snapshot a live process twice, walk a real object's /// retention graph, trace its GC roots, inspect its fields, and diff the two /// snapshots for leak suspects. @@ -105,20 +133,17 @@ fn test_profiler_object_graph_roots_inspect_and_diff_full_stack() { let tmp_dir = tempfile::tempdir().expect("create temp dir"); // 1. Baseline heap snapshot, then let the target allocate, then compare - // snapshot — the §5.1 baseline → exercise → compare workflow. - let baseline_dump = collect_heap_dump(&mut client, target_pid, tmp_dir.path(), "baseline.dmp"); + // snapshot — the [PROFILER-LEAKS-WORKFLOW] baseline → exercise → compare workflow. + // + // 2. Harvest a REAL object address from the baseline. `ProfileTarget`'s + // StringBuilder hotspot supplies the instances, and `StringBuilder`'s + // `m_ChunkChars` char[] field is a never-null reference — exercising the + // graph's edge traversal. + let (baseline_dump, address) = baseline_with_hotspot(&mut client, target_pid, tmp_dir.path()); std::thread::sleep(Duration::from_secs(2)); let comparison_dump = collect_heap_dump(&mut client, target_pid, tmp_dir.path(), "comparison.dmp"); - // 2. Harvest a REAL object address. ProfileTarget's StringBuilder hotspot - // guarantees instances, and StringBuilder's m_ChunkChars char[] field - // is a never-null reference — exercising the graph's edge traversal. - let address = harvest_heap_address(&baseline_dump, "System.Text.StringBuilder").expect( - "baseline heap dump must contain StringBuilder instances \ - (ProfileTarget allocates them constantly)", - ); - // 3. Object retention graph from the real root address. let resp = client.request( "sharplsp/profiler/getObjectGraph", @@ -234,7 +259,7 @@ fn test_profiler_object_graph_roots_inspect_and_diff_full_stack() { ); } - // 5. GC roots of the same real object (§4.5). Chains depend on GC timing, + // 5. GC roots of the same real object ([PROFILER-PROTOCOL-DUMP-ANALYZE]). Chains depend on GC timing, // but the request must succeed and any chain returned must be sound. let resp = client.request( "sharplsp/profiler/findGCRoots", @@ -262,7 +287,7 @@ fn test_profiler_object_graph_roots_inspect_and_diff_full_stack() { } } - // 6. Inspect the same real object (§5A.2). + // 6. Inspect the same real object ([PROFILER-GRAPH-INSPECTION]). let resp = client.request( "sharplsp/profiler/inspectObject", json!({ "dump_path": &baseline_dump, "object_address": &address }), @@ -303,7 +328,7 @@ fn test_profiler_object_graph_roots_inspect_and_diff_full_stack() { ); } - // 7. Diff the two snapshots (§5.1 compare step). With growing_only=false + // 7. Diff the two snapshots ([PROFILER-LEAKS-WORKFLOW]). With growing_only=false // and a 0% floor every stable-or-growing type is reported. let resp = client.request( "sharplsp/profiler/diffHeapSnapshots", @@ -392,7 +417,7 @@ fn test_profiler_object_graph_roots_inspect_and_diff_full_stack() { stop_profile_target(&mut target); } -/// PROFILER-SPEC §4.2.1 — `convertTrace` converts a previously captured REAL +/// [PROFILER-TRACE-CONVERSION] — `convertTrace` converts a previously captured REAL /// `.nettrace` through the explicit handler (chromium format — distinct from /// the speedscope conversion `stopTrace` performs automatically). #[test] diff --git a/tests/e2e_modules/profiler_edge_cases.rs b/src/sharplsp/tests/e2e_modules/profiler_edge_cases.rs similarity index 100% rename from tests/e2e_modules/profiler_edge_cases.rs rename to src/sharplsp/tests/e2e_modules/profiler_edge_cases.rs diff --git a/tests/e2e_modules/profiler_full_stack.rs b/src/sharplsp/tests/e2e_modules/profiler_full_stack.rs similarity index 100% rename from tests/e2e_modules/profiler_full_stack.rs rename to src/sharplsp/tests/e2e_modules/profiler_full_stack.rs diff --git a/tests/e2e_modules/pull_diagnostics.rs b/src/sharplsp/tests/e2e_modules/pull_diagnostics.rs similarity index 100% rename from tests/e2e_modules/pull_diagnostics.rs rename to src/sharplsp/tests/e2e_modules/pull_diagnostics.rs diff --git a/tests/e2e_modules/references.rs b/src/sharplsp/tests/e2e_modules/references.rs similarity index 98% rename from tests/e2e_modules/references.rs rename to src/sharplsp/tests/e2e_modules/references.rs index 085cd24a..1db41903 100644 --- a/tests/e2e_modules/references.rs +++ b/src/sharplsp/tests/e2e_modules/references.rs @@ -1,3 +1,4 @@ +// Coarse protocol coverage for [REFERENCES-PROTOCOL-FIND] and [REFERENCES-PROTOCOL-HIGHLIGHT]. use super::*; // References, document_highlight, and poll_references_until_ready live in diff --git a/tests/e2e_modules/selection.rs b/src/sharplsp/tests/e2e_modules/selection.rs similarity index 100% rename from tests/e2e_modules/selection.rs rename to src/sharplsp/tests/e2e_modules/selection.rs diff --git a/tests/e2e_modules/semantic_coverage.rs b/src/sharplsp/tests/e2e_modules/semantic_coverage.rs similarity index 100% rename from tests/e2e_modules/semantic_coverage.rs rename to src/sharplsp/tests/e2e_modules/semantic_coverage.rs diff --git a/tests/e2e_modules/semantic_tokens_tests.rs b/src/sharplsp/tests/e2e_modules/semantic_tokens_tests.rs similarity index 100% rename from tests/e2e_modules/semantic_tokens_tests.rs rename to src/sharplsp/tests/e2e_modules/semantic_tokens_tests.rs diff --git a/tests/e2e_modules/session_helpers.rs b/src/sharplsp/tests/e2e_modules/session_helpers.rs similarity index 100% rename from tests/e2e_modules/session_helpers.rs rename to src/sharplsp/tests/e2e_modules/session_helpers.rs diff --git a/tests/e2e_modules/sort_members.rs b/src/sharplsp/tests/e2e_modules/sort_members.rs similarity index 94% rename from tests/e2e_modules/sort_members.rs rename to src/sharplsp/tests/e2e_modules/sort_members.rs index 23b1096d..340bad6e 100644 --- a/tests/e2e_modules/sort_members.rs +++ b/src/sharplsp/tests/e2e_modules/sort_members.rs @@ -160,6 +160,19 @@ fn test_sort_members_enum_sorts_members() { alpha_pos < middle_pos && middle_pos < zebra_pos, "expected alphabetical enum members", ); + assert_eq!( + new_text.matches(',').count(), + 2, + "sorting must preserve the two required enum separators: {new_text}", + ); + assert!( + new_text[alpha_pos..middle_pos].contains(','), + "Alpha must remain comma-separated from Middle: {new_text}", + ); + assert!( + new_text[middle_pos..zebra_pos].contains(','), + "Middle must remain comma-separated from Zebra: {new_text}", + ); } // 59. SORT MEMBERS: UNSAVED BUFFER WINS OVER DISK diff --git a/tests/e2e_modules/sort_members_extra.rs b/src/sharplsp/tests/e2e_modules/sort_members_extra.rs similarity index 100% rename from tests/e2e_modules/sort_members_extra.rs rename to src/sharplsp/tests/e2e_modules/sort_members_extra.rs diff --git a/tests/e2e_modules/standalone_csproj.rs b/src/sharplsp/tests/e2e_modules/standalone_csproj.rs similarity index 100% rename from tests/e2e_modules/standalone_csproj.rs rename to src/sharplsp/tests/e2e_modules/standalone_csproj.rs diff --git a/tests/e2e_modules/symbols.rs b/src/sharplsp/tests/e2e_modules/symbols.rs similarity index 100% rename from tests/e2e_modules/symbols.rs rename to src/sharplsp/tests/e2e_modules/symbols.rs diff --git a/tests/e2e_modules/type_hierarchy_tests.rs b/src/sharplsp/tests/e2e_modules/type_hierarchy_tests.rs similarity index 100% rename from tests/e2e_modules/type_hierarchy_tests.rs rename to src/sharplsp/tests/e2e_modules/type_hierarchy_tests.rs diff --git a/tests/e2e_modules/user_session_csharp.rs b/src/sharplsp/tests/e2e_modules/user_session_csharp.rs similarity index 99% rename from tests/e2e_modules/user_session_csharp.rs rename to src/sharplsp/tests/e2e_modules/user_session_csharp.rs index af69411c..741db896 100644 --- a/tests/e2e_modules/user_session_csharp.rs +++ b/src/sharplsp/tests/e2e_modules/user_session_csharp.rs @@ -169,7 +169,7 @@ fn test_full_stack_csharp_user_session_medium_codebase() { // ── Signature help inside ApplyDiscount(...) ── // C# signature help is not implemented sidecar-side yet — the host routes - // the request and must answer null rather than erroring ([FS-SIGHELP]; + // the request and must answer null rather than erroring ([SHARPLSP-FEATURES-INTELLIGENCE]; // C# parity tracked in GitHub #174). Once it lands, the shape assertions // below take over. let sig = position_request( diff --git a/tests/e2e_modules/user_session_fsharp.rs b/src/sharplsp/tests/e2e_modules/user_session_fsharp.rs similarity index 98% rename from tests/e2e_modules/user_session_fsharp.rs rename to src/sharplsp/tests/e2e_modules/user_session_fsharp.rs index a81a5d43..a90330f0 100644 --- a/tests/e2e_modules/user_session_fsharp.rs +++ b/src/sharplsp/tests/e2e_modules/user_session_fsharp.rs @@ -81,7 +81,7 @@ fn test_full_stack_fsharp_user_session_medium_codebase() { // ── References: charge is used from the sibling file ── // Project-wide references need FCS's whole-project check; the first // responses are legitimately empty while it warms, so poll like every - // other references test ([FS-REFS-PROJECT]). + // other references test ([REFERENCES-FSHARP-FIND]). let refs = poll_references_until_ready( &mut client, &domain_uri, @@ -148,7 +148,7 @@ fn test_full_stack_fsharp_user_session_medium_codebase() { // GitHub #178 (F# parity): the accepted item must carry a `textEdit` that // REPLACES the identifier at the caret, so `product.|Price` yields - // `product.Price`, never `product.PricePrice`. Implements [COMPLETION-EDIT-REPLACE]. + // `product.Price`, never `product.PricePrice`. Implements [SHARPLSP-FEATURES-INTELLIGENCE-COMPLETION-EDIT]. let price_item = items .iter() .find(|i| i["label"].as_str() == Some("Price")) @@ -167,7 +167,7 @@ fn test_full_stack_fsharp_user_session_medium_codebase() { client.change_document(&calculations_uri, 3, CALCULATIONS_FS); - // ── Signature help contract at the settle call site ([FS-SIGHELP]) ── + // ── Signature help contract ([SHARPLSP-FEATURES-INTELLIGENCE]) ── let sig = position_request( &mut client, "textDocument/signatureHelp", diff --git a/tests/e2e_modules/version.rs b/src/sharplsp/tests/e2e_modules/version.rs similarity index 100% rename from tests/e2e_modules/version.rs rename to src/sharplsp/tests/e2e_modules/version.rs diff --git a/tests/e2e_modules/workspace_symbols.rs b/src/sharplsp/tests/e2e_modules/workspace_symbols.rs similarity index 100% rename from tests/e2e_modules/workspace_symbols.rs rename to src/sharplsp/tests/e2e_modules/workspace_symbols.rs diff --git a/tests/fixtures/NuGetTest/NuGetTest.csproj b/src/sharplsp/tests/fixtures/NuGetTest/NuGetTest.csproj similarity index 100% rename from tests/fixtures/NuGetTest/NuGetTest.csproj rename to src/sharplsp/tests/fixtures/NuGetTest/NuGetTest.csproj diff --git a/tests/fixtures/ProfileTarget/ProfileTarget.csproj b/src/sharplsp/tests/fixtures/ProfileTarget/ProfileTarget.csproj similarity index 100% rename from tests/fixtures/ProfileTarget/ProfileTarget.csproj rename to src/sharplsp/tests/fixtures/ProfileTarget/ProfileTarget.csproj diff --git a/tests/fixtures/ProfileTarget/Program.cs b/src/sharplsp/tests/fixtures/ProfileTarget/Program.cs similarity index 100% rename from tests/fixtures/ProfileTarget/Program.cs rename to src/sharplsp/tests/fixtures/ProfileTarget/Program.cs diff --git a/tests/lsp_e2e.rs b/src/sharplsp/tests/lsp_e2e.rs similarity index 100% rename from tests/lsp_e2e.rs rename to src/sharplsp/tests/lsp_e2e.rs diff --git a/tests/nuget_e2e.rs b/src/sharplsp/tests/nuget_e2e.rs similarity index 99% rename from tests/nuget_e2e.rs rename to src/sharplsp/tests/nuget_e2e.rs index 0cdbe1e0..75c870d7 100644 --- a/tests/nuget_e2e.rs +++ b/src/sharplsp/tests/nuget_e2e.rs @@ -1,4 +1,4 @@ -//! End-to-end tests for `sharplsp/nuget/*` LSP custom requests. +//! End-to-end tests for `sharplsp/nuget/*` LSP custom requests. [NUGET-TESTS-HOST] //! //! Tests spawn the `sharplsp` binary and communicate over stdio JSON-RPC, //! exactly like a real LSP client. @@ -1458,7 +1458,7 @@ fn nuget_install_missing_params_returns_error() { // ── sharplsp/nuget/consolidate ───────────────────────────────────── // Implements [PKG-CONSOLIDATE-REQUEST]. The pure consolidation logic is unit -// tested in `src/nuget/consolidate.rs`; these drive the LSP request end to end +// tested in `src/sharplsp/src/nuget/consolidate.rs`; these drive the LSP request end to end // through the spawned server, asserting the wire contract and on-disk effects. /// A `.csproj` referencing two shared packages plus one unique to it. diff --git a/sidecars/Directory.Build.props b/src/sidecars/Directory.Build.props similarity index 61% rename from sidecars/Directory.Build.props rename to src/sidecars/Directory.Build.props index c2fc0ca6..c1da9d2f 100644 --- a/sidecars/Directory.Build.props +++ b/src/sidecars/Directory.Build.props @@ -1,19 +1,19 @@ - + - + config/BannedSymbols.txt for the list + rationale. --> + diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/AnalyzerDiagnosticResolverTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/AnalyzerDiagnosticResolverTests.cs new file mode 100644 index 00000000..5ebcc4a2 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/AnalyzerDiagnosticResolverTests.cs @@ -0,0 +1,229 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.CodeFixes; +using SharpLsp.Sidecar.CSharp.Workspace; + +#pragma warning disable CA1307 // StringComparison overloads add no value to xUnit assertions +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable IDE0058 // Setup calls intentionally ignore their return values +#pragma warning disable RS1035 // Real temp-path use is intentional in this coarse E2E + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// End-to-end coverage for . +/// +/// The style-based rewrites (use `var`, file-scoped namespace) are not ordinary +/// refactorings — they are code *fixes*, and only surface when the matching IDE +/// analyzer actually reports a diagnostic over the requested span. That needs +/// three things lined up at once: the analyzer assemblies discovered by +/// reflection, the project's .editorconfig driving the style preference, and a +/// span filter that treats a diagnostic on a namespace body as touching the +/// `namespace` keyword. Nothing exercised that chain, so a discovery or +/// filtering regression would have silently turned the whole rewrite family off +/// with no failing test. +/// +/// The repository's own VS Code fixture deliberately disables analyzers, so +/// these style rules can never fire there — this project turns them on. +/// +public sealed class AnalyzerDiagnosticResolverTests : IDisposable +{ + private const string Source = """ + namespace Styled + { + public class Sample + { + public int Compute() + { + int value = 41; + return value; + } + } + } + """; + + private readonly string _root = Path.Combine( + Path.GetTempPath(), + $"sharplsp-analyzers-{Guid.NewGuid():N}" + ); + + private readonly string _csprojPath; + private readonly string _sourcePath; + + public AnalyzerDiagnosticResolverTests() + { + Directory.CreateDirectory(_root); + const string csproj = """ + + + net10.0 + Library + true + true + + + """; + + // The style preferences are what make IDE0007 / IDE0161 report at all. + const string editorConfig = """ + root = true + + [*.cs] + csharp_style_namespace_declarations = file_scoped:warning + csharp_style_var_for_built_in_types = true:warning + csharp_style_var_when_type_is_apparent = true:warning + csharp_style_var_elsewhere = true:warning + """; + + _csprojPath = Path.Combine(_root, "Styled.csproj"); + _sourcePath = Path.Combine(_root, "Styled.cs"); + File.WriteAllText(_csprojPath, csproj); + File.WriteAllText(Path.Combine(_root, ".editorconfig"), editorConfig); + File.WriteAllText(_sourcePath, Source); + } + + public void Dispose() + { + try + { + Directory.Delete(_root, true); + } + catch (IOException) { } + } + + [Fact] + public void Feature_providers_are_discovered_by_reflection() + { + // Roslyn's feature assemblies are MEF-composed and cannot be resolved + // through the workspace headlessly, so the sidecar reflects over them + // directly. An empty result means every code fix silently disappears. + var fixes = AnalyzerDiagnosticResolver.DiscoverProviders(); + Assert.NotEmpty(fixes); + + // Discovery must be de-duplicated by concrete type. + Assert.Equal(fixes.Length, fixes.Select(provider => provider.GetType()).Distinct().Count()); + } + + [Fact] + public void Only_analyzers_backing_a_rewrite_diagnostic_are_selected() + { + var providers = AnalyzerDiagnosticResolver.DiscoverProviders(); + var analyzers = AnalyzerDiagnosticResolver.DiscoverFixableAnalyzers(providers); + Assert.NotEmpty(analyzers); + + // Every selected analyzer must actually support one of the four rewrite + // ids; anything else is dead weight run on every code-action request. + var rewriteIds = new[] { "IDE0007", "IDE0008", "IDE0160", "IDE0161" }; + Assert.All( + analyzers, + analyzer => + Assert.Contains( + analyzer.SupportedDiagnostics.Select(descriptor => descriptor.Id), + id => rewriteIds.Contains(id, StringComparer.Ordinal) + ) + ); + } + + [Fact] + public void Selecting_analyzers_from_no_providers_yields_nothing() + { + var analyzers = AnalyzerDiagnosticResolver.DiscoverFixableAnalyzers([]); + Assert.Empty(analyzers); + } + + [Fact] + public async Task No_analyzers_means_no_diagnostics_and_no_compilation_work() + { + using var manager = await OpenAsync(); + var actions = Unwrap(await manager.GetCodeActionsAsync(_sourcePath, 6, 12, 6, 12)); + + // The resolver short-circuits on an empty analyzer set; the request must + // still succeed rather than throwing. + Assert.NotNull(actions); + } + + [Fact] + public async Task A_block_namespace_offers_the_file_scoped_rewrite_on_its_keyword() + { + using var manager = await OpenAsync(); + // IDE0161 reports over the namespace *declaration*, but a user's caret + // sits on the `namespace` keyword. The resolver's namespace-keyword + // special case is what connects the two. + var (line, character) = Locate("namespace Styled", "namespace"); + var actions = Unwrap( + await manager.GetCodeActionsAsync(_sourcePath, line, character, line, character) + ); + + Assert.Contains( + actions, + action => action.Title.Contains("file-scoped", StringComparison.OrdinalIgnoreCase) + ); + } + + [Fact] + public async Task An_explicitly_typed_local_offers_the_var_rewrite() + { + using var manager = await OpenAsync(); + var (line, character) = Locate(" int value = 41;", "int"); + var actions = Unwrap( + await manager.GetCodeActionsAsync(_sourcePath, line, character, line, character) + ); + + Assert.Contains( + actions, + action => action.Title.Contains("var", StringComparison.OrdinalIgnoreCase) + ); + } + + [Fact] + public async Task A_statement_inside_the_namespace_still_sees_only_one_of_each_rewrite() + { + using var manager = await OpenAsync(); + // IDE0161's location covers the whole namespace declaration, so a caret + // on any statement inside it legitimately matches. What must not happen + // is the same rewrite arriving twice: the resolver unions syntax and + // semantic diagnostics, and both passes report this one. That is what + // the de-duplication in FilterDiagnostics exists for. + var (line, character) = Locate(" return value;", "return"); + var actions = Unwrap( + await manager.GetCodeActionsAsync(_sourcePath, line, character, line, character) + ); + + var duplicated = actions + .GroupBy(action => action.Title, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToList(); + Assert.Empty(duplicated); + } + + private static (int Line, int Character) Locate(string anchor, string token) + { + var lines = Source.Split('\n').Select(line => line.TrimEnd('\r')).ToArray(); + var index = Array.FindIndex( + lines, + value => value.Contains(anchor, StringComparison.Ordinal) + ); + Assert.True(index >= 0, $"anchor not found: {anchor}"); + var character = lines[index].IndexOf(token, StringComparison.Ordinal); + Assert.True(character >= 0, $"token '{token}' not found on: {anchor}"); + return (index, character); + } + + private async Task OpenAsync() + { + var manager = new WorkspaceManager(); +#pragma warning disable CS0618 // Exercising the real solution-loading boundary + var opened = await manager.OpenAsync(_csprojPath); +#pragma warning restore CS0618 + Assert.False(opened.IsError, opened.Match(_ => "ok", error => error)); + Assert.True(manager.IsLoaded, "workspace must be loaded for code actions"); + return manager; + } + + private static T Unwrap(Outcome.Result result) + { + Assert.False(result.IsError, result.Match(_ => "ok", error => error)); + return +result; + } +} diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/CodeActionInlayCoverageEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/CodeActionInlayCoverageEndToEndTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/CodeActionInlayCoverageEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/CodeActionInlayCoverageEndToEndTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/CommonStackEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/CommonStackEndToEndTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/CommonStackEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/CommonStackEndToEndTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/DeadCodeAnalyzerTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/DeadCodeAnalyzerTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/DeadCodeAnalyzerTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/DeadCodeAnalyzerTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/EdgeCaseEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/EdgeCaseEndToEndTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/EdgeCaseEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/EdgeCaseEndToEndTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/FeatureEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/FeatureEndToEndTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/FeatureEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/FeatureEndToEndTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/GlobalJsonSdkPinEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/GlobalJsonSdkPinEndToEndTests.cs similarity index 99% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/GlobalJsonSdkPinEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/GlobalJsonSdkPinEndToEndTests.cs index 22cda90a..fa1f5d16 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp.Tests/GlobalJsonSdkPinEndToEndTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/GlobalJsonSdkPinEndToEndTests.cs @@ -301,7 +301,7 @@ private static void TryKill(Process process) /// /// Finds the sidecar apphost built alongside this test assembly: - /// sidecars/SharpLsp.Sidecar.CSharp/bin/<Config>/<tfm>/. + /// src/sidecars/SharpLsp.Sidecar.CSharp/bin/<Config>/<tfm>/. /// private static string LocateAppHost() { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HeadlessOverrideGenerationTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HeadlessOverrideGenerationTests.cs new file mode 100644 index 00000000..43588705 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HeadlessOverrideGenerationTests.cs @@ -0,0 +1,315 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.CSharp.Workspace; + +#pragma warning disable CA1307 // StringComparison overloads add no value to xUnit assertions +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable IDE0058 // Setup calls intentionally ignore their return values +#pragma warning disable RS1035 // Real temp-path use is intentional in this coarse E2E + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// End-to-end coverage for the headless "Generate overrides..." action +/// ([REFACTOR-OVERRIDE-HEADLESS]). +/// +/// Roslyn's own implement/override feature components are MEF-only and +/// unavailable in a headless host, so the sidecar builds the declarations +/// itself. The action was previously only ever *offered* in tests, never +/// applied, so every declaration-building path ran zero times: the generator +/// could have emitted uncompilable C# without a single test noticing. These +/// tests apply the action against a real MSBuild project and assert on the C# +/// that comes back. +/// +public sealed class HeadlessOverrideGenerationTests : IDisposable +{ + // A base class covering every shape the generator special-cases: plain and + // generic methods (with and without constraints, and with nullable + // annotations that force an explicit constraint clause on the override), a + // read/write property, a get-only property, an init-only property, an + // indexer, an event, and a non-public member whose accessibility must be + // carried across. + private const string Source = """ + using System; + + namespace Overrides; + + public abstract class Shape + { + public abstract int Compute(int seed); + + public abstract T? Pick(T? value); + + public abstract TRef? PickReference(TRef? value) + where TRef : class; + + public abstract T[]? PickArray(T?[] values); + + public abstract int Total { get; set; } + + public abstract string Name { get; } + + public abstract int Seed { get; init; } + + public abstract int this[int index] { get; set; } + + public abstract event EventHandler? Changed; + + protected abstract void Reset(); + } + + public class Square : Shape + { + } + + public class Circle : Shape + { + public override int Compute(int seed) => seed; + + public override int Total { get; set; } + + public override T? Pick(T? value) + where T : default => value; + + public override TRef? PickReference(TRef? value) + where TRef : class => value; + + public override T[]? PickArray(T?[] values) + where T : default => null; + } + """; + + private readonly string _root = Path.Combine( + Path.GetTempPath(), + $"sharplsp-override-{Guid.NewGuid():N}" + ); + + private readonly string _csprojPath; + private readonly string _sourcePath; + + public HeadlessOverrideGenerationTests() + { + Directory.CreateDirectory(_root); + const string csproj = """ + + + net10.0 + Library + enable + + + """; + _csprojPath = Path.Combine(_root, "Shapes.csproj"); + _sourcePath = Path.Combine(_root, "Shapes.cs"); + File.WriteAllText(_csprojPath, csproj); + File.WriteAllText(_sourcePath, Source); + } + + public void Dispose() + { + try + { + Directory.Delete(_root, true); + } + catch (IOException) { } + } + + [Fact] + public async Task Override_action_is_offered_on_a_type_that_leaves_members_unimplemented() + { + using var manager = await OpenAsync(); + var actions = Unwrap(await CodeActionsOnTypeAsync(manager, "public class Square : Shape")); + Assert.Contains(actions, action => action.Title == "Generate overrides..."); + } + + [Fact] + public async Task Members_the_type_already_overrides_are_not_generated_again() + { + using var manager = await OpenAsync(); + // `Circle` already overrides `Compute` and `Total`. Regenerating either + // would produce a duplicate member and break the build, so the + // candidate scan must treat an occupied slot as satisfied. + var generated = await ApplyOverrideActionAsync(manager, "public class Circle : Shape"); + + // Count inside `Circle` only: the abstract declarations up in `Shape` + // carry the same names and would mask a duplicate. + var circle = generated[ + generated.IndexOf("public class Circle", StringComparison.Ordinal).. + ]; + + Assert.Equal(1, CountOccurrences(circle, "Compute(int seed)")); + Assert.Equal(1, CountOccurrences(circle, "int Total")); + + // Generic signatures must match too: type-parameter ordinal, array rank + // and element type, and constructed generic arguments all feed the + // "is this slot already filled" decision. A false negative here emits a + // duplicate member and breaks the build. + Assert.Equal(1, CountOccurrences(circle, "Pick")); + Assert.Equal(1, CountOccurrences(circle, "PickReference")); + Assert.Equal(1, CountOccurrences(circle, "PickArray")); + + // The members it has not overridden are still generated. + Assert.Contains("override string Name", circle); + Assert.Contains("override int this[int index]", circle); + Assert.Contains("protected override void Reset()", circle); + + AssertParses(generated); + } + + /// The generated file must still be syntactically valid C#. + private static void AssertParses(string generated) + { + var errors = CSharpSyntaxTree + .ParseText(generated) + .GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(diagnostic => diagnostic.ToString()) + .ToList(); + Assert.Empty(errors); + } + + private static int CountOccurrences(string source, string value) + { + return source.Split(value, StringSplitOptions.None).Length - 1; + } + + [Fact] + public async Task Applying_the_action_generates_compilable_overrides_for_every_member_shape() + { + using var manager = await OpenAsync(); + var generated = await ApplyOverrideActionAsync(manager); + + // Every abstract member of the base must now have an override. + Assert.Contains("public override int Compute(int seed)", generated); + Assert.Contains("public override int Total", generated); + Assert.Contains("public override string Name", generated); + Assert.Contains("public override int this[int index]", generated); + Assert.Contains("public override event EventHandler", generated); + // Accessibility is carried across rather than widened to public. + Assert.Contains("protected override void Reset()", generated); + Assert.DoesNotContain("public override void Reset()", generated); + + // Bodies throw rather than returning a default, so an unimplemented + // override fails loudly at runtime instead of silently returning 0. + Assert.Contains("throw new NotImplementedException()", generated); + + // The whole file must still parse. A generator that emits malformed + // members is the exact failure this suite exists to catch. + AssertParses(generated); + } + + [Fact] + public async Task Generic_overrides_carry_the_constraint_clause_their_nullability_requires() + { + using var manager = await OpenAsync(); + var generated = await ApplyOverrideActionAsync(manager); + + // `T?` on an unconstrained parameter is only legal on the override when + // the constraint is restated; C# spells that `where T : default`. + Assert.Contains("Pick", generated); + Assert.Contains("where T : default", generated); + + // A `class`-constrained parameter restates `where TRef : class` instead. + Assert.Contains("PickReference", generated); + Assert.Contains("where TRef : class", generated); + + // Nullability nested inside an array still counts as nullable usage. + Assert.Contains("PickArray", generated); + } + + [Fact] + public async Task Init_only_property_overrides_keep_their_init_accessor() + { + using var manager = await OpenAsync(); + var generated = await ApplyOverrideActionAsync(manager); + + // An `init` accessor regenerated as `set` would not compile against the + // base declaration. Match the accessor itself, not the substring: "init" + // occurs inside plenty of unrelated identifiers. + var start = generated.IndexOf("public override int Seed", StringComparison.Ordinal); + Assert.True(start >= 0, "the init-only property must be overridden"); + + // Bound the search to this one declaration so a `set` elsewhere in the + // file cannot mask a wrongly regenerated accessor here. + var rest = generated[start..]; + var next = rest.IndexOf("public override", 1, StringComparison.Ordinal); + var declaration = next < 0 ? rest : rest[..next]; + + Assert.Contains("init", declaration); + Assert.DoesNotContain("set", declaration); + } + + /// Apply "Generate overrides..." on a type and return the new file text. + private async Task ApplyOverrideActionAsync( + WorkspaceManager manager, + string anchor = "public class Square : Shape" + ) + { + var actions = Unwrap(await CodeActionsOnTypeAsync(manager, anchor)); + var action = actions.Find(item => item.Title == "Generate overrides..."); + Assert.NotNull(action); + + var edit = Unwrap(await manager.ResolveCodeActionAsync(action.Id)); + var document = Assert.Single(edit.DocumentChanges); + Assert.Equal(_sourcePath, document.FilePath); + Assert.NotEmpty(document.Edits); + return ApplyEdits(Source, document.Edits); + } + + private async Task, string>> CodeActionsOnTypeAsync( + WorkspaceManager manager, + string anchor + ) + { + var token = anchor.Split(' ')[2]; + var (line, character) = Locate(anchor, token); + return await manager.GetCodeActionsAsync(_sourcePath, line, character, line, character); + } + + /// Zero-based line/character of on the anchor line. + private static (int Line, int Character) Locate(string anchor, string token) + { + var lines = Source.Split('\n').Select(line => line.TrimEnd('\r')).ToArray(); + var index = Array.FindIndex( + lines, + value => value.Contains(anchor, StringComparison.Ordinal) + ); + Assert.True(index >= 0, $"anchor not found: {anchor}"); + var character = lines[index].IndexOf(token, StringComparison.Ordinal); + Assert.True(character >= 0, $"token not found on anchor line: {token}"); + return (index, character); + } + + private async Task OpenAsync() + { + var manager = new WorkspaceManager(); +#pragma warning disable CS0618 // Exercise the real solution-loading boundary + var opened = await manager.OpenAsync(_csprojPath); +#pragma warning restore CS0618 + Assert.False(opened.IsError, opened.Match(_ => "ok", error => error)); + Assert.True(manager.IsLoaded, "workspace must be loaded for code actions"); + return manager; + } + + private static T Unwrap(Outcome.Result result) + { + Assert.False(result.IsError, result.Match(_ => "ok", error => error)); + return +result; + } + + private static string ApplyEdits(string source, IEnumerable edits) + { + var text = SourceText.From(source); + var changes = edits.Select(edit => new TextChange(EditSpan(text, edit), edit.NewText)); + return text.WithChanges(changes).ToString(); + } + + private static TextSpan EditSpan(SourceText text, TextEditResult edit) + { + var start = text.Lines.GetPosition(new LinePosition(edit.StartLine, edit.StartCharacter)); + var end = text.Lines.GetPosition(new LinePosition(edit.EndLine, edit.EndCharacter)); + return TextSpan.FromBounds(start, end); + } +} diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/HoverBuilderCoverageEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HoverBuilderCoverageEndToEndTests.cs similarity index 92% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/HoverBuilderCoverageEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HoverBuilderCoverageEndToEndTests.cs index e6236290..3267f76a 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp.Tests/HoverBuilderCoverageEndToEndTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HoverBuilderCoverageEndToEndTests.cs @@ -64,10 +64,8 @@ public async Task Hover_on_tuple_literal_resolves_tuple_type() Assert.NotNull(h.StartLine); } - // Hovering the 'var' keyword of a concrete-typed local. When 'var' surfaces as - // a VarKeyword token this drives BuildVarHover's inferred-signature path; - // otherwise the inferred type is resolved through ResolveSymbol. Either way the - // concrete type name must appear in the hover. + // Hovering the contextual 'var' identifier of a concrete-typed local drives + // BuildVarHover's inferred-signature path. [Fact] public async Task Hover_on_var_keyword_of_named_local_resolves_type() { @@ -75,6 +73,8 @@ public async Task Hover_on_var_keyword_of_named_local_resolves_type() "textDocument/hover", fixture.PosPayload(126, 8) ); + // [HOVER-CSHARP-CASES] `var` must identify the result as an inferred type. + Assert.Contains("(inferred)", h.Contents); Assert.Contains("LegacyCalculator", h.Contents); Assert.NotNull(h.StartLine); } @@ -88,6 +88,7 @@ public async Task Hover_on_var_keyword_of_generic_local_resolves_type() "textDocument/hover", fixture.PosPayload(123, 8) ); + Assert.Contains("(inferred)", h.Contents); Assert.Contains("IEnumerable", h.Contents); Assert.NotNull(h.StartLine); } diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/HoverEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HoverEndToEndTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/HoverEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HoverEndToEndTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/MSBuildInstanceSelectorTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MSBuildInstanceSelectorTests.cs similarity index 83% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/MSBuildInstanceSelectorTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MSBuildInstanceSelectorTests.cs index 2bd33fc0..8f3bb712 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp.Tests/MSBuildInstanceSelectorTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MSBuildInstanceSelectorTests.cs @@ -127,6 +127,30 @@ public void BuildNoSdkHint_is_actionable_and_names_the_sdk_and_install_tool() Assert.Contains("dotnet.microsoft.com", hint, StringComparison.Ordinal); } + [Fact] + public void NewestInstancePath_selects_the_highest_discovered_sdk() + { + // [DIST-SDK-DISCOVERY] The degraded fallback must be deterministic. + var instances = MSBuildLocator.QueryVisualStudioInstances().ToList(); + var expected = instances.MaxBy(instance => instance.Version); + + Assert.NotNull(expected); + Assert.Equal(expected.MSBuildPath, MSBuildInstanceSelector.NewestInstancePath(instances)); + } + + [Fact] + public void BuildDiscoveryFailedHint_explains_degraded_mode_and_the_remedy() + { + // [DIST-SDK-DISCOVERY] Discovery failure remains actionable and non-fatal. + var hint = MSBuildInstanceSelector.BuildDiscoveryFailedHint( + new InvalidOperationException("sdk pin missing") + ); + Assert.Contains("WARNING", hint, StringComparison.Ordinal); + Assert.Contains("solution browsing still works", hint, StringComparison.Ordinal); + Assert.Contains("global.json", hint, StringComparison.Ordinal); + Assert.Contains("sdk pin missing", hint, StringComparison.Ordinal); + } + [Fact] public void WarnNoMatch_reports_the_bundled_roslyn_and_installed_sdks() { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MergeDeclarationAssignmentTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MergeDeclarationAssignmentTests.cs new file mode 100644 index 00000000..2b665121 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MergeDeclarationAssignmentTests.cs @@ -0,0 +1,277 @@ +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.CSharp.Workspace; + +#pragma warning disable CA1307 // StringComparison overloads add no value to xUnit assertions +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable IDE0058 // Setup calls intentionally ignore their return values +#pragma warning disable RS1035 // Real temp-path use is intentional in this coarse E2E + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// End-to-end coverage for "Merge declaration and assignment" +/// ([SHARPLSP-FEATURES-REFACTORING]). +/// +/// The refactoring was never applied by any test, so the whole rewriting half — +/// building the initializer, carrying the assignment operator's trivia across, +/// and deleting the assignment statement — ran zero times. A provider that +/// emitted uncompilable C#, or that offered itself on a declaration it must not +/// touch, would have shipped unnoticed. These tests drive the real +/// WorkspaceManager against a real MSBuild project and assert on the C# it +/// returns, and pin the shapes the provider must refuse. +/// +public sealed class MergeDeclarationAssignmentTests : IDisposable +{ + private const string Title = "Merge declaration and assignment"; + + private const string Source = """ + namespace Merging; + + public class Merger + { + private int _field; + + public int FromDeclaration() + { + int total; + total = 41 + 1; + return total; + } + + public int FromAssignment() + { + int value; + value = 7; + return value; + } + + public int InSwitch(int input) + { + switch (input) + { + case 1: + int inner; + inner = 2; + return inner; + default: + return 0; + } + } + + public int AlreadyInitialized() + { + int seeded = 1; + seeded = 2; + return seeded; + } + + public int TwoVariables() + { + int first, second; + first = 1; + second = 2; + return first + second; + } + + public int CommentBetween() + { + int spaced; + // Keeping this comment matters more than merging. + spaced = 3; + return spaced; + } + + public int TargetsAField() + { + int unrelated; + _field = 4; + unrelated = _field; + return unrelated; + } + + public void TrailingDeclaration() + { + int dangling; + } + } + """; + + private readonly string _root = Path.Combine( + Path.GetTempPath(), + $"sharplsp-merge-{Guid.NewGuid():N}" + ); + + private readonly string _csprojPath; + private readonly string _sourcePath; + + public MergeDeclarationAssignmentTests() + { + Directory.CreateDirectory(_root); + const string csproj = """ + + + net10.0 + Library + enable + + + """; + _csprojPath = Path.Combine(_root, "Merging.csproj"); + _sourcePath = Path.Combine(_root, "Merging.cs"); + File.WriteAllText(_csprojPath, csproj); + File.WriteAllText(_sourcePath, Source); + } + + public void Dispose() + { + try + { + Directory.Delete(_root, true); + } + catch (IOException) { } + } + + [Fact] + public async Task Merging_from_the_declaration_folds_the_assignment_into_the_initializer() + { + using var manager = await OpenAsync(); + var merged = await ApplyAtAsync(manager, " int total;", "int"); + + Assert.Contains("int total = 41 + 1;", merged); + // The standalone assignment statement must be gone, not merely duplicated + // into the initializer — a substring check would pass either way. + Assert.DoesNotContain(Statements(merged), statement => statement == "total = 41 + 1;"); + AssertStillCompiles(merged); + } + + [Fact] + public async Task Merging_from_the_assignment_line_finds_the_declaration_above_it() + { + using var manager = await OpenAsync(); + var merged = await ApplyAtAsync(manager, " value = 7;", "value"); + + Assert.Contains("int value = 7;", merged); + AssertStillCompiles(merged); + } + + [Fact] + public async Task Merging_works_inside_a_switch_section_not_only_a_block() + { + using var manager = await OpenAsync(); + var merged = await ApplyAtAsync(manager, " int inner;", "int"); + + Assert.Contains("int inner = 2;", merged); + AssertStillCompiles(merged); + } + + [Theory] + // An initialized declaration has nothing to fold the assignment into. + [InlineData(" int seeded = 1;", "int")] + // Two declarators would have to be split before either could be merged. + [InlineData(" int first, second;", "int")] + // Merging past a comment would silently relocate or drop it. + [InlineData(" int spaced;", "int")] + // The following assignment writes a field, not the declared local. + [InlineData(" int unrelated;", "int")] + // Nothing follows the declaration at all. + [InlineData(" int dangling;", "int")] + public async Task The_refactoring_is_refused_where_merging_would_change_behaviour( + string anchor, + string token + ) + { + using var manager = await OpenAsync(); + var actions = await ActionsAtAsync(manager, anchor, token); + + Assert.DoesNotContain(actions, action => action.Title == Title); + } + + /// Apply the merge at and return the new file text. + private async Task ApplyAtAsync(WorkspaceManager manager, string anchor, string token) + { + var actions = await ActionsAtAsync(manager, anchor, token); + var action = actions.Find(item => item.Title == Title); + Assert.NotNull(action); + + var edit = Unwrap(await manager.ResolveCodeActionAsync(action.Id)); + var document = Assert.Single(edit.DocumentChanges); + Assert.Equal(_sourcePath, document.FilePath); + Assert.NotEmpty(document.Edits); + return ApplyEdits(Source, document.Edits); + } + + private async Task> ActionsAtAsync( + WorkspaceManager manager, + string anchor, + string token + ) + { + var (line, character) = Locate(anchor, token); + return Unwrap( + await manager.GetCodeActionsAsync(_sourcePath, line, character, line, character) + ); + } + + /// Every line of trimmed, for whole-statement assertions. + private static IEnumerable Statements(string text) + { + return text.Split('\n').Select(line => line.Trim()); + } + + /// The merged file must still parse; a malformed rewrite is the failure hunted here. + private static void AssertStillCompiles(string merged) + { + var errors = Microsoft + .CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText(merged) + .GetDiagnostics() + .Where(diagnostic => + diagnostic.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error + ) + .Select(diagnostic => diagnostic.ToString()) + .ToList(); + Assert.Empty(errors); + } + + /// Zero-based line/character of on the anchor line. + private static (int Line, int Character) Locate(string anchor, string token) + { + var lines = Source.Split('\n').Select(line => line.TrimEnd('\r')).ToArray(); + var index = Array.FindIndex(lines, value => value == anchor); + Assert.True(index >= 0, $"anchor line not found verbatim: <{anchor}>"); + var character = lines[index].IndexOf(token, StringComparison.Ordinal); + Assert.True(character >= 0, $"token not found on anchor line: {token}"); + return (index, character); + } + + private async Task OpenAsync() + { + var manager = new WorkspaceManager(); +#pragma warning disable CS0618 // Exercise the real solution-loading boundary + var opened = await manager.OpenAsync(_csprojPath); +#pragma warning restore CS0618 + Assert.False(opened.IsError, opened.Match(_ => "ok", error => error)); + Assert.True(manager.IsLoaded, "workspace must be loaded for code actions"); + return manager; + } + + private static T Unwrap(Outcome.Result result) + { + Assert.False(result.IsError, result.Match(_ => "ok", error => error)); + return +result; + } + + private static string ApplyEdits(string source, IEnumerable edits) + { + var text = SourceText.From(source); + var changes = edits.Select(edit => new TextChange(EditSpan(text, edit), edit.NewText)); + return text.WithChanges(changes).ToString(); + } + + private static TextSpan EditSpan(SourceText text, TextEditResult edit) + { + var start = text.Lines.GetPosition(new LinePosition(edit.StartLine, edit.StartCharacter)); + var end = text.Lines.GetPosition(new LinePosition(edit.EndLine, edit.EndCharacter)); + return TextSpan.FromBounds(start, end); + } +} diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/MetaProbeEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MetaProbeEndToEndTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/MetaProbeEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MetaProbeEndToEndTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/MetadataNavigatorCoverageEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MetadataNavigatorCoverageEndToEndTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/MetadataNavigatorCoverageEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MetadataNavigatorCoverageEndToEndTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/MsBuildRegistration.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MsBuildRegistration.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/MsBuildRegistration.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MsBuildRegistration.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/NavigationEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/NavigationEndToEndTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/NavigationEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/NavigationEndToEndTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/NavigationSweepEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/NavigationSweepEndToEndTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/NavigationSweepEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/NavigationSweepEndToEndTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/PackageEditorEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/PackageEditorEndToEndTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/PackageEditorEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/PackageEditorEndToEndTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/RefactorEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RefactorEndToEndTests.cs similarity index 80% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/RefactorEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RefactorEndToEndTests.cs index 9844d77b..cf28b067 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp.Tests/RefactorEndToEndTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RefactorEndToEndTests.cs @@ -35,14 +35,18 @@ public async Task PrepareRename_on_method_allows_rename() } [Fact] - public async Task PrepareRename_on_namespace_disallows_rename() + public async Task PrepareRename_on_namespace_reports_exact_identifier() { - // The namespace token cannot be renamed (symbol is INamespaceSymbol). var result = await fixture.SendAndDeserializeAsync( "textDocument/prepareRename", fixture.PosPayload(0, 12) ); - Assert.False(result.CanRename); + Assert.True(result.CanRename); + Assert.Equal("TestProject", result.Placeholder); + Assert.Equal(0, result.StartLine); + Assert.Equal(10, result.StartCharacter); + Assert.Equal(0, result.EndLine); + Assert.Equal(21, result.EndCharacter); } [Fact] @@ -57,6 +61,17 @@ public async Task PrepareRename_on_field_allows_rename() Assert.Equal("FieldCount", result.Placeholder); } + [Fact] + public async Task PrepareRename_on_string_literal_is_rejected() + { + // [RENAME-ERRORS] Non-identifiers are a successful negative result. + var result = await fixture.SendAndDeserializeAsync( + "textDocument/prepareRename", + fixture.PosPayload(124, 23) + ); + Assert.False(result.CanRename); + } + [Fact] public async Task Rename_method_produces_edits_across_declaration_and_call() { @@ -111,6 +126,37 @@ public async Task Rename_on_string_literal_returns_empty_edit() Assert.Empty(edit.DocumentChanges); } + [Theory] + [InlineData(3, 13, "Calculator")] + [InlineData(3, 13, " ")] + [InlineData(3, 13, "bad-name")] + [InlineData(3, 13, "Program")] + [InlineData(0, 12, "MetaProbe")] + public async Task Rename_rejects_invalid_unchanged_and_conflicting_names( + int line, + int character, + string newName + ) + { + // [RENAME-ERRORS] Rejected names never leak partial Roslyn edits. + var edit = await fixture.SendAndDeserializeAsync( + "textDocument/rename", + RenameRequestAt(line, character, newName) + ); + Assert.Empty(edit.DocumentChanges); + } + + private RenameRequest RenameRequestAt(int line, int character, string newName) + { + return new RenameRequest + { + FilePath = fixture.SourceFile, + Line = line, + Character = character, + NewName = newName, + }; + } + [Fact] public async Task CodeAction_on_unused_local_offers_quickfix_resolvable_to_edit() { diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/SemanticTokensResolverTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SemanticTokensResolverTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/SemanticTokensResolverTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SemanticTokensResolverTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/SerializationTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SerializationTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/SerializationTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SerializationTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/SharpLsp.Sidecar.CSharp.Tests.csproj b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SharpLsp.Sidecar.CSharp.Tests.csproj similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/SharpLsp.Sidecar.CSharp.Tests.csproj rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SharpLsp.Sidecar.CSharp.Tests.csproj diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/SidecarBadPositionResilienceEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SidecarBadPositionResilienceEndToEndTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/SidecarBadPositionResilienceEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SidecarBadPositionResilienceEndToEndTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/SidecarEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SidecarEndToEndTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/SidecarEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SidecarEndToEndTests.cs diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SidecarExtrasCoverageEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SidecarExtrasCoverageEndToEndTests.cs new file mode 100644 index 00000000..0b28fa2d --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SidecarExtrasCoverageEndToEndTests.cs @@ -0,0 +1,91 @@ +using MessagePack; + +#pragma warning disable CA1307 // StringComparison for Assert.Contains +#pragma warning disable CA1515 // Types can be internal +#pragma warning disable IDE0058 // Expression value is never used + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// Coarse E2E tests for handler paths the broad suites leave uncovered, driven +/// through the real sidecar socket via . +/// Covers successful analyzer configuration and the cross-sidecar rename +/// protocol, including malformed requests that must not poison the socket. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2007:Consider calling ConfigureAwait on the awaited task", + Justification = "xUnit test methods run on the synchronization-context-free test pool" +)] +public sealed class SidecarExtrasCoverageEndToEndTests(CSharpSidecarFixture fixture) + : IClassFixture +{ + [Theory] + [InlineData(true, true)] + [InlineData(false, false)] + [InlineData(true, false)] + public async Task ConfigureAnalyzers_with_valid_request_acknowledges( + bool deadCode, + bool monorepo + ) + { + var response = await fixture.SendAsync( + "analyzers/configure", + MessagePackSerializer.Serialize( + new AnalyzerConfigRequest { DeadCode = deadCode, Monorepo = monorepo } + ) + ); + + Assert.Null(response.Error); + Assert.Equal("ok", MessagePackSerializer.Deserialize(response.Payload)); + + // The sidecar stays healthy after reconfiguring analyzers. + var ping = await fixture.SendAsync("ping", []); + Assert.Null(ping.Error); + Assert.Equal("pong", MessagePackSerializer.Deserialize(ping.Payload)); + } + + [Fact] + public async Task Rename_identity_returns_the_source_symbol_wire_identity() + { + // [RENAME-PREPARE] The real socket boundary preserves the Roslyn identity. + var identity = await fixture.SendAndDeserializeAsync( + "textDocument/renameIdentity", + fixture.PosPayload(3, 13) + ); + Assert.True(identity.Found); + Assert.Equal("TestProject", identity.AssemblyName); + Assert.Equal("T:TestProject.Calculator", identity.XmlDocSig); + } + + [Fact] + public async Task Foreign_rename_without_a_matching_assembly_returns_an_empty_edit() + { + // [RENAME-APPLY] A valid cross-sidecar request remains a successful no-op. + var edit = await fixture.SendAndDeserializeAsync( + "workspace/renameForeign", + new RenameForeignRequest + { + AssemblyName = "ForeignAssembly", + XmlDocSig = "T:Foreign.Widget", + NewName = "RenamedWidget", + } + ); + Assert.Empty(edit.DocumentChanges); + } + + [Theory] + [InlineData("textDocument/renameIdentity")] + [InlineData("workspace/renameForeign")] + public async Task Foreign_rename_handlers_reject_malformed_payload_and_stay_healthy( + string method + ) + { + var response = await fixture.SendAsync(method, [0xC1]); + Assert.NotNull(response.Error); + + var ping = await fixture.SendAsync("ping", []); + Assert.Null(ping.Error); + Assert.Equal("pong", MessagePackSerializer.Deserialize(ping.Payload)); + } +} diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/SolutionAndShutdownEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SolutionAndShutdownEndToEndTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/SolutionAndShutdownEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SolutionAndShutdownEndToEndTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/SolutionLoaderTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SolutionLoaderTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/SolutionLoaderTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SolutionLoaderTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerCrossLanguageCoverageTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerCrossLanguageCoverageTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerCrossLanguageCoverageTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerCrossLanguageCoverageTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerDegenerateCoverageTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerDegenerateCoverageTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerDegenerateCoverageTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerDegenerateCoverageTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerExtraCoverageTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerExtraCoverageTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerExtraCoverageTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerExtraCoverageTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerFeatureCoverageTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerFeatureCoverageTests.cs similarity index 89% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerFeatureCoverageTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerFeatureCoverageTests.cs index 720b3595..dfc53311 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerFeatureCoverageTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerFeatureCoverageTests.cs @@ -1,3 +1,4 @@ +using Microsoft.CodeAnalysis.Text; using SharpLsp.Sidecar.CSharp.Workspace; #pragma warning disable CA1307 // StringComparison for Assert.Contains @@ -107,6 +108,26 @@ private static T Unwrap(Outcome.Result result) return result.Match(value => value, _ => throw new InvalidOperationException("error")); } + private static string ApplyEdits(string source, IEnumerable edits) + { + var text = SourceText.From(source); + var changes = edits.Select(edit => new TextChange(EditSpan(text, edit), edit.NewText)); + return text.WithChanges(changes).ToString(); + } + + private static TextSpan EditSpan(SourceText text, TextEditResult edit) + { + var start = text.Lines.GetPosition(new LinePosition(edit.StartLine, edit.StartCharacter)); + var end = text.Lines.GetPosition(new LinePosition(edit.EndLine, edit.EndCharacter)); + return TextSpan.FromBounds(start, end); + } + + private static string ReplacedText(string source, TextEditResult edit) + { + var text = SourceText.From(source); + return text.ToString(EditSpan(text, edit)); + } + [Fact] public async Task PrepareRename_on_field_reports_renamable_with_placeholder() { @@ -123,7 +144,7 @@ public async Task PrepareRename_on_field_reports_renamable_with_placeholder() } [Fact] - public async Task PrepareRename_on_namespace_cannot_rename() + public async Task PrepareRename_on_namespace_reports_exact_source_identifier() { using var manager = await OpenAsync(); @@ -131,7 +152,12 @@ public async Task PrepareRename_on_namespace_cannot_rename() var result = await manager.PrepareRenameAsync(_sourcePath, 0, 10); var prepare = Unwrap(result); - Assert.False(prepare.CanRename, "namespaces are excluded from rename"); + Assert.True(prepare.CanRename, "source namespaces are renameable"); + Assert.Equal("S", prepare.Placeholder); + Assert.Equal(0, prepare.StartLine); + Assert.Equal(10, prepare.StartCharacter); + Assert.Equal(0, prepare.EndLine); + Assert.Equal(11, prepare.EndCharacter); } [Fact] @@ -158,12 +184,13 @@ public async Task Rename_field_rewrites_declaration_and_usages() Assert.NotEmpty(edit.DocumentChanges); var doc = Assert.Single(edit.DocumentChanges); Assert.Equal(_sourcePath, doc.FilePath); - Assert.NotEmpty(doc.Edits); - // The combined replacement text must use the new name (declaration + - // both usages) and must no longer contain the original field name. - var combined = string.Concat(doc.Edits.Select(e => e.NewText)); - Assert.Contains("_count", combined); - Assert.DoesNotContain("_counter", combined); + Assert.Equal(3, doc.Edits.Count); + Assert.All(doc.Edits, edit => Assert.Equal("_counter", ReplacedText(Source, edit))); + Assert.All(doc.Edits, edit => Assert.Equal("_count", edit.NewText)); + var rewritten = ApplyEdits(Source, doc.Edits); + Assert.Equal(Source.Replace("_counter", "_count"), rewritten); + Assert.DoesNotContain("_counter", rewritten); + Assert.Equal(3, rewritten.Split("_count").Length - 1); } [Fact] @@ -175,14 +202,15 @@ public async Task Rename_method_propagates_to_call_site() var result = await manager.RenameAsync(_sourcePath, 13, 24, "Evaluate"); var edit = Unwrap(result); - Assert.NotEmpty(edit.DocumentChanges); - var allEdits = edit.DocumentChanges.SelectMany(c => c.Edits).ToList(); - Assert.NotEmpty(allEdits); - var combined = string.Concat(allEdits.Select(e => e.NewText)); - // The override declaration and the `Compute(input)` call site both adopt - // the new name; the old identifier disappears entirely. - Assert.Contains("Evaluate(input)", combined); - Assert.DoesNotContain("Compute", combined); + var document = Assert.Single(edit.DocumentChanges); + Assert.Equal(_sourcePath, document.FilePath); + Assert.Equal(3, document.Edits.Count); + Assert.All(document.Edits, edit => Assert.Equal("Compute", ReplacedText(Source, edit))); + Assert.All(document.Edits, edit => Assert.Equal("Evaluate", edit.NewText)); + var rewritten = ApplyEdits(Source, document.Edits); + Assert.Equal(Source.Replace("Compute", "Evaluate"), rewritten); + Assert.DoesNotContain("Compute", rewritten); + Assert.Equal(3, rewritten.Split("Evaluate").Length - 1); } [Fact] @@ -247,7 +275,7 @@ public async Task ResolveCodeAction_then_apply_or_unknown() [Fact] public async Task Completion_supplies_text_edit_that_replaces_identifier_at_caret() { - // GitHub #178 / [COMPLETION-EDIT-REPLACE]: the caret sits at the START of + // GitHub #178 / [SHARPLSP-FEATURES-INTELLIGENCE-COMPLETION-EDIT]: the caret sits at the START of // `Compute` in `var result = Compute(input);` (line 18, col 21). The item's // textEdit must span the whole identifier so acceptance REPLACES it rather // than appending (which would yield `ComputeCompute`). diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerForeignRenameTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerForeignRenameTests.cs new file mode 100644 index 00000000..45bcaf76 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerForeignRenameTests.cs @@ -0,0 +1,478 @@ +using System.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.CSharp.Workspace; + +#pragma warning disable CA1307 // StringComparison overloads add no value to xUnit assertions +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable IDE0058 // Setup calls intentionally ignore their return values +#pragma warning disable RS1035 // Real-process/temp-path use is intentional in this coarse E2E + +namespace SharpLsp.Sidecar.CSharp.Tests; + +public sealed class WorkspaceManagerForeignRenameTests : IClassFixture +{ + private readonly ForeignRenameFixture _fixture; + + public WorkspaceManagerForeignRenameTests(ForeignRenameFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Real_mixed_workspace_exposes_CSharp_identity_and_rejects_keyword_position() + { + using var manager = await _fixture.OpenManagerAsync(); + var (line, character) = _fixture.Locate("public sealed class CSharpOrigin", "CSharpOrigin"); + var identity = AssertOk( + await manager.GetRenameIdentityAsync(_fixture.SourcePath, line, character) + ); + Assert.True(identity.Found); + Assert.Equal("App", identity.AssemblyName); + Assert.Equal("T:App.CSharpOrigin", identity.XmlDocSig); + + var absent = AssertOk(await manager.GetRenameIdentityAsync(_fixture.SourcePath, line, 0)); + Assert.False(absent.Found); + Assert.Equal("", absent.AssemblyName); + Assert.Equal("", absent.XmlDocSig); + } + + [Fact] + public async Task Real_FSharp_metadata_symbol_produces_every_granular_CSharp_reference_edit() + { + using var manager = await _fixture.OpenManagerAsync(); + var result = AssertOk( + await manager.RenameForeignAsync("Lib", "T:FsLib.Widget", "RenamedWidget") + ); + var document = Assert.Single(result.DocumentChanges); + AssertForwardDocument(document); + await AssertRejectedForeignRenameAsync(manager, "Wrong", "T:FsLib.Widget", "Nope"); + await AssertRejectedForeignRenameAsync(manager, "Lib", "T:FsLib.Widget", "bad-name"); + } + + [Fact] + public async Task Repository_mixed_solution_resolves_the_exact_FSharp_identity_into_CSharp() + { + var workspace = FindRepositoryFixture(); + await EnsureFSharpFixtureBuiltAsync(workspace); + var sourcePath = Path.Combine(workspace, "crosslanguage", "FSharpConsumer.cs"); + using var manager = await OpenRepositoryManagerAsync(workspace); + var (line, character) = LocateFile(sourcePath, "Read(FSharpOrigin", "FSharpOrigin"); + Assert.NotEmpty( + AssertOk(await manager.GetDefinitionAsync(sourcePath, line, character)).Locations + ); + var edit = AssertOk( + await manager.RenameForeignAsync( + "FSharpFixtures", + "T:FSharpFixtures.CrossLanguage.FSharpOrigin", + "RenamedFSharpOrigin" + ) + ); + Assert.Equal(sourcePath, Assert.Single(edit.DocumentChanges).FilePath); + } + + [Fact] + public async Task Request_local_projection_reverses_a_fresh_unsaved_overlay_exactly() + { + var (baseline, forward, renamed) = await CreateForwardOverlayAsync(); + using var reverseManager = await _fixture.OpenManagerAsync(); + _ = AssertOk(await reverseManager.UpdateDocumentTextAsync(_fixture.SourcePath, renamed)); + var reverse = await RenameWidgetAsync(reverseManager, "T:FsLib.RenamedWidget", "Widget"); + AssertEditsReplace(renamed, reverse.Edits, "RenamedWidget", "Widget", 4); + Assert.Equal(baseline, ApplyEdits(renamed, reverse.Edits)); + AssertProjectedCoordinates(forward.Edits, reverse.Edits); + } + + private static void AssertProjectedCoordinates( + List forward, + List reverse + ) + { + Assert.Equal( + forward.Select(edit => edit.StartLine), + reverse.Select(edit => edit.StartLine) + ); + Assert.Equal(forward[0].StartCharacter, reverse[0].StartCharacter); + Assert.Equal(forward[1].StartCharacter, reverse[1].StartCharacter); + Assert.Equal(forward[2].StartCharacter, reverse[2].StartCharacter); + Assert.Equal(forward[3].StartCharacter + 7, reverse[3].StartCharacter); + } + + [Fact] + public async Task Request_local_projection_rejects_unbound_same_name_candidates() + { + var (_, _, renamed) = await CreateForwardOverlayAsync(); + using var manager = await _fixture.OpenManagerAsync(); + var stale = TamperForeignReferences(renamed); + Assert.Equal(3, CountOccurrences(stale, "RenamedWidget")); + Assert.Equal(4, CountOccurrences(stale, "TamperedWidget")); + _ = AssertOk(await manager.UpdateDocumentTextAsync(_fixture.SourcePath, stale)); + + var result = AssertOk( + await manager.RenameForeignAsync("Lib", "T:FsLib.RenamedWidget", "Widget") + ); + Assert.Empty(result.DocumentChanges); + Assert.Contains("public sealed class RenamedWidget", stale); + Assert.Contains("new RenamedWidget()", stale); + Assert.Contains("nameof(RenamedWidget)", stale); + } + + private static string TamperForeignReferences(string renamed) + { + return renamed.Replace( + "FsLib.RenamedWidget", + "FsLib.TamperedWidget", + StringComparison.Ordinal + ); + } + + private async Task<( + string Baseline, + DocumentEditResult Forward, + string Renamed + )> CreateForwardOverlayAsync() + { + var baseline = await File.ReadAllTextAsync(_fixture.SourcePath); + using var manager = await _fixture.OpenManagerAsync(); + var forward = await RenameWidgetAsync(manager, "T:FsLib.Widget", "RenamedWidget"); + AssertEditsReplace(baseline, forward.Edits, "Widget", "RenamedWidget", 4); + var renamed = ApplyEdits(baseline, forward.Edits); + Assert.NotEqual(baseline, renamed); + Assert.Equal(7, CountOccurrences(renamed, "RenamedWidget")); + return (baseline, forward, renamed); + } + + private void AssertForwardDocument(DocumentEditResult document) + { + Assert.Equal(_fixture.SourcePath, document.FilePath); + Assert.Equal(4, document.Edits.Count); + Assert.All(document.Edits, edit => Assert.Equal("RenamedWidget", edit.NewText)); + Assert.All(document.Edits, _fixture.AssertReplacesWidget); + Assert.Equal(4, document.Edits.Select(EditKey).Distinct().Count()); + } + + private static async Task AssertRejectedForeignRenameAsync( + WorkspaceManager manager, + string assemblyName, + string xmlDocSig, + string newName + ) + { + var result = AssertOk(await manager.RenameForeignAsync(assemblyName, xmlDocSig, newName)); + Assert.Empty(result.DocumentChanges); + } + + private static async Task OpenRepositoryManagerAsync(string workspace) + { + var manager = new WorkspaceManager(); +#pragma warning disable CS0618 // Exercise the real solution-loading boundary + var opened = await manager.OpenAsync(Path.Combine(workspace, "TestFixtures.slnx")); +#pragma warning restore CS0618 + Assert.False(opened.IsError, opened.Match(_ => "ok", error => error)); + Assert.True(manager.IsLoaded); + return manager; + } + + private static async Task RenameWidgetAsync( + WorkspaceManager manager, + string xmlDocSig, + string newName + ) + { + var result = AssertOk(await manager.RenameForeignAsync("Lib", xmlDocSig, newName)); + return Assert.Single(result.DocumentChanges); + } + + private static void AssertEditsReplace( + string source, + List edits, + string oldName, + string newName, + int count + ) + { + Assert.Equal(count, edits.Count); + Assert.All(edits, edit => Assert.Equal(newName, edit.NewText)); + Assert.All(edits, edit => Assert.Equal(oldName, TextAtEdit(source, edit))); + Assert.Equal(count, edits.Select(EditKey).Distinct().Count()); + } + + private static string ApplyEdits(string source, IEnumerable edits) + { + var sourceText = SourceText.From(source); + foreach (var edit in edits.OrderByDescending(item => EditSpan(sourceText, item).Start)) + { + var span = EditSpan(sourceText, edit); + source = source.Remove(span.Start, span.Length).Insert(span.Start, edit.NewText); + } + return source; + } + + private static string TextAtEdit(string source, TextEditResult edit) + { + var sourceText = SourceText.From(source); + return sourceText.ToString(EditSpan(sourceText, edit)); + } + + private static int CountOccurrences(string source, string value) + { + return source.Split(value, StringSplitOptions.None).Length - 1; + } + + private static TextSpan EditSpan(SourceText source, TextEditResult edit) + { + var start = source.Lines.GetPosition(new LinePosition(edit.StartLine, edit.StartCharacter)); + var end = source.Lines.GetPosition(new LinePosition(edit.EndLine, edit.EndCharacter)); + return TextSpan.FromBounds(start, end); + } + + private static string EditKey(TextEditResult edit) + { + return $"{edit.StartLine}:{edit.StartCharacter}-{edit.EndLine}:{edit.EndCharacter}"; + } + + private static TValue AssertOk(Outcome.Result result) + { + Assert.False(result.IsError, result.Match(_ => "ok", error => error)); + return +result; + } + + private static readonly SemaphoreSlim FixtureBuildGate = new(1, 1); + private static bool _fSharpFixtureBuilt; + + /// + /// Build the F# fixture assembly that the mixed-language solution binds against. + /// + /// Roslyn's MSBuildWorkspace cannot load an .fsproj, so + /// CSharpConsumer's project reference degrades to a metadata reference + /// resolved from the F# project's build output on disk. With no assembly there + /// FSharpOrigin never binds, the foreign rename matches nothing, and the + /// document-change assertion fails — but only on a machine that has not already + /// built the fixture, which is every clean checkout, CI included. Building it + /// here makes the test independent of ambient build state. + /// + /// The configuration is pinned to Debug rather than inherited: MSBuildWorkspace + /// opens the solution under MSBuild's default configuration, so Debug is where + /// it looks for the reference no matter how this test assembly was built. + /// + private static async Task EnsureFSharpFixtureBuiltAsync(string workspace) + { + await FixtureBuildGate.WaitAsync().ConfigureAwait(false); + try + { + if (_fSharpFixtureBuilt) + { + return; + } + + var project = Path.Combine(workspace, "fsharp", "FSharpFixtures.fsproj"); + var (exitCode, output) = await RunDotnetBuildAsync(project).ConfigureAwait(false); + Assert.True(exitCode == 0, $"F# fixture build failed ({exitCode}):{output}"); + _fSharpFixtureBuilt = true; + } + finally + { + FixtureBuildGate.Release(); + } + } + + /// Build one project, returning its exit code and merged output. + private static async Task<(int ExitCode, string Output)> RunDotnetBuildAsync(string project) + { + var startInfo = new ProcessStartInfo("dotnet") + { + ArgumentList = { "build", project, "--configuration", "Debug", "--nologo" }, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + using var build = Process.Start(startInfo); + Assert.NotNull(build); + // Drain both pipes concurrently: reading them in sequence deadlocks as soon + // as the un-read pipe fills its buffer. + var stdout = build.StandardOutput.ReadToEndAsync(); + var stderr = build.StandardError.ReadToEndAsync(); + await build.WaitForExitAsync().ConfigureAwait(false); + var text = await stdout.ConfigureAwait(false) + await stderr.ConfigureAwait(false); + return (build.ExitCode, text); + } + + private static string FindRepositoryFixture() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = RepositoryFixtureCandidate(directory.FullName); + if (File.Exists(Path.Combine(candidate, "TestFixtures.slnx"))) + { + return candidate; + } + directory = directory.Parent; + } + throw new DirectoryNotFoundException("SharpLsp repository fixture was not found"); + } + + private static string RepositoryFixtureCandidate(string directory) + { + return Path.Combine(directory, "src", "editors", "vscode", "test-fixtures", "workspace"); + } + + private static (int Line, int Character) LocateFile(string path, string anchor, string token) + { + var lines = File.ReadAllLines(path); + var line = Array.FindIndex( + lines, + value => value.Contains(anchor, StringComparison.Ordinal) + ); + Assert.True(line >= 0, $"missing line anchor {anchor}"); + var character = lines[line].IndexOf(token, StringComparison.Ordinal); + Assert.True(character >= 0, $"missing token {token}"); + return (line, character); + } +} + +public sealed class ForeignRenameFixture : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), + $"sharplsp-foreign-rename-{Guid.NewGuid():N}" + ); + + public ForeignRenameFixture() + { + var libDirectory = Path.Combine(_root, "Lib"); + var appDirectory = Path.Combine(_root, "App"); + Directory.CreateDirectory(libDirectory); + Directory.CreateDirectory(appDirectory); + WriteFSharpLibrary(libDirectory); + ProjectPath = WriteCSharpConsumer(appDirectory, libDirectory); + SourcePath = Path.Combine(appDirectory, "Program.cs"); + WriteConsumerSource(SourcePath); + BuildProject(ProjectPath); + } + + public string ProjectPath { get; } + + public string SourcePath { get; } + + internal async Task OpenManagerAsync() + { + var manager = new WorkspaceManager(); +#pragma warning disable CS0618 // OpenAsync remains the real workspace-loading boundary + var open = await manager.OpenAsync(ProjectPath); +#pragma warning restore CS0618 + Assert.False(open.IsError, open.Match(_ => "ok", error => error)); + Assert.True(manager.IsLoaded); + return manager; + } + + public (int Line, int Character) Locate(string lineAnchor, string token) + { + var lines = File.ReadAllLines(SourcePath); + var line = Array.FindIndex( + lines, + value => value.Contains(lineAnchor, StringComparison.Ordinal) + ); + Assert.True(line >= 0, $"missing line anchor {lineAnchor}"); + var character = lines[line].IndexOf(token, StringComparison.Ordinal); + Assert.True(character >= 0, $"missing token {token}"); + return (line, character); + } + + internal void AssertReplacesWidget(TextEditResult edit) + { + Assert.Equal(edit.StartLine, edit.EndLine); + Assert.Equal("Widget".Length, edit.EndCharacter - edit.StartCharacter); + var line = File.ReadAllLines(SourcePath)[edit.StartLine]; + Assert.Equal("Widget", line.Substring(edit.StartCharacter, "Widget".Length)); + } + + public void Dispose() + { + try + { + Directory.Delete(_root, true); + } + catch (IOException) { } + } + + private static void WriteFSharpLibrary(string directory) + { + File.WriteAllText(Path.Combine(directory, "Lib.fsproj"), FSharpProject); + File.WriteAllText( + Path.Combine(directory, "Library.fs"), + "namespace FsLib\n\ntype Widget() =\n member _.Value = 42\n" + ); + } + + private static string WriteCSharpConsumer(string directory, string libDirectory) + { + var projectPath = Path.Combine(directory, "App.csproj"); + var relativeReference = Path.GetRelativePath( + directory, + Path.Combine(libDirectory, "Lib.fsproj") + ); + File.WriteAllText(projectPath, CSharpProject.Replace("$REFERENCE$", relativeReference)); + return projectPath; + } + + private static void WriteConsumerSource(string path) + { + File.WriteAllText(path, ConsumerSource); + } + + private static void BuildProject(string projectPath) + { + var startInfo = new ProcessStartInfo("dotnet", $"build \"{projectPath}\" --nologo -v quiet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + using var process = + Process.Start(startInfo) ?? throw new InvalidOperationException("dotnet did not start"); + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + Assert.True(process.ExitCode == 0, $"real mixed build failed:\n{stdout}\n{stderr}"); + } + + private const string FSharpProject = """ + + net10.0 + + + """; + + private const string CSharpProject = """ + + net10.0 + + + """; + + private const string ConsumerSource = """ + namespace App; + + public sealed class CSharpOrigin { } + + public sealed class RenamedWidget + { + public int Value => 99; + } + + public static class Program + { + public static string Use() + { + var first = new FsLib.Widget(); + FsLib.Widget second = first; + return nameof(FsLib.Widget) + typeof(FsLib.Widget).Name + second.Value; + } + + public static int UseUnrelated() + { + var unrelated = new RenamedWidget(); + return unrelated.Value + nameof(RenamedWidget).Length; + } + } + """; +} diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerPackagesCoverageTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerPackagesCoverageTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerPackagesCoverageTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerPackagesCoverageTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerQueryCoverageTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerQueryCoverageTests.cs similarity index 92% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerQueryCoverageTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerQueryCoverageTests.cs index 2f7a7be9..f2ec47d4 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerQueryCoverageTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerQueryCoverageTests.cs @@ -1,3 +1,4 @@ +using Microsoft.CodeAnalysis.Text; using SharpLsp.Sidecar.CSharp.Workspace; #pragma warning disable CA1307 // StringComparison for Assert.Contains @@ -50,6 +51,26 @@ private static TValue AssertOk(Outcome.Result result) return +result; } + private static string ApplyEdits(string source, IEnumerable edits) + { + var text = SourceText.From(source); + var changes = edits.Select(edit => new TextChange(EditSpan(text, edit), edit.NewText)); + return text.WithChanges(changes).ToString(); + } + + private static TextSpan EditSpan(SourceText text, TextEditResult edit) + { + var start = text.Lines.GetPosition(new LinePosition(edit.StartLine, edit.StartCharacter)); + var end = text.Lines.GetPosition(new LinePosition(edit.EndLine, edit.EndCharacter)); + return TextSpan.FromBounds(start, end); + } + + private static string ReplacedText(string source, TextEditResult edit) + { + var text = SourceText.From(source); + return text.ToString(EditSpan(text, edit)); + } + // ── Hover ──────────────────────────────────────────────────────── [Fact] @@ -216,14 +237,17 @@ public async Task RenameMethodProducesEditsAcrossDeclarationAndCall() // Rename the `Add` declaration on line 12; the call on line 29 must // also be rewritten. var edit = AssertOk(await Manager.RenameAsync(SourcePath, 12, 24, "Renamed")); - Assert.NotEmpty(edit.DocumentChanges); - - var allEdits = edit.DocumentChanges.SelectMany(change => change.Edits).ToList(); - Assert.NotEmpty(allEdits); - // Depending on the SourceText subtype Roslyn may emit either granular - // token edits or a single whole-document replacement; in both cases the - // new name must appear in the produced text. - Assert.Contains(allEdits, textEdit => textEdit.NewText.Contains("Renamed")); + var document = Assert.Single(edit.DocumentChanges); + Assert.Equal(SourcePath, document.FilePath); + Assert.Equal(5, document.Edits.Count); + Assert.All( + document.Edits, + edit => Assert.Equal("Add", ReplacedText(WorkspaceManagerQueryFixture.Source, edit)) + ); + Assert.All(document.Edits, edit => Assert.Equal("Renamed", edit.NewText)); + var rewritten = ApplyEdits(WorkspaceManagerQueryFixture.Source, document.Edits); + Assert.Equal(WorkspaceManagerQueryFixture.Source.Replace("Add", "Renamed"), rewritten); + Assert.Equal(5, rewritten.Split("Renamed").Length - 1); } // ── Code Lens ──────────────────────────────────────────────────── diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerRenameShadowingTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerRenameShadowingTests.cs new file mode 100644 index 00000000..6374600c --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerRenameShadowingTests.cs @@ -0,0 +1,119 @@ +using SharpLsp.Sidecar.CSharp.Workspace; + +#pragma warning disable CA1307 // StringComparison for Assert.DoesNotContain +#pragma warning disable CA1515 // Types can be internal +#pragma warning disable IDE0058 // Expression value is never used +#pragma warning disable RS1035 // Path.GetTempPath banned for analyzers — we're tests + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// Renaming a local, parameter or type parameter to a name that also exists as a +/// member of the enclosing type is legal C# — the local simply shadows the member. +/// The declaration-conflict gate must not treat that as a conflict, because an +/// empty WorkspaceEdit is mapped to LSP null by the host, which makes +/// the rename silently do nothing in the editor. +/// +public sealed class WorkspaceManagerRenameShadowingTests : IDisposable +{ + // `_counter` is a field; `seed` is a parameter; `total` is a local. + // Renaming `seed`/`total` onto `_counter` shadows the field — legal. + private const string Source = + "namespace S;\n" + + "\n" + + "public class Shadow\n" + + "{\n" + + " private int _counter;\n" + + "\n" + + " public int Compute(int seed)\n" + + " {\n" + + " var total = seed + 1;\n" + + " return total;\n" + + " }\n" + + "}\n"; + + private readonly string _root = Path.Combine( + Path.GetTempPath(), + $"sharplsp-rename-shadow-{Guid.NewGuid():N}" + ); + + private readonly string _csprojPath; + private readonly string _sourcePath; + + public WorkspaceManagerRenameShadowingTests() + { + Directory.CreateDirectory(_root); + const string csproj = """ + + + net10.0 + Library + + + """; + _csprojPath = Path.Combine(_root, "Shadow.csproj"); + _sourcePath = Path.Combine(_root, "Shadow.cs"); + File.WriteAllText(_csprojPath, csproj); + File.WriteAllText(_sourcePath, Source); + } + + public void Dispose() + { + try + { + Directory.Delete(_root, true); + } + catch (IOException) { } + } + + private async Task OpenAsync() + { + var manager = new WorkspaceManager(); +#pragma warning disable CS0618 // Obsolete OpenAsync placeholder + var openResult = await manager.OpenAsync(_csprojPath).ConfigureAwait(true); +#pragma warning restore CS0618 + Assert.False(openResult.IsError, openResult.Match(_ => "ok", err => err)); + return manager; + } + + private static T Unwrap(Outcome.Result result) + { + Assert.False(result.IsError, result.Match(_ => "ok", err => err)); + return result.Match(value => value, _ => throw new InvalidOperationException("error")); + } + + // Line 6 char 27 -> the `seed` parameter; line 8 char 12 -> the `total` local. + [Theory] + [InlineData(6, 27, "seed")] + [InlineData(8, 12, "total")] + public async Task Rename_local_or_parameter_onto_member_name_shadows_and_produces_edits( + int line, + int character, + string original + ) + { + using var manager = await OpenAsync(); + + var result = await manager.RenameAsync(_sourcePath, line, character, "_counter"); + + var edit = Unwrap(result); + Assert.NotEmpty(edit.DocumentChanges); + var newText = edit.DocumentChanges[0].Edits; + Assert.NotEmpty(newText); + Assert.All(newText, e => Assert.Equal("_counter", e.NewText)); + Assert.DoesNotContain(original, string.Join(" ", newText.Select(e => e.NewText))); + } + + /// A genuine same-kind collision (member vs member) must still be rejected. + [Fact] + public async Task Rename_field_onto_existing_member_name_is_still_rejected() + { + using var manager = await OpenAsync(); + + // Line 4 char 16 -> the `_counter` field; `Compute` is an existing method. + var result = await manager.RenameAsync(_sourcePath, 4, 16, "Compute"); + + var edit = Unwrap(result); + Assert.Empty(edit.DocumentChanges); + } +} diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerSingleFileTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerSingleFileTests.cs similarity index 89% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerSingleFileTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerSingleFileTests.cs index c67b9cbf..c0aa603b 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerSingleFileTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerSingleFileTests.cs @@ -9,7 +9,7 @@ namespace SharpLsp.Sidecar.CSharp.Tests; /// /// Project-less document loading: .NET file-based apps and C# scripts. -/// Covers [FILEBASED], [CSX], [SCRIPT-CLOSURE], [SCRIPT-ANTIPATTERN], [SCRIPT-DEGRADE]. +/// Covers [SCRIPT-FILEBASED], [SCRIPT-CSX], [SCRIPT-CLOSURE], [SCRIPT-ANTIPATTERN], [SCRIPT-DEGRADE]. /// public sealed class WorkspaceManagerSingleFileTests : IDisposable { @@ -84,7 +84,7 @@ .. diagnostics.Where(d => /// The BCL metadata references must actually resolve. Asserting only that the result is not /// an error proves nothing — GetDiagnosticsAsync returns a SUCCESS result carrying a /// LIST of diagnostics, so a workspace with no references would still "pass". Asserting the - /// list is empty is what proves [FILEBASED-REFERENCES-FALLBACK] works. + /// list is empty is what proves [SCRIPT-FILEBASED-REFERENCES-FALLBACK] works. ///
[Fact] public async Task FileBasedApp_resolves_bcl_symbols_with_no_errors() @@ -99,7 +99,7 @@ public async Task FileBasedApp_resolves_bcl_symbols_with_no_errors() Assert.Empty(await ErrorsAsync(manager, app)); } - /// A shebang is valid in a file-based app. Implements [FILEBASED-SHEBANG]. + /// A shebang is valid in a file-based app. Implements [SCRIPT-FILEBASED-SHEBANG]. [Fact] public async Task FileBasedApp_shebang_produces_no_diagnostic() { @@ -164,7 +164,7 @@ public async Task FileBasedApp_include_cycle_terminates() /// /// .csx is Roslyn scripting, not a file-based app: a bare top-level statement plus a - /// #load closure must bind under SourceCodeKind.Script. Implements [CSX-OPTIONS]. + /// #load closure must bind under SourceCodeKind.Script. Implements [SCRIPT-CSX-OPTIONS]. /// [Fact] public async Task CsxScript_loads_with_script_semantics() @@ -192,6 +192,43 @@ public async Task Directory_without_project_or_root_file_succeeds_for_lazy_loadi Assert.False(manager.IsLoaded); } + /// A missing loose C# file is a load failure. Implements [SCRIPT-DEGRADE]. + [Fact] + public async Task Projectless_directory_rejects_update_for_a_missing_file() + { + using var manager = new WorkspaceManager(); + var missing = Path.Combine(_root, "missing.cs"); + Assert.False((await OpenAsync(manager, _root)).IsError); + + var result = await manager.UpdateDocumentTextAsync(missing, "Console.WriteLine(1);"); + + Assert.True(result.IsError); + Assert.Contains( + "not a file-based app or script", + result.Match(_ => "", error => error), + StringComparison.Ordinal + ); + Assert.False(manager.IsLoaded); + } + + [Fact] + public async Task Projectless_update_reports_cancellation_as_a_result_failure() + { + using var manager = new WorkspaceManager(); + Assert.False((await OpenAsync(manager, _root)).IsError); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + var result = await manager.UpdateDocumentTextAsync( + Path.Combine(_root, "cancelled.cs"), + "Console.WriteLine(1);", + cancellation.Token + ); + + Assert.True(result.IsError); + Assert.False(manager.IsLoaded); + } + /// /// Ambiguity is NOT absence. A root holding several solutions must surface an error /// naming the knob that resolves it, never degrade to lazy per-file ad-hoc projects: @@ -245,7 +282,7 @@ public async Task Independent_scripts_in_projectless_directory_are_lazily_loaded /// IGNORED trivia — the SDK owns their meaning — so a correct header contributes zero compiler /// diagnostics. Both payload shapes are exercised: name@version and bare name for /// #:package, name=value and bare name for #:property. - /// Implements [FILEBASED-DIRECTIVES]. + /// Implements [SCRIPT-FILEBASED-DIRECTIVES]. /// [Fact] public async Task FileBasedApp_full_directive_header_produces_no_errors() @@ -297,7 +334,7 @@ await ErrorsAsync(manager, app), /// /// #:include accepts a glob. Every matched file must join the closure — asserted by - /// binding a symbol from each. Implements [FILEBASED-DIRECTIVES], [SCRIPT-CLOSURE]. + /// binding a symbol from each. Implements [SCRIPT-FILEBASED-DIRECTIVES], [SCRIPT-CLOSURE]. /// [Fact] public async Task FileBasedApp_glob_include_pulls_every_match_into_the_closure() @@ -324,7 +361,7 @@ public async Task FileBasedApp_glob_include_pulls_every_match_into_the_closure() /// /// A ** glob must descend. A top-directory-only search would silently miss nested - /// sources and report them as unresolved symbols. Implements [FILEBASED-DIRECTIVES]. + /// sources and report them as unresolved symbols. Implements [SCRIPT-FILEBASED-DIRECTIVES]. /// [Fact] public async Task FileBasedApp_recursive_glob_include_reaches_nested_files() diff --git a/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerTests.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerTests.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Features.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Features.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Features.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Features.cs diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.ForeignRename.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.ForeignRename.cs new file mode 100644 index 00000000..d12c8fab --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.ForeignRename.cs @@ -0,0 +1,45 @@ +using MessagePack; +using ByteResult = Outcome.Result; + +namespace SharpLsp.Sidecar.CSharp; + +internal sealed partial class CSharpSidecar +{ + private async Task HandleRenameIdentityAsync(byte[] payload, CancellationToken ct) + { + try + { + var request = MessagePackSerializer.Deserialize( + payload, + cancellationToken: ct + ); + var result = await _workspace + .GetRenameIdentityAsync(request.FilePath, request.Line, request.Character, ct) + .ConfigureAwait(false); + return SerializeResult(result, ct); + } + catch (Exception ex) + { + return ByteResult.Failure(ex.Message); + } + } + + private async Task HandleRenameForeignAsync(byte[] payload, CancellationToken ct) + { + try + { + var request = MessagePackSerializer.Deserialize( + payload, + cancellationToken: ct + ); + var result = await _workspace + .RenameForeignAsync(request.AssemblyName, request.XmlDocSig, request.NewName, ct) + .ConfigureAwait(false); + return SerializeResult(result, ct); + } + catch (Exception ex) + { + return ByteResult.Failure(ex.Message); + } + } +} diff --git a/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Hierarchy.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Hierarchy.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Hierarchy.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Hierarchy.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Packages.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Packages.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Packages.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Packages.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.cs similarity index 99% rename from sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.cs index 4b483edf..d96fcad6 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.cs @@ -48,6 +48,8 @@ public CSharpSidecar() Register("textDocument/inlayHint", HandleInlayHintAsync); Register("textDocument/prepareRename", HandlePrepareRenameAsync); Register("textDocument/rename", HandleRenameAsync); + Register("textDocument/renameIdentity", HandleRenameIdentityAsync); + Register("workspace/renameForeign", HandleRenameForeignAsync); Register("project/unusedPackages", HandleUnusedPackagesAsync); Register("project/addPackage", HandleAddPackageAsync); Register("project/removePackage", HandleRemovePackageAsync); diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/ForeignRenameMessages.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/ForeignRenameMessages.cs new file mode 100644 index 00000000..c68d5905 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/ForeignRenameMessages.cs @@ -0,0 +1,30 @@ +using MessagePack; + +namespace SharpLsp.Sidecar.CSharp; + +// Cross-sidecar symbol identity used to bridge C# and F# rename references. +[MessagePackObject(AllowPrivate = true)] +internal sealed class RenameIdentityResultWire +{ + [Key(0)] + public bool Found { get; init; } + + [Key(1)] + public string AssemblyName { get; set; } = ""; + + [Key(2)] + public string XmlDocSig { get; set; } = ""; +} + +[MessagePackObject(AllowPrivate = true)] +internal sealed class RenameForeignRequest +{ + [Key(0)] + public string AssemblyName { get; set; } = ""; + + [Key(1)] + public string XmlDocSig { get; set; } = ""; + + [Key(2)] + public string NewName { get; set; } = ""; +} diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Hover/CSharpHoverBuilder.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Hover/CSharpHoverBuilder.cs similarity index 94% rename from sidecars/SharpLsp.Sidecar.CSharp/Hover/CSharpHoverBuilder.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Hover/CSharpHoverBuilder.cs index cc3f1472..7517cb20 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp/Hover/CSharpHoverBuilder.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Hover/CSharpHoverBuilder.cs @@ -7,7 +7,7 @@ namespace SharpLsp.Sidecar.CSharp.Hover; /// -/// Builds rich Markdown hover content for C# symbols using Roslyn. +/// Builds rich Markdown hover content for C# symbols using Roslyn. [HOVER-CSHARP-RENDERING] /// internal static class CSharpHoverBuilder { @@ -61,9 +61,17 @@ CancellationToken ct return null; } - if (token.IsKind(SyntaxKind.VarKeyword)) + // [HOVER-CSHARP-CASES] Roslyn represents contextual `var` as an + // IdentifierToken whose ContextualKind is VarKeyword. Preserve the + // general fallback for forms such as foreach when no local-variable + // hover can be built. + if (token.IsKind(SyntaxKind.IdentifierToken) && token.Text == "var") { - return BuildVarHover(model, token, ct); + var hover = BuildVarHover(model, token, ct); + if (hover is not null) + { + return hover; + } } if (IsNumericLiteral(token)) diff --git a/sidecars/SharpLsp.Sidecar.CSharp/MSBuildInstanceSelector.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/MSBuildInstanceSelector.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/MSBuildInstanceSelector.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/MSBuildInstanceSelector.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Messages.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Messages.cs similarity index 99% rename from sidecars/SharpLsp.Sidecar.CSharp/Messages.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Messages.cs index c7a7d182..1b7c5d16 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp/Messages.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Messages.cs @@ -49,7 +49,7 @@ internal sealed class CompletionItem /// Edit that REPLACES the identifier span at the caret when the item is /// accepted. Without it the editor appends to the /// trigger text, duplicating the member name (GitHub #178). - /// Implements [COMPLETION-EDIT-REPLACE]. + /// Implements [SHARPLSP-FEATURES-INTELLIGENCE-COMPLETION-EDIT]. /// [Key(5)] public TextEditResult? TextEdit { get; init; } diff --git a/sidecars/SharpLsp.Sidecar.CSharp/PackageEditor.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/PackageEditor.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/PackageEditor.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/PackageEditor.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Program.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Program.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Program.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Program.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj b/src/sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj rename to src/sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/AnalyzerDiagnosticResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/AnalyzerDiagnosticResolver.cs new file mode 100644 index 00000000..1fc2fee8 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/AnalyzerDiagnosticResolver.cs @@ -0,0 +1,321 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using Serilog; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Discovers Roslyn feature components and runs the style analyzers needed by code fixes. +/// Project analyzer options carry the effective .editorconfig configuration. +/// +internal static class AnalyzerDiagnosticResolver +{ + private static readonly ImmutableHashSet RewriteDiagnosticIds = ImmutableHashSet.Create( + StringComparer.Ordinal, + "IDE0007", + "IDE0008", + "IDE0160", + "IDE0161" + ); + + private static readonly ImmutableArray FeatureAssemblyNames = + [ + "Microsoft.CodeAnalysis.Features", + "Microsoft.CodeAnalysis.CSharp.Features", + ]; + + public static async Task> ResolveAsync( + Document document, + SemanticModel model, + TextSpan span, + ImmutableArray analyzers, + CancellationToken ct + ) + { + return analyzers.IsDefaultOrEmpty + ? [] + : await ResolveWithCompilationAsync(document, model, span, analyzers, ct) + .ConfigureAwait(false); + } + + public static ImmutableArray DiscoverFixableAnalyzers( + ImmutableArray providers + ) + { + var fixableIds = providers + .SelectMany(provider => provider.FixableDiagnosticIds) + .Where(RewriteDiagnosticIds.Contains) + .ToImmutableHashSet(StringComparer.Ordinal); + return + [ + .. DiscoverProviders() + .Where(analyzer => SupportsAny(analyzer, fixableIds)) + .DistinctBy(analyzer => analyzer.GetType()), + ]; + } + + public static ImmutableArray DiscoverProviders() + where T : class + { + var providers = GetFeatureAssemblies() + .SelectMany(GetLoadableTypes) + .Select(TryInstantiate) + .OfType() + .DistinctBy(provider => provider.GetType()) + .ToImmutableArray(); + Log.Debug( + "[CodeAction] Discovered {Count} {ProviderType} providers", + providers.Length, + typeof(T).Name + ); + return providers; + } + + private static async Task> ResolveWithCompilationAsync( + Document document, + SemanticModel model, + TextSpan span, + ImmutableArray analyzers, + CancellationToken ct + ) + { + var compilation = await document.Project.GetCompilationAsync(ct).ConfigureAwait(false); + if (compilation is null) + { + return []; + } + + var runner = compilation.WithAnalyzers(analyzers, CreateOptions(document.Project)); + return await ResolveForSpanAsync(runner, model, span, ct).ConfigureAwait(false); + } + + private static CompilationWithAnalyzersOptions CreateOptions(Project project) + { + return new CompilationWithAnalyzersOptions( + project.AnalyzerOptions, + LogAnalyzerFailure, + concurrentAnalysis: true, + logAnalyzerExecutionTime: false, + reportSuppressedDiagnostics: false + ); + } + + private static async Task> ResolveForSpanAsync( + CompilationWithAnalyzers runner, + SemanticModel model, + TextSpan span, + CancellationToken ct + ) + { + var syntax = await runner + .GetAnalyzerSyntaxDiagnosticsAsync(model.SyntaxTree, span, ct) + .ConfigureAwait(false); + var semantic = await runner + .GetAnalyzerSemanticDiagnosticsAsync(model, span, ct) + .ConfigureAwait(false); + return FilterDiagnostics(syntax.AddRange(semantic), model.SyntaxTree, span); + } + + private static ImmutableArray FilterDiagnostics( + ImmutableArray diagnostics, + SyntaxTree tree, + TextSpan span + ) + { + return + [ + .. diagnostics + .Where(diagnostic => MatchesSpan(diagnostic, tree, span)) + .DistinctBy(DiagnosticKey), + ]; + } + + private static bool MatchesSpan(Diagnostic diagnostic, SyntaxTree tree, TextSpan span) + { + return DiagnosticLocations(diagnostic) + .Any(location => MatchesLocation(location, tree, span)) + || ( + IsNamespaceStyle(diagnostic.Id) + && DiagnosticLocations(diagnostic) + .Any(location => MatchesNamespaceKeyword(location, tree, span)) + ); + } + + private static IEnumerable DiagnosticLocations(Diagnostic diagnostic) + { + yield return diagnostic.Location; + foreach (var location in diagnostic.AdditionalLocations) + { + yield return location; + } + } + + private static bool MatchesLocation(Location location, SyntaxTree tree, TextSpan requested) + { + return ReferenceEquals(location.SourceTree, tree) + && SpansTouch(location.SourceSpan, requested); + } + + private static bool MatchesNamespaceKeyword( + Location location, + SyntaxTree tree, + TextSpan requested + ) + { + if (!ReferenceEquals(location.SourceTree, tree)) + { + return false; + } + + var declaration = FindNamespaceDeclaration(tree, location.SourceSpan); + return declaration is not null && SpansTouch(declaration.NamespaceKeyword.Span, requested); + } + + private static BaseNamespaceDeclarationSyntax? FindNamespaceDeclaration( + SyntaxTree tree, + TextSpan span + ) + { + var node = tree.GetRoot().FindNode(span, getInnermostNodeForTie: true); + return node.AncestorsAndSelf().OfType().FirstOrDefault(); + } + + private static bool SpansTouch(TextSpan candidate, TextSpan requested) + { + return requested.IsEmpty + ? candidate.Contains(requested.Start) || candidate.Start == requested.Start + : candidate.IntersectsWith(requested); + } + + private static bool IsNamespaceStyle(string diagnosticId) + { + return diagnosticId is "IDE0160" or "IDE0161"; + } + + private static (string Core, string Properties, string Additional) DiagnosticKey( + Diagnostic diagnostic + ) + { + var core = string.Join( + "\u001f", + diagnostic.Id, + LocationKey(diagnostic.Location), + diagnostic.Severity, + diagnostic.GetMessage(CultureInfo.InvariantCulture) + ); + var properties = string.Join( + "\u001e", + diagnostic.Properties.OrderBy(pair => pair.Key).Select(PropertyKey) + ); + return ( + core, + properties, + string.Join("\u001e", diagnostic.AdditionalLocations.Select(LocationKey)) + ); + } + + private static string PropertyKey(KeyValuePair property) + { + return $"{property.Key}\u001d{property.Value}"; + } + + private static string LocationKey(Location location) + { + return $"{location.SourceTree?.FilePath}\u001d{location.SourceSpan.Start}\u001d{location.SourceSpan.Length}"; + } + + private static bool SupportsAny( + DiagnosticAnalyzer analyzer, + ImmutableHashSet fixableIds + ) + { + try + { + return analyzer.SupportedDiagnostics.Any(descriptor => + fixableIds.Contains(descriptor.Id) + ); + } + catch (Exception ex) + { + Log.Debug( + ex, + "[CodeAction] Could not inspect analyzer {Analyzer}", + analyzer.GetType().Name + ); + return false; + } + } + + private static IEnumerable GetLoadableTypes(Assembly assembly) + { + try + { + return assembly.DefinedTypes.OrderBy(type => type.FullName, StringComparer.Ordinal); + } + catch (ReflectionTypeLoadException ex) + { + Log.Debug( + ex, + "[CodeAction] Some types in {Assembly} could not load", + assembly.GetName().Name + ); + return ex.Types.OfType().Select(type => type.GetTypeInfo()); + } + } + + private static T? TryInstantiate(System.Reflection.TypeInfo type) + where T : class + { + if (type.IsAbstract || type.IsInterface || !typeof(T).IsAssignableFrom(type)) + { + return null; + } + + try + { + return Activator.CreateInstance(type.AsType(), nonPublic: true) as T; + } + catch + { + return null; // MEF-only feature components are unavailable headlessly. + } + } + + private static ImmutableArray GetFeatureAssemblies() + { + return [.. FeatureAssemblyNames.Select(TryLoadAssembly).OfType()]; + } + + private static Assembly? TryLoadAssembly(string name) + { + try + { + return Assembly.Load(name); + } + catch (Exception ex) + { + Log.Debug(ex, "[CodeAction] Feature assembly {Assembly} could not load", name); + return null; + } + } + + private static void LogAnalyzerFailure( + Exception exception, + DiagnosticAnalyzer analyzer, + Diagnostic diagnostic + ) + { + Log.Debug( + exception, + "[CodeAction] Analyzer {Analyzer} failed while producing {Diagnostic}", + analyzer.GetType().Name, + diagnostic.Id + ); + } +} diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs new file mode 100644 index 00000000..1c44c978 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs @@ -0,0 +1,510 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CodeRefactorings; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using Serilog; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Discovers Roslyn code fix and refactoring providers via reflection, +/// enumerates available code actions for a range, and resolves them to edits. +/// +internal sealed class CodeActionResolver +{ + private static readonly Lazy> CachedFixProviders = new( + LoadFixProviders + ); + + private static readonly Lazy< + ImmutableArray + > CachedRefactoringProviders = new(LoadRefactoringProviders); + + private static readonly Lazy> CachedDiagnosticAnalyzers = + new(() => AnalyzerDiagnosticResolver.DiscoverFixableAnalyzers(CachedFixProviders.Value)); + + private static readonly ImmutableHashSet RewriteDiagnosticIds = ImmutableHashSet.Create( + StringComparer.Ordinal, + "IDE0007", + "IDE0008", + "IDE0160", + "IDE0161" + ); + + private readonly ConcurrentDictionary _pendingActions = new(); + private int _nextId; + + /// + /// Get available code actions (fixes + refactorings) for a document range. + /// Caches the underlying CodeAction objects for subsequent resolve calls. + /// + public async Task> GetCodeActionsAsync( + Document document, + TextSpan span, + CancellationToken ct + ) + { + var items = new List(); + await CollectCodeFixesAsync(document, span, items, ct).ConfigureAwait(false); + await CollectHeadlessOverridesAsync(document, span, items, ct).ConfigureAwait(false); + await CollectRefactoringsAsync(document, span, items, ct).ConfigureAwait(false); + return items; + } + + /// + /// Resolve a previously cached code action by ID, returning workspace edits. + /// + public async Task ResolveAsync( + int actionId, + Solution originalSolution, + CancellationToken ct + ) + { + if (!_pendingActions.TryRemove(actionId, out var codeAction)) + { + return null; + } + + var operations = await codeAction.GetOperationsAsync(ct).ConfigureAwait(false); + var applyOp = operations.OfType().FirstOrDefault(); + return applyOp is null + ? new WorkspaceEditResult() + : await BuildWorkspaceEditAsync(originalSolution, applyOp.ChangedSolution, ct) + .ConfigureAwait(false); + } + + private async Task CollectCodeFixesAsync( + Document document, + TextSpan span, + List items, + CancellationToken ct + ) + { + var model = await document.GetSemanticModelAsync(ct).ConfigureAwait(false); + if (model is null) + { + return; + } + + await CollectResolvedFixesAsync(document, model, span, items, ct).ConfigureAwait(false); + } + + private async Task CollectResolvedFixesAsync( + Document document, + SemanticModel model, + TextSpan span, + List items, + CancellationToken ct + ) + { + var diagnostics = await ResolveDiagnosticsAsync(document, model, span, ct) + .ConfigureAwait(false); + if (diagnostics.IsEmpty) + { + return; + } + + await RegisterFixProvidersAsync(document, GroupDiagnostics(diagnostics), items, ct) + .ConfigureAwait(false); + } + + private async Task RegisterFixProvidersAsync( + Document document, + Dictionary> diagnostics, + List items, + CancellationToken ct + ) + { + foreach (var provider in CachedFixProviders.Value) + { + ct.ThrowIfCancellationRequested(); + await TryRegisterFixesAsync(provider, document, diagnostics, items, ct) + .ConfigureAwait(false); + } + } + + private static Dictionary> GroupDiagnostics( + ImmutableArray diagnostics + ) + { + return diagnostics + .GroupBy(diagnostic => diagnostic.Id) + .ToDictionary(group => group.Key, group => group.ToImmutableArray()); + } + + private static async Task> ResolveDiagnosticsAsync( + Document document, + SemanticModel model, + TextSpan span, + CancellationToken ct + ) + { + var analyzerDiagnostics = await AnalyzerDiagnosticResolver + .ResolveAsync(document, model, span, CachedDiagnosticAnalyzers.Value, ct) + .ConfigureAwait(false); + return model.GetDiagnostics(span, ct).AddRange(analyzerDiagnostics); + } + + private async Task TryRegisterFixesAsync( + CodeFixProvider provider, + Document document, + Dictionary> diagById, + List items, + CancellationToken ct + ) + { + foreach (var fixableId in provider.FixableDiagnosticIds) + { + if (!diagById.TryGetValue(fixableId, out var matchingDiags)) + { + continue; + } + + await RegisterMatchingFixesAsync(provider, document, matchingDiags, items, ct) + .ConfigureAwait(false); + } + } + + private async Task RegisterMatchingFixesAsync( + CodeFixProvider provider, + Document document, + ImmutableArray diagnostics, + List items, + CancellationToken ct + ) + { + foreach (var diagnostic in diagnostics) + { + await TryRegisterFixAsync(provider, document, diagnostic, items, ct) + .ConfigureAwait(false); + } + } + + private async Task TryRegisterFixAsync( + CodeFixProvider provider, + Document document, + Diagnostic diagnostic, + List items, + CancellationToken ct + ) + { + try + { + await RegisterFixCoreAsync(provider, document, diagnostic, items, ct) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Log.Debug(ex, "[CodeAction] Fix provider {Provider} failed", provider.GetType().Name); + } + } + + private async Task RegisterFixCoreAsync( + CodeFixProvider provider, + Document document, + Diagnostic diagnostic, + List items, + CancellationToken ct + ) + { + var context = CreateFixContext(document, diagnostic, items, ct); + await provider.RegisterCodeFixesAsync(context).ConfigureAwait(false); + } + + private CodeFixContext CreateFixContext( + Document document, + Diagnostic diagnostic, + List items, + CancellationToken ct + ) + { + return new CodeFixContext( + document, + diagnostic, + (action, _) => CacheAndAdd(action, FixKind(diagnostic.Id), items), + ct + ); + } + + private async Task CollectHeadlessOverridesAsync( + Document document, + TextSpan span, + List items, + CancellationToken ct + ) + { + var action = await HeadlessOverrideCodeAction + .TryCreateAsync(document, span, ct) + .ConfigureAwait(false); + if (action is not null) + { + CacheAndAdd(action, "refactor.rewrite", items); + } + } + + private static string FixKind(string diagnosticId) + { + return RewriteDiagnosticIds.Contains(diagnosticId) ? "refactor.rewrite" : "quickfix"; + } + + private async Task CollectRefactoringsAsync( + Document document, + TextSpan span, + List items, + CancellationToken ct + ) + { + foreach (var provider in CachedRefactoringProviders.Value) + { + ct.ThrowIfCancellationRequested(); + await TryRegisterRefactoringAsync(provider, document, span, items, ct) + .ConfigureAwait(false); + } + } + + private async Task TryRegisterRefactoringAsync( + CodeRefactoringProvider provider, + Document document, + TextSpan span, + List items, + CancellationToken ct + ) + { + try + { + await provider + .ComputeRefactoringsAsync( + CreateRefactoringContext(provider, document, span, items, ct) + ) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Log.Debug( + ex, + "[CodeAction] Refactoring provider {Provider} failed", + provider.GetType().Name + ); + } + } + + private CodeRefactoringContext CreateRefactoringContext( + CodeRefactoringProvider provider, + Document document, + TextSpan span, + List items, + CancellationToken ct + ) + { + return new CodeRefactoringContext( + document, + span, + action => CacheAndAdd(action, RefactoringKind(provider, action), items), + ct + ); + } + + private static string RefactoringKind(CodeRefactoringProvider provider, CodeAction action) + { + var providerName = provider.GetType().Name; + return IsOrganizeImports(providerName, action.Title) ? "source.organizeImports" + : providerName.Contains("Inline", StringComparison.OrdinalIgnoreCase) + ? "refactor.inline" + : IsExtractionProvider(providerName) ? "refactor.extract" + : "refactor.rewrite"; + } + + private static bool IsOrganizeImports(string providerName, string title) + { + return providerName.Contains("OrganizeImports", StringComparison.OrdinalIgnoreCase) + || title.Equals("Organize Imports", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsExtractionProvider(string providerName) + { + return providerName.Contains("Extract", StringComparison.OrdinalIgnoreCase) + || providerName.Contains("IntroduceLocal", StringComparison.OrdinalIgnoreCase) + || providerName.Contains("IntroduceVariable", StringComparison.OrdinalIgnoreCase) + || providerName.Contains("IntroduceConstant", StringComparison.OrdinalIgnoreCase) + || providerName.Contains("IntroduceField", StringComparison.OrdinalIgnoreCase); + } + + private void CacheAndAdd(CodeAction action, string kind, List items) + { + if (CacheNestedActions(action, kind, items) || IsDuplicate(action, kind, items)) + { + return; + } + + items.Add(CacheAction(action, kind)); + } + + private bool CacheNestedActions(CodeAction action, string kind, List items) + { + if (action.NestedActions.IsEmpty) + { + return false; + } + + foreach (var nested in action.NestedActions) + { + CacheAndAdd(nested, kind, items); + } + + return true; + } + + private static bool IsDuplicate(CodeAction action, string kind, List items) + { + return items.Any(item => item.Title == action.Title && item.Kind == kind); + } + + private CodeActionItem CacheAction(CodeAction action, string kind) + { + var id = Interlocked.Increment(ref _nextId); + _pendingActions[id] = action; + return new CodeActionItem + { + Id = id, + Title = action.Title, + Kind = kind, + IsPreferred = action.Priority == CodeActionPriority.High, + }; + } + + private static async Task BuildWorkspaceEditAsync( + Solution oldSolution, + Solution newSolution, + CancellationToken ct + ) + { + var result = new WorkspaceEditResult(); + var changes = newSolution.GetChanges(oldSolution); + + foreach (var projectChange in changes.GetProjectChanges()) + { + await CollectChangedDocumentsAsync(oldSolution, newSolution, projectChange, result, ct) + .ConfigureAwait(false); + await CollectAddedDocumentsAsync(newSolution, projectChange, result, ct) + .ConfigureAwait(false); + } + + return result; + } + + private static async Task CollectChangedDocumentsAsync( + Solution oldSolution, + Solution newSolution, + ProjectChanges projectChange, + WorkspaceEditResult result, + CancellationToken ct + ) + { + foreach (var docId in projectChange.GetChangedDocuments()) + { + await CollectChangedDocumentAsync(oldSolution, newSolution, docId, result, ct) + .ConfigureAwait(false); + } + } + + private static async Task CollectChangedDocumentAsync( + Solution oldSolution, + Solution newSolution, + DocumentId docId, + WorkspaceEditResult result, + CancellationToken ct + ) + { + var oldDoc = oldSolution.GetDocument(docId); + var newDoc = newSolution.GetDocument(docId); + if (oldDoc is null || newDoc?.FilePath is null) + { + return; + } + + var edits = await DocumentText.ComputeEditsAsync(oldDoc, newDoc, ct).ConfigureAwait(false); + if (edits.Count > 0) + { + result.DocumentChanges.Add( + new DocumentEditResult { FilePath = newDoc.FilePath, Edits = edits } + ); + } + } + + private static async Task CollectAddedDocumentsAsync( + Solution newSolution, + ProjectChanges projectChange, + WorkspaceEditResult result, + CancellationToken ct + ) + { + foreach (var docId in projectChange.GetAddedDocuments()) + { + await CollectAddedDocumentAsync(newSolution, docId, result, ct).ConfigureAwait(false); + } + } + + private static async Task CollectAddedDocumentAsync( + Solution solution, + DocumentId docId, + WorkspaceEditResult result, + CancellationToken ct + ) + { + var document = solution.GetDocument(docId); + if (document?.FilePath is null) + { + return; + } + + var text = await document.GetTextAsync(ct).ConfigureAwait(false); + result.DocumentChanges.Add(CreateAddedDocumentEdit(document.FilePath, text.ToString())); + } + + private static DocumentEditResult CreateAddedDocumentEdit(string filePath, string text) + { + return new DocumentEditResult + { + FilePath = filePath, + Edits = [CreateWholeDocumentEdit(text)], + }; + } + + private static TextEditResult CreateWholeDocumentEdit(string text) + { + return new TextEditResult + { + StartLine = 0, + StartCharacter = 0, + EndLine = 0, + EndCharacter = 0, + NewText = text, + }; + } + + private static ImmutableArray LoadFixProviders() + { + return AnalyzerDiagnosticResolver.DiscoverProviders(); + } + + private static ImmutableArray LoadRefactoringProviders() + { + return + [ + .. AnalyzerDiagnosticResolver.DiscoverProviders(), + new MergeDeclarationAssignmentCodeRefactoringProvider(), + ]; + } +} diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DeadCodeAnalyzer.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DeadCodeAnalyzer.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/DeadCodeAnalyzer.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DeadCodeAnalyzer.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentClosure.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentClosure.cs similarity index 97% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentClosure.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentClosure.cs index a350fa9b..aaf38f8a 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentClosure.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentClosure.cs @@ -32,7 +32,7 @@ public static Task ExpandFileBasedAsync(string rootPath, CancellationTo /// /// A script closure is the root file alone. Roslyn resolves #load itself through the /// compilation's SourceReferenceResolver; adding the loaded files as documents too - /// would compile them twice. Implements [CSX-RESOLVERS]. + /// would compile them twice. Implements [SCRIPT-CSX-RESOLVERS]. /// public static Task ExpandScriptAsync(string rootPath, CancellationToken ct) { @@ -111,7 +111,7 @@ private static void RecordBound(string full, int depth, ExpansionState state) } // The FileBasedProgram feature flag makes Roslyn lex `#:` as IgnoredDirectiveTrivia in a - // Regular compilation, matching what the SDK passes to csc. [FILEBASED-DIRECTIVES] + // Regular compilation, matching what the SDK passes to csc. [SCRIPT-FILEBASED-DIRECTIVES] private static readonly CSharpParseOptions FileBasedParseOptions = new CSharpParseOptions( LanguageVersion.Latest ).WithFeatures([new KeyValuePair("FileBasedProgram", "true")]); @@ -131,7 +131,7 @@ ExpansionState state } // `#:include` accepts a literal path, a glob, or an MSBuild property. Property expansion - // requires a real MSBuild evaluation and is deferred to [FILEBASED-REFERENCES-MSBUILD]. + // requires a real MSBuild evaluation and is deferred to [SCRIPT-FILEBASED-REFERENCES-MSBUILD]. private static string[] ResolveInclude(string pattern, string baseDir, ExpansionState state) { var usesMsBuildProperty = pattern.Contains("$(", StringComparison.Ordinal); diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentPosition.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentPosition.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentPosition.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentPosition.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentText.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentText.cs similarity index 94% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentText.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentText.cs index 75ba3fbd..de28be8b 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentText.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentText.cs @@ -65,7 +65,7 @@ CancellationToken ct ) { var oldText = await oldDoc.GetTextAsync(ct).ConfigureAwait(false); - var newText = await newDoc.GetTextAsync(ct).ConfigureAwait(false); - return [.. newText.GetTextChanges(oldText).Select(change => ToTextEdit(oldText, change))]; + var changes = await newDoc.GetTextChangesAsync(oldDoc, ct).ConfigureAwait(false); + return [.. changes.Select(change => ToTextEdit(oldText, change))]; } } diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FileLevelDirectives.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FileLevelDirectives.cs similarity index 99% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/FileLevelDirectives.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FileLevelDirectives.cs index ec45c988..73f17e15 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FileLevelDirectives.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FileLevelDirectives.cs @@ -31,7 +31,7 @@ TextSpanLocation Location internal sealed record TextSpanLocation(int Start, int Length); /// -/// Parses .NET file-based app #: directives. Implements [FILEBASED-DIRECTIVES]. +/// Parses .NET file-based app #: directives. Implements [SCRIPT-FILEBASED-DIRECTIVES]. /// /// /// Directives are read from the Roslyn CST, never by scanning text: Roslyn lexes #: as diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FormattingResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FormattingResolver.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/FormattingResolver.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FormattingResolver.cs diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/HeadlessOverrideCodeAction.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/HeadlessOverrideCodeAction.cs new file mode 100644 index 00000000..ea92eafc --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/HeadlessOverrideCodeAction.cs @@ -0,0 +1,349 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; +using Microsoft.CodeAnalysis.Formatting; +using Microsoft.CodeAnalysis.Simplification; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Generates overrides without Roslyn's editor-only member picker service. +/// +internal static class HeadlessOverrideCodeAction +{ + private const string Title = "Generate overrides..."; + + public static async Task TryCreateAsync( + Document document, + TextSpan span, + CancellationToken ct + ) + { + var plan = await CreatePlanAsync(document, span, ct).ConfigureAwait(false); + return plan is null + ? null + : CodeAction.Create(Title, token => ApplyAsync(document, plan, token), Title); + } + + private static async Task CreatePlanAsync( + Document document, + TextSpan span, + CancellationToken ct + ) + { + var declaration = await FindDeclarationAsync(document, span, ct).ConfigureAwait(false); + return declaration is null + ? null + : await BuildPlanAsync(document, declaration, ct).ConfigureAwait(false); + } + + private static async Task FindDeclarationAsync( + Document document, + TextSpan span, + CancellationToken ct + ) + { + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + var declaration = root + ?.FindToken(span.Start) + .Parent?.AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + return declaration is not null && SpansTouch(declaration.Identifier.Span, span) + ? declaration + : null; + } + + private static async Task BuildPlanAsync( + Document document, + TypeDeclarationSyntax declaration, + CancellationToken ct + ) + { + var model = await document.GetSemanticModelAsync(ct).ConfigureAwait(false); + return model is null ? null : BuildPlan(model, declaration, ct); + } + + private static OverridePlan? BuildPlan( + SemanticModel model, + TypeDeclarationSyntax declaration, + CancellationToken ct + ) + { + if ( + model.GetDeclaredSymbol(declaration, ct) is not INamedTypeSymbol target + || model.Compilation.GetTypeByMetadataName("System.NotImplementedException") + is not ITypeSymbol exceptionType + ) + { + return null; + } + + var members = CollectCandidates(target, model.Compilation); + return members.IsEmpty ? null : new(declaration, target, exceptionType, members); + } + + private static ImmutableArray CollectCandidates( + INamedTypeSymbol target, + Compilation compilation + ) + { + var occupied = target.GetMembers().Where(IsSlotMember).ToList(); + var candidates = ImmutableArray.CreateBuilder(); + for (var baseType = target.BaseType; baseType is not null; baseType = baseType.BaseType) + { + if (!CollectFromBase(baseType, target, compilation, occupied, candidates)) + { + return []; + } + } + + return candidates.ToImmutable(); + } + + private static bool CollectFromBase( + INamedTypeSymbol baseType, + INamedTypeSymbol target, + Compilation compilation, + List occupied, + ImmutableArray.Builder candidates + ) + { + return OrderedMembers(baseType) + .All(member => CollectCandidate(member, target, compilation, occupied, candidates)); + } + + private static bool CollectCandidate( + ISymbol member, + INamedTypeSymbol target, + Compilation compilation, + List occupied, + ImmutableArray.Builder candidates + ) + { + if (!TryOccupy(member, occupied)) + { + return true; + } + + var required = IsOverrideCandidate(member); + var supported = !required || CanGenerate(member, target, compilation); + if (required && supported) + { + candidates.Add(member); + } + + return supported; + } + + private static bool TryOccupy(ISymbol member, List occupied) + { + if (occupied.Any(existing => SameSignature(existing, member))) + { + return false; + } + + occupied.Add(member); + return true; + } + + private static IEnumerable OrderedMembers(INamedTypeSymbol baseType) + { + return baseType + .GetMembers() + .Where(IsSlotMember) + .OrderBy(MemberKindOrder) + .ThenBy(member => member.Name, StringComparer.Ordinal) + .ThenBy(MemberDisplay, StringComparer.Ordinal); + } + + private static int MemberKindOrder(ISymbol member) + { + return member switch + { + IMethodSymbol => 0, + IPropertySymbol => 1, + IEventSymbol => 2, + _ => 3, + }; + } + + private static string MemberDisplay(ISymbol member) + { + return member.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + } + + private static bool IsSlotMember(ISymbol member) + { + return member + is IMethodSymbol { MethodKind: MethodKind.Ordinary } + or IPropertySymbol + or IEventSymbol; + } + + private static bool CanGenerate( + ISymbol member, + INamedTypeSymbol target, + Compilation compilation + ) + { + return compilation.IsSymbolAccessibleWithin(member, target) + && HasAccessibleAccessors(member, target, compilation); + } + + private static bool IsOverrideCandidate(ISymbol member) + { + return member.IsAbstract && !member.IsStatic && !member.IsSealed; + } + + private static bool HasAccessibleAccessors( + ISymbol member, + INamedTypeSymbol target, + Compilation compilation + ) + { + return member switch + { + IPropertySymbol property => AccessorAvailable(property.GetMethod, target, compilation) + && AccessorAvailable(property.SetMethod, target, compilation), + IEventSymbol @event => AccessorAvailable(@event.AddMethod, target, compilation) + && AccessorAvailable(@event.RemoveMethod, target, compilation), + _ => true, + }; + } + + private static bool AccessorAvailable( + IMethodSymbol? accessor, + INamedTypeSymbol target, + Compilation compilation + ) + { + return accessor is null || compilation.IsSymbolAccessibleWithin(accessor, target); + } + + private static bool SameSignature(ISymbol left, ISymbol right) + { + return left.Kind == right.Kind + && StringComparer.Ordinal.Equals(left.Name, right.Name) + && (left, right) switch + { + (IMethodSymbol a, IMethodSymbol b) => SameMethodSignature(a, b), + (IPropertySymbol a, IPropertySymbol b) => SamePropertySignature(a, b), + (IEventSymbol, IEventSymbol) => true, + _ => false, + }; + } + + private static bool SameMethodSignature(IMethodSymbol left, IMethodSymbol right) + { + return left.Arity == right.Arity && SameParameters(left.Parameters, right.Parameters); + } + + private static bool SamePropertySignature(IPropertySymbol left, IPropertySymbol right) + { + return left.IsIndexer == right.IsIndexer + && SameParameters(left.Parameters, right.Parameters); + } + + private static bool SameParameters( + ImmutableArray left, + ImmutableArray right + ) + { + return left.Length == right.Length + && left.Zip(right).All(pair => SameParameter(pair.First, pair.Second)); + } + + private static bool SameParameter(IParameterSymbol left, IParameterSymbol right) + { + return left.RefKind == right.RefKind && SameType(left.Type, right.Type); + } + + private static bool SameType(ITypeSymbol left, ITypeSymbol right) + { + return SymbolEqualityComparer.Default.Equals(left, right) + || (left, right) switch + { + (ITypeParameterSymbol a, ITypeParameterSymbol b) => SameTypeParameter(a, b), + (IArrayTypeSymbol a, IArrayTypeSymbol b) => a.Rank == b.Rank + && SameType(a.ElementType, b.ElementType), + (IPointerTypeSymbol a, IPointerTypeSymbol b) => SameType( + a.PointedAtType, + b.PointedAtType + ), + (INamedTypeSymbol a, INamedTypeSymbol b) => SameNamedType(a, b), + _ => false, + }; + } + + private static bool SameTypeParameter(ITypeParameterSymbol left, ITypeParameterSymbol right) + { + return left.TypeParameterKind == right.TypeParameterKind && left.Ordinal == right.Ordinal; + } + + private static bool SameNamedType(INamedTypeSymbol left, INamedTypeSymbol right) + { + return SymbolEqualityComparer.Default.Equals( + left.OriginalDefinition, + right.OriginalDefinition + ) + && left.TypeArguments.Length == right.TypeArguments.Length + && left.TypeArguments.Zip(right.TypeArguments) + .All(pair => SameType(pair.First, pair.Second)); + } + + private static async Task ApplyAsync( + Document document, + OverridePlan plan, + CancellationToken ct + ) + { + var generator = SyntaxGenerator.GetGenerator(document); + var annotation = new SyntaxAnnotation(); + var members = plan.Members.Select(member => + HeadlessOverrideSyntax.Generate( + generator, + member, + plan.ExceptionType, + plan.Target, + annotation + ) + ); + var replacement = generator.AddMembers(plan.Declaration, members); + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + var changed = document.WithSyntaxRoot(root!.ReplaceNode(plan.Declaration, replacement)); + return await CleanDocumentAsync(changed, annotation, ct).ConfigureAwait(false); + } + + private static async Task CleanDocumentAsync( + Document document, + SyntaxAnnotation annotation, + CancellationToken ct + ) + { + var simplified = await Simplifier + .ReduceAsync(document, annotation, null, ct) + .ConfigureAwait(false); + var formatted = await Formatter + .FormatAsync(simplified, annotation, null, ct) + .ConfigureAwait(false); + return formatted.Project.Solution; + } + + private static bool SpansTouch(TextSpan candidate, TextSpan requested) + { + return requested.IsEmpty + ? candidate.Contains(requested.Start) || candidate.Start == requested.Start + : candidate.IntersectsWith(requested); + } + + private sealed record OverridePlan( + TypeDeclarationSyntax Declaration, + INamedTypeSymbol Target, + ITypeSymbol ExceptionType, + ImmutableArray Members + ); +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/HeadlessOverrideSyntax.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/HeadlessOverrideSyntax.cs new file mode 100644 index 00000000..5f3e5b15 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/HeadlessOverrideSyntax.cs @@ -0,0 +1,339 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; +using Microsoft.CodeAnalysis.Formatting; +using Microsoft.CodeAnalysis.Simplification; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Builds compile-safe C# declarations for the headless required-override action. +/// +internal static class HeadlessOverrideSyntax +{ + public static SyntaxNode Generate( + SyntaxGenerator generator, + ISymbol member, + ITypeSymbol exceptionType, + INamedTypeSymbol target, + SyntaxAnnotation annotation + ) + { + var declaration = GenerateDeclaration(generator, member, exceptionType, target); + return MarkOverride(generator, declaration, member, target) + .WithAdditionalAnnotations(annotation, Simplifier.Annotation, Formatter.Annotation); + } + + private static SyntaxNode GenerateDeclaration( + SyntaxGenerator generator, + ISymbol member, + ITypeSymbol exceptionType, + INamedTypeSymbol target + ) + { + return member switch + { + IMethodSymbol method => GenerateMethod(generator, method, exceptionType), + IPropertySymbol property => GenerateProperty( + generator, + property, + exceptionType, + target + ), + IEventSymbol @event => GenerateEvent(generator, @event, exceptionType), + _ => throw new InvalidOperationException($"Unsupported override member {member.Kind}"), + }; + } + + private static MethodDeclarationSyntax GenerateMethod( + SyntaxGenerator generator, + IMethodSymbol method, + ITypeSymbol exceptionType + ) + { + var statements = ThrowNotImplemented(generator, exceptionType).ToImmutableArray(); + var declaration = (MethodDeclarationSyntax)generator.MethodDeclaration(method, statements); + var constrained = WithOverrideConstraints(declaration, method); + return (MethodDeclarationSyntax)generator.WithStatements(constrained, statements); + } + + private static MethodDeclarationSyntax WithOverrideConstraints( + MethodDeclarationSyntax declaration, + IMethodSymbol method + ) + { + var clauses = method + .TypeParameters.Where(parameter => HasNullableUsage(parameter, method)) + .Select(OverrideConstraint) + .OfType(); + return declaration.WithConstraintClauses(SyntaxFactory.List(clauses)); + } + + private static TypeParameterConstraintClauseSyntax? OverrideConstraint( + ITypeParameterSymbol parameter + ) + { + var constraint = + !HasSomeConstraint(parameter) + ? (TypeParameterConstraintSyntax)SyntaxFactory.DefaultConstraint() + : !parameter.HasValueTypeConstraint + ? SyntaxFactory.ClassOrStructConstraint(SyntaxKind.ClassConstraint) + : null; + return constraint is null ? null : ConstraintClause(parameter.Name, constraint); + } + + private static TypeParameterConstraintClauseSyntax ConstraintClause( + string parameterName, + TypeParameterConstraintSyntax constraint + ) + { + return SyntaxFactory + .TypeParameterConstraintClause(parameterName) + .AddConstraints(constraint); + } + + private static bool HasSomeConstraint(ITypeParameterSymbol parameter) + { + return parameter.HasConstructorConstraint + || parameter.HasReferenceTypeConstraint + || parameter.HasValueTypeConstraint + || !parameter.ConstraintTypes.IsEmpty; + } + + private static bool HasNullableUsage(ITypeParameterSymbol parameter, IMethodSymbol method) + { + return ContainsAnnotatedParameter(method.ReturnType, parameter) + || method.Parameters.Any(item => ContainsAnnotatedParameter(item.Type, parameter)); + } + + private static bool ContainsAnnotatedParameter(ITypeSymbol type, ITypeParameterSymbol parameter) + { + return IsAnnotatedParameter(type, parameter) + || type switch + { + IArrayTypeSymbol array => ContainsAnnotatedParameter(array.ElementType, parameter), + IPointerTypeSymbol pointer => ContainsAnnotatedParameter( + pointer.PointedAtType, + parameter + ), + INamedTypeSymbol named => ContainsAnnotatedArgument(named, parameter), + IFunctionPointerTypeSymbol pointer => ContainsAnnotatedSignature( + pointer.Signature, + parameter + ), + _ => false, + }; + } + + private static bool IsAnnotatedParameter(ITypeSymbol type, ITypeParameterSymbol parameter) + { + return type is ITypeParameterSymbol current + && SymbolEqualityComparer.Default.Equals(current, parameter) + && current.NullableAnnotation == NullableAnnotation.Annotated; + } + + private static bool ContainsAnnotatedArgument( + INamedTypeSymbol type, + ITypeParameterSymbol parameter + ) + { + return type.TypeArguments.Any(item => ContainsAnnotatedParameter(item, parameter)); + } + + private static bool ContainsAnnotatedSignature( + IMethodSymbol signature, + ITypeParameterSymbol parameter + ) + { + return ContainsAnnotatedParameter(signature.ReturnType, parameter) + || signature.Parameters.Any(item => ContainsAnnotatedParameter(item.Type, parameter)); + } + + private static BasePropertyDeclarationSyntax GenerateProperty( + SyntaxGenerator generator, + IPropertySymbol property, + ITypeSymbol exceptionType, + INamedTypeSymbol target + ) + { + var getter = AccessorStatements(generator, property.GetMethod, exceptionType); + var setter = AccessorStatements(generator, property.SetMethod, exceptionType); + var declaration = property.IsIndexer + ? generator.IndexerDeclaration(property, getter, setter) + : generator.PropertyDeclaration(property, getter, setter); + var initialized = NormalizeInitAccessor( + (BasePropertyDeclarationSyntax)declaration, + property + ); + var normalized = NormalizeAccessorAccessibility(generator, initialized, property, target); + return WithAccessorBodies(generator, normalized, exceptionType); + } + + private static IEnumerable? AccessorStatements( + SyntaxGenerator generator, + IMethodSymbol? accessor, + ITypeSymbol exceptionType + ) + { + return accessor is null ? null : ThrowNotImplemented(generator, exceptionType); + } + + private static BasePropertyDeclarationSyntax NormalizeInitAccessor( + BasePropertyDeclarationSyntax declaration, + IPropertySymbol property + ) + { + if (property.SetMethod?.IsInitOnly != true || declaration.AccessorList is null) + { + return declaration; + } + + var accessors = declaration.AccessorList.Accessors.Select(ToInitAccessorIfSetter); + return declaration.WithAccessorList( + declaration.AccessorList.WithAccessors(SyntaxFactory.List(accessors)) + ); + } + + private static AccessorDeclarationSyntax ToInitAccessorIfSetter( + AccessorDeclarationSyntax accessor + ) + { + return accessor.IsKind(SyntaxKind.SetAccessorDeclaration) + ? ToInitAccessor(accessor) + : accessor; + } + + private static AccessorDeclarationSyntax ToInitAccessor(AccessorDeclarationSyntax accessor) + { + var keyword = SyntaxFactory.Token( + accessor.Keyword.LeadingTrivia, + SyntaxKind.InitKeyword, + accessor.Keyword.TrailingTrivia + ); + return SyntaxFactory + .AccessorDeclaration(SyntaxKind.InitAccessorDeclaration) + .WithAttributeLists(accessor.AttributeLists) + .WithModifiers(accessor.Modifiers) + .WithKeyword(keyword) + .WithBody(accessor.Body) + .WithExpressionBody(accessor.ExpressionBody) + .WithSemicolonToken(accessor.SemicolonToken); + } + + private static BasePropertyDeclarationSyntax NormalizeAccessorAccessibility( + SyntaxGenerator generator, + BasePropertyDeclarationSyntax declaration, + IPropertySymbol property, + INamedTypeSymbol target + ) + { + if (declaration.AccessorList is null) + { + return declaration; + } + + var memberAccess = OverrideAccessibility(property, target); + var accessors = declaration.AccessorList.Accessors.Select(accessor => + NormalizeAccessor(generator, accessor, property, target, memberAccess) + ); + return declaration.WithAccessorList( + declaration.AccessorList.WithAccessors(SyntaxFactory.List(accessors)) + ); + } + + private static BasePropertyDeclarationSyntax WithAccessorBodies( + SyntaxGenerator generator, + BasePropertyDeclarationSyntax declaration, + ITypeSymbol exceptionType + ) + { + if (declaration.AccessorList is null) + { + return declaration; + } + + var statements = ThrowNotImplemented(generator, exceptionType).ToImmutableArray(); + var accessors = declaration.AccessorList.Accessors.Select(accessor => + (AccessorDeclarationSyntax)generator.WithStatements(accessor, statements) + ); + return declaration.WithAccessorList( + declaration.AccessorList.WithAccessors(SyntaxFactory.List(accessors)) + ); + } + + private static AccessorDeclarationSyntax NormalizeAccessor( + SyntaxGenerator generator, + AccessorDeclarationSyntax accessor, + IPropertySymbol property, + INamedTypeSymbol target, + Accessibility memberAccess + ) + { + var symbol = accessor.IsKind(SyntaxKind.GetAccessorDeclaration) + ? property.GetMethod + : property.SetMethod; + var accessorAccess = symbol is null ? memberAccess : OverrideAccessibility(symbol, target); + var declaredAccess = + accessorAccess == memberAccess ? Accessibility.NotApplicable : accessorAccess; + return (AccessorDeclarationSyntax)generator.WithAccessibility(accessor, declaredAccess); + } + + private static BasePropertyDeclarationSyntax GenerateEvent( + SyntaxGenerator generator, + IEventSymbol @event, + ITypeSymbol exceptionType + ) + { + var declaration = (EventDeclarationSyntax) + generator.CustomEventDeclaration( + @event, + ThrowNotImplemented(generator, exceptionType), + ThrowNotImplemented(generator, exceptionType) + ); + return WithAccessorBodies(generator, declaration, exceptionType); + } + + private static IEnumerable ThrowNotImplemented( + SyntaxGenerator generator, + ITypeSymbol exceptionType + ) + { + return [generator.ThrowStatement(generator.ObjectCreationExpression(exceptionType))]; + } + + private static SyntaxNode MarkOverride( + SyntaxGenerator generator, + SyntaxNode declaration, + ISymbol member, + INamedTypeSymbol target + ) + { + var modifiers = OverrideModifiers(generator.GetModifiers(declaration)); + var updated = generator.WithModifiers(declaration, modifiers); + return generator.WithAccessibility(updated, OverrideAccessibility(member, target)); + } + + private static DeclarationModifiers OverrideModifiers(DeclarationModifiers modifiers) + { + return modifiers + .WithIsAbstract(false) + .WithIsVirtual(false) + .WithIsOverride(true) + .WithIsSealed(false) + .WithIsNew(false) + .WithAsync(false); + } + + private static Accessibility OverrideAccessibility(ISymbol member, INamedTypeSymbol target) + { + var crossAssembly = !SymbolEqualityComparer.Default.Equals( + member.ContainingAssembly, + target.ContainingAssembly + ); + return member.DeclaredAccessibility == Accessibility.ProtectedOrInternal && crossAssembly + ? Accessibility.Protected + : member.DeclaredAccessibility; + } +} diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/MergeDeclarationAssignmentCodeRefactoringProvider.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/MergeDeclarationAssignmentCodeRefactoringProvider.cs new file mode 100644 index 00000000..6ce3314a --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/MergeDeclarationAssignmentCodeRefactoringProvider.cs @@ -0,0 +1,209 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeRefactorings; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; +using Microsoft.CodeAnalysis.Formatting; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Merges an uninitialized local declaration with its immediately following assignment. +/// Implements [SHARPLSP-FEATURES-REFACTORING]. +/// +internal sealed class MergeDeclarationAssignmentCodeRefactoringProvider : CodeRefactoringProvider +{ + private const string Title = "Merge declaration and assignment"; + private const string EquivalenceKey = nameof(MergeDeclarationAssignmentCodeRefactoringProvider); + + public override async Task ComputeRefactoringsAsync(CodeRefactoringContext context) + { + var root = await context + .Document.GetSyntaxRootAsync(context.CancellationToken) + .ConfigureAwait(false); + var candidate = root is null ? null : FindCandidate(root, context.Span.Start); + if (candidate is null) + { + return; + } + + var model = await context + .Document.GetSemanticModelAsync(context.CancellationToken) + .ConfigureAwait(false); + if (model is null || !TargetsDeclaredLocal(model, candidate, context.CancellationToken)) + { + return; + } + + context.RegisterRefactoring(CreateAction(context.Document, candidate)); + } + + private static CodeAction CreateAction(Document document, Candidate candidate) + { + return CodeAction.Create( + Title, + cancellationToken => ApplyAsync(document, candidate, cancellationToken), + EquivalenceKey + ); + } + + private static Candidate? FindCandidate(SyntaxNode root, int position) + { + var statement = root.FindToken(position).Parent?.FirstAncestorOrSelf(); + return statement switch + { + LocalDeclarationStatementSyntax declaration => FromDeclaration(declaration), + ExpressionStatementSyntax assignment => FromAssignment(assignment), + _ => null, + }; + } + + private static Candidate? FromDeclaration(LocalDeclarationStatementSyntax declaration) + { + return AdjacentStatement(declaration, 1) is ExpressionStatementSyntax assignment + ? CreateCandidate(declaration, assignment) + : null; + } + + private static Candidate? FromAssignment(ExpressionStatementSyntax assignment) + { + return AdjacentStatement(assignment, -1) is LocalDeclarationStatementSyntax declaration + ? CreateCandidate(declaration, assignment) + : null; + } + + private static Candidate? CreateCandidate( + LocalDeclarationStatementSyntax declaration, + ExpressionStatementSyntax assignmentStatement + ) + { + return + IsEligibleDeclaration(declaration) + && !assignmentStatement.ContainsDirectives + && HasSafeTrivia(declaration, assignmentStatement) + && assignmentStatement.Expression is AssignmentExpressionSyntax assignment + && assignment.IsKind(SyntaxKind.SimpleAssignmentExpression) + && assignment.Left is IdentifierNameSyntax target + ? new Candidate( + declaration, + assignmentStatement, + declaration.Declaration.Variables[0], + assignment, + target + ) + : null; + } + + private static bool HasSafeTrivia( + LocalDeclarationStatementSyntax declaration, + ExpressionStatementSyntax assignment + ) + { + return IsLayoutTrivia(declaration.GetTrailingTrivia()) + && IsLayoutTrivia(assignment.GetLeadingTrivia()); + } + + private static bool IsLayoutTrivia(SyntaxTriviaList trivia) + { + return trivia.All(item => + item.IsKind(SyntaxKind.WhitespaceTrivia) || item.IsKind(SyntaxKind.EndOfLineTrivia) + ); + } + + private static bool IsEligibleDeclaration(LocalDeclarationStatementSyntax declaration) + { + return declaration.Modifiers.Count == 0 + && declaration.UsingKeyword.RawKind == 0 + && declaration.AwaitKeyword.RawKind == 0 + && !declaration.ContainsDirectives + && declaration.Declaration.Variables.Count == 1 + && declaration.Declaration.Variables[0].Initializer is null; + } + + private static StatementSyntax? AdjacentStatement(StatementSyntax statement, int offset) + { + var statements = ContainingStatements(statement); + var adjacentIndex = statements.IndexOf(statement) + offset; + return adjacentIndex >= 0 && adjacentIndex < statements.Count + ? statements[adjacentIndex] + : null; + } + + private static SyntaxList ContainingStatements(StatementSyntax statement) + { + return statement.Parent switch + { + BlockSyntax block => block.Statements, + SwitchSectionSyntax section => section.Statements, + _ => default, + }; + } + + private static bool TargetsDeclaredLocal( + SemanticModel model, + Candidate candidate, + CancellationToken cancellationToken + ) + { + var local = model.GetDeclaredSymbol(candidate.Variable, cancellationToken) as ILocalSymbol; + var target = model.GetSymbolInfo(candidate.Target, cancellationToken).Symbol; + return local is not null && SymbolEqualityComparer.Default.Equals(local, target); + } + + private static async Task ApplyAsync( + Document document, + Candidate candidate, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var editor = new SyntaxEditor(root, document.Project.Solution.Workspace.Services); + editor.ReplaceNode(candidate.Declaration, Merge(candidate)); + editor.RemoveNode(candidate.AssignmentStatement, SyntaxRemoveOptions.KeepNoTrivia); + return document.WithSyntaxRoot(editor.GetChangedRoot()); + } + + private static LocalDeclarationStatementSyntax Merge(Candidate candidate) + { + var initializer = SyntaxFactory.EqualsValueClause( + AssignmentOperator(candidate), + candidate.Assignment.Right + ); + var variable = candidate.Variable.WithInitializer(initializer); + var declaration = candidate.Declaration.Declaration.WithVariables( + SyntaxFactory.SingletonSeparatedList(variable) + ); + return candidate + .Declaration.WithDeclaration(declaration) + .WithLeadingTrivia(candidate.Declaration.GetLeadingTrivia()) + .WithTrailingTrivia(MergedTrailingTrivia(candidate)) + .WithAdditionalAnnotations(Formatter.Annotation); + } + + private static SyntaxToken AssignmentOperator(Candidate candidate) + { + var leadingTrivia = candidate + .Target.GetTrailingTrivia() + .AddRange(candidate.Assignment.OperatorToken.LeadingTrivia); + return candidate.Assignment.OperatorToken.WithLeadingTrivia(leadingTrivia); + } + + private static SyntaxTriviaList MergedTrailingTrivia(Candidate candidate) + { + return candidate.AssignmentStatement.GetTrailingTrivia(); + } + + private sealed record Candidate( + LocalDeclarationStatementSyntax Declaration, + ExpressionStatementSyntax AssignmentStatement, + VariableDeclaratorSyntax Variable, + AssignmentExpressionSyntax Assignment, + IdentifierNameSyntax Target + ); +} diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/MetadataNavigator.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/MetadataNavigator.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/MetadataNavigator.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/MetadataNavigator.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SemanticTokensResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SemanticTokensResolver.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/SemanticTokensResolver.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SemanticTokensResolver.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionLoader.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionLoader.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionLoader.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionLoader.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs new file mode 100644 index 00000000..5f5e4fe1 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs @@ -0,0 +1,330 @@ +// Implements [RENAME-CROSSLANGUAGE]. +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.FindSymbols; +using Microsoft.CodeAnalysis.Text; +using RenameEditResult = Outcome.Result; +using RenameIdentityQueryResult = Outcome.Result< + SharpLsp.Sidecar.CSharp.RenameIdentityResultWire, + string +>; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +internal sealed partial class WorkspaceManager +{ + private readonly record struct ForeignRenameKey(string AssemblyName, string XmlDocSig); + + private readonly record struct ForeignRenameRequest( + Solution Solution, + ForeignRenameKey Identity, + string NewName, + CancellationToken CancellationToken + ); + + private readonly record struct ForeignReference( + DocumentId DocumentId, + string FilePath, + TextSpan Span + ); + + public async Task GetRenameIdentityAsync( + string filePath, + int line, + int character, + CancellationToken ct = default + ) + { + try + { + var identity = await ResolveRenameIdentityAsync(filePath, line, character, ct) + .ConfigureAwait(false); + return new RenameIdentityQueryResult.Ok(identity); + } + catch (Exception ex) + { + return RenameIdentityQueryResult.Failure(ex.Message); + } + } + + private async Task ResolveRenameIdentityAsync( + string filePath, + int line, + int character, + CancellationToken ct + ) + { + var document = await FindDocumentAsync(filePath, ct).ConfigureAwait(false); + if (document is null) + { + return new RenameIdentityResultWire(); + } + + var target = await FindRenameTargetAsync(document, line, character, ct) + .ConfigureAwait(false); + return CreateRenameIdentity(target?.Symbol); + } + + private static RenameIdentityResultWire CreateRenameIdentity(ISymbol? symbol) + { + var assemblyName = symbol?.ContainingAssembly?.Name; + var xmlDocSig = symbol is null ? null : DocumentationCommentId.CreateDeclarationId(symbol); + return string.IsNullOrEmpty(assemblyName) || string.IsNullOrEmpty(xmlDocSig) + ? new RenameIdentityResultWire() + : new RenameIdentityResultWire + { + Found = true, + AssemblyName = assemblyName, + XmlDocSig = xmlDocSig, + }; + } + + public async Task RenameForeignAsync( + string assemblyName, + string xmlDocSig, + string newName, + CancellationToken ct = default + ) + { + try + { + return await RenameForeignCoreAsync(assemblyName, xmlDocSig, newName, ct) + .ConfigureAwait(false); + } + catch (Exception ex) + { + return RenameEditResult.Failure(ex.Message); + } + } + + private async Task RenameForeignCoreAsync( + string assemblyName, + string xmlDocSig, + string newName, + CancellationToken ct + ) + { + var solution = _solution; + if (solution is null || !IsForeignRequestValid(assemblyName, xmlDocSig, newName)) + { + return ForeignRenameSuccess([]); + } + + var request = CreateForeignRenameRequest(solution, assemblyName, xmlDocSig, newName, ct); + return await RenameLoadedForeignAsync(request).ConfigureAwait(false); + } + + private static ForeignRenameRequest CreateForeignRenameRequest( + Solution solution, + string assemblyName, + string xmlDocSig, + string newName, + CancellationToken ct + ) + { + return new ForeignRenameRequest( + solution, + new ForeignRenameKey(assemblyName, xmlDocSig), + newName, + ct + ); + } + + private static async Task RenameLoadedForeignAsync( + ForeignRenameRequest request + ) + { + var references = await FindCurrentForeignReferencesAsync(request).ConfigureAwait(false); + references = await CompleteForeignReferencesAsync(request, references) + .ConfigureAwait(false); + var edits = await BuildForeignEditsAsync( + request.Solution, + references, + request.NewName, + request.CancellationToken + ) + .ConfigureAwait(false); + return ForeignRenameSuccess(edits); + } + + private static bool IsForeignRequestValid(string assemblyName, string xmlDocSig, string newName) + { + return !string.IsNullOrWhiteSpace(assemblyName) + && !string.IsNullOrWhiteSpace(xmlDocSig) + && IsValidIdentifier(newName); + } + + private static async Task> FindCurrentForeignReferencesAsync( + ForeignRenameRequest request + ) + { + var symbols = await ResolveForeignSymbolsAsync( + request.Solution, + request.Identity, + request.CancellationToken + ) + .ConfigureAwait(false); + return await FindForeignReferencesAsync( + request.Solution, + symbols, + request.CancellationToken + ) + .ConfigureAwait(false); + } + + private static async Task> ResolveForeignSymbolsAsync( + Solution solution, + ForeignRenameKey identity, + CancellationToken ct + ) + { + var symbols = new List(); + foreach (var project in solution.Projects.Where(IsCSharpProject)) + { + var compilation = + await project.GetCompilationAsync(ct).ConfigureAwait(false) + ?? throw new InvalidOperationException( + $"Compilation unavailable for {project.Name}" + ); + AddMatchingSymbols(symbols, compilation, identity); + } + + return symbols; + } + + private static void AddMatchingSymbols( + List symbols, + Compilation compilation, + ForeignRenameKey identity + ) + { + var resolved = DocumentationCommentId.GetSymbolsForDeclarationId( + identity.XmlDocSig, + compilation + ); + symbols.AddRange(resolved.Where(symbol => MatchesAssembly(symbol, identity.AssemblyName))); + } + + private static bool IsCSharpProject(Project project) + { + return project.Language == LanguageNames.CSharp; + } + + private static bool MatchesAssembly(ISymbol symbol, string assemblyName) + { + return string.Equals( + symbol.ContainingAssembly?.Name, + assemblyName, + StringComparison.Ordinal + ); + } + + private static async Task> FindForeignReferencesAsync( + Solution solution, + IEnumerable symbols, + CancellationToken ct + ) + { + var references = new List(); + foreach (var symbol in symbols) + { + var found = await SymbolFinder + .FindReferencesAsync(symbol, solution, ct) + .ConfigureAwait(false); + AddForeignReferences(references, found); + } + + return DistinctReferences(references); + } + + private static void AddForeignReferences( + List references, + IEnumerable found + ) + { + foreach (var referencedSymbol in found) + { + references.AddRange( + referencedSymbol.Locations.Where(IsEditableReference).Select(MapReference) + ); + } + } + + private static bool IsEditableReference(ReferenceLocation location) + { + return location.Location.IsInSource + && location.Location.SourceSpan.Length > 0 + && location.Document.FilePath is not null + && IsCSharpProject(location.Document.Project); + } + + private static ForeignReference MapReference(ReferenceLocation location) + { + return new ForeignReference( + location.Document.Id, + location.Document.FilePath!, + location.Location.SourceSpan + ); + } + + private static List DistinctReferences( + IEnumerable references + ) + { + return + [ + .. references.DistinctBy(item => (item.DocumentId, item.Span.Start, item.Span.Length)), + ]; + } + + private static async Task> BuildForeignEditsAsync( + Solution solution, + IEnumerable references, + string newName, + CancellationToken ct + ) + { + var edits = new List(); + foreach (var group in references.GroupBy(item => item.DocumentId)) + { + var edit = await BuildForeignDocumentEditAsync(solution, group, newName, ct) + .ConfigureAwait(false); + if (edit is not null) + { + edits.Add(edit); + } + } + + return [.. edits.OrderBy(edit => edit.FilePath, StringComparer.OrdinalIgnoreCase)]; + } + + private static async Task BuildForeignDocumentEditAsync( + Solution solution, + IEnumerable references, + string newName, + CancellationToken ct + ) + { + var ordered = references.OrderBy(item => item.Span.Start).ToList(); + var first = ordered.First(); + var document = + solution.GetDocument(first.DocumentId) + ?? throw new InvalidOperationException( + $"Rename document unavailable: {first.FilePath}" + ); + + var text = await document.GetTextAsync(ct).ConfigureAwait(false); + var edits = ordered.Select(item => MapForeignEdit(text, item.Span, newName)).ToList(); + return new DocumentEditResult { FilePath = first.FilePath, Edits = edits }; + } + + private static TextEditResult MapForeignEdit(SourceText text, TextSpan span, string newName) + { + return DocumentText.ToTextEdit(text, new TextChange(span, newName)); + } + + private static RenameEditResult ForeignRenameSuccess(List edits) + { + var workspaceEdit = new WorkspaceEditResult { DocumentChanges = edits }; + return new RenameEditResult.Ok(workspaceEdit); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRenameProjection.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRenameProjection.cs new file mode 100644 index 00000000..db7c7dcf --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRenameProjection.cs @@ -0,0 +1,332 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +internal sealed partial class WorkspaceManager +{ + private readonly record struct ForeignProjection( + string CurrentName, + string NewName, + ForeignRenameKey MetadataIdentity + ); + + private readonly record struct ProjectionDocument( + Document Document, + SyntaxNode Root, + List Candidates, + ForeignProjection Projection + ); + + private readonly record struct ProjectionBinding( + ProjectionDocument Source, + Dictionary Annotations, + SyntaxNode Root, + SemanticModel Model + ); + + private static async Task> CompleteForeignReferencesAsync( + ForeignRenameRequest request, + List references + ) + { + return references.Count > 0 + ? references + : await FindProjectedForeignReferencesAsync( + request.Solution, + request.Identity, + request.NewName, + request.CancellationToken + ) + .ConfigureAwait(false); + } + + private static async Task> FindProjectedForeignReferencesAsync( + Solution solution, + ForeignRenameKey identity, + string newName, + CancellationToken ct + ) + { + var projection = CreateForeignProjection(identity, newName); + return projection is null + ? [] + : await FindVerifiedProjectedReferencesAsync(solution, projection.Value, ct) + .ConfigureAwait(false); + } + + private static async Task> FindVerifiedProjectedReferencesAsync( + Solution solution, + ForeignProjection projection, + CancellationToken ct + ) + { + var symbols = await ResolveForeignSymbolsAsync(solution, projection.MetadataIdentity, ct) + .ConfigureAwait(false); + return symbols.Count == 0 + ? [] + : await FindProjectedCandidatesAsync(solution, projection, ct).ConfigureAwait(false); + } + + private static ForeignProjection? CreateForeignProjection( + ForeignRenameKey identity, + string newName + ) + { + if (!TryFindXmlDocNameSpan(identity.XmlDocSig, out var span)) + { + return null; + } + + var currentName = identity.XmlDocSig.Substring(span.Start, span.Length); + var metadataName = SyntaxFactory.ParseToken(newName).ValueText; + var metadataXml = ReplaceXmlDocName(identity.XmlDocSig, span, metadataName); + var metadataIdentity = new ForeignRenameKey(identity.AssemblyName, metadataXml); + return currentName == metadataName + ? null + : new ForeignProjection(currentName, newName, metadataIdentity); + } + + private static string ReplaceXmlDocName(string xmlDocSig, TextSpan span, string newName) + { + return xmlDocSig[..span.Start] + newName + xmlDocSig[span.End..]; + } + + private static bool TryFindXmlDocNameSpan(string xmlDocSig, out TextSpan nameSpan) + { + nameSpan = default; + if (xmlDocSig.Length < 3 || xmlDocSig[1] != ':') + { + return false; + } + + var end = TrimXmlDocArity(xmlDocSig, FindXmlDocHeadEnd(xmlDocSig)); + var start = end; + while (start > 2 && SyntaxFacts.IsIdentifierPartCharacter(xmlDocSig[start - 1])) + { + start--; + } + + nameSpan = TextSpan.FromBounds(start, end); + return start < end; + } + + private static int FindXmlDocHeadEnd(string xmlDocSig) + { + for (var index = 2; index < xmlDocSig.Length; index++) + { + if (xmlDocSig[index] is '(' or '~') + { + return index; + } + } + + return xmlDocSig.Length; + } + + private static int TrimXmlDocArity(string xmlDocSig, int end) + { + var cursor = end; + while (cursor > 2 && char.IsAsciiDigit(xmlDocSig[cursor - 1])) + { + cursor--; + } + + if (cursor == end || cursor <= 2 || xmlDocSig[cursor - 1] != '`') + { + return end; + } + + cursor--; + return cursor > 2 && xmlDocSig[cursor - 1] == '`' ? cursor - 1 : cursor; + } + + private static async Task> FindProjectedCandidatesAsync( + Solution solution, + ForeignProjection projection, + CancellationToken ct + ) + { + var references = new List(); + var documents = solution.Projects.Where(IsCSharpProject).SelectMany(p => p.Documents); + foreach (var document in documents) + { + references.AddRange( + await FindProjectedDocumentReferencesAsync(document, projection, ct) + .ConfigureAwait(false) + ); + } + + return DistinctReferences(references); + } + + private static async Task> FindProjectedDocumentReferencesAsync( + Document document, + ForeignProjection projection, + CancellationToken ct + ) + { + var source = await CreateProjectionDocumentAsync(document, projection, ct) + .ConfigureAwait(false); + return source is null + ? [] + : await BindProjectionDocumentAsync(source.Value, ct).ConfigureAwait(false); + } + + private static async Task CreateProjectionDocumentAsync( + Document document, + ForeignProjection projection, + CancellationToken ct + ) + { + var root = + await document.GetSyntaxRootAsync(ct).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Syntax root unavailable for {document.Name}"); + var candidates = ProjectableTokens(root, projection.CurrentName); + if (candidates.Count == 0) + { + return null; + } + + _ = + document.FilePath + ?? throw new InvalidOperationException($"Rename path unavailable for {document.Name}"); + return new ProjectionDocument(document, root, candidates, projection); + } + + private static async Task> BindProjectionDocumentAsync( + ProjectionDocument source, + CancellationToken ct + ) + { + var annotations = CreateCandidateAnnotations(source.Candidates); + var root = ProjectProjectionRoot(source, annotations); + var projected = source.Document.WithSyntaxRoot(root); + var model = + await projected.GetSemanticModelAsync(ct).ConfigureAwait(false) + ?? throw new InvalidOperationException( + $"Semantic model unavailable for {source.Document.Name}" + ); + var semanticRoot = await model.SyntaxTree.GetRootAsync(ct).ConfigureAwait(false); + var binding = new ProjectionBinding(source, annotations, semanticRoot, model); + return BoundProjectedReferences(binding, ct); + } + + private static SyntaxNode ProjectProjectionRoot( + ProjectionDocument source, + Dictionary annotations + ) + { + return ProjectTokens( + source.Root, + source.Candidates, + annotations, + source.Projection.NewName + ); + } + + private static List ProjectableTokens(SyntaxNode root, string currentName) + { + return + [ + .. root.DescendantTokens(descendIntoTrivia: true) + .Where(token => token.IsKind(SyntaxKind.IdentifierToken)) + .Where(token => token.ValueText.Equals(currentName, StringComparison.Ordinal)), + ]; + } + + private static Dictionary CreateCandidateAnnotations( + IEnumerable candidates + ) + { + return candidates.ToDictionary( + token => token.SpanStart, + _ => new SyntaxAnnotation("ForeignRenameCandidate") + ); + } + + private static SyntaxNode ProjectTokens( + SyntaxNode root, + IEnumerable candidates, + Dictionary annotations, + string newName + ) + { + return root.ReplaceTokens( + candidates, + (token, _) => ProjectToken(token, newName, annotations[token.SpanStart]) + ); + } + + private static SyntaxToken ProjectToken( + SyntaxToken token, + string newName, + SyntaxAnnotation annotation + ) + { + return SyntaxFactory + .ParseToken(newName) + .WithLeadingTrivia(token.LeadingTrivia) + .WithTrailingTrivia(token.TrailingTrivia) + .WithAdditionalAnnotations(annotation); + } + + private static List BoundProjectedReferences( + ProjectionBinding binding, + CancellationToken ct + ) + { + var references = new List(); + foreach (var candidate in binding.Source.Candidates) + { + var annotation = binding.Annotations[candidate.SpanStart]; + if (ProjectedTokenMatches(binding, annotation, ct)) + { + references.Add(MapProjectedReference(binding.Source.Document, candidate)); + } + } + + return references; + } + + private static ForeignReference MapProjectedReference(Document document, SyntaxToken candidate) + { + return new ForeignReference(document.Id, document.FilePath!, candidate.Span); + } + + private static bool ProjectedTokenMatches( + ProjectionBinding binding, + SyntaxAnnotation annotation, + CancellationToken ct + ) + { + var token = binding.Root.GetAnnotatedTokens(annotation).Single(); + var symbol = token.Parent is { } parent + ? binding.Model.GetSymbolInfo(parent, ct).Symbol + : null; + return MatchesForeignIdentity(symbol, binding.Source.Projection.MetadataIdentity); + } + + private static bool MatchesForeignIdentity(ISymbol? symbol, ForeignRenameKey identity) + { + var canonical = CanonicalForeignSymbol(symbol); + var xmlDocSig = canonical is null + ? null + : DocumentationCommentId.CreateDeclarationId(canonical); + return canonical is not null + && MatchesAssembly(canonical, identity.AssemblyName) + && xmlDocSig == identity.XmlDocSig; + } + + private static ISymbol? CanonicalForeignSymbol(ISymbol? symbol) + { + return symbol switch + { + IAliasSymbol alias => alias.Target.OriginalDefinition, + IMethodSymbol { ReducedFrom: { } reduced } => reduced.OriginalDefinition, + null => null, + _ => symbol.OriginalDefinition, + }; + } +} diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs similarity index 99% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs index 3b6facd9..6394d1a1 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs @@ -93,7 +93,7 @@ CancellationToken ct // caret; extend it over any identifier characters that already follow the caret // so an accepted item REPLACES an existing member name instead of being appended // to it (`Console.WriteLineWriteLine`). GitHub #178. - // Implements [COMPLETION-EDIT-REPLACE]. + // Implements [SHARPLSP-FEATURES-INTELLIGENCE-COMPLETION-EDIT]. private static LinePositionSpan ComputeCompletionEditSpan( CompletionService service, SourceText text, diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Packages.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Packages.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Packages.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Packages.cs diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs new file mode 100644 index 00000000..b2ec115e --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs @@ -0,0 +1,442 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using PrepareRenameQueryResult = Outcome.Result< + SharpLsp.Sidecar.CSharp.PrepareRenameResult, + string +>; +using RenameEditResult = Outcome.Result; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +internal sealed partial class WorkspaceManager +{ + private readonly record struct RenameTarget( + Document Document, + SourceText Text, + ISymbol Symbol, + SyntaxToken Token + ); + + private readonly record struct RenameSolutions( + Solution Original, + Solution Renamed, + string NewName + ); + + private readonly record struct RenameDocuments(Document Old, Document New, string NewName); + + // Implements [RENAME-PREPARE] + /// Check whether the source identifier at the position can be renamed. + public async Task PrepareRenameAsync( + string filePath, + int line, + int character, + CancellationToken ct = default + ) + { + try + { + var target = await FindRenameTargetAsync(filePath, line, character, ct) + .ConfigureAwait(false); + return PrepareResult(target); + } + catch (Exception ex) + { + return PrepareRenameQueryResult.Failure(ex.Message); + } + } + + // Implements [RENAME-APPLY] + /// Rename a source identifier and return granular edits for every use. + public async Task RenameAsync( + string filePath, + int line, + int character, + string newName, + CancellationToken ct = default + ) + { + try + { + return await RenameCoreAsync(filePath, line, character, newName, ct) + .ConfigureAwait(false); + } + catch (Exception ex) + { + return RenameEditResult.Failure(ex.Message); + } + } + + private async Task RenameCoreAsync( + string filePath, + int line, + int character, + string newName, + CancellationToken ct + ) + { + var target = await FindRenameTargetAsync(filePath, line, character, ct) + .ConfigureAwait(false); + if (target is null || _solution is null || !CanUseNewName(target.Value, newName)) + { + return EmptyRenameResult(); + } + + var renamed = await RenameSolutionAsync(_solution, target.Value.Symbol, newName, ct) + .ConfigureAwait(false); + return await BuildRenameResultAsync(_solution, renamed, newName, ct).ConfigureAwait(false); + } + + private async Task FindRenameTargetAsync( + string filePath, + int line, + int character, + CancellationToken ct + ) + { + var document = + await FindDocumentAsync(filePath, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("Document not found"); + return await FindRenameTargetAsync(document, line, character, ct).ConfigureAwait(false); + } + + private static async Task FindRenameTargetAsync( + Document document, + int line, + int character, + CancellationToken ct + ) + { + var text = await document.GetTextAsync(ct).ConfigureAwait(false); + var position = text.Lines.GetPosition(new LinePosition(line, character)); + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + var token = root?.FindToken(position); + if (!IsIdentifierAtPosition(token, position)) + { + return null; + } + + var symbol = await FindSymbolAsync(document, position, ct).ConfigureAwait(false); + var targetSymbol = symbol is null ? null : RenameConflictTarget(symbol); + return targetSymbol is not null && IsSourceSymbol(targetSymbol) + ? new RenameTarget(document, text, targetSymbol, token!.Value) + : null; + } + + private static async Task FindSymbolAsync( + Document document, + int position, + CancellationToken ct + ) + { + return await Microsoft + .CodeAnalysis.FindSymbols.SymbolFinder.FindSymbolAtPositionAsync(document, position, ct) + .ConfigureAwait(false); + } + + private static bool IsIdentifierAtPosition(SyntaxToken? token, int position) + { + return token is { RawKind: (int)SyntaxKind.IdentifierToken } + && token.Value.Span.Contains(position); + } + + private static bool IsSourceSymbol(ISymbol symbol) + { + return symbol.Locations.Any(location => location.IsInSource); + } + + private static PrepareRenameQueryResult PrepareResult(RenameTarget? target) + { + if (target is null) + { + return PrepareSuccess(new PrepareRenameResult { CanRename = false }); + } + + var lineSpan = target.Value.Text.Lines.GetLinePositionSpan(target.Value.Token.Span); + return PrepareSuccess(CreatePrepareResult(target.Value.Token.Text, lineSpan)); + } + + private static PrepareRenameResult CreatePrepareResult( + string placeholder, + LinePositionSpan span + ) + { + return new PrepareRenameResult + { + CanRename = true, + StartLine = span.Start.Line, + StartCharacter = span.Start.Character, + EndLine = span.End.Line, + EndCharacter = span.End.Character, + Placeholder = placeholder, + }; + } + + private static PrepareRenameQueryResult PrepareSuccess(PrepareRenameResult result) + { + return new PrepareRenameQueryResult.Ok(result); + } + + private static bool CanUseNewName(RenameTarget target, string newName) + { + if (!IsValidIdentifier(newName) || newName == target.Token.Text) + { + return false; + } + + var valueText = SyntaxFactory.ParseToken(newName).ValueText; + return !HasDeclarationConflict(target.Symbol, valueText); + } + + private static bool IsValidIdentifier(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + var token = SyntaxFactory.ParseToken(name); + return token.IsKind(SyntaxKind.IdentifierToken) + && token.Text == name + && token.LeadingTrivia.Count == 0 + && token.TrailingTrivia.Count == 0; + } + + private static bool HasDeclarationConflict(ISymbol symbol, string valueText) + { + if (CanShadowContainingTypeMembers(symbol)) + { + return false; + } + + var target = RenameConflictTarget(symbol); + return target.ContainingType is { } type + ? HasDifferentSymbol(type.GetMembers(valueText), target) + : target is INamedTypeSymbol named ? HasNamedTypeConflict(named, valueText) + : target is INamespaceSymbol ns && HasNamespaceConflict(ns, valueText); + } + + /// + /// Locals, parameters, type parameters and range variables legally shadow a + /// same-named member of the enclosing type, so a member collision is not a + /// redeclaration conflict for them. is + /// non-null for all of these (it is the type owning the enclosing method), so + /// without this guard the member scan below would reject a legal rename and + /// return an empty edit — which the host maps to LSP null, making the + /// rename silently do nothing. Their real scope conflicts are detected by + /// Roslyn's Renamer, which is what resolves them. + /// + private static bool CanShadowContainingTypeMembers(ISymbol symbol) + { + return symbol.Kind + is SymbolKind.Local + or SymbolKind.Parameter + or SymbolKind.TypeParameter + or SymbolKind.RangeVariable; + } + + private static ISymbol RenameConflictTarget(ISymbol symbol) + { + return symbol is IMethodSymbol { MethodKind: MethodKind.Constructor } constructor + ? constructor.ContainingType + : symbol; + } + + private static bool HasDifferentSymbol(IEnumerable candidates, ISymbol target) + { + return candidates.Any(candidate => + !SymbolEqualityComparer.Default.Equals(candidate, target) + ); + } + + private static bool HasNamedTypeConflict(INamedTypeSymbol symbol, string valueText) + { + var candidates = + symbol.ContainingType?.GetTypeMembers(valueText).Cast() + ?? symbol.ContainingNamespace.GetTypeMembers(valueText); + return HasDifferentSymbol(candidates, symbol); + } + + private static bool HasNamespaceConflict(INamespaceSymbol symbol, string valueText) + { + return HasDifferentSymbol(symbol.ContainingNamespace.GetMembers(valueText), symbol); + } + + private static Task RenameSolutionAsync( + Solution solution, + ISymbol symbol, + string newName, + CancellationToken ct + ) + { + return Microsoft.CodeAnalysis.Rename.Renamer.RenameSymbolAsync( + solution, + symbol, + new Microsoft.CodeAnalysis.Rename.SymbolRenameOptions(), + newName, + ct + ); + } + + private static async Task BuildRenameResultAsync( + Solution original, + Solution renamed, + string newName, + CancellationToken ct + ) + { + var edits = new List(); + var solutions = new RenameSolutions(original, renamed, newName); + foreach (var projectChange in renamed.GetChanges(original).GetProjectChanges()) + { + await AddChangedDocumentsAsync(solutions, projectChange, edits, ct) + .ConfigureAwait(false); + } + + return RenameSuccess(new WorkspaceEditResult { DocumentChanges = edits }); + } + + private static async Task AddChangedDocumentsAsync( + RenameSolutions solutions, + ProjectChanges projectChange, + List result, + CancellationToken ct + ) + { + foreach (var documentId in projectChange.GetChangedDocuments()) + { + var edit = await BuildDocumentRenameEditAsync(solutions, documentId, ct) + .ConfigureAwait(false); + if (edit is not null) + { + result.Add(edit); + } + } + } + + private static async Task BuildDocumentRenameEditAsync( + RenameSolutions solutions, + DocumentId documentId, + CancellationToken ct + ) + { + var documents = GetRenameDocuments(solutions, documentId); + return documents is null + ? null + : await CreateDocumentRenameEditAsync(documents.Value, ct).ConfigureAwait(false); + } + + private static RenameDocuments? GetRenameDocuments( + RenameSolutions solutions, + DocumentId documentId + ) + { + var oldDocument = solutions.Original.GetDocument(documentId); + var newDocument = solutions.Renamed.GetDocument(documentId); + return oldDocument?.FilePath is null || newDocument is null + ? null + : new RenameDocuments(oldDocument, newDocument, solutions.NewName); + } + + private static async Task CreateDocumentRenameEditAsync( + RenameDocuments documents, + CancellationToken ct + ) + { + var edits = await ComputeRenameEditsAsync( + documents.Old, + documents.New, + documents.NewName, + ct + ) + .ConfigureAwait(false); + return edits.Count == 0 + ? null + : new DocumentEditResult { FilePath = documents.Old.FilePath!, Edits = edits }; + } + + private static async Task> ComputeRenameEditsAsync( + Document oldDocument, + Document newDocument, + string newName, + CancellationToken ct + ) + { + var oldText = await oldDocument.GetTextAsync(ct).ConfigureAwait(false); + var oldRoot = await oldDocument.GetSyntaxRootAsync(ct).ConfigureAwait(false); + var changes = await newDocument.GetTextChangesAsync(oldDocument, ct).ConfigureAwait(false); + return + [ + .. changes + .Select(change => ExpandRenameEdit(oldText, oldRoot, change, newName)) + .DistinctBy(EditLocation), + ]; + } + + private static TextEditResult ExpandRenameEdit( + SourceText oldText, + SyntaxNode? oldRoot, + TextChange change, + string newName + ) + { + var token = FindChangedIdentifier(oldRoot, change, newName); + var expanded = token.RawKind == 0 ? change : new TextChange(token.Span, newName); + return DocumentText.ToTextEdit(oldText, expanded); + } + + private static SyntaxToken FindChangedIdentifier( + SyntaxNode? root, + TextChange change, + string newName + ) + { + return root is null + ? default + : ChangeCandidatePositions(root, change) + .Select(position => root.FindToken(position, findInsideTrivia: true)) + .FirstOrDefault(token => RewritesIdentifierTo(token, change, newName)); + } + + private static IEnumerable ChangeCandidatePositions(SyntaxNode root, TextChange change) + { + return new[] + { + change.Span.Start, + change.Span.End, + change.Span.Start - 1, + change.Span.End - 1, + } + .Where(position => position >= 0 && position < root.FullSpan.End) + .Distinct(); + } + + private static bool RewritesIdentifierTo(SyntaxToken token, TextChange change, string newName) + { + if (!token.IsKind(SyntaxKind.IdentifierToken) || !token.Span.Contains(change.Span)) + { + return false; + } + + var start = change.Span.Start - token.Span.Start; + var end = change.Span.End - token.Span.Start; + var rewritten = token.Text[..start] + (change.NewText ?? "") + token.Text[end..]; + return string.Equals(rewritten, newName, StringComparison.Ordinal); + } + + private static (int, int, int, int) EditLocation(TextEditResult edit) + { + return (edit.StartLine, edit.StartCharacter, edit.EndLine, edit.EndCharacter); + } + + private static RenameEditResult EmptyRenameResult() + { + return RenameSuccess(new WorkspaceEditResult()); + } + + private static RenameEditResult RenameSuccess(WorkspaceEditResult edit) + { + return new RenameEditResult.Ok(edit); + } +} diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SingleFile.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SingleFile.cs similarity index 94% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SingleFile.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SingleFile.cs index c2d22661..7e6be6f9 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SingleFile.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SingleFile.cs @@ -17,13 +17,13 @@ internal enum ProjectlessKind /// /// Loads project-less C# documents: .NET file-based apps (.cs with #: directives) -/// and Roslyn scripts (.csx). Implements [FILEBASED], [CSX]. +/// and Roslyn scripts (.csx). Implements [SCRIPT-FILEBASED], [SCRIPT-CSX]. /// internal sealed partial class WorkspaceManager { private AdhocWorkspace? _adhocWorkspace; - // Script default imports, matching Roslyn's scripting host. Implements [CSX-OPTIONS]. + // Script default imports, matching Roslyn's scripting host. Implements [SCRIPT-CSX-OPTIONS]. private static readonly string[] ScriptImports = [ "System", @@ -147,14 +147,14 @@ private static ProjectInfo BuildProjectInfo(ProjectlessKind kind, string rootPat compilationOptions: BuildCompilationOptions(isScript, rootPath), parseOptions: BuildParseOptions(isScript), // Tier 2 reference resolution: in-memory BCL only. `#:package` symbols do not bind - // until the synthesized-project path lands. [FILEBASED-REFERENCES-FALLBACK] + // until the synthesized-project path lands. [SCRIPT-FILEBASED-REFERENCES-FALLBACK] metadataReferences: Basic.Reference.Assemblies.Net100.References.All ); } // Scripts need a SourceReferenceResolver rooted at the script's directory, otherwise Roslyn // reports CS8099 "Source file references are not supported" for every #load. - // Implements [CSX-RESOLVERS]. + // Implements [SCRIPT-CSX-RESOLVERS]. private static CSharpCompilationOptions BuildCompilationOptions(bool isScript, string rootPath) { var options = new CSharpCompilationOptions( @@ -172,11 +172,11 @@ private static CSharpCompilationOptions BuildCompilationOptions(bool isScript, s } // LanguageVersion.Latest, not Preview: Preview enables unstable features the user's SDK may - // reject, producing editor-only false negatives. Implements [FILEBASED-PARSEOPTIONS]. + // reject, producing editor-only false negatives. Implements [SCRIPT-FILEBASED-PARSEOPTIONS]. // // The FileBasedProgram feature flag is what unlocks `#!` and `#:` in a Regular compilation — // without it Roslyn reports CS9314/CS9313. The .NET SDK passes the same flag to csc when it - // builds a file-based app. Implements [FILEBASED-SHEBANG], [FILEBASED-DIRECTIVES]. + // builds a file-based app. Implements [SCRIPT-FILEBASED-SHEBANG], [SCRIPT-FILEBASED-DIRECTIVES]. private static CSharpParseOptions BuildParseOptions(bool isScript) { var options = new CSharpParseOptions( @@ -190,7 +190,7 @@ private static CSharpParseOptions BuildParseOptions(bool isScript) // CSharpCompilationOptions.Usings is honored only for SourceCodeKind.Script. A regular // compilation gets its implicit usings from a generated source file, exactly as the SDK - // emits obj///.GlobalUsings.g.cs. Implements [FILEBASED-REFERENCES]. + // emits obj///.GlobalUsings.g.cs. Implements [SCRIPT-FILEBASED-REFERENCES]. private const string GlobalUsingsFileName = "SharpLsp.ImplicitUsings.g.cs"; private static DocumentInfo BuildGlobalUsingsInfo(ProjectId projectId, string rootPath) @@ -211,7 +211,7 @@ private static DocumentInfo BuildGlobalUsingsInfo(ProjectId projectId, string ro // A Document's SourceCodeKind is per-document and defaults to Regular; the project's // parseOptions kind does not propagate to it. Without this a `.csx` document reports - // "#load is only allowed in scripts". Implements [CSX-OPTIONS]. + // "#load is only allowed in scripts". Implements [SCRIPT-CSX-OPTIONS]. private static DocumentInfo BuildDocumentInfo( ProjectId projectId, ClosureFile file, diff --git a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs similarity index 81% rename from sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs rename to src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs index c8758ae7..4e31e9ca 100644 --- a/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs @@ -1,3 +1,4 @@ +// Navigation methods implement [DEFINITION-CSHARP]. using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.MSBuild; using Microsoft.CodeAnalysis.Text; @@ -32,12 +33,7 @@ >; using HoverQueryResult = Outcome.Result; using ImplementationsResult = Outcome.Result; -using PrepareRenameQueryResult = Outcome.Result< - SharpLsp.Sidecar.CSharp.PrepareRenameResult, - string ->; using ReferencesResult = Outcome.Result; -using RenameEditResult = Outcome.Result; using ResolveResult = Outcome.Result; using VoidResult = Outcome.Result; @@ -582,7 +578,7 @@ public Task GetDocumentHighlightsAsync( // Names every candidate so the user can copy one straight into sharplsp.toml, and names // the setting so the message is actionable rather than merely descriptive. - // Implements [SCRIPT-DEGRADE] and [WORKSPACE-SOLUTION-PATH]. + // Implements [SCRIPT-DEGRADE] and [SHARPLSP-ARCHITECTURE-PROJECTS-SOLUTION-PATH]. private static string AmbiguousSolutionMessage(string path, string[] candidates) { var names = string.Join(", ", candidates.Select(Path.GetFileName)); @@ -866,185 +862,4 @@ private static string NormalizedPath(string path) { return SharpLsp.Sidecar.Common.NativePaths.NormalizeFullPath(path); } - - // Implements [RENAME-PREPARE] - /// Check whether the symbol at the given position can be renamed. - public async Task PrepareRenameAsync( - string filePath, - int line, - int character, - CancellationToken ct = default - ) - { - try - { - var document = await FindDocumentAsync(filePath, ct).ConfigureAwait(false); - if (document is null) - { - return PrepareRenameQueryResult.Failure("Document not found"); - } - - var (text, position, symbol) = await FindSymbolAtLineCharacterAsync( - document, - line, - character, - ct - ) - .ConfigureAwait(false); - - if (symbol is null or INamespaceSymbol) - { - return new PrepareRenameQueryResult.Ok( - new PrepareRenameResult { CanRename = false } - ); - } - - var syntaxRoot = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); - var token = syntaxRoot?.FindToken(position); - if (token is null || !token.Value.Span.Contains(position)) - { - return new PrepareRenameQueryResult.Ok( - new PrepareRenameResult { CanRename = false } - ); - } - - var span = token.Value.Span; - var lineSpan = text.Lines.GetLinePositionSpan(span); - return new PrepareRenameQueryResult.Ok( - new PrepareRenameResult - { - CanRename = true, - StartLine = lineSpan.Start.Line, - StartCharacter = lineSpan.Start.Character, - EndLine = lineSpan.End.Line, - EndCharacter = lineSpan.End.Character, - Placeholder = symbol.Name, - } - ); - } - catch (Exception ex) - { - return PrepareRenameQueryResult.Failure(ex.Message); - } - } - - // Implements [RENAME-APPLY] - /// Rename the symbol at the given position to . - public async Task RenameAsync( - string filePath, - int line, - int character, - string newName, - CancellationToken ct = default - ) - { - try - { - var document = await FindDocumentAsync(filePath, ct).ConfigureAwait(false); - if (document is null || _solution is null) - { - return RenameEditResult.Failure("Document or solution not available"); - } - - var (_, _, symbol) = await FindSymbolAtLineCharacterAsync(document, line, character, ct) - .ConfigureAwait(false); - - if (symbol is null) - { - return new RenameEditResult.Ok( - new WorkspaceEditResult() - ); - } - - var renamedSolution = await Microsoft - .CodeAnalysis.Rename.Renamer.RenameSymbolAsync( - _solution, - symbol, - new Microsoft.CodeAnalysis.Rename.SymbolRenameOptions(), - newName, - ct - ) - .ConfigureAwait(false); - - var changes = renamedSolution.GetChanges(_solution); - var documentChanges = new List(); - foreach (var projectChange in changes.GetProjectChanges()) - { - foreach (var docId in projectChange.GetChangedDocuments()) - { - var oldDoc = _solution.GetDocument(docId); - var newDoc = renamedSolution.GetDocument(docId); - if (oldDoc is null || newDoc is null) - { - continue; - } - - var oldText = await oldDoc.GetTextAsync(ct).ConfigureAwait(false); - var rawNewText = await newDoc.GetTextAsync(ct).ConfigureAwait(false); - // Normalize to the same SourceText subtype so GetTextChanges - // produces granular diffs rather than a single whole-document replacement. - var newText = SourceText.From(rawNewText.ToString(), oldText.Encoding); - var textChanges = newText.GetTextChanges(oldText); - var edits = textChanges - .Select(change => - { - var changeSpan = oldText.Lines.GetLinePositionSpan(change.Span); - return new TextEditResult - { - StartLine = changeSpan.Start.Line, - StartCharacter = changeSpan.Start.Character, - EndLine = changeSpan.End.Line, - EndCharacter = changeSpan.End.Character, - NewText = change.NewText ?? string.Empty, - }; - }) - .ToList(); - - if (edits.Count > 0) - { - documentChanges.Add( - new DocumentEditResult - { - FilePath = oldDoc.FilePath ?? filePath, - Edits = edits, - } - ); - } - } - } - - return new RenameEditResult.Ok( - new WorkspaceEditResult { DocumentChanges = documentChanges } - ); - } - catch (Exception ex) - { - return RenameEditResult.Failure(ex.Message); - } - } - - /// - /// Fetch the document's source text and the symbol at the given - /// (, ) position. Returns the - /// text so callers needing it for further work avoid a second fetch. Collapses - /// the identical preamble shared by the prepare-rename and rename flows. - /// - private static async Task<( - SourceText Text, - int Position, - ISymbol? Symbol - )> FindSymbolAtLineCharacterAsync( - Document document, - int line, - int character, - CancellationToken ct - ) - { - var text = await document.GetTextAsync(ct).ConfigureAwait(false); - var position = text.Lines[line].Start + character; - var symbol = await Microsoft - .CodeAnalysis.FindSymbols.SymbolFinder.FindSymbolAtPositionAsync(document, position, ct) - .ConfigureAwait(false); - return (text, position, symbol); - } } diff --git a/sidecars/SharpLsp.Sidecar.Common.Tests/EnvelopeTests.cs b/src/sidecars/SharpLsp.Sidecar.Common.Tests/EnvelopeTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common.Tests/EnvelopeTests.cs rename to src/sidecars/SharpLsp.Sidecar.Common.Tests/EnvelopeTests.cs diff --git a/sidecars/SharpLsp.Sidecar.Common.Tests/FramedTransportTests.cs b/src/sidecars/SharpLsp.Sidecar.Common.Tests/FramedTransportTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common.Tests/FramedTransportTests.cs rename to src/sidecars/SharpLsp.Sidecar.Common.Tests/FramedTransportTests.cs diff --git a/sidecars/SharpLsp.Sidecar.Common.Tests/IpcConnectionTests.cs b/src/sidecars/SharpLsp.Sidecar.Common.Tests/IpcConnectionTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common.Tests/IpcConnectionTests.cs rename to src/sidecars/SharpLsp.Sidecar.Common.Tests/IpcConnectionTests.cs diff --git a/sidecars/SharpLsp.Sidecar.Common.Tests/MessageRouterTests.cs b/src/sidecars/SharpLsp.Sidecar.Common.Tests/MessageRouterTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common.Tests/MessageRouterTests.cs rename to src/sidecars/SharpLsp.Sidecar.Common.Tests/MessageRouterTests.cs diff --git a/sidecars/SharpLsp.Sidecar.Common.Tests/MetadataDecompilerTests.cs b/src/sidecars/SharpLsp.Sidecar.Common.Tests/MetadataDecompilerTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common.Tests/MetadataDecompilerTests.cs rename to src/sidecars/SharpLsp.Sidecar.Common.Tests/MetadataDecompilerTests.cs diff --git a/sidecars/SharpLsp.Sidecar.Common.Tests/NativePathsTests.cs b/src/sidecars/SharpLsp.Sidecar.Common.Tests/NativePathsTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common.Tests/NativePathsTests.cs rename to src/sidecars/SharpLsp.Sidecar.Common.Tests/NativePathsTests.cs diff --git a/sidecars/SharpLsp.Sidecar.Common.Tests/ProjectReferencesTests.cs b/src/sidecars/SharpLsp.Sidecar.Common.Tests/ProjectReferencesTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common.Tests/ProjectReferencesTests.cs rename to src/sidecars/SharpLsp.Sidecar.Common.Tests/ProjectReferencesTests.cs diff --git a/sidecars/SharpLsp.Sidecar.Common.Tests/SerilogGlobalLoggerCollection.cs b/src/sidecars/SharpLsp.Sidecar.Common.Tests/SerilogGlobalLoggerCollection.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common.Tests/SerilogGlobalLoggerCollection.cs rename to src/sidecars/SharpLsp.Sidecar.Common.Tests/SerilogGlobalLoggerCollection.cs diff --git a/sidecars/SharpLsp.Sidecar.Common.Tests/SharpLsp.Sidecar.Common.Tests.csproj b/src/sidecars/SharpLsp.Sidecar.Common.Tests/SharpLsp.Sidecar.Common.Tests.csproj similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common.Tests/SharpLsp.Sidecar.Common.Tests.csproj rename to src/sidecars/SharpLsp.Sidecar.Common.Tests/SharpLsp.Sidecar.Common.Tests.csproj diff --git a/sidecars/SharpLsp.Sidecar.Common.Tests/SidecarDependencyTests.cs b/src/sidecars/SharpLsp.Sidecar.Common.Tests/SidecarDependencyTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common.Tests/SidecarDependencyTests.cs rename to src/sidecars/SharpLsp.Sidecar.Common.Tests/SidecarDependencyTests.cs diff --git a/sidecars/SharpLsp.Sidecar.Common.Tests/SidecarHostEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.Common.Tests/SidecarHostEndToEndTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common.Tests/SidecarHostEndToEndTests.cs rename to src/sidecars/SharpLsp.Sidecar.Common.Tests/SidecarHostEndToEndTests.cs diff --git a/sidecars/SharpLsp.Sidecar.Common.Tests/SidecarLogTests.cs b/src/sidecars/SharpLsp.Sidecar.Common.Tests/SidecarLogTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common.Tests/SidecarLogTests.cs rename to src/sidecars/SharpLsp.Sidecar.Common.Tests/SidecarLogTests.cs diff --git a/sidecars/SharpLsp.Sidecar.Common.Tests/SolutionFileReaderTests.cs b/src/sidecars/SharpLsp.Sidecar.Common.Tests/SolutionFileReaderTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common.Tests/SolutionFileReaderTests.cs rename to src/sidecars/SharpLsp.Sidecar.Common.Tests/SolutionFileReaderTests.cs diff --git a/sidecars/SharpLsp.Sidecar.Common.Tests/UnitTest1.cs b/src/sidecars/SharpLsp.Sidecar.Common.Tests/UnitTest1.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common.Tests/UnitTest1.cs rename to src/sidecars/SharpLsp.Sidecar.Common.Tests/UnitTest1.cs diff --git a/sidecars/SharpLsp.Sidecar.Common.Tests/XmlDocRendererTests.cs b/src/sidecars/SharpLsp.Sidecar.Common.Tests/XmlDocRendererTests.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common.Tests/XmlDocRendererTests.cs rename to src/sidecars/SharpLsp.Sidecar.Common.Tests/XmlDocRendererTests.cs diff --git a/sidecars/SharpLsp.Sidecar.Common/Hover/XmlDocRenderer.cs b/src/sidecars/SharpLsp.Sidecar.Common/Hover/XmlDocRenderer.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common/Hover/XmlDocRenderer.cs rename to src/sidecars/SharpLsp.Sidecar.Common/Hover/XmlDocRenderer.cs diff --git a/sidecars/SharpLsp.Sidecar.Common/Ipc/FramedTransport.cs b/src/sidecars/SharpLsp.Sidecar.Common/Ipc/FramedTransport.cs similarity index 98% rename from sidecars/SharpLsp.Sidecar.Common/Ipc/FramedTransport.cs rename to src/sidecars/SharpLsp.Sidecar.Common/Ipc/FramedTransport.cs index a3f40a58..31e1eb90 100644 --- a/sidecars/SharpLsp.Sidecar.Common/Ipc/FramedTransport.cs +++ b/src/sidecars/SharpLsp.Sidecar.Common/Ipc/FramedTransport.cs @@ -13,7 +13,7 @@ public sealed class FramedTransport : IAsyncDisposable /// same-user processes, so this is a robustness/DoS guard rather than a /// trust boundary: it stops a corrupt or runaway 4-byte length prefix from /// forcing a multi-gigabyte allocation. Mirrors MAX_FRAME_LEN in the - /// Rust host transport (src/sidecar/transport.rs). + /// Rust host transport (src/sharplsp/src/sidecar/transport.rs). /// private const uint MaxFrameLength = 64 * 1024 * 1024; diff --git a/sidecars/SharpLsp.Sidecar.Common/Ipc/IpcConnection.cs b/src/sidecars/SharpLsp.Sidecar.Common/Ipc/IpcConnection.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common/Ipc/IpcConnection.cs rename to src/sidecars/SharpLsp.Sidecar.Common/Ipc/IpcConnection.cs diff --git a/sidecars/SharpLsp.Sidecar.Common/Ipc/MessageRouter.cs b/src/sidecars/SharpLsp.Sidecar.Common/Ipc/MessageRouter.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common/Ipc/MessageRouter.cs rename to src/sidecars/SharpLsp.Sidecar.Common/Ipc/MessageRouter.cs diff --git a/sidecars/SharpLsp.Sidecar.Common/Logging/SidecarLog.cs b/src/sidecars/SharpLsp.Sidecar.Common/Logging/SidecarLog.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common/Logging/SidecarLog.cs rename to src/sidecars/SharpLsp.Sidecar.Common/Logging/SidecarLog.cs diff --git a/sidecars/SharpLsp.Sidecar.Common/Messages/Envelope.cs b/src/sidecars/SharpLsp.Sidecar.Common/Messages/Envelope.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common/Messages/Envelope.cs rename to src/sidecars/SharpLsp.Sidecar.Common/Messages/Envelope.cs diff --git a/sidecars/SharpLsp.Sidecar.Common/MetadataDecompiler.cs b/src/sidecars/SharpLsp.Sidecar.Common/MetadataDecompiler.cs similarity index 100% rename from sidecars/SharpLsp.Sidecar.Common/MetadataDecompiler.cs rename to src/sidecars/SharpLsp.Sidecar.Common/MetadataDecompiler.cs diff --git a/sidecars/SharpLsp.Sidecar.Common/NativePaths.cs b/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.cs similarity index 97% rename from sidecars/SharpLsp.Sidecar.Common/NativePaths.cs rename to src/sidecars/SharpLsp.Sidecar.Common/NativePaths.cs index 751de813..32251867 100644 --- a/sidecars/SharpLsp.Sidecar.Common/NativePaths.cs +++ b/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.cs @@ -7,7 +7,7 @@ namespace SharpLsp.Sidecar.Common; /// (std::fs::canonicalize) which produces the prefixed form on /// Windows, while MSBuild, Roslyn, and FCS report normal-form paths — both /// spellings must compare equal. Mirrors strip_verbatim in the host -/// (src/vfs.rs). [GitHub #110] +/// (src/sharplsp/src/vfs.rs). [GitHub #110] /// public static class NativePaths { diff --git a/sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj b/src/sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj similarity index 93% rename from sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj rename to src/sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj index 4d847645..f10f6075 100644 --- a/sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj +++ b/src/sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj @@ -6,7 +6,7 @@ SharpLsp.Sidecar.Common enable enable - {{ title | default(site.title) }} - + {% if site.author %}{% endif %} {% if site.keywords %}{% endif %} - - - - - + @@ -47,10 +45,10 @@ - + - + {% for langCode in supportedLanguages %}{% if langCode != (lang | default('en')) %} @@ -66,7 +64,7 @@ - + {% if site.twitterSite %}{% endif %} {% if site.twitterCreator %}{% endif %} {% if site.ogImage %}{% endif %} @@ -81,7 +79,7 @@ "@id": "{{ site.url }}/#website", "url": "{{ site.url }}/", "name": "{{ site.title }}", - "description": "{{ site.description }}", + "description": {{ i18n[pageLang].home.lede | default(site.description) | dump | safe }}, "inLanguage": "{{ lang | default('en') }}"{% if site.searchUrl %}, "potentialAction": { "@type": "SearchAction", @@ -90,11 +88,11 @@ }{% endif %} }, { - "@type": "{% if page.url.startsWith('/docs/') %}TechArticle{% elif page.url.startsWith('/blog/') and page.url != '/blog/' %}BlogPosting{% else %}WebPage{% endif %}", + "@type": "{% if page.url.startsWith('/docs/') or page.url.startsWith('/' + pageLang + '/docs/') %}TechArticle{% elif (page.url.startsWith('/blog/') or page.url.startsWith('/' + pageLang + '/blog/')) and page.url != '/blog/' and page.url != '/' + pageLang + '/blog/' %}BlogPosting{% else %}WebPage{% endif %}", "@id": "{{ site.url }}{{ page.url }}#webpage", "url": "{{ site.url }}{{ page.url }}", "name": "{{ title | default(site.title) }}", - "description": "{{ description | default(site.description) }}", + "description": {{ pageDescription | dump | safe }}, "isPartOf": { "@id": "{{ site.url }}/#website" }, "inLanguage": "{{ lang | default('en') }}"{% if page.date %}, "datePublished": "{{ page.date | isoDate }}"{% endif %}{% if author %}, @@ -110,7 +108,7 @@ { "@type": "ListItem", "position": 1, - "name": "Home", + "name": "{{ 'nav.home' | t(pageLang) | default('Home') }}", "item": "{{ site.url }}/" }{% if page.url != '/' %}, { @@ -143,10 +141,12 @@ + + {% block head %}{% endblock %} - +