From a1134472e095249646e7c5c9aab6ce9e4d4998ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:34:06 +0000 Subject: [PATCH 001/151] build(deps): bump the rust-host group with 3 updates Bumps the rust-host group with 3 updates: [tree-sitter](https://github.com/tree-sitter/tree-sitter), [serde_json](https://github.com/serde-rs/json) and [toml](https://github.com/toml-rs/toml). Updates `tree-sitter` from 0.27.0 to 0.27.1 - [Release notes](https://github.com/tree-sitter/tree-sitter/releases) - [Commits](https://github.com/tree-sitter/tree-sitter/compare/v0.27.0...v0.27.1) Updates `serde_json` from 1.0.151 to 1.0.154 - [Release notes](https://github.com/serde-rs/json/releases) - [Commits](https://github.com/serde-rs/json/compare/v1.0.151...v1.0.154) Updates `toml` from 1.1.6+spec-1.1.0 to 1.1.8+spec-1.1.0 - [Commits](https://github.com/toml-rs/toml/compare/toml-v1.1.6...toml-v1.1.8) --- updated-dependencies: - dependency-name: tree-sitter dependency-version: 0.27.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: rust-host - dependency-name: serde_json dependency-version: 1.0.154 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: rust-host - dependency-name: toml dependency-version: 1.1.8+spec-1.1.0 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: rust-host ... Signed-off-by: dependabot[bot] --- Cargo.lock | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c78abaa..06101877 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -190,6 +190,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -273,7 +279,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1088,10 +1094,11 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash 0.2.0", "indexmap", "itoa", "memchr", @@ -1113,9 +1120,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -1448,9 +1455,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -1463,27 +1470,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -1619,9 +1626,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2038684e0058edba0d17302619f62eabce4a8e11c6ac59506996a8d79848851d" +checksum = "a5129de2ccc97808c4583d48f1fb44576d015cf241e432800572bda11ef30c3b" dependencies = [ "cc", "regex", @@ -1652,9 +1659,9 @@ dependencies = [ [[package]] name = "tree-sitter-language" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" +checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a" [[package]] name = "try-lock" From 511172185a8367d2404ef09e77226994058a2825 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:35:00 +0000 Subject: [PATCH 002/151] build(deps): bump toml in /src/editors/zed in the rust-zed group Bumps the rust-zed group in /src/editors/zed with 1 update: [toml](https://github.com/toml-rs/toml). Updates `toml` from 1.1.6+spec-1.1.0 to 1.1.8+spec-1.1.0 - [Commits](https://github.com/toml-rs/toml/compare/toml-v1.1.6...toml-v1.1.8) --- updated-dependencies: - dependency-name: toml dependency-version: 1.1.8+spec-1.1.0 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: rust-zed ... Signed-off-by: dependabot[bot] --- src/editors/zed/Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/editors/zed/Cargo.lock b/src/editors/zed/Cargo.lock index 60b277e0..601932d8 100644 --- a/src/editors/zed/Cargo.lock +++ b/src/editors/zed/Cargo.lock @@ -477,9 +477,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -560,9 +560,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -575,27 +575,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "topological-sort" From 70e2a3b5dd5d0513ac5a8cdb8856175ed42581f7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:36:58 +0000 Subject: [PATCH 003/151] build(deps): bump the github-actions group with 6 updates Bumps the github-actions group with 6 updates: | Package | From | To | | --- | --- | --- | | [taiki-e/install-action](https://github.com/taiki-e/install-action) | `2.87.23` | `2.87.27` | | [actions/setup-node](https://github.com/actions/setup-node) | `7.0.0` | `7.1.0` | | [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.1` | `8.0.2` | | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.1` | `7.0.2` | | [github/codeql-action/init](https://github.com/github/codeql-action) | `4.38.2` | `4.38.3` | | [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.38.2` | `4.38.3` | Updates `taiki-e/install-action` from 2.87.23 to 2.87.27 - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/861a07ce7084f55488e375df125cdc99bba60eb7...ab6895351a88b908e96a4697860d4ffea5e57dbe) Updates `actions/setup-node` from 7.0.0 to 7.1.0 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020...949feb2413d6458794dcd2491c4babbbce0c15c1) Updates `actions/download-artifact` from 8.0.1 to 8.0.2 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333) Updates `actions/upload-artifact` from 7.0.1 to 7.0.2 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9) Updates `github/codeql-action/init` from 4.38.2 to 4.38.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d) Updates `github/codeql-action/analyze` from 4.38.2 to 4.38.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.87.27 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: actions/setup-node dependency-version: 7.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: actions/download-artifact dependency-version: 8.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: actions/upload-artifact dependency-version: 7.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: github/codeql-action/init dependency-version: 4.38.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: github/codeql-action/analyze dependency-version: 4.38.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-audit.yml | 4 ++-- .github/workflows/ci-build.yml | 2 +- .github/workflows/ci-coverage.yml | 12 +++++----- .github/workflows/ci-test-dotnet.yml | 2 +- .github/workflows/ci-test-editors.yml | 6 ++--- .github/workflows/ci-test-rust.yml | 14 +++++------ .github/workflows/ci-test-tooling.yml | 2 +- .github/workflows/ci.yml | 2 +- .github/workflows/codeql.yml | 4 ++-- .github/workflows/deploy-pages.yml | 2 +- .github/workflows/release.yml | 34 +++++++++++++-------------- 11 files changed, 42 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci-audit.yml b/.github/workflows/ci-audit.yml index 7ac5c8a0..fb606e2f 100644 --- a/.github/workflows/ci-audit.yml +++ b/.github/workflows/ci-audit.yml @@ -36,13 +36,13 @@ jobs: - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: toolchain: stable - - uses: taiki-e/install-action@861a07ce7084f55488e375df125cdc99bba60eb7 # v2 + - uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2 with: tool: cargo-audit - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.303 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' - name: Audit Rust, .NET and npm dependencies diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index fbfe6d65..083cefee 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -89,7 +89,7 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' - name: Download the pinned macOS debugger diff --git a/.github/workflows/ci-coverage.yml b/.github/workflows/ci-coverage.yml index aa0636c2..a8eb22fc 100644 --- a/.github/workflows/ci-coverage.yml +++ b/.github/workflows/ci-coverage.yml @@ -36,10 +36,10 @@ jobs: # check-coverage.mjs ratchets against the threshold committed at the # merge base, which a shallow checkout does not contain. fetch-depth: 0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: coverage-rust-shard-* merge-multiple: true @@ -47,7 +47,7 @@ jobs: - name: Merge shard coverage and enforce threshold run: make _gate-rust-coverage - name: Upload merged coverage - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: coverage-rust path: | @@ -66,11 +66,11 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' - name: Download every shard tracefile (both platforms) - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: coverage-vsix-shard-* merge-multiple: true @@ -78,7 +78,7 @@ jobs: - name: Merge shard coverage and enforce threshold run: make _gate-vsix-coverage - name: Upload merged coverage - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: coverage-vsix path: | diff --git a/.github/workflows/ci-test-dotnet.yml b/.github/workflows/ci-test-dotnet.yml index 34f1b561..febb8218 100644 --- a/.github/workflows/ci-test-dotnet.yml +++ b/.github/workflows/ci-test-dotnet.yml @@ -33,7 +33,7 @@ jobs: 9.0.x 10.0.303 - name: Download prebuilt sidecars - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: linux-lsp-binaries path: target diff --git a/.github/workflows/ci-test-editors.yml b/.github/workflows/ci-test-editors.yml index 7fe0d6ed..d6abf5c8 100644 --- a/.github/workflows/ci-test-editors.yml +++ b/.github/workflows/ci-test-editors.yml @@ -43,10 +43,10 @@ jobs: - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 with: workspaces: src/editors/zed - - uses: taiki-e/install-action@861a07ce7084f55488e375df125cdc99bba60eb7 # v2 + - uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2 with: tool: cargo-llvm-cov - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' - name: Test + coverage gate (Zed) @@ -67,7 +67,7 @@ jobs: with: distribution: temurin java-version: '21' - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' # kover-line-percent.cs is a `dotnet run --file` app, matching how diff --git a/.github/workflows/ci-test-rust.yml b/.github/workflows/ci-test-rust.yml index 37c741b8..1ddda8ec 100644 --- a/.github/workflows/ci-test-rust.yml +++ b/.github/workflows/ci-test-rust.yml @@ -40,10 +40,10 @@ jobs: toolchain: stable components: llvm-tools-preview - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 - - uses: taiki-e/install-action@861a07ce7084f55488e375df125cdc99bba60eb7 # v2 + - uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2 with: tool: cargo-llvm-cov - - uses: taiki-e/install-action@861a07ce7084f55488e375df125cdc99bba60eb7 # v2 + - uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2 with: tool: nextest - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 @@ -58,7 +58,7 @@ jobs: ${{ 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 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' - name: Install .NET diagnostic tools @@ -67,7 +67,7 @@ jobs: dotnet tool install -g dotnet-counters dotnet tool install -g dotnet-dump - name: Download prebuilt host and sidecars - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: linux-lsp-binaries path: target @@ -81,7 +81,7 @@ jobs: - name: Test Rust shard with coverage run: env VSIX_PREBUILT=1 make _test-rust-shard SHARD=${{ matrix.shard }} SHARD_COUNT=2 - name: Upload shard coverage - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: coverage-rust-shard-${{ matrix.shard }} retention-days: 1 @@ -105,11 +105,11 @@ jobs: - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: toolchain: stable - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' - name: Download prebuilt release binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: linux-lsp-binaries path: target diff --git a/.github/workflows/ci-test-tooling.yml b/.github/workflows/ci-test-tooling.yml index a62cb858..db845d5a 100644 --- a/.github/workflows/ci-test-tooling.yml +++ b/.github/workflows/ci-test-tooling.yml @@ -26,7 +26,7 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' - name: Test the build tooling diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a680921..ef90356e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,7 +155,7 @@ jobs: # manifest changed. steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '24' - name: Validate shipwright.json diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5012db0b..dcf68b95 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -68,7 +68,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -76,7 +76,7 @@ jobs: # 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@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: category: "/language:${{ matrix.language }}" # Drop the SARIF on disk so the gate step can read it. `upload` stays on diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index c1abf179..077a2a08 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -39,7 +39,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: true - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' cache: npm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8371ee1f..7786a2c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: version: "${{ steps.extract.outputs.version }}" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '24' - name: Extract version from tag @@ -126,7 +126,7 @@ jobs: dotnet-version: | 9.0.x 10.0.303 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' cache: npm @@ -182,14 +182,14 @@ jobs: export SKIP_RUN=1 fi bash tools/packaging/verify-archive.sh ${{ matrix.platform }} "${{ needs.version.outputs.version }}" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: vsix-${{ matrix.platform }} path: dist/*.vsix if-no-files-found: error # Uploaded separately from the VSIX so publish-marketplace / publish-openvsx # keep globbing a directory that holds nothing but .vsix files. - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: server-${{ matrix.platform }} path: | @@ -219,7 +219,7 @@ jobs: with: distribution: temurin java-version: '21' - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' # Mirrors ci-test-editors.yml: the IntelliJ Platform SDK is a multi-GB Gradle @@ -246,7 +246,7 @@ jobs: # tag into it. A stale 0.1.0 here means stamping silently stopped working. ls src/editors/rider/build/distributions/sharplsp-rider-${{ needs.version.outputs.version }}.zip unzip -l dist/sharplsp-rider.zip | grep -F 'sharplsp-rider/lib/' - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: rider-plugin path: dist/sharplsp-rider.zip @@ -268,17 +268,17 @@ jobs: # Three downloads, one directory. publish-marketplace and publish-openvsx # pull `vsix-*` on their own and must keep seeing a directory of nothing but # VSIXes, so the artifacts stay split at upload and are merged only here. - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: artifacts pattern: vsix-* merge-multiple: true - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: artifacts pattern: server-* merge-multiple: true - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: artifacts name: rider-plugin @@ -359,10 +359,10 @@ jobs: contents: read id-token: write steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: artifacts pattern: vsix-* @@ -487,12 +487,12 @@ jobs: exit 1 fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' # Checksums come from the archives actually published, never from a # hand-edited formula — a carried-over sha256 is the classic tap bug. - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: artifacts pattern: server-* @@ -545,10 +545,10 @@ jobs: exit 1 fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: artifacts pattern: server-* @@ -589,10 +589,10 @@ jobs: permissions: contents: read steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: artifacts pattern: vsix-* From 114737a0d7526a1a3c219465a9b988e383195ddf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:37:12 +0000 Subject: [PATCH 004/151] build(deps): bump @playwright/test in /src/website in the website group Bumps the website group in /src/website with 1 update: [@playwright/test](https://github.com/microsoft/playwright). Updates `@playwright/test` from 1.63.0 to 1.64.0 - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](https://github.com/microsoft/playwright/compare/v1.63.0...v1.64.0) --- updated-dependencies: - dependency-name: "@playwright/test" dependency-version: 1.64.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: website ... Signed-off-by: dependabot[bot] --- src/website/package-lock.json | 24 ++++++++++++------------ src/website/package.json | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/website/package-lock.json b/src/website/package-lock.json index 9fd6b511..4d4809e2 100644 --- a/src/website/package-lock.json +++ b/src/website/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@11ty/eleventy": "^3.1.6", - "@playwright/test": "^1.63.0", + "@playwright/test": "^1.64.0", "eleventy-plugin-techdoc": "0.2.0", "markdown-it": "^15.0.2" }, @@ -628,12 +628,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", - "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.63.0" + "playwright": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -1757,12 +1757,12 @@ } }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -1772,9 +1772,9 @@ } }, "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/src/website/package.json b/src/website/package.json index 346b7265..d067aa1a 100644 --- a/src/website/package.json +++ b/src/website/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@11ty/eleventy": "^3.1.6", - "@playwright/test": "^1.63.0", + "@playwright/test": "^1.64.0", "eleventy-plugin-techdoc": "0.2.0", "markdown-it": "^15.0.2" }, From c42ca8325d8375bec293e009b009f365f52e609c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:38:33 +0000 Subject: [PATCH 005/151] build(deps-dev): bump the vscode-extension group Bumps the vscode-extension group in /src/editors/vscode with 7 updates: | Package | From | To | | --- | --- | --- | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.6.4` | `26.6.5` | | [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.71.0` | `8.71.1` | | [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.71.0` | `8.71.1` | | [playwright](https://github.com/microsoft/playwright) | `1.63.0` | `1.64.0` | | [prettier](https://github.com/prettier/prettier) | `3.9.9` | `3.9.10` | | [typescript](https://github.com/microsoft/TypeScript) | `6.0.3` | `7.0.2` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.71.0` | `8.71.1` | Updates `@types/node` from 26.6.4 to 26.6.5 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `@typescript-eslint/eslint-plugin` from 8.71.0 to 8.71.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.1/packages/eslint-plugin) Updates `@typescript-eslint/parser` from 8.71.0 to 8.71.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.1/packages/parser) Updates `playwright` from 1.63.0 to 1.64.0 - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](https://github.com/microsoft/playwright/compare/v1.63.0...v1.64.0) Updates `prettier` from 3.9.9 to 3.9.10 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.9.9...3.9.10) Updates `typescript` from 6.0.3 to 7.0.2 - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.3...v7.0.2) Updates `typescript-eslint` from 8.71.0 to 8.71.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.1/packages/typescript-eslint) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 26.6.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: vscode-extension - dependency-name: "@typescript-eslint/eslint-plugin" dependency-version: 8.71.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: vscode-extension - dependency-name: "@typescript-eslint/parser" dependency-version: 8.71.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: vscode-extension - dependency-name: playwright dependency-version: 1.64.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: vscode-extension - dependency-name: prettier dependency-version: 3.9.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: vscode-extension - dependency-name: typescript dependency-version: 7.0.2 dependency-type: direct:development update-type: version-update:semver-major dependency-group: vscode-extension - dependency-name: typescript-eslint dependency-version: 8.71.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: vscode-extension ... Signed-off-by: dependabot[bot] --- src/editors/vscode/package-lock.json | 714 +++++++++++++++++++++------ src/editors/vscode/package.json | 14 +- 2 files changed, 578 insertions(+), 150 deletions(-) diff --git a/src/editors/vscode/package-lock.json b/src/editors/vscode/package-lock.json index 8824fed3..3585a6e7 100644 --- a/src/editors/vscode/package-lock.json +++ b/src/editors/vscode/package-lock.json @@ -18,10 +18,10 @@ "@eslint/js": "^10.0.1", "@types/d3-force": "^3.0.10", "@types/mocha": "^10.0.6", - "@types/node": "^26.6.4", + "@types/node": "^26.6.5", "@types/vscode": "^1.105.0", - "@typescript-eslint/eslint-plugin": "^8.71.0", - "@typescript-eslint/parser": "^8.71.0", + "@typescript-eslint/eslint-plugin": "^8.71.1", + "@typescript-eslint/parser": "^8.71.1", "@vscode/test-cli": "^0.0.15", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^4.0.0", @@ -29,10 +29,10 @@ "eslint": "^10.12.0", "glob": "^13.0.6", "mocha": "^12.0.3", - "playwright": "^1.63.0", - "prettier": "3.9.9", - "typescript": "~6.0.3", - "typescript-eslint": "^8.71.0" + "playwright": "^1.64.0", + "prettier": "3.9.10", + "typescript": "~7.0.2", + "typescript-eslint": "^8.71.1" }, "engines": { "vscode": "^1.105.0" @@ -1328,9 +1328,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", - "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", "dev": true, "license": "MIT", "dependencies": { @@ -1345,17 +1345,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", - "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.71.0", - "@typescript-eslint/type-utils": "8.71.0", - "@typescript-eslint/utils": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/type-utils": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1368,21 +1368,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.71.0", + "@typescript-eslint/parser": "^8.71.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", - "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", + "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.71.0", - "@typescript-eslint/types": "^8.71.0", - "debug": "^4.4.3" + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1392,15 +1394,27 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", - "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1412,18 +1426,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", - "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0", - "@typescript-eslint/utils": "8.71.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1433,27 +1445,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", - "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.71.0", - "@typescript-eslint/tsconfig-utils": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1466,16 +1466,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", - "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0" + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1489,18 +1489,22 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", - "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0", - "debug": "^4.4.3" + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1510,19 +1514,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", - "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.71.0", - "@typescript-eslint/types": "^8.71.0", + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", "debug": "^4.4.3" }, "engines": { @@ -1536,12 +1539,36 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", - "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", + "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1550,20 +1577,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", - "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.71.0", - "@typescript-eslint/tsconfig-utils": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0", + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1581,15 +1609,54 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", - "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", + "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0" + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1600,9 +1667,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", - "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", "dev": true, "license": "MIT", "engines": { @@ -1614,13 +1681,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", - "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", + "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/types": "8.71.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1644,6 +1711,346 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@typespec/ts-http-runtime": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", @@ -4176,13 +4583,13 @@ } }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -4192,9 +4599,9 @@ } }, "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4215,9 +4622,9 @@ } }, "node_modules/prettier": { - "version": "3.9.9", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", - "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", "dev": true, "license": "MIT", "bin": { @@ -4808,30 +5215,51 @@ } }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/typescript-eslint": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", - "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz", + "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.71.0", - "@typescript-eslint/parser": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0", - "@typescript-eslint/utils": "8.71.0" + "@typescript-eslint/eslint-plugin": "8.71.1", + "@typescript-eslint/parser": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4846,16 +5274,16 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", - "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.71.0", - "@typescript-eslint/tsconfig-utils": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0", + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4874,14 +5302,14 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", - "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.71.0", - "@typescript-eslint/types": "^8.71.0", + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", "debug": "^4.4.3" }, "engines": { @@ -4896,9 +5324,9 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", - "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", "dev": true, "license": "MIT", "engines": { @@ -4913,16 +5341,16 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", - "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0" + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/src/editors/vscode/package.json b/src/editors/vscode/package.json index 74148da5..1850534f 100644 --- a/src/editors/vscode/package.json +++ b/src/editors/vscode/package.json @@ -1272,10 +1272,10 @@ "@eslint/js": "^10.0.1", "@types/d3-force": "^3.0.10", "@types/mocha": "^10.0.6", - "@types/node": "^26.6.4", + "@types/node": "^26.6.5", "@types/vscode": "^1.105.0", - "@typescript-eslint/eslint-plugin": "^8.71.0", - "@typescript-eslint/parser": "^8.71.0", + "@typescript-eslint/eslint-plugin": "^8.71.1", + "@typescript-eslint/parser": "^8.71.1", "@vscode/test-cli": "^0.0.15", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^4.0.0", @@ -1283,10 +1283,10 @@ "eslint": "^10.12.0", "glob": "^13.0.6", "mocha": "^12.0.3", - "playwright": "^1.63.0", - "prettier": "3.9.9", - "typescript": "~6.0.3", - "typescript-eslint": "^8.71.0" + "playwright": "^1.64.0", + "prettier": "3.9.10", + "typescript": "~7.0.2", + "typescript-eslint": "^8.71.1" }, "files": [ "bin/", From 92573de2a877317451ee47a0ca07cb215eafa53a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:41:52 +0000 Subject: [PATCH 006/151] Bump the dotnet-sidecars group with 8 updates Bumps Fantomas.Core from 8.0.7 to 8.0.8 Bumps FSharp.Compiler.Service from 43.12.401 to 43.12.402 Bumps FSharp.Core from 10.1.401 to 10.1.402 Bumps MessagePack from 3.1.11 to 3.1.12 Bumps Microsoft.CodeAnalysis.NetAnalyzers from 10.0.401 to 10.0.402 Bumps Microsoft.NET.Test.Sdk from 18.10.1 to 18.11.0 Bumps System.Security.Cryptography.Xml from 10.0.12 to 10.0.13 Bumps xunit.runner.visualstudio from 4.0.0 to 4.0.1 --- updated-dependencies: - dependency-name: Fantomas.Core dependency-version: 8.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dotnet-sidecars - dependency-name: FSharp.Compiler.Service dependency-version: 43.12.402 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dotnet-sidecars - dependency-name: FSharp.Core dependency-version: 10.1.402 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dotnet-sidecars - dependency-name: FSharp.Core dependency-version: 10.1.402 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dotnet-sidecars - dependency-name: MessagePack dependency-version: 3.1.12 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dotnet-sidecars - dependency-name: MessagePack dependency-version: 3.1.12 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dotnet-sidecars - dependency-name: Microsoft.CodeAnalysis.NetAnalyzers dependency-version: 10.0.402 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dotnet-sidecars - dependency-name: Microsoft.NET.Test.Sdk dependency-version: 18.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: dotnet-sidecars - dependency-name: Microsoft.NET.Test.Sdk dependency-version: 18.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: dotnet-sidecars - dependency-name: System.Security.Cryptography.Xml dependency-version: 10.0.13 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dotnet-sidecars - dependency-name: xunit.runner.visualstudio dependency-version: 4.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dotnet-sidecars - dependency-name: xunit.runner.visualstudio dependency-version: 4.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dotnet-sidecars ... Signed-off-by: dependabot[bot] --- .config/dotnet/common.props | 2 +- .../SharpLsp.Sidecar.Common.Tests.csproj | 4 ++-- .../SharpLsp.Sidecar.Common.csproj | 2 +- .../SharpLsp.Sidecar.FSharp.Tests.fsproj | 8 ++++---- .../SharpLsp.Sidecar.FSharp.fsproj | 10 +++++----- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.config/dotnet/common.props b/.config/dotnet/common.props index 51399b9e..90939ca4 100644 --- a/.config/dotnet/common.props +++ b/.config/dotnet/common.props @@ -29,7 +29,7 @@ - + all runtime; build; native; contentfiles; analyzers diff --git a/src/sidecars/SharpLsp.Sidecar.Common.Tests/SharpLsp.Sidecar.Common.Tests.csproj b/src/sidecars/SharpLsp.Sidecar.Common.Tests/SharpLsp.Sidecar.Common.Tests.csproj index 08a44fa0..cbd6c217 100644 --- a/src/sidecars/SharpLsp.Sidecar.Common.Tests/SharpLsp.Sidecar.Common.Tests.csproj +++ b/src/sidecars/SharpLsp.Sidecar.Common.Tests/SharpLsp.Sidecar.Common.Tests.csproj @@ -14,9 +14,9 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + - + diff --git a/src/sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj b/src/sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj index 21548672..ed9ed165 100644 --- a/src/sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj +++ b/src/sidecars/SharpLsp.Sidecar.Common/SharpLsp.Sidecar.Common.csproj @@ -23,7 +23,7 @@ - + diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj index faf47b24..66809513 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj @@ -40,11 +40,11 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + - + diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj b/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj index 944d7f00..f8962846 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj @@ -31,17 +31,17 @@ - - + + - + - + - + From 62da612303cc7cc6c732e4611fdc44b210158806 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:37:24 +1100 Subject: [PATCH 007/151] Razor phase 4 (in progress): MVC tag helper names, scope and attribute completion Legacy tag helper elements and attributes become markup facts, with names read where the compiler gives no span; @addTagHelper, @removeTagHelper and @tagHelperPrefix scope MVC attribute completion. Builds; tests not yet run. --- .../RazorMvcTests.cs | 269 ++++++++++++++++++ .../Razor/HtmlMarkupContext.cs | 10 + .../Razor/RazorHtmlProjection.cs | 167 ++--------- .../Razor/RazorIr.Attributes.cs | 230 +++++++++++++++ .../SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs | 128 ++++----- .../Razor/RazorMarkup.cs | 7 +- .../Razor/RazorMarkupCapture.cs | 12 +- .../Razor/RazorMarkupCompletion.cs | 90 ++++-- .../Razor/RazorTagHelperScope.cs | 121 ++++++++ 9 files changed, 790 insertions(+), 244 deletions(-) create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMvcTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorTagHelperScope.cs diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMvcTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMvcTests.cs new file mode 100644 index 00000000..3abce99e --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMvcTests.cs @@ -0,0 +1,269 @@ +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; +using SharpLsp.Sidecar.CSharp.Workspace; + +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable RS1035 // Real-file reads are intentional in this end-to-end suite +#pragma warning disable IDE0042 // Positions read better as (Line, Character) pairs + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// Razor Pages and MVC views against the real fixture's PagesApp: @model, +/// @inject, @functions and @section through the projection, tag helper +/// elements and attributes from their descriptors, and _ViewImports.cshtml as an +/// import. Implements [RAZOR-TESTING] for [RAZOR-MVC]. +/// +public sealed class RazorMvcTests : IClassFixture +{ + private readonly RazorFixture _fixture; + + public RazorMvcTests(RazorFixture fixture) + { + _fixture = fixture; + } + + private string Edit => _fixture.Path("PagesApp", "Pages", "Edit.cshtml"); + + private string Index => _fixture.Path("PagesApp", "Views", "Home", "Index.cshtml"); + + [Fact] + public async Task Model_inject_and_functions_answer_through_the_projection() + { + using var manager = await _fixture.OpenManagerAsync(); + + // `@model`'s type is C#, and so is `Model.X`. + var model = await HoverAsync( + manager, + Edit, + RazorFixture.Locate(Edit, "@model EditModel", "EditModel") + ); + Assert.Contains("Edits one item.", model, StringComparison.Ordinal); + var title = await HoverAsync( + manager, + Edit, + RazorFixture.Locate(Edit, "@Shout(Model.Title)", "Title") + ); + Assert.Contains("The item's title.", title, StringComparison.Ordinal); + + // `@inject` declares a property of its type, and `@functions` members are C#. + var log = await HoverAsync( + manager, + Edit, + RazorFixture.Locate(Edit, "@inject ILogger", "Log") + ); + Assert.Contains("ILogger<", log, StringComparison.Ordinal); + var call = RazorFixture.Locate(Edit, "@Shout(Model.Title)", "Shout"); + var shout = Assert.Single( + RazorFixture + .Ok(await manager.GetDefinitionAsync(Edit, call.Line, call.Character)) + .Locations + ); + Assert.True(NativePaths.AreEqual(Edit, shout.FilePath), shout.FilePath); + Assert.Equal( + RazorFixture.Locate(Edit, "string Shout", "Shout"), + (shout.Line, shout.Character) + ); + } + + [Fact] + public async Task ViewImports_imports_reach_the_views_below_them() + { + using var manager = await _fixture.OpenManagerAsync(); + + // Views/_ViewImports.cshtml's `@using PagesApp.Models` resolves `@model Item`... + var item = await HoverAsync( + manager, + Index, + RazorFixture.Locate(Index, "@model Item", "Item") + ); + Assert.Contains("An item the home page shows.", item, StringComparison.Ordinal); + Assert.DoesNotContain( + RazorFixture.Ok(await manager.GetDiagnosticsAsync(Index)), + diagnostic => diagnostic.Severity == "Error" + ); + + // ...and its `@addTagHelper` binds the anchor tag helper. + var markup = await manager.FindRazorMarkupAsync(Index, default); + Assert.NotNull(markup); + var anchor = Assert.Single(markup.Tags, tag => tag.Name == "a"); + Assert.Contains( + anchor.MvcTagHelpers, + helper => helper.Name.EndsWith("AnchorTagHelper", StringComparison.Ordinal) + ); + } + + [Fact] + public async Task Tag_helper_elements_and_attributes_answer_hover_from_their_descriptors() + { + using var manager = await _fixture.OpenManagerAsync(); + + // An element a tag helper binds answers the tag helper's type. + var input = await HoverAsync( + manager, + Edit, + RazorFixture.Locate(Edit, " item.Label == "asp-page"); + Assert.Contains(items, item => item.Label == "asp-controller"); + Assert.Contains(items, item => item.Label == "asp-route-"); + var page = Assert.Single(items, item => item.Label == "asp-page"); + Assert.Equal("Property", page.Kind); + } + + [Fact] + public async Task Section_blocks_fold_and_outline() + { + using var manager = await _fixture.OpenManagerAsync(); + var text = await File.ReadAllTextAsync(Index); + const string section = "\n@section Scripts {\n \n}\n"; + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Index, text + section)); + var opened = text.Split('\n').Length; + + var folds = RazorFixture.Ok(await manager.GetFoldingRangesAsync(Index, default)); + Assert.Contains((opened, opened + 1), folds.Select(fold => (fold.StartLine, fold.EndLine))); + var symbols = RazorFixture.Ok(await manager.GetDocumentSymbolsAsync(Index, default)); + Assert.Contains(symbols, symbol => symbol.Name == "@section" && symbol.Kind == "Key"); + } + + [Fact] + public async Task Tag_helper_elements_keep_their_markup_and_blank_their_csharp_values() + { + using var manager = await _fixture.OpenManagerAsync(); + var markup = await manager.FindRazorMarkupAsync(Edit, default); + Assert.NotNull(markup); + + // The elements are HTML, so the HTML projection keeps their tags. [RAZOR-ARCHITECTURE-HTML] + var html = markup.HtmlText; + Assert.Contains("
", html, StringComparison.Ordinal); + Assert.Contains("
", html, StringComparison.Ordinal); + Assert.Contains("class=\"form-control\" />", html, StringComparison.Ordinal); + Assert.Contains(">Reload", html, StringComparison.Ordinal); + Assert.Contains("", + html, + StringComparison.Ordinal + ); + Assert.Contains( + "Reload", + html, + StringComparison.Ordinal + ); + Assert.DoesNotContain("Title", html, StringComparison.Ordinal); + Assert.DoesNotContain("Model", html, StringComparison.Ordinal); + + // Each element a tag helper binds is a tag of the file, with its tag helpers. + string[] bound = ["form", "label", "input", "span", "a"]; + Assert.All( + bound, + name => Assert.Contains(markup.Tags, tag => tag.Name == name && tag.MvcTagHelpers.Any()) + ); + } + + [Fact] + public async Task Tag_helper_directives_scope_completion_and_binding() + { + using var manager = await _fixture.OpenManagerAsync(); + var original = await File.ReadAllTextAsync(Edit); + + // `@removeTagHelper` takes the anchor tag helper out of scope, wherever it is written: + // anchors stop binding and completing, and the form tag helper stays. [RAZOR-MVC-SCOPE] + const string anchor = + "@removeTagHelper Microsoft.AspNetCore.Mvc.TagHelpers.AnchorTagHelper, " + + "Microsoft.AspNetCore.Mvc.TagHelpers"; + var removed = await EditedAsync(manager, original + "\n" + anchor + "\n item.Label.StartsWith("asp-", StringComparison.Ordinal) + ); + Assert.DoesNotContain(removed.Markup.Tags, tag => tag.Name == "a"); + var form = Assert.Single(removed.Markup.Tags, tag => tag.Name == "form"); + Assert.Contains( + form.MvcTagHelpers, + helper => helper.Name.EndsWith(".FormTagHelper", StringComparison.Ordinal) + ); + + // `@tagHelperPrefix` makes only prefixed elements tag helpers: they bind, complete and + // answer hover under their prefixed name, and unprefixed ones do none of it. + var prefixed = await EditedAsync( + manager, + "@tagHelperPrefix th:\n" + original + "\nGo\n item.Label == "asp-route-"); + Assert.DoesNotContain(prefixed.Markup.Tags, tag => tag.Name == "form"); + var go = Assert.Single(prefixed.Markup.Tags, tag => tag.Name == "a"); + Assert.Equal("th:a", prefixed.Markup.Text.ToString(go.StartName)); + Assert.Equal("th:a", prefixed.Markup.Text.ToString(go.EndName!.Value)); + var at = prefixed.Markup.Text.Lines.GetLinePosition(go.StartName.Start + 3); + var hover = RazorFixture.Ok(await manager.GetHoverAsync(Edit, at.Line, at.Character)); + Assert.NotNull(hover); + Assert.Contains("AnchorTagHelper", hover.Contents, StringComparison.Ordinal); + var unprefixed = await EditedAsync( + manager, + "@tagHelperPrefix th:\n" + original + "\n item.Label.StartsWith("asp-", StringComparison.Ordinal) + ); + } + + /// The markup facts and the completion at the end of , once it is Edit.cshtml's. + private async Task<(RazorMarkup Markup, List Items)> EditedAsync( + WorkspaceManager manager, + string text + ) + { + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Edit, text)); + var lines = text.Split('\n'); + var items = RazorFixture.Ok( + await manager.GetCompletionsAsync(Edit, lines.Length - 1, lines[^1].Length) + ); + var markup = await manager.FindRazorMarkupAsync(Edit, default); + Assert.NotNull(markup); + return (markup, items); + } + + private static async Task HoverAsync( + WorkspaceManager manager, + string file, + (int Line, int Character) at, + int offset = 0 + ) + { + var hover = RazorFixture.Ok( + await manager.GetHoverAsync(file, at.Line, at.Character + offset) + ); + Assert.NotNull(hover); + return hover.Contents; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs index a7b9633f..d1b67517 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs @@ -33,6 +33,16 @@ public static MarkupContext At(string html, int offset) return Context(ScanTo(html, Math.Clamp(offset, 0, html.Length))); } + /// + /// The name of the start tag whose attributes hold , lowercased as + /// the tokenizer reads it; null outside a start tag's attributes. [RAZOR-FEATURES-COMPLETION] + /// + public static string? StartTagAt(string html, int offset) + { + var scan = ScanTo(html, Math.Clamp(offset, 0, html.Length)); + return Context(scan) == MarkupContext.AttributeName && !scan.EndTag ? scan.Tag : null; + } + /// The tokenizer states the caret context derives from. private enum Scanner { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs index 2a961a57..fe0e3dd3 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Intermediate; using Microsoft.CodeAnalysis.Text; @@ -10,31 +9,37 @@ namespace SharpLsp.Sidecar.CSharp.Razor; /// internal static class RazorHtmlProjection { - public static string Create(SourceText text, DocumentIntermediateNode root) + /// The projection of , whose tag helper elements carry . + public static string Create(SourceText text, DocumentIntermediateNode root, string prefix) { var chars = text.ToString().Select(Blank).ToArray(); - Write(root, text, chars); + Write(root, text, chars, prefix); return new string(chars); } - private static void Write(IntermediateNode node, SourceText text, char[] chars) + private static void Write(IntermediateNode node, SourceText text, char[] chars, string prefix) { // A directive attribute's value is never HTML, whatever the compiler holds under it. - if (node.IsImported || IsDirective(node)) + if (node.IsImported || RazorIr.IsDirective(node)) { return; } - WriteNode(node, text, chars); + WriteNode(node, text, chars, prefix); foreach (var child in node.Children) { - Write(child, text, chars); + Write(child, text, chars, prefix); } } - private static void WriteNode(IntermediateNode node, SourceText text, char[] chars) + private static void WriteNode( + IntermediateNode node, + SourceText text, + char[] chars, + string prefix + ) { - if (RazorIr.Tag(node, text) is { } tag) + if (RazorIr.Tag(node, text, prefix) is { } tag) { WriteTag(node, tag, text, chars); } @@ -96,11 +101,12 @@ char[] chars chars, TextSpan.FromBounds(tag.Element.Start, OpeningEnd(text, tag.Element, attributes)) ); - // The IR lists attributes in source order, so each one starts after the one before it. - int? after = tag.StartName.End; - foreach (var attribute in node.Children.Where(IsAttribute)) + foreach (var attribute in RazorIr.NamedAttributes(node, tag, text)) { - after = ClearAttribute(chars, text, attribute, after); + if (Cleared(attribute) is { } span) + { + Clear(chars, span); + } } if (tag.EndName is { } end) @@ -110,126 +116,25 @@ char[] chars } /// - /// Blanks an attribute's IR span and a directive's whole @name="value", and answers - /// where the attribute ends, or null when the IR does not say. + /// What an attribute blanks: a directive's whole @name="value", else its IR span, under + /// which HTML is copied back as written. A tag helper attribute's name is markup, so a legacy + /// file's (asp-for, whose span is its value's) stays. [RAZOR-ARCHITECTURE-HTML] /// - private static int? ClearAttribute( - char[] chars, - SourceText text, - IntermediateNode node, - int? after - ) - { - var last = LastEnd(node); - if (DirectiveName(node, text, after) is { } name) - { - var end = Closing(Structure(node), last ?? name.End); - Clear(chars, TextSpan.FromBounds(name.Start, end)); - return end; - } - - if (node.Source is { } source) - { - Clear(chars, new TextSpan(source.AbsoluteIndex, source.Length)); - } - - return last is { } found - ? Closing(Structure(node), found) - : MinimizedEnd(node, text, after); - } - - /// A directive attribute's name, its @ included. - private static TextSpan? DirectiveName(IntermediateNode node, SourceText text, int? after) + private static TextSpan? Cleared(NamedAttribute attribute) { - return node switch - { - TagHelperDirectiveAttributeIntermediateNode directive => Located( - directive.OriginalAttributeSpan, - directive.OriginalAttributeName, - directive.AttributeStructure, - text, - after - ), - TagHelperDirectiveAttributeParameterIntermediateNode parameter => Located( - parameter.OriginalAttributeSpan, - parameter.OriginalAttributeName, - parameter.AttributeStructure, - text, - after - ), - _ => null, - }; - } - - /// - /// A directive attribute's name from the compiler's span. The compiler gives a minimized - /// directive parameter (@onclick:stopPropagation) no span, so its name is read where - /// the attribute before it ends, and only when the text there holds it. - /// - private static TextSpan? Located( - SourceSpan? original, - string name, - AttributeStructure structure, - SourceText text, - int? after - ) - { - return original is null && structure == AttributeStructure.Minimized && after is { } start - ? RazorIr.NameAfter(text, start, name) - : RazorIr.DirectiveName(original, name, text); - } - - /// The end of an attribute's last IR span, its own or a value's under it. - private static int? LastEnd(IntermediateNode node) - { - var ends = RazorIr - .Descendants(node) - .Select(child => child.Source) - .OfType() - .Select(source => source.AbsoluteIndex + source.Length) - .ToArray(); - return ends.Length > 0 ? ends.Max() : null; - } - - /// A minimized attribute has no span, so it ends after its name, read where the one before it ends. - private static int? MinimizedEnd(IntermediateNode node, SourceText text, int? after) - { - return - node is TagHelperHtmlAttributeIntermediateNode html - && html.AttributeStructure == AttributeStructure.Minimized - && after is { } start - ? RazorIr.NameAfter(text, start, html.AttributeName)?.End + return RazorIr.IsDirective(attribute.Node) && attribute is { Name: { } name, End: { } end } + ? TextSpan.FromBounds(name.Start, end) + : attribute.Node.Source is { } source + ? new TextSpan(source.AbsoluteIndex, source.Length) : null; } - /// How the compiler read a bound attribute's value; a plain attribute's span holds its quotes. - private static AttributeStructure Structure(IntermediateNode node) - { - return node switch - { - TagHelperHtmlAttributeIntermediateNode html => html.AttributeStructure, - TagHelperPropertyIntermediateNode property => property.AttributeStructure, - TagHelperDirectiveAttributeIntermediateNode directive => directive.AttributeStructure, - TagHelperDirectiveAttributeParameterIntermediateNode parameter => - parameter.AttributeStructure, - _ => AttributeStructure.Minimized, - }; - } - - /// An attribute's end: its value's end, plus the closing quote the compiler parsed. - private static int Closing(AttributeStructure structure, int valueEnd) - { - return structure is AttributeStructure.DoubleQuotes or AttributeStructure.SingleQuotes - ? valueEnd + 1 - : valueEnd; - } - private static TextSpan[] Attributes(IntermediateNode node) { return [ .. node - .Children.Where(IsAttribute) + .Children.Where(RazorIr.IsAttribute) .Where(child => child.Source is not null) .Select(child => new TextSpan( child.Source!.Value.AbsoluteIndex, @@ -238,22 +143,6 @@ .. node ]; } - private static bool IsAttribute(IntermediateNode node) - { - return node - is HtmlAttributeIntermediateNode - or TagHelperHtmlAttributeIntermediateNode - or TagHelperPropertyIntermediateNode - || IsDirective(node); - } - - private static bool IsDirective(IntermediateNode node) - { - return node - is TagHelperDirectiveAttributeIntermediateNode - or TagHelperDirectiveAttributeParameterIntermediateNode; - } - /// Tokenizes only an IR-owned opening tag; compiler attribute spans are opaque. private static int OpeningEnd(SourceText text, TextSpan element, TextSpan[] attributes) { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs new file mode 100644 index 00000000..7a7131a7 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs @@ -0,0 +1,230 @@ +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// A tag's attribute node, with its name's span and its end where the compiler's spans and the text say. +internal readonly record struct NamedAttribute(IntermediateNode Node, TextSpan? Name, int? End); + +/// +/// Attribute facts read from a tag's IR nodes, in source order. Implements +/// [RAZOR-ARCHITECTURE-ENGINE]. +/// +internal static partial class RazorIr +{ + /// The attributes of a tag node, each with what binds it. + public static IEnumerable Attributes( + IntermediateNode node, + MarkupTag tag, + SourceText text + ) + { + return NamedAttributes(node, tag, text) + .Select(named => + Bound(named.Node) is { } bound + ? new MarkupAttribute( + bound.Kind, + bound.Name, + named.Name, + tag, + bound.TagHelper, + bound.Attribute, + bound.Parameter + ) + : null + ) + .OfType(); + } + + /// + /// A tag node's attributes, each with its name's span and its end. A name the compiler gives + /// no span (a legacy file's attribute, a minimized directive parameter) is read where the + /// attribute before it ends, and only where the text holds it. The nodes that several tag + /// helpers bind for one attribute share its value, and so its name. + /// + public static IEnumerable NamedAttributes( + IntermediateNode node, + MarkupTag tag, + SourceText text + ) + { + int? after = tag.StartName.End; + var from = after; + IntermediateNode? previous = null; + foreach (var child in node.Children.Where(IsAttribute)) + { + from = previous is not null && SameAttribute(previous, child) ? from : after; + var name = NameOf(child, text, from); + var end = EndOf(child, name); + yield return new NamedAttribute(child, name, end); + (after, previous) = (end, child); + } + } + + /// Whether a node is one of a tag's attributes. + public static bool IsAttribute(IntermediateNode node) + { + return node + is HtmlAttributeIntermediateNode + or TagHelperHtmlAttributeIntermediateNode + or TagHelperPropertyIntermediateNode + || IsDirective(node); + } + + /// Whether a node is a directive attribute (@onclick) or one of its parameters. + public static bool IsDirective(IntermediateNode node) + { + return node + is TagHelperDirectiveAttributeIntermediateNode + or TagHelperDirectiveAttributeParameterIntermediateNode; + } + + /// What an attribute node is to the compiler, by the name it was written with. + private static ( + MarkupAttributeKind Kind, + string Name, + TagHelperDescriptor? TagHelper, + BoundAttributeDescriptor? Attribute, + BoundAttributeParameterDescriptor? Parameter + )? Bound(IntermediateNode node) + { + return node switch + { + TagHelperPropertyIntermediateNode property => ( + MarkupAttributeKind.Property, + property.AttributeName, + property.TagHelper, + property.BoundAttribute, + null + ), + TagHelperDirectiveAttributeIntermediateNode directive => ( + MarkupAttributeKind.Directive, + directive.OriginalAttributeName, + directive.TagHelper, + directive.BoundAttribute, + null + ), + TagHelperDirectiveAttributeParameterIntermediateNode parameter => ( + MarkupAttributeKind.DirectiveParameter, + parameter.OriginalAttributeName, + parameter.TagHelper, + parameter.BoundAttribute, + parameter.BoundAttributeParameter + ), + TagHelperHtmlAttributeIntermediateNode html => Html(html.AttributeName), + HtmlAttributeIntermediateNode html => Html(html.AttributeName), + _ => null, + }; + } + + private static ( + MarkupAttributeKind, + string, + TagHelperDescriptor?, + BoundAttributeDescriptor?, + BoundAttributeParameterDescriptor? + ) Html(string name) + { + return (MarkupAttributeKind.Html, name, null, null, null); + } + + /// An attribute's name: the compiler's span, else read where the attribute starts. + private static TextSpan? NameOf(IntermediateNode node, SourceText text, int? from) + { + return node switch + { + TagHelperDirectiveAttributeIntermediateNode directive => Located( + directive.OriginalAttributeSpan, + directive.OriginalAttributeName, + directive.AttributeStructure, + text, + from + ), + TagHelperDirectiveAttributeParameterIntermediateNode parameter => Located( + parameter.OriginalAttributeSpan, + parameter.OriginalAttributeName, + parameter.AttributeStructure, + text, + from + ), + TagHelperPropertyIntermediateNode { OriginalAttributeSpan: { } span } => Span(span), + _ => Bound(node) is { } bound && from is { } start + ? NameAfter(text, start, bound.Name) + : null, + }; + } + + /// + /// A directive attribute's name from the compiler's span. The compiler gives a minimized + /// directive parameter (@onclick:stopPropagation) no span, so its name is read where + /// the attribute before it ends. + /// + private static TextSpan? Located( + SourceSpan? original, + string name, + AttributeStructure structure, + SourceText text, + int? from + ) + { + return original is null && structure == AttributeStructure.Minimized && from is { } start + ? NameAfter(text, start, name) + : DirectiveName(original, name, text); + } + + /// + /// Where an attribute ends: its value's end and closing quote, else, for a directive or a + /// minimized attribute, its name's end; null when neither the IR nor the text says. + /// + private static int? EndOf(IntermediateNode node, TextSpan? name) + { + var structure = Structure(node); + var value = + LastEnd(node) + ?? (IsDirective(node) || structure == AttributeStructure.Minimized ? name?.End : null); + return value is { } found ? Closing(structure, found) : null; + } + + /// Whether two property nodes are one attribute that two tag helpers bind. + private static bool SameAttribute(IntermediateNode first, IntermediateNode second) + { + return first is TagHelperPropertyIntermediateNode { Source: { } value } one + && second is TagHelperPropertyIntermediateNode { Source: { } other } two + && one.AttributeName == two.AttributeName + && value.Equals(other); + } + + /// The end of an attribute's last IR span, its own or a value's under it. + private static int? LastEnd(IntermediateNode node) + { + var ends = Descendants(node) + .Select(child => child.Source) + .OfType() + .Select(source => source.AbsoluteIndex + source.Length) + .ToArray(); + return ends.Length > 0 ? ends.Max() : null; + } + + /// How the compiler read a bound attribute's value; a plain attribute's span holds its quotes. + private static AttributeStructure Structure(IntermediateNode node) + { + return node switch + { + TagHelperHtmlAttributeIntermediateNode html => html.AttributeStructure, + TagHelperPropertyIntermediateNode property => property.AttributeStructure, + TagHelperDirectiveAttributeIntermediateNode directive => directive.AttributeStructure, + TagHelperDirectiveAttributeParameterIntermediateNode parameter => + parameter.AttributeStructure, + _ => AttributeStructure.Minimized, + }; + } + + /// An attribute's end: its value's end, plus the closing quote the compiler parsed. + private static int Closing(AttributeStructure structure, int valueEnd) + { + return structure is AttributeStructure.DoubleQuotes or AttributeStructure.SingleQuotes + ? valueEnd + 1 + : valueEnd; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs index cd428a40..2d94cae7 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs @@ -14,7 +14,7 @@ namespace SharpLsp.Sidecar.CSharp.Razor; /// verified against the text, so a span the compiler did not shape that way is never /// reported. Implements [RAZOR-ARCHITECTURE-ENGINE]. /// -internal static class RazorIr +internal static partial class RazorIr { /// Every node under , parents before children. public static IEnumerable Descendants(IntermediateNode root) @@ -79,6 +79,22 @@ public static IEnumerable CodeBlocks(IntermediateNode root) }; } + /// The tag helper directives under , its imports' first, in order. + public static IEnumerable TagHelperDirectives(IntermediateNode root) + { + return Descendants(root) + .OfType() + .Where(directive => RazorTagHelperScope.Declares(directive.DirectiveName)) + .Select(directive => new TagHelperDirective( + directive.DirectiveName, + directive + .Children.OfType() + .FirstOrDefault() + ?.Content + ?? "" + )); + } + /// The namespace an @using imports, without a global:: qualifier; null for an alias or static using. public static string? Namespace(UsingDirectiveIntermediateNode use) { @@ -94,21 +110,20 @@ public static IEnumerable CodeBlocks(IntermediateNode root) return (global is null ? name : name.ReplaceNode(global, global.Name)).ToString(); } - /// The tag a node is: an element bound to tag helpers, or a plain component-file element. - public static MarkupTag? Tag(IntermediateNode node, SourceText text) + /// + /// The tag a node is: an element bound to tag helpers, or a plain component-file element. + /// A legacy file's tag helper element is spelled with the file's @tagHelperPrefix. + /// + public static MarkupTag? Tag(IntermediateNode node, SourceText text, string prefix) { return node switch { - TagHelperIntermediateNode { Source: { } source, StartTagSpan: { } start } bound => - new MarkupTag( - bound.TagName, - Span(source), - Span(start), - bound.TagMode == TagMode.StartTagAndEndTag - ? EndName(Span(source), bound.TagName, text) - : null, - [.. bound.TagHelpers] - ), + TagHelperIntermediateNode { Source: { } source } bound => BoundTag( + bound, + Span(source), + text, + prefix + ), MarkupElementIntermediateNode { Source: { } source } element => PlainTag( element.TagName, Span(source), @@ -118,16 +133,6 @@ [.. bound.TagHelpers] }; } - /// The attributes of a tag node, each with what binds it. - public static IEnumerable Attributes( - IntermediateNode node, - MarkupTag tag, - SourceText text - ) - { - return node.Children.Select(child => Attribute(child, tag, text)).OfType(); - } - /// Whether a diagnostic belongs to the processed file, not to one of its imports. public static bool IsIn(RazorDiagnostic diagnostic, string? path, SourceText text) { @@ -137,58 +142,35 @@ public static bool IsIn(RazorDiagnostic diagnostic, string? path, SourceText tex && (string.IsNullOrEmpty(span.FilePath) || NativePaths.AreEqual(span.FilePath, path)); } - private static MarkupAttribute? Attribute( - IntermediateNode child, - MarkupTag tag, - SourceText text + /// + /// An element tag helpers bind. A component's IR carries its start tag name's span; a + /// legacy file's does not, so its name, prefix first, is read just after the element's + /// <, as HTML spells it in any case, and only where the text holds it. + /// + private static MarkupTag? BoundTag( + TagHelperIntermediateNode bound, + TextSpan element, + SourceText text, + string prefix ) { - return child switch - { - TagHelperPropertyIntermediateNode property => new MarkupAttribute( - MarkupAttributeKind.Property, - property.AttributeName, - property.OriginalAttributeSpan is { } span ? Span(span) : null, - tag, - property.TagHelper, - property.BoundAttribute, - null - ), - TagHelperDirectiveAttributeIntermediateNode directive => new MarkupAttribute( - MarkupAttributeKind.Directive, - directive.OriginalAttributeName, - DirectiveName( - directive.OriginalAttributeSpan, - directive.OriginalAttributeName, - text - ), - tag, - directive.TagHelper, - directive.BoundAttribute, - null - ), - TagHelperDirectiveAttributeParameterIntermediateNode parameter => new MarkupAttribute( - MarkupAttributeKind.DirectiveParameter, - parameter.OriginalAttributeName, - DirectiveName( - parameter.OriginalAttributeSpan, - parameter.OriginalAttributeName, - text - ), - tag, - parameter.TagHelper, - parameter.BoundAttribute, - parameter.BoundAttributeParameter - ), - TagHelperHtmlAttributeIntermediateNode html => Html(html.AttributeName, tag), - HtmlAttributeIntermediateNode html => Html(html.AttributeName, tag), - _ => null, - }; - } - - private static MarkupAttribute Html(string name, MarkupTag tag) - { - return new MarkupAttribute(MarkupAttributeKind.Html, name, null, tag, null, null, null); + var legacy = bound.StartTagSpan is null; + var comparison = legacy ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var spelled = legacy ? prefix + bound.TagName : bound.TagName; + var start = bound.StartTagSpan is { } span + ? Span(span) + : new TextSpan(element.Start + 1, spelled.Length); + return Holds(text, start, spelled, comparison) + ? new MarkupTag( + bound.TagName, + element, + start, + bound.TagMode == TagMode.StartTagAndEndTag + ? EndName(element, spelled, text, comparison) + : null, + [.. bound.TagHelpers] + ) + : null; } private static MarkupTag? PlainTag(string name, TextSpan element, SourceText text) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs index f6232e69..29f0964e 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs @@ -39,9 +39,7 @@ ImmutableArray TagHelpers /// The MVC tag helpers bound to this tag. public IEnumerable MvcTagHelpers => - TagHelpers.Where(helper => - helper.Kind is TagHelperKind.ITagHelper or TagHelperKind.ViewComponent - ); + TagHelpers.Where(RazorTagHelperScope.IsMvc); /// Whether lies on the start or end tag's name. public bool NameContains(int offset) @@ -116,6 +114,9 @@ TagHelperCollection TagHelpers /// The C# code blocks (@{ … }) of the file, which fold. [RAZOR-FEATURES-STRUCTURE] public ImmutableArray CodeBlocks { get; init; } = []; + /// The MVC tag helpers the file's directives bring into scope. [RAZOR-MVC-SCOPE] + public RazorTagHelperScope Scope { get; init; } = RazorTagHelperScope.Empty; + /// The caret's HTML context, [RAZOR-ARCHITECTURE-HTML]. public MarkupContext ContextAt(int offset) { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs index 5a443dbb..3072ef54 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs @@ -6,7 +6,8 @@ namespace SharpLsp.Sidecar.CSharp.Razor; /// /// Reads markup facts out of the compiler's IR while it is built. Directives are read first -/// in the directive-classifier phase, before @code becomes plain C#; tags and +/// in the directive-classifier phase, before @code becomes plain C#, and with them the +/// tag helper directives that scope a legacy file's tag helpers ([RAZOR-MVC-SCOPE]); tags and /// attributes first in the optimization phase, before components are lowered and attribute /// names lose their spans; diagnostics last. The IR is mutated after each read, so each /// pass copies what it needs at once. Implements [RAZOR-ARCHITECTURE-ENGINE]. @@ -21,6 +22,8 @@ internal sealed class RazorMarkupCapture private RazorCodeDocument? _codeDocument; private string _htmlText = ""; private TextSpan[] _codeBlocks = []; + private TagHelperDirective[] _tagHelperDirectives = []; + private string _prefix = ""; /// Add the capture passes to an engine. public void Register(RazorProjectEngineBuilder builder) @@ -50,13 +53,16 @@ [.. _diagnostics.Distinct()], { HtmlText = _htmlText, CodeBlocks = [.. _codeBlocks], + Scope = RazorTagHelperScope.Of(_tagHelperDirectives, tagHelpers), }; } private void ReadDirectives(RazorCodeDocument document, DocumentIntermediateNode root) { var text = document.Source.Text; - _htmlText = RazorHtmlProjection.Create(text, root); + _tagHelperDirectives = [.. RazorIr.TagHelperDirectives(root)]; + _prefix = RazorTagHelperScope.Of(_tagHelperDirectives, []).Prefix; + _htmlText = RazorHtmlProjection.Create(text, root, _prefix); _codeBlocks = [.. RazorIr.CodeBlocks(root)]; foreach (var node in RazorIr.Descendants(root).Where(node => !node.IsImported)) { @@ -77,7 +83,7 @@ private void ReadTags(RazorCodeDocument document, DocumentIntermediateNode root) { _namespaces.Add(ns); } - else if (!node.IsImported && RazorIr.Tag(node, text) is { } tag) + else if (!node.IsImported && RazorIr.Tag(node, text, _prefix) is { } tag) { _tags.Add(tag); _attributes.AddRange(RazorIr.Attributes(node, tag, text)); diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs index 1bb56518..8156be5a 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs @@ -6,12 +6,12 @@ namespace SharpLsp.Sidecar.CSharp.Razor; /// -/// Completion in Blazor markup, from the compiler's tag helpers. After <: components, +/// Completion in Razor markup, from the compiler's tag helpers. After <: components, /// by their short name where their namespace is in scope and their full name otherwise, and /// inside a component its child-content parameters. In a start tag: the parameters not yet -/// set, [EditorRequired] ones first and marked, then @bind-X and the directive -/// attributes with their parameters. Each item replaces the name typed at the caret. -/// Implements [RAZOR-FEATURES-COMPLETION]. +/// set, [EditorRequired] ones first and marked, the attributes of the MVC tag helpers in +/// scope, then @bind-X and the directive attributes with their parameters. Each item +/// replaces the name typed at the caret. Implements [RAZOR-FEATURES-COMPLETION] and [RAZOR-MVC]. /// internal static class RazorMarkupCompletion { @@ -47,8 +47,8 @@ int Group var context = markup.ContextAt(offset); var entries = context == MarkupContext.TagName ? TagEntries(markup, offset) - : context == MarkupContext.AttributeName && OwnerAt(markup, offset) is { } tag - ? AttributeEntries(markup, compilation, tag) + : HtmlMarkupContext.StartTagAt(markup.HtmlText, offset) is { } spelled + ? AttributeEntries(markup, compilation, OwnerAt(markup, offset, spelled), spelled) : null; var replace = TextSpan.FromBounds(HtmlMarkupContext.NameStart(markup.Text, offset), offset); return entries is null @@ -134,28 +134,60 @@ private static IEnumerable ChildContents(RazorMarkup markup, MarkupTag pa )); } + /// + /// The attributes not yet set on the start tag spelled : a + /// component's parameters, the attributes its MVC tag helpers in scope bind, and in a + /// component file the directive attributes. + /// private static IEnumerable AttributeEntries( RazorMarkup markup, Compilation compilation, - MarkupTag tag + MarkupTag? tag, + string spelled ) { var set = markup .Attributes.Where(attribute => attribute.Owner == tag) .Select(attribute => attribute.Name) .ToHashSet(StringComparer.Ordinal); - var parameters = tag.Component is { } component ? Parameters(compilation, component) : []; - var directives = markup - .TagHelpers.Where(helper => - DirectiveKinds.Contains(helper.Kind) && Applies(helper, tag) - ) - .SelectMany(DirectiveEntries); + var parameters = tag?.Component is { } component ? Parameters(compilation, component) : []; + var helpers = markup.Scope.Targeting(spelled).SelectMany(TagHelperEntries); + var directives = + markup.FileKind == RazorFileKind.Component + ? markup + .TagHelpers.Where(helper => + DirectiveKinds.Contains(helper.Kind) + && Applies(helper, tag?.Name ?? spelled) + ) + .SelectMany(DirectiveEntries) + : []; return parameters + .Concat(helpers) .Concat(directives) .Where(entry => !set.Contains(entry.Label)) .DistinctBy(entry => entry.Label); } + /// An MVC tag helper's attributes, and each indexer's prefix (asp-route-). + private static IEnumerable TagHelperEntries(TagHelperDescriptor helper) + { + return helper.BoundAttributes.SelectMany(attribute => + new[] + { + (Label: attribute.Name, Type: attribute.TypeName), + (Label: attribute.IndexerNamePrefix, Type: attribute.IndexerTypeName), + } + .Where(named => !string.IsNullOrEmpty(named.Label)) + .Select(named => new Entry( + named.Label!, + "Property", + named.Type, + attribute.Documentation, + 1 + )) + ); + } + /// A component's parameters; one its property marks [EditorRequired] sorts first. private static IEnumerable Parameters( Compilation compilation, @@ -210,24 +242,30 @@ private static IEnumerable DirectiveEntries(TagHelperDescriptor helper) ); } - /// Whether a directive attribute's tag helper matches 's name. - private static bool Applies(TagHelperDescriptor helper, MarkupTag tag) + /// Whether a directive attribute's tag helper targets a tag named . + private static bool Applies(TagHelperDescriptor helper, string name) { - return helper.TagMatchingRules.Any(rule => - rule.TagName == "*" - || string.Equals( - rule.TagName, - tag.Name, - rule.CaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase - ) - ); + return helper.TagMatchingRules.Any(rule => RazorTagHelperScope.Targets(rule, name)); } - /// The tag whose start tag holds : the nearest one before it. - private static MarkupTag? OwnerAt(RazorMarkup markup, int offset) + /// + /// The tag whose start tag holds : the innermost one holding the + /// caret whose name is spelled as the tokenizer read it. A legacy file's plain HTML element + /// is no tag, so none owns its start tag. + /// + private static MarkupTag? OwnerAt(RazorMarkup markup, int offset, string spelled) { return markup - .Tags.Where(tag => tag.StartName.End <= offset && tag.Element.Start < offset) + .Tags.Where(tag => + tag.StartName.End <= offset + && tag.Element.Start < offset + && offset <= tag.Element.End + && string.Equals( + markup.Text.ToString(tag.StartName), + spelled, + StringComparison.OrdinalIgnoreCase + ) + ) .MaxBy(tag => tag.StartName.Start); } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorTagHelperScope.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorTagHelperScope.cs new file mode 100644 index 00000000..8cb5de17 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorTagHelperScope.cs @@ -0,0 +1,121 @@ +using System.Collections.Immutable; +using Microsoft.AspNetCore.Razor.Language; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// A tag helper directive of a legacy file or of an import: its name and its text. +internal sealed record TagHelperDirective(string Name, string Text); + +/// +/// The MVC tag helpers a legacy file can use and the prefix their tags carry: what its +/// @addTagHelper, @removeTagHelper and @tagHelperPrefix directives +/// declare, its imports' first, read as the compiler reads them. Implements [RAZOR-MVC-SCOPE]. +/// +internal sealed record RazorTagHelperScope( + string Prefix, + ImmutableArray TagHelpers +) +{ + private const string Add = "addTagHelper"; + private const string Remove = "removeTagHelper"; + private const string PrefixDirective = "tagHelperPrefix"; + private const string Global = "global::"; + + /// No tag helpers and no prefix: a component file's scope. + public static RazorTagHelperScope Empty { get; } = new("", []); + + /// The scope declare, in order, over . + public static RazorTagHelperScope Of( + IEnumerable directives, + IEnumerable all + ) + { + var mvc = all.Where(IsMvc).ToArray(); + return directives.Aggregate(Empty, (scope, directive) => scope.Apply(directive, mvc)); + } + + /// Whether a directive named declares the scope. + public static bool Declares(string name) + { + return name is Add or Remove or PrefixDirective; + } + + /// Whether a tag helper is an MVC one, which the directives scope, not a component document's. + public static bool IsMvc(TagHelperDescriptor helper) + { + return helper.Kind is TagHelperKind.ITagHelper or TagHelperKind.ViewComponent; + } + + /// Whether targets elements named . + public static bool Targets(TagMatchingRuleDescriptor rule, string tagName) + { + return rule.TagName is null or "*" + || string.Equals( + rule.TagName, + tagName, + rule.CaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase + ); + } + + /// The tag helpers in scope that target a tag spelled , prefix included. + public IEnumerable Targeting(string tagName) + { + return tagName.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase) + ? TagHelpers.Where(helper => + helper.TagMatchingRules.Any(rule => Targets(rule, tagName[Prefix.Length..])) + ) + : []; + } + + private RazorTagHelperScope Apply(TagHelperDirective directive, TagHelperDescriptor[] mvc) + { + var text = Unquoted(directive.Text.Trim()); + return directive.Name switch + { + Add => this with { TagHelpers = [.. TagHelpers.Union(Matching(text, mvc))] }, + Remove => this with { TagHelpers = [.. TagHelpers.Except(Matching(text, mvc))] }, + PrefixDirective when text.Length > 0 => this with { Prefix = text }, + _ => this, + }; + } + + /// A directive's text without the quotes it may be written in. + private static string Unquoted(string text) + { + return text.Length >= 2 && text[0] == '"' && text[^1] == '"' ? text[1..^1] : text; + } + + /// + /// The tag helpers a TypePattern, AssemblyName lookup names: all of the assembly's + /// for *, those whose full name starts with what precedes a trailing *, else + /// the one so named. A lookup the compiler rejects names none. + /// + private static IEnumerable Matching( + string lookup, + TagHelperDescriptor[] mvc + ) + { + var parts = lookup.Split(',', StringSplitOptions.TrimEntries); + var valid = + parts is [{ Length: > 0 }, { Length: > 0 }] + && !lookup.StartsWith('\'') + && !lookup.EndsWith('\''); + var pattern = + valid && parts[0].StartsWith(Global, StringComparison.Ordinal) + ? parts[0][Global.Length..] + : parts[0]; + return valid + ? mvc.Where(helper => helper.AssemblyName == parts[1] && Matches(pattern, helper.Name)) + : []; + } + + private static bool Matches(string pattern, string name) + { + return pattern == "*" + || ( + pattern.Length > 1 && pattern[^1] == '*' + ? name.StartsWith(pattern[..^1], StringComparison.Ordinal) + : name == pattern + ); + } +} From eb2bb243845c258de23ab1c60da67c53a333ae6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:14:03 +0000 Subject: [PATCH 008/151] build(deps): bump tokio from 1.53.2 to 1.53.3 in the rust-host group Bumps the rust-host group with 1 update: [tokio](https://github.com/tokio-rs/tokio). Updates `tokio` from 1.53.2 to 1.53.3 - [Release notes](https://github.com/tokio-rs/tokio/releases) - [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.53.2...tokio-1.53.3) --- updated-dependencies: - dependency-name: tokio dependency-version: 1.53.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: rust-host ... Signed-off-by: dependabot[bot] --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06101877..13d09c09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,9 +1418,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.2" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", From f7fc97204a2afedda3531e1cf38d77039223d07e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:14:18 +0000 Subject: [PATCH 009/151] build(deps): bump the website group in /src/website with 3 updates Bumps the website group in /src/website with 3 updates: [@11ty/eleventy](https://github.com/11ty/buildawesome), [@playwright/test](https://github.com/microsoft/playwright) and [markdown-it](https://github.com/markdown-it/markdown-it). Updates `@11ty/eleventy` from 3.1.6 to 3.1.7 - [Release notes](https://github.com/11ty/buildawesome/releases) - [Changelog](https://github.com/11ty/buildawesome/blob/main/docs/release-instructions.md) - [Commits](https://github.com/11ty/buildawesome/compare/v3.1.6...v3.1.7) Updates `@playwright/test` from 1.64.0 to 1.64.1 - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](https://github.com/microsoft/playwright/compare/v1.64.0...v1.64.1) Updates `markdown-it` from 15.0.2 to 15.0.3 - [Changelog](https://github.com/markdown-it/markdown-it/blob/master/CHANGELOG.md) - [Commits](https://github.com/markdown-it/markdown-it/compare/15.0.2...15.0.3) --- updated-dependencies: - dependency-name: "@11ty/eleventy" dependency-version: 3.1.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: website - dependency-name: "@playwright/test" dependency-version: 1.64.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: website - dependency-name: markdown-it dependency-version: 15.0.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: website ... Signed-off-by: dependabot[bot] --- src/website/package-lock.json | 192 ++++++++++++++++------------------ src/website/package.json | 6 +- 2 files changed, 96 insertions(+), 102 deletions(-) diff --git a/src/website/package-lock.json b/src/website/package-lock.json index 4d4809e2..38bf7ecd 100644 --- a/src/website/package-lock.json +++ b/src/website/package-lock.json @@ -8,15 +8,32 @@ "name": "sharp-lsp-website", "version": "1.0.0", "dependencies": { - "@11ty/eleventy": "^3.1.6", - "@playwright/test": "^1.64.0", + "@11ty/eleventy": "^3.1.7", + "@playwright/test": "^1.64.1", "eleventy-plugin-techdoc": "0.2.0", - "markdown-it": "^15.0.2" + "markdown-it": "^15.0.3" }, "devDependencies": { "luxon": "^3.7.2" } }, + "node_modules/@11ty/chokidar-with-glob-support": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@11ty/chokidar-with-glob-support/-/chokidar-with-glob-support-1.1.0.tgz", + "integrity": "sha512-B4X9ZPBHG3j/HCT64A8a0PZMkxOj8cUSPto0L61J4KRuwX4EIuUWerggHcWc3RdUx3ih4xtvK8WGjJ4Ak7S/fQ==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "picomatch": "^4.0.7" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/11ty" + } + }, "node_modules/@11ty/dependency-tree": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@11ty/dependency-tree/-/dependency-tree-4.0.2.tgz", @@ -39,14 +56,15 @@ } }, "node_modules/@11ty/eleventy": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/@11ty/eleventy/-/eleventy-3.1.6.tgz", - "integrity": "sha512-ZlSiR1PLdS2lv7TelBgWAhcvMiLNZkPBlLEb+lh7kGYZ+Mk0bo9qcYgVsewvw9W7Em0RH3wd01h5fAstNDh0zA==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@11ty/eleventy/-/eleventy-3.1.7.tgz", + "integrity": "sha512-ioyq2V884tmKDxVH80OPq5cCn4IJqfV4p2BzznxNDn7o7LbA/g5RDl7zYLk5B/TUgoFyLO+dFbIawBWpAHxlyA==", "license": "MIT", "dependencies": { + "@11ty/chokidar-with-glob-support": "^1.1.0", "@11ty/dependency-tree": "^4.0.2", "@11ty/dependency-tree-esm": "^2.0.4", - "@11ty/eleventy-dev-server": "^2.0.8", + "@11ty/eleventy-dev-server": "^2.1.2", "@11ty/eleventy-plugin-bundle": "^3.0.7", "@11ty/eleventy-utils": "^2.0.7", "@11ty/lodash-custom": "^4.17.21", @@ -54,27 +72,26 @@ "@11ty/recursive-copy": "^4.0.4", "@sindresorhus/slugify": "^2.2.1", "bcp-47-normalize": "^2.3.0", - "chokidar": "^3.6.0", "debug": "^4.4.3", "dependency-graph": "^1.0.0", "entities": "^6.0.1", "filesize": "^10.1.6", - "gray-matter": "^4.0.3", - "iso-639-1": "^3.1.5", + "gray-matter": "npm:@11ty/gray-matter@^1.0.0", + "iso-639-1": "^3.1.6", "js-yaml": "^4.1.1", "kleur": "^4.1.5", - "liquidjs": "^10.27.0", + "liquidjs": "^10.30.0", "luxon": "^3.7.2", "markdown-it": "^14.2.0", "minimist": "^1.2.8", "moo": "0.5.2", "node-retrieve-globals": "^6.0.1", - "nunjucks": "^3.2.4", - "picomatch": "^4.0.4", + "nunjucks": "npm:@11ty/nunjucks@^3.3.0", + "picomatch": "^4.0.7", "please-upgrade-node": "^3.2.0", "posthtml": "^0.16.7", "posthtml-match-helper": "^2.0.3", - "semver": "^7.8.1", + "semver": "^7.8.5", "slugify": "^1.6.9", "tinyglobby": "^0.2.16" }, @@ -90,13 +107,13 @@ } }, "node_modules/@11ty/eleventy-dev-server": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@11ty/eleventy-dev-server/-/eleventy-dev-server-2.0.8.tgz", - "integrity": "sha512-15oC5M1DQlCaOMUq4limKRYmWiGecDaGwryr7fTE/oM9Ix8siqMvWi+I8VjsfrGr+iViDvWcH/TVI6D12d93mA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@11ty/eleventy-dev-server/-/eleventy-dev-server-2.1.2.tgz", + "integrity": "sha512-cwReJlQtIZsFuwMhm2fXwwjj+uUVvRkuoESgDtR5Mg9tY2qRj8UbNC8xhYRBuYVpACd3E09CxGPkk/Qj15Zo+A==", "license": "MIT", "dependencies": { + "@11ty/chokidar-with-glob-support": "^1.1.0", "@11ty/eleventy-utils": "^2.0.1", - "chokidar": "^3.6.0", "debug": "^4.4.0", "finalhandler": "^1.3.1", "mime": "^3.0.0", @@ -628,12 +645,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", - "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.64.0" + "playwright": "1.64.1" }, "bin": { "playwright": "cli.js" @@ -1092,19 +1109,6 @@ "acorn": "^8.15.0" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1228,31 +1232,19 @@ } }, "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "name": "@11ty/gray-matter", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@11ty/gray-matter/-/gray-matter-1.0.0.tgz", + "integrity": "sha512-7mJJl+wf1AByoT0PknQiQfOPnVNT4fevGrUBVWO4HXsnYn1aQPyRyrELYrNUFleUBM++KzMKN6QaxHPk0t/6/g==", "license": "MIT", "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", + "js-yaml": "^4.1.0", + "kind-of": "^6.0.3", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" }, "engines": { - "node": ">=6.0" - } - }, - "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.15.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", - "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "node": ">=11" } }, "node_modules/htmlparser2": { @@ -1387,9 +1379,9 @@ "license": "ISC" }, "node_modules/iso-639-1": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/iso-639-1/-/iso-639-1-3.1.5.tgz", - "integrity": "sha512-gXkz5+KN7HrG0Q5UGqSMO2qB9AsbEeyLP54kF1YrMsIxmu+g4BdB7rflReZTSTZGpfj8wywu6pfPBCylPIzGQA==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/iso-639-1/-/iso-639-1-3.1.6.tgz", + "integrity": "sha512-ZFar/L4ngX7wZh2QX+Fiftmuf0igWJsrJtfizrovWifF1gAWkfmRa5Z1m0LQZbm0hKCHRDYhLRSLFrSqNe4EJA==", "license": "MIT", "engines": { "node": ">=6.0" @@ -1464,9 +1456,9 @@ } }, "node_modules/liquidjs": { - "version": "10.27.2", - "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.27.2.tgz", - "integrity": "sha512-kvknfAEtOHjHkAAv7GxLEJh8ghpMQm3Fc4uWVyF7hERSTsSRsdC7saWs0p5aDG7GDcWsu5o+T4232O+8KZO55w==", + "version": "10.30.0", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.30.0.tgz", + "integrity": "sha512-Fw4wA+8CZsJkaeLEIlPE3AoponfjEZhj7FQvYNcAEcg7jNRik7mEMoaco382FBdrYlykVf6w4vClPNctz2OYjg==", "license": "MIT", "dependencies": { "commander": "^10.0.0" @@ -1499,9 +1491,9 @@ } }, "node_modules/markdown-it": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.2.tgz", - "integrity": "sha512-q4IGxMv56jCqT4OCRCADBoDP3LO4MhmTXjFbphHPXs4g3j9Xg5RDnxqN8IF/3vIWEU+VCnUq+7JUg/cfy2E6Qw==", + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.3.tgz", + "integrity": "sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==", "funding": [ { "type": "github", @@ -1514,9 +1506,9 @@ ], "license": "MIT", "dependencies": { - "argparse": "^3.0.0", - "entities": "^8.0.0", - "linkify-it": "^6.0.0", + "argparse": "^3.0.3", + "entities": "^8.1.0", + "linkify-it": "^6.1.0", "mdurl": "^2.1.0", "punycode.js": "^2.3.1", "uc.micro": "^3.0.0" @@ -1536,9 +1528,9 @@ } }, "node_modules/markdown-it/node_modules/argparse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.1.tgz", - "integrity": "sha512-nM4mHF/KM1v59ZNKX7zfusQz5wUAxR511YG8Vo6TyiV4aqhu++rbJW4v04xsWhpSsHFj66flT8P7znVpyO20xQ==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.3.tgz", + "integrity": "sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==", "funding": [ { "type": "github", @@ -1552,9 +1544,9 @@ "license": "PSF-2.0" }, "node_modules/markdown-it/node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -1684,9 +1676,10 @@ } }, "node_modules/nunjucks": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", - "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", + "name": "@11ty/nunjucks", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@11ty/nunjucks/-/nunjucks-3.3.0.tgz", + "integrity": "sha512-kmMWFLcTMVeniw5G/pYV/uvPL/rN3Nm4WjRtkL9gZoXYh4cbME09ubVTHVJFr/G/h6wR89qJ8jULVqYJ4HI3yg==", "license": "BSD-2-Clause", "dependencies": { "a-sync-waterfall": "^1.0.0", @@ -1696,14 +1689,11 @@ "bin": { "nunjucks-precompile": "bin/precompile" }, - "engines": { - "node": ">= 6.9.0" - }, "peerDependencies": { - "chokidar": "^3.3.0" + "@11ty/chokidar-with-glob-support": "^1.1.0" }, "peerDependenciesMeta": { - "chokidar": { + "@11ty/chokidar-with-glob-support": { "optional": true } } @@ -1745,9 +1735,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -1757,12 +1747,12 @@ } }, "node_modules/playwright": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", - "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.64.0" + "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" @@ -1772,9 +1762,9 @@ } }, "node_modules/playwright-core": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", - "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -1866,12 +1856,16 @@ } }, "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/readdirp": { @@ -1907,9 +1901,9 @@ } }, "node_modules/semver": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", - "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2063,9 +2057,9 @@ "license": "MIT" }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/src/website/package.json b/src/website/package.json index d067aa1a..57cc10c0 100644 --- a/src/website/package.json +++ b/src/website/package.json @@ -10,10 +10,10 @@ "test:mobile": "npx playwright test tests/mobile-nav.spec.js" }, "dependencies": { - "@11ty/eleventy": "^3.1.6", - "@playwright/test": "^1.64.0", + "@11ty/eleventy": "^3.1.7", + "@playwright/test": "^1.64.1", "eleventy-plugin-techdoc": "0.2.0", - "markdown-it": "^15.0.2" + "markdown-it": "^15.0.3" }, "devDependencies": { "luxon": "^3.7.2" From b341de90349fb2b4860f722c405920c30dd78463 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:14:53 +0000 Subject: [PATCH 010/151] build(deps-dev): bump the vscode-extension group Bumps the vscode-extension group in /src/editors/vscode with 5 updates: | Package | From | To | | --- | --- | --- | | [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.71.1` | `8.71.2` | | [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.71.1` | `8.71.2` | | [eslint](https://github.com/eslint/eslint) | `10.12.0` | `10.13.0` | | [playwright](https://github.com/microsoft/playwright) | `1.64.0` | `1.64.1` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.71.1` | `8.71.2` | Updates `@typescript-eslint/eslint-plugin` from 8.71.1 to 8.71.2 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.2/packages/eslint-plugin) Updates `@typescript-eslint/parser` from 8.71.1 to 8.71.2 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.2/packages/parser) Updates `eslint` from 10.12.0 to 10.13.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.12.0...v10.13.0) Updates `playwright` from 1.64.0 to 1.64.1 - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](https://github.com/microsoft/playwright/compare/v1.64.0...v1.64.1) Updates `typescript-eslint` from 8.71.1 to 8.71.2 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.2/packages/typescript-eslint) --- updated-dependencies: - dependency-name: "@typescript-eslint/eslint-plugin" dependency-version: 8.71.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: vscode-extension - dependency-name: "@typescript-eslint/parser" dependency-version: 8.71.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: vscode-extension - dependency-name: eslint dependency-version: 10.13.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: vscode-extension - dependency-name: playwright dependency-version: 1.64.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: vscode-extension - dependency-name: typescript-eslint dependency-version: 8.71.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: vscode-extension ... Signed-off-by: dependabot[bot] --- src/editors/vscode/package-lock.json | 254 +++++++++++++-------------- src/editors/vscode/package.json | 10 +- 2 files changed, 132 insertions(+), 132 deletions(-) diff --git a/src/editors/vscode/package-lock.json b/src/editors/vscode/package-lock.json index 3585a6e7..fe7cd153 100644 --- a/src/editors/vscode/package-lock.json +++ b/src/editors/vscode/package-lock.json @@ -20,19 +20,19 @@ "@types/mocha": "^10.0.6", "@types/node": "^26.6.5", "@types/vscode": "^1.105.0", - "@typescript-eslint/eslint-plugin": "^8.71.1", - "@typescript-eslint/parser": "^8.71.1", + "@typescript-eslint/eslint-plugin": "^8.71.2", + "@typescript-eslint/parser": "^8.71.2", "@vscode/test-cli": "^0.0.15", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^4.0.0", "esbuild": "^0.28.2", - "eslint": "^10.12.0", + "eslint": "^10.13.0", "glob": "^13.0.6", "mocha": "^12.0.3", - "playwright": "^1.64.0", + "playwright": "^1.64.1", "prettier": "3.9.10", "typescript": "~7.0.2", - "typescript-eslint": "^8.71.1" + "typescript-eslint": "^8.71.2" }, "engines": { "vscode": "^1.105.0" @@ -1345,17 +1345,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", - "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.71.1", - "@typescript-eslint/type-utils": "8.71.1", - "@typescript-eslint/utils": "8.71.1", - "@typescript-eslint/visitor-keys": "8.71.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1368,21 +1368,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.71.1", + "@typescript-eslint/parser": "^8.71.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", - "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/typescript-estree": "8.71.1", - "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1399,16 +1399,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", - "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.71.1", - "@typescript-eslint/tsconfig-utils": "8.71.1", - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/visitor-keys": "8.71.1", + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1427,14 +1427,14 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", - "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.71.1", - "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", "debug": "^4.4.3" }, "engines": { @@ -1449,9 +1449,9 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", - "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "dev": true, "license": "MIT", "engines": { @@ -1466,16 +1466,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", - "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.71.1", - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/typescript-estree": "8.71.1" + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1490,16 +1490,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", - "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.71.1", - "@typescript-eslint/tsconfig-utils": "8.71.1", - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/visitor-keys": "8.71.1", + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1518,14 +1518,14 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", - "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.71.1", - "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", "debug": "^4.4.3" }, "engines": { @@ -1540,9 +1540,9 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", - "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "dev": true, "license": "MIT", "engines": { @@ -1557,16 +1557,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", - "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.71.1", - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/typescript-estree": "8.71.1", - "@typescript-eslint/visitor-keys": "8.71.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3" }, "engines": { @@ -1582,16 +1582,16 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", - "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.71.1", - "@typescript-eslint/tsconfig-utils": "8.71.1", - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/visitor-keys": "8.71.1", + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1610,14 +1610,14 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", - "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.71.1", - "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", "debug": "^4.4.3" }, "engines": { @@ -1632,9 +1632,9 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", - "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "dev": true, "license": "MIT", "engines": { @@ -1649,14 +1649,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", - "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/visitor-keys": "8.71.1" + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1667,9 +1667,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", - "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", "dev": true, "license": "MIT", "engines": { @@ -1681,13 +1681,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", - "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/types": "8.71.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3111,9 +3111,9 @@ } }, "node_modules/eslint": { - "version": "10.12.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", - "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "version": "10.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz", + "integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==", "dev": true, "license": "MIT", "workspaces": [ @@ -4583,13 +4583,13 @@ } }, "node_modules/playwright": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", - "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.64.0" + "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" @@ -4599,9 +4599,9 @@ } }, "node_modules/playwright-core": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", - "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5250,16 +5250,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz", - "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.71.1", - "@typescript-eslint/parser": "8.71.1", - "@typescript-eslint/typescript-estree": "8.71.1", - "@typescript-eslint/utils": "8.71.1" + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5274,16 +5274,16 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", - "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.71.1", - "@typescript-eslint/tsconfig-utils": "8.71.1", - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/visitor-keys": "8.71.1", + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5302,14 +5302,14 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", - "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.71.1", - "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", "debug": "^4.4.3" }, "engines": { @@ -5324,9 +5324,9 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", - "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "dev": true, "license": "MIT", "engines": { @@ -5341,16 +5341,16 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", - "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.71.1", - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/typescript-estree": "8.71.1" + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/src/editors/vscode/package.json b/src/editors/vscode/package.json index 1850534f..3c6ec4b6 100644 --- a/src/editors/vscode/package.json +++ b/src/editors/vscode/package.json @@ -1274,19 +1274,19 @@ "@types/mocha": "^10.0.6", "@types/node": "^26.6.5", "@types/vscode": "^1.105.0", - "@typescript-eslint/eslint-plugin": "^8.71.1", - "@typescript-eslint/parser": "^8.71.1", + "@typescript-eslint/eslint-plugin": "^8.71.2", + "@typescript-eslint/parser": "^8.71.2", "@vscode/test-cli": "^0.0.15", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^4.0.0", "esbuild": "^0.28.2", - "eslint": "^10.12.0", + "eslint": "^10.13.0", "glob": "^13.0.6", "mocha": "^12.0.3", - "playwright": "^1.64.0", + "playwright": "^1.64.1", "prettier": "3.9.10", "typescript": "~7.0.2", - "typescript-eslint": "^8.71.1" + "typescript-eslint": "^8.71.2" }, "files": [ "bin/", From fa27869ea0d013dd2c6c05f7070b05b2af088870 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:15:11 +0000 Subject: [PATCH 011/151] Bump the dotnet-sidecars group with 1 update Bumps Fantomas.Core from 8.0.8 to 8.0.10 --- updated-dependencies: - dependency-name: Fantomas.Core dependency-version: 8.0.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dotnet-sidecars ... Signed-off-by: dependabot[bot] --- .../SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj b/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj index f8962846..a2729d5e 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj @@ -37,7 +37,7 @@ sidecar ships it like the C# sidecar does: restore full asset flow. [SHARPLSP-ARCHITECTURE-PROJECTS-FSHARP-CSHARP-REFERENCES] --> - + From dbbf3e20c79c71a638be0018a79c1d3350f69ca9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:18:08 +0000 Subject: [PATCH 012/151] build(deps): bump the github-actions group with 4 updates Bumps the github-actions group with 4 updates: [taiki-e/install-action](https://github.com/taiki-e/install-action), [actions/setup-java](https://github.com/actions/setup-java), [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action). Updates `taiki-e/install-action` from 2.87.27 to 2.87.29 - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/ab6895351a88b908e96a4697860d4ffea5e57dbe...8cca0918f8471c33515202b73f56ab27f2bed283) Updates `actions/setup-java` from 6.0.1 to 6.1.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/de7274f081f381c8f8158605e0321c36c376e2e6...b843ebf9b913146b38da2eaa7aeddf76074ae1ad) Updates `github/codeql-action/init` from 4.38.3 to 4.39.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee) Updates `github/codeql-action/analyze` from 4.38.3 to 4.39.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.87.29 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: actions/setup-java dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: github/codeql-action/init dependency-version: 4.39.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: github/codeql-action/analyze dependency-version: 4.39.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-audit.yml | 2 +- .github/workflows/ci-test-editors.yml | 4 ++-- .github/workflows/ci-test-rust.yml | 4 ++-- .github/workflows/codeql.yml | 4 ++-- .github/workflows/release.yml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-audit.yml b/.github/workflows/ci-audit.yml index fb606e2f..d98275a7 100644 --- a/.github/workflows/ci-audit.yml +++ b/.github/workflows/ci-audit.yml @@ -36,7 +36,7 @@ jobs: - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: toolchain: stable - - uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2 + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 with: tool: cargo-audit - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/ci-test-editors.yml b/.github/workflows/ci-test-editors.yml index d6abf5c8..e8f09b94 100644 --- a/.github/workflows/ci-test-editors.yml +++ b/.github/workflows/ci-test-editors.yml @@ -43,7 +43,7 @@ jobs: - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 with: workspaces: src/editors/zed - - uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2 + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 with: tool: cargo-llvm-cov - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 @@ -63,7 +63,7 @@ jobs: RIDER_REQUIRED: '1' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: temurin java-version: '21' diff --git a/.github/workflows/ci-test-rust.yml b/.github/workflows/ci-test-rust.yml index 1ddda8ec..9ca4fd81 100644 --- a/.github/workflows/ci-test-rust.yml +++ b/.github/workflows/ci-test-rust.yml @@ -40,10 +40,10 @@ jobs: toolchain: stable components: llvm-tools-preview - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 - - uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2 + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 with: tool: cargo-llvm-cov - - uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2 + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 with: tool: nextest - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index dcf68b95..1a24110b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -68,7 +68,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -76,7 +76,7 @@ jobs: # 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@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: category: "/language:${{ matrix.language }}" # Drop the SARIF on disk so the gate step can read it. `upload` stays on diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7786a2c1..8f6fe44b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -215,7 +215,7 @@ jobs: - name: Stamp release version shell: bash run: make _stamp-version VERSION="${{ needs.version.outputs.version }}" - - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: temurin java-version: '21' From 052d32c3226b481ad00578cd271758c7836acf39 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:12:22 +1100 Subject: [PATCH 013/151] Razor: Pages and MVC, F# in Razor, editing features, collocated files, WebAssembly debugging and Hot Reload Completes the Razor plan: - Razor Pages and MVC views: tag helper hover, definition, completion and scope; @model, @inject and @functions through the projection. - F# in Razor solutions: hover with F# docs, definition into the F# source, and references and rename across F#, C# and Razor, found by assembly and XML doc identity. - Editing features from Razor positions: selection ranges, code actions (add @using, generate a handler, extract to code-behind or a component), inlay hints, code lens, and call and type hierarchy. C# hierarchy kinds are PascalCase, as the host maps them. - Collocated files: .razor.css classes complete as LSP values and define to their selectors, an "import" call defines to its .razor.js, and VS Code nests the files. The attribute at a caret is read by the HTML tokenizer. - Blazor WebAssembly launches through the sharplsp-blazorwasm debugger and js-debug. - Razor Hot Reload: a Razor save swaps the additional document in every framework copy. A re-save of committed text answers applied with nothing to apply, for .cs saves too. - Review fixes: the host maps the Value completion kind, the F# Razor e2e builds its F# library, and fixture copies and hierarchy runners are shared. --- docs/plans/RAZOR-PLAN.md | 64 ++- docs/specs/DEBUGGING-SPEC.md | 43 +- docs/specs/DEFINITION-SPEC.md | 47 +- docs/specs/RAZOR-SPEC.md | 190 ++++++- docs/specs/REFERENCES-SPEC.md | 11 +- docs/specs/RENAME-SPEC.md | 2 + src/editors/vscode/package.json | 71 ++- src/editors/vscode/src/blazor-wasm-debug.ts | 443 ++++++++++++++++ src/editors/vscode/src/blazor-wasm-launch.ts | 468 +++++++++++++++++ src/editors/vscode/src/dap-hot-reload.ts | 10 +- src/editors/vscode/src/debug.ts | 17 + src/editors/vscode/src/hot-reload.ts | 5 +- src/editors/vscode/src/launch-profiles.ts | 12 + src/editors/vscode/src/razor-html.ts | 14 +- .../blazor-wasm-debug-resolution.test.ts | 479 ++++++++++++++++++ .../src/test/suite/blazor-wasm-debug.test.ts | 286 +++++++++++ .../test/suite/debug-hot-reload-e2e.test.ts | 60 +++ .../src/test/suite/dotnet-project-kit.ts | 21 + .../test/suite/fsi-build-output-e2e.test.ts | 27 +- .../vscode/src/test/suite/razor-kit.ts | 29 +- .../vscode/src/test/suite/razor-lsp.test.ts | 160 ++++++ .../suite/run-debug-contributions.test.ts | 11 +- src/editors/vscode/test-chunks.json | 4 +- src/sharplsp/src/call_hierarchy.rs | 1 + src/sharplsp/src/code_actions.rs | 177 ++++++- src/sharplsp/src/cross_language.rs | 305 +++++++++++ src/sharplsp/src/main.rs | 8 +- src/sharplsp/src/razor.rs | 103 +++- src/sharplsp/src/semantic.rs | 141 ++++-- .../e2e_modules/definition_cross_language.rs | 62 ++- .../e2e_modules/fixtures_cross_language.rs | 2 +- .../tests/e2e_modules/fixtures_razor.rs | 18 + src/sharplsp/tests/e2e_modules/mod.rs | 5 + src/sharplsp/tests/e2e_modules/razor.rs | 29 +- .../tests/e2e_modules/razor_collocation.rs | 175 +++++++ .../tests/e2e_modules/razor_fsharp.rs | 341 +++++++++++++ .../tests/e2e_modules/razor_hierarchy.rs | 191 +++++++ src/sharplsp/tests/e2e_modules/razor_mvc.rs | 143 ++++++ .../tests/e2e_modules/razor_selection.rs | 179 +++++++ .../tests/fixtures/blazor-wasm/App.razor | 9 + .../fixtures/blazor-wasm/BlazorWasmApp.csproj | 17 + .../blazor-wasm/Directory.Build.props | 6 + .../blazor-wasm/Directory.Build.targets | 4 + .../fixtures/blazor-wasm/Pages/Counter.razor | 14 + .../tests/fixtures/blazor-wasm/Program.cs | 11 + .../Properties/launchSettings.json | 11 + .../tests/fixtures/blazor-wasm/_Imports.razor | 5 + .../fixtures/blazor-wasm/wwwroot/index.html | 23 + .../BlazorApp/Components/Pages/Leaders.razor | 4 +- .../BlazorApp/Components/Shared/Card.razor | 36 ++ .../BlazorApp/Components/Shared/Card.razor.cs | 28 + .../Components/Shared/Card.razor.css | 52 ++ .../BlazorApp/Components/Shared/Card.razor.js | 10 + .../Components/Shared/CardInterop.cs | 17 + .../BlazorApp/Components/Shared/Counting.cs | 14 + .../BlazorApp/Components/Shared/Ticker.razor | 19 + .../tests/fixtures/razor/Domain/Scores.fs | 18 +- .../CallHierarchyKindTests.cs | 49 ++ .../HeadlessOverrideGenerationTests.cs | 16 +- .../HotReloadSessionRegistryTests.cs | 27 + .../HtmlMarkupContextTests.cs | 66 +++ .../MergeDeclarationAssignmentTests.cs | 16 +- .../NavigationEndToEndTests.cs | 2 +- .../RazorCodeActionsTests.cs | 349 +++++++++++++ .../RazorCollocationTests.cs | 301 +++++++++++ .../RazorCompletionEditsTests.cs | 48 +- .../RazorFSharpTests.cs | 310 ++++++++++++ .../RazorHierarchyTests.cs | 191 +++++++ .../RazorHintsAndLensesTests.cs | 136 +++++ .../RazorHotReloadTests.cs | 366 +++++++++++++ .../RazorMvcTests.cs | 107 ++++ .../RazorSelectionRangesTests.cs | 288 +++++++++++ .../RazorSidecarTests.cs | 96 ++++ .../RazorStylesheetTests.cs | 196 +++++++ .../TextEditing.cs | 23 + .../WorkspaceManagerExtraCoverageTests.cs | 14 +- .../WorkspaceManagerFeatureCoverageTests.cs | 19 +- .../WorkspaceManagerForeignRenameTests.cs | 19 +- .../CSharpSidecar.ForeignRename.cs | 19 + .../CSharpSidecar.Razor.cs | 62 +++ .../SharpLsp.Sidecar.CSharp/CSharpSidecar.cs | 2 + .../Debugging/HotReloadSession.cs | 115 ++++- .../Debugging/HotReloadSignatureGuard.cs | 21 +- .../ForeignRenameMessages.cs | 14 + .../SharpLsp.Sidecar.CSharp/Messages.cs | 11 + .../Razor/CssTokenizer.cs | 377 ++++++++++++++ .../Razor/HtmlMarkupContext.Attributes.cs | 108 ++++ .../Razor/HtmlMarkupContext.cs | 23 +- .../Razor/RazorCollocation.cs | 240 +++++++++ .../Razor/RazorCompletionEdits.cs | 45 +- .../Razor/RazorExtractActions.cs | 330 ++++++++++++ .../Razor/RazorHandlerText.cs | 64 +++ .../Razor/RazorHints.cs | 94 ++++ .../Razor/RazorIr.Attributes.cs | 1 + .../Razor/RazorMarkup.cs | 7 +- .../Razor/RazorMarkupCompletion.cs | 34 +- .../Razor/RazorQuickFixes.cs | 244 +++++++++ .../Razor/RazorSelectionRanges.cs | 134 +++++ .../Razor/RazorStylesheet.cs | 111 ++++ .../Workspace/CallHierarchyResolver.cs | 71 ++- .../Workspace/CodeActionResolver.cs | 1 + .../Workspace/CodeLensResolver.cs | 43 +- .../Workspace/DefinitionResolver.cs | 25 +- .../Workspace/FSharpProjectSymbols.cs | 133 +++++ .../Workspace/InlayHintResolver.cs | 22 +- .../Workspace/TypeHierarchyResolver.cs | 10 +- .../Workspace/WorkspaceManager.Features.cs | 38 +- .../WorkspaceManager.ForeignReferences.cs | 67 +++ .../WorkspaceManager.ForeignRename.cs | 33 +- .../Workspace/WorkspaceManager.Helpers.cs | 6 +- .../Workspace/WorkspaceManager.Hierarchy.cs | 127 ++++- .../Workspace/WorkspaceManager.Navigation.cs | 79 ++- .../WorkspaceManager.RazorCodeActions.cs | 113 +++++ .../WorkspaceManager.RazorCollocation.cs | 157 ++++++ .../WorkspaceManager.RazorHierarchy.cs | 240 +++++++++ .../Workspace/WorkspaceManager.RazorMarkup.cs | 11 +- .../WorkspaceManager.RazorSelection.cs | 76 +++ .../Workspace/WorkspaceManager.Rename.cs | 55 +- .../Workspace/WorkspaceManager.cs | 20 +- .../NativePaths.Razor.cs | 43 ++ .../Solutions/ProjectReferences.cs | 4 +- .../FSharpForeignSymbolsTests.fs | 157 ++++++ .../FSharpForeignSymbolsWireTests.fs | 76 +++ .../SharpLsp.Sidecar.FSharp.Tests.fsproj | 2 + .../FSharpForeignSymbols.fs | 124 +++++ .../FSharpReferences.fs | 52 +- .../SharpLsp.Sidecar.FSharp/FSharpRename.fs | 64 +-- .../SharpLsp.Sidecar.FSharp/FSharpSidecar.fs | 5 + .../SharpLsp.Sidecar.FSharp/FSharpWire.fs | 22 + .../SharpLsp.Sidecar.FSharp.fsproj | 3 +- 130 files changed, 10735 insertions(+), 491 deletions(-) create mode 100644 src/editors/vscode/src/blazor-wasm-debug.ts create mode 100644 src/editors/vscode/src/blazor-wasm-launch.ts create mode 100644 src/editors/vscode/src/test/suite/blazor-wasm-debug-resolution.test.ts create mode 100644 src/editors/vscode/src/test/suite/blazor-wasm-debug.test.ts create mode 100644 src/sharplsp/src/cross_language.rs create mode 100644 src/sharplsp/tests/e2e_modules/razor_collocation.rs create mode 100644 src/sharplsp/tests/e2e_modules/razor_fsharp.rs create mode 100644 src/sharplsp/tests/e2e_modules/razor_hierarchy.rs create mode 100644 src/sharplsp/tests/e2e_modules/razor_mvc.rs create mode 100644 src/sharplsp/tests/e2e_modules/razor_selection.rs create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/App.razor create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/BlazorWasmApp.csproj create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.props create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.targets create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/Pages/Counter.razor create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/Program.cs create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/Properties/launchSettings.json create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/_Imports.razor create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/wwwroot/index.html create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.cs create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.css create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.js create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/CardInterop.cs create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Counting.cs create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Ticker.razor create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/CallHierarchyKindTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCodeActionsTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCollocationTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorFSharpTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHierarchyTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHintsAndLensesTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHotReloadTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSelectionRangesTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorStylesheetTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/TextEditing.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/CssTokenizer.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.Attributes.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCollocation.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorExtractActions.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHandlerText.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHints.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorQuickFixes.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSelectionRanges.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorStylesheet.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FSharpProjectSymbols.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignReferences.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCodeActions.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCollocation.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHierarchy.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorSelection.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsTests.fs create mode 100644 src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsWireTests.fs create mode 100644 src/sidecars/SharpLsp.Sidecar.FSharp/FSharpForeignSymbols.fs diff --git a/docs/plans/RAZOR-PLAN.md b/docs/plans/RAZOR-PLAN.md index 561a3f3f..5197f243 100644 --- a/docs/plans/RAZOR-PLAN.md +++ b/docs/plans/RAZOR-PLAN.md @@ -20,7 +20,7 @@ The phases below are in dependency order. Each item is checked only when a test - [x] Fixture solution `src/sharplsp/tests/fixtures/razor/` with `BlazorApp`, `Widgets` (RCL), `PagesApp` (Pages + MVC) and `Domain` (F#), isolated by its own `Directory.Build.props` ([RAZOR-TESTING]) - [x] Host: add `LangId::Razor` for `.razor` and `.cshtml`, owned by the C# sidecar ([RAZOR-ARCHITECTURE-ROUTING]) -- [x] Host: route `documentSymbol`, `foldingRange` and `linkedEditingRange` for Razor to the sidecar, and answer `selectionRange` empty +- [x] Host: route `documentSymbol`, `foldingRange` and `linkedEditingRange` for Razor to the sidecar, and answer `selectionRange` empty (the Deferred selection-range item has since routed it to the sidecar too) - [x] Host: add `<` and `@` completion triggers, and short-circuit them for C# and F# documents - [x] Host: publish Razor diagnostics under the source `sharplsp-razor` - [x] Sidecar: bundle the Razor compiler from the build SDK, and list it in `THIRD-PARTY-NOTICES.md` ([RAZOR-ARCHITECTURE-ENGINE]) @@ -76,7 +76,7 @@ The phases below are in dependency order. Each item is checked only when a test - Renaming `Badge.Text` from its code-behind edits `@Text` and Board's `Text=`. - Renaming `Tally.Label` turns `@bind-Label` into `@bind-Title`. - prepareRename refuses on a component's start and end tags. - - The F# half of the rename is Phase 6 + - The F# half of the rename is Phase 5 ### Phase 3: Blazor Markup @@ -110,11 +110,30 @@ The phases below are in dependency order. Each item is checked only when a test ### Phase 4: Razor Pages and MVC -- [ ] `@model`, `@inject`, `@section` and `@functions` through the projection ([RAZOR-MVC]) -- [ ] Tag helper elements and attributes: hover and completion -- [ ] `_ViewImports.cshtml` imports resolved by the engine - -### Phase 5: Editors +- [x] `@model`, `@inject`, `@section` and `@functions` through the projection ([RAZOR-MVC-DIRECTIVES]) + - `RazorMvcTests.Model_inject_and_functions_answer_through_the_projection`: `@model EditModel` and `Model.Title` hover their docs, `@inject` hovers `ILogger<…>`, and `@Shout` goes to its `@functions` declaration. + - `Model_expressions_and_section_code_get_csharp_features`: `asp-for="Title"` hovers and goes to `EditModel.Title`, finds its 4 references in `Edit.cshtml`, and renames them all with no generated-file edit; `@Model.Price` inside `@section` hovers. `Section_blocks_fold_and_outline` folds and outlines `@section`. + - Full stack: `test_full_stack_razor_pages_and_mvc_views` (`razor_mvc.rs`). +- [x] Tag helper elements and attributes: hover and completion ([RAZOR-MVC-TAGHELPERS], [RAZOR-MVC-SCOPE]) + - Hover: `Tag_helper_elements_and_attributes_answer_hover_from_their_descriptors` (`` answers `InputTagHelper`, `asp-for` its `ModelExpression`). + - Completion: `Tag_helper_attributes_complete_in_an_element_start_tag` (`asp-page`, `asp-controller`, `asp-route-`) and `Tag_helper_elements_complete_after_an_open_angle_bracket` (`partial`, `environment`, `a` once, the `th:` prefix, `@removeTagHelper`). That test found components completing after `<` in `.cshtml` files, where no component binds by tag; they no longer do. + - Scope: `Tag_helper_directives_scope_completion_and_binding`. VS Code: `a Razor Page completes tag helper elements once, before the HTML service` (an element that both HTML and a tag helper name is listed once). +- [x] `_ViewImports.cshtml` imports resolved by the engine ([RAZOR-MVC-IMPORTS]) — `ViewImports_imports_reach_the_views_below_them`: `@model Item` resolves through `Views/_ViewImports.cshtml`'s `@using`, the view reports no error, and its `@addTagHelper` binds `` + +### Phase 5: F# + +- [x] Hover, completion and definition from Razor into F# types of a referenced F# project ([RAZOR-FSHARP-METADATA], [RAZOR-FSHARP-DEFINITION]) + - Hover shows the F# XML docs: the design-time build references Domain's `refint` assembly, which has no XML, so the sidecar swaps in the built DLL with its documentation. `RazorFSharpTests.Hover_and_signature_help_read_FSharp_symbols_from_their_assembly` and `Completion_offers_FSharp_members_in_markup_and_code`. + - Definition lands in `Scores.fs`, not decompiled source: the C# sidecar names each F# symbol by assembly and XML doc ID, and the F# sidecar finds its declaration (`workspace/declarationForeign`, [DEFINITION-CROSSLANG-IDENTITY]). `Definition_names_each_FSharp_symbol_for_the_FSharp_sidecar`; F#: `FSharpForeignSymbolsTests`. The C#→F# definition e2e now lands in `Shapes.fs` too. + - References merge both sides by the same identity (`workspace/referencesForeign`, [REFERENCES-CROSS-LANGUAGE]): `Foreign_references_and_rename_reach_Razor_uses_of_an_FSharp_symbol`. + - F# references and highlights placed a dotted use (`player.Score`) over its whole path. They now sit on the name, the same span the foreign path reports (`F# references and foreign uses place each use on its name alike`). +- [x] F#-origin rename that includes Razor usages, mapped back from the projections ([RAZOR-FSHARP-RENAME]) + - From F#: `Foreign_references_and_rename_reach_Razor_uses_of_an_FSharp_symbol` renames `Leaders.razor`'s `@player.Score`. + - From Razor or C#: the rename resolves to the F# symbol and renames it everywhere (`Rename_from_Razor_renames_an_FSharp_symbol_in_CSharp_and_Razor`). + - Full stack: `test_full_stack_fsharp_in_razor_session` (hover, definition into `Scores.fs`, references with and without the declaration, rename from Razor and from F#). VS Code: `F# symbols in Razor navigate to, find and rename in their F# source`. + - That full-stack test only restored the fixture, so C# had no `Domain.dll` to read and every F# symbol stayed unresolved ([DEFINITION-CROSSLANG]: the F# project must be built). Its workspace now builds the F# library (`create_razor_workspace_with_fsharp_built`). + +### Phase 6: Editors - [x] VS Code: sync `razor` and `aspnetcorerazor` documents, and activate on `onLanguage:razor` ([RAZOR-EDITORS]) — `razor-lsp.test.ts`: a Razor file opens as Razor, and its unsaved edits reach the server - [x] VS Code: forward HTML completion and hover over `sharplsp/razor/htmlProjection` ([RAZOR-FEATURES-HTML]) — `razor-lsp.test.ts`: element names and HTML hovers in markup, C# completion and hover in code @@ -122,16 +141,21 @@ The phases below are in dependency order. Each item is checked only when a test - [x] VS Code: add a `razor` end-to-end chunk on both platforms — `test-chunks.json`, in both matrices - [x] Zed: Razor language registration (Zed has no built-in Razor grammar) — `the_server_attaches_to_the_csharp_fsharp_and_razor_languages` -### Phase 6: F# - -- [ ] Hover, completion and definition from Razor into F# types of a referenced F# project ([RAZOR-FSHARP]) -- [ ] F#-origin rename that includes Razor usages, mapped back from the projections - -### Deferred ([RAZOR-FEATURES-DEFERRED]) - -- [ ] Razor formatting -- [ ] Selection ranges -- [ ] Code actions: extract a block to code-behind, extract a component, generate an event handler, add `@using` -- [ ] Inlay hints, code lens, and call and type hierarchy from Razor positions -- [ ] `.razor.css` and `.razor.js` collocation awareness -- [ ] Blazor WebAssembly debugging and Razor Hot Reload +### Deferred + +- [x] Razor formatting — resolved as out of scope: SharpLsp formats no language ([SHARPLSP-FEATURES-FORMATTING]), so the spec now says so for Razor ([RAZOR-SCOPE-FORMATTING]) and nothing is advertised +- [x] Selection ranges ([RAZOR-FEATURES-SELECTION]) — `RazorSelectionRangesTests` (6 cases: C# chains through syntax nodes, tag names through enclosing elements, attribute names and values, implicit expressions, legacy files, unlisted files); `test_full_stack_razor_selection_ranges`; the routing session's `selectionRange` now asserts the exact chain. An attribute's whole span comes from the compiler's spans (a new `MarkupAttribute.End` fact), not a scan of the text +- [x] Code actions: extract a block to code-behind, extract a component, generate an event handler, add `@using` ([RAZOR-CODE-ACTIONS]) — `RazorCodeActionsTests` (5 cases). A new `@using` goes after a Razor Page's `@page` line, never above it (`A_new_import_follows_the_page_directive_a_Razor_Page_opens_with`). New files arrive as LSP `CreateFile` operations (`map_workspace_edit_creates_new_files_before_editing_them`) +- [x] Inlay hints, code lens, and call and type hierarchy from Razor positions ([RAZOR-FEATURES-INLAY-HINTS], [RAZOR-FEATURES-CODE-LENS], [RAZOR-FEATURES-HIERARCHY]) — `RazorHintsAndLensesTests` (2), `RazorHierarchyTests` (3), `test_full_stack_razor_hierarchy_and_hints` + - Outgoing calls from a Razor projection threw: the resolver read the declaration through `Solution.GetDocument`, which is null for a source-generated document. It now reads each declaration through its own tree + - Markup callers are the component, named by its file with kind `Class`, never the generated `BuildRenderTree` + - The C# sidecar named hierarchy kinds in lowercase, while the host maps PascalCase (the F# sidecar's casing). So every C# class, property or interface item reached the client as a method or class. The C# kinds are now PascalCase, and the e2e asserts the LSP kinds +- [x] `.razor.css` and `.razor.js` collocation awareness ([RAZOR-COLLOCATION]) — `RazorCollocationTests` (6), `RazorStylesheetTests` (10), `HtmlMarkupContextTests` (attribute at a caret), `test_full_stack_razor_collocated_styles_and_scripts`, VS Code `a component completes and defines its stylesheet classes, and nests its collocated files` + - Classes come from a CSS Syntax Level 3 tokenizer over rule preludes; a class completes as LSP `Value` and defines to every selector that declares it. A module literal in an `"import"` call defines to its `.razor.js`, from Razor, a code-behind and plain C# + - The attribute at a caret is read by the HTML tokenizer, continued past the caret. Review found three bugs in a text-scanning first version: an attribute after a bare one (``, `
`, ``) is a tag of the file's markup facts, with the tag helpers bound to it. Its attributes that the tag helpers bind (`asp-for`, `asp-page`, `asp-route-*`) are `Property` attributes. +- **Hover:** a bound element answers the tag helper type's hover, and a bound attribute the hover of the property it sets. +- **Completion after `<`:** each element a tag helper in scope targets by name, spelled with the scope's prefix, once, as a `Class` item whose detail names the tag helpers that bind it. A rule that targets every element (`*`) names no element. The client drops its own HTML item with the same label. +- **Completion in a start tag:** the attributes that the tag helpers targeting the element bind, not yet set, and each indexer's prefix (`asp-route-`). +- **Model expressions:** an attribute whose property is a `ModelExpression` (`asp-for="Title"`) holds a C# model expression, so it gets C# hover, definition, references and rename through the projection. Its value is blank in the HTML projection; every other literal value stays. + +### [RAZOR-MVC-SCOPE] Tag Helper Scope + +The tag helpers a legacy file can use are those its imports' and its own `@addTagHelper`, `@removeTagHelper` and `@tagHelperPrefix` directives declare, applied in order, as the compiler applies them: + +- `@addTagHelper TypePattern, Assembly` adds the assembly's tag helpers whose full name the pattern names: all of them for `*`, those whose name starts with what precedes a trailing `*`, or the one so named. A `global::` prefix is ignored. A lookup the compiler rejects adds none. +- `@removeTagHelper` removes the tag helpers its lookup names, wherever in the file it is written. +- `@tagHelperPrefix p:` makes only elements spelled `p:name` tag helper elements. They bind, complete and answer hover under the prefixed name, and unprefixed elements do none of it. +- A component file has no tag helper scope: its MVC tag helpers never bind. A legacy file binds no component by its tag, so components never complete after `<` there. + +### [RAZOR-MVC-IMPORTS] Imports + +- `_ViewImports.cshtml` is an import. Its `@using`, `@addTagHelper` and other directives reach every view and page in its folder and below, read from the workspace's current text ([RAZOR-ARCHITECTURE-ENGINE]). +- `_ViewStart.cshtml`, layouts and partials are ordinary legacy files. ## [RAZOR-FSHARP] F# in Razor Solutions -Blazor front ends often sit over F# domain libraries. A Razor project that references an F# project reads it as its built DLL, like any C# project does ([DEFINITION-CROSSLANG]). +Blazor front ends often sit over F# domain libraries. A Razor project that references an F# project reads it as its built DLL, like any C# project does ([DEFINITION-CROSSLANG]). Every feature below reaches the F# project through the symbol's identity, its assembly and XML doc signature, so F# symbols in Razor files are as navigable and renameable as C# ones ([DEFINITION-CROSSLANG-IDENTITY]). + +### [RAZOR-FSHARP-METADATA] Hover, Completion and Signature Help + +- These work on F# types and members used in Razor, through Domain's metadata: records and their fields, unions and their cases' tests (`IsLeading`), and module functions. +- Hover shows the F# documentation, read from the F# project's XML documentation file. +- A curried F# function is a method of all its parameters to C#, so signature help lists them all. + +### [RAZOR-FSHARP-DEFINITION] Definition + +- Definition, type definition and declaration on an F# symbol from Razor land on its F# declaration in the `.fs` file, ranged on its name. +- A record's or union's constructor (`new Player(…)`) lands on its type. +- Until the F# sidecar has loaded, the answer is the decompiled source, and it is not cached. + +### [RAZOR-FSHARP-REFERENCES] References + +- References from Razor on an F# symbol hold its Razor and C# uses, its F# uses and, when asked for, its F# declaration ([REFERENCES-CROSS-LANGUAGE]). +- References that start in F# hold the symbol's C# and Razor uses too. Each Razor use is mapped back to its Razor file and never reported in generated code. + +### [RAZOR-FSHARP-RENAME] Rename + +- **From F#:** renaming an F# symbol renames its Razor uses too. The Rust host asks the C# sidecar for the C# uses of the symbol ([RENAME-CROSSLANGUAGE]). Those uses include the projections, and each one is mapped back to its Razor file, never edited in generated code. +- **From Razor or C#:** `prepareRename` accepts an F# symbol on its name. The C# sidecar edits its C# and Razor uses, and the F# sidecar renames its declaration and F# uses, in one workspace edit. +- **A C# symbol from Razor:** the identity a rename sends to the F# sidecar resolves through the projection, so the symbol's F# uses are renamed too. + +### [RAZOR-FSHARP-LANGUAGE] No F# Razor -- **Hover, completion and signature help:** these work on F# types and members used in Razor through that metadata. -- **Definition:** definition on an F# symbol from Razor lands where it lands from C#: in decompiled metadata-as-source for the symbol's type ([DEFINITION-CROSSLANG]). -- **Rename from F#:** renaming an F# symbol renames its Razor usages too. The Rust host asks the C# sidecar for the C# usages of the symbol ([RENAME-CROSSLANGUAGE]). Those usages include the projections, and each one is mapped back to its Razor file, never edited in generated code. -- **References from F#:** references that start in F# reach C# and Razor usages only where they reach C# usages today ([DEFINITION-CROSSLANG]). -- **No F# Razor:** F# is not a Razor code language. A Razor `@code` block is C#. +F# is not a Razor code language, because the Razor compiler generates only C#. A Razor `@code` block is C#. ## [RAZOR-EDITORS] Editors diff --git a/docs/specs/REFERENCES-SPEC.md b/docs/specs/REFERENCES-SPEC.md index 68a0c758..8188dc22 100644 --- a/docs/specs/REFERENCES-SPEC.md +++ b/docs/specs/REFERENCES-SPEC.md @@ -131,7 +131,7 @@ F# references MUST search every compile item in the loaded project; a file-local 3. From the `FSharpSymbolUse.Symbol`, call [`GetUsesOfSymbolInFile()`](https://fsharp.github.io/fsharp-compiler-docs/) for document-scoped results. 4. For project-wide results, iterate all project files and call `GetAllUsesOfAllSymbolsInFile()` on each, filtering to the target symbol by `FSharpSymbol` equality. 5. If `context.includeDeclaration` is true, include the symbol's declaration range. -6. Map each `FSharpSymbolUse.Range` to LSP `Location`. +6. Map each use to an LSP `Location` on the symbol's name. FCS ranges a dotted use (`player.Score`) over the whole path, so the name is the identifier token at the range's end that spells the symbol's name; a use no token spells keeps FCS's range. Document highlights use the same name ranges. ### textDocument/documentHighlight `[REFERENCES-FSHARP-HIGHLIGHT]` @@ -158,10 +158,13 @@ When a C# project references an F# project (or vice versa), find-all-references | Scenario | Approach | |---|---| -| 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 | +| C# symbol used in F# code | C# sidecar finds references in C# projects and Razor files → the Rust host asks it for the symbol's identity, then asks the F# sidecar for the F# uses of that identity | +| F# symbol used in C# or Razor code | F# sidecar finds references in F# projects → the Rust host asks it for the symbol's identity, then asks the C# sidecar for the C# and Razor uses of that identity | +| F# symbol, asked from a C# or Razor use | C# sidecar finds the C# and Razor uses of the metadata symbol → the Rust host asks the F# sidecar for its F# declaration and uses by the same identity | -The Rust host merges results from both sidecars and deduplicates by location. +- **Identity:** the symbol's assembly and XML doc signature, from `textDocument/renameIdentity` ([DEFINITION-CROSSLANG-IDENTITY]). A Razor position resolves through its projection. +- **Request:** `workspace/referencesForeign` (`{ assemblyName, xmlDocSig, includeDeclaration }`) answers a location list. The C# sidecar searches every C# project and maps Razor uses back to their Razor files, dropping hidden scaffolding. The F# sidecar searches only the project that builds the assembly and its in-memory readers, or, for an assembly no F# project builds, the F# projects that reference it. It never walks every file of the workspace. +- **Merge:** the Rust host appends the other side's locations to the primary's, each place once. A missing, loading or failing other sidecar adds nothing, so the primary answer always stands. ## Extended Results `[REFERENCES-EXTENSIONS]` diff --git a/docs/specs/RENAME-SPEC.md b/docs/specs/RENAME-SPEC.md index a678b7c9..c260eba2 100644 --- a/docs/specs/RENAME-SPEC.md +++ b/docs/specs/RENAME-SPEC.md @@ -119,6 +119,8 @@ Public symbols used across C# and F# project boundaries MUST be renamed across b - C# symbol renamed with F# references: C# sidecar computes C# edits; Rust host asks F# sidecar for F# usages and merges edits. - F# symbol renamed with C# references: F# sidecar computes F# edits; Rust host asks C# sidecar for C# usages and merges edits. +- F# symbol renamed from a C# or Razor use: C# reads it from the F# project's DLL. `prepareRename` accepts it on its name, and the C# sidecar computes the C# and Razor edits by the symbol's identity ([DEFINITION-CROSSLANG-IDENTITY]); the Rust host asks the F# sidecar to rename the declaration and its F# uses, and merges the edits. +- A rename asked from a Razor file resolves the symbol's identity through the file's projection, so its other-language uses are renamed too ([RAZOR-FSHARP-RENAME]). - Conflicting or overlapping edits MUST fail the rename with a clear error instead of returning a partial edit. ## [RENAME-ERRORS] Error Handling diff --git a/src/editors/vscode/package.json b/src/editors/vscode/package.json index 7b08a348..e45472dd 100644 --- a/src/editors/vscode/package.json +++ b/src/editors/vscode/package.json @@ -42,7 +42,9 @@ "workspaceContains:**/*.fsproj", "onLanguage:razor", "onDebugResolve:sharplsp-coreclr", - "onDebugDynamicConfigurations:sharplsp-coreclr" + "onDebugDynamicConfigurations:sharplsp-coreclr", + "onDebugResolve:sharplsp-blazorwasm", + "onDebugDynamicConfigurations:sharplsp-blazorwasm" ], "main": "./dist/extension.js", "capabilities": { @@ -365,6 +367,12 @@ } } }, + "configurationDefaults": { + "explorer.fileNesting.patterns": { + "*.razor": "${capture}.razor.cs, ${capture}.razor.css, ${capture}.razor.js", + "*.cshtml": "${capture}.cshtml.cs, ${capture}.cshtml.css, ${capture}.cshtml.js" + } + }, "debuggers": [ { "type": "sharplsp-coreclr", @@ -573,6 +581,67 @@ "stopAtEntry": false } ] + }, + { + "type": "sharplsp-blazorwasm", + "label": "Blazor WebAssembly", + "configurationAttributes": { + "launch": { + "required": [], + "properties": { + "project": { + "type": "string", + "description": "The Blazor WebAssembly .csproj/.fsproj to run. Omit to use the project the active document belongs to." + }, + "launchProfile": { + "type": "string", + "description": "The Properties/launchSettings.json profile to run, by name. Omit to use the project's only Project profile." + }, + "browser": { + "type": "string", + "enum": [ + "chrome", + "edge" + ], + "default": "chrome", + "description": "The browser to debug, through VS Code's built-in JavaScript debugger." + }, + "url": { + "type": "string", + "description": "The page to open. Omit to use the address the DevServer announces." + }, + "webRoot": { + "type": "string", + "description": "The directory served URLs map onto. Omit to use the project's wwwroot." + }, + "timeout": { + "type": "number", + "default": 30000, + "description": "How long the browser debugger waits for the page to load." + } + } + } + }, + "configurationSnippets": [ + { + "label": "Blazor WebAssembly: Launch", + "description": "Run a Blazor WebAssembly project and debug it in the browser", + "body": { + "type": "sharplsp-blazorwasm", + "request": "launch", + "name": "Launch Blazor WebAssembly", + "browser": "chrome" + } + } + ], + "initialConfigurations": [ + { + "type": "sharplsp-blazorwasm", + "request": "launch", + "name": "Launch Blazor WebAssembly", + "browser": "chrome" + } + ] } ], "viewsContainers": { diff --git a/src/editors/vscode/src/blazor-wasm-debug.ts b/src/editors/vscode/src/blazor-wasm-debug.ts new file mode 100644 index 00000000..da38c0a7 --- /dev/null +++ b/src/editors/vscode/src/blazor-wasm-debug.ts @@ -0,0 +1,443 @@ +// Blazor WebAssembly launch resolution: everything a `sharplsp-blazorwasm` launch +// decides that never touches a process. +// +// Implements [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]. +// +// A Blazor WebAssembly app is served by `Microsoft.AspNetCore.Components.WebAssembly +// .DevServer` (or `app.UseWebAssemblyDebugging()`), which exposes the .NET debug proxy +// at `/_framework/debug/ws-proxy`. VS Code's built-in JavaScript debugger reaches .NET +// breakpoints through that proxy when it is handed an `inspectUri`, so this tier's whole +// job is to turn a launch configuration into the `pwa-chrome`/`pwa-msedge` one js-debug +// understands. Process control lives in blazor-wasm-launch.ts. +import * as fs from 'node:fs'; +import { XMLParser } from 'fast-xml-parser'; +import type * as vscode from 'vscode'; +import { + directoryOf, + extensionOf, + fileNameOf, + isAbsolutePath, + joinPath, + resolvePath, +} from './paths'; +import { resolveLaunchTarget } from './launch-resolver'; +import { projectProfiles, readProfiles, type LaunchProfile } from './launch-profiles'; +import { err, ok, type Result } from './result'; +import { isRecord } from './utils'; + +/** The name a launch that states none is listed under. */ +export const DEFAULT_LAUNCH_NAME = 'Launch Blazor WebAssembly'; + +/** The debug type this extension contributes for Blazor WebAssembly. */ +export const BLAZOR_WASM_DEBUG_TYPE = 'sharplsp-blazorwasm'; + +/** The SDK a standalone Blazor WebAssembly project names on its `Project` element. */ +export const BLAZOR_WASM_SDK = 'Microsoft.NET.Sdk.BlazorWebAssembly'; + +/** The browsers js-debug ships a debug type for. */ +export type BlazorWasmBrowser = 'chrome' | 'edge'; + +/** Chromium: the browser a `blazorwasm` template profile is written for. */ +export const DEFAULT_BLAZOR_WASM_BROWSER: BlazorWasmBrowser = 'chrome'; + +/** + * The `inspectUri` the `blazorwasm` template writes into `launchSettings.json`. + * + * A launch profile that states none still has to be debuggable, so this is the + * fallback. `{browserInspectUri}` is deliberately LEFT in place: js-debug + * substitutes it with the address of the browser tab it opened, which is + * information only it has. + */ +export const DEFAULT_INSPECT_URI = + '{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}'; + +/** How long js-debug waits for the page before it gives up. */ +export const DEFAULT_BROWSER_TIMEOUT_MS = 30_000; + +/** The two `contributes.debuggers` labels the browsers map onto. */ +const BROWSER_DEBUG_TYPES: Readonly> = { + chrome: 'pwa-chrome', + edge: 'pwa-msedge', +}; + +/** The prefix the ASP.NET Core host logs a bound address behind. */ +const LISTENING_PREFIX = 'Now listening on:'; + +/** The tokens a launch profile's `inspectUri` carries. */ +const WS_PROTOCOL_TOKEN = '{wsProtocol}'; +const HOSTNAME_TOKEN = '{url.hostname}'; +const PORT_TOKEN = '{url.port}'; + +/** Attributes are read with an `@_` prefix, the spelling dependencies.ts uses. */ +const ATTRIBUTE_PREFIX = '@_'; + +/** One parser for every project file this module reads. */ +const projectParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: ATTRIBUTE_PREFIX, +}); + +/** + * A browser name from user input, defaulting to Chromium. + * + * `browser` arrives from a hand-written `launch.json`, so it can be absent or + * misspelled; an unknown value MUST NOT refuse the launch, because the only + * thing a wrong browser name changes is which of two identical debug types is + * started. + */ +export function browserOf(value: unknown): BlazorWasmBrowser { + return typeof value === 'string' && value.trim().toLowerCase() === 'edge' ? 'edge' : 'chrome'; +} + +/** The js-debug type that drives `browser`. */ +export function browserDebugType(browser: BlazorWasmBrowser): string { + return BROWSER_DEBUG_TYPES[browser]; +} + +/** A URL from process output, or undefined when the text is not one. */ +function httpUrlOf(token: string): string | undefined { + try { + const parsed = new URL(token); + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? token : undefined; + } catch { + return undefined; + } +} + +/** The first space-delimited token of `text`. */ +function firstToken(text: string): string { + const end = text.indexOf(' '); + return end === -1 ? text : text.slice(0, end); +} + +/** + * The URL a `Now listening on: ` line announces. + * + * `dotnet run` interleaves that line with build output, restore warnings and + * the SDK's telemetry notice, so the line is found by its prefix and the URL by + * parsing the token after it — text that merely *contains* a URL is not an + * announcement, which is what keeps `Content root path:` and the dev + * certificate warning out of the answer. + */ +export function listeningUrlOf(line: string): string | undefined { + const trimmed = line.trim(); + const at = trimmed.indexOf(LISTENING_PREFIX); + if (at === -1) return undefined; + return httpUrlOf(firstToken(trimmed.slice(at + LISTENING_PREFIX.length).trim())); +} + +/** Every URL announced in a whole block of output, in order. */ +export function listeningUrlsOf(output: string): string[] { + return output.split('\n').flatMap((line) => { + const url = listeningUrlOf(line); + return url === undefined ? [] : [url]; + }); +} + +/** Output split into whole lines, plus the unfinished tail of the last one. */ +export interface LineBuffer { + /** The tail of a line whose newline has not arrived yet. */ + readonly pending: string; + /** The lines completed by the chunk. */ + readonly lines: readonly string[]; +} + +/** + * Split one output chunk into whole lines. + * + * A child's output arrives in arbitrary chunks, so `Now listening on:` lands + * split across two of them often enough to matter; keeping the tail makes the + * announcement findable wherever the pipe happened to cut it. + */ +export function takeLines(pending: string, chunk: string): LineBuffer { + const parts = `${pending}${chunk}`.split('\n'); + const tail = parts.pop() ?? ''; + return { pending: tail, lines: parts }; +} + +/** + * `pattern` with the host, port and socket protocol of `appUrl` filled in. + * + * `{browserInspectUri}` is left alone: js-debug owns it. An unparseable + * `appUrl` leaves the pattern untouched rather than producing a half-expanded + * URL the browser would refuse to load. + */ +export function expandInspectUri(pattern: string, appUrl: string): string { + const parsed = urlOf(appUrl); + if (parsed === undefined) return pattern; + return pattern + .replaceAll(WS_PROTOCOL_TOKEN, parsed.protocol === 'https:' ? 'wss' : 'ws') + .replaceAll(HOSTNAME_TOKEN, parsed.hostname) + .replaceAll(PORT_TOKEN, parsed.port); +} + +/** `value` as a URL, or undefined when it is not one. */ +function urlOf(value: string): URL | undefined { + try { + return new URL(value); + } catch { + return undefined; + } +} + +/** The `inspectUri` for a launch: the profile's own, or the template's default. */ +export function inspectUriFor(pattern: string | undefined, appUrl: string): string { + const usable = + pattern === undefined || pattern.trim().length === 0 ? DEFAULT_INSPECT_URI : pattern; + return expandInspectUri(usable, appUrl); +} + +/** Everything the browser session needs, once the app is listening. */ +export interface BrowserSessionOptions { + /** The configuration name the session is listed under. */ + readonly name: string; + /** The URL to open. */ + readonly url: string; + /** The .NET debug proxy URL, already expanded. */ + readonly inspectUri: string; + readonly browser: BlazorWasmBrowser; + /** Where js-debug maps served URLs back onto source files. */ + readonly webRoot: string; + /** js-debug's own wait for the page. */ + readonly timeout: number; +} + +/** + * The js-debug launch configuration a Blazor WebAssembly session is handed to. + * + * `inspectUri` is the whole bridge: without it js-debug attaches to the browser + * only, and every .NET breakpoint in a `.razor` file or a C# class silently + * never binds. + */ +export function browserConfiguration(options: BrowserSessionOptions): vscode.DebugConfiguration { + return { + type: browserDebugType(options.browser), + request: 'launch', + name: options.name, + url: options.url, + inspectUri: options.inspectUri, + webRoot: options.webRoot, + timeout: options.timeout, + }; +} + +/** The `wwwroot` directory a Blazor WebAssembly project serves from. */ +export function webRootOf(projectFile: string): string { + return joinPath(directoryOf(projectFile), 'wwwroot'); +} + +/** The `dotnet run` arguments for `projectFile`, without a shell in between. */ +export function runArguments( + projectFile: string, + launchProfile: string | undefined, +): readonly string[] { + const named = launchProfile === undefined ? '' : launchProfile.trim(); + const args = ['run', '--project', projectFile]; + return named.length === 0 ? args : [...args, '--launch-profile', named]; +} + +/** + * The launch profile a launch runs under. + * + * The profile is named when the configuration says so; otherwise the project's + * SOLE `Project` profile applies, exactly as [DEBUG-FEATURES-LAUNCH-PROFILES] + * makes it apply to every other launch. Several profiles and no name is not an + * ambiguity to guess at here: the dev server announces whatever URL it binds, + * so the launch still works, it just carries no `inspectUri` of its own. + */ +export function launchProfileOf( + projectFile: string, + named: string | undefined, +): LaunchProfile | undefined { + const eligible = projectProfiles(readProfiles(projectFile)); + if (named === undefined || named.trim().length === 0) { + return eligible.length === 1 ? eligible[0] : undefined; + } + return eligible.find((profile) => profile.name === named); +} + +/** SDK names an MSBuild `Project` element declares, in order. */ +function sdkNamesOf(element: unknown): string[] { + const sdk = isRecord(element) ? element[`${ATTRIBUTE_PREFIX}Sdk`] : undefined; + return typeof sdk === 'string' ? sdk.split(';') : []; +} + +/** True when `element` is a `Project` carrying the Blazor WebAssembly SDK. */ +function isWebAssemblySdk(element: unknown): boolean { + return sdkNamesOf(element).some( + (name) => name.trim().toLowerCase() === BLAZOR_WASM_SDK.toLowerCase(), + ); +} + +/** + * True when project XML declares `Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"`. + * + * Read through a real XML parser, never by matching text: a commented-out + * `` element, an `` item inside an `ItemGroup` and a + * property whose VALUE happens to name the SDK all look identical to a string + * search and are three different facts. + */ +export function isBlazorWasmProject(xml: string): boolean { + if (xml.trim().length === 0) return false; + try { + const parsed: unknown = projectParser.parse(xml); + const project = isRecord(parsed) ? parsed.Project : undefined; + const elements = Array.isArray(project) ? project : [project]; + return elements.some(isWebAssemblySdk); + } catch { + return false; + } +} + +/** True when the project file on disk is a Blazor WebAssembly project. */ +export function isBlazorWasmProjectFile(file: string): boolean { + try { + return isBlazorWasmProject(fs.readFileSync(file, 'utf-8')); + } catch { + return false; + } +} + +/** The Blazor WebAssembly projects among `files`, keeping their order. */ +export function blazorWasmProjects(files: readonly string[]): string[] { + return files.filter(isBlazorWasmProjectFile); +} + +/** The name a generated configuration carries for `projectFile`. */ +export function configurationName(projectFile: string): string { + return `Blazor WebAssembly: ${fileNameOf(projectFile, extensionOf(projectFile))}`; +} + +/** One generated configuration, carrying nothing but what a user must choose. */ +export function blazorWasmConfiguration(projectFile: string): vscode.DebugConfiguration { + return { + type: BLAZOR_WASM_DEBUG_TYPE, + request: 'launch', + name: configurationName(projectFile), + project: projectFile, + browser: DEFAULT_BLAZOR_WASM_BROWSER, + }; +} + +/** One configuration per Blazor WebAssembly project, without prompting. */ +export function blazorWasmConfigurations(files: readonly string[]): vscode.DebugConfiguration[] { + return blazorWasmProjects(files).map(blazorWasmConfiguration); +} + +/** + * How long the app gets to announce a listening URL. + * + * A cold `dotnet run` restores and builds before it binds, which on a first run + * with an empty package cache takes far longer than the bind itself; the wait is + * generous for that reason and still bounded, so a project that never serves + * reports itself instead of leaving a session open on nothing. + */ +export const STARTUP_TIMEOUT_MS = 180_000; + +/** How a launch's startup ended. */ +export type Startup = + | { readonly kind: 'listening'; readonly url: string } + | { readonly kind: 'exited'; readonly outcome: Result } + | { readonly kind: 'timeout' }; + +/** Everything one launch needs, once the project is known. */ +export interface BlazorWasmRequest { + readonly projectFile: string; + readonly launchProfile: LaunchProfile | undefined; + readonly browser: BlazorWasmBrowser; + readonly url: string | undefined; + readonly webRoot: string; + readonly name: string; + readonly timeout: number; +} + +/** Why a launch has no project to run, in the words of a user. */ +const NO_PROJECT_MESSAGE = + 'Blazor WebAssembly launch found no runnable project for this workspace.'; +const NOT_A_PROJECT_MESSAGE = + 'Blazor WebAssembly launches need a .csproj/.fsproj project, not a script or a single file.'; + +/** A `string` field of the configuration, or undefined when absent or empty. */ +export function stringField(config: vscode.DebugConfiguration, key: string): string | undefined { + const value: unknown = config[key]; + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} + +/** A positive `number` field of the configuration, or undefined. */ +export function numberField(config: vscode.DebugConfiguration, key: string): number | undefined { + const value: unknown = config[key]; + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined; +} + +/** + * The project a configuration names, or the one the workspace resolves to. + * + * A stated `project` is the user's decision and is used as given; only a + * MISSING one consults the resolver every other launch surface shares + * ([DEBUG-FEATURES-LAUNCH-TARGET]). + */ +export async function resolveProject( + folder: vscode.WorkspaceFolder | undefined, + stated: unknown, +): Promise> { + const root = folder?.uri.fsPath; + const named = typeof stated === 'string' ? stated.trim() : ''; + if (named.length > 0) return projectAt(root, named); + if (folder === undefined) return err(NO_PROJECT_MESSAGE); + const resolved = await resolveLaunchTarget(undefined, folder); + if (!resolved.ok) + return err(resolved.error.trim().length === 0 ? NO_PROJECT_MESSAGE : resolved.error); + if (resolved.value.kind !== 'project') return err(NOT_A_PROJECT_MESSAGE); + return ok(resolved.value.projectFile); +} + +/** `named` as an existing project path, resolved against `root` when relative. */ +export function projectAt(root: string | undefined, named: string): Result { + if (root === undefined && !isAbsolutePath(named)) { + return err(`Cannot resolve '${named}' without a workspace folder.`); + } + const file = isAbsolutePath(named) || root === undefined ? named : resolvePath(root, named); + return fs.existsSync(file) ? ok(file) : err(`Blazor WebAssembly project not found: ${file}`); +} + +/** The request a configuration describes, or why it cannot be launched. */ +export async function requestOf( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, +): Promise> { + const project = await resolveProject(folder, config.project); + if (!project.ok) return project; + const named = stringField(config, 'launchProfile'); + const profile = launchProfileOf(project.value, named); + if (named !== undefined && profile === undefined) { + return err( + `Launch profile '${named}' is not declared by ${fileNameOf(project.value)}. ` + + 'Add it to Properties/launchSettings.json, or omit launchProfile.', + ); + } + return ok({ + projectFile: project.value, + launchProfile: profile, + browser: browserOf(config.browser), + url: stringField(config, 'url'), + webRoot: stringField(config, 'webRoot') ?? webRootOf(project.value), + name: stringField(config, 'name') ?? DEFAULT_LAUNCH_NAME, + timeout: numberField(config, 'timeout') ?? DEFAULT_BROWSER_TIMEOUT_MS, + }); +} + +/** The sentence a startup that never announced an address earns. */ +export function startupFailure(outcome: Startup, projectFile: string): string { + const named = fileNameOf(projectFile); + if (outcome.kind === 'timeout') { + return ( + `dotnet run for ${named} announced no listening URL within ` + + `${String(STARTUP_TIMEOUT_MS / 1000)}s. Check that the project is a Blazor ` + + 'WebAssembly app and that Properties/launchSettings.json binds a URL.' + ); + } + if (outcome.kind === 'exited' && !outcome.outcome.ok) { + return `dotnet run for ${named} stopped before it served anything: ${outcome.outcome.error}`; + } + return `dotnet run for ${named} exited before it announced a listening URL.`; +} diff --git a/src/editors/vscode/src/blazor-wasm-launch.ts b/src/editors/vscode/src/blazor-wasm-launch.ts new file mode 100644 index 00000000..65b5f1cb --- /dev/null +++ b/src/editors/vscode/src/blazor-wasm-launch.ts @@ -0,0 +1,468 @@ +// Launching a Blazor WebAssembly app and handing it to js-debug. +// +// Implements [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]. +// +// Three moving parts, in this order: `dotnet run` (which starts the DevServer that +// serves both the app and the .NET debug proxy), the announcement of the address it +// bound, and the browser session that receives the expanded `inspectUri`. Everything +// the launch decides WITHOUT a process lives in blazor-wasm-debug.ts; everything here +// is process control and the handoff. +// +// The process is started through dotnet-process.ts rather than a bare `spawn`, so the +// CLI's UI language is pinned (the announcement is English text) and the stop reaps the +// whole tree: `dotnet run` launches the dev server as a CHILD, so signalling only the +// parent leaves the port bound by a process nothing owns. +import * as vscode from 'vscode'; +import { + BLAZOR_WASM_DEBUG_TYPE, + DEFAULT_LAUNCH_NAME, + STARTUP_TIMEOUT_MS, + blazorWasmConfigurations, + browserConfiguration, + browserDebugType, + browserOf, + inspectUriFor, + listeningUrlOf, + requestOf, + runArguments, + startupFailure, + stringField, + takeLines, + type BlazorWasmRequest, + type Startup, +} from './blazor-wasm-debug'; +import { runDotnet, type DotnetRun } from './dotnet-process'; +import { info, traceInfo, warn } from './log'; +import { directoryOf, fileNameOf } from './paths'; +import { err, ok, type Result } from './result'; + +/** + * A backstop on the whole session, not a policy. + * + * dotnet-process.ts kills an invocation that outlives its own ceiling; a dev + * server legitimately lives as long as the browser session does, so the ceiling + * is set far past any debugging session rather than at the run timeout. + */ +const SESSION_CEILING_MS = 24 * 60 * 60 * 1_000; + +/** A `dotnet run` process a launch owns. */ +export interface BlazorWasmProcess { + /** Resolves once the process, and everything it started, is gone. */ + readonly ended: Promise>; + /** Terminates the process and everything it started. */ + stop(): void; +} + +/** Starts `dotnet run`; `onOutput` sees every chunk as it arrives. */ +export type BlazorWasmProcessStarter = ( + args: readonly string[], + cwd: string, + onOutput: (chunk: string) => void, +) => BlazorWasmProcess; + +/** A started browser debug session, as far as the launch cares. */ +export interface BrowserSession { + /** Resolves when the user's browser session ends, however it ends. */ + readonly ended: Promise; +} + +/** Hands a configuration to js-debug, or reports that it refused. */ +export type BrowserSessionStarter = ( + folder: vscode.WorkspaceFolder | undefined, + configuration: vscode.DebugConfiguration, +) => Promise; + +/** + * What a launch needs from outside itself. + * + * A seam, not a testing afterthought: a browser debug session cannot be started + * in a headless test host, and a real dev server takes minutes to build, so the + * suite that proves the handoff replaces exactly these three and nothing else. + */ +export interface BlazorWasmHost { + readonly startProcess: BlazorWasmProcessStarter; + readonly startBrowserSession: BrowserSessionStarter; + /** Every project file under a workspace folder. */ + readonly listProjects: (root: string) => Promise; +} + +/** A launch that is currently running, and how to end it. */ +export interface RunningLaunch { + readonly projectFile: string; + /** The address the DevServer announced. */ + readonly url: string; + /** The configuration js-debug was handed. */ + readonly configuration: vscode.DebugConfiguration; + /** Resolves once the `dotnet run` process is gone. */ + readonly ended: Promise; + /** Ends the launch; the browser session ends with the app. */ + stop(): void; +} + +/** The launches in flight, and the process each one owns. */ +interface LaunchRegistry { + readonly running: Map; + /** The process each launch owns, so only its OWN exit un-registers it. */ + readonly processes: Map; +} + +declare global { + /** The live launches; global for the reason {@link useBlazorWasmHost} is. */ + var __sharplspBlazorWasmLaunches: LaunchRegistry | undefined; +} + +/** The one registry, wherever this module was loaded from. */ +function registry(): LaunchRegistry { + globalThis.__sharplspBlazorWasmLaunches ??= { running: new Map(), processes: new Map() }; + return globalThis.__sharplspBlazorWasmLaunches; +} + +/** The launch in flight for `projectFile`, if any. */ +export function runningBlazorWasmLaunch(projectFile: string): RunningLaunch | undefined { + return registry().running.get(projectFile); +} + +/** + * `dotnet run`, supervised by the one module that knows how to stop a tree. + * + * `runDotnet` never rejects: a spawn that failed, a non-zero exit and a kill all + * arrive as a {@link DotnetRun} the caller can report. + */ +const dotnetRunProcess: BlazorWasmProcessStarter = (args, cwd, onOutput) => { + const controller = new AbortController(); + const ended = runDotnet(args, cwd, SESSION_CEILING_MS, controller.signal, { onOutput }).then( + exitOf, + ); + return { + ended, + stop: () => { + controller.abort(); + }, + }; +}; + +/** What a finished invocation means to a launch: OK, or the reason it is not. */ +function exitOf(run: DotnetRun): Result { + if (!run.failed && !run.killed) return ok(undefined); + const reason = run.errorMessage?.trim(); + return err(reason !== undefined && reason.length > 0 ? reason : 'dotnet run stopped'); +} + +/** + * The shipped browser handoff: `vscode.debug.startDebugging` onto js-debug. + * + * The session is matched by NAME because `startDebugging` answers whether the + * session started, not which one it was; the name is the one this launch wrote + * into the configuration, so the pair is unambiguous. + */ +async function startBrowserSession( + folder: vscode.WorkspaceFolder | undefined, + configuration: vscode.DebugConfiguration, +): Promise { + const started = await vscode.debug.startDebugging(folder, configuration); + if (!started) return undefined; + return { ended: sessionLifetime(configuration.name) }; +} + +/** Resolves when the session named `name` terminates. */ +async function sessionLifetime(name: string): Promise { + await new Promise((resolve) => { + const starting = vscode.debug.onDidStartDebugSession((session) => { + if (session.name !== name) return; + starting.dispose(); + const ending = vscode.debug.onDidTerminateDebugSession((gone) => { + if (gone.id !== session.id) return; + ending.dispose(); + resolve(); + }); + }); + }); +} + +/** Every project file beneath a folder, minus build output and dependencies. */ +async function listProjectFiles(root: string): Promise { + const found = await vscode.workspace.findFiles( + new vscode.RelativePattern(root, '**/*.{csproj,fsproj}'), + '**/{node_modules,bin,obj,target}/**', + ); + return found.map((uri) => uri.fsPath); +} + +/** The shipped launch environment. */ +const shippedHost: BlazorWasmHost = { + startProcess: dotnetRunProcess, + startBrowserSession, + listProjects: listProjectFiles, +}; + +declare global { + /** Overrides on the launch environment; see {@link useBlazorWasmHost}. */ + var __sharplspBlazorWasmHost: Partial | undefined; +} + +/** + * Overrides live on `globalThis`, not in a module variable. + * + * The suite runs from `out/` while the extension runs from the bundled + * `dist/extension.js`, so those are two module instances that share no module + * state; a module-scoped seam would be replaced in a copy nothing calls, and + * the test would pass or fail for reasons unrelated to the launch. hot-reload.ts + * keeps its flag in the same place for the same reason. + */ +function host(): BlazorWasmHost { + return { ...shippedHost, ...(globalThis.__sharplspBlazorWasmHost ?? {}) }; +} + +/** + * Replace part of the launch environment, returning the undo. + * + * Callers MUST restore: a suite that leaves an override in place changes every + * launch the extension makes afterwards, in this window and any later one. + */ +export function useBlazorWasmHost(overrides: Partial): () => void { + const previous = globalThis.__sharplspBlazorWasmHost; + globalThis.__sharplspBlazorWasmHost = { ...previous, ...overrides }; + return () => { + globalThis.__sharplspBlazorWasmHost = previous; + }; +} + +/** A promise, and the one call that settles it. */ +interface Deferred { + readonly promise: Promise; + settle(value: T): void; +} + +/** A {@link Deferred}, resolved exactly once by whoever gets there first. */ +function deferred(): Deferred { + let settle: (value: T) => void = () => undefined; + const promise = new Promise((resolve) => { + settle = resolve; + }); + return { + promise, + settle: (value) => { + settle(value); + }, + }; +} + +/** The app's address, or the reason there is none. */ +async function waitForAddress(startup: Deferred): Promise { + const timer = setTimeout(() => { + startup.settle({ kind: 'timeout' }); + }, STARTUP_TIMEOUT_MS); + try { + return await startup.promise; + } finally { + clearTimeout(timer); + } +} + +/** Streams `dotnet run` output to the log and reports the first bound address. */ +interface OutputScanner { + readonly push: (chunk: string) => void; +} + +/** A scanner that logs whole lines and reports the first listening URL. */ +function outputScanner(onUrl: (url: string) => void): OutputScanner { + let pending = ''; + const feed = (line: string): void => { + const trimmed = line.trimEnd(); + if (trimmed.length > 0) traceInfo(`dotnet run: ${trimmed}`); + const url = listeningUrlOf(trimmed); + if (url !== undefined) onUrl(url); + }; + return { + push: (chunk) => { + const buffer = takeLines(pending, chunk); + pending = buffer.pending; + buffer.lines.forEach(feed); + }, + }; +} + +/** + * One `dotnet run` process, and what became of it. + * + * `exit` is recorded and queryable because a dev server can die between the + * moment it announces its address and the moment the browser session opens; + * registering a launch whose process is already gone would leave the next + * launch believing a DevServer it does not own is still serving the port. + */ +interface ProcessRun { + readonly process: BlazorWasmProcess; + /** Resolves once the process is gone and this launch is un-registered. */ + readonly finished: Promise; + /** How the process ended, or undefined while it is still alive. */ + readonly exit: () => Result | undefined; +} + +/** + * Forget a launch ONLY when the process that ended is the one it owns. + * + * A stopped app takes a moment to exit, and a newer launch of the same project + * can register in that window; deleting by key alone un-registered the LIVE + * launch and left a second DevServer nobody would ever stop. + */ +function forget(projectFile: string, process: BlazorWasmProcess): void { + const live = registry(); + if (live.processes.get(projectFile) !== process) return; + live.processes.delete(projectFile); + live.running.delete(projectFile); +} + +/** Start the app, wait for its address, and hand it to the browser. */ +async function startSession( + folder: vscode.WorkspaceFolder | undefined, + request: BlazorWasmRequest, +): Promise> { + const startup = deferred(); + const scanner = outputScanner((url) => { + startup.settle({ kind: 'listening', url }); + }); + info(`Starting Blazor WebAssembly launch for ${fileNameOf(request.projectFile)}`); + const process = host().startProcess( + runArguments(request.projectFile, request.launchProfile?.name), + directoryOf(request.projectFile), + scanner.push, + ); + let exit: Result | undefined; + const finished = process.ended.then((outcome) => { + exit = outcome; + forget(request.projectFile, process); + startup.settle({ kind: 'exited', outcome }); + }); + const outcome = await waitForAddress(startup); + if (outcome.kind !== 'listening') { + process.stop(); + return err(startupFailure(outcome, request.projectFile)); + } + const run: ProcessRun = { process, finished, exit: () => exit }; + return await handOff(folder, request, { ...run, url: request.url ?? outcome.url }); +} + +/** A process that has announced its address, ready for a browser session. */ +type ServedApp = ProcessRun & { readonly url: string }; + +/** Hand the served app to js-debug, and tie the app's life to that session's. */ +async function handOff( + folder: vscode.WorkspaceFolder | undefined, + request: BlazorWasmRequest, + app: ServedApp, +): Promise> { + const configuration = browserConfiguration({ + name: request.name, + url: app.url, + inspectUri: inspectUriFor(request.launchProfile?.inspectUri, app.url), + browser: request.browser, + webRoot: request.webRoot, + timeout: request.timeout, + }); + info( + `Blazor WebAssembly app for ${fileNameOf(request.projectFile)} is listening on ${app.url}; ` + + `handing it to ${browserDebugType(request.browser)}`, + ); + const session = await host().startBrowserSession(folder, configuration); + const died = app.exit(); + if (session === undefined) { + app.process.stop(); + return err(`Could not start ${browserDebugType(request.browser)} for ${app.url}.`); + } + if (died !== undefined) { + app.process.stop(); + return err(startupFailure({ kind: 'exited', outcome: died }, request.projectFile)); + } + const viaBrowser = session.ended.then(async () => { + info( + `Blazor WebAssembly browser session for ${fileNameOf(request.projectFile)} ended; ` + + 'stopping dotnet run', + ); + app.process.stop(); + await app.finished; + }); + // The launch's whole lifetime: the browser session ends (which stops the app), + // or the app dies on its own. Both paths have reaped the process by the time it + // settles, so a caller that awaits `ended` may rely on the port being free. + const ended = Promise.race([viaBrowser, app.finished]); + const launch: RunningLaunch = { + projectFile: request.projectFile, + url: app.url, + configuration, + ended, + stop: () => { + app.process.stop(); + }, + }; + const live = registry(); + live.processes.set(request.projectFile, app.process); + live.running.set(request.projectFile, launch); + return ok(launch); +} + +/** + * Launch the Blazor WebAssembly app a configuration describes. + * + * The returned launch owns the `dotnet run` process: when the browser session + * that received its `inspectUri` ends, that process — and only the tree it + * started — is terminated. + */ +export async function launchBlazorWasm( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, +): Promise> { + const request = await requestOf(folder, config); + if (!request.ok) return request; + const previous = registry().running.get(request.value.projectFile); + if (previous !== undefined) { + warn( + `Stopping the Blazor WebAssembly launch already running for ${fileNameOf(request.value.projectFile)}`, + ); + previous.stop(); + } + return await startSession(folder, request.value); +} + +/** + * Fills in, and later launches, a `sharplsp-blazorwasm` session. + * + * Implements [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM] and + * [DEBUG-FEATURES-LAUNCH-DYNAMIC]. + */ +export class BlazorWasmDebugProvider implements vscode.DebugConfigurationProvider { + /** Nothing to resolve before `${...}` are expanded: this tier is not a debugger. */ + public resolveDebugConfiguration( + _folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, + ): vscode.DebugConfiguration { + config.type = BLAZOR_WASM_DEBUG_TYPE; + config.request = config.request === 'attach' ? 'launch' : config.request; + config.browser = browserOf(config.browser); + config.name = stringField(config, 'name') ?? DEFAULT_LAUNCH_NAME; + return config; + } + + /** + * Start the app and hand it to js-debug. + * + * `undefined` AFTER the browser session has started is the handoff, not a + * refusal: VS Code must start no session for `sharplsp-blazorwasm` itself, + * because the session the user debugs is the `pwa-chrome`/`pwa-msedge` one. + */ + public async resolveDebugConfigurationWithSubstitutedVariables( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, + ): Promise { + const outcome = await launchBlazorWasm(folder, config); + if (!outcome.ok) void vscode.window.showErrorMessage(outcome.error); + return undefined; + } + + /** One configuration per Blazor WebAssembly project, generated without a prompt. */ + public async provideDebugConfigurations( + folder: vscode.WorkspaceFolder | undefined, + ): Promise { + if (folder === undefined) return []; + return blazorWasmConfigurations(await host().listProjects(folder.uri.fsPath)); + } +} diff --git a/src/editors/vscode/src/dap-hot-reload.ts b/src/editors/vscode/src/dap-hot-reload.ts index 9f2a0416..5805eced 100644 --- a/src/editors/vscode/src/dap-hot-reload.ts +++ b/src/editors/vscode/src/dap-hot-reload.ts @@ -11,8 +11,12 @@ import * as vscode from 'vscode'; import type { DapMessage } from './dap-emulate'; import { error, traceInfo } from './log'; import * as state from './state'; +import { RAZOR_LANGUAGES } from './razor-html'; import { getErrorMessage, isRecord, removeDirRecursive } from './utils'; +/** The languages whose saves this pipeline owns: C# and Razor ([RAZOR-HOT-RELOAD]). */ +const OWNED_LANGUAGES: readonly string[] = ['csharp', ...RAZOR_LANGUAGES]; + interface HotReloadHost { request(command: string, args: Record): Promise; /** Per-type debugger metadata caches go stale once new IL is committed. */ @@ -353,7 +357,11 @@ export class DapHotReload implements vscode.Disposable { } private owns(document: vscode.TextDocument): boolean { - if (!this.enabled || document.languageId !== 'csharp' || this.projectPath === undefined) { + if ( + !this.enabled || + !OWNED_LANGUAGES.includes(document.languageId) || + this.projectPath === undefined + ) { return false; } return isLexicallyWithin(directoryOf(this.projectPath), document.uri.fsPath); diff --git a/src/editors/vscode/src/debug.ts b/src/editors/vscode/src/debug.ts index 6e598177..95ab36b1 100644 --- a/src/editors/vscode/src/debug.ts +++ b/src/editors/vscode/src/debug.ts @@ -20,6 +20,8 @@ import { findNetcoredbg, getNetcoredbgCandidates } from './netcoredbg'; import { info, warn } from './log'; import { DapRouter } from './dap-router'; import { resolveAttachTarget } from './attach-target'; +import { BlazorWasmDebugProvider } from './blazor-wasm-launch'; +import { BLAZOR_WASM_DEBUG_TYPE } from './blazor-wasm-debug'; import { NO_TARGET_MESSAGE, folderFor, @@ -509,6 +511,7 @@ export function registerDebugAdapter( ): void { activeFrameworkReader = readActiveFramework; const provider = new SharpLspLaunchProvider(); + const blazorWasm = new BlazorWasmDebugProvider(); context.subscriptions.push( // Both trigger kinds: `Initial` fills a generated launch.json, `Dynamic` // puts SharpLsp in the "Show all automatic debug configurations" dropdown. @@ -526,6 +529,20 @@ export function registerDebugAdapter( DEBUG_TYPE, new SharpLspDebugAdapterFactory(context.extensionPath), ), + // The Blazor WebAssembly debugger has no adapter of its own: it starts `dotnet run` + // and hands the app to js-debug ([DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]), so only its + // configuration providers are registered — both trigger kinds, as + // [DEBUG-FEATURES-LAUNCH-DYNAMIC] requires of every SharpLsp debugger. + vscode.debug.registerDebugConfigurationProvider( + BLAZOR_WASM_DEBUG_TYPE, + blazorWasm, + vscode.DebugConfigurationProviderTriggerKind.Initial, + ), + vscode.debug.registerDebugConfigurationProvider( + BLAZOR_WASM_DEBUG_TYPE, + blazorWasm, + vscode.DebugConfigurationProviderTriggerKind.Dynamic, + ), vscode.commands.registerCommand(CMD_DEBUG_PROGRAM, async (argument?: LaunchArgument) => { await launch(argument, false); }), diff --git a/src/editors/vscode/src/hot-reload.ts b/src/editors/vscode/src/hot-reload.ts index 050a1ba2..3545658b 100644 --- a/src/editors/vscode/src/hot-reload.ts +++ b/src/editors/vscode/src/hot-reload.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { hotReloadOnSave } from './config.js'; import { info } from './log.js'; +import { RAZOR_LANGUAGES } from './razor-html.js'; declare global { var __sharplspHotReloadRunning: boolean | undefined; @@ -106,9 +107,9 @@ function handleDocumentSave(doc: vscode.TextDocument): void { info(`Hot Reload triggered on save: ${doc.uri.fsPath}`); } -/** Check if the language is C# or F#. */ +/** Check if the language is C#, F#, or Razor ([RAZOR-HOT-RELOAD]). */ export function isRelevantLanguage(languageId: string): boolean { - return languageId === 'csharp' || languageId === 'fsharp'; + return languageId === 'csharp' || languageId === 'fsharp' || RAZOR_LANGUAGES.includes(languageId); } /** Stop hot reload when a debug session ends. */ diff --git a/src/editors/vscode/src/launch-profiles.ts b/src/editors/vscode/src/launch-profiles.ts index 9bd20fc4..9344de51 100644 --- a/src/editors/vscode/src/launch-profiles.ts +++ b/src/editors/vscode/src/launch-profiles.ts @@ -19,6 +19,17 @@ export interface LaunchProfile { readonly commandLineArgs?: string; readonly environmentVariables?: Readonly>; readonly applicationUrl?: string; + /** + * The .NET debug proxy URL a Blazor WebAssembly profile carries. + * + * `Microsoft.AspNetCore.Components.WebAssembly.DevServer` writes the template's + * `{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser= + * {browserInspectUri}` here, and that value is the whole bridge to .NET + * breakpoints ([DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]). It is parsed with the rest + * of the profile rather than re-read at the launch site, so one reader owns the + * profile document. + */ + readonly inspectUri?: string; } /** ASP.NET reads its listening URLs from this variable. */ @@ -65,6 +76,7 @@ function toProfile(name: string, raw: unknown): LaunchProfile | undefined { commandName, ...optional('commandLineArgs', stringOf(raw, 'commandLineArgs')), ...optional('applicationUrl', stringOf(raw, 'applicationUrl')), + ...optional('inspectUri', stringOf(raw, 'inspectUri')), ...optional('environmentVariables', envOf(raw, 'environmentVariables')), }; return profile; diff --git a/src/editors/vscode/src/razor-html.ts b/src/editors/vscode/src/razor-html.ts index 8051010b..5cfc4c07 100644 --- a/src/editors/vscode/src/razor-html.ts +++ b/src/editors/vscode/src/razor-html.ts @@ -153,9 +153,19 @@ async function htmlHover(uri: Uri, position: Position): Promise !offered.has(labelOf(item))); + return new CompletionList([...items, ...added], incomplete); +} + +/** A completion item's label text. */ +function labelOf(item: CompletionItem): string { + return typeof item.label === 'string' ? item.label : item.label.label; } diff --git a/src/editors/vscode/src/test/suite/blazor-wasm-debug-resolution.test.ts b/src/editors/vscode/src/test/suite/blazor-wasm-debug-resolution.test.ts new file mode 100644 index 00000000..98bdd1a1 --- /dev/null +++ b/src/editors/vscode/src/test/suite/blazor-wasm-debug-resolution.test.ts @@ -0,0 +1,479 @@ +// Blazor WebAssembly launch RESOLUTION — every part of a `sharplsp-blazorwasm` +// launch that decides an answer without starting a real process. +// Spec: [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]. +// +// Nothing here mocks the extension's own surface: the pure functions, the real +// `launchSettings.json` of the repository fixture and the shipped provider are +// driven directly. Only the two things a headless host cannot own — a `dotnet +// run` child and a browser debug session — are replaced, through the shipped +// `useBlazorWasmHost` seam. +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type * as vscode from 'vscode'; +import { + BLAZOR_WASM_DEBUG_TYPE, + DEFAULT_INSPECT_URI, + blazorWasmConfigurations, + browserConfiguration, + browserDebugType, + browserOf, + expandInspectUri, + inspectUriFor, + isBlazorWasmProject, + isBlazorWasmProjectFile, + launchProfileOf, + listeningUrlOf, + listeningUrlsOf, + projectAt, + resolveProject, + runArguments, + startupFailure, + takeLines, +} from '../../blazor-wasm-debug'; +import { + BlazorWasmDebugProvider, + runningBlazorWasmLaunch, + launchBlazorWasm, + useBlazorWasmHost, + type BlazorWasmProcess, +} from '../../blazor-wasm-launch'; +import { err, ok, type Result } from '../../result'; +import { fakeFolder } from './run-debug-kit'; + +/** The Blazor WebAssembly fixture in the repository. */ +const FIXTURE = path.resolve(__dirname, '../../../../../sharplsp/tests/fixtures/blazor-wasm'); +/** The one project it declares, and the one the launch is pointed at. */ +const PROJECT = path.join(FIXTURE, 'BlazorWasmApp.csproj'); +/** A real project of the same repository that is NOT a Blazor WebAssembly app. */ +const OTHER_PROJECT = path.resolve( + __dirname, + '../../../../../sharplsp/tests/fixtures/razor/BlazorApp/BlazorApp.csproj', +); +/** The template's own `inspectUri`, as written into launchSettings.json. */ +const TEMPLATE_INSPECT_URI = + '{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}'; +/** What the DevServer prints on a loopback HTTP bind, in its real shape. */ +const HTTP_ADDRESS = 'http://127.0.0.1:58480'; +/** A profile that binds HTTPS: the socket must be `wss`, not `ws`. */ +const HTTPS_ADDRESS = 'https://localhost:7297'; +/** The expanded forms the two addresses must produce. */ +const HTTP_INSPECT = `ws://127.0.0.1:58480/_framework/debug/ws-proxy?browser={browserInspectUri}`; +const HTTPS_INSPECT = `wss://localhost:7297/_framework/debug/ws-proxy?browser={browserInspectUri}`; + +/** + * `dotnet run` output, captured from a real run of the repository fixture: + * build chatter first, then the host's announcement, then lifecycle noise. + */ +const REAL_OUTPUT = [ + 'Using launch settings from Properties/launchSettings.json...', + 'info: Microsoft.Hosting.Lifetime[14]', + ` Now listening on: ${HTTPS_ADDRESS}`, + 'info: Microsoft.Hosting.Lifetime[14]', + ` Now listening on: ${HTTP_ADDRESS}`, + 'info: Microsoft.Hosting.Lifetime[0]', + ' Application started. Press Ctrl+C to shut down.', + ` Content root path: ${FIXTURE}`, +].join('\n'); + +/** A `dotnet run` child the test drives by hand. */ +interface FakeRun { + readonly process: BlazorWasmProcess; + /** The argv the launch built, in full. */ + readonly args: readonly string[]; + /** The directory the launch ran it in. */ + readonly cwd: string; + /** How many times the launch asked for it to stop. */ + readonly stops: () => number; + /** Emit child output exactly as a pipe would. */ + readonly announce: (lines: readonly string[]) => void; + /** End the child on its own terms, before it announces anything. */ + readonly crash: (reason: string) => void; +} + +/** A fake `dotnet run` that records everything the launch does to it. */ +function fakeRun(args: readonly string[], cwd: string, onOutput: (chunk: string) => void): FakeRun { + let settle: (value: Result) => void = () => undefined; + let stops = 0; + const ended = new Promise>((resolve) => { + settle = resolve; + }); + return { + process: { + ended, + stop: () => { + stops += 1; + settle(ok(undefined)); + }, + }, + args, + cwd, + stops: () => stops, + announce: (lines) => { + onOutput(`${lines.join('\n')}\n`); + }, + crash: (reason) => { + settle(err(reason)); + }, + }; +} + +/** A launch configuration pointing at the fixture project. */ +function launchConfig(overrides: Record = {}): vscode.DebugConfiguration { + return { + type: BLAZOR_WASM_DEBUG_TYPE, + request: 'launch', + name: 'Launch Blazor WebAssembly', + project: PROJECT, + ...overrides, + }; +} + +suite('Blazor WebAssembly launch resolution', () => { + test('expands the inspectUri onto the address the DevServer announced', () => { + // 1. The loopback HTTP address the fixture's own profile binds. + assert.strictEqual(expandInspectUri(DEFAULT_INSPECT_URI, HTTP_ADDRESS), HTTP_INSPECT); + assert.strictEqual(inspectUriFor(undefined, HTTP_ADDRESS), HTTP_INSPECT); + + // 2. An HTTPS profile must produce a SECURE socket: `ws` refuses to speak + // to a TLS listener, and js-debug then never reaches .NET. + assert.strictEqual(expandInspectUri(DEFAULT_INSPECT_URI, HTTPS_ADDRESS), HTTPS_INSPECT); + assert.strictEqual(expandInspectUri(TEMPLATE_INSPECT_URI, HTTPS_ADDRESS), HTTPS_INSPECT); + + // 3. A profile that states no pattern at all falls back to the template's, + // and `{browserInspectUri}` is left for js-debug to substitute. + assert.strictEqual( + inspectUriFor(undefined, 'http://localhost:5297'), + 'ws://localhost:5297/_framework/debug/ws-proxy?browser={browserInspectUri}', + ); + assert.strictEqual(inspectUriFor(' ', HTTP_ADDRESS), HTTP_INSPECT); + assert.ok(inspectUriFor(undefined, HTTP_ADDRESS).endsWith('?browser={browserInspectUri}')); + + // 4. An address that is not a URL leaves the pattern whole rather than + // producing a half-expanded proxy the browser would refuse. + assert.strictEqual( + expandInspectUri(DEFAULT_INSPECT_URI, 'not an address'), + DEFAULT_INSPECT_URI, + ); + }); + + test('reads the bound address out of real dotnet run output', () => { + // 1. A real capture: two announcements, and only they are URLs. + assert.deepStrictEqual(listeningUrlsOf(REAL_OUTPUT), [HTTPS_ADDRESS, HTTP_ADDRESS]); + const twice = `${REAL_OUTPUT}\n Now listening on: ${HTTP_ADDRESS}`; + assert.deepStrictEqual(listeningUrlsOf(twice), [HTTPS_ADDRESS, HTTP_ADDRESS, HTTP_ADDRESS]); + // A bare URL on its own line is output, not an announcement of one. + assert.deepStrictEqual(listeningUrlsOf(HTTP_ADDRESS), []); + + // 2. Noise that merely CONTAINS a URL is not an announcement. + assert.strictEqual(listeningUrlOf(` Content root path: ${FIXTURE}`), undefined); + assert.strictEqual( + listeningUrlOf( + 'warn: The ASP.NET Core developer certificate is not trusted: https://aka.ms/aspnet/https-trust-dev-cert', + ), + undefined, + ); + assert.deepStrictEqual(listeningUrlsOf('info: Microsoft.Hosting.Lifetime[14]'), []); + + // 3. A pipe cuts the line wherever it likes; the tail is kept until the + // newline arrives, so the announcement is still found whole. + const first = takeLines('', ` Now lis`); + assert.strictEqual(first.pending, ' Now lis'); + assert.deepStrictEqual(first.lines, []); + const second = takeLines(first.pending, `tening on: ${HTTP_ADDRESS}\n`); + assert.strictEqual(second.pending, ''); + assert.strictEqual(second.lines.length, 1); + assert.deepStrictEqual(listeningUrlsOf(second.lines.join('\n')), [HTTP_ADDRESS]); + }); + + test('maps a browser onto the debug type VS Code ships for it', () => { + // 1. Both browsers js-debug ships a type for. + assert.strictEqual(browserDebugType('chrome'), 'pwa-chrome'); + assert.strictEqual(browserDebugType('edge'), 'pwa-msedge'); + + // 2. A browser name is hand-written in launch.json, so it arrives in any + // case and can be absent; neither may refuse a launch. + assert.strictEqual(browserDebugType(browserOf('EDGE')), 'pwa-msedge'); + assert.strictEqual(browserDebugType(browserOf('Chrome')), 'pwa-chrome'); + assert.strictEqual(browserDebugType(browserOf(undefined)), 'pwa-chrome'); + assert.strictEqual(browserDebugType(browserOf(42)), 'pwa-chrome'); + + // 3. The configuration js-debug receives names the browser type, and the + // proxy URL is the expanded one. + const configuration = browserConfiguration({ + name: 'Launch Blazor WebAssembly', + url: HTTP_ADDRESS, + inspectUri: HTTP_INSPECT, + browser: 'edge', + webRoot: path.join(FIXTURE, 'wwwroot'), + timeout: 30_000, + }); + assert.strictEqual(configuration.type, 'pwa-msedge'); + assert.strictEqual(configuration.request, 'launch'); + assert.strictEqual(configuration.url, HTTP_ADDRESS); + assert.strictEqual(configuration.inspectUri, HTTP_INSPECT); + assert.strictEqual(configuration.name, 'Launch Blazor WebAssembly'); + }); + + test('generates one configuration per Blazor WebAssembly project, without prompting', async () => { + // 1. Discovery is the shipped XML reader against the real fixture files. + assert.strictEqual(isBlazorWasmProject(fs.readFileSync(PROJECT, 'utf-8')), true); + assert.strictEqual(isBlazorWasmProject(fs.readFileSync(OTHER_PROJECT, 'utf-8')), false); + assert.strictEqual( + isBlazorWasmProject( + '', + ), + false, + ); + assert.strictEqual(isBlazorWasmProject('not xml at all'), false); + + // 2. The provider answers from the project list it is given, checking the + // Sdk attribute of each — the non-WebAssembly project is dropped. + const provider = new BlazorWasmDebugProvider(); + const restore = useBlazorWasmHost({ + listProjects: () => Promise.resolve([PROJECT, OTHER_PROJECT]), + }); + try { + const generated = await provider.provideDebugConfigurations(fakeFolder(FIXTURE)); + assert.deepStrictEqual(generated, [ + { + type: BLAZOR_WASM_DEBUG_TYPE, + request: 'launch', + name: 'Blazor WebAssembly: BlazorWasmApp', + project: PROJECT, + browser: 'chrome', + }, + ]); + const fromFiles = blazorWasmConfigurations([OTHER_PROJECT, PROJECT]); + assert.strictEqual(fromFiles.length, 1); + assert.strictEqual(fromFiles[0]?.project, PROJECT); + + // 3. With no folder there is nothing to enumerate, and nothing is offered. + assert.deepStrictEqual(await provider.provideDebugConfigurations(undefined), []); + } finally { + restore(); + } + + // 4. The shipped listing finds the folder's project files itself, build + // output excluded, and offers the WebAssembly one. + const listed = await provider.provideDebugConfigurations(fakeFolder(FIXTURE)); + assert.deepStrictEqual( + listed.map((configuration) => configuration.project), + [PROJECT], + 'the fixture folder holds one Blazor WebAssembly project', + ); + }); + + test('reads the profile, its inspectUri and the run arguments from the project', () => { + // 1. The fixture's sole `Project` profile applies without being named. + const profile = launchProfileOf(PROJECT, undefined); + assert.strictEqual(profile?.name, 'http'); + assert.strictEqual(profile?.inspectUri, TEMPLATE_INSPECT_URI); + + // 2. A named profile that the project does not declare is NOT invented. + assert.strictEqual(launchProfileOf(PROJECT, 'nope'), undefined); + assert.strictEqual(launchProfileOf(PROJECT, 'http')?.name, 'http'); + + // 3. The argv is a real argument vector — no shell, so no quoting. + assert.deepStrictEqual(runArguments(PROJECT, 'http'), [ + 'run', + '--project', + PROJECT, + '--launch-profile', + 'http', + ]); + assert.deepStrictEqual(runArguments(PROJECT, undefined), ['run', '--project', PROJECT]); + assert.deepStrictEqual(runArguments(PROJECT, ' '), ['run', '--project', PROJECT]); + }); + + test('kills the dotnet run process when the browser session ends', async () => { + const runs: FakeRun[] = []; + const handed: vscode.DebugConfiguration[] = []; + let endBrowser: () => void = () => undefined; + const restore = useBlazorWasmHost({ + startProcess: (args, cwd, onOutput) => { + const run = fakeRun(args, cwd, onOutput); + runs.push(run); + run.announce([` Now listening on: ${HTTP_ADDRESS}`]); + return run.process; + }, + startBrowserSession: (_folder, configuration) => { + handed.push(configuration); + return Promise.resolve({ + ended: new Promise((resolve) => { + endBrowser = resolve; + }), + }); + }, + }); + try { + // 1. The launch runs the project's own profile and hands js-debug the + // expanded proxy — and touches no process while the session is live. + const first = await launchBlazorWasm(fakeFolder(FIXTURE), launchConfig()); + assert.ok(first.ok, `the fake app served an address; ${JSON.stringify(first)}`); + const firstRun = runs[0]; + const firstHanded = handed[0]; + assert.ok(firstRun !== undefined && firstHanded !== undefined); + assert.deepStrictEqual(firstRun.args, [ + 'run', + '--project', + PROJECT, + '--launch-profile', + 'http', + ]); + assert.strictEqual(firstRun.cwd, path.dirname(PROJECT)); + assert.strictEqual(firstHanded.type, 'pwa-chrome'); + assert.strictEqual(firstHanded.url, HTTP_ADDRESS); + assert.strictEqual(firstHanded.inspectUri, HTTP_INSPECT); + assert.strictEqual(firstHanded.webRoot, path.join(FIXTURE, 'wwwroot')); + assert.strictEqual(first.value.url, HTTP_ADDRESS); + assert.strictEqual(firstRun.stops(), 0); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), first.value); + + // 2. A second launch of the SAME project replaces the first: the old app + // is stopped once, so two DevServers never race for one port. + const second = await launchBlazorWasm(fakeFolder(FIXTURE), launchConfig()); + assert.ok(second.ok); + const secondRun = runs[1]; + assert.ok(secondRun !== undefined); + assert.strictEqual(firstRun.stops(), 1); + assert.strictEqual(handed.length, 2); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), second.value); + + // 3. The browser session ends, and with it the app it was attached to — + // and only that app, and only once. + endBrowser(); + await second.value.ended; + assert.strictEqual(secondRun.stops(), 1); + assert.strictEqual(firstRun.stops(), 1); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), undefined); + } finally { + restore(); + } + }); + + test('stops the served app when the browser never starts, or the app dies during the handoff', async () => { + const runs: FakeRun[] = []; + let browser: () => Promise<{ readonly ended: Promise } | undefined> = () => + Promise.resolve(undefined); + const restore = useBlazorWasmHost({ + startProcess: (args, cwd, onOutput) => { + const run = fakeRun(args, cwd, onOutput); + runs.push(run); + run.announce([` Now listening on: ${HTTP_ADDRESS}`]); + return run.process; + }, + startBrowserSession: () => browser(), + }); + try { + // 1. The app served its address, but js-debug never started: the app is + // stopped, and the refusal names the browser and the address. + const refused = await launchBlazorWasm(fakeFolder(FIXTURE), launchConfig()); + assert.strictEqual(refused.ok, false); + assert.strictEqual( + refused.ok ? '' : refused.error, + `Could not start pwa-chrome for ${HTTP_ADDRESS}.`, + ); + assert.strictEqual(runs[0]?.stops(), 1, 'the served app is stopped once'); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), undefined); + + // 2. The browser started, but the app had died meanwhile: its reason is + // carried into the refusal, and nothing is left running. + browser = async () => { + runs[1]?.crash('the DevServer crashed'); + await new Promise((resolve) => setTimeout(resolve, 0)); + return { ended: new Promise(() => undefined) }; + }; + const died = await launchBlazorWasm(fakeFolder(FIXTURE), launchConfig()); + assert.strictEqual(died.ok, false); + assert.strictEqual( + died.ok ? '' : died.error, + 'dotnet run for BlazorWasmApp.csproj stopped before it served anything: the DevServer crashed', + ); + assert.strictEqual(runs.length, 2); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), undefined); + } finally { + restore(); + } + }); + + test('names why a launch has no project, or why its startup failed', async () => { + // 1. With no folder, a relative project cannot be resolved, and a missing + // project is never assumed. + assert.deepStrictEqual( + projectAt(undefined, 'App.csproj'), + err("Cannot resolve 'App.csproj' without a workspace folder."), + ); + assert.deepStrictEqual( + await resolveProject(undefined, undefined), + err('Blazor WebAssembly launch found no runnable project for this workspace.'), + ); + + // 2. Without a stated project, the folder's only project is the one launched; + // a folder holding none is refused, never guessed at. + assert.deepStrictEqual(await resolveProject(fakeFolder(FIXTURE), ' '), ok(PROJECT)); + const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'sharplsp-wasm-empty-')); + try { + const none = await resolveProject(fakeFolder(empty), undefined); + assert.strictEqual(none.ok, false, 'an empty folder has no project to launch'); + } finally { + fs.rmSync(empty, { recursive: true, force: true }); + } + + // 3. A project file that cannot be read is not a Blazor WebAssembly app, and + // an address that is not a URL is not announced. + assert.strictEqual(isBlazorWasmProjectFile(path.join(FIXTURE, 'Missing.csproj')), false); + assert.strictEqual(isBlazorWasmProjectFile(PROJECT), true); + assert.strictEqual(listeningUrlOf(' Now listening on: http://[::1'), undefined); + + // 4. A startup that timed out or exited says which, by project name. + assert.match( + startupFailure({ kind: 'timeout' }, PROJECT), + /^dotnet run for BlazorWasmApp\.csproj announced no listening URL within 180s\./u, + ); + assert.strictEqual( + startupFailure({ kind: 'exited', outcome: ok(undefined) }, PROJECT), + 'dotnet run for BlazorWasmApp.csproj exited before it announced a listening URL.', + ); + }); + + test('refuses a project that stops before it serves anything', async () => { + const restore = useBlazorWasmHost({ + startProcess: (args, cwd, onOutput) => { + const run = fakeRun(args, cwd, onOutput); + run.crash('MSB1009: Project file does not exist.'); + return run.process; + }, + startBrowserSession: () => Promise.resolve(undefined), + }); + try { + // 1. dotnet's own reason is carried into the refusal, named by project. + const failed = await launchBlazorWasm(fakeFolder(FIXTURE), launchConfig()); + assert.strictEqual(failed.ok, false); + assert.match( + failed.ok ? '' : failed.error, + /dotnet run for BlazorWasmApp\.csproj stopped before it served anything: MSB1009/u, + ); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), undefined); + + // 2. A project path that does not exist is refused before a process runs. + const missing = await launchBlazorWasm( + fakeFolder(FIXTURE), + launchConfig({ project: path.join(FIXTURE, 'Nope.csproj') }), + ); + assert.strictEqual(missing.ok, false); + assert.match(missing.ok ? '' : missing.error, /project not found/iu); + + // 3. A profile the project does not declare is refused, not ignored. + const unknownProfile = await launchBlazorWasm( + fakeFolder(FIXTURE), + launchConfig({ launchProfile: 'nope' }), + ); + assert.strictEqual(unknownProfile.ok, false); + assert.match(unknownProfile.ok ? '' : unknownProfile.error, /'nope' is not declared/iu); + } finally { + restore(); + } + }); +}); diff --git a/src/editors/vscode/src/test/suite/blazor-wasm-debug.test.ts b/src/editors/vscode/src/test/suite/blazor-wasm-debug.test.ts new file mode 100644 index 00000000..1060b130 --- /dev/null +++ b/src/editors/vscode/src/test/suite/blazor-wasm-debug.test.ts @@ -0,0 +1,286 @@ +// Blazor WebAssembly debugging, end to end: a REAL `dotnet run` of a REAL +// Blazor WebAssembly app, handed to js-debug, and stopped with the session. +// Spec: [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]. +// +// The launch is driven exactly as a user drives it — `vscode.debug.startDebugging` +// with a `sharplsp-blazorwasm` configuration, through the provider the extension +// registered at activation. Only the browser session itself is replaced, by the +// shipped `useBlazorWasmHost` seam: a headless host has no js-debug to start, and +// what this suite must prove is the configuration js-debug would have received +// and the app that answers behind it. +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import { + runningBlazorWasmLaunch, + useBlazorWasmHost, + type RunningLaunch, +} from '../../blazor-wasm-launch.js'; +import { copyPinnedFixture, dotnet } from './dotnet-project-kit'; +import { authoredPackageJson } from './run-debug-kit'; +import { + comparablePath, + pollUntilResult, + removeDirRecursive, + requireWorkspaceRoot, +} from './test-helpers'; +import { DEBUG_SESSION_MS, FIXTURE_BUILD_MS, POLL_INTERVAL_MS } from './test-timeouts'; + +/** The Blazor WebAssembly fixture in the repository. */ +const SOURCE = path.resolve(__dirname, '../../../../../sharplsp/tests/fixtures/blazor-wasm'); + +/** The project the launch is pointed at, relative to the fixture root. */ +const PROJECT_NAME = 'BlazorWasmApp.csproj'; + +/** + * The manifest as AUTHORED, which is not what VS Code core hands back: core + * injects its own attributes into every debugger it loads + * ([DEBUG-FEATURES-LAUNCH-OUTPUT] rule 5). + */ +interface AuthoredDebugger { + readonly type?: string; + readonly configurationAttributes?: { readonly launch?: { properties?: object } }; + readonly configurationSnippets?: { readonly body?: { readonly type?: string } }[]; +} + +/** The parts of the authored manifest this suite judges. */ +interface AuthoredManifest { + readonly activationEvents?: string[]; + readonly contributes?: { readonly debuggers?: AuthoredDebugger[] }; +} + +/** The page the DevServer answers the debug proxy on. */ +const DEBUG_PROXY_PATH = '/_framework/debug'; + +/** Copy the fixture to `root` and build it, so the launch only has to run it. */ +async function writeFixture(root: string): Promise { + copyPinnedFixture(SOURCE, root); + await dotnet(['build', '--nologo'], root); +} + +/** An HTTP status for `url`, or undefined when nothing answers. */ +function statusOf(url: string): Promise { + return new Promise((resolve) => { + const request = http.get(url, (response) => { + response.resume(); + resolve(response.statusCode); + }); + request.on('error', () => { + resolve(undefined); + }); + request.setTimeout(5_000, () => { + request.destroy(); + resolve(undefined); + }); + }); +} + +/** + * The scratch copy of the fixture this suite works against. + * + * Inside the OPENED workspace folder, as every debug suite's scratch tree is: a + * session started against a temp directory has no `workspaceFolder`, which is a + * different refusal path from the one under test here. + */ +let root = ''; + +/** Everything the browser session received, in order. */ +const handed: vscode.DebugConfiguration[] = []; + +/** How many browser sessions were started. */ +let sessions = 0; + +/** Ends the browser session the launch is waiting on. */ +let endBrowser: () => void = () => undefined; + +/** The fixture, built, with the browser session replaced by a recorder. */ +suiteSetup(async function () { + this.timeout(FIXTURE_BUILD_MS); + root = fs.mkdtempSync(path.join(requireWorkspaceRoot(), 'blazor-wasm-')); + await writeFixture(root); + useBlazorWasmHost({ + startBrowserSession: (_folder, configuration) => { + handed.push(configuration); + sessions += 1; + return Promise.resolve({ + ended: new Promise((resolve) => { + endBrowser = resolve; + }), + }); + }, + }); +}); + +suiteTeardown(function () { + this.timeout(DEBUG_SESSION_MS); + removeDirRecursive(root); +}); + +/** The project file of the scratch copy. */ +function projectFile(): string { + return path.join(root, PROJECT_NAME); +} + +/** A `sharplsp-blazorwasm` configuration for the scratch project. */ +function launchConfiguration(): vscode.DebugConfiguration { + return { + type: 'sharplsp-blazorwasm', + request: 'launch', + name: 'Launch Blazor WebAssembly', + project: projectFile(), + browser: 'chrome', + }; +} + +/** Start the launch through the provider the extension registered, and wait for it. */ +async function startLaunch(): Promise { + const folder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file(requireWorkspaceRoot()), + name: path.basename(requireWorkspaceRoot()), + index: 0, + }; + const wanted = sessions + 1; + // The provider hands off to js-debug and answers `undefined`, which is how a + // provider starts NO session of its own — so `false` here is success, and the + // seam is what says the launch reached the browser. + await vscode.debug.startDebugging(folder, launchConfiguration()); + const launch = await pollUntilResult( + () => Promise.resolve(runningBlazorWasmLaunch(projectFile())), + (found) => found !== undefined && sessions === wanted, + FIXTURE_BUILD_MS, + POLL_INTERVAL_MS, + 'the Blazor WebAssembly launch to serve and hand off', + ); + assert.ok(launch, 'the launch registers itself while it runs'); + return launch; +} + +suite('Blazor WebAssembly debugging', () => { + test('serves the app, hands js-debug the proxy and dies with the session', async function () { + this.timeout(FIXTURE_BUILD_MS + DEBUG_SESSION_MS); + + // 1. The user presses F5 on a `sharplsp-blazorwasm` launch. `dotnet run` + // builds and serves the app, and js-debug receives the expanded proxy. + const launch = await startLaunch(); + const configuration = handed[0]; + assert.ok(configuration !== undefined, 'js-debug was handed a configuration'); + assert.strictEqual(configuration.type, 'pwa-chrome', 'chrome maps onto pwa-chrome'); + assert.strictEqual(configuration.request, 'launch'); + assert.strictEqual(configuration.name, 'Launch Blazor WebAssembly'); + assert.ok( + /^http:\/\/127\.0\.0\.1:\d+$/u.test(launch.url), + `the DevServer's own address is launched: ${launch.url}`, + ); + assert.strictEqual(configuration.url, launch.url, 'the browser opens the real address'); + const expanded = `ws://${new URL(launch.url).host}${DEBUG_PROXY_PATH}/ws-proxy?browser={browserInspectUri}`; + assert.strictEqual(configuration.inspectUri, expanded, 'the proxy carries host and port'); + assert.ok( + !configuration.inspectUri.includes('{wsProtocol}'), + 'every token but {browserInspectUri} is expanded', + ); + assert.strictEqual( + comparablePath(configuration.webRoot), + comparablePath(path.join(root, 'wwwroot')), + 'webRoot defaults to the project wwwroot', + ); + + // 2. The proxy the configuration names is really served: a GET of the + // DevServer's `/_framework/debug` answers 200, which only the dev server + // package produces. TWICE, so a single lucky answer cannot pass. + const status = await statusOf(`${launch.url}${DEBUG_PROXY_PATH}`); + assert.strictEqual(status, 200, `the .NET debug proxy answers on ${launch.url}`); + const again = await statusOf(`${launch.url}${DEBUG_PROXY_PATH}`); + assert.strictEqual(again, 200, 'and keeps answering'); + const page = await statusOf(`${launch.url}/`); + assert.strictEqual(page, 200, 'the app itself is served too'); + + // 3. The user stops the browser session: the app it was attached to stops + // with it, and the port it held is released. Polled, because the stop is + // a signal and the process takes a moment to go. + endBrowser(); + await launch.ended; + assert.strictEqual( + runningBlazorWasmLaunch(projectFile()), + undefined, + 'a finished launch is not registered', + ); + const closed = await pollUntilResult( + () => statusOf(`${launch.url}${DEBUG_PROXY_PATH}`), + (status) => status !== 200, + DEBUG_SESSION_MS, + POLL_INTERVAL_MS, + 'the DevServer to stop answering once the browser session ended', + ); + assert.notStrictEqual(closed, 200, 'the dotnet run process is gone'); + }); + + test('stops the app it replaced when the same project launches twice', async function () { + this.timeout(FIXTURE_BUILD_MS + DEBUG_SESSION_MS); + + // 1. A launch of the project, serving. + const first = await startLaunch(); + assert.strictEqual(sessions, 2, 'the recorder saw the session'); + assert.strictEqual(await statusOf(`${first.url}${DEBUG_PROXY_PATH}`), 200); + + // 2. A second launch of the SAME project: one DevServer may hold the port, + // so the first is stopped and the second takes over the registry. + const second = await startLaunch(); + assert.notStrictEqual(second.url, first.url, 'the second app bound its own address'); + assert.strictEqual(runningBlazorWasmLaunch(projectFile()), second); + await pollUntilResult( + () => statusOf(`${first.url}${DEBUG_PROXY_PATH}`), + (status) => status !== 200, + DEBUG_SESSION_MS, + POLL_INTERVAL_MS, + 'the replaced DevServer to stop answering', + ); + + // 3. Ending the browser session ends only what it was attached to. + endBrowser(); + await second.ended; + assert.notStrictEqual(await statusOf(`${second.url}${DEBUG_PROXY_PATH}`), 200); + assert.strictEqual(runningBlazorWasmLaunch(projectFile()), undefined); + }); + + test('contributes the debugger, its snippet and its activation events', async function () { + this.timeout(DEBUG_SESSION_MS); + + // 1. The extension activated, so the providers a launch needs are registered. + const extension = vscode.extensions.getExtension('nimblesite.sharplsp'); + assert.ok(extension !== undefined, 'the SharpLsp extension is installed in the host'); + await extension.activate(); + assert.strictEqual(extension.isActive, true); + + // 2. Both debuggers are declared: the C#/F# one, and the Blazor WebAssembly + // one, whose launch properties are what `launch.json` IntelliSense reads. + // Read from the AUTHORED manifest, because the loaded one is merged with + // core's injected attributes and would report properties we never wrote. + const manifest = authoredPackageJson() as AuthoredManifest; + const debuggers = manifest.contributes?.debuggers ?? []; + const wasm = debuggers.find((entry) => entry.type === 'sharplsp-blazorwasm'); + assert.ok( + wasm !== undefined, + `contributed debuggers: ${debuggers.map((d) => d.type).join(', ')}`, + ); + const properties = Object.keys(wasm.configurationAttributes?.launch?.properties ?? {}).sort(); + assert.deepStrictEqual(properties, [ + 'browser', + 'launchProfile', + 'project', + 'timeout', + 'url', + 'webRoot', + ]); + const snippets = wasm.configurationSnippets?.map((snippet) => snippet.body?.type) ?? []; + assert.deepStrictEqual(snippets, ['sharplsp-blazorwasm'], 'one snippet, of this type'); + + // 3. The type makes the extension activate before it is used, and the + // manifest the host loaded agrees with the one in the source tree. + assert.ok(manifest.activationEvents?.includes('onDebugResolve:sharplsp-blazorwasm')); + assert.ok( + manifest.activationEvents?.includes('onDebugDynamicConfigurations:sharplsp-blazorwasm'), + ); + }); +}); diff --git a/src/editors/vscode/src/test/suite/debug-hot-reload-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-hot-reload-e2e.test.ts index ca847f7d..2ad47854 100644 --- a/src/editors/vscode/src/test/suite/debug-hot-reload-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-hot-reload-e2e.test.ts @@ -11,6 +11,8 @@ // their locals, and the state they spent minutes reaching. So the session id is // asserted unchanged across every edit. import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; import * as vscode from 'vscode'; import { MODE, type DebugFixture } from './debug-fixture-programs'; import { CMD_CONTINUE, assertStoppedAt, topFrame } from './debug-drive-kit'; @@ -238,4 +240,62 @@ suite('Debug hot reload — editing a method while the debuggee is paused', () = ); }, ); + + // Implements [RAZOR-HOT-RELOAD]: the DAP save pipeline owns Razor documents + // as well as C#. A saved .razor file inside the debugged project reaches the + // sidecar — which refuses an unlisted Razor file BY NAME — instead of + // vanishing as a foreign language, and the session survives the refusal. + debugTest( + 'a saved Razor document is owned by the pipeline and names itself when unlisted', + debuggee, + async ({ fixture, recorder, stubs }) => { + // Interaction 1 — run the debuggee with hot reload enabled. + const { session } = await runToFirstStop(debuggee(), 'accumulate-entry', { + mode: MODE.plain, + extra: { hotReload: true }, + }); + + // Interaction 2 — save a Razor document inside the debugged project. + // This console debuggee lists no Razor file, so the sidecar must refuse + // the save by name rather than apply anything. + const razorPath = path.join(fixture.dir, 'Owned.razor'); + fs.writeFileSync(razorPath, '

Owned

\n', 'utf8'); + const razorDoc = await vscode.workspace.openTextDocument(vscode.Uri.file(razorPath)); + eq(razorDoc.languageId, 'razor', 'VS Code opens a .razor file as the razor language'); + const razorEditor = await vscode.window.showTextDocument(razorDoc); + await razorEditor.edit((b) => { + b.insert(new vscode.Position(0, 0), '

touched

'); + }); + assert.ok(await razorDoc.save(), 'the Razor document must save — that is the trigger'); + await sleep(RELOAD_SETTLE_MS); + + // Interaction 3 — the save REACHED the pipeline: one refusal, naming the + // file. An unowned language would leave `refusalsOf` empty here. + const reported = refusalsOf(stubs); + eq( + reported.length, + 1, + `exactly one refusal for the unlisted Razor save: ${JSON.stringify(reported)}`, + ); + const message = requireAt(reported, 0, 'the unlisted-Razor refusal'); + assert.ok( + message.includes('Owned.razor'), + `the refusal must name the Razor file; it said: '${message}'`, + ); + + // Interaction 4 — the session SURVIVED the refusal, and the original IL + // still runs: a refused save is a verdict, not a lost debuggee. + eq( + vscode.debug.activeDebugSession?.id, + session.id, + 'an unlisted Razor save must not restart or kill the session', + ); + await vscode.commands.executeCommand(CMD_CONTINUE); + await assertRanToCompletion(recorder, 0, 'a session after an unlisted Razor save'); + assert.ok( + recorder.outputText().includes('total=8'), + 'nothing was applied, so the original result must appear', + ); + }, + ); }); diff --git a/src/editors/vscode/src/test/suite/dotnet-project-kit.ts b/src/editors/vscode/src/test/suite/dotnet-project-kit.ts index 57f4476a..fff2b171 100644 --- a/src/editors/vscode/src/test/suite/dotnet-project-kit.ts +++ b/src/editors/vscode/src/test/suite/dotnet-project-kit.ts @@ -10,10 +10,12 @@ // Solutions are produced by `dotnet new sln` + `dotnet sln add`, never authored: // .NET 10 emits the XML `.slnx` format by default and the extension has to cope // with whichever the CLI chose, so the fixture must not assume an extension. +import * as assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { execFile } from 'node:child_process'; import { XMLBuilder } from 'fast-xml-parser'; +import { findGlobalJson } from '../../global-json.js'; import { FIXTURE_BUILD_MS } from './test-timeouts'; /** A ``. */ @@ -433,3 +435,22 @@ export async function installedFrameworkPair(cwd: string): Promise { } return targets.slice(-2); } + +/** Build output a copied fixture must not bring along: a copied `obj` points back at the source. */ +const BUILD_OUTPUT = new Set(['bin', 'obj']); + +/** + * Copy the repository fixture at `source` to `root`, without its build output, pinned to + * the SDK the repository builds it with. A copy under the OS temp directory has no + * `global.json` above it, so it would build with whatever SDK is newest on the machine, + * whose Razor generator can need a newer Roslyn than the sidecar's. + */ +export function copyPinnedFixture(source: string, root: string): void { + fs.cpSync(source, root, { + recursive: true, + filter: (entry) => !BUILD_OUTPUT.has(path.basename(entry)), + }); + const pin = findGlobalJson(source); + assert.ok(pin, 'the repository pins the fixture SDK in a global.json'); + fs.copyFileSync(pin, path.join(root, 'global.json')); +} diff --git a/src/editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts b/src/editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts index 427f33de..1b764bdb 100644 --- a/src/editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts @@ -420,11 +420,16 @@ suite('FSI / Build / Output-filter / Hot-reload E2E', () => { // ── hot-reload.ts ─────────────────────────────────────────────── - test('isRelevantLanguage classifies C#/F# vs other languages within a save flow', async function () { + test('isRelevantLanguage classifies C#/F#/Razor vs other languages within a save flow', async function () { this.timeout(COMMAND_MS); assert.ok(isRelevantLanguage('csharp')); assert.ok(isRelevantLanguage('fsharp')); + assert.ok(isRelevantLanguage('razor'), 'a .razor save feeds hot reload ([RAZOR-HOT-RELOAD])'); + assert.ok( + isRelevantLanguage('aspnetcorerazor'), + "the C# extension's Razor id feeds hot reload too ([RAZOR-HOT-RELOAD])", + ); assert.ok(!isRelevantLanguage('json')); assert.ok(!isRelevantLanguage('plaintext')); @@ -473,6 +478,26 @@ suite('FSI / Build / Output-filter / Hot-reload E2E', () => { assert.ok(!isRelevantLanguage(txtDoc.languageId)); assert.ok(isHotReloadRunning()); + // Saving a Razor document must also be harmless: its language is relevant, + // so the onSave path runs instead of early-returning ([RAZOR-HOT-RELOAD]). + const razorPath = path.join(tmpDir, 'Saved.razor'); + fs.writeFileSync(razorPath, '

Saved

\n', 'utf8'); + const razorDoc = await vscode.workspace.openTextDocument(vscode.Uri.file(razorPath)); + assert.strictEqual( + razorDoc.languageId, + 'razor', + 'VS Code opens a .razor file as the razor language', + ); + const razorEditor = await vscode.window.showTextDocument(razorDoc); + await razorEditor.edit((b) => { + b.insert(new vscode.Position(0, 0), '

touched

'); + }); + await assert.doesNotReject(async () => { + await razorDoc.save(); + }, 'Saving a Razor doc during hot reload must not throw'); + assert.ok(isRelevantLanguage(razorDoc.languageId)); + assert.ok(isHotReloadRunning(), 'Hot reload must remain running after a Razor save'); + await vscode.commands.executeCommand('sharplsp.hotReload.stop'); assert.ok(!isHotReloadRunning()); }); diff --git a/src/editors/vscode/src/test/suite/razor-kit.ts b/src/editors/vscode/src/test/suite/razor-kit.ts index d5f39aee..9d7fd63e 100644 --- a/src/editors/vscode/src/test/suite/razor-kit.ts +++ b/src/editors/vscode/src/test/suite/razor-kit.ts @@ -2,50 +2,33 @@ // repository's src/sharplsp/tests/fixtures/razor, copied without its build output // to a scratch root, built so the SDK's Razor generator has run, and loaded // through the real extension. -import * as assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import type * as vscode from 'vscode'; -import { findGlobalJson } from '../../global-json.js'; import { serverProjection } from '../../razor-html.js'; -import { dotnet } from './dotnet-project-kit'; +import { copyPinnedFixture, dotnet } from './dotnet-project-kit'; import { type LoadedFixture, useLoadedFixture } from './netfx-language-kit'; import { lspClient } from './profiler-kit'; /** The fixture in the repository. */ const SOURCE = path.resolve(__dirname, '../../../../../sharplsp/tests/fixtures/razor'); -/** Build output a copy leaves behind: a copied `obj` would point back at the source. */ -const BUILD_OUTPUT = new Set(['bin', 'obj']); - /** The Razor files the suite opens, relative to the fixture root. */ export const RAZOR_FILES = { counter: 'BlazorApp/Components/Pages/Counter.razor', board: 'BlazorApp/Components/Pages/Board.razor', + leaders: 'BlazorApp/Components/Pages/Leaders.razor', + card: 'BlazorApp/Components/Shared/Card.razor', + edit: 'PagesApp/Pages/Edit.cshtml', } as const; /** The Razor file the suite writes where no project lists it. */ export const STRAY_RAZOR = 'Stray.razor'; -/** - * Pin `root` to the SDK the fixture has in the repository. A scratch copy has no - * `global.json` above it, so it would build and load with the newest installed SDK, - * whose Razor generator can need a newer Roslyn than the sidecar's. - */ -function keepSdkPin(root: string): void { - const pin = findGlobalJson(SOURCE); - assert.ok(pin, 'the repository pins the fixture SDK in a global.json'); - fs.copyFileSync(pin, path.join(root, 'global.json')); -} - -/** Copy the fixture to `root`, with its SDK pin, and build it. */ +/** Copy the fixture to `root`, pinned to the repository SDK, and build it. */ async function writeRazorFixture(root: string): Promise { - fs.cpSync(SOURCE, root, { - recursive: true, - filter: (source) => !BUILD_OUTPUT.has(path.basename(source)), - }); - keepSdkPin(root); + copyPinnedFixture(SOURCE, root); const solution = path.join(root, 'RazorFixtures.slnx'); await dotnet(['build', solution], root); return { diff --git a/src/editors/vscode/src/test/suite/razor-lsp.test.ts b/src/editors/vscode/src/test/suite/razor-lsp.test.ts index f0188dbb..5d86d6c2 100644 --- a/src/editors/vscode/src/test/suite/razor-lsp.test.ts +++ b/src/editors/vscode/src/test/suite/razor-lsp.test.ts @@ -300,4 +300,164 @@ suite('Razor in VS Code', () => { 'every usage finds the same references', ); }); + + test('F# symbols in Razor navigate to, find and rename in their F# source', async function () { + this.timeout(LSP_RESPONSE_MS * 3); + const { doc, uri } = await openRepoFile(fixture().root, RAZOR_FILES.leaders); + const inScores = (target: vscode.Uri): boolean => target.path.endsWith('/Domain/Scores.fs'); + + // Interaction 1 — definition on an F# record lands on its F# declaration, not + // decompiled source. [RAZOR-FSHARP-DEFINITION] + const definitions = await pollProvider( + 'vscode.executeDefinitionProvider', + [uri, positionOf(doc, 'private readonly Player[]', 'Player')], + (found) => found.some((target) => inScores(targetUri(target))), + LSP_RESPONSE_MS * 2, + ); + const declared = definitions.find((target) => inScores(targetUri(target))); + assert.ok(declared, 'a definition in Scores.fs'); + const scores = await vscode.workspace.openTextDocument(targetUri(declared)); + assert.equal(scores.getText(targetRange(declared)), 'Player', 'ranged on the name'); + assert.ok( + scores.lineAt(targetRange(declared).start.line).text.startsWith('type Player'), + 'on the type declaration', + ); + + // Interaction 2 — references on an F# field span F# and Razor, each on its name. + // [RAZOR-FSHARP-REFERENCES] + const score = positionOf(doc, '@player.Score', 'Score'); + const references = await pollProvider( + 'vscode.executeReferenceProvider', + [uri, score], + (found) => + found.some((location) => inScores(location.uri)) && + found.some((location) => location.uri.toString() === uri.toString()), + ); + assert.equal(references.filter((location) => inScores(location.uri)).length, 5); + for (const location of references) { + const holder = inScores(location.uri) ? scores : doc; + assert.equal(holder.getText(location.range), 'Score', 'every reference spans the name'); + assert.ok(!location.uri.path.endsWith('.g.cs'), 'never generated code'); + } + + // Interaction 3 — renaming it from Razor renames its F# declaration and uses. + // [RAZOR-FSHARP-RENAME] + const edit = await vscode.commands.executeCommand( + 'vscode.executeDocumentRenameProvider', + uri, + score, + 'Points', + ); + const byFile = new Map(edit.entries().map(([target, edits]) => [target.path, edits])); + assert.equal(byFile.get(uri.path)?.length, 1, 'the Razor use'); + assert.equal(byFile.get(scores.uri.path)?.length, 5, 'the F# declaration and four uses'); + assert.ok( + [...byFile.values()].flat().every((change) => change.newText === 'Points'), + 'every edit renames to Points', + ); + assert.ok(![...byFile.keys()].some((file) => file.endsWith('.g.cs')), 'no generated file'); + }); + + test('a Razor Page completes tag helper elements once, before the HTML service', async function () { + this.timeout(LSP_RESPONSE_MS * 2); + const { doc, uri, editor } = await openRepoFile(fixture().root, RAZOR_FILES.edit); + const end = doc.lineAt(doc.lineCount - 1).range.end; + await editor.edit((builder) => { + builder.insert(end, '\n<'); + }); + const caret = doc.lineAt(doc.lineCount - 1).range.end; + + // The tag helper's element comes from the server, HTML's own from the HTML + // service, and `a`, which both offer, appears once. [RAZOR-MVC-TAGHELPERS] + const list = await completionList(uri, caret, 'partial'); + const labels = list.items.map((item) => + typeof item.label === 'string' ? item.label : item.label.label, + ); + assert.ok(labels.includes('environment'), 'every tag helper element'); + assert.ok( + HTML_ONLY.every((element) => labels.includes(element)), + "and the HTML service's", + ); + assert.equal(labels.filter((label) => label === 'a').length, 1, '`a` once'); + const anchor = list.items.find((item) => item.label === 'a'); + assert.match( + String(anchor?.detail), + /AnchorTagHelper/, + "the server's item, naming its tag helper", + ); + assert.ok(!labels.includes('ErrorBoundary'), 'no component completes in a Razor Page'); + }); + + test('a component completes and defines its stylesheet classes, and nests its collocated files', async function () { + this.timeout(LSP_RESPONSE_MS * 3); + const { doc, uri, editor } = await openRepoFile(fixture().root, RAZOR_FILES.card); + const inStylesheet = (target: vscode.Uri): boolean => + target.path.endsWith('/Shared/Card.razor.css'); + + // Interaction 1 — a class name defines to every selector that declares it, each on + // the name after its `.`. [RAZOR-COLLOCATION-CSS] + const definitions = await pollProvider( + 'vscode.executeDefinitionProvider', + [uri, positionOf(doc, 'class="card title"', 'title')], + (found) => found.length > 0 && found.every((target) => inStylesheet(targetUri(target))), + LSP_RESPONSE_MS * 2, + ); + assert.equal(definitions.length, 5, 'the five rules whose selectors hold .title'); + const [first] = definitions; + assert.ok(first, 'a definition'); + const stylesheet = await vscode.workspace.openTextDocument(targetUri(first)); + for (const definition of definitions) { + assert.equal( + stylesheet.getText(targetRange(definition)), + 'title', + 'ranged on the class name', + ); + } + + // Interaction 2 — inside a class value, the stylesheet's classes complete as values + // that replace the class name at the caret. [RAZOR-COLLOCATION-CSS] + const end = doc.lineAt(doc.lineCount - 1).range.end; + await editor.edit((builder) => { + builder.insert(end, '\n
{ assert.strictEqual(DEBUG_TYPE, DEBUG_TYPE_ID, 'constants and the kit name one debug type'); const debuggers: unknown = block.debuggers; assert.ok(Array.isArray(debuggers), 'contributes.debuggers must be an array'); - assert.strictEqual(debuggers.length, 1, 'a second debugger would split the F5 auto-pick'); - assert.strictEqual(debuggers[0], contribution, 'the only entry IS the sharplsp-coreclr one'); + // Judged on `languages`, not on the array's length: a debugger that declares + // NONE ([DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]'s `sharplsp-blazorwasm`, reached + // from launch.json and the Select Debugger list) takes no part in the F5 + // auto-pick, so only an entry that DOES declare them can split it. + const autoPicked = debuggers.filter( + (item) => Array.isArray(item?.languages) && item.languages.length > 0, + ); + assert.strictEqual(autoPicked.length, 1, 'a second debugger would split the F5 auto-pick'); + assert.strictEqual(autoPicked[0], contribution, 'the auto-picked entry IS sharplsp-coreclr'); const bodies = snippetBodies(); assert.strictEqual(bodies.length, 2, 'a launch and an attach snippet are both offered'); const types = bodies.map((body) => String(body.type)); diff --git a/src/editors/vscode/test-chunks.json b/src/editors/vscode/test-chunks.json index 3e274eba..ed0ba200 100644 --- a/src/editors/vscode/test-chunks.json +++ b/src/editors/vscode/test-chunks.json @@ -56,11 +56,13 @@ ] }, "debug": { - "description": "Getting a program to START, and nothing about what happens once it has: the F5 / no-launch.json resolve contract, launchSettings.json + .run.json profile parsing, the netcoredbg adapter factory, manifest conformance for the debugger, breakpoint, task-definition, command and menu contributions, the [SCRIPT-CONE] launch-target walk with MSBuild output resolution, and the F5 / Ctrl+F5 / sharplsp.runProgram gestures over real projects, C# file-based apps and .fsx scripts.", + "description": "Getting a program to START, and nothing about what happens once it has: the F5 / no-launch.json resolve contract, launchSettings.json + .run.json profile parsing, the netcoredbg adapter factory, manifest conformance for the debugger, breakpoint, task-definition, command and menu contributions, the [SCRIPT-CONE] launch-target walk with MSBuild output resolution, and the F5 / Ctrl+F5 / sharplsp.runProgram gestures over real projects, C# file-based apps and .fsx scripts. Also the Blazor WebAssembly launch ([DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]), which starts `dotnet run`, hands the DevServer's debug proxy to js-debug and dies with that browser session.", "files": [ "debug-e2e.test.js", "debug-adapter-e2e.test.js", "debug-adapter-startup.test.js", + "blazor-wasm-debug.test.js", + "blazor-wasm-debug-resolution.test.js", "run-debug-profiles.test.js", "run-debug-contributions.test.js", "run-debug-target.test.js", diff --git a/src/sharplsp/src/call_hierarchy.rs b/src/sharplsp/src/call_hierarchy.rs index e5ff5d9c..c92f0bd8 100644 --- a/src/sharplsp/src/call_hierarchy.rs +++ b/src/sharplsp/src/call_hierarchy.rs @@ -75,6 +75,7 @@ fn parse_symbol_kind(kind: &str) -> SymbolKind { "Property" => SymbolKind::PROPERTY, "Field" => SymbolKind::FIELD, "Class" => SymbolKind::CLASS, + "Event" => SymbolKind::EVENT, "Interface" => SymbolKind::INTERFACE, "Struct" => SymbolKind::STRUCT, "Enum" => SymbolKind::ENUM, diff --git a/src/sharplsp/src/code_actions.rs b/src/sharplsp/src/code_actions.rs index 6dea6e3f..a8d12c10 100644 --- a/src/sharplsp/src/code_actions.rs +++ b/src/sharplsp/src/code_actions.rs @@ -8,7 +8,10 @@ use std::sync::Arc; use anyhow::Result; use lsp_server::Request; use lsp_types::{ - CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams, TextEdit, Uri, WorkspaceEdit, + CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams, CreateFile, + CreateFileOptions, DocumentChangeOperation, DocumentChanges, OneOf, + OptionalVersionedTextDocumentIdentifier, ResourceOp, TextDocumentEdit, TextEdit, Uri, + WorkspaceEdit, }; use tracing::debug; @@ -104,11 +107,59 @@ fn map_action_kind(kind: &str) -> CodeActionKind { } /// Convert a sidecar workspace edit into an LSP `WorkspaceEdit`. +/// The LSP edit for a sidecar edit: a map of text edits by file, or, when it +/// creates a file, document changes that create each new file before editing +/// it, since a text edit cannot reach a file that does not exist yet. +/// [RAZOR-CODE-ACTIONS] +fn map_workspace_edit(edit: &SidecarWorkspaceEdit) -> WorkspaceEdit { + if edit.document_changes.iter().any(|change| change.create) { + WorkspaceEdit { + document_changes: Some(DocumentChanges::Operations( + edit.document_changes + .iter() + .flat_map(document_operations) + .collect(), + )), + ..WorkspaceEdit::default() + } + } else { + map_text_changes(edit) + } +} + +/// A document's operations: its creation when it is new, then its edits. +fn document_operations(change: &SidecarDocumentEdit) -> Vec { + let Ok(uri) = crate::paths::path_to_lsp_uri(&change.file_path) else { + return Vec::new(); + }; + let create = change.create.then(|| { + DocumentChangeOperation::Op(ResourceOp::Create(CreateFile { + uri: uri.clone(), + options: Some(CreateFileOptions { + overwrite: Some(false), + ignore_if_exists: Some(false), + }), + annotation_id: None, + })) + }); + let edits = DocumentChangeOperation::Edit(TextDocumentEdit { + text_document: OptionalVersionedTextDocumentIdentifier { uri, version: None }, + edits: change + .edits + .iter() + .map(map_text_edit) + .map(OneOf::Left) + .collect(), + }); + create.into_iter().chain([edits]).collect() +} + +/// A sidecar edit that creates no file, as per-document text changes. #[expect( clippy::mutable_key_type, reason = "Uri is the key type mandated by lsp-types WorkspaceEdit" )] -fn map_workspace_edit(edit: &SidecarWorkspaceEdit) -> WorkspaceEdit { +fn map_text_changes(edit: &SidecarWorkspaceEdit) -> WorkspaceEdit { let mut changes = std::collections::HashMap::new(); for doc_edit in &edit.document_changes { if let Ok(uri) = crate::paths::path_to_lsp_uri(&doc_edit.file_path) { @@ -166,6 +217,9 @@ struct SidecarDocumentEdit { file_path: String, /// Text edits to apply to this document. edits: Vec, + /// Whether the edit creates the file, whose whole text it then holds. + #[serde(default)] + create: bool, } /// A workspace-wide set of edits returned by the sidecar. @@ -238,6 +292,7 @@ mod tests { use crate::paths::test_paths::{NATIVE_FILE, NATIVE_FILE_URI}; let edit = SidecarWorkspaceEdit { document_changes: vec![SidecarDocumentEdit { + create: false, file_path: NATIVE_FILE.to_string(), edits: vec![ SidecarTextEdit { @@ -271,6 +326,7 @@ mod tests { fn map_workspace_edit_skips_unparseable_paths() { let edit = SidecarWorkspaceEdit { document_changes: vec![SidecarDocumentEdit { + create: false, file_path: "\u{0}".to_string(), edits: vec![], }], @@ -278,4 +334,121 @@ mod tests { let workspace_edit = map_workspace_edit(&edit); assert!(workspace_edit.changes.unwrap().is_empty()); } + + /// An edit that creates a file creates it before writing its text, and + /// edits the other files in the same document changes. [RAZOR-CODE-ACTIONS] + #[test] + fn map_workspace_edit_creates_new_files_before_editing_them() { + use crate::paths::test_paths::{NATIVE_FILE, NATIVE_FILE_URI}; + let text = |new_text: &str| SidecarTextEdit { + start_line: 0, + start_character: 0, + end_line: 0, + end_character: 0, + new_text: new_text.to_string(), + }; + let created = format!("{NATIVE_FILE}.cs"); + let edit = SidecarWorkspaceEdit { + document_changes: vec![ + SidecarDocumentEdit { + create: true, + file_path: created.clone(), + edits: vec![text("public partial class Counter {}")], + }, + SidecarDocumentEdit { + create: false, + file_path: NATIVE_FILE.to_string(), + edits: vec![text("")], + }, + ], + }; + let workspace_edit = map_workspace_edit(&edit); + assert!(workspace_edit.changes.is_none()); + let operations = match workspace_edit.document_changes { + Some(DocumentChanges::Operations(operations)) => operations, + _ => Vec::new(), + }; + let steps: Vec<(&str, &str, Option, usize)> = + operations.iter().map(operation_step).collect(); + let created_uri = crate::paths::path_to_lsp_uri(&created).unwrap(); + assert_eq!( + steps, + [ + ("create", created_uri.as_str(), Some(false), 0), + ("edit", created_uri.as_str(), None, 1), + ("edit", NATIVE_FILE_URI, None, 1), + ], + "the new file is created, then written, then the existing file is edited" + ); + assert!( + operations.iter().all(|operation| !matches!( + operation, + DocumentChangeOperation::Edit(TextDocumentEdit { + text_document: OptionalVersionedTextDocumentIdentifier { + version: Some(_), + .. + }, + .. + }) + )), + "no edit pins a document version" + ); + } + + /// A creating edit drops a document whose path is no URI, and keeps the + /// others: the new file is still created. [RAZOR-CODE-ACTIONS] + #[test] + fn map_workspace_edit_creating_files_skips_unparseable_paths() { + use crate::paths::test_paths::NATIVE_FILE; + let created = format!("{NATIVE_FILE}.cs"); + let edit = SidecarWorkspaceEdit { + document_changes: vec![ + SidecarDocumentEdit { + create: true, + file_path: created.clone(), + edits: vec![], + }, + SidecarDocumentEdit { + create: false, + file_path: "\u{0}".to_string(), + edits: vec![], + }, + ], + }; + let operations = match map_workspace_edit(&edit).document_changes { + Some(DocumentChanges::Operations(operations)) => operations, + _ => Vec::new(), + }; + let created_uri = crate::paths::path_to_lsp_uri(&created).unwrap(); + let steps: Vec<_> = operations.iter().map(operation_step).collect(); + assert_eq!( + steps, + [ + ("create", created_uri.as_str(), Some(false), 0), + ("edit", created_uri.as_str(), None, 0), + ] + ); + } + + /// An operation as `(kind, uri, overwrite, edit count)`. + fn operation_step(operation: &DocumentChangeOperation) -> (&str, &str, Option, usize) { + match operation { + DocumentChangeOperation::Op(ResourceOp::Create(create)) => ( + "create", + create.uri.as_str(), + create + .options + .as_ref() + .and_then(|options| options.overwrite), + 0, + ), + DocumentChangeOperation::Edit(edit) => ( + "edit", + edit.text_document.uri.as_str(), + None, + edit.edits.len(), + ), + DocumentChangeOperation::Op(_) => ("other", "", None, 0), + } + } } diff --git a/src/sharplsp/src/cross_language.rs b/src/sharplsp/src/cross_language.rs new file mode 100644 index 00000000..1915b660 --- /dev/null +++ b/src/sharplsp/src/cross_language.rs @@ -0,0 +1,305 @@ +//! Symbols that cross the C# ↔ F# boundary, found in the other sidecar by +//! their identity: the assembly that declares them and their XML doc +//! signature, the only name both compilers give a symbol. +//! +//! C# sees an F# project only as its built DLL, so definition from C# or a +//! Razor file onto an F# symbol lands in decompiled source. That location +//! carries the symbol's identity, and the F# sidecar answers its F# +//! declaration instead. References merge each side's uses the same way. +//! Implements [DEFINITION-CROSSLANG-IDENTITY], [REFERENCES-CROSS-LANGUAGE] +//! and [RAZOR-FSHARP]. + +use std::sync::Arc; + +use anyhow::Result; +use lsp_types::Location; +use tracing::debug; + +use crate::semantic::{ + request_symbol_identity, sidecar_location_to_lsp, sidecar_workspace_loaded, + SidecarLocationListResult, SidecarLocationResult, SidecarRenameIdentityResult, +}; +use crate::sidecar::manager::SidecarManager; +use crate::utils::{request_sidecar, SidecarPositionReq}; + +/// The request naming a symbol by identity, matching the sidecars' +/// `ForeignSymbolRequest` key layout. +#[derive(serde::Serialize)] +struct ForeignSymbolReq<'a> { + /// Simple name of the assembly that declares the symbol. + assembly_name: &'a str, + /// The symbol's XML doc signature. + xml_doc_sig: &'a str, + /// Whether references include the declaration. + include_declaration: bool, +} + +/// `result` with each decompiled location of an F# project's symbol replaced +/// by that symbol's F# declarations, where the F# sidecar finds them, and +/// whether the answer is settled. A location without an identity stays; one +/// the F# sidecar cannot place yet stays too, and leaves the answer unsettled, +/// since the F# sidecar may still be loading. With no F# sidecar, nothing can +/// place it, so the decompiled answer is final. +pub fn in_fsharp_source( + runtime: &tokio::runtime::Runtime, + fsharp: Option<&Arc>, + result: SidecarLocationListResult, +) -> (SidecarLocationListResult, bool) { + let Some(fsharp) = fsharp else { + return (result, true); + }; + let (placed, settled): (Vec<_>, Vec<_>) = result + .locations + .into_iter() + .map(|location| declarations_of(runtime, fsharp, location)) + .unzip(); + let locations = placed.into_iter().flatten().collect(); + ( + SidecarLocationListResult { locations }, + settled.into_iter().all(|done| done), + ) +} + +/// The F# declarations of the symbol `location` shows, else `location`, and +/// whether that is final: a location naming no F# symbol always is. +fn declarations_of( + runtime: &tokio::runtime::Runtime, + fsharp: &Arc, + location: SidecarLocationResult, +) -> (Vec, bool) { + let Some(symbol) = location.symbol.as_ref().filter(|symbol| symbol.found) else { + return (vec![location], true); + }; + by_identity( + runtime, + fsharp, + "workspace/declarationForeign", + symbol, + true, + ) + .inspect_err(|err| debug!(error = %format_args!("{err:#}"), "F# declaration lookup failed")) + .ok() + .filter(|declared| !declared.is_empty()) + .map_or_else(|| (vec![location], false), |declared| (declared, true)) +} + +/// The other language's uses of the symbol at `at`, found by its identity: +/// none when the other sidecar has no workspace, the symbol has no identity, +/// or either sidecar fails, so the primary answer always stands. +pub fn foreign_references( + runtime: &tokio::runtime::Runtime, + primary: &SidecarManager, + other: Option<&Arc>, + at: &SidecarPositionReq, + include_declaration: bool, +) -> Vec { + let Some(other) = other else { + return Vec::new(); + }; + references_by_identity(runtime, primary, other, at, include_declaration).unwrap_or_else(|err| { + debug!(error = %format_args!("{err:#}"), "cross-language references failed"); + Vec::new() + }) +} + +/// The other sidecar's references to the symbol at `at`, found by the identity +/// the primary sidecar names it with; none when it names no foreign symbol. +fn references_by_identity( + runtime: &tokio::runtime::Runtime, + primary: &SidecarManager, + other: &Arc, + at: &SidecarPositionReq, + include_declaration: bool, +) -> Result> { + if !sidecar_workspace_loaded(runtime, other)? { + return Ok(Vec::new()); + } + let identity = request_symbol_identity(runtime, primary, at)?; + if !identity.found { + return Ok(Vec::new()); + } + let method = "workspace/referencesForeign"; + let found = by_identity(runtime, other, method, &identity, include_declaration)?; + Ok(found.iter().filter_map(sidecar_location_to_lsp).collect()) +} + +/// Ask `sidecar` for the locations `method` finds for the symbol `identity`. +fn by_identity( + runtime: &tokio::runtime::Runtime, + sidecar: &Arc, + method: &str, + identity: &SidecarRenameIdentityResult, + include_declaration: bool, +) -> Result> { + let request = ForeignSymbolReq { + assembly_name: &identity.assembly_name, + xml_doc_sig: &identity.xml_doc_sig, + include_declaration, + }; + let found: Option = + request_sidecar(runtime, sidecar, method, &request)?; + Ok(found.map_or_else(Vec::new, |result| result.locations)) +} + +/// `primary` followed by each of `foreign` it does not already hold. +pub fn merged(primary: Vec, foreign: Vec) -> Vec { + foreign.into_iter().fold(primary, |mut all, location| { + if !all.contains(&location) { + all.push(location); + } + all + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use lsp_types::{Position, Range, Uri}; + use std::str::FromStr; + + /// A sidecar whose process cannot start, so every request to it fails. + fn unreachable_sidecar() -> Arc { + Arc::new(SidecarManager::new( + "unreachable", + "sharplsp-no-such-sidecar", + Vec::new(), + "sharplsp-no-such-sidecar.sock", + )) + } + + /// A sidecar location as the wire carries it: `symbol` names an F# + /// project's symbol as `(found, assembly, xml doc signature)`. + fn location(line: u32, symbol: Option<(bool, &str, &str)>) -> Option { + let wire = ("/a/Domain.dll.cs", line, 4, line, 10, symbol); + rmp_serde::from_slice(&rmp_serde::to_vec(&wire).ok()?).ok() + } + + fn lines(result: &SidecarLocationListResult) -> Vec { + result.locations.iter().map(|found| found.line).collect() + } + + /// With no F# sidecar nothing can place a decompiled location, so the + /// answer is final as it stands. [DEFINITION-CROSSLANG-IDENTITY] + #[test] + fn without_an_fsharp_sidecar_decompiled_locations_are_final() { + let runtime = tokio::runtime::Runtime::new().ok(); + let result = SidecarLocationListResult { + locations: [ + location(3, Some((true, "Domain", "T:Domain.Player"))), + location(7, None), + ] + .into_iter() + .flatten() + .collect(), + }; + let placed = runtime.map(|runtime| in_fsharp_source(&runtime, None, result)); + let (result, settled) = placed.unzip(); + assert_eq!(settled, Some(true), "nothing could ever place it"); + assert_eq!( + result.as_ref().map(lines), + Some(vec![3, 7]), + "every location stays" + ); + } + + /// A location the F# sidecar cannot place stays decompiled and leaves the + /// answer unsettled, so it is asked again; a location naming no F# symbol, + /// or one whose identity was not found, never asks. [DEFINITION-CROSSLANG-IDENTITY] + #[test] + fn an_unplaced_fsharp_symbol_stays_decompiled_and_unsettled() { + let runtime = tokio::runtime::Runtime::new().ok(); + let fsharp = unreachable_sidecar(); + let answer = |locations: Vec>| { + let result = SidecarLocationListResult { + locations: locations.into_iter().flatten().collect(), + }; + runtime + .as_ref() + .map(|runtime| in_fsharp_source(runtime, Some(&fsharp), result)) + .map(|(result, settled)| (lines(&result), settled)) + }; + assert_eq!( + answer(vec![ + location(3, Some((true, "Domain", "T:Domain.Player"))), + location(7, None) + ]), + Some((vec![3, 7], false)), + "the F# sidecar failed, so the decompiled answer stands for now" + ); + assert_eq!( + answer(vec![location(5, Some((false, "", ""))), location(7, None)]), + Some((vec![5, 7], true)), + "no F# symbol to place: the answer is final" + ); + } + + /// No other sidecar, or one that fails, adds no references: the primary + /// answer always stands. [REFERENCES-CROSS-LANGUAGE] + #[test] + fn foreign_references_are_none_without_a_working_other_sidecar() { + let runtime = tokio::runtime::Runtime::new().ok(); + let primary = unreachable_sidecar(); + let other = unreachable_sidecar(); + let at = SidecarPositionReq { + file_path: "/a/Board.razor".to_string(), + line: 3, + character: 9, + }; + let found = |other: Option<&Arc>| { + runtime + .as_ref() + .map(|runtime| foreign_references(runtime, &primary, other, &at, true)) + }; + assert_eq!(found(None), Some(Vec::new()), "no other sidecar"); + assert_eq!( + found(Some(&other)), + Some(Vec::new()), + "the other sidecar fails" + ); + } + + fn at(uri: &str, line: u32) -> Option { + Some(Location { + uri: Uri::from_str(uri).ok()?, + range: Range::new(Position::new(line, 0), Position::new(line, 4)), + }) + } + + /// Each side's answers once, the primary's first. [REFERENCES-CROSS-LANGUAGE] + #[test] + fn merged_keeps_the_primary_order_and_drops_duplicates() { + let primary: Vec = [ + at("file:///a/Leaders.razor", 6), + at("file:///a/Scores.fs", 3), + ] + .into_iter() + .flatten() + .collect(); + let foreign: Vec = [at("file:///a/Scores.fs", 3), at("file:///a/Scores.fs", 9)] + .into_iter() + .flatten() + .collect(); + let all = merged(primary.clone(), foreign.clone()); + assert_eq!(all.len(), 3); + assert_eq!(all.first(), primary.first()); + assert_eq!(all.last(), foreign.last()); + assert_eq!(merged(Vec::new(), foreign.clone()), foreign); + } + + /// The wire request is positional, in the sidecars' key order. + #[test] + fn foreign_symbol_request_is_positional() { + let request = ForeignSymbolReq { + assembly_name: "Domain", + xml_doc_sig: "T:Domain.Player", + include_declaration: true, + }; + let decoded: Option<(String, String, bool)> = rmp_serde::to_vec(&request) + .ok() + .and_then(|bytes| rmp_serde::from_slice(&bytes).ok()); + assert_eq!( + decoded, + Some(("Domain".into(), "T:Domain.Player".into(), true)) + ); + } +} diff --git a/src/sharplsp/src/main.rs b/src/sharplsp/src/main.rs index 99ba9ab8..0a710ab9 100644 --- a/src/sharplsp/src/main.rs +++ b/src/sharplsp/src/main.rs @@ -8,6 +8,7 @@ mod code_lens; mod config; mod config_debug; mod configuration; +mod cross_language; mod diagnostics; mod document_symbols; mod folding; @@ -810,8 +811,9 @@ fn handle_request( // than a syntax tree can give). The other syntax features — // foldingRange, selectionRange, linkedEditingRange — run on the F# // tree-sitter grammar for both languages. Razor has no grammar, so the - // C# sidecar answers its syntax requests, and selection ranges are not - // served yet. [SHARPLSP-FEATURES-NAVIGATION] [RAZOR-ARCHITECTURE-ROUTING] + // C# sidecar answers every one of its syntax requests, selection + // ranges chained out to the whole document. + // [SHARPLSP-FEATURES-NAVIGATION] [RAZOR-ARCHITECTURE-ROUTING] DocumentSymbolRequest::METHOD => match request_lang(&req) { Some(LangId::FSharp | LangId::Razor) => { let sidecar = pick_sidecar(&req, csharp_sidecar, fsharp_sidecar); @@ -823,7 +825,7 @@ fn handle_request( razor::handle_folding_ranges(req, runtime, csharp_sidecar) } SelectionRangeRequest::METHOD if request_lang(&req) == Some(LangId::Razor) => { - Ok(serde_json::json!([])) + razor::handle_selection_ranges(req, runtime, csharp_sidecar) } LinkedEditingRange::METHOD if request_lang(&req) == Some(LangId::Razor) => { razor::handle_linked_editing_range(req, runtime, csharp_sidecar) diff --git a/src/sharplsp/src/razor.rs b/src/sharplsp/src/razor.rs index 8a4099a8..46bc5107 100644 --- a/src/sharplsp/src/razor.rs +++ b/src/sharplsp/src/razor.rs @@ -1,7 +1,8 @@ //! Razor documents (`.razor`, `.cshtml`) in the host. //! //! The host has no Razor grammar, so the syntax requests it answers from -//! tree-sitter for C# go to the C# sidecar for Razor, whose Razor compiler is +//! tree-sitter for C# — documentSymbol, foldingRange, selectionRange and +//! linkedEditingRange — go to the C# sidecar for Razor, whose Razor compiler is //! the parser. The two Razor completion triggers are useless in C# and F# //! documents, so those answer at once. Implements [RAZOR-ARCHITECTURE-ROUTING]. @@ -11,7 +12,7 @@ use anyhow::Result; use lsp_server::Request; use lsp_types::{ CompletionParams, FoldingRange, FoldingRangeParams, LinkedEditingRangeParams, - LinkedEditingRanges, Position, Range, TextDocumentIdentifier, Uri, + LinkedEditingRanges, Position, Range, SelectionRangeParams, TextDocumentIdentifier, Uri, }; use crate::semantic::SidecarLocationListResult; @@ -129,6 +130,70 @@ fn linked_ranges(found: &SidecarLocationListResult) -> Result }) } +/// Handle `textDocument/selectionRange` for a Razor document via the C# sidecar: +/// each position's chain of Razor ranges, innermost first, nested into LSP +/// parents. Implements [RAZOR-FEATURES-SELECTION]. +pub fn handle_selection_ranges( + req: Request, + runtime: &tokio::runtime::Runtime, + sidecar: Option<&Arc>, +) -> Result { + let params: SelectionRangeParams = serde_json::from_value(req.params)?; + let request = SidecarSelectionRangesReq { + file_path: crate::paths::uri_to_path(params.text_document.uri.as_str())?, + positions: params + .positions + .iter() + .map(|at| (at.line, at.character)) + .collect(), + }; + let chains: Vec = + fetch(runtime, sidecar, "textDocument/selectionRange", &request)?; + Ok(serde_json::to_value(selection_ranges(&chains))?) +} + +/// The host's request for a Razor file's selection ranges: the file and its +/// carets, matching the sidecar's `SelectionRangeRequest` Key layout. +#[derive(serde::Serialize)] +struct SidecarSelectionRangesReq { + /// Absolute path of the Razor file. + file_path: String, + /// The carets, in request order. + positions: Vec<(u32, u32)>, +} + +/// One position's answer: its Razor ranges, innermost first. +#[derive(serde::Deserialize)] +struct SidecarRangeList { + /// The chain, innermost first, as `(start_line, start_character, end_line, + /// end_character)`. + ranges: Vec<(u32, u32, u32, u32)>, +} + +/// The sidecar's chains as LSP selection ranges, one per position. LSP 3.17 +/// types the answer `SelectionRange[] | null`, so a position with no chain — a +/// file no project lists — makes the whole answer none. +fn selection_ranges(chains: &[SidecarRangeList]) -> Option> { + chains + .iter() + .map(|chain| nest(chain.ranges.as_slice())) + .collect() +} + +/// Ranges innermost first as one [`lsp_types::SelectionRange`], each range its +/// successor's `parent`; none when the chain is empty. +fn nest(ranges: &[(u32, u32, u32, u32)]) -> Option { + ranges.iter().rev().fold(None, |parent, range| { + Some(lsp_types::SelectionRange { + range: Range::new( + Position::new(range.0, range.1), + Position::new(range.2, range.3), + ), + parent: parent.map(Box::new), + }) + }) +} + /// Send one request to the sidecar that owns Razor and decode its answer. fn fetch( runtime: &tokio::runtime::Runtime, @@ -268,4 +333,38 @@ mod tests { assert_eq!((fold.start_character, fold.end_character), (None, None)); assert!(fold.kind.is_none()); } + + /// A sidecar chain nests into LSP parents, innermost first; an empty chain + /// answers none. [RAZOR-FEATURES-SELECTION] + #[test] + fn selection_range_chains_nest_innermost_first() { + let nested = selection_ranges(&[SidecarRangeList { + ranges: vec![(1, 2, 1, 8), (1, 0, 3, 4)], + }]); + let inner = nested + .as_ref() + .and_then(|chains| chains.first()) + .expect("the innermost range"); + assert_eq!( + inner.range, + Range::new(Position::new(1, 2), Position::new(1, 8)) + ); + let outer = inner.parent.as_ref().expect("the outer range"); + assert_eq!( + outer.range, + Range::new(Position::new(1, 0), Position::new(3, 4)) + ); + assert!(outer.parent.is_none(), "the document has no parent"); + + let empty = selection_ranges(&[ + SidecarRangeList { + ranges: vec![(0, 0, 0, 1)], + }, + SidecarRangeList { ranges: vec![] }, + ]); + assert_eq!( + empty, None, + "a position with no chain makes the whole answer none" + ); + } } diff --git a/src/sharplsp/src/semantic.rs b/src/sharplsp/src/semantic.rs index 5e015293..8fc265b3 100644 --- a/src/sharplsp/src/semantic.rs +++ b/src/sharplsp/src/semantic.rs @@ -297,9 +297,9 @@ pub fn handle_implementation( fallback: Option<&Arc>, ) -> Result { let method = "textDocument/implementation"; - let value = handle_multi_location_nav(req.clone(), runtime, sidecar, method)?; + let (value, _) = handle_multi_location_nav(req.clone(), runtime, (sidecar, fallback), method)?; Ok(or_fallback(value, fallback, method, |fb| { - handle_multi_location_nav(req, runtime, Some(fb), method) + handle_multi_location_nav(req, runtime, (Some(fb), None), method).map(|(value, _)| value) })) } @@ -319,12 +319,12 @@ pub fn handle_references( "textDocument/references:nodecl" }; cached_at_position(req, vfs, nav_cache, cache_method, |req| { - let value = handle_references_nav(req.clone(), runtime, sidecar)?; + let value = handle_references_nav(req.clone(), runtime, (sidecar, fallback))?; Ok(or_fallback( value, fallback, "textDocument/references", - |fb| handle_references_nav(req, runtime, Some(fb)), + |fb| handle_references_nav(req, runtime, (Some(fb), None)), )) }) } @@ -393,10 +393,13 @@ fn to_document_highlight(h: &SidecarDocumentHighlightResult) -> DocumentHighligh } /// Inner handler for references (serializes `ReferencesRequest` with `include_declaration`). +/// References from the sidecar that owns the document, merged with the other +/// language's uses of the same symbol, found by its identity. +/// [REFERENCES-CROSS-LANGUAGE] fn handle_references_nav( req: Request, runtime: &tokio::runtime::Runtime, - sidecar: Option<&Arc>, + (sidecar, other): NavRoute<'_>, ) -> Result { with_sidecar(req, sidecar, |sidecar, params: ReferenceParams| { let at = SidecarPositionReq::at( @@ -412,7 +415,7 @@ fn handle_references_nav( "References request dispatching to sidecar" ); let request = SidecarReferencesReq { - file_path: at.file_path, + file_path: at.file_path.clone(), line: at.line, character: at.character, include_declaration, @@ -423,12 +426,21 @@ fn handle_references_nav( else { return Ok(serde_json::Value::Null); }; - let locations: Vec = result + let own: Vec = result .locations .iter() .filter_map(sidecar_location_to_lsp) .collect(); - Ok(serde_json::to_value(locations)?) + let foreign = crate::cross_language::foreign_references( + runtime, + sidecar, + other, + &at, + include_declaration, + ); + Ok(serde_json::to_value(crate::cross_language::merged( + own, foreign, + ))?) }) } @@ -440,9 +452,9 @@ fn handle_references_nav( fn dispatch_position_request( req: Request, runtime: &tokio::runtime::Runtime, - sidecar: Option<&Arc>, + (sidecar, foreign): NavRoute<'_>, method: &str, -) -> Result> { +) -> Result> { let Some(sidecar) = sidecar else { debug!("{method}: no sidecar available"); return Ok(None); @@ -457,19 +469,28 @@ fn dispatch_position_request( character = request.character, "request dispatching to sidecar" ); - request_sidecar(runtime, sidecar, method, &request) + let found: Option = + request_sidecar(runtime, sidecar, method, &request)?; + Ok(found.map(|result| crate::cross_language::in_fsharp_source(runtime, foreign, result))) } +/// The sidecar a navigation request goes to, and the F# sidecar that places +/// the F# symbols its decompiled answers show. [DEFINITION-CROSSLANG-IDENTITY] +type NavRoute<'a> = ( + Option<&'a Arc>, + Option<&'a Arc>, +); + /// Shared handler for multi-location navigation requests /// (definition, implementation). fn handle_multi_location_nav( req: Request, runtime: &tokio::runtime::Runtime, - sidecar: Option<&Arc>, + route: NavRoute<'_>, method: &str, -) -> Result { - let Some(result) = dispatch_position_request(req, runtime, sidecar, method)? else { - return Ok(serde_json::Value::Null); +) -> Result<(serde_json::Value, bool)> { + let Some((result, settled)) = dispatch_position_request(req, runtime, route, method)? else { + return Ok((serde_json::Value::Null, true)); }; let locations: Vec = result .locations @@ -478,7 +499,7 @@ fn handle_multi_location_nav( .collect(); let response = (!locations.is_empty()).then(|| GotoDefinitionResponse::Array(locations)); - Ok(serde_json::to_value(response)?) + Ok((serde_json::to_value(response)?, settled)) } /// Cached navigation with cross-language fallback. @@ -501,9 +522,18 @@ fn handle_cached_nav_with_fallback( method: &str, multi: bool, ) -> Result { - let value = handle_cached_nav(req.clone(), vfs, nav_cache, runtime, sidecar, method, multi)?; + let route = (sidecar, fallback); + let value = handle_cached_nav(req.clone(), vfs, nav_cache, runtime, route, method, multi)?; Ok(or_fallback(value, fallback, method, |fb| { - handle_cached_nav(req, vfs, nav_cache, runtime, Some(fb), method, multi) + handle_cached_nav( + req, + vfs, + nav_cache, + runtime, + (Some(fb), None), + method, + multi, + ) })) } @@ -542,6 +572,22 @@ fn cached_at_position( nav_cache: &mut crate::nav_cache::NavCache, method: &str, dispatch: impl FnOnce(Request) -> Result, +) -> Result { + cached_at_position_when_settled(req, vfs, nav_cache, method, |req| { + dispatch(req).map(|value| (value, true)) + }) +} + +/// [`cached_at_position`] for an answer that may not be settled yet: one that +/// still shows an F# symbol in decompiled source, because the F# sidecar could +/// not place it yet, is answered but not remembered, so the next request asks +/// again. [DEFINITION-CROSSLANG-IDENTITY] +fn cached_at_position_when_settled( + req: Request, + vfs: &crate::vfs::Vfs, + nav_cache: &mut crate::nav_cache::NavCache, + method: &str, + dispatch: impl FnOnce(Request) -> Result<(serde_json::Value, bool)>, ) -> Result { let at: TextDocumentPositionParams = serde_json::from_value(req.params.clone())?; let uri = at.text_document.uri.as_str(); @@ -550,8 +596,10 @@ fn cached_at_position( if let Some(cached) = cached_nav_hit(nav_cache, uri, version, line, character, method, method) { return Ok(cached); } - let value = dispatch(req)?; - nav_cache.insert(uri, version, line, character, method, value.clone()); + let (value, settled) = dispatch(req)?; + if settled { + nav_cache.insert(uri, version, line, character, method, value.clone()); + } Ok(value) } @@ -581,15 +629,15 @@ fn handle_cached_nav( vfs: &crate::vfs::Vfs, nav_cache: &mut crate::nav_cache::NavCache, runtime: &tokio::runtime::Runtime, - sidecar: Option<&Arc>, + route: NavRoute<'_>, method: &str, multi: bool, ) -> Result { - cached_at_position(req, vfs, nav_cache, method, |req| { + cached_at_position_when_settled(req, vfs, nav_cache, method, |req| { if multi { - handle_multi_location_nav(req, runtime, sidecar, method) + handle_multi_location_nav(req, runtime, route, method) } else { - handle_single_location_nav(req, runtime, sidecar, method) + handle_single_location_nav(req, runtime, route, method) } }) } @@ -599,22 +647,22 @@ fn handle_cached_nav( fn handle_single_location_nav( req: Request, runtime: &tokio::runtime::Runtime, - sidecar: Option<&Arc>, + route: NavRoute<'_>, method: &str, -) -> Result { - let Some(result) = dispatch_position_request(req, runtime, sidecar, method)? else { - return Ok(serde_json::Value::Null); +) -> Result<(serde_json::Value, bool)> { + let Some((result, settled)) = dispatch_position_request(req, runtime, route, method)? else { + return Ok((serde_json::Value::Null, true)); }; let response = result.locations.first().and_then(|loc| { let location = sidecar_location_to_lsp(loc)?; Some(GotoDefinitionResponse::Scalar(location)) }); - Ok(serde_json::to_value(response)?) + Ok((serde_json::to_value(response)?, settled)) } /// Convert a sidecar `LocationResult` to an LSP `Location`. -fn sidecar_location_to_lsp(loc: &SidecarLocationResult) -> Option { +pub(crate) fn sidecar_location_to_lsp(loc: &SidecarLocationResult) -> Option { let uri: Uri = path_to_lsp_uri(&loc.file_path).ok()?; Some(Location { uri, @@ -658,6 +706,7 @@ fn map_completion_kind(tag: &str) -> CompletionItemKind { "Constant" => CompletionItemKind::CONSTANT, "Delegate" => CompletionItemKind::FUNCTION, "TypeParameter" => CompletionItemKind::TYPE_PARAMETER, + "Value" => CompletionItemKind::VALUE, _ => CompletionItemKind::TEXT, } } @@ -776,6 +825,10 @@ pub(crate) struct SidecarLocationResult { pub(crate) end_line: u32, /// End character of the target range. pub(crate) end_character: u32, + /// For decompiled source of an F# project's symbol, the symbol's identity, + /// which the F# sidecar places. [DEFINITION-CROSSLANG-IDENTITY] + #[serde(default)] + pub(crate) symbol: Option, } /// Sidecar response containing a list of locations. @@ -956,7 +1009,7 @@ fn foreign_rename_edits( } /// Check whether the other-language sidecar has a project that can contain references. -fn sidecar_workspace_loaded( +pub(crate) fn sidecar_workspace_loaded( runtime: &tokio::runtime::Runtime, sidecar: &Arc, ) -> Result { @@ -997,7 +1050,18 @@ fn request_rename_identity( line: rename.line, character: rename.character, }; - let payload = rmp_serde::to_vec(&request)?; + request_symbol_identity(runtime, sidecar, &request) +} + +/// Ask the sidecar that owns `at`'s document for the identity of the symbol +/// there: a C# source symbol, an F# one, or an F# project's symbol that C# or +/// Razor reads from its DLL. [DEFINITION-CROSSLANG-IDENTITY] +pub(crate) fn request_symbol_identity( + runtime: &tokio::runtime::Runtime, + sidecar: &SidecarManager, + at: &SidecarPositionReq, +) -> Result { + let payload = rmp_serde::to_vec(at)?; let bytes = runtime.block_on(sidecar.request("textDocument/renameIdentity", payload))?; Ok(rmp_serde::from_slice(&bytes)?) } @@ -1211,13 +1275,13 @@ struct SidecarRenameRequest { /// Portable metadata identity returned by the symbol's owning sidecar. #[derive(serde::Deserialize)] -struct SidecarRenameIdentityResult { +pub(crate) struct SidecarRenameIdentityResult { /// Whether the source position resolved to a cross-language-visible symbol. - found: bool, + pub(crate) found: bool, /// Simple name of the assembly that owns the symbol. - assembly_name: String, + pub(crate) assembly_name: String, /// Standard .NET XML documentation signature for the symbol. - xml_doc_sig: String, + pub(crate) xml_doc_sig: String, } /// Request for references to a metadata symbol owned by the other sidecar. @@ -1281,6 +1345,10 @@ struct SidecarDocumentEditResult { file_path: String, /// Text edits to apply. edits: Vec, + /// Whether the edit creates the file: decoded to keep the positional wire + /// shape, since a rename never creates one. + #[serde(default)] + _create: bool, } /// A workspace-wide set of edits from the sidecar rename operation. @@ -1408,6 +1476,7 @@ mod rename_merge_tests { SidecarDocumentEditResult { file_path: path.to_string(), edits, + _create: false, } } diff --git a/src/sharplsp/tests/e2e_modules/definition_cross_language.rs b/src/sharplsp/tests/e2e_modules/definition_cross_language.rs index f58e1902..63e0fd5f 100644 --- a/src/sharplsp/tests/e2e_modules/definition_cross_language.rs +++ b/src/sharplsp/tests/e2e_modules/definition_cross_language.rs @@ -1,11 +1,11 @@ //! Full-stack cross-language go-to-definition: C# ↔ F#. //! //! A C# project and an F# project reference each other across the language -//! boundary. Each sidecar sees the *other* language only as a compiled -//! assembly, so navigating from a use site into the other-language definition -//! resolves through metadata-as-source (the referenced type is decompiled to a -//! navigable location) — the same behavior mature IDEs give for a compiled -//! cross-language reference. Implements [DEFINITION-CROSSLANG]. +//! boundary. Navigating from a use site into the other-language definition +//! lands in its source: F# compiles the C# project in memory, and C# names the +//! F# symbol it reads from the DLL by its identity, which the F# sidecar +//! places in the `.fs` file. Implements [DEFINITION-CROSSLANG] and +//! [DEFINITION-CROSSLANG-IDENTITY]. use super::*; @@ -51,8 +51,8 @@ fn poll_cross_language_definition( // ── C# → F# ────────────────────────────────────────────────────────── -// FULL-STACK: go-to-definition from C# onto an F#-defined type resolves into -// the F# assembly (decompiled metadata-as-source), not the C# consumer file. +// FULL-STACK: go-to-definition from C# onto an F#-defined type lands on its +// declaration in the F# source, not in decompiled source or the C# consumer. #[test] fn test_cross_language_definition_csharp_to_fsharp() { require_dotnet(); @@ -81,6 +81,54 @@ fn test_cross_language_definition_csharp_to_fsharp() { "C#→F# definition must land on the F#-defined `Shape` type, got: {uri}" ); + // Once the F# sidecar has loaded, the answer is the F# declaration: the + // decompiled answer before it is never cached. [DEFINITION-CROSSLANG-IDENTITY] + let source = poll_cross_language_definition( + &mut client, + &file_uri, + 8, + 22, + ".cs", + Duration::from_mins(3), + ); + let uri = source["uri"].as_str().unwrap(); + assert!( + uri.ends_with("/Shapes.fs"), + "C#→F# lands in the F# source, got: {uri}" + ); + // `type Shape(name: string, sides: int) =` (line 3) + // 0123456789 + assert_eq!( + source["range"]["start"], + json!({ "line": 3, "character": 5 }) + ); + assert_eq!( + source["range"]["end"], + json!({ "line": 3, "character": 10 }) + ); + + // With the F# sidecar loaded, a C# local has no cross-language identity, so + // its references are C#'s alone: the declaration and its one use. + // [REFERENCES-CROSS-LANGUAGE] + let local = references(&mut client, &file_uri, 8, 12, true); + let starts: Vec<(Value, Value)> = local["result"] + .as_array() + .map(|found| { + found + .iter() + .map(|at| (at["uri"].clone(), at["range"]["start"].clone())) + .collect() + }) + .unwrap_or_default(); + assert_eq!( + starts, + vec![ + (json!(file_uri), json!({ "line": 8, "character": 12 })), + (json!(file_uri), json!({ "line": 9, "character": 15 })), + ], + "the local's references stay in C#: {local}" + ); + client.shutdown_and_exit(); client.wait_with_timeout(); } diff --git a/src/sharplsp/tests/e2e_modules/fixtures_cross_language.rs b/src/sharplsp/tests/e2e_modules/fixtures_cross_language.rs index b3ad6a7c..5911ccec 100644 --- a/src/sharplsp/tests/e2e_modules/fixtures_cross_language.rs +++ b/src/sharplsp/tests/e2e_modules/fixtures_cross_language.rs @@ -18,7 +18,7 @@ use super::*; use std::path::Path; /// Run a `dotnet` subcommand in `cwd`, asserting success. -fn dotnet(args: &[&str], cwd: &Path) { +pub fn dotnet(args: &[&str], cwd: &Path) { let status = std::process::Command::new("dotnet") .args(args) .current_dir(cwd) diff --git a/src/sharplsp/tests/e2e_modules/fixtures_razor.rs b/src/sharplsp/tests/e2e_modules/fixtures_razor.rs index 3cdd4d0a..636cc169 100644 --- a/src/sharplsp/tests/e2e_modules/fixtures_razor.rs +++ b/src/sharplsp/tests/e2e_modules/fixtures_razor.rs @@ -44,6 +44,24 @@ pub fn create_razor_workspace() -> RazorWorkspace { create_razor_workspace_with_pin(true) } +/// [`create_razor_workspace`] with the F# `Domain` library built: C# reads an F# +/// project only as its built DLL, so a C# or Razor use of an F# symbol resolves +/// only once that DLL exists. [DEFINITION-CROSSLANG] [RAZOR-FSHARP] +pub fn create_razor_workspace_with_fsharp_built() -> RazorWorkspace { + let ws = create_razor_workspace(); + super::fixtures_cross_language::dotnet( + &[ + "build", + "Domain/Domain.fsproj", + "--verbosity", + "quiet", + "--nologo", + ], + ws.root(), + ); + ws +} + /// Restore under the newest installed SDK to catch compiler/sidecar version skew. /// [RAZOR-ARCHITECTURE-GENERATOR] pub fn create_razor_workspace_with_latest_sdk() -> RazorWorkspace { diff --git a/src/sharplsp/tests/e2e_modules/mod.rs b/src/sharplsp/tests/e2e_modules/mod.rs index 40451186..83dad1d7 100644 --- a/src/sharplsp/tests/e2e_modules/mod.rs +++ b/src/sharplsp/tests/e2e_modules/mod.rs @@ -68,6 +68,11 @@ pub mod nuget_unused_full_stack; pub mod profiler_shared; pub mod pull_diagnostics; pub mod razor; +pub mod razor_collocation; +pub mod razor_fsharp; +pub mod razor_hierarchy; +pub mod razor_mvc; +pub mod razor_selection; pub mod references; pub mod selection; pub mod semantic_coverage; diff --git a/src/sharplsp/tests/e2e_modules/razor.rs b/src/sharplsp/tests/e2e_modules/razor.rs index 9d7df02f..93187e35 100644 --- a/src/sharplsp/tests/e2e_modules/razor.rs +++ b/src/sharplsp/tests/e2e_modules/razor.rs @@ -81,8 +81,8 @@ fn assert_latest_sdk_razor_hover(client: &mut LspClient, ws: &RazorWorkspace) { assert_eq!(hover["range"]["start"], json!({"line": 7, "character": 33})); } -/// documentSymbol, foldingRange and linkedEditingRange come from the sidecar's -/// Razor compiler; selectionRange answers empty. [RAZOR-FEATURES-STRUCTURE] +/// documentSymbol, foldingRange, linkedEditingRange and selectionRange come from +/// the sidecar's Razor compiler. [RAZOR-FEATURES-STRUCTURE] [RAZOR-FEATURES-SELECTION] fn assert_syntax_requests_reach_the_sidecar(client: &mut LspClient, board: &str) { let symbols = document_request(client, "textDocument/documentSymbol", board); assert_rpc_ok(&symbols, "documentSymbol(Board.razor)"); @@ -160,7 +160,19 @@ fn assert_syntax_requests_reach_the_sidecar(client: &mut LspClient, board: &str) json!({ "textDocument": { "uri": board }, "positions": [{ "line": 6, "character": 3 }] }), ); assert_rpc_ok(&selection, "selectionRange(Board.razor)"); - assert_eq!(selection["result"], json!([])); + assert_eq!( + selection["result"], + json!([{ + "range": { "start": { "line": 6, "character": 1 }, "end": { "line": 6, "character": 6 } }, + "parent": { + "range": { "start": { "line": 6, "character": 0 }, "end": { "line": 10, "character": 8 } }, + "parent": { + "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 26, "character": 0 } } + } + } + }]), + "the tag name, then its element, then the document [RAZOR-FEATURES-SELECTION]" + ); } /// The HTML projection keeps markup at its own positions and blanks Razor; a @@ -372,8 +384,13 @@ fn assert_markup_completion_reaches_the_client(client: &mut LspClient, board: &s client.change_document(board, 8, text); } -/// The completion items at the end of `typed`, once Board holds it. -fn complete_after(client: &mut LspClient, board: &str, version: i32, typed: &str) -> Vec { +/// The completion items at the end of `typed`, once the Razor file holds it. +pub(super) fn complete_after( + client: &mut LspClient, + board: &str, + version: i32, + typed: &str, +) -> Vec { client.change_document(board, version, typed); let line = typed.lines().count() - 1; let character = typed @@ -389,7 +406,7 @@ fn complete_after(client: &mut LspClient, board: &str, version: i32, typed: &str "position": { "line": line, "character": character }, }), ); - assert_rpc_ok(&response, "completion(Board.razor, markup)"); + assert_rpc_ok(&response, "completion(Razor markup)"); let items = response["result"].as_array().cloned().unwrap_or_default(); if !items.is_empty() || std::time::Instant::now() > deadline { return items; diff --git a/src/sharplsp/tests/e2e_modules/razor_collocation.rs b/src/sharplsp/tests/e2e_modules/razor_collocation.rs new file mode 100644 index 00000000..a7ab340b --- /dev/null +++ b/src/sharplsp/tests/e2e_modules/razor_collocation.rs @@ -0,0 +1,175 @@ +//! Full-stack collocated files over the real fixture's `Card.razor`: its +//! `Card.razor.css` classes complete as LSP values inside a `class` value and +//! define to every selector that declares them, and an `"import"` call defines +//! to the start of the `Card.razor.js` module it names, from Razor and from C#. +//! [RAZOR-COLLOCATION] [RAZOR-COLLOCATION-CSS] [RAZOR-COLLOCATION-JS] + +use super::razor::complete_after; +use super::*; + +/// The component with a collocated stylesheet and module. +const CARD: &[&str] = &["BlazorApp", "Components", "Shared", "Card.razor"]; + +/// The component's scoped stylesheet. +const STYLESHEET: &[&str] = &["BlazorApp", "Components", "Shared", "Card.razor.css"]; + +/// Plain C# that imports the component's module. +const INTEROP: &[&str] = &["BlazorApp", "Components", "Shared", "CardInterop.cs"]; + +/// LSP `CompletionItemKind.Value`. +const VALUE: u64 = 12; + +#[test] +fn test_full_stack_razor_collocated_styles_and_scripts() { + require_dotnet(); + let ws = create_razor_workspace(); + let mut client = LspClient::start_verbose(); + let init = client.initialize_with_root(json!(path_to_file_uri(ws.root()))); + assert_rpc_ok(&init, "initialize the Razor fixture"); + + let card = ws.uri(CARD); + let text = ws.text(CARD); + client.open_document(&card, &text); + + assert_classes_define_to_their_selectors(&mut client, &ws, &card); + assert_modules_define_to_their_start(&mut client, &ws, &card); + assert_classes_complete_as_values(&mut client, &card, &text); + + client.shutdown_and_exit(); + client.wait_with_timeout(); +} + +/// `title` in `
` lands on the five selectors of `.title`, +/// each ranged on the name after its `.`. +fn assert_classes_define_to_their_selectors( + client: &mut LspClient, + ws: &RazorWorkspace, + card: &str, +) { + let first = poll_definition_until_ready(client, card, 4, 19, Duration::from_mins(3)); + assert!( + first["uri"] + .as_str() + .is_some_and(|uri| uri.ends_with("/Card.razor.css")), + "a class defines in the component's stylesheet: {first}" + ); + let all = definition(client, card, 4, 19); + assert_nav_ok(&all); + let locations = all["result"].as_array().cloned().unwrap_or_default(); + assert_eq!( + locations.len(), + 5, + "every selector of `.title`: {locations:?}" + ); + let stylesheet = ws.text(STYLESHEET); + let lines: Vec<&str> = stylesheet.lines().collect(); + for location in &locations { + assert!( + location["uri"] + .as_str() + .is_some_and(|uri| uri.ends_with("/Card.razor.css")), + "{location}" + ); + assert_eq!( + ranged_text(&lines, location), + "title", + "on the name: {location}" + ); + } +} + +/// The module literal in `@code` and in plain C# both define to the module's start. +fn assert_modules_define_to_their_start(client: &mut LspClient, ws: &RazorWorkspace, card: &str) { + let razor = definition(client, card, 22, 20); + assert_nav_ok(&razor); + assert_module_start(&razor["result"], "from the component's @code block"); + + let interop = ws.uri(INTEROP); + let source = ws.text(INTEROP); + client.open_document(&interop, &source); + let (line, character) = source + .lines() + .enumerate() + .find_map(|(line, text)| text.find("\"./Components").map(|at| (line, at + 4))) + .expect("CardInterop.cs imports the module"); + let csharp = definition( + client, + &interop, + u32::try_from(line).unwrap_or_default(), + u32::try_from(character).unwrap_or_default(), + ); + assert_nav_ok(&csharp); + assert_module_start(&csharp["result"], "from plain C#"); +} + +/// One location, at the very start of `Card.razor.js`. +fn assert_module_start(result: &Value, label: &str) { + let locations = result.as_array().cloned().unwrap_or_default(); + assert_eq!(locations.len(), 1, "{label}: one module: {result}"); + assert!( + locations[0]["uri"] + .as_str() + .is_some_and(|uri| uri.ends_with("/Card.razor.js")), + "{label}: the collocated module: {result}" + ); + assert_eq!( + locations[0]["range"], + json!({ "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } }), + "{label}: at its start" + ); +} + +/// After `
= items + .iter() + .filter(|item| item["label"] == "title") + .collect(); + assert_eq!(titles.len(), 1, "each class once: {items:?}"); + let title = titles[0]; + assert_eq!( + title["kind"], VALUE, + "a stylesheet class is a value: {title}" + ); + assert_eq!( + title["detail"], "Card.razor.css", + "naming its stylesheet: {title}" + ); + let line = typed.lines().count() - 1; + let end = typed + .lines() + .last() + .map_or(0, |last| last.encode_utf16().count()); + assert_eq!( + title["textEdit"]["range"], + json!({ + "start": { "line": line, "character": end - 2 }, + "end": { "line": line, "character": end } + }), + "replacing the class name at the caret: {title}" + ); + assert!( + items + .iter() + .any(|item| item["label"] == "card--grid" && item["kind"] == VALUE), + "a class inside nested at-rules completes too: {items:?}" + ); + client.change_document(card, 3, text); +} + +/// The text a single-line location covers in `lines`. +fn ranged_text<'a>(lines: &[&'a str], location: &Value) -> &'a str { + let at = |key: &str, field: &str| { + location["range"][key][field] + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or_default() + }; + lines + .get(at("start", "line")) + .and_then(|line| line.get(at("start", "character")..at("end", "character"))) + .unwrap_or_default() +} diff --git a/src/sharplsp/tests/e2e_modules/razor_fsharp.rs b/src/sharplsp/tests/e2e_modules/razor_fsharp.rs new file mode 100644 index 00000000..bf5feb79 --- /dev/null +++ b/src/sharplsp/tests/e2e_modules/razor_fsharp.rs @@ -0,0 +1,341 @@ +//! Full-stack F# in a Razor solution over the real fixture: `Leaders.razor` +//! uses the F# `Domain` library's record, union and module functions. The C# +//! sidecar reads Domain as its built DLL; the host finds each F# symbol in the +//! F# sidecar by its identity, so definition lands in `Scores.fs`, and +//! references and rename span F#, C# and Razor, from either side. +//! [RAZOR-FSHARP] [DEFINITION-CROSSLANG-IDENTITY] [REFERENCES-CROSS-LANGUAGE] + +use super::*; + +/// The page that uses the F# library. +const LEADERS: &[&str] = &["BlazorApp", "Components", "Pages", "Leaders.razor"]; + +/// The F# library's only file. +const SCORES: &[&str] = &["Domain", "Scores.fs"]; + +/// The zero-based position of `token` on the first line of `text` holding +/// `anchor`. The fixture is ASCII, so bytes are UTF-16 code units. +fn locate(text: &str, anchor: &str, token: &str) -> (u32, u32) { + let (line, content) = text + .lines() + .enumerate() + .find(|(_, content)| content.contains(anchor)) + .unwrap_or_else(|| panic!("missing anchor {anchor}")); + let character = content + .find(token) + .unwrap_or_else(|| panic!("missing token {token}")); + ( + u32::try_from(line).expect("line fits"), + u32::try_from(character).expect("character fits"), + ) +} + +/// The name position of every `.name` member use in `text`. +fn member_uses(text: &str, name: &str) -> Vec<(u32, u32)> { + let dotted = format!(".{name}"); + text.lines() + .enumerate() + .flat_map(|(line, content)| { + content + .match_indices(dotted.as_str()) + .map(move |(at, _)| (line, at + 1)) + .collect::>() + }) + .map(|(line, at)| { + ( + u32::try_from(line).expect("line fits"), + u32::try_from(at).expect("character fits"), + ) + }) + .collect() +} + +/// A location's file name and start. +fn placed(location: &Value) -> (String, u64, u64) { + let uri = location["uri"].as_str().unwrap_or_default(); + let name = uri.rsplit('/').next().unwrap_or_default().to_string(); + let start = &location["range"]["start"]; + ( + name, + start["line"].as_u64().unwrap_or(u64::MAX), + start["character"].as_u64().unwrap_or(u64::MAX), + ) +} + +/// A position in a named file, as [`placed`] reports it. +fn at(file: &str, (line, character): (u32, u32)) -> (String, u64, u64) { + (file.to_string(), u64::from(line), u64::from(character)) +} + +/// Poll definition at `position` until it lands in `Scores.fs`: until the F# +/// sidecar has loaded Domain, the C# sidecar's decompiled answer stands. +fn poll_fsharp_definition( + client: &mut LspClient, + uri: &str, + (line, character): (u32, u32), +) -> Value { + let deadline = std::time::Instant::now() + Duration::from_mins(3); + loop { + let response = definition(client, uri, line, character); + assert_nav_ok(&response); + let location = first_location(&response["result"]); + if location["uri"] + .as_str() + .is_some_and(|found| found.ends_with("Scores.fs")) + { + return location; + } + assert!( + std::time::Instant::now() < deadline, + "definition at {line}:{character} never reached Scores.fs: {response}" + ); + std::thread::sleep(Duration::from_secs(2)); + } +} + +#[test] +fn test_full_stack_fsharp_in_razor_session() { + require_dotnet(); + let ws = create_razor_workspace_with_fsharp_built(); + let mut client = LspClient::start_verbose(); + let init = client.initialize_with_root(json!(path_to_file_uri(ws.root()))); + assert_rpc_ok(&init, "initialize Razor fixture"); + let leaders = ws.uri(LEADERS); + let page = ws.text(LEADERS); + let scores = ws.text(SCORES); + client.open_document(&leaders, &page); + + // ── Hover: the F# record, with its F# documentation, from Domain's DLL ── + let (line, character) = locate(&page, "private readonly Player[]", "Player"); + let hover = poll_hover_until_ready( + &mut client, + &leaders, + line, + character, + Duration::from_mins(3), + ); + let markdown = hover["contents"]["value"].as_str().unwrap_or_default(); + assert!( + markdown.contains("A player on the leader board."), + "Player hover: {markdown}" + ); + assert_eq!( + hover["range"]["start"], + json!({ "line": line, "character": character }) + ); + + assert_definition_lands_in_fsharp_source(&mut client, &leaders, &page, &scores); + assert_references_span_fsharp_and_razor(&mut client, &leaders, &page, &scores); + assert_rename_from_razor_renames_fsharp(&mut client, &leaders, &page, &scores); + assert_rename_from_fsharp_renames_razor(&mut client, &ws, &page, &scores); + + client.shutdown_and_exit(); + client.wait_with_timeout(); +} + +/// Definition, type definition and declaration from Razor onto F# symbols land +/// on their F# declarations, never decompiled source. [DEFINITION-CROSSLANG-IDENTITY] +fn assert_definition_lands_in_fsharp_source( + client: &mut LspClient, + leaders: &str, + page: &str, + scores: &str, +) { + let player = poll_fsharp_definition( + client, + leaders, + locate(page, "private readonly Player[]", "Player"), + ); + assert_eq!( + placed(&player), + at("Scores.fs", locate(scores, "type Player", "Player")) + ); + assert_eq!( + player["range"]["end"]["character"].as_u64(), + Some(u64::from(locate(scores, "type Player", "Player").1) + 6), + "the range is the type's name" + ); + let score = poll_fsharp_definition(client, leaders, locate(page, "@player.Score", "Score")); + assert_eq!( + placed(&score), + at("Scores.fs", locate(scores, "Score: int", "Score")) + ); + let total = poll_fsharp_definition(client, leaders, locate(page, "@Scoring.total", "total")); + assert_eq!( + placed(&total), + at("Scores.fs", locate(scores, "let total", "total")) + ); + let constructed = + poll_fsharp_definition(client, leaders, locate(page, "[new Player(", "Player")); + assert_eq!( + placed(&constructed), + placed(&player), + "a record's constructor is its type" + ); + + let (line, character) = locate(page, "var player in players", "player"); + let type_definition = type_definition(client, leaders, line, character); + assert_nav_ok(&type_definition); + assert_eq!( + placed(&first_location(&type_definition["result"])), + placed(&player) + ); +} + +/// References from Razor on an F# record field hold its F# declaration and +/// every F# and Razor use; without the declaration, only the uses. +/// [REFERENCES-CROSS-LANGUAGE] [RAZOR-FSHARP-REFERENCES] +fn assert_references_span_fsharp_and_razor( + client: &mut LspClient, + leaders: &str, + page: &str, + scores: &str, +) { + let (line, character) = locate(page, "@player.Score", "Score"); + let mut expected: Vec<(String, u64, u64)> = member_uses(scores, "Score") + .into_iter() + .map(|position| at("Scores.fs", position)) + .chain([at("Leaders.razor", (line, character))]) + .collect(); + expected.sort(); + assert_eq!(expected.len(), 5, "four F# uses and one Razor use"); + + let uses = references(client, leaders, line, character, false); + assert_rpc_ok(&uses, "references(Score) without declaration"); + let mut found: Vec<_> = uses["result"] + .as_array() + .expect("locations") + .iter() + .map(placed) + .collect(); + found.sort(); + assert_eq!(found, expected); + + let all = references(client, leaders, line, character, true); + assert_rpc_ok(&all, "references(Score) with declaration"); + let mut with_declaration: Vec<_> = all["result"] + .as_array() + .expect("locations") + .iter() + .map(placed) + .collect(); + with_declaration.sort(); + expected.push(at("Scores.fs", locate(scores, "Score: int", "Score"))); + expected.sort(); + assert_eq!(with_declaration, expected); + assert!( + all["result"] + .as_array() + .expect("locations") + .iter() + .all(|location| { + location["uri"] + .as_str() + .is_some_and(|uri| !uri.ends_with(".g.cs")) + }), + "no generated file: {all}" + ); +} + +/// Renaming an F# record field from Razor renames its F# declaration and uses +/// and its Razor use, in one edit. [RAZOR-FSHARP-RENAME] +fn assert_rename_from_razor_renames_fsharp( + client: &mut LspClient, + leaders: &str, + page: &str, + scores: &str, +) { + let (line, character) = locate(page, "@player.Score", "Score"); + let prepared = client.request( + "textDocument/prepareRename", + json!({ "textDocument": { "uri": leaders }, "position": { "line": line, "character": character } }), + ); + assert_rpc_ok(&prepared, "prepareRename(Score) in Razor"); + assert_eq!(prepared["result"]["placeholder"], "Score"); + assert_eq!( + prepared["result"]["range"]["start"], + json!({ "line": line, "character": character }) + ); + + let renamed = rename_request(client, leaders, line, character, "Points"); + assert_rpc_ok(&renamed, "rename(Score) from Razor"); + let changes = renamed["result"]["documentChanges"] + .as_array() + .expect("documentChanges") + .clone(); + let razor = edits_for(&changes, "Leaders.razor"); + assert_eq!(razor.len(), 1, "the Razor use: {renamed}"); + assert_eq!( + razor[0]["range"]["start"], + json!({ "line": line, "character": character }) + ); + let fsharp = edits_for(&changes, "Scores.fs"); + assert_eq!( + fsharp.len(), + 5, + "the F# declaration and four uses: {renamed}" + ); + let applied = apply_text_edits(scores, &fsharp); + assert!( + applied.contains("Points: int }"), + "the field is renamed: {applied}" + ); + assert!( + !applied.contains(".Score"), + "every F# use is renamed: {applied}" + ); + assert!( + edits_for(&changes, ".g.cs").is_empty(), + "no generated file: {renamed}" + ); + assert!( + fsharp + .iter() + .chain(razor.iter()) + .all(|edit| edit["newText"] == "Points"), + "every edit renames to Points" + ); +} + +/// Renaming the F# field from its F# declaration renames the Razor use too. +/// [RAZOR-FSHARP-RENAME] [RENAME-CROSSLANGUAGE] +fn assert_rename_from_fsharp_renames_razor( + client: &mut LspClient, + ws: &RazorWorkspace, + page: &str, + scores: &str, +) { + let uri = ws.uri(SCORES); + client.open_document(&uri, scores); + let (line, character) = locate(scores, "Score: int", "Score"); + let deadline = std::time::Instant::now() + Duration::from_mins(3); + let changes = loop { + let renamed = rename_request(client, &uri, line, character, "Points"); + assert_rpc_ok(&renamed, "rename(Score) from F#"); + let changes = renamed["result"]["documentChanges"] + .as_array() + .cloned() + .unwrap_or_default(); + if !edits_for(&changes, "Leaders.razor").is_empty() { + break changes; + } + assert!( + std::time::Instant::now() < deadline, + "F# rename never reached Razor: {renamed}" + ); + std::thread::sleep(Duration::from_secs(2)); + }; + assert_eq!(edits_for(&changes, "Scores.fs").len(), 5); + let razor = edits_for(&changes, "Leaders.razor"); + assert_eq!(razor.len(), 1); + let (use_line, use_character) = locate(page, "@player.Score", "Score"); + assert_eq!( + razor[0]["range"]["start"], + json!({ "line": use_line, "character": use_character }) + ); + assert_eq!( + edits_for(&changes, ".g.cs"), + Vec::::new(), + "no edit touches a generated file" + ); +} diff --git a/src/sharplsp/tests/e2e_modules/razor_hierarchy.rs b/src/sharplsp/tests/e2e_modules/razor_hierarchy.rs new file mode 100644 index 00000000..68b572a3 --- /dev/null +++ b/src/sharplsp/tests/e2e_modules/razor_hierarchy.rs @@ -0,0 +1,191 @@ +//! Full-stack Razor inlay hints, code lenses, and call and type hierarchy over the real +//! fixture. Each runs on the Razor file's C# projection and answers in the file itself: +//! positions, items and call sites are Razor ranges, a component class is its own file. +//! [RAZOR-FEATURES-INLAY-HINTS] [RAZOR-FEATURES-HIERARCHY] [RAZOR-ARCHITECTURE-PROJECTION] + +use super::*; + +/// `Ticker.razor`: a component that inherits another, calls a plain C# method, and renders +/// markup that clicks its own handler. +const TICKER: &[&str] = &["BlazorApp", "Components", "Shared", "Ticker.razor"]; + +/// `Panel.razor`, the component `Ticker` inherits. +const PANEL: &[&str] = &["Widgets", "Panel.razor"]; + +#[test] +fn test_full_stack_razor_hierarchy_and_hints() { + require_dotnet(); + let ws = create_razor_workspace(); + let mut client = LspClient::start_verbose(); + let init = client.initialize_with_root(json!(path_to_file_uri(ws.root()))); + assert_rpc_ok(&init, "initialize the Razor fixture"); + + let ticker = ws.uri(TICKER); + client.open_document(&ticker, &ws.text(TICKER)); + + // ── Readiness: a hover inside the file's C# arrives through the projection ── + let hover = poll_hover_until_ready(&mut client, &ticker, 2, 24, Duration::from_mins(3)); + let markdown = hover["contents"]["value"].as_str().unwrap_or_default(); + assert!( + markdown.contains("Describe"), + "hover on `Describe` must show the method: {markdown}" + ); + + assert_call_hierarchy(&mut client, &ticker); + assert_type_hierarchy(&mut client, &ws, &ticker); + assert_inlay_hints(&mut client, &ticker); + + client.shutdown_and_exit(); + client.wait_with_timeout(); +} + +/// `prepareCallHierarchy` and `incomingCalls` from a Razor position: the item is the Razor +/// declaration, and the markup that clicks it is a caller in the same file. +fn assert_call_hierarchy(client: &mut LspClient, ticker: &str) { + let hit = prepared(client, "textDocument/prepareCallHierarchy", ticker, 8, 17); + assert_eq!(hit["name"], "Hit", "{hit}"); + assert_eq!(hit["kind"], 6, "a method: {hit}"); + assert_eq!(hit["uri"], json!(ticker), "{hit}"); + assert_eq!( + hit["range"], + json!({ + "start": { "line": 8, "character": 17 }, + "end": { "line": 8, "character": 20 }, + }), + "{hit}" + ); + + let incoming = client.request("callHierarchy/incomingCalls", json!({ "item": hit })); + assert_rpc_ok(&incoming, "incomingCalls(Hit)"); + let calls = incoming["result"].as_array().expect("incoming calls"); + assert_eq!(calls.len(), 1, "{incoming}"); + let call = &calls[0]; + assert_eq!( + call["from"]["name"], "Ticker.razor", + "the component's own markup calls it: {call}" + ); + assert_eq!(call["from"]["kind"], 5, "the component is a class: {call}"); + assert_eq!(call["from"]["uri"], json!(ticker), "{call}"); + assert_eq!( + call["from"]["selectionRange"]["start"], + json!({ "line": 0, "character": 0 }), + "a component is its file: {call}" + ); + assert_eq!( + call["fromRanges"], + json!([{ + "start": { "line": 3, "character": 18 }, + "end": { "line": 3, "character": 21 }, + }]), + "the click the markup writes: {call}" + ); +} + +/// `prepareTypeHierarchy` from `@inherits`, and its subtypes: the base component is its own +/// file, and the subtype is this one's. +fn assert_type_hierarchy(client: &mut LspClient, ws: &RazorWorkspace, ticker: &str) { + let panel = prepared(client, "textDocument/prepareTypeHierarchy", ticker, 0, 10); + assert_eq!(panel["name"], "Panel", "{panel}"); + assert_eq!(panel["kind"], 5, "a component is a class: {panel}"); + assert_eq!(panel["uri"], json!(ws.uri(PANEL)), "{panel}"); + assert_eq!( + panel["selectionRange"], + json!({ + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 }, + }), + "a component class is the start of its file: {panel}" + ); + + let subtypes = client.request("typeHierarchy/subtypes", json!({ "item": panel })); + assert_rpc_ok(&subtypes, "subtypes(Panel)"); + let items = subtypes["result"].as_array().expect("subtypes"); + assert_eq!(items.len(), 1, "{subtypes}"); + assert_eq!(items[0]["name"], "Ticker", "{subtypes}"); + assert_eq!(items[0]["uri"], json!(ticker), "{subtypes}"); +} + +/// `textDocument/inlayHint` over the file: the hints of its C# fragments sit at their Razor +/// positions, the generated builder's own arguments are dropped, and a range outside every +/// fragment answers none. +fn assert_inlay_hints(client: &mut LspClient, ticker: &str) { + let hints = inlay_hints(client, ticker, (0, 0), (18, 0)); + assert!( + hints.iter().any( + |hint| hint["position"] == json!({ "line": 10, "character": 11 }) + && hint["label"] == ": int" + && hint["kind"] == 1 + ), + "the type of a `var` in `@code`: {hints:?}" + ); + assert!( + hints.iter().any( + |hint| hint["position"] == json!({ "line": 10, "character": 33 }) + && hint["label"] == "by:" + && hint["kind"] == 2 + ), + "the name of the argument `Bump` takes: {hints:?}" + ); + assert!( + hints.iter().all(|hint| hint["position"]["line"] + .as_u64() + .is_some_and(|line| line <= 18)), + "every hint is in the file, not in its projection: {hints:?}" + ); + + assert_eq!( + inlay_hints(client, ticker, (4, 0), (4, 0)), + Vec::::new(), + "a blank line outside every C# fragment has no hints" + ); +} + +/// The hints the server answers over `range`, asserted well-formed. +fn inlay_hints( + client: &mut LspClient, + uri: &str, + start: (u64, u64), + end: (u64, u64), +) -> Vec { + let response = client.request( + "textDocument/inlayHint", + json!({ + "textDocument": { "uri": uri }, + "range": { + "start": { "line": start.0, "character": start.1 }, + "end": { "line": end.0, "character": end.1 }, + }, + }), + ); + assert_rpc_ok(&response, "inlayHint"); + let hints = response["result"] + .as_array() + .expect("an array of hints") + .clone(); + for hint in &hints { + assert!(hint["position"].is_object(), "a position: {hint}"); + assert!( + hint["label"] + .as_str() + .is_some_and(|label| !label.is_empty()), + "a label: {hint}" + ); + } + hints +} + +/// The single item `method` prepares at a position. +fn prepared(client: &mut LspClient, method: &str, uri: &str, line: u64, character: u64) -> Value { + let response = client.request( + method, + json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character }, + }), + ); + assert_rpc_ok(&response, method); + let none = Vec::new(); + let items = response["result"].as_array().unwrap_or(&none); + assert_eq!(items.len(), 1, "{method} prepares one item: {response}"); + items[0].clone() +} diff --git a/src/sharplsp/tests/e2e_modules/razor_mvc.rs b/src/sharplsp/tests/e2e_modules/razor_mvc.rs new file mode 100644 index 00000000..35a0a14d --- /dev/null +++ b/src/sharplsp/tests/e2e_modules/razor_mvc.rs @@ -0,0 +1,143 @@ +//! Full-stack Razor Pages and MVC views over the real fixture's `PagesApp`: +//! `@model`, `@inject` and `@functions` answer through the projection, tag +//! helper elements and attributes answer from their descriptors and complete +//! in scope, and a view resolves what its `_ViewImports.cshtml` imports. +//! [RAZOR-MVC] [RAZOR-MVC-DIRECTIVES] [RAZOR-MVC-TAGHELPERS] [RAZOR-MVC-IMPORTS] + +use super::razor::complete_after; +use super::*; + +/// The Razor Page with a model, an injection, tag helpers and `@functions`. +const EDIT: &[&str] = &["PagesApp", "Pages", "Edit.cshtml"]; + +/// The MVC view whose `@model` only `Views/_ViewImports.cshtml` resolves. +const INDEX: &[&str] = &["PagesApp", "Views", "Home", "Index.cshtml"]; + +#[test] +fn test_full_stack_razor_pages_and_mvc_views() { + require_dotnet(); + let ws = create_razor_workspace(); + let mut client = LspClient::start_verbose(); + let init = client.initialize_with_root(json!(path_to_file_uri(ws.root()))); + assert_rpc_ok(&init, "initialize the Razor fixture"); + + let edit = ws.uri(EDIT); + let text = ws.text(EDIT); + client.open_document(&edit, &text); + + assert_directives_answer_through_the_projection(&mut client, &edit); + assert_tag_helpers_answer_from_their_descriptors(&mut client, &edit, &text); + assert_view_imports_reach_the_view(&mut client, &ws); + + client.shutdown_and_exit(); + client.wait_with_timeout(); +} + +/// `@model`'s type, `@inject`'s property and a `@functions` method are C#. +fn assert_directives_answer_through_the_projection(client: &mut LspClient, edit: &str) { + let model = poll_hover_until_ready(client, edit, 1, 8, Duration::from_mins(3)); + let markdown = model["contents"]["value"].as_str().unwrap_or_default(); + assert!( + markdown.contains("Edits one item."), + "`@model EditModel` hovers the page model's summary: {markdown}" + ); + assert_eq!( + model["range"], + json!({ "start": { "line": 1, "character": 7 }, "end": { "line": 1, "character": 16 } }), + "ranged on the type name" + ); + + let log = hover(client, edit, 2, 28); + assert_hover_ok(&log); + let markdown = log["result"]["contents"]["value"] + .as_str() + .unwrap_or_default(); + assert!( + markdown.contains("ILogger<"), + "`@inject` declares a property of its type: {markdown}" + ); + + let shout = definition(client, edit, 10, 5); + assert_nav_ok(&shout); + let target = first_location(&shout["result"]); + assert_eq!( + target["uri"], + json!(edit), + "the method is the page's: {shout}" + ); + assert_location_line(&target, 13, "`@Shout` goes to its `@functions` declaration"); +} + +/// An element a tag helper binds, and the attribute it sets, hover from their +/// descriptors; tag helper elements and attributes complete in scope. +fn assert_tag_helpers_answer_from_their_descriptors( + client: &mut LspClient, + edit: &str, + text: &str, +) { + let input = hover(client, edit, 6, 6); + assert_hover_ok(&input); + let markdown = input["result"]["contents"]["value"] + .as_str() + .unwrap_or_default(); + assert!( + markdown.contains("InputTagHelper"), + "`` hovers its tag helper: {markdown}" + ); + + let title = definition(client, edit, 6, 20); + assert_nav_ok(&title); + let entries = location_entries(&title["result"]); + assert_has_location_in( + &entries, + "Edit.cshtml.cs", + "`asp-for=\"Title\"` names the model's property", + ); + + let elements = complete_after(client, edit, 2, &format!("{text}\n<")); + let labels: Vec<&str> = elements + .iter() + .filter_map(|item| item["label"].as_str()) + .collect(); + for expected in ["partial", "environment"] { + assert!( + labels.contains(&expected), + "`{expected}` completes after `<`: {labels:?}" + ); + } + assert_eq!( + labels.iter().filter(|label| **label == "a").count(), + 1, + "an element HTML also names completes once: {labels:?}" + ); + + let attributes = complete_after(client, edit, 3, &format!("{text}\n::new(), "the view compiles: {errors:?}"); +} diff --git a/src/sharplsp/tests/e2e_modules/razor_selection.rs b/src/sharplsp/tests/e2e_modules/razor_selection.rs new file mode 100644 index 00000000..a5eff9b8 --- /dev/null +++ b/src/sharplsp/tests/e2e_modules/razor_selection.rs @@ -0,0 +1,179 @@ +//! Full-stack Razor selection ranges over the real fixture: the host routes +//! `textDocument/selectionRange` to the C# sidecar, which answers each position's +//! chain from its innermost span out to the whole document, nested as LSP parents. +//! [RAZOR-FEATURES-SELECTION] + +use super::*; + +/// `Board.razor`, the fixture's richest component. +const BOARD: &[&str] = &["BlazorApp", "Components", "Pages", "Board.razor"]; + +/// The whole file's span: the fixture's files end with a line break. +fn document() -> Value { + json!({ + "start": {"line": 0, "character": 0}, + "end": {"line": 26, "character": 0} + }) +} + +#[test] +fn test_full_stack_razor_selection_ranges() { + require_dotnet(); + let ws = create_razor_workspace(); + let mut client = LspClient::start_verbose(); + let _init = client.initialize_with_root(json!(path_to_file_uri(ws.root()))); + + let board = ws.uri(BOARD); + client.open_document(&board, &ws.text(BOARD)); + + // ── A caret on the Tally tag name: tag → element → Panel → document ── + let chains = poll_selection_until_ready(&mut client, &board, 7, 5); + let tally = chains + .as_array() + .and_then(|all| all.first()) + .expect("one chain per position"); + let levels = selection_levels(tally); + assert!( + levels.len() >= 3, + "a tag name chains at least three levels: {levels:?}" + ); + assert_eq!( + levels[0], + json!({"start": {"line": 7, "character": 5}, "end": {"line": 7, "character": 10}}), + "the innermost range is the tag name: {levels:?}" + ); + assert_eq!( + levels[1], + json!({"start": {"line": 7, "character": 4}, "end": {"line": 9, "character": 12}}), + "then the element: {levels:?}" + ); + assert_eq!( + levels[2], + json!({"start": {"line": 6, "character": 0}, "end": {"line": 10, "character": 8}}), + "then the enclosing Panel: {levels:?}" + ); + assert_eq!( + levels.last(), + Some(&document()), + "the document closes the chain" + ); + assert!( + selection_levels(tally) + .last() + .and_then(|level| level.get("parent")) + .is_none(), + "the document has no parent: {tally}" + ); + assert_parents_hold(tally); + + // ── A caret inside a C# identifier in markup chains through the attribute ── + let answer = client.request( + "textDocument/selectionRange", + json!({ + "textDocument": { "uri": board }, + "positions": [ + { "line": 8, "character": 25 }, + { "line": 19, "character": 20 } + ] + }), + ); + assert_rpc_ok(&answer, "selectionRange(Board.razor, two carets)"); + let chains = answer["result"].as_array().expect("one chain per position"); + assert_eq!(chains.len(), 2, "one chain per position: {answer}"); + + let attribute = selection_levels(&chains[0]); + assert_eq!( + attribute[0], + json!({"start": {"line": 8, "character": 22}, "end": {"line": 8, "character": 29}}), + "the innermost range is the C# identifier in the value: {attribute:?}" + ); + assert!( + attribute.len() >= 5, + "the identifier chains through the attribute and its elements: {attribute:?}" + ); + assert_eq!( + attribute.last(), + Some(&document()), + "the document closes the chain" + ); + assert_parents_hold(&chains[0]); + + let field = selection_levels(&chains[1]); + assert_eq!( + field[0], + json!({"start": {"line": 19, "character": 19}, "end": {"line": 19, "character": 26}}), + "the innermost range is the field's identifier: {field:?}" + ); + assert!(field.len() >= 4, "the identifier chains outward: {field:?}"); + assert_eq!( + field.last(), + Some(&document()), + "the document closes the chain" + ); + assert_parents_hold(&chains[1]); + + client.shutdown_and_exit(); + client.wait_with_timeout(); +} + +/// Ask for `textDocument/selectionRange` until the sidecar has loaded the +/// workspace and answers a chain with a parent, then return the chains. +fn poll_selection_until_ready( + client: &mut LspClient, + uri: &str, + line: u32, + character: u32, +) -> Value { + let deadline = std::time::Instant::now() + Duration::from_mins(3); + loop { + let response = client.request( + "textDocument/selectionRange", + json!({ + "textDocument": { "uri": uri }, + "positions": [{ "line": line, "character": character }] + }), + ); + assert_rpc_ok(&response, "selectionRange(Board.razor)"); + let chains = response["result"].as_array().cloned().unwrap_or_default(); + if chains + .first() + .is_some_and(|chain| chain["parent"].is_object()) + { + return Value::Array(chains); + } + assert!( + std::time::Instant::now() < deadline, + "selection ranges never nested within the deadline: {response}" + ); + std::thread::sleep(Duration::from_secs(2)); + } +} + +/// The chain as its ranges, innermost first. +fn selection_levels(chain: &Value) -> Vec { + let mut levels = Vec::new(); + let mut at = Some(chain); + while let Some(item) = at { + levels.push(item["range"].clone()); + at = item.get("parent").filter(|parent| parent.is_object()); + } + levels +} + +/// A `line`/`character` object as its numbers, so positions compare. +fn point(value: &Value) -> (u64, u64) { + ( + value["line"].as_u64().unwrap_or_default(), + value["character"].as_u64().unwrap_or_default(), + ) +} + +/// Every level holds the one before it, so an editor can grow the selection. +fn assert_parents_hold(chain: &Value) { + let levels = selection_levels(chain); + for (inner, outer) in levels.iter().zip(levels.iter().skip(1)) { + let (start, end) = (point(&inner["start"]), point(&inner["end"])); + let holds = point(&outer["start"]) <= start && point(&outer["end"]) >= end; + assert!(holds, "each parent must hold its child: {inner} in {outer}"); + } +} diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/App.razor b/src/sharplsp/tests/fixtures/blazor-wasm/App.razor new file mode 100644 index 00000000..e6585e93 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/App.razor @@ -0,0 +1,9 @@ +@* The root component the host mounts into `#app`. [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM] *@ + + + + + +

Sorry, there is nothing at this address.

+
+
diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/BlazorWasmApp.csproj b/src/sharplsp/tests/fixtures/blazor-wasm/BlazorWasmApp.csproj new file mode 100644 index 00000000..a90cdc56 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/BlazorWasmApp.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + BlazorWasmApp + enable + enable + + + + + + + + diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.props b/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.props new file mode 100644 index 00000000..d320e322 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.props @@ -0,0 +1,6 @@ + + + diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.targets b/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.targets new file mode 100644 index 00000000..df1b2315 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.targets @@ -0,0 +1,4 @@ + + + diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/Pages/Counter.razor b/src/sharplsp/tests/fixtures/blazor-wasm/Pages/Counter.razor new file mode 100644 index 00000000..f342a792 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/Pages/Counter.razor @@ -0,0 +1,14 @@ +@page "/counter" + +@* The one page: enough that a breakpoint in a Razor file has something to hit. *@ +

Counter

+ +

Current count: @currentCount

+ + + +@code { + private int currentCount; + + private void Increment() => currentCount++; +} diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/Program.cs b/src/sharplsp/tests/fixtures/blazor-wasm/Program.cs new file mode 100644 index 00000000..d6cf22a5 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/Program.cs @@ -0,0 +1,11 @@ +using BlazorWasmApp; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +// The template's entry point: build the WebAssembly host in the browser, then let it +// take over the page. [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM] +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +await builder.Build().RunAsync(); diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/Properties/launchSettings.json b/src/sharplsp/tests/fixtures/blazor-wasm/Properties/launchSettings.json new file mode 100644 index 00000000..ba7323af --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://127.0.0.1:0", + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}" + } + } +} diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/_Imports.razor b/src/sharplsp/tests/fixtures/blazor-wasm/_Imports.razor new file mode 100644 index 00000000..5f14b71b --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/_Imports.razor @@ -0,0 +1,5 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using BlazorWasmApp +@using BlazorWasmApp.Pages diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/wwwroot/index.html b/src/sharplsp/tests/fixtures/blazor-wasm/wwwroot/index.html new file mode 100644 index 00000000..069ab110 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/wwwroot/index.html @@ -0,0 +1,23 @@ + + + + + + + + BlazorWasmApp + + + +
+

Loading…

+
+ +
+ An unhandled error has occurred. +
+ + + + + diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor index 46d61fd9..cb4e91c8 100644 --- a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor @@ -4,11 +4,13 @@
    @foreach (var player in players) { -
  • @player.Name: @player.Score
  • +
  • @player.Name: @player.Score (@Describe(Scoring.standing(players, player)))
  • }

Total: @Scoring.total(players)

@code { private readonly Player[] players = [new Player("Ada", 3), new Player("Grace", 5)]; + + private static string Describe(Standing standing) => standing.IsLeading ? "leading" : "chasing"; } diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor new file mode 100644 index 00000000..fb14970c --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor @@ -0,0 +1,36 @@ +@using Microsoft.JSInterop +@inject IJSRuntime JS +@implements IAsyncDisposable + +
+ @Title +
+ +@code { + private IJSObjectReference? module; + + /// The card's title. + [Parameter] + public string Title { get; set; } = ""; + + /// Loads the card's collocated JavaScript module. + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + module = await JS.InvokeAsync( + "import", + "./Components/Shared/Card.razor.js" + ); + } + } + + /// Releases the collocated module. + public async ValueTask DisposeAsync() + { + if (module is not null) + { + await module.DisposeAsync(); + } + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.cs b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.cs new file mode 100644 index 00000000..a4d06b5f --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.cs @@ -0,0 +1,28 @@ +using Microsoft.JSInterop; + +namespace BlazorApp.Components.Shared; + +/// +/// The code-behind half of Card.razor. Implements [RAZOR-COLLOCATION-JS] for the +/// sidecar's tests: a collocated module import is answered from a code-behind file too. +/// +public partial class Card +{ + /// Loads the component's collocated module from a code-behind method. + public static ValueTask LoadAsync(IJSRuntime js) + { + return js.InvokeAsync("import", "./Components/Shared/Card.razor.js"); + } + + /// Loads whatever module the caller names, which no definition can resolve. + public static ValueTask LoadAnyAsync(IJSRuntime js, string path) + { + return js.InvokeAsync("import", path); + } + + /// Runs a JavaScript function; the first argument is not an import. + public static ValueTask InvokeAsync(IJSRuntime js, string identifier) + { + return js.InvokeAsync(identifier, "./Components/Shared/Card.razor.js"); + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.css b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.css new file mode 100644 index 00000000..f2ed2e8a --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.css @@ -0,0 +1,52 @@ +/* + * Card.razor.css — the collocated stylesheet of Card.razor. + * A comment holds braces and class names: } .commented { .alsoCommented { } + */ + +.card { + color: #1a1a1a; + margin: .5rem; + padding: 0.25rem 0.5rem; + background: url(card.bg.png); +} + +.card .title { + font-weight: 600; +} + +.card::deep .card__title { + text-transform: uppercase; + grid-area: zone; +} + +.title:is(.accent, .muted) { + letter-spacing: 0.05em; +} + +a[href$=".pdf"] .title { + text-decoration: underline; +} + +.panel .title { + font-size: 1.25rem; +} + +.p-1\.5 { + padding: 0.375rem; +} + +.labeled { + content: "a.b"; +} + +@media (min-width: 641px) { + .title { + font-size: 2rem; + } + + @supports (display: grid) { + .card--grid .meta { + display: grid; + } + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.js b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.js new file mode 100644 index 00000000..efc042ff --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.js @@ -0,0 +1,10 @@ +/** + * The collocated JavaScript module of Card.razor, imported by + * `JS.InvokeAsync("import", "./Components/Shared/Card.razor.js")`. + */ +export function render(elementId) { + const card = document.getElementById(elementId); + if (card) { + card.dataset.enhanced = "true"; + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/CardInterop.cs b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/CardInterop.cs new file mode 100644 index 00000000..12e58eba --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/CardInterop.cs @@ -0,0 +1,17 @@ +using Microsoft.JSInterop; + +namespace BlazorApp.Components.Shared; + +/// +/// Loads a component's collocated JavaScript module from plain C#. Implements +/// [RAZOR-COLLOCATION-JS]: the definition of the module a "import" call names is +/// answered from a .cs file as well as from Razor. +/// +public static class CardInterop +{ + /// Loads Card's collocated module. + public static ValueTask LoadCardAsync(IJSRuntime js) + { + return js.InvokeAsync("import", "./Components/Shared/Card.razor.js"); + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Counting.cs b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Counting.cs new file mode 100644 index 00000000..85f4a5d1 --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Counting.cs @@ -0,0 +1,14 @@ +namespace BlazorApp.Components.Shared; + +/// +/// A plain C# type the fixture's Razor components call into, so that a call from Razor code and +/// markup reaches a method declared outside the projection. [RAZOR-TESTING] +/// +internal static class Counting +{ + /// Doubles a count. + internal static int Bump(int by) + { + return by * 2; + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Ticker.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Ticker.razor new file mode 100644 index 00000000..5518e7f3 --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Ticker.razor @@ -0,0 +1,19 @@ +@inherits Panel + +@Describe(count) + + +@code { + private int count; + + private void Hit() + { + var next = Counting.Bump(count); + count = next; + } + + private static string Describe(int value) + { + return $"Count: {value}"; + } +} diff --git a/src/sharplsp/tests/fixtures/razor/Domain/Scores.fs b/src/sharplsp/tests/fixtures/razor/Domain/Scores.fs index 11578364..05c2a042 100644 --- a/src/sharplsp/tests/fixtures/razor/Domain/Scores.fs +++ b/src/sharplsp/tests/fixtures/razor/Domain/Scores.fs @@ -1,9 +1,25 @@ namespace Domain /// A player on the leader board. -type Player = { Name: string; Score: int } +type Player = + { /// The player's display name. + Name: string + /// The points the player has scored. + Score: int } + +/// Where a player stands against the field. +type Standing = + /// No player has scored more. + | Leading + /// Behind the leader by some points. + | Chasing of gap: int /// Scoring rules for the leader board. module Scoring = /// The sum of every player's score. let total (players: seq) = players |> Seq.sumBy (fun player -> player.Score) + + /// Where `player` stands against `field`. + let standing (field: seq) (player: Player) = + let best = field |> Seq.map (fun other -> other.Score) |> Seq.max + if player.Score = best then Leading else Chasing(best - player.Score) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/CallHierarchyKindTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/CallHierarchyKindTests.cs new file mode 100644 index 00000000..374abba2 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/CallHierarchyKindTests.cs @@ -0,0 +1,49 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SharpLsp.Sidecar.CSharp.Workspace; + +#pragma warning disable CA1515 // Public xUnit discovery type + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// The kinds call hierarchy names its items with, in the PascalCase the host maps to LSP +/// SymbolKind, for the symbols a hierarchy request can land on that no workspace test +/// reaches. Implements [RAZOR-TESTING] for [RAZOR-FEATURES-HIERARCHY]. +/// +public sealed class CallHierarchyKindTests +{ + private const string Source = """ + namespace Kinds + { + public class Box + { + public object Items() => from item in new[] { 1 } select item; + } + } + """; + + [Fact] + public void Namespaces_type_parameters_and_kinds_lsp_lacks_are_named_for_the_host() + { + var tree = CSharpSyntaxTree.ParseText(Source); + var compilation = CSharpCompilation.Create("Kinds", [tree]); + var model = compilation.GetSemanticModel(tree); + var nodes = tree.GetRoot().DescendantNodes().ToList(); + + var space = model.GetDeclaredSymbol(nodes.OfType().Single()); + var item = model.GetDeclaredSymbol(nodes.OfType().Single()); + var range = model.GetDeclaredSymbol(nodes.OfType().Single()); + Assert.Equal( + ("Namespace", "TypeParameter", "Function", "Function"), + ( + CallHierarchyResolver.MapSymbolKind(space!), + CallHierarchyResolver.MapSymbolKind(item!), + CallHierarchyResolver.MapSymbolKind(range!), + CallHierarchyResolver.MapSymbolKind(compilation.Assembly) + ) + ); + Assert.Equal(SymbolKind.RangeVariable, range!.Kind); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HeadlessOverrideGenerationTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HeadlessOverrideGenerationTests.cs index e5ef6399..5043e01a 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HeadlessOverrideGenerationTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HeadlessOverrideGenerationTests.cs @@ -1,8 +1,8 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Text; using SharpLsp.Sidecar.Common; using SharpLsp.Sidecar.CSharp.Workspace; +using static SharpLsp.Sidecar.CSharp.Tests.TextEditing; #pragma warning disable CA1307 // StringComparison overloads add no value to xUnit assertions #pragma warning disable CA1515 // Public xUnit discovery type @@ -411,18 +411,4 @@ 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/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HotReloadSessionRegistryTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HotReloadSessionRegistryTests.cs index 6d30a4f8..756ad8b0 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HotReloadSessionRegistryTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HotReloadSessionRegistryTests.cs @@ -372,6 +372,33 @@ public async Task Two_phase_updates_commit_discard_and_span_documents() .ConfigureAwait(true); Assert.Equal("committed", committed.Status); + // A re-save of the committed text changes nothing at runtime: Roslyn has nothing to + // apply, so it is applied with no deltas and leaves nothing to commit. + var quiet = await SendAsync( + registry, + new HotReloadRequest + { + Action = "update", + SessionId = started.SessionId, + FilePath = sourcePath, + NewText = UpdatedSource, + } + ) + .ConfigureAwait(true); + Assert.Equal( + ("applied", 0, 0), + (quiet.Status, quiet.Updates.Count, quiet.Diagnostics.Count) + ); + _ = await Assert + .ThrowsAsync(async () => + _ = await SendAsync( + registry, + new HotReloadRequest { Action = "commit", SessionId = started.SessionId } + ) + .ConfigureAwait(true) + ) + .ConfigureAwait(true); + // A cross-file-consistent batch: the call in Calculator.cs needs the // method added in Helper.cs — only a single candidate spanning both // documents can emit it. diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs index c266559e..b8859d3b 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs @@ -255,6 +255,72 @@ string expected Assert.Equal(expected, Of(html).ToString()); } + [Fact] + public void The_attribute_at_a_caret_is_the_one_the_tokenizer_reads_there() + { + // In a value: the attribute's name, and its value without the quotes. + Assert.Equal(("class", "class", "a b"), AttributeOf("")); + Assert.Equal(("class", "class", "a"), AttributeOf("")); + Assert.Equal(("data-x", "data-x", "1"), AttributeOf("

")); + Assert.Equal(("x", "x", "ab"), AttributeOf("

")); + Assert.Equal(("class", "class", ""), AttributeOf("

")); + + // On a name: the whole name, and the value the tokenizer reads after the caret. + Assert.Equal(("class", "class", "x"), AttributeOf("

")); + Assert.Equal(("b", "b", "x"), AttributeOf("")); + Assert.Equal(("hidden", "hidden", null), AttributeOf("")); + Assert.Equal(("x", "x", null), AttributeOf("

"); + var action = Assert.Single( + await ActionsAsync(manager, Counter, (section.Line, 0), (close.Line, 0)), + a => a.Title == "Extract element to new component 'Component'" + ); + var edit = await ResolveAsync(manager, action); + Assert.Equal( + "
\n

Nested

\n
\n", + Assert.Single(edit.DocumentChanges[0].Edits).NewText + ); + Assert.Contains( + "
\n \n
\n", + ApplyEdits(text, edit.DocumentChanges[1].Edits), + StringComparison.Ordinal + ); + + // A local declared outside the selection is out of the new component's reach. + var span = Find(text, "@item"); + Assert.DoesNotContain( + await ActionsAsync( + manager, + Counter, + span, + (span.Line, span.Character + "@item".Length) + ), + a => a.Title.StartsWith("Extract element", StringComparison.Ordinal) + ); + + // One declared inside it, a lambda's parameter, moves with it. + const string emphasis = "@(new[] { 1 }.Select(n => n + 1).First())"; + var em = Find(text, emphasis); + _ = Assert.Single( + await ActionsAsync(manager, Counter, em, (em.Line, em.Character + emphasis.Length)), + a => a.Title == "Extract element to new component 'Component'" + ); + } + + private static (int Line, int Character) Find(string text, string token) + { + var lines = text.Split('\n'); + var line = Array.FindIndex(lines, l => l.Contains(token, StringComparison.Ordinal)); + Assert.True(line >= 0, $"missing {token}"); + return (line, lines[line].IndexOf(token, StringComparison.Ordinal)); + } + + private static async Task> ActionsAsync( + WorkspaceManager manager, + string file, + (int Line, int Character) start, + (int Line, int Character)? end = null + ) + { + var (endLine, endCharacter) = end ?? start; + return RazorFixture.Ok( + await manager.GetCodeActionsAsync( + file, + start.Line, + start.Character, + endLine, + endCharacter + ) + ); + } + + private static async Task ResolveAsync( + WorkspaceManager manager, + CodeActionItem action + ) + { + Assert.True(action.Id < 0, "a Razor action has its own id"); + return RazorFixture.Ok(await manager.ResolveCodeActionAsync(action.Id)); + } + + private static async Task> ErrorsAsync(WorkspaceManager manager, string file) + { + return + [ + .. RazorFixture + .Ok(await manager.GetDiagnosticsAsync(file)) + .Where(diagnostic => diagnostic.Severity == "Error") + .Select(diagnostic => diagnostic.Code), + ]; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCollocationTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCollocationTests.cs new file mode 100644 index 00000000..51faf35a --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCollocationTests.cs @@ -0,0 +1,301 @@ +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; + +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable RS1035 // Real-file reads are intentional in this end-to-end suite +#pragma warning disable IDE0042 // Positions read better as (Line, Character) pairs + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// Collocated files of a Razor file, against the real fixture solution: the class selectors of +/// Card.razor.css complete inside a class value and define to every selector that +/// declares them, and a InvokeAsync<IJSObjectReference>("import", "…") call defines +/// to the start of the Card.razor.js it names, from Razor, a code-behind and plain C#. +/// Implements [RAZOR-TESTING] for [RAZOR-COLLOCATION]. +/// +public sealed class RazorCollocationTests : IClassFixture +{ + private readonly RazorFixture _fixture; + + public RazorCollocationTests(RazorFixture fixture) + { + _fixture = fixture; + } + + private string Card => _fixture.Path("BlazorApp", "Components", "Shared", "Card.razor"); + + private string Stylesheet => + _fixture.Path("BlazorApp", "Components", "Shared", "Card.razor.css"); + + private string Module => _fixture.Path("BlazorApp", "Components", "Shared", "Card.razor.js"); + + private string CodeBehind => + _fixture.Path("BlazorApp", "Components", "Shared", "Card.razor.cs"); + + private string Interop => _fixture.Path("BlazorApp", "Components", "Shared", "CardInterop.cs"); + + private string Board => _fixture.Path("BlazorApp", "Components", "Pages", "Board.razor"); + + /// Text of the stylesheet that is not a class selector. + private static readonly string[] NotClasses = + [ + "commented", + "alsoCommented", + "zone", + "rem", + "pdf", + "png", + "a", + "b", + ]; + + [Fact] + public async Task Class_completion_offers_each_stylesheet_class_once() + { + using var manager = await _fixture.OpenManagerAsync(); + var (items, at) = await CompleteAfterAsync(manager, Card, "\n
item.Label == "title"); + Assert.Equal("Value", title.Kind); + Assert.Equal("Card.razor.css", title.Detail); + Assert.NotNull(title.TextEdit); + Assert.Equal(at.Line, title.TextEdit.StartLine); + Assert.Equal(at.Character - 2, title.TextEdit.StartCharacter); + Assert.Equal(at.Character, title.TextEdit.EndCharacter); + Assert.Equal("title", title.TextEdit.NewText); + + // Declared inside at-rules, `:is()` and `::deep`, they all complete. + Assert.Contains(items, item => item.Label == "card__title"); + Assert.Contains(items, item => item.Label == "accent"); + Assert.Contains(items, item => item.Label == "muted"); + Assert.Contains(items, item => item.Label == "card--grid"); + Assert.Contains(items, item => item.Label == "meta"); + + // Never text that is not a class selector: a comment, a declaration value, a number, + // a string or a url. + Assert.DoesNotContain( + items, + item => item.Kind == "Value" && NotClasses.Contains(item.Label, StringComparer.Ordinal) + ); + + // A class after a bare attribute, single-quoted, still completes the class at the caret. + var (after, end) = await CompleteAfterAsync(manager, Card, "\n
-internal static class HtmlMarkupContext +internal static partial class HtmlMarkupContext { /// The markup context at ; out-of-range offsets clamp. public static MarkupContext At(string html, int offset) @@ -67,7 +67,19 @@ private enum Scanner /// The tokenizer state. /// The current start tag's name; the raw-text target while in . /// Whether the current tag is an end tag. - private readonly record struct Scan(Scanner State, string Tag, bool EndTag); + /// Where the current attribute's name begins; -1 when no start tag has one. + /// Where the current attribute's name ends; -1 while the tokenizer reads it. + /// Where the current attribute's value begins, its quote excluded; -1 before it. + /// Where the current attribute's value ends, its quote excluded; -1 while it is read. + private readonly record struct Scan( + Scanner State, + string Tag, + bool EndTag, + int AttributeStart = -1, + int NameEnd = -1, + int ValueStart = -1, + int ValueEnd = -1 + ); private static Scan ScanTo(string html, int end) { @@ -83,7 +95,7 @@ private static Scan ScanTo(string html, int end) private static (Scan Next, int Index) Advance(Scan s, string html, int i, int end) { - return (s.State, html[i]) switch + var (next, index) = (s.State, html[i]) switch { (Scanner.TagOpen, '!') => Declaration(html, i), (Scanner.TagOpen, '?') => (Data(), Bogus(html, i + 1)), @@ -96,6 +108,7 @@ private static (Scan Next, int Index) Advance(Scan s, string html, int i, int en (Scanner.RawText, _) => RawText(s, html, i, end), _ => (Step(s, html[i]), i + 1), }; + return (Track(s, next, i), index); } private static Scan Step(Scan s, char c) @@ -174,7 +187,7 @@ private static Scan Attribute(Scan s, char c) '>' => Close(s), '/' => Kept(Scanner.SelfClosing, s), '=' => Kept(Scanner.BeforeValue, s), - _ => IsSpace(c) ? Kept(Scanner.AfterAttribute, s) : s, + _ => IsSpace(c) ? Kept(Scanner.AfterAttribute, s) : Kept(Scanner.Attribute, s), }; } @@ -366,7 +379,7 @@ private static Scan Data(Scanner state = Scanner.Data) private static Scan Kept(Scanner state, Scan s) { - return new(state, s.Tag, s.EndTag); + return s with { State = state }; } private static Scan Named(Scan s, char c, bool endTag) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCollocation.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCollocation.cs new file mode 100644 index 00000000..4260de14 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCollocation.cs @@ -0,0 +1,240 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Workspace; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// A Razor file's collocated files: the stylesheet that scopes a component's CSS +/// (Card.razor.css) and the JavaScript module it imports (Card.razor.js). The +/// stylesheet's classes complete and define inside a class attribute value, and an +/// InvokeAsync<IJSObjectReference>("import", "…") call defines to its module. +/// Implements [RAZOR-COLLOCATION]. +/// +internal static class RazorCollocation +{ + /// The types whose InvokeAsync imports a JavaScript module. + private static readonly string[] JruntimeTypes = + [ + "Microsoft.JSInterop.IJSRuntime", + "Microsoft.JSInterop.IJSObjectReference", + ]; + + /// + /// The class attribute value holding , with the class name + /// the caret is in: the whole token between whitespace, not the whole value. Null wherever the + /// attribute is not a plain HTML class, a directive attribute and a component's own + /// parameter never counting. [RAZOR-COLLOCATION-CSS] + /// + public static (TextSpan Value, TextSpan Class)? ClassValueAt(RazorMarkup markup, int offset) + { + return + HtmlMarkupContext.AttributeAt(markup.HtmlText, offset) is { } attribute + && IsHtmlClass(markup, attribute) + && attribute.Value is { } value + && value.Start <= offset + && offset <= value.End + ? (value, TokenAt(markup.HtmlText, value, offset)) + : null; + } + + /// + /// The items for the classes declares, each once, replacing the + /// class name at the caret and naming the stylesheet. [RAZOR-COLLOCATION-CSS] + /// + public static List ClassCompletions( + RazorMarkup markup, + RazorStylesheet stylesheet, + (TextSpan Value, TextSpan Class) at + ) + { + return + [ + .. stylesheet.Names.Select(name => new CompletionItem + { + Label = name, + Kind = "Value", + Detail = NativePaths.NameOf(stylesheet.FilePath), + SortText = name, + Index = -1, + TextEdit = DocumentText.ToTextEdit(markup.Text, new TextChange(at.Class, name)), + }), + ]; + } + + /// + /// The definitions of the class name at the caret: every selector occurrence of that class in + /// the stylesheet, each ranged on the name after its .. [RAZOR-COLLOCATION-CSS] + /// + public static LocationListResult ClassDefinitions( + RazorMarkup markup, + RazorStylesheet stylesheet, + (TextSpan Value, TextSpan Class) at + ) + { + var name = markup.Text.ToString(at.Class); + return new LocationListResult + { + Locations = + [ + .. stylesheet + .Classes.Where(cssClass => cssClass.Name == name) + .Select(cssClass => ToLocation(stylesheet, cssClass)), + ], + }; + } + + /// The name a module import at passes to InvokeAsync; null elsewhere. + public static LiteralExpressionSyntax? ModuleLiteral(SyntaxNode root, int offset) + { + var token = root.FindToken(Math.Clamp(offset, 0, root.FullSpan.End)); + return + token.Parent is LiteralExpressionSyntax literal + && literal.IsKind(SyntaxKind.StringLiteralExpression) + && literal.Span.Contains(offset) + ? literal + : null; + } + + /// + /// The module path names, when the call it is part of passes the + /// constant "import" to an IJSRuntime or IJSObjectReference + /// InvokeAsync. Resolved with the semantic model, never by reading text. + /// [RAZOR-COLLOCATION-JS] + /// + public static string? ImportedModule( + SemanticModel model, + LiteralExpressionSyntax literal, + CancellationToken ct + ) + { + return + ImportCall(literal) is { } call + && ImportsModule(model, call, ct) + && model.GetConstantValue(literal, ct) is { HasValue: true, Value: string path } + ? path + : null; + } + + /// + /// The file a module path names: the leading ./ and the app's base path both resolve + /// against the project directory, and only a .razor.js or .cshtml.js is a module. + /// [RAZOR-COLLOCATION-JS] + /// + public static string? ModuleFile(string projectDirectory, string path) + { + return + NativePaths.FromModulePath(projectDirectory, path) is { } resolved + && ( + NativePaths.HasExtension(resolved, ".razor.js") + || NativePaths.HasExtension(resolved, ".cshtml.js") + ) + ? resolved + : null; + } + + /// Whether the attribute names the plain HTML class, unbound by any tag helper. + private static bool IsHtmlClass( + RazorMarkup markup, + (string Name, TextSpan NameSpan, TextSpan? Value) attribute + ) + { + return string.Equals(attribute.Name, "class", StringComparison.OrdinalIgnoreCase) + && !markup.Attributes.Any(fact => + fact.Kind != MarkupAttributeKind.Html + && fact.NameSpan is { } name + && name.IntersectsWith(attribute.NameSpan) + ); + } + + /// The whole class name holding , kept inside the value. + private static TextSpan TokenAt(string html, TextSpan value, int offset) + { + var start = Math.Clamp(offset, value.Start, value.End); + var end = start; + while (start > value.Start && !HtmlMarkupContext.IsSpace(html[start - 1])) + { + start--; + } + + while (end < value.End && !HtmlMarkupContext.IsSpace(html[end])) + { + end++; + } + + return TextSpan.FromBounds(start, end); + } + + private static LocationResult ToLocation(RazorStylesheet stylesheet, CssClass cssClass) + { + var start = stylesheet.Text.Lines.GetLinePosition(cssClass.Span.Start); + var end = stylesheet.Text.Lines.GetLinePosition(cssClass.Span.End); + return new LocationResult + { + FilePath = stylesheet.FilePath, + Line = start.Line, + Character = start.Character, + EndLine = end.Line, + EndCharacter = end.Character, + }; + } + + /// The call the literal is the second argument of; null when it is not one. + private static InvocationExpressionSyntax? ImportCall(LiteralExpressionSyntax literal) + { + return + literal.Ancestors().OfType().FirstOrDefault() + is { Parent: ArgumentListSyntax list } argument + && list.Arguments.IndexOf(argument) == 1 + && list.Parent is InvocationExpressionSyntax call + ? call + : null; + } + + /// + /// Whether the call is an InvokeAsync of a JavaScript runtime whose first argument is the + /// constant "import". + /// + private static bool ImportsModule( + SemanticModel model, + InvocationExpressionSyntax call, + CancellationToken ct + ) + { + return model.GetConstantValue(call.ArgumentList.Arguments[0].Expression, ct) + is { HasValue: true, Value: "import" } + && model.GetSymbolInfo(call, ct).Symbol is IMethodSymbol method + && method.Name == "InvokeAsync" + && IsRuntime(model.Compilation, method); + } + + /// + /// Whether the call runs on a JavaScript runtime: its declaring type, or its receiver, is + /// IJSRuntime or IJSObjectReference. The params object?[] overload is an + /// extension method called on the runtime, so its receiver is the runtime; called as a static + /// method it passes the path third, which no import names. + /// + private static bool IsRuntime(Compilation compilation, IMethodSymbol method) + { + var runtimes = JruntimeTypes + .Select(compilation.GetTypeByMetadataName) + .OfType(); + return new ITypeSymbol?[] { method.ContainingType, method.ReceiverType }.Any(receiver => + runtimes.Any(runtime => IsOrImplements(receiver, runtime)) + ); + } + + private static bool IsOrImplements(ITypeSymbol? type, INamedTypeSymbol runtime) + { + return type is not null + && ( + SymbolEqualityComparer.Default.Equals(type, runtime) + || type.AllInterfaces.Any(iface => + SymbolEqualityComparer.Default.Equals(iface, runtime) + ) + ); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs index eccd1d7f..1e835447 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs @@ -9,7 +9,8 @@ namespace SharpLsp.Sidecar.CSharp.Razor; /// /// A completion's additional edits, moved from a projection into its Razor file: an edit /// inside one C# fragment maps across, an added C# using becomes an @using line -/// after the file's own, and anything else cannot be placed and is dropped. +/// after the file's own (else after its @page, which a Razor Page must open with), and +/// anything else cannot be placed and is dropped. /// Implements [RAZOR-ARCHITECTURE-PROJECTION] and [RAZOR-FEATURES-COMPLETION]. /// internal static class RazorCompletionEdits @@ -17,7 +18,8 @@ internal static class RazorCompletionEdits /// The Razor edits for computed on 's projection. public static IEnumerable Map( RazorProjection origin, - IEnumerable changes + IEnumerable changes, + RazorMarkup? markup ) { var existing = OwnUsings(origin).Select(Spelling).ToHashSet(StringComparer.Ordinal); @@ -34,7 +36,7 @@ IEnumerable changes foreach (var added in AddedUsings(change).Where(existing.Add)) { - yield return UsingLine(origin, added); + yield return UsingLine(origin, added, markup); } } } @@ -72,25 +74,32 @@ private static string Spelling(UsingDirectiveSyntax directive) return isStatic + alias + directive.NamespaceOrType.ToString(); } - /// An @using line inserted after the file's last own using, else at its start. - private static TextEditResult UsingLine(RazorProjection origin, string spelling) + /// + /// An @using line inserted after the file's last own using, else after its + /// @page directive, else at its start. [RAZOR-CODE-ACTIONS-USING] + /// + /// The line holding the last character of , which may end in its line break. + private static int LastLineOf(SourceText text, TextSpan span) { - var after = OwnUsings(origin) + return text.Lines.GetLineFromPosition(Math.Max(span.Start, span.End - 1)).LineNumber; + } + + private static TextEditResult UsingLine( + RazorProjection origin, + string spelling, + RazorMarkup? markup + ) + { + var usings = OwnUsings(origin) .Select(directive => directive.NamespaceOrType.GetLocation().GetMappedLineSpan()) - .Select(span => span.EndLinePosition.Line + 1) - .DefaultIfEmpty(0) - .Max(); + .Select(span => span.EndLinePosition.Line + 1); + var page = (markup?.Directives ?? []) + .Where(directive => directive.Name == "page") + .Select(directive => LastLineOf(origin.RazorText, directive.Span) + 1); + var after = usings.Concat(page).DefaultIfEmpty(0).Max(); var line = Math.Min(after, origin.RazorText.Lines.Count - 1); var at = origin.RazorText.Lines[line].Start; - var text = "@using " + spelling + LineBreak(origin.RazorText); + var text = "@using " + spelling + RazorHandlerText.LineBreak(origin.RazorText); return DocumentText.ToTextEdit(origin.RazorText, new TextChange(new TextSpan(at, 0), text)); } - - private static string LineBreak(SourceText text) - { - var first = text.Lines[0]; - return first.SpanIncludingLineBreak.Length > first.Span.Length - ? text.ToString(TextSpan.FromBounds(first.End, first.EndIncludingLineBreak)) - : "\n"; - } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorExtractActions.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorExtractActions.cs new file mode 100644 index 00000000..7561dbda --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorExtractActions.cs @@ -0,0 +1,330 @@ +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Workspace; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Razor refactorings that move markup or code into a new file: a component's @code +/// block into a code-behind partial class, and a run of sibling elements into a new +/// component. Implements [RAZOR-CODE-ACTIONS-CODE-BEHIND] and [RAZOR-CODE-ACTIONS-COMPONENT]. +/// +internal static class RazorExtractActions +{ + /// + /// Move the @code block whose name holds the caret into Name.razor.cs, when the + /// component has no code-behind yet. The partial class carries the file's imported + /// namespaces that the project does not import globally. + /// + public static RazorCodeAction? CodeBehind( + RazorProjection projection, + RazorMarkup markup, + Compilation compilation, + int caret + ) + { + // The caret may sit on the directive's `@` as well as on its name. + var block = markup.Directives.FirstOrDefault(directive => + directive.Span.Start <= caret && caret <= directive.NameSpan.End + ); + var path = NativePaths.CodeBehindOf(projection.RazorPath); + var body = block is { Name: "code" } ? BodyOf(projection, block.Span) : null; + if (markup.FileKind != RazorFileKind.Component || body is null || File.Exists(path)) + { + return null; + } + + var name = NativePaths.StemOf(projection.RazorPath); + var newline = RazorHandlerText.LineBreak(markup.Text); + var text = PartialClass( + markup, + compilation, + name, + markup.Text.ToString(body.Value), + newline + ); + var removed = WithLineBreak(markup.Text, block!.Span); + return new RazorCodeAction( + "Extract block to code behind", + "refactor.extract", + false, + Edits(markup.Text, projection.RazorPath, new TextChange(removed, ""), path, text) + ); + } + + /// + /// Move the sibling elements the selection covers into a new component beside the file, + /// and put its tag in their place. Offered only when their C# names nothing the + /// component or an enclosing block declares, which the new component could not see. + /// + public static RazorCodeAction? Component( + RazorProjection projection, + RazorMarkup markup, + SemanticModel model, + TextSpan range + ) + { + var selected = Trimmed(markup.Text, range); + if ( + markup.FileKind != RazorFileKind.Component + || !CoversSiblings(markup, selected) + || !StandsAlone(projection, model, selected) + ) + { + return null; + } + + var folder = NativePaths.DirectoryOf(projection.RazorPath); + var name = FreeName(folder); + var newline = RazorHandlerText.LineBreak(markup.Text); + var text = ComponentText(markup, selected, newline); + return new RazorCodeAction( + $"Extract element to new component '{name}'", + "refactor.extract", + false, + Edits( + markup.Text, + projection.RazorPath, + new TextChange(selected, $"<{name} />"), + NativePaths.ComponentIn(folder, name), + text + ) + ); + } + + /// The C# of a block: from its first fragment's start to its last fragment's end. + private static TextSpan? BodyOf(RazorProjection projection, TextSpan block) + { + var inside = projection + .Map.Mappings.Where(mapping => + block.Start <= mapping.RazorStart && mapping.RazorEnd <= block.End + ) + .ToList(); + return inside.Count == 0 + ? null + : TextSpan.FromBounds(inside.Min(m => m.RazorStart), inside.Max(m => m.RazorEnd)); + } + + private static string PartialClass( + RazorMarkup markup, + Compilation compilation, + string name, + string body, + string newline + ) + { + var global = GlobalUsings(compilation); + var usings = markup + .ImportedNamespaces.Where(space => !global.Contains(space)) + .Distinct() + .Order(StringComparer.Ordinal) + .Select(space => $"using {space};{newline}"); + var space = markup.Namespace is { Length: > 0 } declared + ? $"namespace {declared};{newline}{newline}" + : ""; + var members = body.Trim('\r', '\n').TrimEnd(); + return string.Concat(usings) + + (global.Count < markup.ImportedNamespaces.Length ? newline : "") + + space + + $"public partial class {name}{newline}{{{newline}{members}{newline}}}{newline}"; + } + + /// The namespaces the compilation imports with global using, as their symbols name them. + private static HashSet GlobalUsings(Compilation compilation) + { + return compilation + .SyntaxTrees.SelectMany(tree => + tree.GetCompilationUnitRoot() + .Usings.Where(directive => + directive.GlobalKeyword.Span.Length > 0 && directive.Alias is null + ) + .Select(directive => + compilation + .GetSemanticModel(tree) + .GetSymbolInfo(directive.NamespaceOrType) + .Symbol + ) + ) + .OfType() + .Select(space => space.ToDisplayString()) + .ToHashSet(StringComparer.Ordinal); + } + + /// with the line break that follows it, so no blank line stays. + private static TextSpan WithLineBreak(SourceText text, TextSpan span) + { + var line = text.Lines.GetLineFromPosition(span.End); + return line.End == span.End + ? TextSpan.FromBounds(span.Start, line.EndIncludingLineBreak) + : span; + } + + /// without the whitespace at either end. + private static TextSpan Trimmed(SourceText text, TextSpan range) + { + var start = range.Start; + var end = range.End; + while (start < end && char.IsWhiteSpace(text[start])) + { + start++; + } + + while (end > start && char.IsWhiteSpace(text[end - 1])) + { + end--; + } + + return TextSpan.FromBounds(start, end); + } + + /// Whether starts at one element and ends at a sibling's end. + private static bool CoversSiblings(RazorMarkup markup, TextSpan selected) + { + var first = markup + .Tags.Where(tag => tag.Element.Start == selected.Start) + .MaxBy(tag => tag.Element.Length); + var last = markup + .Tags.Where(tag => tag.Element.End == selected.End) + .MaxBy(tag => tag.Element.Length); + return selected.Length > 0 + && first is not null + && last is not null + && (first == last || first.Element.End <= last.Element.Start) + && Parent(markup, first) == Parent(markup, last); + } + + private static MarkupTag? Parent(RazorMarkup markup, MarkupTag tag) + { + return markup + .Tags.Where(other => other != tag && other.Element.Contains(tag.Element)) + .MinBy(other => other.Element.Length); + } + + /// + /// Whether the selection's C# binds only to what it declares itself or what any component + /// can name: never a member of this component, nor a local or parameter declared outside it. + /// + private static bool StandsAlone( + RazorProjection projection, + SemanticModel model, + TextSpan selected + ) + { + var generated = projection + .Map.Mappings.Where(mapping => + selected.Start <= mapping.RazorStart && mapping.RazorEnd <= selected.End + ) + .Select(mapping => new TextSpan(mapping.GeneratedStart, mapping.Length)) + .ToList(); + var component = model.GetDeclaredSymbol( + projection.GeneratedRoot.DescendantNodes().OfType().First() + ); + return generated + .SelectMany(span => + projection.GeneratedRoot.DescendantNodes(span).OfType() + ) + .Where(name => generated.Any(span => span.Contains(name.Span))) + .Select(name => model.GetSymbolInfo(name).Symbol) + .OfType() + .All(symbol => !Captures(symbol, component, generated)); + } + + private static bool Captures( + ISymbol symbol, + INamedTypeSymbol? component, + List selected + ) + { + var declaredInside = symbol.Locations.Any(location => + location.IsInSource && selected.Any(span => span.Contains(location.SourceSpan)) + ); + return symbol switch + { + ILocalSymbol or IParameterSymbol or IRangeVariableSymbol => !declaredInside, + _ => SymbolEqualityComparer.Default.Equals(symbol.ContainingType, component), + }; + } + + /// Component, else the first ComponentN no file in the folder takes. + private static string FreeName(string folder) + { + return Enumerable + .Range(1, int.MaxValue - 1) + .Select(n => n == 1 ? "Component" : $"Component{n}") + .First(name => !File.Exists(NativePaths.ComponentIn(folder, name))); + } + + /// The new component: the file's own @using lines, then the elements, dedented. + private static string ComponentText(RazorMarkup markup, TextSpan selected, string newline) + { + var usings = markup + .Directives.Where(directive => directive.Name == "using") + .Select(directive => markup.Text.ToString(directive.Span).TrimEnd() + newline); + var text = markup.Text; + var first = text.Lines.GetLineFromPosition(selected.Start); + var indent = selected.Start - first.Start; + var lines = Enumerable + .Range( + first.LineNumber, + text.Lines.GetLineFromPosition(selected.End).LineNumber - first.LineNumber + 1 + ) + .Select(number => text.Lines[number]) + .Select(line => + text.ToString( + TextSpan.FromBounds( + Math.Max( + line.Start, + line == first ? selected.Start : Dedented(text, line, indent) + ), + Math.Min(line.End, selected.End) + ) + ) + ); + var head = string.Concat(usings); + return head + (head.Length > 0 ? newline : "") + string.Join(newline, lines) + newline; + } + + /// Where 's text starts once up to of its leading whitespace is dropped. + private static int Dedented(SourceText text, TextLine line, int indent) + { + var start = line.Start; + while (start < line.End && start - line.Start < indent && char.IsWhiteSpace(text[start])) + { + start++; + } + + return start; + } + + /// The edit to the Razor file and the new file's whole text, the new file first. + private static WorkspaceEditResult Edits( + SourceText razorText, + string razorPath, + TextChange change, + string newPath, + string newText + ) + { + return new WorkspaceEditResult + { + DocumentChanges = + [ + new DocumentEditResult + { + FilePath = newPath, + Edits = [new TextEditResult { NewText = newText }], + Create = true, + }, + new DocumentEditResult + { + FilePath = razorPath, + Edits = [DocumentText.ToTextEdit(razorText, change)], + }, + ], + }; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHandlerText.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHandlerText.cs new file mode 100644 index 00000000..efaad5f9 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHandlerText.cs @@ -0,0 +1,64 @@ +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.CSharp.Workspace; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// The text of a generated event handler and where it goes: before the closing brace of the +/// file's last @code or @functions block, else in a new @code block at the +/// end of the file. Implements [RAZOR-CODE-ACTIONS-HANDLER]. +/// +internal static class RazorHandlerText +{ + private const string Indent = " "; + + /// A private method named , taking the event's argument when there is one. + public static IReadOnlyList Method(string name, string? argument) + { + var parameters = argument is null ? "" : $"{argument} args"; + return [$"private void {name}({parameters})", "{", "}"]; + } + + /// The edit that inserts into 's file. + public static TextEditResult Insert(RazorMarkup markup, IReadOnlyList method) + { + var text = markup.Text; + var newline = LineBreak(text); + var block = markup + .Directives.Where(directive => directive.Name is "code" or "functions") + .Select(directive => directive.Span) + .Where(span => span.Length > 0 && text[span.End - 1] == '}') + .OrderBy(span => span.End) + .LastOrDefault(); + var body = string.Concat(method.Select(line => Indent + line + newline)); + var change = + block.Length > 0 + ? new TextChange(new TextSpan(block.End - 1, 0), newline + body) + : new TextChange( + new TextSpan(text.Length, 0), + (EndsWithLineBreak(text) ? "" : newline) + + newline + + "@code {" + + newline + + body + + "}" + + newline + ); + return DocumentText.ToTextEdit(text, change); + } + + /// The file's own line break, from its first line; \n for a one-line file. + internal static string LineBreak(SourceText text) + { + var first = text.Lines[0]; + return first.EndIncludingLineBreak > first.End + ? text.ToString(TextSpan.FromBounds(first.End, first.EndIncludingLineBreak)) + : "\n"; + } + + private static bool EndsWithLineBreak(SourceText text) + { + var last = text.Lines[^1]; + return text.Length == 0 || last.Span.Length == 0; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHints.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHints.cs new file mode 100644 index 00000000..9748fa5a --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHints.cs @@ -0,0 +1,94 @@ +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Inlay hints of a projection read in the Razor file it belongs to: hints computed on the C# +/// the Razor lines map to, each placed at its Razor position. A hint outside every C# fragment +/// cannot be shown in the file, so it is dropped. Implements [RAZOR-ARCHITECTURE-PROJECTION] +/// and [RAZOR-FEATURES-INLAY-HINTS]. +/// +internal static class RazorHints +{ + /// + /// The generated lines the Razor .. + /// map to, the last one included so that a caller reading to the end of it sees its content; + /// null when they cover no fragment, where the file has no C# to hint. + /// + public static (int Start, int End)? Lines( + RazorProjection projection, + int startLine, + int endLine + ) + { + var lines = projection.RazorText.Lines; + var window = TextSpan.FromBounds( + lines[Clamped(lines, startLine)].Start, + lines[Clamped(lines, endLine)].End + ); + var covering = projection + .Map.Mappings.Where(entry => + entry.RazorStart < window.End && window.Start < entry.RazorEnd + ) + .ToList(); + return covering.Count == 0 + ? null + : ( + LineOf(projection, covering.Min(entry => entry.GeneratedStart)), + LineOf(projection, covering.Max(entry => entry.GeneratedEnd)) + ); + } + + /// + /// , whose positions are in the projection, placed in + /// 's Razor file when they lie within its lines + /// ..; the rest are dropped. + /// + public static List Map( + List hints, + RazorProjection projection, + int startLine, + int endLine + ) + { + return + [ + .. hints + .Select(hint => Map(hint, projection, startLine, endLine)) + .OfType(), + ]; + } + + private static InlayHintResult? Map( + InlayHintResult hint, + RazorProjection projection, + int startLine, + int endLine + ) + { + var at = new LinePosition(hint.Line, hint.Character); + return + projection.ToRazor(new LinePositionSpan(at, at)) is { } razor + && razor.Start.Line >= startLine + && razor.Start.Line <= endLine + ? new InlayHintResult + { + Line = razor.Start.Line, + Character = razor.Start.Character, + Label = hint.Label, + Kind = hint.Kind, + } + : null; + } + + /// The generated line is on. + private static int LineOf(RazorProjection projection, int generatedOffset) + { + return projection.GeneratedText.Lines.GetLinePosition(generatedOffset).Line; + } + + private static int Clamped(TextLineCollection lines, int line) + { + return Math.Clamp(line, 0, lines.Count - 1); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs index 7a7131a7..a195a0c8 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs @@ -27,6 +27,7 @@ SourceText text bound.Kind, bound.Name, named.Name, + named.End, tag, bound.TagHelper, bound.Attribute, diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs index 29f0964e..87b86868 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs @@ -64,11 +64,16 @@ private static bool Touches(TextSpan span, int offset) } } -/// An attribute of a tag, with what binds it. +/// +/// An attribute of a tag, with what binds it. is where the attribute +/// ends — its value and closing quote, or a minimized attribute's name — as the compiler's +/// spans say; null when they do not. +/// internal sealed record MarkupAttribute( MarkupAttributeKind Kind, string Name, TextSpan? NameSpan, + int? End, MarkupTag Owner, TagHelperDescriptor? TagHelper, BoundAttributeDescriptor? Bound, diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs index 8156be5a..9eac8b7a 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs @@ -94,14 +94,42 @@ tag.Component is not null ) .MinBy(tag => tag.Element.Length); var children = parent is null ? [] : ChildContents(markup, parent); - return children.Concat(Components(markup)); + return children.Concat(Components(markup)).Concat(TagHelperElements(markup.Scope)); } - /// Each component once: its short name where its namespace is in scope, else its full one. + /// + /// Each element an MVC tag helper in scope targets by name (partial, environment, + /// a), spelled with the scope's prefix, once, with the tag helpers that bind it. A rule + /// for every element (*) names none. [RAZOR-MVC-TAGHELPERS] + /// + private static IEnumerable TagHelperElements(RazorTagHelperScope scope) + { + return scope + .TagHelpers.SelectMany(helper => + helper + .TagMatchingRules.Where(rule => rule.TagName is not (null or "*")) + .Select(rule => (Label: scope.Prefix + rule.TagName, Helper: helper)) + ) + .GroupBy(element => element.Label, StringComparer.OrdinalIgnoreCase) + .Select(element => new Entry( + element.Key, + "Class", + string.Join(", ", element.Select(bound => bound.Helper.Name).Distinct()), + element.First().Helper.Documentation, + 1 + )); + } + + /// + /// Each component once: its short name where its namespace is in scope, else its full one. + /// A legacy file binds no component by its tag, so it completes none. [RAZOR-MVC-SCOPE] + /// private static IEnumerable Components(RazorMarkup markup) { return markup - .TagHelpers.Where(helper => helper.Kind == TagHelperKind.Component) + .TagHelpers.Where(helper => + helper.Kind == TagHelperKind.Component && markup.FileKind != RazorFileKind.Legacy + ) .GroupBy(helper => helper.TypeName, StringComparer.Ordinal) .Select(group => group.FirstOrDefault(helper => diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorQuickFixes.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorQuickFixes.cs new file mode 100644 index 00000000..10a87331 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorQuickFixes.cs @@ -0,0 +1,244 @@ +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.FindSymbols; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// A Razor code action, its edit computed when it is offered. +internal sealed record RazorCodeAction( + string Title, + string Kind, + bool IsPreferred, + WorkspaceEditResult Edit +); + +/// +/// Razor quick fixes for C# names the projection cannot bind: @using for a type +/// another namespace declares, and a handler method for an event attribute or +/// EventCallback parameter that names one not yet written. Implements +/// [RAZOR-CODE-ACTIONS-USING] and [RAZOR-CODE-ACTIONS-HANDLER]. +/// +internal static class RazorQuickFixes +{ + /// The C# diagnostics that name something the projection cannot find. + private static readonly string[] Unbound = ["CS0103", "CS0246"]; + + /// The fixes for the projection's unbound names that lie in . + public static async Task> ForAsync( + RazorProjection projection, + RazorMarkup markup, + TextSpan range, + CancellationToken ct + ) + { + var model = + await projection.Generated.GetSemanticModelAsync(ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("The projection has no semantic model"); + var names = model + .GetDiagnostics(cancellationToken: ct) + .Where(diagnostic => Unbound.Contains(diagnostic.Id)) + .Select(diagnostic => UnboundName(projection, diagnostic, range)) + .OfType() + .DistinctBy(name => name.SpanStart); + var fixes = new List(); + foreach (var name in names) + { + fixes.AddRange( + await UsingsAsync(projection, markup, model, name, ct).ConfigureAwait(false) + ); + fixes.AddRange(Handlers(projection, markup, model, name)); + } + + return fixes + .Concat(ComponentUsings(projection, markup, range)) + .DistinctBy(fix => fix.Title); + } + + /// The name a diagnostic reports, when its Razor span overlaps . + private static SimpleNameSyntax? UnboundName( + RazorProjection projection, + Diagnostic diagnostic, + TextSpan range + ) + { + var span = diagnostic.Location.SourceSpan; + var razor = projection.Map.ToRazor(span); + return razor is { } mapped && (mapped.OverlapsWith(range) || mapped.Contains(range.Start)) + ? projection + .GeneratedRoot.FindNode(span, getInnermostNodeForTie: true) + .DescendantNodesAndSelf() + .OfType() + .FirstOrDefault() + : null; + } + + /// + /// @using for each namespace that declares an accessible type of the unbound name + /// and arity, which the file does not import yet; one choice is preferred. + /// + private static async Task> UsingsAsync( + RazorProjection projection, + RazorMarkup markup, + SemanticModel model, + SimpleNameSyntax name, + CancellationToken ct + ) + { + var arity = name is GenericNameSyntax generic + ? generic.TypeArgumentList.Arguments.Count + : 0; + var declared = await SymbolFinder + .FindDeclarationsAsync( + projection.Generated.Project, + name.Identifier.ValueText, + ignoreCase: false, + SymbolFilter.Type, + ct + ) + .ConfigureAwait(false); + var namespaces = declared + .OfType() + .Where(type => + type.Arity == arity + && type.ContainingType is null + && model.IsAccessible(name.SpanStart, type) + && !type.ContainingNamespace.IsGlobalNamespace + ) + .Select(type => type.ContainingNamespace.ToDisplayString()) + .Where(space => !markup.ImportedNamespaces.Contains(space)) + .Distinct() + .Order(StringComparer.Ordinal) + .ToList(); + return namespaces.Select(space => + UsingFix(projection, markup, space, namespaces.Count == 1) + ); + } + + /// + /// @using for each namespace holding a component that an unknown element names + /// (RZ10012): the compiler binds a component tag only where its namespace is in scope. + /// + private static IEnumerable ComponentUsings( + RazorProjection projection, + RazorMarkup markup, + TextSpan range + ) + { + var spans = markup + .Diagnostics.Where(diagnostic => + diagnostic.Id == "RZ10012" + && (diagnostic.Span.OverlapsWith(range) || diagnostic.Span.Contains(range.Start)) + ) + .Select(diagnostic => diagnostic.Span) + .ToList(); + var unknown = markup + .Tags.Where(tag => spans.Any(span => span.Contains(tag.StartName.Start))) + .Select(tag => tag.Name) + .ToHashSet(StringComparer.Ordinal); + return markup + .TagHelpers.Where(helper => + helper.Kind == TagHelperKind.Component + && unknown.Contains(helper.TypeNameIdentifier ?? "") + && helper.TypeNamespace is { Length: > 0 } space + && !markup.ImportedNamespaces.Contains(space) + ) + .Select(helper => helper.TypeNamespace!) + .Distinct() + .Select(space => UsingFix(projection, markup, space, true)); + } + + private static RazorCodeAction UsingFix( + RazorProjection projection, + RazorMarkup markup, + string space, + bool preferred + ) + { + var line = RazorCompletionEdits.Map( + projection, + [new TextChange(new TextSpan(0, 0), $"using {space};")], + markup + ); + return new RazorCodeAction( + $"@using {space}", + "quickfix", + preferred, + Edit(projection.RazorPath, line) + ); + } + + /// + /// A handler for an unbound name that the projection passes to + /// EventCallback.Factory.Create: an event attribute (@onclick="Missing") or an + /// EventCallback parameter. It takes the event's argument when the callback has one. + /// + private static IEnumerable Handlers( + RazorProjection projection, + RazorMarkup markup, + SemanticModel model, + SimpleNameSyntax name + ) + { + var create = name.Ancestors() + .OfType() + .FirstOrDefault(invocation => CreatesCallback(model, invocation)); + if (create is null || name is GenericNameSyntax) + { + return []; + } + + var argument = CallbackArgument(model, create, name.SpanStart); + var method = RazorHandlerText.Method(name.Identifier.ValueText, argument); + return + [ + new RazorCodeAction( + $"Generate event handler '{name.Identifier.ValueText}'", + "quickfix", + true, + Edit(projection.RazorPath, [RazorHandlerText.Insert(markup, method)]) + ), + ]; + } + + /// Whether calls EventCallbackFactory.Create. + private static bool CreatesCallback(SemanticModel model, InvocationExpressionSyntax invocation) + { + var info = model.GetSymbolInfo(invocation); + return info + .CandidateSymbols.Append(info.Symbol) + .OfType() + .Any(method => + method.Name == "Create" + && method.ContainingType.ToDisplayString() + == "Microsoft.AspNetCore.Components.EventCallbackFactory" + ); + } + + /// The callback's argument type, as the file spells it, from the call's type argument; none for a plain EventCallback. + private static string? CallbackArgument( + SemanticModel model, + InvocationExpressionSyntax create, + int position + ) + { + var argument = create.Expression switch + { + MemberAccessExpressionSyntax { Name: GenericNameSyntax generic } => + generic.TypeArgumentList.Arguments.FirstOrDefault(), + _ => null, + }; + return argument is null + ? null + : model.GetTypeInfo(argument).Type?.ToMinimalDisplayString(model, position); + } + + private static WorkspaceEditResult Edit(string path, IEnumerable edits) + { + return new WorkspaceEditResult + { + DocumentChanges = [new DocumentEditResult { FilePath = path, Edits = [.. edits] }], + }; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSelectionRanges.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSelectionRanges.cs new file mode 100644 index 00000000..71b74354 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSelectionRanges.cs @@ -0,0 +1,134 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// The selection ranges of a Razor file: each position answers the chain from its innermost +/// span — the C# fragment's token and enclosing syntax nodes, then the markup facts' attribute, +/// tag name, elements, directives and code blocks — out to the whole document. A candidate +/// that cannot map back to the Razor file as a whole, that does not hold the chain so far, or +/// that duplicates it, is dropped, never approximated. Implements [RAZOR-FEATURES-SELECTION]. +/// +internal static class RazorSelectionRanges +{ + /// The spans holding , innermost first, in Razor offsets. + public static IEnumerable At( + RazorProjection? projection, + RazorMarkup? markup, + int offset + ) + { + return Nested( + Fragment(projection, offset) + .Concat(Markup(markup, offset)) + .Append(Document(projection, markup)), + offset + ); + } + + /// + /// The C# fragment at : the Roslyn token there, then each + /// enclosing node of the projection's syntax tree, keeping only the spans that map back + /// to the Razor file whole. [RAZOR-ARCHITECTURE-PROJECTION] + /// + private static IEnumerable Fragment(RazorProjection? projection, int offset) + { + return projection is not { } found || found.Map.ToGenerated(offset) is not { } generated + ? [] + : NodeSpans(found.GeneratedRoot.FindToken(generated), generated) + .Select(found.Map.ToRazor) + .OfType(); + } + + /// The token's span when it holds the position, then every enclosing node's span. + private static IEnumerable NodeSpans(SyntaxToken token, int generated) + { + var nodes = token.Parent?.AncestorsAndSelf().Select(node => node.Span) ?? []; + return token.Span.Contains(generated) ? new[] { token.Span }.Concat(nodes) : nodes; + } + + /// + /// The markup facts' spans holding : an attribute, a tag name, + /// every enclosing element, directives and C# code blocks. [RAZOR-ARCHITECTURE-ENGINE-FACTS] + /// + private static IEnumerable Markup(RazorMarkup? markup, int offset) + { + return markup is not { } facts + ? [] + : Attribute(facts, offset) + .Concat(TagName(facts, offset)) + .Concat( + facts + .Tags.Where(tag => tag.Element.Contains(offset)) + .OrderBy(tag => tag.Element.Length) + .Select(tag => tag.Element) + ) + .Concat( + facts + .Directives.Where(directive => directive.Span.Contains(offset)) + .Select(directive => directive.Span) + ) + .Concat(facts.CodeBlocks.Where(block => block.Contains(offset))); + } + + /// + /// The attribute whose name or value holds : its name's span, + /// then the whole attribute when it reaches past the name. + /// + private static IEnumerable Attribute(RazorMarkup facts, int offset) + { + var found = facts + .Attributes.Select(attribute => (attribute.NameSpan, Whole: Whole(attribute))) + .FirstOrDefault(pair => pair.Whole is { } whole && whole.Contains(offset)); + return found.NameSpan is not { } name ? [] + : found.Whole is { } whole && whole != name ? [name, whole] + : [name]; + } + + /// + /// The attribute's whole span, from its name's start to the end the compiler's spans give + /// it; an attribute they give no end is its name. + /// + private static TextSpan? Whole(MarkupAttribute attribute) + { + return attribute.NameSpan is not { } name ? null + : attribute.End is { } end && end > name.End ? TextSpan.FromBounds(name.Start, end) + : name; + } + + /// The start or end tag name holding , when one does. + private static IEnumerable TagName(RazorMarkup facts, int offset) + { + return facts.TagNamedAt(offset) is { } tag ? [tag.NameAt(offset)] : []; + } + + /// The whole file's span, from the facts' or the projection's text. + private static TextSpan Document(RazorProjection? projection, RazorMarkup? markup) + { + return new TextSpan(0, (markup?.Text ?? projection?.RazorText)?.Length ?? 0); + } + + /// + /// The candidates that hold and hold every span kept before + /// them, innermost first: one that does not contain the chain so far, or that duplicates + /// it, drops out. + /// + private static ImmutableList Nested(IEnumerable candidates, int offset) + { + return candidates.Aggregate( + ImmutableList.Empty, + (chain, candidate) => + chain.Count == 0 && !candidate.Contains(offset) ? chain + : chain.Count > 0 && !StrictlyHolds(chain[^1], candidate) ? chain + : chain.Add(candidate) + ); + } + + /// Whether contains with room to spare. + private static bool StrictlyHolds(TextSpan inner, TextSpan outer) + { + return outer.Contains(inner) && outer != inner; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorStylesheet.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorStylesheet.cs new file mode 100644 index 00000000..59e43804 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorStylesheet.cs @@ -0,0 +1,111 @@ +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// A class selector a stylesheet declares: its name, with its escapes decoded, and the span of +/// the class name after its .. [RAZOR-COLLOCATION-CSS] +/// +internal readonly record struct CssClass(string Name, TextSpan Span); + +/// +/// A collocated stylesheet: the classes its rule preludes declare, in the order they appear. +/// Only a rule's prelude names selectors, so a class written in a declaration value, a string, +/// a comment or a url is never one. Implements [RAZOR-COLLOCATION-CSS]. +/// +internal sealed record RazorStylesheet( + string FilePath, + SourceText Text, + IReadOnlyList Classes +) +{ + /// The distinct class names the stylesheet declares, in the order they first appear. + public IReadOnlyList Names { get; } = + [.. Classes.Select(cssClass => cssClass.Name).Distinct(StringComparer.Ordinal)]; + + /// The stylesheet holds, read from its text. + public static RazorStylesheet Parse(string filePath, SourceText text) + { + return new RazorStylesheet(filePath, text, RazorStylesheetReader.ClassesOf(text)); + } +} + +/// +/// Reads the class selectors of a stylesheet from the tokens of the CSS Syntax Module Level 3 +/// tokenizer. Every { ends a prelude: an at-rule's preludes are skipped, and a rule's +/// own yields each . immediately followed by an ident. Blocks nest, so at-rules inside +/// at-rules and nested rules read the same way. Implements [RAZOR-COLLOCATION-CSS]. +/// +internal static class RazorStylesheetReader +{ + /// Every class selector occurrence of , in source order. + public static IReadOnlyList ClassesOf(SourceText css) + { + var tokens = CssTokenizer.Tokenize(css.ToString()); + var classes = new List(); + var prelude = 0; + for (var at = 0; at < tokens.Count; at++) + { + if (tokens[at].Kind == CssTokenKind.BraceOpen && IsSelectorPrelude(tokens, prelude, at)) + { + classes.AddRange(ClassesIn(css, tokens, prelude, at)); + } + + prelude = StartsPrelude(tokens[at]) ? at + 1 : prelude; + } + + return classes; + } + + /// Whether the block a { opens starts a prelude of its own after it. + private static bool StartsPrelude(CssToken token) + { + return token.Kind + is CssTokenKind.BraceOpen + or CssTokenKind.BraceClose + or CssTokenKind.Semicolon; + } + + /// Whether the tokens from name a rule's selectors, not an at-rule. + private static bool IsSelectorPrelude(List tokens, int start, int end) + { + // A default token's empty span stands for a prelude holding nothing but whitespace. + var first = tokens.Skip(start).Take(end - start).FirstOrDefault(NotSpace); + return first.Span.Length > 0 + && first.Kind is not (CssTokenKind.AtKeyword or CssTokenKind.Cdo or CssTokenKind.Cdc); + } + + private static bool NotSpace(CssToken token) + { + return token.Kind != CssTokenKind.Whitespace; + } + + /// The classes of a prelude: each . and the ident right after it. + private static IEnumerable ClassesIn( + SourceText css, + List tokens, + int start, + int end + ) + { + return Enumerable + .Range(start, end - start) + .Where(at => IsClassDot(css, tokens, at)) + .Select(at => tokens[at + 1]) + .Select(ident => new CssClass(CssTokenizer.Value(css, ident), ident.Span)); + } + + /// Whether the token is a . directly followed by the ident naming a class. + private static bool IsClassDot(SourceText css, List tokens, int at) + { + return tokens[at].Kind == CssTokenKind.Delim + && IsDot(css, tokens[at]) + && tokens[at + 1].Kind == CssTokenKind.Ident + && tokens[at + 1].Span.Start == tokens[at].Span.End; + } + + private static bool IsDot(SourceText css, CssToken token) + { + return token.Span.Length == 1 && css[token.Span.Start] == '.'; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs index 4241e430..d0b16935 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs @@ -67,27 +67,40 @@ CancellationToken ct { foreach (var location in symbol.Locations.Where(l => l.IsInSource)) { - var tree = location.SourceTree; - if (tree is null) - { - continue; - } - - var model = await document - .Project.Solution.GetDocument(document.Id)! - .GetSemanticModelAsync(ct) + var (model, root) = await DeclarationAsync(document, location, ct) .ConfigureAwait(false); - if (model is null) + if (model is not null && root is not null) { - continue; + CollectOutgoingCalls(root.FindNode(location.SourceSpan), model, results, ct); } - - var root = await tree.GetRootAsync(ct).ConfigureAwait(false); - var node = root.FindNode(location.SourceSpan); - CollectOutgoingCalls(node, model, results, ct); } } + /// + /// The semantic model and syntax root of the tree is declared in: + /// the queried document's own — a Razor projection's included, which has no document of its + /// own in the solution — or, for a declaration in another regular document, that document's. + /// + private static async Task<(SemanticModel? Model, SyntaxNode? Root)> DeclarationAsync( + Document document, + Location location, + CancellationToken ct + ) + { + var tree = location.SourceTree; + var own = await document.GetSyntaxTreeAsync(ct).ConfigureAwait(false); + var owner = + tree is null ? null + : tree == own ? document + : document.Project.Solution.GetDocument(tree); + return owner is null + ? (null, null) + : ( + await owner.GetSemanticModelAsync(ct).ConfigureAwait(false), + await owner.GetSyntaxRootAsync(ct).ConfigureAwait(false) + ); + } + private static void CollectOutgoingCalls( SyntaxNode node, SemanticModel model, @@ -203,19 +216,23 @@ private static CallSiteResult ToCallSite(Location location) }; } - private static string MapSymbolKind(ISymbol symbol) + /// + /// The PascalCase kind the host maps to an LSP SymbolKind; a symbol LSP has no kind for + /// is a Function. [RAZOR-FEATURES-HIERARCHY] + /// + internal static string MapSymbolKind(ISymbol symbol) { return symbol.Kind switch { - SymbolKind.Method => "method", - SymbolKind.Property => "property", - SymbolKind.Field => "field", - SymbolKind.NamedType => "class", - SymbolKind.Event => "event", - SymbolKind.Namespace => "namespace", - SymbolKind.Local => "variable", - SymbolKind.Parameter => "parameter", - SymbolKind.TypeParameter => "typeParameter", + SymbolKind.Method => "Method", + SymbolKind.Property => "Property", + SymbolKind.Field => "Field", + SymbolKind.NamedType => "Class", + SymbolKind.Event => "Event", + SymbolKind.Namespace => "Namespace", + SymbolKind.Local => "Variable", + SymbolKind.Parameter => "Parameter", + SymbolKind.TypeParameter => "TypeParameter", SymbolKind.ArrayType or SymbolKind.PointerType or SymbolKind.FunctionPointerType @@ -227,8 +244,8 @@ or SymbolKind.Alias or SymbolKind.RangeVariable or SymbolKind.Assembly or SymbolKind.NetModule - or SymbolKind.Discard => "function", - _ => "function", + or SymbolKind.Discard => "Function", + _ => "Function", }; } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs index 46b12a36..4bf170d0 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs @@ -564,6 +564,7 @@ private static DocumentEditResult CreateAddedDocumentEdit(string filePath, strin { FilePath = filePath, Edits = [CreateWholeDocumentEdit(text)], + Create = true, }; } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs index ab04ff74..5cc46bf0 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs @@ -1,6 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; namespace SharpLsp.Sidecar.CSharp.Workspace; @@ -10,11 +11,16 @@ namespace SharpLsp.Sidecar.CSharp.Workspace; /// internal static class CodeLensResolver { - /// Get code lenses for a document. + /// + /// Get code lenses for a document, each on its declaration. A declaration + /// moves is lensed where it puts it, and one it maps nowhere has no lens: that is how a Razor + /// file's lenses land on the declarations in its C# fragments ([RAZOR-FEATURES-CODE-LENS]). + /// public static async Task> GetLensesAsync( Document document, SearchScope scope, - CancellationToken ct + CancellationToken ct, + Func? place = null ) { var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); @@ -25,7 +31,7 @@ CancellationToken ct } var lenses = new List(); - await CollectLensesAsync(root, model, scope, lenses, ct).ConfigureAwait(false); + await CollectLensesAsync(root, model, scope, lenses, place, ct).ConfigureAwait(false); return lenses; } @@ -34,6 +40,7 @@ private static async Task CollectLensesAsync( SemanticModel model, SearchScope scope, List lenses, + Func? place, CancellationToken ct ) { @@ -54,13 +61,7 @@ or ConstructorDeclarationSyntax } var symbol = model.GetDeclaredSymbol(node, ct); - if (symbol is null) - { - continue; - } - - var span = node.GetLocation().GetMappedLineSpan(); - if (!span.IsValid) + if (symbol is null || Declared(node, place) is not { } span) { continue; } @@ -81,6 +82,28 @@ symbol is INamedTypeSymbol implSymbol } } + /// + /// Where the lens for sits: its declaration's own mapped range, or, + /// when maps it, that range in the file it belongs to; null when the + /// declaration maps nowhere and its lens is dropped. + /// + private static FileLinePositionSpan? Declared( + SyntaxNode node, + Func? place + ) + { + var location = node.GetLocation(); + if (place is null) + { + var mapped = location.GetMappedLineSpan(); + return mapped.IsValid ? mapped : null; + } + + return place(location.GetLineSpan().Span) is { } placed + ? new FileLinePositionSpan(location.SourceTree?.FilePath ?? "", placed) + : null; + } + /// A lens titled at the start of . private static CodeLensResult LensAt(FileLinePositionSpan span, string title) { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs index e8465f6a..988c5017 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs @@ -78,13 +78,31 @@ CancellationToken ct return new LocationListResult(); } - var metadataLocation = MetadataNavigator.ResolveMetadataSymbol(symbol, compilation); - + var metadataLocation = NamedMetadataLocation(project, symbol, compilation); return metadataLocation is null ? new LocationListResult() : new LocationListResult { Locations = [metadataLocation] }; } + /// + /// The decompiled source of , carrying its identity when an F# + /// project declares it, so the host can land in its F# source instead. [DEFINITION-CROSSLANG-IDENTITY] + /// + private static LocationResult? NamedMetadataLocation( + Project project, + ISymbol symbol, + Compilation compilation + ) + { + var decompiled = MetadataNavigator.ResolveMetadataSymbol(symbol, compilation); + return decompiled is null + ? null + : FSharpProjectSymbols.Naming( + decompiled, + FSharpProjectSymbols.IdentityOf(project, symbol) + ); + } + /// /// Single-location metadata fallback for type-definition and declaration. /// @@ -95,10 +113,9 @@ CancellationToken ct ) { var compilation = await document.Project.GetCompilationAsync(ct).ConfigureAwait(false); - return compilation is null ? null - : MetadataNavigator.ResolveMetadataSymbol(symbol, compilation); + : NamedMetadataLocation(document.Project, symbol, compilation); } /// Find the type definition of the symbol at a position. diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FSharpProjectSymbols.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FSharpProjectSymbols.cs new file mode 100644 index 00000000..b10df527 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FSharpProjectSymbols.cs @@ -0,0 +1,133 @@ +using Microsoft.CodeAnalysis; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.Common.Solutions; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Symbols a C# project reads from an F# project of the solution. C# sees an F# project only +/// as its built DLL ([DEFINITION-CROSSLANG]), so such a symbol is a metadata symbol whose +/// assembly is that DLL. Its identity, its assembly and XML doc signature, is the name the +/// F# sidecar finds it by, to navigate to its F# declaration, to find its F# uses and to +/// rename it there. Implements [DEFINITION-CROSSLANG-IDENTITY]. +/// +internal static class FSharpProjectSymbols +{ + /// + /// Whether , used in , is declared by an + /// F# project that references: it has no source, and its + /// assembly is that F# project's. Assembly names, not paths: a design-time build and + /// can spell one DLL's path apart. + /// + public static bool Declares(Project project, ISymbol symbol) + { + return !symbol.Locations.Any(location => location.IsInSource) + && symbol.ContainingAssembly?.Name is { } assembly + && FSharpAssembliesOf(project).Contains(assembly); + } + + /// + /// A metadata reference to , with the XML documentation the build + /// writes beside it, so hover shows an F# symbol's /// comments. [RAZOR-FSHARP-METADATA] + /// + public static MetadataReference Reference(string dll) + { + var xml = NativePaths.WithExtension(dll, ".xml"); + return MetadataReference.CreateFromFile( + dll, + documentation: File.Exists(xml) ? XmlDocumentationProvider.CreateFromFile(xml) : null + ); + } + + /// + /// The identity the F# sidecar finds by, when an F# project + /// declares it; null otherwise. A constructor is named by its type: F# declares a record's + /// or union's constructor nowhere but in the type itself. + /// + public static RenameIdentityResultWire? IdentityOf(Project project, ISymbol symbol) + { + var named = symbol is IMethodSymbol { MethodKind: MethodKind.Constructor } constructor + ? constructor.ContainingType + : symbol; + return Declares(project, named) ? Identity(named) : null; + } + + /// + /// reading the F# project's build output , + /// documented, in place of any reference of the same assembly: a design-time build hands C# + /// the F# project's reference assembly (obj/…/refint), which has no XML documentation + /// beside it. [RAZOR-FSHARP-METADATA] + /// + public static Solution WithDocumentedOutput(Solution solution, ProjectId projectId, string dll) + { + var stem = NativePaths.StemOf(dll); + var stale = solution + .GetProject(projectId)! + .MetadataReferences.OfType() + .Where(reference => + reference.FilePath is { } path + && NativePaths.SameName(NativePaths.StemOf(path), stem) + ) + .ToList(); + return stale + .Aggregate( + solution, + (current, reference) => current.RemoveMetadataReference(projectId, reference) + ) + .AddMetadataReference(projectId, Reference(dll)); + } + + /// + /// The assembly and XML doc signature both compilers give ; none + /// without either. FCS writes a method's signature without the ~ReturnType Roslyn + /// appends, which only a conversion operator needs to be told apart. [DEFINITION-CROSSLANG-IDENTITY] + /// + public static RenameIdentityResultWire Identity(ISymbol? symbol) + { + var assemblyName = symbol?.ContainingAssembly?.Name; + var declared = symbol is null ? null : DocumentationCommentId.CreateDeclarationId(symbol); + var xmlDocSig = + symbol is IMethodSymbol { MethodKind: not MethodKind.Conversion } + && declared?.IndexOf('~', StringComparison.Ordinal) is int at and >= 0 + ? declared[..at] + : declared; + return string.IsNullOrEmpty(assemblyName) || string.IsNullOrEmpty(xmlDocSig) + ? new RenameIdentityResultWire() + : new RenameIdentityResultWire + { + Found = true, + AssemblyName = assemblyName, + XmlDocSig = xmlDocSig, + }; + } + + /// , carrying the identity of the F# symbol it shows, if any. + public static LocationResult Naming(LocationResult location, RenameIdentityResultWire? symbol) + { + return symbol is null + ? location + : new LocationResult + { + FilePath = location.FilePath, + Line = location.Line, + Character = location.Character, + EndLine = location.EndLine, + EndCharacter = location.EndCharacter, + Symbol = symbol, + }; + } + + /// The assembly name of each F# project references. + private static HashSet FSharpAssembliesOf(Project project) + { + return project.FilePath is null + ? [] + : + [ + .. ProjectReferences + .ReadReferencedProjects(project.FilePath) + .Where(referenced => NativePaths.HasExtension(referenced, ".fsproj")) + .Select(ProjectReferences.AssemblyNameOf), + ]; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs index 6d1179ad..9ad46873 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs @@ -33,7 +33,7 @@ CancellationToken ct var span = TextSpan.FromBounds(start, Math.Max(start, endPos)); var hints = new List(); - CollectParameterHints(root, model, span, hints, ct); + CollectParameterHints(root, model, text, span, hints, ct); CollectTypeHints(root, model, text, span, hints, ct); CollectLambdaTypeHints(root, model, text, span, hints, ct); return hints; @@ -42,6 +42,7 @@ CancellationToken ct private static void CollectParameterHints( SyntaxNode root, SemanticModel model, + SourceText text, TextSpan span, List hints, CancellationToken ct @@ -50,7 +51,7 @@ CancellationToken ct foreach (var invocation in root.DescendantNodes(span).OfType()) { ct.ThrowIfCancellationRequested(); - AddParameterHintsForArgs(invocation.ArgumentList, model, hints, ct); + AddParameterHintsForArgs(invocation.ArgumentList, model, text, hints, ct); } foreach ( @@ -60,7 +61,7 @@ var creation in root.DescendantNodes(span).OfType hints, CancellationToken ct ) @@ -85,17 +87,15 @@ CancellationToken ct continue; } - var pos = arg.Expression.GetLocation().GetMappedLineSpan(); - if (!pos.IsValid) - { - continue; - } - + // The position is in this document's own text, like every other hint's, so a caller + // reading it in another file — a Razor projection read in its Razor file — can place + // it, and a generated argument it cannot place is dropped rather than misplaced. + var pos = text.Lines.GetLinePosition(arg.Expression.SpanStart); hints.Add( new InlayHintResult { - Line = pos.StartLinePosition.Line, - Character = pos.StartLinePosition.Character, + Line = pos.Line, + Character = pos.Character, Label = $"{paramSymbol.Name}:", Kind = 2, // Parameter } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs index b838ac0a..5fcb8961 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs @@ -133,10 +133,10 @@ CancellationToken ct private static string MapKind(INamedTypeSymbol symbol) { - return symbol.TypeKind == TypeKind.Interface ? "interface" - : symbol.TypeKind == TypeKind.Struct ? "struct" - : symbol.TypeKind == TypeKind.Enum ? "enum" - : symbol.TypeKind == TypeKind.Delegate ? "delegate" - : "class"; + return symbol.TypeKind == TypeKind.Interface ? "Interface" + : symbol.TypeKind == TypeKind.Struct ? "Struct" + : symbol.TypeKind == TypeKind.Enum ? "Enum" + : symbol.TypeKind == TypeKind.Delegate ? "Delegate" + : "Class"; } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs index 1c3feb9e..4f4f4e9a 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs @@ -24,18 +24,23 @@ namespace SharpLsp.Sidecar.CSharp.Workspace; /// internal sealed partial class WorkspaceManager { - /// Get code lenses for a document. + /// + /// Get code lenses for a document, or, for a Razor file, those of the members its C# + /// fragments declare, each on its Razor declaration. [RAZOR-FEATURES-CODE-LENS] + /// public Task GetCodeLensesAsync( string filePath, CancellationToken ct = default ) { - return RunScopedQueryAsync>( - filePath, - [], - (document, scope) => CodeLensResolver.GetLensesAsync(document, scope, ct), - ct - ); + return NativePaths.IsRazor(filePath) + ? GetRazorCodeLensesAsync(filePath, ct) + : RunScopedQueryAsync>( + filePath, + [], + (document, scope) => CodeLensResolver.GetLensesAsync(document, scope, ct), + ct + ); } /// Format an entire document. SEQUESTERED — not called by the LSP server. @@ -150,7 +155,10 @@ public Task GetSemanticTokensRangeAsync( ); } - /// Get inlay hints for a range. + /// + /// Get inlay hints for a range, or, for a Razor file, those of the C# its lines hold, each + /// placed back in the file. [RAZOR-FEATURES-INLAY-HINTS] + /// public Task GetInlayHintsAsync( string filePath, int startLine, @@ -158,11 +166,13 @@ public Task GetInlayHintsAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync>( - filePath, - [], - document => InlayHintResolver.GetHintsAsync(document, startLine, endLine, ct), - ct - ); + return NativePaths.IsRazor(filePath) + ? GetRazorInlayHintsAsync(filePath, startLine, endLine, ct) + : RunDocumentQueryAsync>( + filePath, + [], + document => InlayHintResolver.GetHintsAsync(document, startLine, endLine, ct), + ct + ); } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignReferences.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignReferences.cs new file mode 100644 index 00000000..dc6780e3 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignReferences.cs @@ -0,0 +1,67 @@ +using ReferencesResult = Outcome.Result; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// A symbol the other sidecar names by identity, and whether its declaration counts. +internal readonly record struct ForeignSymbolQuery( + string AssemblyName, + string XmlDocSig, + bool IncludeDeclaration +); + +internal sealed partial class WorkspaceManager +{ + /// + /// Every C# and Razor use of the symbol the other sidecar names by identity, and its C# + /// declaration when C# declares it. Razor uses land in their Razor files, and hidden + /// scaffolding is dropped. Implements [REFERENCES-CROSS-LANGUAGE] and [RAZOR-FSHARP-REFERENCES]. + /// + public async Task GetForeignReferencesAsync( + ForeignSymbolQuery query, + CancellationToken ct = default + ) + { + try + { + return new ReferencesResult.Ok( + await ForeignReferencesAsync(query, ct).ConfigureAwait(false) + ); + } + catch (Exception ex) + { + return ReferencesResult.Failure(ex.Message); + } + } + + private async Task ForeignReferencesAsync( + ForeignSymbolQuery query, + CancellationToken ct + ) + { + var solution = _solution; + if ( + solution is null + || string.IsNullOrWhiteSpace(query.AssemblyName) + || string.IsNullOrWhiteSpace(query.XmlDocSig) + ) + { + return new LocationListResult(); + } + + var scope = new SearchScope(solution, null, null); + var identity = new ForeignRenameKey(query.AssemblyName, query.XmlDocSig); + var symbols = await ResolveForeignSymbolsAsync(solution, identity, ct) + .ConfigureAwait(false); + var found = await Task.WhenAll( + symbols.Select(symbol => + ReferenceResolver.ReferencesOfAsync(symbol, scope, query.IncludeDeclaration, ct) + ) + ) + .ConfigureAwait(false); + return await MapLocationsAsync( + ReferenceResolver.Distinct(found.SelectMany(result => result.Locations)), + ct + ) + .ConfigureAwait(false); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs index 3b2c4ca2..32315e00 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs @@ -48,6 +48,11 @@ public async Task GetRenameIdentityAsync( } } + /// + /// The identity of the symbol a rename at the position targets: a C# source symbol, or an + /// F# project's symbol C# uses. A Razor position resolves through its projection, so a + /// rename asked from a Razor file reaches the other language too. [RAZOR-FSHARP-RENAME] + /// private async Task ResolveRenameIdentityAsync( string filePath, int line, @@ -55,29 +60,13 @@ private async Task ResolveRenameIdentityAsync( 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) + var at = await ResolveQueryTargetAsync(filePath, (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, - }; + var target = at is null + ? null + : await FindRenameTargetAsync(at.Document, at.Line, at.Character, ct) + .ConfigureAwait(false); + return FSharpProjectSymbols.Identity(target?.Symbol); } public async Task RenameForeignAsync( diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs index d79a0c95..002dec09 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs @@ -211,7 +211,11 @@ _lastCompletionList is null ); result.AdditionalEdits.AddRange( _lastCompletionOrigin is { } origin - ? RazorCompletionEdits.Map(origin, additional) + ? RazorCompletionEdits.Map( + origin, + additional, + await RazorMarkupEngine.ProcessAsync(origin.Razor, ct).ConfigureAwait(false) + ) : additional.Select(textChange => DocumentText.ToTextEdit(text, textChange)) ); return result; diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs index 8a8e571f..f5858380 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs @@ -12,10 +12,13 @@ namespace SharpLsp.Sidecar.CSharp.Workspace; /// -/// Call hierarchy and type hierarchy workspace methods. +/// Call hierarchy and type hierarchy workspace methods: each runs where the position lands — a +/// C# document, or a Razor file's projection — and places what it finds in the file it belongs +/// to, dropping hidden scaffolding ([RAZOR-FEATURES-HIERARCHY]). /// internal sealed partial class WorkspaceManager { + /// Prepare a call hierarchy item where the position lands. public Task PrepareCallHierarchyAsync( string filePath, int line, @@ -23,14 +26,10 @@ public Task PrepareCallHierarchyAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( - filePath, - null, - document => CallHierarchyResolver.PrepareAsync(document, line, character, ct), - ct - ); + return RunItemAtAsync(filePath, (line, character), CallHierarchyResolver.PrepareAsync, ct); } + /// The calls of the symbol the position lands on, from each project's active framework. public Task GetIncomingCallsAsync( string filePath, int line, @@ -38,7 +37,7 @@ public Task GetIncomingCallsAsync( CancellationToken ct = default ) { - return RunScopedAtAsync( + return RunScopedRowsAtAsync( filePath, (line, character), CallHierarchyResolver.GetIncomingAsync, @@ -46,6 +45,7 @@ public Task GetIncomingCallsAsync( ); } + /// The calls the symbol the position lands on makes. public Task GetOutgoingCallsAsync( string filePath, int line, @@ -53,14 +53,15 @@ public Task GetOutgoingCallsAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( + return RunRowsAtAsync( filePath, - new List(), - document => CallHierarchyResolver.GetOutgoingAsync(document, line, character, ct), + (line, character), + CallHierarchyResolver.GetOutgoingAsync, ct ); } + /// Prepare a type hierarchy item where the position lands. public Task PrepareTypeHierarchyAsync( string filePath, int line, @@ -68,14 +69,10 @@ public Task PrepareTypeHierarchyAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( - filePath, - null, - document => TypeHierarchyResolver.PrepareAsync(document, line, character, ct), - ct - ); + return RunItemAtAsync(filePath, (line, character), TypeHierarchyResolver.PrepareAsync, ct); } + /// The supertypes of the type the position lands on. public Task GetSupertypesAsync( string filePath, int line, @@ -83,14 +80,18 @@ public Task GetSupertypesAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( + return RunRowsAtAsync( filePath, - new List(), - document => TypeHierarchyResolver.GetSupertypesAsync(document, line, character, ct), + (line, character), + TypeHierarchyResolver.GetSupertypesAsync, ct ); } + /// + /// The subtypes of the type the position lands on, from each project's active framework + /// ([NETFX-PROJECTS-CSHARP]). + /// public Task GetSubtypesAsync( string filePath, int line, @@ -98,7 +99,7 @@ public Task GetSubtypesAsync( CancellationToken ct = default ) { - return RunScopedAtAsync( + return RunScopedRowsAtAsync( filePath, (line, character), TypeHierarchyResolver.GetSubtypesAsync, @@ -106,21 +107,93 @@ public Task GetSubtypesAsync( ); } + /// The hierarchy item finds, placed in the file it belongs to. + private Task RunItemAtAsync( + string filePath, + (int Line, int Character) at, + Func> prepare, + CancellationToken ct + ) + { + return RunPlacedAtAsync( + filePath, + at, + null, + prepare, + (item, token) => PlaceAsync(item, token), + ct + ); + } + + /// The hierarchy rows finds, each placed in its file. + private Task, string>> RunRowsAtAsync( + string filePath, + (int Line, int Character) at, + Func>> query, + CancellationToken ct + ) + where T : HierarchyItem + { + return RunPlacedAtAsync( + filePath, + at, + [], + query, + (rows, token) => PlaceAsync(rows, token), + ct + ); + } + + /// + /// What finds where the position lands — a C# document, or a Razor + /// file's projection — placed by in the file it belongs to. + /// + private Task> RunPlacedAtAsync( + string filePath, + (int Line, int Character) at, + TValue empty, + Func> query, + Func> place, + CancellationToken ct + ) + { + return RunQueryAsync( + () => ResolveHierarchyTargetAsync(filePath, at, ct), + empty, + async target => + await place( + await query(target.Document, target.Line, target.Character, ct) + .ConfigureAwait(false), + ct + ) + .ConfigureAwait(false) + ); + } + /// - /// A hierarchy list at a position, searched in each project's active framework - /// ([NETFX-PROJECTS-CSHARP]); empty when the document is not in the solution. + /// for a query that searches beyond the document: + /// also gets each project's active framework to search. /// - private Task, string>> RunScopedAtAsync( + private Task, string>> RunScopedRowsAtAsync( string filePath, (int Line, int Character) at, Func>> query, CancellationToken ct ) + where T : HierarchyItem { - return RunScopedQueryAsync( + return RunRowsAtAsync( filePath, - new List(), - (document, scope) => query(document, scope, at.Line, at.Character, ct), + at, + async (document, line, character, token) => + await query( + document, + await ScopeAsync(document, token).ConfigureAwait(false), + line, + character, + token + ) + .ConfigureAwait(false), ct ); } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Navigation.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Navigation.cs index 7f60fe70..53861ecc 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Navigation.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Navigation.cs @@ -74,32 +74,71 @@ public async Task GetDefinitionAsync( filePath, (line, character), new LocationListResult(), - async target => - await MapLocationsAsync( - await DefinitionResolver - .ResolveDefinitionLocationsAsync( - target.Document, - target.Line, - target.Character, - ct - ) - .ConfigureAwait(false), - ct - ) - .ConfigureAwait(false), + target => DefinitionAtAsync(target, ct), ct ) .ConfigureAwait(false); - // Where no C# fragment answers in a Razor file, the markup name there does. - // [RAZOR-FEATURES-DEFINITION] - return - csharp - is ImplementationsResult.Ok { Value.Locations.Count: 0 } - && NativePaths.IsRazor(filePath) - ? await MarkupDefinitionAsync(filePath, line, character, ct).ConfigureAwait(false) + return IsEmpty(csharp) && NativePaths.IsRazor(filePath) + ? await RazorMarkupDefinitionAsync(filePath, line, character, ct).ConfigureAwait(false) : csharp; } + /// + /// The definition at a target: the collocated module an import call names, ahead of any + /// enclosing declaration the position would fall back to ([RAZOR-COLLOCATION-JS]); else the + /// declarations of the symbol there. + /// + private async Task DefinitionAtAsync( + QueryTarget target, + CancellationToken ct + ) + { + var module = await ModuleStartAsync(target, ct).ConfigureAwait(false); + return module.Locations.Count > 0 + ? module + : await MapLocationsAsync( + await DefinitionResolver + .ResolveDefinitionLocationsAsync( + target.Document, + target.Line, + target.Character, + ct + ) + .ConfigureAwait(false), + ct + ) + .ConfigureAwait(false); + } + + /// + /// Where no C# fragment answers in a Razor file: the markup name there + /// ([RAZOR-FEATURES-DEFINITION]), else the collocated stylesheet's selectors of the class at + /// the caret ([RAZOR-COLLOCATION-CSS]). + /// + private async Task RazorMarkupDefinitionAsync( + string filePath, + int line, + int character, + CancellationToken ct + ) + { + var markup = await MarkupDefinitionAsync(filePath, line, character, ct) + .ConfigureAwait(false); + return IsEmpty(markup) + ? new ImplementationsResult.Ok( + await CollocatedClassDefinitionAsync(filePath, line, character, ct) + .ConfigureAwait(false) + ) + : markup; + } + + /// Whether a definition answered, and found nothing. + private static bool IsEmpty(ImplementationsResult result) + { + return result + is ImplementationsResult.Ok { Value.Locations.Count: 0 }; + } + /// Go to type definition at a position. public Task GetTypeDefinitionAsync( string filePath, diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCodeActions.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCodeActions.cs new file mode 100644 index 00000000..45d7fcf0 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCodeActions.cs @@ -0,0 +1,113 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; +using CodeActionsResult = Outcome.Result< + System.Collections.Generic.List, + string +>; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Razor code actions: quick fixes for C# names the projection cannot bind, and refactorings +/// that move a block or elements into a new file. Each is offered with its edit computed, under +/// a negative id so it never meets a Roslyn action's; a new request replaces the last offers. +/// Implements [RAZOR-CODE-ACTIONS]. +/// +internal sealed partial class WorkspaceManager +{ + private ImmutableDictionary _razorActions = []; + + private int _lastRazorAction; + + /// The Razor file's code actions at ; none for a file no project lists. + private Task GetRazorCodeActionsAsync( + string filePath, + LinePositionSpan range, + CancellationToken ct + ) + { + return RunQueryAsync( + async () => + { + var projection = await FindRazorProjectionAsync(filePath, ct).ConfigureAwait(false); + var markup = await FindRazorMarkupAsync(filePath, ct).ConfigureAwait(false); + return projection is null || markup is null + ? null + : new RazorFile(projection, markup); + }, + new List(), + async found => + Offer( + await RazorActionsAsync(found.Projection, found.Markup, range, ct) + .ConfigureAwait(false) + ) + ); + } + + /// A Razor file's projection and markup facts, which its code actions read. + private sealed record RazorFile(RazorProjection Projection, RazorMarkup Markup); + + private static async Task> RazorActionsAsync( + RazorProjection projection, + RazorMarkup markup, + LinePositionSpan range, + CancellationToken ct + ) + { + var span = markup.Text.Lines.GetTextSpan(range); + var model = + await projection.Generated.GetSemanticModelAsync(ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("The projection has no semantic model"); + var fixes = await RazorQuickFixes + .ForAsync(projection, markup, span, ct) + .ConfigureAwait(false); + RazorCodeAction?[] refactorings = + [ + RazorExtractActions.CodeBehind(projection, markup, model.Compilation, span.Start), + RazorExtractActions.Component(projection, markup, model, span), + ]; + return fixes.Concat(refactorings.OfType()); + } + + /// Offer in place of the last ones offered. + private List Offer(IEnumerable actions) + { + var offered = actions + .Select(action => (Id: Interlocked.Decrement(ref _lastRazorAction), Action: action)) + .ToList(); + _razorActions = offered.ToImmutableDictionary( + entry => entry.Id, + entry => entry.Action.Edit + ); + return + [ + .. offered.Select(entry => new CodeActionItem + { + Id = entry.Id, + Title = entry.Action.Title, + Kind = entry.Action.Kind, + IsPreferred = entry.Action.IsPreferred, + }), + ]; + } + + /// The edit of a Razor action offered last; null for any other id. + private WorkspaceEditResult? ResolveRazorAction(int actionId) + { + return _razorActions.GetValueOrDefault(actionId); + } + + /// Whether names a Razor action: Roslyn's ids count up from one. + private static bool IsRazorAction(int actionId) + { + return actionId < 0; + } + + /// Whether a code action request is for a Razor file. + private static bool IsRazorRequest(string filePath) + { + return NativePaths.IsRazor(filePath); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCollocation.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCollocation.cs new file mode 100644 index 00000000..a2ceddaf --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCollocation.cs @@ -0,0 +1,157 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Serilog; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// The collocated files of a Razor file: the stylesheet its CSS isolation declares +/// (Card.razor.css) and the JavaScript module it imports (Card.razor.js). +/// Implements [RAZOR-COLLOCATION]. +/// +internal sealed partial class WorkspaceManager +{ + /// + /// The items the collocated stylesheet adds at a caret inside a class value; none + /// elsewhere or when the file has no stylesheet. [RAZOR-COLLOCATION-CSS] + /// + private static async Task> CollocatedClassCompletionsAsync( + TextDocument? razor, + RazorMarkup markup, + int offset, + CancellationToken ct + ) + { + return + razor is not null + && RazorCollocation.ClassValueAt(markup, offset) is { } at + && await CollocatedStylesheetAsync(razor, ct).ConfigureAwait(false) is { } stylesheet + ? RazorCollocation.ClassCompletions(markup, stylesheet, at) + : []; + } + + /// The start of the collocated module an import call at the target's position names. + private static async Task ModuleStartAsync( + QueryTarget target, + CancellationToken ct + ) + { + var module = await ImportedModuleAsync(target, ct).ConfigureAwait(false); + return module is null + ? new LocationListResult() + : new LocationListResult { Locations = [StartOf(module)] }; + } + + /// The selectors of the class named in a class value's collocated stylesheet. [RAZOR-COLLOCATION-CSS] + private async Task CollocatedClassDefinitionAsync( + string filePath, + int line, + int character, + CancellationToken ct + ) + { + var razor = FindRazorDocument(filePath); + if (razor is null) + { + return new LocationListResult(); + } + + var markup = await RazorMarkupEngine.ProcessAsync(razor, ct).ConfigureAwait(false); + return + markup is not null + && DocumentText.TryOffset(markup.Text, new LinePosition(line, character)) is { } offset + && RazorCollocation.ClassValueAt(markup, offset) is { } at + && await CollocatedStylesheetAsync(razor, ct).ConfigureAwait(false) is { } stylesheet + ? RazorCollocation.ClassDefinitions(markup, stylesheet, at) + : new LocationListResult(); + } + + /// + /// The stylesheet beside , from its project's own copy of the file + /// when it lists one, else read from disk: an editor never sends a stylesheet as a document + /// ([RAZOR-COLLOCATION-CSS]). + /// + private static async Task CollocatedStylesheetAsync( + TextDocument razor, + CancellationToken ct + ) + { + var path = NativePaths.StylesheetOf(razor.FilePath ?? ""); + var buffered = razor.Project.AdditionalDocuments.FirstOrDefault(document => + NativePaths.AreEqual(document.FilePath, path) + ); + var text = buffered is null + ? await ReadCollocatedTextAsync(path, ct).ConfigureAwait(false) + : await buffered.GetTextAsync(ct).ConfigureAwait(false); + return text is null ? null : RazorStylesheet.Parse(path, text); + } + + private static async Task ReadCollocatedTextAsync( + string path, + CancellationToken ct + ) + { + if (!File.Exists(path)) + { + return null; + } + + try + { + return SourceText.From(await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Log.Warning("[Razor] collocated file unreadable {File} {Failure}", path, ex.GetType()); + return null; + } + } + + /// The collocated module file an import call at a position names in the project. + private static async Task ImportedModuleAsync(QueryTarget target, CancellationToken ct) + { + return + await ModuleLiteralAsync(target, ct).ConfigureAwait(false) is { } literal + && await target.Document.GetSemanticModelAsync(ct).ConfigureAwait(false) is { } model + && RazorCollocation.ImportedModule(model, literal, ct) is { } path + ? ModuleTarget(target.Document.Project, path) + : null; + } + + /// The string literal at the target's position, when the caret sits in one. + private static async Task ModuleLiteralAsync( + QueryTarget target, + CancellationToken ct + ) + { + var root = await target.Document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + var text = await target.Document.GetTextAsync(ct).ConfigureAwait(false); + return + root is not null + && DocumentText.TryOffset(text, new LinePosition(target.Line, target.Character)) + is { } offset + ? RazorCollocation.ModuleLiteral(root, offset) + : null; + } + + /// The module file names in the project, when it exists. + private static string? ModuleTarget(Project project, string path) + { + return + project.FilePath is not null + && RazorCollocation.ModuleFile(NativePaths.DirectoryOf(project.FilePath), path) + is { } module + && File.Exists(module) + ? module + : null; + } + + /// A definition location at the very start of . + private static LocationResult StartOf(string filePath) + { + return new LocationResult { FilePath = filePath }; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHierarchy.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHierarchy.cs new file mode 100644 index 00000000..d5287cc3 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHierarchy.cs @@ -0,0 +1,240 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; +using CodeLensesResult = Outcome.Result< + System.Collections.Generic.List, + string +>; +using InlayHintsResult = Outcome.Result< + System.Collections.Generic.List, + string +>; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Inlay hints, code lenses and hierarchies answered from Razor positions: each runs on the +/// file's C# projection and puts what it finds back in the Razor text, dropping whatever lies +/// in the projection's hidden scaffolding. Implements [RAZOR-ARCHITECTURE-PROJECTION], +/// [RAZOR-FEATURES-INLAY-HINTS], [RAZOR-FEATURES-CODE-LENS] and [RAZOR-FEATURES-HIERARCHY]. +/// +internal sealed partial class WorkspaceManager +{ + /// + /// A Razor file's inlay hints for its lines ..: + /// those of the generated lines they map to, each placed back in the file. + /// [RAZOR-FEATURES-INLAY-HINTS] + /// + private Task GetRazorInlayHintsAsync( + string filePath, + int startLine, + int endLine, + CancellationToken ct + ) + { + return RunQueryAsync>( + () => FindRazorProjectionAsync(filePath, ct), + [], + async projection => + await HintsAsync(projection, startLine, endLine, ct).ConfigureAwait(false) + ); + } + + private static async Task> HintsAsync( + RazorProjection projection, + int startLine, + int endLine, + CancellationToken ct + ) + { + // A reader asking to the end of a line means to see it, but the resolver's range stops + // at the start of the line it ends on, so the window reaches one line further. + var hints = RazorHints.Lines(projection, startLine, endLine) is not { } lines + ? [] + : await InlayHintResolver + .GetHintsAsync(projection.Generated, lines.Start, lines.End + 1, ct) + .ConfigureAwait(false); + return RazorHints.Map(hints, projection, startLine, endLine); + } + + /// + /// A Razor file's code lenses: those of the members its projection declares in C# fragments, + /// each on its Razor declaration, so its class and generated members are not lensed. + /// [RAZOR-FEATURES-CODE-LENS] + /// + private Task GetRazorCodeLensesAsync(string filePath, CancellationToken ct) + { + return RunQueryAsync>( + () => FindRazorProjectionAsync(filePath, ct), + [], + async projection => + await CodeLensResolver + .GetLensesAsync( + projection.Generated, + await ScopeAsync(projection.Generated, ct).ConfigureAwait(false), + ct, + projection.ToRazor + ) + .ConfigureAwait(false) + ); + } + + /// The locations hierarchy answers search, in each project's active framework. + private Task ScopeAsync(Document document, CancellationToken ct) + { + return SearchScope.OfAsync(document, _activeFrameworks, ct); + } + + /// + /// Where a hierarchy request at a Razor position runs: the mapped C# position, or, at the + /// very start of the file, the component class its projection generates. The second is the + /// inverse of the rule that lands that class at the file's start, so the follow-up a client + /// makes from such an item — subtypes of a component, and its calls — answers from the + /// component's own file. [RAZOR-ARCHITECTURE-PROJECTION] + /// + private async Task ResolveHierarchyTargetAsync( + string filePath, + (int Line, int Character) at, + CancellationToken ct + ) + { + return await ResolveQueryTargetAsync(filePath, at, ct).ConfigureAwait(false) + ?? ( + NativePaths.IsRazor(filePath) && at is (0, 0) + ? await ComponentTargetAsync(filePath, ct).ConfigureAwait(false) + : null + ); + } + + /// The component class generates, as a query target. + private async Task ComponentTargetAsync(string razorPath, CancellationToken ct) + { + var projection = await FindRazorProjectionAsync(razorPath, ct).ConfigureAwait(false); + var declared = projection is null ? null : FileClass(projection); + if (projection is null || declared is null) + { + return null; + } + + var at = projection.GeneratedText.Lines.GetLinePosition(declared.Identifier.SpanStart); + return new QueryTarget(projection.Generated, at.Line, at.Character, projection); + } + + /// The class a projection generates for its Razor file: the file's own type. + private static ClassDeclarationSyntax? FileClass(RazorProjection projection) + { + return projection + .GeneratedRoot.DescendantNodes() + .OfType() + .FirstOrDefault(); + } + + /// + /// placed in the file it belongs to, or null when its declaration + /// cannot be placed. A call row's sites are inside its declaration, so they are placed with + /// it: within a C# fragment they already carry their Razor range, and a row that cannot be + /// placed takes its sites with it. [RAZOR-FEATURES-HIERARCHY] + /// + private async Task PlaceAsync(T? item, CancellationToken ct) + where T : HierarchyItem + { + if (item is null) + { + return null; + } + + var declaration = DeclarationOf(item); + if (await MapLocationAsync(declaration, ct).ConfigureAwait(false) is { } mapped) + { + return Place(item, mapped); + } + + var file = await FileMemberAsync(declaration, ct).ConfigureAwait(false); + return file is null + ? null + : Place(AsFile(item, file), new LocationResult { FilePath = file }); + } + + /// + /// The Razor file whose projection declares in its own + /// generated class, its own members included; null for a location in no projection, or in + /// the scaffolding of one outside that class, which has nothing in the file to point at. A + /// component is its file, so the class and its members are the file's C#, and a call made from + /// them names the file as its caller ([RAZOR-ARCHITECTURE-PROJECTION]). + /// + private async Task FileMemberAsync(LocationResult declaration, CancellationToken ct) + { + var projection = _solution is null + ? null + : await RazorLocations + .FindProjectionAsync(_solution, declaration.FilePath, ct) + .ConfigureAwait(false); + return projection is not null && Owns(projection, declaration) + ? projection.RazorPath + : null; + } + + /// + /// A generated member's item as the component it belongs to: named by its Razor file and a + /// class, because a follow-up at the file's start runs on the component class + /// ([RAZOR-FEATURES-HIERARCHY]), never on a member the file does not show. + /// + private static T AsFile(T item, string razorPath) + where T : HierarchyItem + { + item.Name = NativePaths.NameOf(razorPath); + item.Kind = "Class"; + return item; + } + + /// Whether the file's own generated class holds . + private static bool Owns(RazorProjection projection, LocationResult declaration) + { + var at = new LinePosition(declaration.Line, declaration.Character); + return DocumentText.TryOffset(projection.GeneratedText, at) is { } offset + && FileClass(projection) is { } own + && projection.GeneratedRoot.FindToken(offset).Parent?.AncestorsAndSelf().Contains(own) + == true; + } + + /// placed in their files, hidden scaffolding dropped. + private async Task> PlaceAsync(List items, CancellationToken ct) + where T : HierarchyItem + { + var placed = new List(); + foreach (var item in items) + { + if (await PlaceAsync(item, ct).ConfigureAwait(false) is { } found) + { + placed.Add(found); + } + } + + return placed; + } + + private static LocationResult DeclarationOf(HierarchyItem item) + { + return new LocationResult + { + FilePath = item.FilePath, + Line = item.Line, + Character = item.Character, + EndLine = item.EndLine, + EndCharacter = item.EndCharacter, + }; + } + + private static T Place(T item, LocationResult place) + where T : HierarchyItem + { + item.FilePath = place.FilePath; + item.Line = place.Line; + item.Character = place.Character; + item.EndLine = place.EndLine; + item.EndCharacter = place.EndCharacter; + return item; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorMarkup.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorMarkup.cs index ddcfc578..2591b38c 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorMarkup.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorMarkup.cs @@ -85,7 +85,16 @@ CancellationToken ct var markup = csharpAnswered ? null : RazorMarkupCompletion.At(found.Markup, found.Compilation, found.Offset); - return [.. RazorMarkupCompletion.Directives(found.Markup, found.Offset), .. markup ?? []]; + // The collocated stylesheet's classes complete inside a `class` value. + // [RAZOR-COLLOCATION-CSS] + var classes = await CollocatedClassCompletionsAsync(razor, found.Markup, found.Offset, ct) + .ConfigureAwait(false); + return + [ + .. RazorMarkupCompletion.Directives(found.Markup, found.Offset), + .. markup ?? [], + .. classes, + ]; } /// What a markup query at a position works on: the facts, the compilation, the offset. diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorSelection.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorSelection.cs new file mode 100644 index 00000000..bf7d2d65 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorSelection.cs @@ -0,0 +1,76 @@ +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.CSharp.Razor; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Selection ranges of Razor files: each position chains from its innermost C# fragment or +/// markup span out to the whole document, so an editor can grow the selection outward. A file +/// no project lists, or a non-Razor path, answers empty. Implements +/// [RAZOR-FEATURES-SELECTION]. +/// +internal sealed partial class WorkspaceManager +{ + /// One answer per position, in order; empty when the file has no workspace presence. + public async Task, string>> GetSelectionRangesAsync( + string filePath, + List<(int Line, int Character)> positions, + CancellationToken ct + ) + { + try + { + var projection = await FindRazorProjectionAsync(filePath, ct).ConfigureAwait(false); + var markup = await FindRazorMarkupAsync(filePath, ct).ConfigureAwait(false); + var text = markup?.Text ?? projection?.RazorText; + return new Outcome.Result, string>.Ok< + List, + string + >( + text is null + ? [.. positions.Select(_ => new SelectionRangeResult())] + : [.. positions.Select(position => At(projection, markup, text, position))] + ); + } + catch (Exception ex) + { + return Outcome.Result, string>.Failure(ex.Message); + } + } + + /// One position's chain, mapped onto the file's lines. + private static SelectionRangeResult At( + RazorProjection? projection, + RazorMarkup? markup, + SourceText text, + (int Line, int Character) position + ) + { + var offset = DocumentText.TryOffset( + text, + new LinePosition(position.Line, position.Character) + ); + return new SelectionRangeResult + { + Ranges = + [ + .. ( + offset is { } found ? RazorSelectionRanges.At(projection, markup, found) : [] + ).Select(span => SpanOf(text, span)), + ], + }; + } + + /// A Razor offset span as its (line, character) bounds. + private static SelectionSpanResult SpanOf(SourceText text, TextSpan span) + { + var at = text.Lines.GetLinePositionSpan(span); + return new SelectionSpanResult + { + StartLine = at.Start.Line, + StartCharacter = at.Start.Character, + EndLine = at.End.Line, + EndCharacter = at.End.Character, + }; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs index e2c31307..09e0c428 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs @@ -13,11 +13,16 @@ namespace SharpLsp.Sidecar.CSharp.Workspace; internal sealed partial class WorkspaceManager { + /// + /// What a rename at a position renames. A foreign target is a symbol an F# project + /// declares, which C# reads from its DLL: F# renames its declaration. [RAZOR-FSHARP-RENAME] + /// private readonly record struct RenameTarget( Document Document, SourceText Text, ISymbol Symbol, - SyntaxToken Token + SyntaxToken Token, + bool Foreign ); private readonly record struct RenameSolutions( @@ -80,21 +85,48 @@ 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(); - } + return target is null || _solution is null || !CanUseNewName(target.Value, newName) + ? EmptyRenameResult() + : target.Value.Foreign + ? await RenameFSharpSymbolAsync(target.Value.Symbol, newName, ct) + .ConfigureAwait(false) + : await RenameSourceSymbolAsync(_solution, target.Value.Symbol, newName, ct) + .ConfigureAwait(false); + } - var renamed = await RenameSolutionAsync(_solution, target.Value.Symbol, newName, ct) + /// A C# source symbol's rename: Roslyn's edits, and its Razor uses'. + private static async Task RenameSourceSymbolAsync( + Solution solution, + ISymbol symbol, + string newName, + CancellationToken ct + ) + { + var renamed = await RenameSolutionAsync(solution, symbol, newName, ct) .ConfigureAwait(false); - var edits = await BuildRenameEditsAsync(_solution, renamed, newName, ct) + var edits = await BuildRenameEditsAsync(solution, renamed, newName, ct) .ConfigureAwait(false); var razor = await RazorRenameEdits - .FindAsync(_solution, target.Value.Symbol, newName, ct) + .FindAsync(solution, symbol, newName, ct) .ConfigureAwait(false); return RenameSuccess(new WorkspaceEditResult { DocumentChanges = [.. edits, .. razor] }); } + /// + /// An F# project's symbol, renamed from C# or Razor: its C# and Razor uses here. The host + /// adds F#'s edits, its declaration included, through the symbol's identity. + /// [RAZOR-FSHARP-RENAME] [RENAME-CROSSLANGUAGE] + /// + private Task RenameFSharpSymbolAsync( + ISymbol symbol, + string newName, + CancellationToken ct + ) + { + var identity = FSharpProjectSymbols.Identity(symbol); + return RenameForeignCoreAsync(identity.AssemblyName, identity.XmlDocSig, newName, ct); + } + /// /// The rename target at a position, and the Razor file it was asked from. A Razor position /// outside every C# fragment has no target; any other file must be in the workspace. @@ -139,8 +171,11 @@ CancellationToken ct 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) + var foreign = + targetSymbol is not null + && FSharpProjectSymbols.Declares(document.Project, targetSymbol); + return targetSymbol is not null && (foreign || IsSourceSymbol(targetSymbol)) + ? new RenameTarget(document, text, targetSymbol, token!.Value, foreign) : null; } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs index f8f1a0fb..13f523ff 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs @@ -317,6 +317,15 @@ public Task GetCodeActionsAsync( CancellationToken ct = default ) { + if (IsRazorRequest(filePath)) + { + var range = new LinePositionSpan( + new LinePosition(startLine, startCharacter), + new LinePosition(endLine, endCharacter) + ); + return GetRazorCodeActionsAsync(filePath, range, ct); + } + return RunDocumentQueryAsync>( filePath, [], @@ -347,9 +356,11 @@ public async Task ResolveCodeActionAsync( return ResolveResult.Failure("No solution loaded"); } - var result = await _codeActionResolver - .ResolveAsync(actionId, _solution, ct) - .ConfigureAwait(false); + var result = IsRazorAction(actionId) + ? ResolveRazorAction(actionId) + : await _codeActionResolver + .ResolveAsync(actionId, _solution, ct) + .ConfigureAwait(false); return result is null ? ResolveResult.Failure($"Code action {actionId} not found") : new ResolveResult.Ok(result); @@ -596,6 +607,7 @@ var stubRef in project solution = solution.RemoveProjectReference(projectId, stubRef); } + solution = FSharpProjectSymbols.WithDocumentedOutput(solution, projectId, dll); project = solution.GetProject(projectId)!; // Add the referenced project's output DLL *and its sibling assemblies*. @@ -616,7 +628,7 @@ var stubRef in project { solution = solution.AddMetadataReference( projectId, - MetadataReference.CreateFromFile(sibling) + FSharpProjectSymbols.Reference(sibling) ); } } diff --git a/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs b/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs index 0c5fac11..a747a1a3 100644 --- a/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs +++ b/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs @@ -40,4 +40,47 @@ public static string FromRazorProjectPath(string projectDirectory, string razorP { return Resolve(projectDirectory, razorPath.TrimStart('/')); } + + /// + /// The code-behind partial class file of a component: Counter.razor.cs beside + /// Counter.razor. [RAZOR-CODE-ACTIONS-CODE-BEHIND] + /// + public static string CodeBehindOf(string componentPath) + { + return componentPath + ".cs"; + } + + /// + /// The stylesheet that scopes a Razor file's CSS: Counter.razor.css beside + /// Counter.razor. [RAZOR-COLLOCATION-CSS] + /// + public static string StylesheetOf(string razorPath) + { + return razorPath + ".css"; + } + + /// + /// The file a JavaScript module path names, the app's base path being the project directory: + /// ./Components/Card.razor.js and /Components/Card.razor.js both name + /// Components/Card.razor.js in it. Null for a path that names no file. [RAZOR-COLLOCATION-JS] + /// + public static string? FromModulePath(string projectDirectory, string modulePath) + { + var relative = AppRelative(Slashed(modulePath)); + return relative.Length > 0 ? Resolve(projectDirectory, relative) : null; + } + + /// A module path with the app's base path removed: ./a/b.js is a/b.js. + private static string AppRelative(string path) + { + return path.StartsWith("./", StringComparison.Ordinal) ? AppRelative(path[2..]) + : path.StartsWith('/') ? AppRelative(path[1..]) + : path; + } + + /// The component file named in : Name.razor. + public static string ComponentIn(string folder, string name) + { + return Join(folder, name + ".razor"); + } } diff --git a/src/sidecars/SharpLsp.Sidecar.Common/Solutions/ProjectReferences.cs b/src/sidecars/SharpLsp.Sidecar.Common/Solutions/ProjectReferences.cs index 193261f4..7986af22 100644 --- a/src/sidecars/SharpLsp.Sidecar.Common/Solutions/ProjectReferences.cs +++ b/src/sidecars/SharpLsp.Sidecar.Common/Solutions/ProjectReferences.cs @@ -56,7 +56,7 @@ .. doc.Descendants() return null; } - var dllName = AssemblyName(projectFilePath) + ".dll"; + var dllName = AssemblyNameOf(projectFilePath) + ".dll"; return Directory .EnumerateFiles(binDir, dllName, SearchOption.AllDirectories) .OrderByDescending(File.GetLastWriteTimeUtc) @@ -69,7 +69,7 @@ .. doc.Descendants() } /// Simple assembly name: <AssemblyName> or the file stem. - private static string AssemblyName(string projectFilePath) + public static string AssemblyNameOf(string projectFilePath) { try { diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsTests.fs b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsTests.fs new file mode 100644 index 00000000..b71495ed --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsTests.fs @@ -0,0 +1,157 @@ +/// The F# side of a symbol C# or Razor names by identity, its assembly and XML doc +/// signature: where F# declares it and every F# use, searched only in the project that +/// builds its assembly and the projects reading that one. Implements [RAZOR-TESTING] for +/// [DEFINITION-CROSSLANG-IDENTITY] and [REFERENCES-CROSS-LANGUAGE]. +module SharpLsp.Sidecar.FSharp.Tests.FSharpForeignSymbolsTests + +open Xunit +open FSharp.Compiler.Symbols +open SharpLsp.Sidecar.FSharp +open SharpLsp.Sidecar.FSharp.Tests.FSharpCoverageTests +open SharpLsp.Sidecar.FSharp.Tests.FSharpRenameSemanticTests +open SharpLsp.Sidecar.Common + +let private DOMAIN = + source + [ "namespace Domain" + "" + "type Player = { Name: string; Score: int }" + "" + "module Scoring =" + " let total (players: seq) = players |> Seq.sumBy (fun player -> player.Score)" + " let best (players: seq) = players |> Seq.maxBy (fun player -> player.Score)" ] + +let private BOARD = + source + [ "module Board" + "open Domain" + "let ada = { Name = \"Ada\"; Score = 3 }" + "let points = ada.Score + Scoring.total [ ada ]" ] + +/// A symbol of the test project's assembly, as C# names it. +let private named xmlDocSig : FSharpForeignSymbols.SymbolIdentity = + { AssemblyName = "TestProject"; XmlDocSig = xmlDocSig } + +let private placed (locations: FSharpWorkspace.DefinitionLocation list) = + locations + |> List.map (fun location -> string (NativePaths.NameOf location.FilePath), location.Line, location.Character) + |> List.sort + +let private domain () = loadWorkspace [ "Domain.fs", DOMAIN; "Board.fs", BOARD ] + +[] +let ``declarations place a type, a record field and a function where F# declares them`` () = task { + let! (state, dir, _, _) = domain () + try + let! player = FSharpForeignSymbols.declarations state (named "T:Domain.Player") true + Assert.Equal<(string * int * int) list>([ "Domain.fs", 2, 5 ], placed player) + let! score = FSharpForeignSymbols.declarations state (named "P:Domain.Player.Score") true + Assert.Equal<(string * int * int) list>([ "Domain.fs", 2, 30 ], placed score) + let total = "M:Domain.Scoring.total(System.Collections.Generic.IEnumerable{Domain.Player})" + let! declared = FSharpForeignSymbols.declarations state (named total) false + Assert.Equal<(string * int * int) list>([ "Domain.fs", 5, 8 ], placed declared) + let range = List.exactlyOne declared + Assert.Equal((5, 13), (range.EndLine, range.EndCharacter)) + + // Another assembly's symbol, an unknown one and an empty identity have no F# declaration. + let elsewhere = { named "T:Domain.Player" with AssemblyName = "Elsewhere" } + let! foreign = FSharpForeignSymbols.declarations state elsewhere true + Assert.Empty(foreign) + let! missing = FSharpForeignSymbols.declarations state (named "T:Domain.Missing") true + Assert.Empty(missing) + let! empty = FSharpForeignSymbols.declarations state (named "") true + Assert.Empty(empty) + finally + cleanup dir +} + +[] +let ``uses list every F# use, with the declaration only when asked`` () = task { + let! (state, dir, _, _) = domain () + try + let uses = [ "Domain.fs", 5, 82; "Domain.fs", 6, 81; "Board.fs", 2, 26; "Board.fs", 3, 17 ] + let! withDeclaration = FSharpForeignSymbols.uses state (named "P:Domain.Player.Score") true + Assert.Equal<(string * int * int) list>(List.sort (("Domain.fs", 2, 30) :: uses), placed withDeclaration) + let! withoutDeclaration = FSharpForeignSymbols.uses state (named "P:Domain.Player.Score") false + Assert.Equal<(string * int * int) list>(List.sort uses, placed withoutDeclaration) + + // The type's uses: each annotation; the function's: its one call. + let! player = FSharpForeignSymbols.uses state (named "T:Domain.Player") false + Assert.Equal<(string * int * int) list>([ "Domain.fs", 5, 28; "Domain.fs", 6, 27 ], placed player) + let total = "M:Domain.Scoring.total(System.Collections.Generic.IEnumerable{Domain.Player})" + let! called = FSharpForeignSymbols.uses state (named total) false + Assert.Equal<(string * int * int) list>([ "Board.fs", 3, 33 ], placed called) + finally + cleanup dir +} + +[] +let ``an unloaded workspace has no declarations or uses`` () = task { + let state = FSharpWorkspace.create () + let! declared = FSharpForeignSymbols.declarations state (named "T:Domain.Player") true + Assert.Empty(declared) + let! used = FSharpForeignSymbols.uses state (named "T:Domain.Player") true + Assert.Empty(used) + Assert.Empty(FSharpForeignSymbols.scopeOf state "TestProject") +} + +/// F#'s own references and a foreign request agree: each use is placed on its name, never +/// on the whole dotted path FCS ranges it over (`ada.Score`). [REFERENCES-FSHARP-FIND] +[] +let ``F# references and foreign uses place each use on its name alike`` () = task { + let! (state, dir, _, paths) = domain () + try + let! own = FSharpReferences.getReferences state paths[0] 2 30 true + let! foreign = FSharpForeignSymbols.uses state (named "P:Domain.Player.Score") true + Assert.Equal<(string * int * int) list>(placed foreign, placed own) + let ends = own |> List.map (fun location -> location.EndCharacter - location.Character) |> List.distinct + Assert.Equal([ "Score".Length ], ends) + + // From a use, too: the dotted `ada.Score` in Board.fs. + let! fromUse = FSharpReferences.getReferences state paths[1] 3 18 false + Assert.Equal<(string * int * int) list>( + [ "Board.fs", 2, 26; "Board.fs", 3, 17; "Domain.fs", 5, 82; "Domain.fs", 6, 81 ], + placed fromUse + ) + finally + cleanup dir +} + +let private MARKS = + source + [ "module Marks" + "" + "type MarkAttribute() =" + " inherit System.Attribute()" + "" + "[]" + "let marked (value: 'T) = value" ] + +/// An attribute used by its short name (`[]` for `MarkAttribute`) has no token that +/// spells its symbol, so its use keeps FCS's range; a type parameter has no XML doc signature, +/// so C# can never name it. [REFERENCES-FSHARP-FIND] [DEFINITION-CROSSLANG-IDENTITY] +[] +let ``a use no token spells keeps its range, and a symbol without a signature has no identity`` () = task { + let! (state, dir, _, paths) = loadWorkspace [ "Marks.fs", MARKS ] + try + let! fileCheck = FSharpWorkspace.checkFile state paths[0] + let checkResults, _ = FSharpForeignSymbols.requireCheckedFile paths[0] fileCheck + let uses = checkResults.GetAllUsesOfAllSymbolsInFile() |> Seq.toArray + let tokenized = FSharpRenameToken.tokenizeSource state.Checker (FSharpWorkspace.readSource state paths[0]) + + let mark = + uses + |> Array.find (fun symbolUse -> + let range = symbolUse.Range + range.StartLine = 6 && range.StartColumn = 2) + Assert.Equal((5, 2, 5, 6), FSharpReferences.nameSpan state.Checker tokenized mark) + + let typeParameter = uses |> Array.find (fun symbolUse -> symbolUse.Symbol :? FSharpGenericParameter) + Assert.True((FSharpForeignSymbols.identityOf typeParameter.Symbol).IsNone, "a type parameter") + let declared = + uses |> Array.find (fun symbolUse -> symbolUse.Symbol :? FSharpEntity && symbolUse.IsFromDefinition) + let identity = FSharpForeignSymbols.identityOf declared.Symbol + Assert.Equal(Some "T:Marks.MarkAttribute", identity |> Option.map (fun named -> named.XmlDocSig)) + finally + cleanup dir +} diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsWireTests.fs b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsWireTests.fs new file mode 100644 index 00000000..ecc50dd3 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsWireTests.fs @@ -0,0 +1,76 @@ +/// The foreign-symbol requests over the sidecar's real IPC socket: the C# sidecar names an F# +/// symbol by its assembly and XML doc signature, and `workspace/declarationForeign` and +/// `workspace/referencesForeign` answer where F# declares and uses it. Implements +/// [RAZOR-TESTING] for [DEFINITION-CROSSLANG-IDENTITY] and [REFERENCES-CROSS-LANGUAGE]. +module SharpLsp.Sidecar.FSharp.Tests.FSharpForeignSymbolsWireTests + +open Xunit +open MessagePack +open SharpLsp.Sidecar.FSharp +open SharpLsp.Sidecar.Common +open SharpLsp.Sidecar.FSharp.Tests.SidecarEndToEndTests + +/// The `workspace/*Foreign` request for the fixture project's symbol `xmlDocSig`. +let private request assemblyName xmlDocSig includeDeclaration = + MessagePackSerializer.Serialize( + { ForeignSymbolRequest.AssemblyName = assemblyName + XmlDocSig = xmlDocSig + IncludeDeclaration = includeDeclaration } + ) + +/// Each location as (file name, line, character, end character). +let private placed (result: LocationListResult) = + result.Locations + |> Array.map (fun location -> + string (NativePaths.NameOf location.FilePath), location.Line, location.Character, location.EndCharacter) + |> Array.sort + |> List.ofArray + +let private ADD = "M:TestProject.Library.add(System.Int32,System.Int32)" + +type FSharpForeignSymbolsWireTests(fixture: SidecarFixture) = + interface IClassFixture + + [] + member _.``a foreign declaration request lands on the F# declaration``() = task { + let! person = fixture.Send("workspace/declarationForeign", request "TestProject" "T:TestProject.Library.Person" true) + Assert.Null(person.Error) + Assert.Equal<(string * int * int * int) list>( + [ "Library.fs", 9, 5, 11 ], + placed (deserialize person.Payload) + ) + + // A function is named with its parameter types, and lands on its name. + let! add = fixture.Send("workspace/declarationForeign", request "TestProject" ADD false) + Assert.Null(add.Error) + Assert.Equal<(string * int * int * int) list>( + [ "Library.fs", 6, 4, 7 ], + placed (deserialize add.Payload) + ) + + // Another assembly's symbol of the same name has no F# declaration here. + let! elsewhere = fixture.Send("workspace/declarationForeign", request "Elsewhere" "T:TestProject.Library.Person" true) + Assert.Null(elsewhere.Error) + Assert.Empty((deserialize elsewhere.Payload).Locations) + } + + [] + member _.``a foreign references request lists F# uses, the declaration only when asked``() = task { + let! withDeclaration = fixture.Send("workspace/referencesForeign", request "TestProject" ADD true) + Assert.Null(withDeclaration.Error) + let all = placed (deserialize withDeclaration.Payload) + Assert.Contains(("Library.fs", 6, 4, 7), all) + Assert.Contains(("Library.fs", 15, 13, 16), all) + + let! withoutDeclaration = fixture.Send("workspace/referencesForeign", request "TestProject" ADD false) + Assert.Null(withoutDeclaration.Error) + let uses = placed (deserialize withoutDeclaration.Payload) + Assert.DoesNotContain(("Library.fs", 6, 4, 7), uses) + Assert.Equal<(string * int * int * int) list>(List.except [ "Library.fs", 6, 4, 7 ] all, uses) + Assert.All(uses, fun (_, _, character, endCharacter) -> Assert.Equal("add".Length, endCharacter - character)) + + // A symbol F# does not declare has no uses. + let! missing = fixture.Send("workspace/referencesForeign", request "TestProject" "T:TestProject.Library.Missing" true) + Assert.Null(missing.Error) + Assert.Empty((deserialize missing.Payload).Locations) + } diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj index ed6abf86..b73ff18d 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj @@ -35,6 +35,8 @@ + + diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpForeignSymbols.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpForeignSymbols.fs new file mode 100644 index 00000000..38b220f4 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpForeignSymbols.fs @@ -0,0 +1,124 @@ +/// Symbols the other language's sidecar names by identity: the assembly that declares +/// them and their XML doc signature, the only names both compilers give a symbol. The +/// C# sidecar names an F# symbol this way when C# or Razor uses it, and these are its +/// F# declarations and uses. Implements [DEFINITION-CROSSLANG-IDENTITY], +/// [REFERENCES-CROSS-LANGUAGE] and [RENAME-CROSSLANGUAGE]. +module SharpLsp.Sidecar.FSharp.FSharpForeignSymbols + +open System +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Symbols +open SharpLsp.Sidecar.Common + +/// A symbol as both compilers name it. +[] +type SymbolIdentity = + { AssemblyName: string + XmlDocSig: string } + +/// The XML doc signature FCS gives `symbol`; empty for a symbol that has none. +let internal xmlDocSignature (symbol: FSharpSymbol) = + match symbol with + | :? FSharpEntity as entity -> entity.XmlDocSig + | :? FSharpMemberOrFunctionOrValue as memberValue -> memberValue.XmlDocSig + | :? FSharpField as field -> field.XmlDocSig + | :? FSharpUnionCase as unionCase -> unionCase.XmlDocSig + | _ -> "" + +/// The simple name of the assembly that declares `symbol`; empty when FCS cannot say. +let internal assemblyNameOf (symbol: FSharpSymbol) = + try symbol.Assembly.SimpleName |> string + with _ -> "" + +/// `symbol`'s identity, when it has both an assembly and a signature. +let internal identityOf (symbol: FSharpSymbol) = + let assemblyName = assemblyNameOf symbol + let xmlDocSig = xmlDocSignature symbol + if String.IsNullOrWhiteSpace(assemblyName) || String.IsNullOrWhiteSpace(xmlDocSig) then None + else Some { AssemblyName = assemblyName; XmlDocSig = xmlDocSig } + +/// Whether `symbolUse` is a use of the symbol `assemblyName` and `xmlDocSig` name. +let internal matches assemblyName xmlDocSig (symbolUse: FSharpSymbolUse) = + String.Equals(assemblyNameOf symbolUse.Symbol, assemblyName, StringComparison.Ordinal) + && String.Equals(xmlDocSignature symbolUse.Symbol, xmlDocSig, StringComparison.Ordinal) + +/// The check results of `filePath`; FCS failing to check a project file is an error. +let internal requireCheckedFile filePath checkedFile = + checkedFile + |> Option.defaultWith (fun () -> raise (InvalidOperationException($"FCS could not check {filePath}"))) + +/// Every use in `filePath` of the symbol `assemblyName` and `xmlDocSig` name. +let internal usesInFile state assemblyName xmlDocSig filePath = + task { + let! fileCheck = FSharpWorkspace.checkFile state filePath + let checkResults, _ = requireCheckedFile filePath fileCheck + return + checkResults.GetAllUsesOfAllSymbolsInFile() + |> Seq.filter (matches assemblyName xmlDocSig) + |> Seq.toArray + } + +/// Every use in `files` of the symbol `assemblyName` and `xmlDocSig` name, file by file. +let internal usesIn state assemblyName xmlDocSig (files: string array) = + task { + let collected = ResizeArray() + for filePath in files do + let! uses = usesInFile state assemblyName xmlDocSig filePath + collected.AddRange(uses) + return collected.ToArray() + } + +/// Whether `entry` builds the assembly named `assemblyName`. +let private builds assemblyName (entry: FSharpDesignTime.FSharpProjectEntry) = + String.Equals(FSharpWorkspace.parseFsprojAssemblyName entry.Path, assemblyName, StringComparison.Ordinal) + +/// Whether `entry` references an assembly named `assemblyName` in any framework. +let private references assemblyName (entry: FSharpDesignTime.FSharpProjectEntry) = + entry.Resolved.Values + |> Seq.concat + |> Seq.exists (fun reference -> + String.Equals(NativePaths.StemOf reference.Assembly, assemblyName, StringComparison.Ordinal)) + +/// The files of `entries`' query scopes ([SHARPLSP-ARCHITECTURE-PROJECTS-FSHARP-REFERENCES]). +let private scopesOf state (entries: FSharpDesignTime.FSharpProjectEntry seq) = + entries + |> Seq.choose (fun entry -> entry.Options.SourceFiles |> Array.tryHead) + |> Seq.collect (FSharpWorkspace.scopeSourceFiles state) + |> Seq.distinctBy NativePaths.NormalizeFullPath + |> Array.ofSeq + +/// The files that can hold a use of a symbol of `assemblyName`: the F# project that builds +/// that assembly and every project reading it in memory; for an assembly no F# project +/// builds, the F# projects that reference it. Never every file of the workspace. +let internal scopeOf (state: FSharpWorkspace.FSharpWorkspaceState) assemblyName = + let owners = state.Projects.Values |> Seq.filter (builds assemblyName) |> List.ofSeq + if List.isEmpty owners then + state.Projects.Values |> Seq.filter (references assemblyName) |> scopesOf state + else + scopesOf state owners + +/// The names of the uses `query` keeps, of those in scope of the symbol `identity` names; an +/// unloaded workspace or an empty identity has none. +let private inScope (state: FSharpWorkspace.FSharpWorkspaceState) (identity: SymbolIdentity) query = + task { + if not state.IsLoaded + || String.IsNullOrWhiteSpace identity.AssemblyName + || String.IsNullOrWhiteSpace identity.XmlDocSig then + return [] + else + let files = scopeOf state identity.AssemblyName + let! uses = usesIn state identity.AssemblyName identity.XmlDocSig files + return FSharpReferences.nameLocations state (query uses) + } + +let private isDefinition (symbolUse: FSharpSymbolUse) = symbolUse.IsFromDefinition + +/// Where F# declares the symbol `identity` names: the name of each defining use. A symbol +/// no loaded F# project declares has none. [DEFINITION-CROSSLANG-IDENTITY] +let declarations state identity (_: bool) = + inScope state identity (Seq.filter isDefinition) + +/// Every F# use of the symbol `identity` names, and its F# declaration when +/// `includeDeclaration` asks for it. [REFERENCES-CROSS-LANGUAGE] +let uses state identity includeDeclaration = + inScope state identity (Seq.filter (fun symbolUse -> includeDeclaration || not (isDefinition symbolUse))) diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs index 349f1439..e2ecabf3 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs @@ -16,6 +16,39 @@ type HighlightLocation = EndCharacter: int Kind: int } +/// Where `symbolUse` names its symbol, as zero-based (startLine, start, endLine, end). FCS +/// ranges a dotted use (`player.Score`) over the whole path, so the name is the token at the +/// range's end that spells the symbol's name; a use no token spells keeps FCS's range. +let internal nameSpan checker tokenized (symbolUse: FSharpSymbolUse) = + let range = symbolUse.Range + match FSharpRenameToken.tokenForUse checker tokenized symbolUse with + | Some token -> range.EndLine - 1, token.StartCharacter, range.EndLine - 1, token.EndCharacter + | None -> range.StartLine - 1, range.StartColumn, range.EndLine - 1, range.EndColumn + +/// Each use's name as a location, each file read and tokenized once, each place once. +let internal nameLocations (state: FSharpWorkspace.FSharpWorkspaceState) (uses: FSharpSymbolUse seq) = + uses + |> Seq.filter (fun symbolUse -> + let range = symbolUse.Range + range.FileName <> "") + |> Seq.groupBy (fun symbolUse -> + let range = symbolUse.Range + range.FileName) + |> Seq.collect (fun (filePath, inFile) -> + let tokenized = FSharpRenameToken.tokenizeSource state.Checker (FSharpWorkspace.readSource state filePath) + inFile + |> Seq.map (fun symbolUse -> + let line, start, endLine, finish = nameSpan state.Checker tokenized symbolUse + let location: FSharpWorkspace.DefinitionLocation = + { FilePath = filePath + Line = line + Character = start + EndLine = endLine + EndCharacter = finish } + location)) + |> Seq.distinctBy (fun location -> NativePaths.NormalizeFullPath location.FilePath, location.Line, location.Character) + |> List.ofSeq + /// Check whether an FSharpSymbolUse represents a write (definition or pattern). let private isWriteUse (su: FSharpSymbolUse) = su.IsFromDefinition || su.IsFromPattern @@ -144,10 +177,8 @@ let getReferences 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 + |> Array.filter (fun (su: FSharpSymbolUse) -> includeDeclaration || not su.IsFromDefinition) + |> nameLocations state } /// Find document highlights for the symbol at a position (current file only). @@ -168,20 +199,21 @@ let getDocumentHighlights | Some symbolUse -> let usesInFile = checkResults.GetUsesOfSymbolInFile(symbolUse.Symbol) + let tokenized = FSharpRenameToken.tokenizeSource state.Checker source 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 + let line, start, endLine, finish = nameSpan state.Checker tokenized su Some { FilePath = r.FileName - StartLine = r.StartLine - 1 - StartCharacter = r.StartColumn - EndLine = r.EndLine - 1 - EndCharacter = r.EndColumn - Kind = kind }) + StartLine = line + StartCharacter = start + EndLine = endLine + EndCharacter = finish + Kind = if isWriteUse su then 3 else 2 }) |> Array.toList with ex -> Log.Debug(ex, "[F# DocumentHighlight] failed") diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs index e6a9571a..cc3d262d 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs @@ -21,10 +21,7 @@ type PrepareRename = Placeholder: string } /// Stable compiler identity shared with the other language sidecar. -[] -type RenameIdentity = - { AssemblyName: string - XmlDocSig: string } +type RenameIdentity = FSharpForeignSymbols.SymbolIdentity [] type private ResolvedRename = @@ -53,27 +50,6 @@ type private ProjectedCandidate = StartCharacter: int EndCharacter: int } -let private xmlDocSignature (symbol: FSharpSymbol) = - match symbol with - | :? FSharpEntity as entity -> entity.XmlDocSig - | :? FSharpMemberOrFunctionOrValue as memberValue -> memberValue.XmlDocSig - | :? FSharpField as field -> field.XmlDocSig - | :? FSharpUnionCase as unionCase -> unionCase.XmlDocSig - | _ -> "" - -let private symbolAssemblyName (symbol: FSharpSymbol) = - try symbol.Assembly.SimpleName |> string - with _ -> "" - -let private identityForSymbol symbol = - let assemblyName = symbolAssemblyName symbol - let xmlDocSig = xmlDocSignature symbol - if String.IsNullOrWhiteSpace(assemblyName) || String.IsNullOrWhiteSpace(xmlDocSig) then None - else Some { AssemblyName = assemblyName; XmlDocSig = xmlDocSig } - -let private requireCheckedFile filePath checkedFile = - checkedFile |> Option.defaultWith (fun () -> raise (InvalidOperationException($"FCS could not check {filePath}"))) - /// 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). @@ -217,7 +193,7 @@ let prepareRename let private computeRenameIdentity state parseResults checkResults source line character = resolveRenameUse state checkResults parseResults source line character - |> Option.bind (fun resolved -> identityForSymbol (normalizeSymbol resolved.SymbolUse.Symbol)) + |> Option.bind (fun resolved -> FSharpForeignSymbols.identityOf (normalizeSymbol resolved.SymbolUse.Symbol)) /// Return the compiler-stable identity for the renameable symbol at a position. let getRenameIdentity (state: FSharpWorkspace.FSharpWorkspaceState) filePath line character = @@ -227,7 +203,7 @@ let getRenameIdentity (state: FSharpWorkspace.FSharpWorkspaceState) filePath lin return raise (InvalidOperationException("F# workspace is not loaded")) else let! fileCheck = FSharpWorkspace.checkFileWithParse state filePath - let parseResults, checkResults, source = requireCheckedFile filePath fileCheck + let parseResults, checkResults, source = FSharpForeignSymbols.requireCheckedFile filePath fileCheck return computeRenameIdentity state parseResults checkResults source line character with ex -> Log.Debug(ex, "[F# RenameIdentity] failed") @@ -306,32 +282,10 @@ let private renameChecked state parseResults checkResults source line character | Some resolved -> return! standardRename state newName resolved } -let private matchesIdentity assemblyName xmlDocSig (symbolUse: FSharpSymbolUse) = - String.Equals(symbolAssemblyName symbolUse.Symbol, assemblyName, StringComparison.Ordinal) - && String.Equals(xmlDocSignature symbolUse.Symbol, xmlDocSig, StringComparison.Ordinal) - -let private matchingUsesInFile state assemblyName xmlDocSig filePath = - task { - let! fileCheck = FSharpWorkspace.checkFile state filePath - let checkResults, _ = requireCheckedFile filePath fileCheck - return - checkResults.GetAllUsesOfAllSymbolsInFile() - |> Seq.filter (matchesIdentity assemblyName xmlDocSig) - |> Seq.toArray - } - -let private collectMatchingUses - (state: FSharpWorkspace.FSharpWorkspaceState) - assemblyName - xmlDocSig = - task { - let collected = ResizeArray() - let files = FSharpWorkspace.allSourceFiles state - for filePath in files do - let! uses = matchingUsesInFile state assemblyName xmlDocSig filePath - collected.AddRange(uses) - return collected.ToArray() - } +/// Every use of the symbol `assemblyName` and `xmlDocSig` name. A rename by identity, from +/// the other language with no F# file to anchor on, walks every loaded project. +let private collectMatchingUses (state: FSharpWorkspace.FSharpWorkspaceState) assemblyName xmlDocSig = + FSharpForeignSymbols.usesIn state assemblyName xmlDocSig (FSharpWorkspace.allSourceFiles state) let private signatureHeaderEnd (xmlDocSig: string) = [| xmlDocSig.IndexOf('('); xmlDocSig.IndexOf('~') |] @@ -432,7 +386,7 @@ let private matchingProjectedEdits FSharpWorkspace.getSymbolUse checkResults source candidate.Original.Line candidate.StartCharacter |> Option.filter (matchesProjectedRange candidate) - |> Option.filter (matchesIdentity assemblyName staleXml) + |> Option.filter (FSharpForeignSymbols.matches assemblyName staleXml) |> Option.map (fun _ -> candidateEdit newName candidate.Original)) let private transientFileEdits @@ -446,7 +400,7 @@ let private transientFileEdits let projectedCandidates = projectCandidatePositions newName candidates let projectedSource = projectSource source newName candidates let! checkedFile = FSharpWorkspace.checkFileWithSource state filePath projectedSource - let _, checkResults, checkedSource = requireCheckedFile filePath checkedFile + let _, checkResults, checkedSource = FSharpForeignSymbols.requireCheckedFile filePath checkedFile return matchingProjectedEdits assemblyName staleXml newName checkResults checkedSource projectedCandidates } diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs index c80b3e80..0406fe6c 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs @@ -293,6 +293,11 @@ type FSharpSidecar() = base.Register("textDocument/renameIdentity", Helpers.renameIdentityHandler workspace) base.Register("workspace/renameForeign", Helpers.renameForeignHandler workspace) + // A symbol C# or Razor uses, named by identity: where F# declares it and every F# + // use. [DEFINITION-CROSSLANG-IDENTITY] [REFERENCES-CROSS-LANGUAGE] + base.Register("workspace/declarationForeign", Helpers.foreignLocationsHandler (FSharpForeignSymbols.declarations workspace)) + base.Register("workspace/referencesForeign", Helpers.foreignLocationsHandler (FSharpForeignSymbols.uses workspace)) + // Call hierarchy prepare [SHARPLSP-FEATURES-NAVIGATION] base.Register("textDocument/prepareCallHierarchy", Helpers.atPosition (FSharpHierarchy.prepareCall workspace) (Helpers.optionOf Helpers.toHierItem)) diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs index 37b02a1d..d0981807 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs @@ -294,6 +294,16 @@ type RenameIdentityResultWire = [] AssemblyName: string [] XmlDocSig: string } +/// A symbol another sidecar names by identity, for its F# declarations or uses. +/// [DEFINITION-CROSSLANG-IDENTITY] [REFERENCES-CROSS-LANGUAGE] +[] +[] +type ForeignSymbolRequest = + { [] AssemblyName: string + [] XmlDocSig: string + /// Whether references include the declaration; a declaration request ignores it. + [] IncludeDeclaration: bool } + [] [] type RenameForeignRequest = @@ -383,6 +393,18 @@ module internal Helpers = atPosition (getLocations workspace) (fun locations ct -> serializeOk { Locations = locations |> List.map toLocationResult |> Array.ofList } ct) + /// Answer a request naming a symbol by identity with the locations `query` finds for it. + let foreignLocationsHandler + (query: FSharpForeignSymbols.SymbolIdentity -> bool -> Task) + : Func> = + handle + (fun (request: ForeignSymbolRequest) -> + query + { AssemblyName = request.AssemblyName; XmlDocSig = request.XmlDocSig } + request.IncludeDeclaration) + (fun locations ct -> + serializeOk { Locations = locations |> List.map toLocationResult |> Array.ofList } ct) + /// Map a hierarchy domain item to its wire shape (call + type hierarchy). let toHierItem (item: FSharpHierarchy.HierItem) : HierarchyItemResult = { Name = item.Name diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj b/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj index 9a1e005e..a794faa9 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj @@ -58,8 +58,9 @@ - + + From 79838e2cfde8da919d4a9537d4e947053db1c374 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:17:23 +1100 Subject: [PATCH 014/151] Upgrade rustls-webpki, webpki-roots and the website's markdown-it past new advisories RUSTSEC-2026-0382 (rustls-webpki 0.103.15 -> 0.103.16), RUSTSEC-2026-0381 (webpki-roots 1.0.9 -> 1.0.10), and GHSA-vfgh-jcmw-g37q / GHSA-gfr2-rv44-r23c (markdown-it 14.3.2 -> 14.3.3 under Eleventy) failed the dependency audit. --- Cargo.lock | 8 ++++---- src/website/package-lock.json | 24 ++++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 700864d0..599c7ad9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,9 +1148,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -1946,9 +1946,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/src/website/package-lock.json b/src/website/package-lock.json index 6d2a4bd5..54e4dfe4 100644 --- a/src/website/package-lock.json +++ b/src/website/package-lock.json @@ -230,9 +230,9 @@ } }, "node_modules/@11ty/eleventy/node_modules/markdown-it": { - "version": "14.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", - "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "version": "14.3.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", + "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==", "funding": [ { "type": "github", @@ -1018,9 +1018,9 @@ } }, "node_modules/eleventy-plugin-techdoc/node_modules/markdown-it": { - "version": "14.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", - "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "version": "14.3.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", + "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==", "funding": [ { "type": "github", @@ -1492,9 +1492,9 @@ } }, "node_modules/markdown-it": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.2.tgz", - "integrity": "sha512-q4IGxMv56jCqT4OCRCADBoDP3LO4MhmTXjFbphHPXs4g3j9Xg5RDnxqN8IF/3vIWEU+VCnUq+7JUg/cfy2E6Qw==", + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.3.tgz", + "integrity": "sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==", "funding": [ { "type": "github", @@ -1507,9 +1507,9 @@ ], "license": "MIT", "dependencies": { - "argparse": "^3.0.0", - "entities": "^8.0.0", - "linkify-it": "^6.0.0", + "argparse": "^3.0.3", + "entities": "^8.1.0", + "linkify-it": "^6.1.0", "mdurl": "^2.1.0", "punycode.js": "^2.3.1", "uc.micro": "^3.0.0" From 68dd7983850b747cf9504b2dfc8543cce1d26a47 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:37:50 +1100 Subject: [PATCH 015/151] Compare the F# Razor rename's files as the file system does On Windows the rename's edit for Scores.fs and the document the test opened spell the drive letter differently, so a lookup by Uri.path found no edits. Key the edits by comparablePath(fsPath), merging spellings of one file, and assert exactly two files are edited. --- .../vscode/src/test/suite/razor-lsp.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/editors/vscode/src/test/suite/razor-lsp.test.ts b/src/editors/vscode/src/test/suite/razor-lsp.test.ts index 5d86d6c2..0215ac9a 100644 --- a/src/editors/vscode/src/test/suite/razor-lsp.test.ts +++ b/src/editors/vscode/src/test/suite/razor-lsp.test.ts @@ -23,6 +23,7 @@ import { import { packageJson } from './run-debug-kit'; import { closeAllEditors, + comparablePath, pollProvider, pollUntilResult, removeDirRecursive, @@ -348,9 +349,20 @@ suite('Razor in VS Code', () => { score, 'Points', ); - const byFile = new Map(edit.entries().map(([target, edits]) => [target.path, edits])); - assert.equal(byFile.get(uri.path)?.length, 1, 'the Razor use'); - assert.equal(byFile.get(scores.uri.path)?.length, 5, 'the F# declaration and four uses'); + // Keyed as the file system compares paths: on Windows a drive letter may be + // spelled in either case, and one file must never count as two. + const byFile = new Map(); + for (const [target, edits] of edit.entries()) { + const key = comparablePath(target.fsPath); + byFile.set(key, [...(byFile.get(key) ?? []), ...edits]); + } + assert.equal(byFile.get(comparablePath(uri.fsPath))?.length, 1, 'the Razor use'); + assert.equal( + byFile.get(comparablePath(scores.uri.fsPath))?.length, + 5, + 'the F# declaration and four uses', + ); + assert.equal(byFile.size, 2, 'the Razor file and the F# file, and nothing else'); assert.ok( [...byFile.values()].flat().every((change) => change.newText === 'Points'), 'every edit renames to Points', From 0b5c5f0d1ed6e901aac736c952a476872cafce05 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:14:45 +1100 Subject: [PATCH 016/151] Prove a .NET Framework run by the code the program wrote, not only the code the pty reported VS Code's terminal process fires exitCode || 0, and on Windows node-pty can close conout before ConPTY hands over the exit code, so a run that returned 50 was reported as 0. Each fixture program now writes its code beside its binary; the test asserts that code exactly, and that the one task exit is that code or the pty's lost 0, which these programs never return. --- .../src/test/suite/netfx-msbuild-kit.ts | 50 ++++++++++++++++--- .../src/test/suite/netfx-run-fsharp.test.ts | 14 ++++-- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/editors/vscode/src/test/suite/netfx-msbuild-kit.ts b/src/editors/vscode/src/test/suite/netfx-msbuild-kit.ts index 3c852201..4a993ef8 100644 --- a/src/editors/vscode/src/test/suite/netfx-msbuild-kit.ts +++ b/src/editors/vscode/src/test/suite/netfx-msbuild-kit.ts @@ -240,6 +240,16 @@ export function fsharpSidecarLinesAbout(projectFile: string): string[] { export const EXIT_ON_NETFX = 48; export const EXIT_ON_NET = 10; +/** What a console program writes its exit code to, beside its own binary. */ +const RESULT_FILE = 'exit-code.txt'; + +/** + * The exit code VS Code reports when its Windows pty lost the real one: + * node-pty can close conout before ConPTY hands over the code, and VS Code's + * terminal process then fires `exitCode || 0`. These programs never return 0. + */ +const LOST_EXIT = 0; + /** One runnable fixture project. */ export interface RunnableProject { readonly name: string; @@ -261,20 +271,25 @@ export interface RunFixture extends LoadedFixture { /** * A console program named `name` returning 48 on .NET Framework and 10 on - * .NET, PLUS the number of arguments it received — so an exit code proves both - * the framework that ran and the arguments that reached it. + * .NET, PLUS the number of arguments it received — so its exit code proves both + * the framework that ran and the arguments that reached it. It also writes that + * code to {@link RESULT_FILE} beside its binary, which no pty can lose. */ function writeConsole(root: string, name: string, frameworks: ProjectFrameworks): string { const program = `${name}/Program.fs`; - const exits = [ + const code = [ + ' let code =', '#if NETFRAMEWORK', - ` ${String(EXIT_ON_NETFX)} + argv.Length`, + ` ${String(EXIT_ON_NETFX)} + argv.Length`, '#else', - ` ${String(EXIT_ON_NET)} + argv.Length`, + ` ${String(EXIT_ON_NET)} + argv.Length`, '#endif', + ` let result = System.IO.Path.Combine(System.AppContext.BaseDirectory, "${RESULT_FILE}")`, + ' System.IO.File.WriteAllText(result, string code)', + ' code', ]; writeFiles(root, { - [program]: [`module Fx.${name}`, '', '[]', 'let main argv =', ...exits, ''].join( + [program]: [`module Fx.${name}`, '', '[]', 'let main argv =', ...code, ''].join( '\n', ), }); @@ -322,6 +337,29 @@ export async function writeRunFixture(root: string): Promise { return { root, solution, probe, netfxOnly, ready }; } +/** Where `project`'s `tfm` build writes the code it returned. */ +export function resultFileOf(root: string, project: RunnableProject, tfm: string): string { + return path.join(root, project.name, 'bin', 'Debug', tfm, RESULT_FILE); +} + +/** + * `project`'s `tfm` build really returned `code`: the code it wrote, and the + * ONE task exit VS Code reported — that code, or {@link LOST_EXIT} when the + * Windows pty dropped it. + */ +export function assertRanWith( + result: string, + exits: readonly number[], + code: number, + tfm: string, +): void { + const why = `it really ran on ${tfm}: it returned ${String(code)}`; + assert.strictEqual(fs.readFileSync(result, 'utf8'), String(code), why); + assert.strictEqual(exits.length, 1, `one task process ended: ${exits.join(', ')}`); + const [reported = -1] = exits; + assert.ok([code, LOST_EXIT].includes(reported), `${why}; the task reported ${String(reported)}`); +} + /** A started task's definition, and whether it runs a process with no shell. */ export interface StartedTask { readonly definition: vscode.TaskDefinition; diff --git a/src/editors/vscode/src/test/suite/netfx-run-fsharp.test.ts b/src/editors/vscode/src/test/suite/netfx-run-fsharp.test.ts index bf87c024..0f73c0b3 100644 --- a/src/editors/vscode/src/test/suite/netfx-run-fsharp.test.ts +++ b/src/editors/vscode/src/test/suite/netfx-run-fsharp.test.ts @@ -7,20 +7,25 @@ // message, as a warning, starting nothing. // // Each console program returns 48 on .NET Framework and 10 on .NET, PLUS its -// argument count, so a task's exit code proves the framework that really ran -// and the profile arguments that really reached it. RunProbe declares two .NET +// argument count, and writes that code beside its binary, so the code proves +// the framework that really ran and the profile arguments that really reached +// it — read from the file, because VS Code's Windows pty can report a lost +// exit code as 0. RunProbe declares two .NET // versions, so F5's "active .NET" and "first .NET" cases are told apart. // // Covers [NETFX-DEBUG] and [NETFX-CONTEXT]. import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; import * as path from 'node:path'; import type { SharpLspExtensionApi } from '../../extension.js'; import { assertContext, frameworkContextOf, underFramework } from './netfx-context-kit'; import { useLoadedFixture } from './netfx-language-kit'; import { + assertRanWith, EXIT_ON_NET, EXIT_ON_NETFX, recordStartedTasks, + resultFileOf, type RunnableProject, type StartedTask, writeRunFixture, @@ -100,13 +105,14 @@ suite('.NET Framework F# — Run on the active framework, Debug on .NET only', ( const tasks = new TaskRecorder(); const started = recordStartedTasks(); const mark = logMark(api); + const result = resultFileOf(fixture().root, project, tfm); + fs.rmSync(result, { force: true }); try { await focusDocument(path.join(fixture().root, project.program)); const run = await invokeCommand(CMD_RUN_PROGRAM); assert.ok(!run.rejected, `${CMD_RUN_PROGRAM} runs: ${run.message}`); await assertRunTask(tasks, started.started, project, tfm); - const exits = await tasks.waitForExits(1, FIXTURE_BUILD_MS); - assert.deepStrictEqual(exits, [exit], `it really ran on ${tfm}: it returned ${String(exit)}`); + assertRanWith(result, await tasks.waitForExits(1, FIXTURE_BUILD_MS), exit, tfm); await awaitLogged(api, mark, `Run: ${project.name} under ${tfm}`); } finally { tasks.dispose(); From 7a517d9820912d36414b92f3bd67941ef763cfbe Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:14:45 +1100 Subject: [PATCH 017/151] Prove a .NET Framework run by the code the program wrote, not only the code the pty reported VS Code's terminal process fires exitCode || 0, and on Windows node-pty can close conout before ConPTY hands over the exit code, so a run that returned 50 was reported as 0. Each fixture program now writes its code beside its binary; the test asserts that code exactly, and that the one task exit is that code or the pty's lost 0, which these programs never return. --- .../src/test/suite/netfx-msbuild-kit.ts | 50 ++++++++++++++++--- .../src/test/suite/netfx-run-fsharp.test.ts | 14 ++++-- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/editors/vscode/src/test/suite/netfx-msbuild-kit.ts b/src/editors/vscode/src/test/suite/netfx-msbuild-kit.ts index 3c852201..4a993ef8 100644 --- a/src/editors/vscode/src/test/suite/netfx-msbuild-kit.ts +++ b/src/editors/vscode/src/test/suite/netfx-msbuild-kit.ts @@ -240,6 +240,16 @@ export function fsharpSidecarLinesAbout(projectFile: string): string[] { export const EXIT_ON_NETFX = 48; export const EXIT_ON_NET = 10; +/** What a console program writes its exit code to, beside its own binary. */ +const RESULT_FILE = 'exit-code.txt'; + +/** + * The exit code VS Code reports when its Windows pty lost the real one: + * node-pty can close conout before ConPTY hands over the code, and VS Code's + * terminal process then fires `exitCode || 0`. These programs never return 0. + */ +const LOST_EXIT = 0; + /** One runnable fixture project. */ export interface RunnableProject { readonly name: string; @@ -261,20 +271,25 @@ export interface RunFixture extends LoadedFixture { /** * A console program named `name` returning 48 on .NET Framework and 10 on - * .NET, PLUS the number of arguments it received — so an exit code proves both - * the framework that ran and the arguments that reached it. + * .NET, PLUS the number of arguments it received — so its exit code proves both + * the framework that ran and the arguments that reached it. It also writes that + * code to {@link RESULT_FILE} beside its binary, which no pty can lose. */ function writeConsole(root: string, name: string, frameworks: ProjectFrameworks): string { const program = `${name}/Program.fs`; - const exits = [ + const code = [ + ' let code =', '#if NETFRAMEWORK', - ` ${String(EXIT_ON_NETFX)} + argv.Length`, + ` ${String(EXIT_ON_NETFX)} + argv.Length`, '#else', - ` ${String(EXIT_ON_NET)} + argv.Length`, + ` ${String(EXIT_ON_NET)} + argv.Length`, '#endif', + ` let result = System.IO.Path.Combine(System.AppContext.BaseDirectory, "${RESULT_FILE}")`, + ' System.IO.File.WriteAllText(result, string code)', + ' code', ]; writeFiles(root, { - [program]: [`module Fx.${name}`, '', '[]', 'let main argv =', ...exits, ''].join( + [program]: [`module Fx.${name}`, '', '[]', 'let main argv =', ...code, ''].join( '\n', ), }); @@ -322,6 +337,29 @@ export async function writeRunFixture(root: string): Promise { return { root, solution, probe, netfxOnly, ready }; } +/** Where `project`'s `tfm` build writes the code it returned. */ +export function resultFileOf(root: string, project: RunnableProject, tfm: string): string { + return path.join(root, project.name, 'bin', 'Debug', tfm, RESULT_FILE); +} + +/** + * `project`'s `tfm` build really returned `code`: the code it wrote, and the + * ONE task exit VS Code reported — that code, or {@link LOST_EXIT} when the + * Windows pty dropped it. + */ +export function assertRanWith( + result: string, + exits: readonly number[], + code: number, + tfm: string, +): void { + const why = `it really ran on ${tfm}: it returned ${String(code)}`; + assert.strictEqual(fs.readFileSync(result, 'utf8'), String(code), why); + assert.strictEqual(exits.length, 1, `one task process ended: ${exits.join(', ')}`); + const [reported = -1] = exits; + assert.ok([code, LOST_EXIT].includes(reported), `${why}; the task reported ${String(reported)}`); +} + /** A started task's definition, and whether it runs a process with no shell. */ export interface StartedTask { readonly definition: vscode.TaskDefinition; diff --git a/src/editors/vscode/src/test/suite/netfx-run-fsharp.test.ts b/src/editors/vscode/src/test/suite/netfx-run-fsharp.test.ts index bf87c024..0f73c0b3 100644 --- a/src/editors/vscode/src/test/suite/netfx-run-fsharp.test.ts +++ b/src/editors/vscode/src/test/suite/netfx-run-fsharp.test.ts @@ -7,20 +7,25 @@ // message, as a warning, starting nothing. // // Each console program returns 48 on .NET Framework and 10 on .NET, PLUS its -// argument count, so a task's exit code proves the framework that really ran -// and the profile arguments that really reached it. RunProbe declares two .NET +// argument count, and writes that code beside its binary, so the code proves +// the framework that really ran and the profile arguments that really reached +// it — read from the file, because VS Code's Windows pty can report a lost +// exit code as 0. RunProbe declares two .NET // versions, so F5's "active .NET" and "first .NET" cases are told apart. // // Covers [NETFX-DEBUG] and [NETFX-CONTEXT]. import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; import * as path from 'node:path'; import type { SharpLspExtensionApi } from '../../extension.js'; import { assertContext, frameworkContextOf, underFramework } from './netfx-context-kit'; import { useLoadedFixture } from './netfx-language-kit'; import { + assertRanWith, EXIT_ON_NET, EXIT_ON_NETFX, recordStartedTasks, + resultFileOf, type RunnableProject, type StartedTask, writeRunFixture, @@ -100,13 +105,14 @@ suite('.NET Framework F# — Run on the active framework, Debug on .NET only', ( const tasks = new TaskRecorder(); const started = recordStartedTasks(); const mark = logMark(api); + const result = resultFileOf(fixture().root, project, tfm); + fs.rmSync(result, { force: true }); try { await focusDocument(path.join(fixture().root, project.program)); const run = await invokeCommand(CMD_RUN_PROGRAM); assert.ok(!run.rejected, `${CMD_RUN_PROGRAM} runs: ${run.message}`); await assertRunTask(tasks, started.started, project, tfm); - const exits = await tasks.waitForExits(1, FIXTURE_BUILD_MS); - assert.deepStrictEqual(exits, [exit], `it really ran on ${tfm}: it returned ${String(exit)}`); + assertRanWith(result, await tasks.waitForExits(1, FIXTURE_BUILD_MS), exit, tfm); await awaitLogged(api, mark, `Run: ${project.name} under ${tfm}`); } finally { tasks.dispose(); From a37a787a247e169b3e32f8a070fc1c8d34fcb7f3 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:41:38 +1100 Subject: [PATCH 018/151] Complete Razor review fixes and stabilize Windows test execution --- .deslop.toml | 2 +- Makefile | 2 +- coverage-thresholds.json | 2 +- docs/plans/RAZOR-PLAN.md | 161 ------------------ docs/specs/RAZOR-SPEC.md | 2 +- src/editors/vscode/src/blazor-wasm-launch.ts | 29 +++- .../test/suite/blazor-wasm-session.test.ts | 107 ++++++++++++ .../src/test/suite/context-menus.test.ts | 8 +- .../profiler-fsharp-soak-cpu-e2e.test.ts | 6 +- src/editors/vscode/test-chunks.json | 1 + .../tests/e2e_modules/multi_solution.rs | 2 +- .../tests/e2e_modules/razor_hierarchy.rs | 26 ++- src/sharplsp/tests/e2e_modules/razor_mvc.rs | 14 +- .../RazorCodeActionsTests.cs | 59 +++++++ .../RazorCollocationTests.cs | 25 ++- .../Razor/RazorExtractActions.cs | 36 +++- .../Razor/RazorProjection.cs | 4 + .../WorkspaceManager.RazorCodeActions.cs | 1 + .../WorkspaceManager.RazorHierarchy.cs | 14 +- tools/vsix/model-generators.test.mjs | 21 +++ tools/vsix/rebuild-contract.test.mjs | 9 +- tools/vsix/sync-profiler-contributions.mjs | 4 +- tools/vsix/tutorial-model.mjs | 4 +- 23 files changed, 316 insertions(+), 223 deletions(-) delete mode 100644 docs/plans/RAZOR-PLAN.md create mode 100644 src/editors/vscode/src/test/suite/blazor-wasm-session.test.ts create mode 100644 tools/vsix/model-generators.test.mjs diff --git a/.deslop.toml b/.deslop.toml index abf62af6..2bbd9f1a 100644 --- a/.deslop.toml +++ b/.deslop.toml @@ -1,6 +1,6 @@ # agent-pmo:a72c926 [threshold] -max_duplication_percent = 6.48 +max_duplication_percent = 6.15 [defaults] exclude = [ diff --git a/Makefile b/Makefile index ef12acdc..b6500a1f 100644 --- a/Makefile +++ b/Makefile @@ -795,7 +795,7 @@ _test-tooling: node --test tools/vsix/chunk-once.test.mjs @echo "==> Running repo tooling tests..." node --test tools/netcoredbg/custody.test.mjs tools/make/reinstall-loop.test.mjs tools/make/vsix-rebuild.test.mjs tools/vsix/rebuild-contract.test.mjs tools/vsix/debug-cleanup.test.mjs tools/audit/dotnet-vulnerable.test.mjs \ - tools/rider/gradle.test.mjs tools/vsix/resolve-symlink-stubs.test.mjs tools/make/vsix-profiles.test.mjs + tools/rider/gradle.test.mjs tools/vsix/resolve-symlink-stubs.test.mjs tools/make/vsix-profiles.test.mjs tools/vsix/model-generators.test.mjs _website-build: @echo "==> Building website..." diff --git a/coverage-thresholds.json b/coverage-thresholds.json index 95763c8e..66305842 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -12,7 +12,7 @@ "language": "dotnet" }, "sharplsp-sidecar-csharp": { - "threshold": 95.72, + "threshold": 96.16, "language": "dotnet" }, "sharplsp-sidecar-fsharp": { diff --git a/docs/plans/RAZOR-PLAN.md b/docs/plans/RAZOR-PLAN.md deleted file mode 100644 index 5197f243..00000000 --- a/docs/plans/RAZOR-PLAN.md +++ /dev/null @@ -1,161 +0,0 @@ -# Razor and Blazor Implementation Plan - -**Spec:** [RAZOR-SPEC.md](../specs/RAZOR-SPEC.md) - -## Context - -SharpLsp has not supported `.razor` or `.cshtml` files until now. Research confirmed three things ([RAZOR-RESEARCH]): - -- The SDK's own Razor source generator already runs inside the C# sidecar's design-time workspace. -- Its enhanced `#line` directives map every C# fragment back to Razor. -- The public compiler IR supplies everything the mapping misses. - -So the C# sidecar owns Razor end to end, the host routes the documents to it, and the editors sync and forward HTML. - -The phases below are in dependency order. Each item is checked only when a test that exercises it passes. - -## TODO - -### Phase 1: Foundation - -- [x] Fixture solution `src/sharplsp/tests/fixtures/razor/` with `BlazorApp`, `Widgets` (RCL), `PagesApp` (Pages + MVC) and `Domain` (F#), isolated by its own `Directory.Build.props` ([RAZOR-TESTING]) -- [x] Host: add `LangId::Razor` for `.razor` and `.cshtml`, owned by the C# sidecar ([RAZOR-ARCHITECTURE-ROUTING]) -- [x] Host: route `documentSymbol`, `foldingRange` and `linkedEditingRange` for Razor to the sidecar, and answer `selectionRange` empty (the Deferred selection-range item has since routed it to the sidecar too) -- [x] Host: add `<` and `@` completion triggers, and short-circuit them for C# and F# documents -- [x] Host: publish Razor diagnostics under the source `sharplsp-razor` -- [x] Sidecar: bundle the Razor compiler from the build SDK, and list it in `THIRD-PARTY-NOTICES.md` ([RAZOR-ARCHITECTURE-ENGINE]) -- [x] Sidecar: find Razor files as additional documents, and sync their text in every framework context, including the pending-edit replay ([RAZOR-ARCHITECTURE-DOCUMENTS]) -- [x] Sidecar: build the C# projection — find the generated document by its checksum pragma, and build the verified span map in both directions ([RAZOR-ARCHITECTURE-PROJECTION]) - - [x] Verify the checksum lookup, the mappings that never map (hidden, fileless, foreign, outside either text), carets at a fragment's end, and rename's check that a span holds the old name — `RazorProjectionTests`: 6 passing cases over hand-written generator output -- [x] Sidecar: build the Razor engine per project — configuration from the analyzer and parse options, an in-memory file system, tag helpers per compilation, the capture pass and markup facts - - [x] Verify configuration fallbacks, target-path metadata, current file/import reads, missing files, missing paths, cancellation and failure recovery — `RazorEngineSettingsTests`: 12 passing cases; settings, filesystem and engine each have 100% line coverage -- [x] Sidecar: build the HTML projection and the markup-context tokenizer ([RAZOR-ARCHITECTURE-HTML]) - - [x] HTML projection from compiler IR, preserving offsets/newlines and current unsaved text — `RazorHtmlProjectionTests`: 19 passing fixture cases (Blazor and MVC, comments, UTF-16, incomplete tags, unsaved edits) - - [x] The C# in an HTML attribute value (`disabled="@(…)"`) and every directive attribute, including a minimized parameter the compiler gives no span (`@onclick:stopPropagation`) and a binding parameter's value (`@bind:event="oninput"`), is blank in the projection, while the attribute's name, quotes and literal text stay (#407, review R10, Claude2, 694b48c0). Three regressions failed with the fix off, then passed unchanged; the full .NET leg passes and C# coverage ratchets to 95.54% - - [x] HTML markup-context tokenizer — `HtmlMarkupContextTests`: 36 passing cases with 100% line coverage, including malformed start/end tag recovery; the three character-reconsumption regressions were verified failing before the fix and pass unchanged afterward - - [x] Host: `sharplsp/razor/htmlProjection` answers `{ text }`, or `null` for a file no project lists — `test_full_stack_razor_routing_session` - - [x] Verify `sharplsp/razor/htmlProjection` through editor integration — `razor-lsp.test.ts`: the projection keeps the editor's offsets, line breaks and unsaved text, and feeds VS Code's HTML completion and hover - -- [x] Sidecar: Razor C# features answer whatever SDK a project resolves (#399). When the project's SDK Razor generator was built against a newer Roslyn than the sidecar's, the bundled Razor compiler loads in its place, and a warning names both Roslyn versions ([RAZOR-ARCHITECTURE-GENERATOR]) - - [x] Root cause: a newer SDK's Razor compiler has a higher assembly version than the bundled copy. Roslyn's analyzer loader stands the sidecar's own copy in only once that copy is loaded. Otherwise it loads the SDK's copy, finds it references a newer `Microsoft.CodeAnalysis`, and refuses it for the session. xUnit's discovery always loads the bundled copy before any test runs, which is why the sidecar's own Razor tests passed on runners with SDK 10.0.401 while the end-to-end suites failed. Confirmed on SDK 10.0.401 itself: its compiler is version 10.4.0.0 and references `Microsoft.CodeAnalysis` 5.9, against the sidecar's 5.6 - - [x] Failing tests, failing for the bug: `RazorNewerSdkTests` gives the fixture's design-time builds a newer SDK's Razor compiler (a higher assembly version that references a newer Roslyn). In a fresh sidecar process, hover in `Counter.razor` answers null: the process loads that compiler from the SDK's folder, never its own copy, and Roslyn refuses it. In process, the projects keep the newer SDK's generator files. The fixture copies now keep the repository's SDK pin, as the editor suites' copies do - - [x] Fix: at project load, an SDK Razor compiler that references a newer `Microsoft.CodeAnalysis` than the running one is swapped, with its two companion files, for the bundled copies, and a warning names both versions (`RazorGenerator`, Codex1, 029fc3a) - - [x] Pass: the two failing tests pass unchanged, and a third proves a compiler that cannot be read leaves the project as it loaded - - [x] Full sidecar suite and coverage gates with the fix: C# 693/693, F# 447/447, Common 125/125; C# coverage 96.50% ratchets its threshold to 95.49% - - [x] CI proof on a real newer SDK: a Rust end-to-end Razor test whose fixture copy keeps no SDK pin, so runners carrying SDK 10.0.401 load it with that SDK (Codex1, 029fc3a). It passes locally against an isolated real SDK 10.0.401, and in CI: `test_latest_sdk_razor_generator_remains_usable` passes on Rust Shard 2/2 of run 39364686929, whose ubuntu-24.04 image (20261012.334) ships SDK 10.0.401 beside the pinned 10.0.303 - - [x] Docs and blog: drop the advice to pin SDK 10.0.303, and explain the bundled-compiler fallback (Claude1, 8c418ced) - -**Validation (2026-10-20):** the Release sidecar solution builds with zero warnings and errors; 671 C#, 440 F# and 125 Common tests pass, and all three coverage gates pass. C# coverage is 96.39%, with all 1,530 new C# executable lines covered. `RazorSidecarTests` exercises the real IPC contract and malformed-request recovery; `RazorCompletionEditsTests` covers mapped edits, import deduplication, aliases, static imports and LF/CRLF insertion. - -**Editor validation (2026-10-20):** the final Razor chunk passes all 6 cases, the workspace chunk passes all 446 cases (including server-start lifecycle), the debugger chunk passes all 80 cases, and editor lint and formatting pass. The debugger group-outcome test that previously timed out passes unchanged in the full chunk in 6.5 seconds. - -**Template validation (2026-10-20):** the real host and sidecars run over a fresh `dotnet new blazor` app (.NET 10). Its files report no errors and keep their shape in the HTML projection. Hover, definition, references, member completion and signature help answer on its real code: layouts, `@inherits`, `@Body`, `NavLinkMatch`, `@Assets`, `typeof(Program)`. The one false report found was usings marked unnecessary: every using in `_Imports.razor`, and, as review showed, a component's usings that only its markup needs. Razor files no longer report unnecessary usings (`Import_files_never_report_their_usings_as_unnecessary`, `Components_never_report_a_using_their_markup_may_need_as_unnecessary`). - -### Phase 2: C# Inside Razor - -- [x] Diagnostics: C# diagnostics from projections, `RZ` diagnostics from facts, and Razor files in solution-wide diagnostics ([RAZOR-FEATURES-DIAGNOSTICS]) - - [x] The compiler's `RZ` diagnostics reach the file and the solution scan with LSP severities (Claude2). `RazorDiagnosticsTests` failed for the gap: the scan held no entry for a file whose only diagnostics were Razor's. It passed unchanged once both paths add the markup facts' diagnostics. The full-stack session's new step publishes RZ1034 as an Error and RZ10012 as a Warning under `sharplsp-razor`; it failed against the old sidecar and passes against the new one -- [x] Hover at mapped positions ([RAZOR-FEATURES-HOVER]) -- [x] Definition, type definition, declaration and implementation from mapped positions, with generated component classes landing in their `.razor` file ([RAZOR-FEATURES-DEFINITION]) - - [x] `RazorNavigationTests` (Claude2): - - Type definition from `@ref="tally"` and from the field lands in `Tally.razor`. - - The declaration of `Dispose` is `IDisposable.Dispose`. - - The implementations of `@implements IDisposable` include `Board.razor`. - - [x] Type definition at a declaration's name answered nothing, in C# as well. `TypeDefinitionFromSymbolTests` failed for it, then passed once the resolver falls back to the symbol's type ([DEFINITION-CSHARP-TYPE]) -- [x] References and document highlights across projections, with hidden scaffolding dropped ([RAZOR-FEATURES-REFERENCES]) -- [x] C# completion, with mapped edits and `using` → `@using` ([RAZOR-FEATURES-COMPLETION]) - - [x] Completion right after a typed dot offers members: `@scores.` and an unclosed `@(scores.` (#401, review R2). Red tests `RazorCompletionRecoveryTests` (Codex1) failed for the bug, then passed unchanged once completion asks a copy of the file with the expression finished at the caret (Claude2). An unclosed `@{` already completed and is kept as a control - - [x] Coverage gates with the #401 fix; the full C# sidecar suite passes with it - - [x] Completion inside a call or lambda of an unfinished `@(` offers members (#404, review R8). Red tests from Claude1's report failed for the bug, then passed unchanged once the copy closes every construct Roslyn's parser reads as open, innermost first, and its map keeps the parts of a fragment outside the C# it inserted. Completion now reads the compiler's diagnostics only when the projection's C# does not parse, and forks only the file's active framework -- [x] Signature help ([RAZOR-FEATURES-SIGNATURE-HELP]) - - [x] Signature help after `@(` no longer lists the generator's scaffolding (`AddContent`, `AddAttribute`) (#400, review R1, Codex1): the regression cases failed for the bug, then passed unchanged -- [x] Semantic tokens for C# fragments ([RAZOR-FEATURES-SEMANTIC-TOKENS]) -- [x] Rename and prepareRename — Razor usages and `@code` declarations, from `.cs` and `.razor` origins ([RAZOR-FEATURES-RENAME]) - - [x] Escaped identifiers (`@class`) get their Razor rename edits (review R4, Codex1): the real-fixture regression failed for the bug, then passed unchanged - - [x] `RazorRenameTests` (Claude2): - - Renaming `Badge.Text` from its code-behind edits `@Text` and Board's `Text=`. - - Renaming `Tally.Label` turns `@bind-Label` into `@bind-Title`. - - prepareRename refuses on a component's start and end tags. - - The F# half of the rename is Phase 5 - -### Phase 3: Blazor Markup - -- [x] Component tags: hover, definition, references, highlights and linked editing (Claude2) - - Hover: `RazorMarkupHoverTests`. A start or end tag answers its component's type and summary, ranged on the tag name. - - Definition: `RazorMarkupDefinitionTests`. A tag goes to the component's `.razor` file and its code-behind partial, or a framework component to its decompiled source. - - References: `RazorMarkupReferencesTests`. A component's references gain every tag that instantiates it, from markup or from C#, in every Razor file, the app's tags of a class-library component included. - - Highlights: a component's start and end tags highlight with its C# uses. - - Linked editing: `RazorStructureTests`. - - Each new test failed against the C#-only answers, then passed unchanged. -- [x] Component attributes and `@bind-X`: hover, definition, references and rename (Claude2) - - Hover: a parameter answers its property; on `@bind-` the markup answers, ranged on the whole name, and on `Label` the C# the projection maps answers. - - Definition: `@bind-` goes to the parameter it binds. - - References: a parameter's references include every `@bind-X` that names it. - - Rename: `RazorRenameTests`. -- [x] Directive attributes and directives: hover (Claude2). `@onclick` answers its descriptor's documentation and `@page` its directive's description, both from the compiler; HTML answers nothing -- [x] Completion: components, parameters (with `[EditorRequired]` first), `@bind-X`, directive attributes and their parameters, and directives (Claude2, `RazorMarkupCompletionTests`) - - After `<`, components complete by their short name where their namespace is in scope: imported, the file's own, or one containing it, as the compiler binds them. Otherwise they complete by their full name, with their documentation. Inside a component, its child-content parameters complete too. - - In a component start tag: the parameters not yet set, `[EditorRequired]` first and marked, then `@bind-X` and the directive attributes. - - In an element start tag: the directive attributes and each of their parameters (`@bind:event`, `@onclick:preventDefault`). - - An `@` opening a markup line offers the parser's directives for the file's kind. - - Every item replaces the name typed at the caret. - - The host carries the new `documentation` and `sortText` to the client. The full-stack session proves both, and that the required parameter sorts first -- [x] Semantic tokens for markup: component tags, attributes, directive attributes and directives (Claude2, `RazorMarkupTokensTests`) - - Component tags are classes, their parameters properties, directive attributes decorators and directives keywords. - - Where a name holds C#, the C# token wins and the markup keeps its part before it, so no tokens overlap. - - The C# token test now requires a markup line's markup token alone, where it used to require no token at all -- [x] Folding ranges and document symbols ([RAZOR-FEATURES-STRUCTURE]) (Claude2) - - `RazorStructureTests` covers element, `@code` and `@functions` folds and the outline. - - C# code blocks (`@{ … }`) did not fold. They do now, from the code nodes the IR holds outside every directive. - -### Phase 4: Razor Pages and MVC - -- [x] `@model`, `@inject`, `@section` and `@functions` through the projection ([RAZOR-MVC-DIRECTIVES]) - - `RazorMvcTests.Model_inject_and_functions_answer_through_the_projection`: `@model EditModel` and `Model.Title` hover their docs, `@inject` hovers `ILogger<…>`, and `@Shout` goes to its `@functions` declaration. - - `Model_expressions_and_section_code_get_csharp_features`: `asp-for="Title"` hovers and goes to `EditModel.Title`, finds its 4 references in `Edit.cshtml`, and renames them all with no generated-file edit; `@Model.Price` inside `@section` hovers. `Section_blocks_fold_and_outline` folds and outlines `@section`. - - Full stack: `test_full_stack_razor_pages_and_mvc_views` (`razor_mvc.rs`). -- [x] Tag helper elements and attributes: hover and completion ([RAZOR-MVC-TAGHELPERS], [RAZOR-MVC-SCOPE]) - - Hover: `Tag_helper_elements_and_attributes_answer_hover_from_their_descriptors` (`` answers `InputTagHelper`, `asp-for` its `ModelExpression`). - - Completion: `Tag_helper_attributes_complete_in_an_element_start_tag` (`asp-page`, `asp-controller`, `asp-route-`) and `Tag_helper_elements_complete_after_an_open_angle_bracket` (`partial`, `environment`, `a` once, the `th:` prefix, `@removeTagHelper`). That test found components completing after `<` in `.cshtml` files, where no component binds by tag; they no longer do. - - Scope: `Tag_helper_directives_scope_completion_and_binding`. VS Code: `a Razor Page completes tag helper elements once, before the HTML service` (an element that both HTML and a tag helper name is listed once). -- [x] `_ViewImports.cshtml` imports resolved by the engine ([RAZOR-MVC-IMPORTS]) — `ViewImports_imports_reach_the_views_below_them`: `@model Item` resolves through `Views/_ViewImports.cshtml`'s `@using`, the view reports no error, and its `@addTagHelper` binds `` - -### Phase 5: F# - -- [x] Hover, completion and definition from Razor into F# types of a referenced F# project ([RAZOR-FSHARP-METADATA], [RAZOR-FSHARP-DEFINITION]) - - Hover shows the F# XML docs: the design-time build references Domain's `refint` assembly, which has no XML, so the sidecar swaps in the built DLL with its documentation. `RazorFSharpTests.Hover_and_signature_help_read_FSharp_symbols_from_their_assembly` and `Completion_offers_FSharp_members_in_markup_and_code`. - - Definition lands in `Scores.fs`, not decompiled source: the C# sidecar names each F# symbol by assembly and XML doc ID, and the F# sidecar finds its declaration (`workspace/declarationForeign`, [DEFINITION-CROSSLANG-IDENTITY]). `Definition_names_each_FSharp_symbol_for_the_FSharp_sidecar`; F#: `FSharpForeignSymbolsTests`. The C#→F# definition e2e now lands in `Shapes.fs` too. - - References merge both sides by the same identity (`workspace/referencesForeign`, [REFERENCES-CROSS-LANGUAGE]): `Foreign_references_and_rename_reach_Razor_uses_of_an_FSharp_symbol`. - - F# references and highlights placed a dotted use (`player.Score`) over its whole path. They now sit on the name, the same span the foreign path reports (`F# references and foreign uses place each use on its name alike`). -- [x] F#-origin rename that includes Razor usages, mapped back from the projections ([RAZOR-FSHARP-RENAME]) - - From F#: `Foreign_references_and_rename_reach_Razor_uses_of_an_FSharp_symbol` renames `Leaders.razor`'s `@player.Score`. - - From Razor or C#: the rename resolves to the F# symbol and renames it everywhere (`Rename_from_Razor_renames_an_FSharp_symbol_in_CSharp_and_Razor`). - - Full stack: `test_full_stack_fsharp_in_razor_session` (hover, definition into `Scores.fs`, references with and without the declaration, rename from Razor and from F#). VS Code: `F# symbols in Razor navigate to, find and rename in their F# source`. - - That full-stack test only restored the fixture, so C# had no `Domain.dll` to read and every F# symbol stayed unresolved ([DEFINITION-CROSSLANG]: the F# project must be built). Its workspace now builds the F# library (`create_razor_workspace_with_fsharp_built`). - -### Phase 6: Editors - -- [x] VS Code: sync `razor` and `aspnetcorerazor` documents, and activate on `onLanguage:razor` ([RAZOR-EDITORS]) — `razor-lsp.test.ts`: a Razor file opens as Razor, and its unsaved edits reach the server -- [x] VS Code: forward HTML completion and hover over `sharplsp/razor/htmlProjection` ([RAZOR-FEATURES-HTML]) — `razor-lsp.test.ts`: element names and HTML hovers in markup, C# completion and hover in code - - [x] A C# position offers C# items alone: no word suggestions from the HTML projection, including after a typed dot and inside an unfinished `@(` (#405, review R9, Codex1, fb1fff3). The client sends the caret, and the projection answers null where the compiler-backed completion target is C#, while tag and attribute names stay markup. The host, sidecar and real-editor regressions failed for the bug, then passed unchanged -- [x] VS Code: add a `razor` end-to-end chunk on both platforms — `test-chunks.json`, in both matrices -- [x] Zed: Razor language registration (Zed has no built-in Razor grammar) — `the_server_attaches_to_the_csharp_fsharp_and_razor_languages` - -### Deferred - -- [x] Razor formatting — resolved as out of scope: SharpLsp formats no language ([SHARPLSP-FEATURES-FORMATTING]), so the spec now says so for Razor ([RAZOR-SCOPE-FORMATTING]) and nothing is advertised -- [x] Selection ranges ([RAZOR-FEATURES-SELECTION]) — `RazorSelectionRangesTests` (6 cases: C# chains through syntax nodes, tag names through enclosing elements, attribute names and values, implicit expressions, legacy files, unlisted files); `test_full_stack_razor_selection_ranges`; the routing session's `selectionRange` now asserts the exact chain. An attribute's whole span comes from the compiler's spans (a new `MarkupAttribute.End` fact), not a scan of the text -- [x] Code actions: extract a block to code-behind, extract a component, generate an event handler, add `@using` ([RAZOR-CODE-ACTIONS]) — `RazorCodeActionsTests` (5 cases). A new `@using` goes after a Razor Page's `@page` line, never above it (`A_new_import_follows_the_page_directive_a_Razor_Page_opens_with`). New files arrive as LSP `CreateFile` operations (`map_workspace_edit_creates_new_files_before_editing_them`) -- [x] Inlay hints, code lens, and call and type hierarchy from Razor positions ([RAZOR-FEATURES-INLAY-HINTS], [RAZOR-FEATURES-CODE-LENS], [RAZOR-FEATURES-HIERARCHY]) — `RazorHintsAndLensesTests` (2), `RazorHierarchyTests` (3), `test_full_stack_razor_hierarchy_and_hints` - - Outgoing calls from a Razor projection threw: the resolver read the declaration through `Solution.GetDocument`, which is null for a source-generated document. It now reads each declaration through its own tree - - Markup callers are the component, named by its file with kind `Class`, never the generated `BuildRenderTree` - - The C# sidecar named hierarchy kinds in lowercase, while the host maps PascalCase (the F# sidecar's casing). So every C# class, property or interface item reached the client as a method or class. The C# kinds are now PascalCase, and the e2e asserts the LSP kinds -- [x] `.razor.css` and `.razor.js` collocation awareness ([RAZOR-COLLOCATION]) — `RazorCollocationTests` (6), `RazorStylesheetTests` (10), `HtmlMarkupContextTests` (attribute at a caret), `test_full_stack_razor_collocated_styles_and_scripts`, VS Code `a component completes and defines its stylesheet classes, and nests its collocated files` - - Classes come from a CSS Syntax Level 3 tokenizer over rule preludes; a class completes as LSP `Value` and defines to every selector that declares it. A module literal in an `"import"` call defines to its `.razor.js`, from Razor, a code-behind and plain C# - - The attribute at a caret is read by the HTML tokenizer, continued past the caret. Review found three bugs in a text-scanning first version: an attribute after a bare one (`