From df4589b78ca698a46d5569f8aa2c6152ca2afab0 Mon Sep 17 00:00:00 2001 From: nickna Date: Tue, 6 Oct 2026 21:22:50 -0700 Subject: [PATCH 01/15] Verify external TypeScript formatter interoperability (#1398) --- docs/README.md | 1 + docs/formatting.md | 253 ++++++++++++++++++ docs/language-server.md | 5 + scripts/editor-smoke/helix/README.md | 35 +++ scripts/editor-smoke/helix/prepare.mjs | 76 ++++++ scripts/editor-smoke/helix/verify.mjs | 68 +++++ scripts/editor-smoke/neovim/README.md | 22 ++ scripts/editor-smoke/neovim/run.mjs | 101 +++++++ scripts/editor-smoke/neovim/test.lua | 98 +++++++ scripts/editor-smoke/vscode/README.md | 38 +++ scripts/editor-smoke/vscode/extension.cjs | 2 + scripts/editor-smoke/vscode/package.json | 9 + scripts/editor-smoke/vscode/run.mjs | 125 +++++++++ scripts/editor-smoke/vscode/test.cjs | 188 +++++++++++++ scripts/lib/lsp-stdio.mjs | 133 +++++++++ scripts/test-formatter-interop.mjs | 139 ++++++++++ scripts/test-formatter-interop.ps1 | 42 +++ .../fixtures/FormatterInterop/.gitattributes | 3 + .../FormatterInterop/.prettierrc.json | 6 + .../fixtures/FormatterInterop/InteropProbe.cs | 7 + tests/fixtures/FormatterInterop/Program.cs | 236 ++++++++++++++++ tests/fixtures/FormatterInterop/README.md | 72 +++++ .../SharpTS.FormatterInteropEvidence.csproj | 9 + .../FormatterInterop/cases/declarations.d.ts | 4 + .../FormatterInterop/cases/dotnet-imports.ts | 6 + .../cases/dotnettype-legacy.ts | 4 + tests/fixtures/FormatterInterop/cases/jsx.tsx | 6 + .../cases/legacy-decorators.ts | 6 + .../FormatterInterop/cases/runtime-private.ts | 10 + .../cases/stage3-decorators.ts | 3 + .../FormatterInterop/cases/syntax-private.ts | 9 + tests/fixtures/FormatterInterop/fixtures.json | 56 ++++ tools/formatter-interop/package-lock.json | 34 +++ tools/formatter-interop/package.json | 8 + 34 files changed, 1814 insertions(+) create mode 100644 docs/formatting.md create mode 100644 scripts/editor-smoke/helix/README.md create mode 100644 scripts/editor-smoke/helix/prepare.mjs create mode 100644 scripts/editor-smoke/helix/verify.mjs create mode 100644 scripts/editor-smoke/neovim/README.md create mode 100644 scripts/editor-smoke/neovim/run.mjs create mode 100644 scripts/editor-smoke/neovim/test.lua create mode 100644 scripts/editor-smoke/vscode/README.md create mode 100644 scripts/editor-smoke/vscode/extension.cjs create mode 100644 scripts/editor-smoke/vscode/package.json create mode 100644 scripts/editor-smoke/vscode/run.mjs create mode 100644 scripts/editor-smoke/vscode/test.cjs create mode 100644 scripts/lib/lsp-stdio.mjs create mode 100644 scripts/test-formatter-interop.mjs create mode 100644 scripts/test-formatter-interop.ps1 create mode 100644 tests/fixtures/FormatterInterop/.gitattributes create mode 100644 tests/fixtures/FormatterInterop/.prettierrc.json create mode 100644 tests/fixtures/FormatterInterop/InteropProbe.cs create mode 100644 tests/fixtures/FormatterInterop/Program.cs create mode 100644 tests/fixtures/FormatterInterop/README.md create mode 100644 tests/fixtures/FormatterInterop/SharpTS.FormatterInteropEvidence.csproj create mode 100644 tests/fixtures/FormatterInterop/cases/declarations.d.ts create mode 100644 tests/fixtures/FormatterInterop/cases/dotnet-imports.ts create mode 100644 tests/fixtures/FormatterInterop/cases/dotnettype-legacy.ts create mode 100644 tests/fixtures/FormatterInterop/cases/jsx.tsx create mode 100644 tests/fixtures/FormatterInterop/cases/legacy-decorators.ts create mode 100644 tests/fixtures/FormatterInterop/cases/runtime-private.ts create mode 100644 tests/fixtures/FormatterInterop/cases/stage3-decorators.ts create mode 100644 tests/fixtures/FormatterInterop/cases/syntax-private.ts create mode 100644 tests/fixtures/FormatterInterop/fixtures.json create mode 100644 tools/formatter-interop/package-lock.json create mode 100644 tools/formatter-interop/package.json diff --git a/docs/README.md b/docs/README.md index deba36db9..885f02b54 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,6 +50,7 @@ known gaps, use [STATUS.md](../STATUS.md). - [Debug interpreted TypeScript](debugging-interpreter.md) - [Debug compiled TypeScript](debugging-typescript.md) - [Language server](language-server.md) +- [Formatting TypeScript and TSX](formatting.md) - [External benchmark harness](../benchmarks/cross-runtime/README.md) - [Managed microbenchmarks](../benchmarks/micro/SharpTS.Microbenchmarks/README.md) - [Public performance snapshots](../benchmarks/snapshots/README.md) diff --git a/docs/formatting.md b/docs/formatting.md new file mode 100644 index 000000000..298808a1d --- /dev/null +++ b/docs/formatting.md @@ -0,0 +1,253 @@ +# Formatting TypeScript and TSX + +Use Prettier **3.9.9**, pinned in [tools/formatter-interop](../tools/formatter-interop/package.json), +to format SharpTS source. The editor owns formatting in both `full` and `interop-only` language-server +modes. `sharpts-lsp` does not advertise document, range or on-type formatting and does not require +Node.js or Prettier to run. + +## Install and verify + +Use Node.js 22 or newer (the repository version is recorded in [`.node-version`](../.node-version)). +From the repository root, install the exact development-tool versions in the lockfile and run the +compatibility smoke check with PowerShell 7 and the repository's .NET SDK: + +```powershell +npm ci --prefix tools/formatter-interop +pwsh ./scripts/test-formatter-interop.ps1 +``` + +The check formats representative TypeScript, declaration and TSX inputs, checks a second pass for +idempotence, and passes original and formatted inputs through SharpTS with matching options. It +also compares representative execution results and checks the server's formatting capabilities. +This is an opt-in development check; ordinary .NET builds and the packaged language server do not +install a formatter. + +For a separate SharpTS application, install the same version as a project development dependency: + +```powershell +npm install --save-dev --save-exact prettier@3.9.9 +``` + +Use that application's `node_modules/prettier` in place of `tools/formatter-interop/node_modules/prettier` +in the editor recipes below, and commit its package lockfile. + +Keep project formatting choices in a `.prettierrc.json` or another +[Prettier configuration file](https://prettier.io/docs/configuration). For example: + +```json +{ + "tabWidth": 2, + "semi": true, + "endOfLine": "lf" +} +``` + +Use the installed CLI directly when diagnosing editor differences: + +```powershell +node tools/formatter-interop/node_modules/prettier/bin/prettier.cjs --version +node tools/formatter-interop/node_modules/prettier/bin/prettier.cjs --write path/to/app.ts +``` + +When sending a buffer through stdin, pass its real filename with `--stdin-filepath`, including its +`.ts`, `.d.ts` or `.tsx` extension. That lets Prettier infer the TypeScript parser and discover the +configuration for that file. A `.tsx` filename also selects TSX syntax. Do not use a fictitious +`stdin.ts` filename or force a JavaScript parser. See the +[Prettier CLI documentation](https://prettier.io/docs/cli#--stdin-filepath). + +## Select one formatting owner + +The same Prettier installation can serve both language-server arrangements: + +| Editing arrangement | SharpTS mode and diagnostics | Ordinary TypeScript features | Formatting | +| --- | --- | --- | --- | +| SharpTS as the sole server in Neovim or Helix | `full --diagnostics all` | SharpTS's supported navigation and interop features | External Prettier | +| SharpTS alongside an existing TypeScript server | `interop-only --diagnostics sharpts-only` | The existing TypeScript server | External Prettier | +| VS Code with the SharpTS extension | Extension's default `interop-only`; `sharpts.diagnostics: sharpts-only` | VS Code's TypeScript service | Prettier extension using the pinned module | + +Full mode's supported features are described in the [language-server guide](language-server.md); +it does not provide every feature of TypeScript's language service. Disable other format-on-save +hooks for these files when choosing Prettier. Import organization and lint fixes are separate +actions and are not required for formatting. + +## VS Code + +Install **Prettier - Code formatter** (`esbenp.prettier-vscode`). In a checkout of this repository, +merge the following into `.vscode/settings.json`: + +```json +{ + "prettier.prettierPath": "./tools/formatter-interop/node_modules/prettier", + "sharpts.diagnostics": "sharpts-only", + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + } +} +``` + +`prettier.prettierPath` names the module directory, not its CLI script. For another workspace, +use the path to the same pinned module there. Keep the language settings in separate blocks. +Use **Format Document With...** to confirm the selected owner and the **Prettier** output channel +to check the resolved module/version. The extension uses the open document's filename, so no +stdin override is needed. Local module loading requires a trusted workspace; an untrusted +workspace can use the extension's bundled version instead. See the official +[Prettier VS Code settings](https://github.com/prettier/prettier-vscode#settings). + +## Neovim + +For Neovim 0.11+, register SharpTS with the recipe in the +[language-server guide](language-server.md#install-and-launch). Use `full --diagnostics all` when +it is the sole server. When keeping an existing TypeScript client, use +`interop-only --diagnostics sharpts-only` for SharpTS and keep that client's ordinary feature setup. + +The following `init.lua` example sends the unsaved buffer to the pinned CLI before saving. Replace +the absolute CLI path with your checkout's path; forward slashes also work in Windows paths. +`node` must be available on the editor's `PATH`. It runs the executable directly, without a shell. + +```lua +local prettier_cli = "/absolute/path/to/SharpTS/tools/formatter-interop/node_modules/prettier/bin/prettier.cjs" + +vim.api.nvim_create_autocmd("BufWritePre", { + group = vim.api.nvim_create_augroup("sharpts_prettier", { clear = true }), + pattern = { "*.ts", "*.tsx" }, + callback = function(ev) + local path = vim.api.nvim_buf_get_name(ev.buf) + if path == "" or vim.bo[ev.buf].buftype ~= "" then + return + end + local lines = vim.api.nvim_buf_get_lines(ev.buf, 0, -1, false) + local newline = vim.bo[ev.buf].fileformat == "dos" and "\r\n" or "\n" + local input = table.concat(lines, newline) + if vim.bo[ev.buf].endofline then + input = input .. newline + end + local tick = vim.api.nvim_buf_get_changedtick(ev.buf) + local ok, result = pcall(function() + return vim.system( + { "node", prettier_cli, "--stdin-filepath", path }, + { stdin = input } + ):wait(5000) + end) + if not ok or result.code ~= 0 then + local detail = ok and result.stderr or tostring(result) + vim.notify("Prettier failed; saving unformatted: " .. (detail or ""), + vim.log.levels.WARN) + return + end + if not vim.api.nvim_buf_is_valid(ev.buf) + or vim.api.nvim_buf_get_changedtick(ev.buf) ~= tick then + vim.notify("Buffer changed; skipped stale formatting", vim.log.levels.WARN) + return + end + if result.stdout == input then + return + end + local output = result.stdout or "" + local fileformat = output:find("\r\n", 1, true) and "dos" or "unix" + output = output:gsub("\r\n", "\n") + local endofline = output:sub(-1) == "\n" + if endofline then + output = output:sub(1, -2) + end + local view = vim.fn.winsaveview() + vim.api.nvim_buf_set_lines(ev.buf, 0, -1, false, + vim.split(output, "\n", { plain = true })) + vim.bo[ev.buf].fileformat = fileformat + vim.bo[ev.buf].endofline = endofline + vim.fn.winrestview(view) + end, +}) +``` + +This uses Neovim's documented [direct process API](https://neovim.io/doc/user/lua/#vim.system()) +and a bounded synchronous save hook. A formatting failure keeps and saves the original buffer. +Do not add a second TypeScript formatting autocmd calling +`vim.lsp.buf.format()`. If another formatter plugin already owns these files, configure that +plugin to invoke this pinned Prettier CLI with the buffer filename instead of installing this hook. + +## Helix + +Merge this into your project's `.helix/languages.toml`. Replace both absolute CLI paths and ensure +`node` and `sharpts-lsp` are available on `PATH`: + +```toml +[language-server.sharpts] +command = "sharpts-lsp" +args = ["--language-features", "full", "--diagnostics", "all"] + +[[language]] +name = "typescript" +language-servers = ["sharpts"] +auto-format = true +formatter = { command = "node", args = ["/absolute/path/to/SharpTS/tools/formatter-interop/node_modules/prettier/bin/prettier.cjs", "--stdin-filepath", "%{buffer_name}"] } + +[[language]] +name = "tsx" +language-servers = ["sharpts"] +auto-format = true +formatter = { command = "node", args = ["/absolute/path/to/SharpTS/tools/formatter-interop/node_modules/prettier/bin/prettier.cjs", "--stdin-filepath", "%{buffer_name}"] } +``` + +Helix passes the buffer through stdin; `%{buffer_name}` supplies the filename. The external +`formatter` takes precedence over LSP formatting, and `auto-format` enables it on save. See +[Helix language configuration](https://docs.helix-editor.com/languages.html). + +For coexistence, change SharpTS's arguments to +`["--language-features", "interop-only", "--diagnostics", "sharpts-only"]` and replace each +`language-servers` list with: + +```toml +language-servers = [ + { name = "typescript-language-server", except-features = ["format"] }, + "sharpts", +] +``` + +This uses Helix's built-in TypeScript-server definition and your existing installation of that +server. Completions, code actions and diagnostics from both servers are merged. Hover and signature +help use the first server advertising the feature: this order prioritizes ordinary TypeScript +results. Put `sharpts` first if you prefer SharpTS-specific hover/signature results; ordinary results +for those requests may then be unavailable. Server ordering does not merge these two providers. + +## Recorded editor smoke + +On October 6, 2026, the following checks passed on Windows ARM64 with Node.js 25.5.0, Prettier +3.9.9 and a Release build of `SharpTS.LanguageServer`: + +| Client | Mode | Execution and result | +| --- | --- | --- | +| VS Code 1.134.0, ARM64; prettier-vscode 12.4.0 | `interop-only` | Two dirty format-on-save cycles matched the pinned local module exactly; fresh SharpTS CLR hover worked before and after each save. The extension log confirmed Prettier 3.9.9 loaded from the requested module directory. | +| Neovim 0.12.5, ARM64 | `full`, `interop-only` | Headless editor using the exact Lua save hook above and native LSP client. Dirty `.ts`/`.tsx` saves matched Prettier byte for byte; fresh CLR hover worked before and after formatting; a second dirty save produced identical formatted output. | +| Helix 25.07.1 (`a05c151b`), x64 | `full`, `interop-only` | Interactive terminal session using the external formatter above. Dirty `.ts`/`.tsx` saves matched Prettier byte for byte; correlated LSP requests resolved CLR hover before and after each formatted save. | + +All three clients verified that SharpTS did not supply formatting. Reproduction instructions are in the +[VS Code smoke](../scripts/editor-smoke/vscode/README.md), +[Neovim smoke](../scripts/editor-smoke/neovim/README.md) and +[Helix smoke](../scripts/editor-smoke/helix/README.md) guides. These runs attached SharpTS in each +mode. The Neovim and Helix sessions did not attach a second TypeScript server or test third-party +formatter plugins. The VS Code smoke used the installed Prettier extension and a test-only SharpTS +hover bridge; it did not exercise the shipping SharpTS extension's activation or packaging. + +## Troubleshooting and compatibility limits + +- Run `npm ci --prefix tools/formatter-interop` if the pinned module cannot be loaded. Invoke its + CLI with `--version` and check that the editor resolves that same installation. +- Confirm the buffer has its real filename and correct `.ts`/`.tsx` extension. If configuration + seems wrong, use `--find-config-path path/to/file.ts` with the pinned CLI and check project + overrides and ignore files. +- Disable duplicate save hooks or formatting providers if a file changes twice. Selecting + `interop-only` removes duplicate SharpTS navigation; it does not select a formatter in the editor. +- A formatter parser error leaves source unchanged. Correct incomplete syntax or disable + format-on-save while editing it; do not remove supported SharpTS constructs to make formatting run. +- The smoke fixture set is a compatibility sample, not a promise about every combination of syntax, + plugins and configuration. Preserve a failing supported example and rerun the smoke command when + upgrading the pinned formatter. An unsupported combination needs a verified workaround or an + explicit compatibility issue. + +The separate compatibility command checks syntax and behavior beyond these small editor fixtures. +Recheck editor integration when changing client, formatter or save-hook versions. diff --git a/docs/language-server.md b/docs/language-server.md index 9e3b3f991..a5633cbe2 100644 --- a/docs/language-server.md +++ b/docs/language-server.md @@ -5,6 +5,11 @@ diagnostics, hover, completion, signature help, and quick fixes in every mode. I provides document symbols, definition, references, and completeness-gated rename for standalone editors. +Formatting is supplied by the editor and an external formatter. Neither language-feature mode +advertises document, range or on-type formatting. See [Formatting TypeScript and TSX](formatting.md) +for the pinned Prettier workflow, format-on-save recipes and formatter ownership when another +TypeScript server is active. + ## Install and launch Install the separately packaged tool: diff --git a/scripts/editor-smoke/helix/README.md b/scripts/editor-smoke/helix/README.md new file mode 100644 index 000000000..5c37fedd9 --- /dev/null +++ b/scripts/editor-smoke/helix/README.md @@ -0,0 +1,35 @@ +# Helix format-on-save smoke + +Install the pinned formatter and build the language server, then prepare an isolated workspace: + +```powershell +npm ci --prefix tools/formatter-interop +dotnet build src/SharpTS.LanguageServer -c Release +node scripts/editor-smoke/helix/prepare.mjs --mode full --hx /absolute/path/to/hx.exe +``` + +The preparation command prints the editor, runtime, config, log and workspace paths. Launch that +editor in a real terminal with `HELIX_RUNTIME` set to its extracted `runtime` directory: + +```powershell +hx -c /config.toml --log /helix.log -vvv -w /format-on-save.ts +``` + +In each `.ts` and `.tsx` fixture, change `original` to `edited`. Put the cursor on `DotNetType` and +use Space+k to request hover; confirm the CLR type/XML documentation appears. Use `:write` and +confirm Prettier expanded the compact source. Request hover again after the save. Open the second +file with `:open format-on-save.tsx`, +repeat the edit/hover/save, and quit. Then verify the saved bytes and real LSP log: + +```powershell +node scripts/editor-smoke/helix/verify.mjs artifacts/formatter-interop/helix/full +``` + +Repeat preparation and the terminal workflow with `--mode interop-only`, then verify that mode's +artifact directory. This uses the external formatter and filename expansion from `docs/formatting.md` +with the real server active. It does not require or test a second TypeScript server. + +Verification matches each hover response to its JSON-RPC request and requires successful hovers +both before and after each formatted `didSave`. It also checks initial and dynamic formatting +capabilities. An empty generated GUI contract bounds metadata discovery to the disposable fixture; +the fixture project is never built or restored. diff --git a/scripts/editor-smoke/helix/prepare.mjs b/scripts/editor-smoke/helix/prepare.mjs new file mode 100644 index 000000000..4f78ba41a --- /dev/null +++ b/scripts/editor-smoke/helix/prepare.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const options = new Map(); +for (let index = 2; index < process.argv.length; index += 2) { + assert.ok(process.argv[index]?.startsWith("--") && process.argv[index + 1], "use --name value pairs"); + options.set(process.argv[index].slice(2), process.argv[index + 1]); +} +const mode = options.get("mode") ?? "full"; +assert.ok(["full", "interop-only"].includes(mode)); +const hx = path.resolve(root, options.get("hx") ?? "artifacts/editor-tools/helix-25.07.1-x86_64-windows/hx.exe"); +const server = path.resolve(root, options.get("server") ?? "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll"); +const workspace = path.resolve(root, options.get("artifacts") ?? `artifacts/formatter-interop/helix/${mode}`); +const prettier = path.join(root, "tools/formatter-interop/node_modules/prettier/bin/prettier.cjs"); +await fs.access(hx); +await fs.access(server); +const pin = JSON.parse(await fs.readFile(path.join(root, "tools/formatter-interop/package.json"), "utf8")).devDependencies.prettier; +const installed = spawnSync(process.execPath, [prettier, "--version"], { encoding: "utf8", windowsHide: true, timeout: 10000 }); +assert.equal(installed.status, 0, installed.stderr); +const prettierVersion = installed.stdout.trim(); +assert.equal(prettierVersion, pin, "installed formatter differs from the exact repository pin"); +await fs.mkdir(path.join(workspace, ".helix"), { recursive: true }); +await fs.writeFile(path.join(workspace, ".prettierrc.json"), '{"semi":true,"endOfLine":"lf"}\n'); +// Bound GUI metadata discovery to this disposable fixture instead of user ancestors. +await fs.writeFile(path.join(workspace, "Smoke.csproj"), '\n'); +await fs.mkdir(path.join(workspace, "obj/@sharpts/gui"), { recursive: true }); +await fs.writeFile(path.join(workspace, "obj/@sharpts/gui/control-docs.generated.json"), '{"controls":[]}\n'); +const tomlString = (value) => JSON.stringify(value.replaceAll("\\", "/")); +const formatter = `{ command = "node", args = [${tomlString(prettier)}, "--stdin-filepath", "%{buffer_name}"] }`; +await fs.writeFile(path.join(workspace, ".helix/languages.toml"), ` +[language-server.sharpts] +command = "dotnet" +args = [${tomlString(server)}, "--language-features", "${mode}", "--diagnostics", "${mode === "full" ? "all" : "sharpts-only"}"] + +[[language]] +name = "typescript" +language-servers = ["sharpts"] +auto-format = true +formatter = ${formatter} + +[[language]] +name = "tsx" +language-servers = ["sharpts"] +auto-format = true +formatter = ${formatter} +`); +await fs.writeFile(path.join(workspace, "config.toml"), '[editor]\nauto-pairs = false\n'); +const fixtures = []; +for (const extension of ["ts", "tsx"]) { + const file = path.join(workspace, `format-on-save.${extension}`); + const original = '@DotNetType("System.Text.StringBuilder")\ndeclare class Builder{constructor();append(value:string):Builder;}\nconst message={text:"original",count:1};\n' + + (extension === "tsx" ? 'declare function Panel(props:{title:string}):any;\nconst view=;\n' : ""); + const edited = original.replace('text:"original"', 'text:"edited"'); + const format = spawnSync(process.execPath, [prettier, "--stdin-filepath", file], { + input: edited, cwd: workspace, encoding: "utf8", windowsHide: true, timeout: 10000, + }); + assert.equal(format.status, 0, format.stderr); + assert.notEqual(edited, format.stdout); + await fs.writeFile(file, original); + fixtures.push({ file, edited, expected: format.stdout }); +} +const version = spawnSync(hx, ["--version"], { encoding: "utf8", windowsHide: true }); +assert.equal(version.status, 0, version.stderr); +const config = { + mode, workspace, hx, server, fixtures, prettierVersion, version: version.stdout.trim(), + runtime: path.join(path.dirname(hx), "runtime"), + log: path.join(workspace, "helix.log"), + editorConfig: path.join(workspace, "config.toml"), +}; +await fs.rm(config.log, { force: true }); +await fs.writeFile(path.join(workspace, "test-config.json"), JSON.stringify(config, null, 2) + "\n"); +console.log(JSON.stringify(config, null, 2)); diff --git a/scripts/editor-smoke/helix/verify.mjs b/scripts/editor-smoke/helix/verify.mjs new file mode 100644 index 000000000..f245ea295 --- /dev/null +++ b/scripts/editor-smoke/helix/verify.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const workspace = path.resolve(process.argv[2] ?? "artifacts/formatter-interop/helix/full"); +const config = JSON.parse(await fs.readFile(path.join(workspace, "test-config.json"), "utf8")); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const pin = JSON.parse(await fs.readFile(path.join(root, "tools/formatter-interop/package.json"), "utf8")).devDependencies.prettier; +const installed = spawnSync(process.execPath, [path.join(root, "tools/formatter-interop/node_modules/prettier/bin/prettier.cjs"), "--version"], + { encoding: "utf8", windowsHide: true, timeout: 10000 }); +assert.equal(installed.status, 0, installed.stderr); +const prettierVersion = installed.stdout.trim(); +assert.equal(prettierVersion, pin, "installed formatter differs from the exact repository pin"); +if (config.prettierVersion) assert.equal(prettierVersion, config.prettierVersion); +const log = await fs.readFile(config.log, "utf8"); +const messages = log.split(/\r?\n/).flatMap((line, index) => { + const match = line.match(/\bsharpts (->|<-) (\{.*\})$/); + return match ? [{ direction: match[1], message: JSON.parse(match[2]), index }] : []; +}); +const initialize = messages.findLast((item) => item.direction === "->" && item.message.method === "initialize"); +assert.ok(initialize, "no real LSP initialize request"); +const initialized = messages.find((item) => item.index > initialize.index && item.direction === "<-" + && item.message.id === initialize.message.id); +assert.ok(initialized?.message.result?.capabilities, "no successful initialize response"); +const capabilities = initialized.message.result.capabilities; +for (const name of ["documentFormattingProvider", "documentRangeFormattingProvider", "documentOnTypeFormattingProvider"]) { + assert.ok(!capabilities[name], `SharpTS advertised ${name}`); +} +assert.equal(Boolean(capabilities.definitionProvider), config.mode === "full", "wrong feature mode"); +const formattingMethod = /^textDocument\/(formatting|rangeFormatting|onTypeFormatting)$/; +for (const item of messages.filter((item) => item.direction === "<-" && item.message.method === "client/registerCapability")) { + assert.ok(!item.message.params.registrations.some((registration) => formattingMethod.test(registration.method)), + "SharpTS dynamically registered formatting"); +} +const hoverEvidence = []; +for (const fixture of config.fixtures) { + assert.equal(await fs.readFile(fixture.file, "utf8"), fixture.expected, `save bytes differ: ${fixture.file}`); + const uri = pathToFileURL(fixture.file).href.toLowerCase(); + const save = messages.find((item) => item.direction === "->" && item.message.method === "textDocument/didSave" + && item.message.params.textDocument.uri.toLowerCase() === uri); + assert.ok(save, `no real didSave for ${fixture.file}`); + assert.equal(save.message.params.text, fixture.expected, "didSave did not contain formatted source"); + const hovers = messages.filter((item) => item.direction === "->" && item.message.method === "textDocument/hover" + && item.message.params.textDocument.uri.toLowerCase() === uri).flatMap((request) => { + const response = messages.find((item) => item.index > request.index && item.direction === "<-" + && item.message.id === request.message.id); + const contents = response?.message.result?.contents?.value; + return !response?.message.error && contents?.includes("System.Text.StringBuilder") + && contents.includes("Represents a mutable string of characters") ? [{ request, response }] : []; + }); + const before = hovers.find((item) => item.response.index < save.index); + const after = hovers.find((item) => item.request.index > save.index); + assert.ok(before, `no successful pre-save hover for ${fixture.file}`); + assert.ok(after, `no successful fresh post-save hover for ${fixture.file}`); + hoverEvidence.push({ file: fixture.file, beforeRequest: before.request.message.id, afterRequest: after.request.message.id }); +} +assert.ok(!messages.some((item) => formattingMethod.test(item.message.method)), + "formatting was incorrectly delegated to LSP"); +const result = { + completed: true, timestamp: new Date().toISOString(), editor: config.version, + mode: config.mode, node: process.version, prettier: prettierVersion, + formatted: config.fixtures.map((fixture) => fixture.file), + hoverEvidence, interopHover: true, lspFormattingRequests: 0, log: config.log, +}; +await fs.writeFile(path.join(workspace, "result.json"), JSON.stringify(result, null, 2) + "\n"); +console.log(JSON.stringify(result, null, 2)); diff --git a/scripts/editor-smoke/neovim/README.md b/scripts/editor-smoke/neovim/README.md new file mode 100644 index 000000000..31f313eb6 --- /dev/null +++ b/scripts/editor-smoke/neovim/README.md @@ -0,0 +1,22 @@ +# Neovim format-on-save smoke + +Install the pinned formatter and build the language server, then run from the repository root: + +```powershell +npm ci --prefix tools/formatter-interop +dotnet build src/SharpTS.LanguageServer -c Release +node scripts/editor-smoke/neovim/run.mjs --nvim /absolute/path/to/nvim.exe --server src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll +``` + +The runner uses isolated workspaces and Neovim's headless mode, extracting the actual save-hook +recipe from `docs/formatting.md`. In both SharpTS modes it attaches the real server, checks its +formatting/navigation capabilities, obtains .NET interop hover from dirty `.ts` and `.tsx` buffers, +checks fresh hover again after formatting, and verifies a second dirty save against the pinned +Prettier CLI. Logs and version-stamped results go to +`artifacts/formatter-interop/neovim`; override that with `--artifacts`. + +This exercises the editor's real save autocmd and LSP client. It does not test a graphical UI, +another TypeScript client, or arbitrary third-party Neovim formatting plugins. + +An empty generated GUI contract in each disposable workspace bounds the server's existing GUI +metadata discovery; the fixture project is never built or restored. diff --git a/scripts/editor-smoke/neovim/run.mjs b/scripts/editor-smoke/neovim/run.mjs new file mode 100644 index 000000000..ff3468b24 --- /dev/null +++ b/scripts/editor-smoke/neovim/run.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const directory = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(directory, "../../.."); +const options = new Map(); +for (let index = 2; index < process.argv.length; index += 2) { + assert.ok(process.argv[index]?.startsWith("--") && process.argv[index + 1], "use --name value pairs"); + options.set(process.argv[index].slice(2), process.argv[index + 1]); +} +const nvim = path.resolve(root, options.get("nvim") ?? "artifacts/editor-tools/nvim-win-arm64/bin/nvim.exe"); +const server = path.resolve(root, options.get("server") ?? "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll"); +const artifacts = path.resolve(root, options.get("artifacts") ?? "artifacts/formatter-interop/neovim"); +const prettier = path.join(root, "tools/formatter-interop/node_modules/prettier/bin/prettier.cjs"); +await fs.access(nvim); +await fs.access(server); +const pin = JSON.parse(await fs.readFile(path.join(root, "tools/formatter-interop/package.json"), "utf8")).devDependencies.prettier; +const installed = spawnSync(process.execPath, [prettier, "--version"], { encoding: "utf8", windowsHide: true, timeout: 10000 }); +assert.equal(installed.status, 0, installed.stderr); +const prettierVersion = installed.stdout.trim(); +assert.equal(prettierVersion, pin, "installed formatter differs from the exact repository pin"); +const markdown = await fs.readFile(path.join(root, "docs/formatting.md"), "utf8"); +const recipe = markdown.match(/```lua\r?\n([\s\S]*?)\r?\n```/)?.[1]; +assert.ok(recipe, "documentation must include the tested Neovim recipe"); +const results = []; +for (const mode of ["full", "interop-only"]) { + const workspace = path.join(artifacts, mode); + await fs.mkdir(workspace, { recursive: true }); + // Bound the existing GUI metadata discovery walk to this disposable workspace. + await fs.writeFile(path.join(workspace, "Smoke.csproj"), '\n'); + const gui = path.join(workspace, "obj/@sharpts/gui"); + await fs.mkdir(gui, { recursive: true }); + await fs.writeFile(path.join(gui, "control-docs.generated.json"), '{"controls":[]}\n'); + await fs.writeFile(path.join(workspace, ".prettierrc.json"), '{"semi":true,"endOfLine":"lf"}\n'); + const recipePath = path.join(workspace, "doc-recipe.lua"); + await fs.writeFile(recipePath, recipe.replaceAll("/absolute/path/to/SharpTS", root.replaceAll("\\", "/"))); + const fixtures = []; + for (const extension of ["ts", "tsx"]) { + const file = path.join(workspace, `format-on-save.${extension}`); + const original = '@DotNetType("System.Text.StringBuilder")\ndeclare class Builder{constructor();append(value:string):Builder;}\nconst message={text:"original",count:1};\n' + + (extension === "tsx" ? 'const view=;\n' : ""); + const edited = original.replace('text:"original"', 'text:"edited"'); + const format = spawnSync(process.execPath, [prettier, "--stdin-filepath", file], { + input: edited, cwd: workspace, encoding: "utf8", timeout: 10000, windowsHide: true, + }); + assert.equal(format.status, 0, format.stderr); + assert.notEqual(edited, format.stdout, "save fixture must require formatting"); + await fs.writeFile(file, original); + fixtures.push({ file, edited, expected: format.stdout }); + } + const config = { + mode, workspace, server, fixtures, recipe: recipePath, + result: path.join(workspace, "result.json"), + }; + const configPath = path.join(workspace, "test-config.json"); + await fs.writeFile(configPath, JSON.stringify(config, null, 2)); + await fs.rm(config.result, { force: true }); + const child = spawn(nvim, ["--headless", "-u", "NONE", "-l", path.join(directory, "test.lua")], { + cwd: workspace, + env: { + ...process.env, SHARPTS_EDITOR_SMOKE_CONFIG: configPath, + XDG_CONFIG_HOME: path.join(workspace, "config"), XDG_DATA_HOME: path.join(workspace, "data"), + XDG_STATE_HOME: path.join(workspace, "state"), XDG_CACHE_HOME: path.join(workspace, "cache"), + NVIM_APPNAME: "sharpts-editor-smoke", NVIM_LOG_FILE: path.join(workspace, "nvim.log"), + }, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let log = ""; + child.stdout.on("data", (chunk) => { log += chunk; }); + child.stderr.on("data", (chunk) => { log += chunk; }); + const timer = setTimeout(() => { + if (process.platform === "win32") { + spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true }); + } else child.kill("SIGKILL"); + }, 30000); + let code; + try { + code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", resolve); + }); + } finally { clearTimeout(timer); } + await fs.writeFile(path.join(workspace, "process.log"), log); + assert.equal(code, 0, log); + const result = JSON.parse(await fs.readFile(config.result, "utf8")); + assert.equal(result.completed, true, JSON.stringify(result)); + for (const fixture of fixtures) { + assert.equal(await fs.readFile(fixture.file, "utf8"), fixture.expected, fixture.file); + } + results.push(result); +} +const summary = { + completed: true, timestamp: new Date().toISOString(), platform: `${process.platform}/${process.arch}`, + node: process.version, prettier: prettierVersion, results, +}; +await fs.writeFile(path.join(artifacts, "summary.json"), JSON.stringify(summary, null, 2) + "\n"); +console.log(JSON.stringify(summary, null, 2)); diff --git a/scripts/editor-smoke/neovim/test.lua b/scripts/editor-smoke/neovim/test.lua new file mode 100644 index 000000000..bbf66018c --- /dev/null +++ b/scripts/editor-smoke/neovim/test.lua @@ -0,0 +1,98 @@ +local config = vim.json.decode(table.concat(vim.fn.readfile(assert(vim.env.SHARPTS_EDITOR_SMOKE_CONFIG)), "\n")) +local clients = {} +local result = { mode = config.mode, editor = "Neovim " .. tostring(vim.version()), fixtures = {} } +result.log = vim.lsp.log.get_filename() +vim.fn.writefile({}, result.log) +vim.lsp.log.set_level("debug") + +local function run() + dofile(config.recipe) + for _, fixture in ipairs(config.fixtures) do + vim.cmd.edit(vim.fn.fnameescape(fixture.file)) + local bufnr = vim.api.nvim_get_current_buf() + vim.bo[bufnr].filetype = fixture.file:match("%.tsx$") and "typescriptreact" or "typescript" + local id = assert(vim.lsp.start({ + name = "sharpts-editor-smoke", + cmd = { "dotnet", config.server, "--language-features", config.mode, "--diagnostics", "sharpts-only" }, + root_dir = config.workspace, + })) + clients[id] = true + local client = assert(vim.lsp.get_client_by_id(id)) + assert(vim.wait(10000, function() return client.initialized end, 20), "LSP initialize timed out") + local caps = client.server_capabilities + assert(not caps.documentFormattingProvider, "SharpTS advertised document formatting") + assert(not caps.documentRangeFormattingProvider, "SharpTS advertised range formatting") + assert(not caps.documentOnTypeFormattingProvider, "SharpTS advertised on-type formatting") + if config.mode == "full" then + assert(vim.wait(5000, function() + return client:supports_method("textDocument/definition", bufnr) + end, 20), "full-mode definition registration timed out") + else + assert(not client:supports_method("textDocument/definition", bufnr), "interop-only registered definition") + end + assert(not client:supports_method("textDocument/formatting", bufnr), "SharpTS registered formatting") + assert(not client:supports_method("textDocument/rangeFormatting", bufnr), "SharpTS registered range formatting") + assert(not client:supports_method("textDocument/onTypeFormatting", bufnr), "SharpTS registered on-type formatting") + local lines = vim.split(fixture.edited:gsub("\n$", ""), "\n", { plain = true }) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + assert(vim.bo[bufnr].modified, "fixture must have an unsaved edit") + local function hover() + local response = assert(client:request_sync("textDocument/hover", { + textDocument = { uri = vim.uri_from_fname(fixture.file) }, + position = { line = 0, character = 5 }, + }, 5000, bufnr)) + assert(not response.err, vim.inspect(response.err)) + local text = vim.json.encode(response.result) + assert(text:find("System.Text.StringBuilder", 1, true), "interop hover did not resolve") + assert(text:find("Represents a mutable string of characters", 1, true), "CLR documentation missing") + return response.result + end + local function save() + vim.cmd.write() + local file = assert(io.open(fixture.file, "rb")) + local actual = file:read("*a") + file:close() + assert(actual == fixture.expected, "format-on-save bytes differ from pinned Prettier: " .. fixture.file) + end + local before = hover() + save() + local after = hover() + local formatted = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + formatted[#formatted] = formatted[#formatted] .. " " + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, formatted) + assert(vim.bo[bufnr].modified, "second save must include a dirty edit") + save() + local afterSecond = hover() + table.insert(result.fixtures, { + file = fixture.file, hoverBefore = before, hoverAfter = after, + hoverAfterSecondSave = afterSecond, formatted = true, secondSaveStable = true, + }) + end + result.completed = true +end + +local ok, err = xpcall(run, debug.traceback) +if not ok then + result.completed = false + result.error = err +end +for id in pairs(clients) do + local client = vim.lsp.get_client_by_id(id) + if client then client:stop() end +end +vim.wait(3000, function() + for id in pairs(clients) do + local client = vim.lsp.get_client_by_id(id) + if client and not client:is_stopped() then return false end + end + return true +end, 20) +for id in pairs(clients) do + local client = vim.lsp.get_client_by_id(id) + if client then client:stop(true) end +end +vim.fn.writefile({ vim.json.encode(result) }, config.result) +if not ok then + io.stderr:write(err .. "\n") + vim.cmd.cquit() +end diff --git a/scripts/editor-smoke/vscode/README.md b/scripts/editor-smoke/vscode/README.md new file mode 100644 index 000000000..6a49ea0e5 --- /dev/null +++ b/scripts/editor-smoke/vscode/README.md @@ -0,0 +1,38 @@ +# Isolated VS Code format-on-save smoke + +This optional extension-host smoke uses the installed VS Code executable, the +real Prettier extension, the locally pinned Prettier package and a built SharpTS +language server. It creates its workspace, user data, downloaded extension and +results under `artifacts/formatter-interop/vscode`; normal editor settings and +extensions are untouched. This directory is a development/test extension only. + +Acquire the optional formatter package with `npm ci --prefix tools/formatter-interop`. +Install the pinned editor extension into the isolated directories: + +```powershell +code --extensions-dir artifacts/formatter-interop/vscode/extensions --user-data-dir artifacts/formatter-interop/vscode/user-data --install-extension esbenp.prettier-vscode@12.4.0 --force +``` + +Build the Release language server, then run: + +```powershell +dotnet build src/SharpTS.LanguageServer/SharpTS.LanguageServer.csproj -c Release +node scripts/editor-smoke/vscode/run.mjs +``` + +`--code`, `--server`, `--dotnet` and `--artifacts` accept explicit paths. The +default editor path is the standard Windows per-user VS Code installation; +other platforms use `code` from PATH. The runner stops after 45 seconds. + +The test opens and dirties a TypeScript document, then calls the actual editor +save pipeline twice. The installed Prettier extension must produce exactly the +output of local Prettier 3.9.9, and its current-run extension log must confirm that +both saves used that exact local module and version. A small test-only hover bridge keeps a real +`sharpts-lsp --language-features interop-only` process active and obtains SharpTS +.NET decorator hover through VS Code before and after both saves. The initialize +response must advertise hover and no formatting capabilities. This verifies +editor-owned formatting coexistence; it does not exercise the shipping SharpTS +VS Code extension's activation or packaging. + +The machine-readable evidence is `artifacts/formatter-interop/vscode/result.json`; +extension-host output is saved alongside it. diff --git a/scripts/editor-smoke/vscode/extension.cjs b/scripts/editor-smoke/vscode/extension.cjs new file mode 100644 index 000000000..c8d798d6f --- /dev/null +++ b/scripts/editor-smoke/vscode/extension.cjs @@ -0,0 +1,2 @@ +// Development/test extension only. The test runner owns its temporary LSP bridge. +exports.activate = function activate() {}; diff --git a/scripts/editor-smoke/vscode/package.json b/scripts/editor-smoke/vscode/package.json new file mode 100644 index 000000000..678f8ccd6 --- /dev/null +++ b/scripts/editor-smoke/vscode/package.json @@ -0,0 +1,9 @@ +{ + "name": "formatter-interop-smoke", + "publisher": "sharpts-tests", + "version": "1.0.0", + "private": true, + "engines": { "vscode": "^1.101.0" }, + "activationEvents": ["onStartupFinished"], + "main": "./extension.cjs" +} diff --git a/scripts/editor-smoke/vscode/run.mjs b/scripts/editor-smoke/vscode/run.mjs new file mode 100644 index 000000000..27733bd90 --- /dev/null +++ b/scripts/editor-smoke/vscode/run.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(scriptDirectory, "../../.."); +const values = new Map(); +for (let index = 2; index < process.argv.length; index += 2) { + assert.ok(process.argv[index]?.startsWith("--"), "arguments must be --name value pairs"); + assert.ok(process.argv[index + 1], `missing value for ${process.argv[index]}`); + values.set(process.argv[index].slice(2), process.argv[index + 1]); +} +const artifacts = path.resolve(root, values.get("artifacts") ?? "artifacts/formatter-interop/vscode"); +const code = values.get("code") ?? (process.platform === "win32" + ? path.join(process.env.LOCALAPPDATA, "Programs/Microsoft VS Code/Code.exe") + : "code"); +const config = { + root, + workspace: path.join(artifacts, "workspace"), + fixture: path.join(artifacts, "workspace/format-on-save.ts"), + result: path.join(artifacts, "extension-result.json"), + server: path.resolve(root, values.get("server") ?? "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll"), + dotnet: values.get("dotnet") ?? "dotnet", + prettierPath: path.join(root, "tools/formatter-interop/node_modules/prettier"), + prettierVersion: "3.9.9", + extensionVersion: "12.4.0", +}; +await fs.access(config.server); +await fs.access(path.join(config.prettierPath, "package.json")); +await fs.mkdir(path.join(config.workspace, ".vscode"), { recursive: true }); +await fs.rm(config.result, { force: true }); +const finalResult = path.join(artifacts, "result.json"); +await fs.rm(finalResult, { force: true }); +await fs.writeFile(config.fixture, + '@DotNetType("System.Text.StringBuilder")\ndeclare class Builder{constructor();Append(value:string):Builder;}\nconst message={text:"hello",count:1};\n'); +await fs.writeFile(path.join(config.workspace, ".prettierrc.json"), + JSON.stringify({ semi: true, singleQuote: false, endOfLine: "lf" }) + "\n"); +await fs.writeFile(path.join(config.workspace, ".vscode/settings.json"), + JSON.stringify({ + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": {}, + "prettier.prettierPath": config.prettierPath, + "prettier.requireConfig": true, + "typescript.format.enable": false, + "files.autoSave": "off", + "security.workspace.trust.enabled": false, + "update.mode": "none", + "extensions.autoCheckUpdates": false, + "extensions.autoUpdate": false, + "telemetry.telemetryLevel": "off", + }, null, 2) + "\n"); +const configPath = path.join(artifacts, "test-config.json"); +await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n"); + +const args = [ + "--user-data-dir", path.join(artifacts, "user-data"), + "--extensions-dir", path.join(artifacts, "extensions"), + "--extensionDevelopmentPath", scriptDirectory, + "--extensionTestsPath", path.join(scriptDirectory, "test.cjs"), + "--disable-workspace-trust", "--skip-welcome", "--skip-release-notes", + "--disable-updates", "--disable-telemetry", "--disable-gpu", "--new-window", + config.workspace, +]; +const env = { ...process.env, SHARPTS_EDITOR_SMOKE_CONFIG: configPath }; +delete env.ELECTRON_RUN_AS_NODE; +const startedAt = Date.now(); +const child = spawn(code, args, { cwd: root, env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); +let log = ""; +child.stdout.on("data", (chunk) => { log += chunk; }); +child.stderr.on("data", (chunk) => { log += chunk; }); +let timedOut = false; +const timer = setTimeout(() => { + timedOut = true; + if (process.platform === "win32") { + spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true }); + } else child.kill("SIGKILL"); +}, 45000); +const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", resolve); +}).finally(() => clearTimeout(timer)); +await fs.writeFile(path.join(artifacts, "extension-host.log"), log); +assert.equal(timedOut, false, `VS Code editor smoke exceeded 45 seconds; see ${artifacts}`); +assert.equal(exitCode, 0, `VS Code editor smoke failed; see ${path.join(artifacts, "extension-host.log")}`); +const result = JSON.parse(await fs.readFile(config.result, "utf8")); +assert.equal(result.status, "passed"); + +// Equal output alone could also come from the extension's bundled formatter. +// Verify the real pinned extension logged the requested local module/version +// during this run, rather than silently falling back to its bundled Prettier. +async function prettierLogs(directory) { + const found = []; + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) found.push(...await prettierLogs(entryPath)); + else if (entry.isFile() && entry.name.endsWith("-Prettier.log")) { + if ((await fs.stat(entryPath)).mtimeMs >= startedAt) found.push(entryPath); + } + } + return found; +} +const logPaths = await prettierLogs(path.join(artifacts, "user-data/logs")); +const normalizeModulePath = (value) => process.platform === "win32" + ? path.resolve(value).toLowerCase() + : path.resolve(value); +let verifiedInstances = 0; +for (const logPath of logPaths) { + const text = await fs.readFile(logPath, "utf8"); + for (const match of text.matchAll(/PrettierInstance:\r?\n(\{[\s\S]*?\r?\n\})/g)) { + const instance = JSON.parse(match[1]); + assert.equal(instance.version, config.prettierVersion, "editor formatter version must be pinned"); + assert.equal(normalizeModulePath(instance.modulePath), normalizeModulePath(config.prettierPath), + "editor must use the pinned local formatter module"); + verifiedInstances++; + } +} +assert.ok(verifiedInstances >= 2, "both saves must log the actual pinned formatter instance"); +result.formatterModuleVerified = true; +result.prettierExtensionLogs = logPaths; +await fs.writeFile(finalResult, JSON.stringify(result, null, 2) + "\n"); +console.log(JSON.stringify(result, null, 2)); diff --git a/scripts/editor-smoke/vscode/test.cjs b/scripts/editor-smoke/vscode/test.cjs new file mode 100644 index 000000000..93305fde3 --- /dev/null +++ b/scripts/editor-smoke/vscode/test.cjs @@ -0,0 +1,188 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); +const vscode = require("vscode"); + +// Invoked by VS Code's real extension-host test runner, without a test framework. +exports.run = async function run() { + const config = JSON.parse( + await fs.readFile(process.env.SHARPTS_EDITOR_SMOKE_CONFIG, "utf8"), + ); + const { LspStdioClient } = await import( + pathToFileURL(path.join(config.root, "scripts/lib/lsp-stdio.mjs")).href + ); + const prettier = require(config.prettierPath); + assert.equal(prettier.version, config.prettierVersion); + const prettierExtension = vscode.extensions.getExtension( + "esbenp.prettier-vscode", + ); + assert.ok(prettierExtension, "isolated Prettier extension must be installed"); + assert.equal(prettierExtension.packageJSON.version, config.extensionVersion); + await prettierExtension.activate(); + + const client = new LspStdioClient( + config.dotnet, + [config.server, "--language-features", "interop-only", "--diagnostics", "off"], + { cwd: config.workspace, timeoutMs: 10000 }, + ); + const registrations = []; + let hoverRequests = 0; + let lastHover; + let opened; + try { + const initialize = await client.request("initialize", { + processId: process.pid, + rootUri: vscode.Uri.file(config.workspace).toString(), + capabilities: { + workspace: { configuration: false }, + textDocument: { + synchronization: { dynamicRegistration: false, didSave: true }, + hover: { + dynamicRegistration: false, + contentFormat: ["markdown", "plaintext"], + }, + formatting: { dynamicRegistration: false }, + rangeFormatting: { dynamicRegistration: false }, + onTypeFormatting: { dynamicRegistration: false }, + }, + }, + }); + assert.equal(initialize.error, undefined); + const capabilities = initialize.result.capabilities; + assert.ok(capabilities.hoverProvider, "SharpTS hover must be advertised"); + for (const name of [ + "documentFormattingProvider", + "documentRangeFormattingProvider", + "documentOnTypeFormattingProvider", + ]) { + assert.ok(!capabilities[name], `SharpTS must not own ${name}`); + } + client.notify("initialized", {}); + + const uri = vscode.Uri.file(config.fixture); + opened = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(opened); + client.notify("textDocument/didOpen", { + textDocument: { + uri: uri.toString(), + languageId: opened.languageId, + version: opened.version, + text: opened.getText(), + }, + }); + registrations.push( + vscode.workspace.onDidChangeTextDocument((event) => { + if (event.document.uri.toString() === uri.toString()) { + client.notify("textDocument/didChange", { + textDocument: { uri: uri.toString(), version: event.document.version }, + contentChanges: [{ text: event.document.getText() }], + }); + } + }), + vscode.languages.registerHoverProvider( + { language: "typescript", scheme: "file" }, + { + async provideHover(document, position) { + if (document.uri.toString() !== uri.toString()) return undefined; + const reply = await client.request("textDocument/hover", { + textDocument: { uri: uri.toString() }, + position: { line: position.line, character: position.character }, + }); + assert.equal(reply.error, undefined); + lastHover = reply.result; + hoverRequests++; + if (!lastHover) return undefined; + const contents = lastHover.contents; + const text = typeof contents === "string" ? contents : contents.value; + return new vscode.Hover(new vscode.MarkdownString(text)); + }, + }, + ), + ); + + async function assertSharpTsHover() { + const countBefore = hoverRequests; + // VS Code may log and swallow a provider exception. A previous successful + // hover must never count as proof that this fresh request succeeded. + lastHover = undefined; + await vscode.commands.executeCommand( + "vscode.executeHoverProvider", + uri, + new vscode.Position(0, 4), + ); + assert.ok(hoverRequests > countBefore, "editor must obtain a fresh SharpTS response"); + assert.match(JSON.stringify(lastHover), /System\.Text\.StringBuilder/); + } + + await assertSharpTsHover(); + const before = opened.getText(); + const expected = await prettier.format(before, { + ...(await prettier.resolveConfig(config.fixture)), + filepath: config.fixture, + }); + assert.notEqual(before, expected, "fixture must require formatting"); + assert.equal( + vscode.workspace.getConfiguration("editor", uri).get("defaultFormatter"), + "esbenp.prettier-vscode", + ); + assert.equal( + vscode.workspace.getConfiguration("editor", uri).get("formatOnSave"), + true, + ); + + // A clean document's save can bypass save participants. Make a real edit, + // then let VS Code's save pipeline invoke the installed formatter itself. + const dirtyEdit = new vscode.WorkspaceEdit(); + dirtyEdit.insert(uri, opened.positionAt(before.length), " "); + assert.equal(await vscode.workspace.applyEdit(dirtyEdit), true); + assert.equal(opened.isDirty, true); + assert.equal(await opened.save(), true); + assert.equal(opened.isDirty, false); + assert.equal(opened.getText(), expected, "format-on-save must match pinned Prettier"); + assert.equal(await fs.readFile(config.fixture, "utf8"), expected); + await assertSharpTsHover(); + const firstSavedVersion = opened.version; + + // A second dirty save proves the real save participant remains idempotent. + const secondEdit = new vscode.WorkspaceEdit(); + secondEdit.insert(uri, opened.positionAt(expected.length), " "); + assert.equal(await vscode.workspace.applyEdit(secondEdit), true); + assert.equal(await opened.save(), true); + assert.equal(opened.getText(), expected); + assert.ok(opened.version > firstSavedVersion); + await assertSharpTsHover(); + + await fs.writeFile( + config.result, + JSON.stringify( + { + status: "passed", + editor: `VS Code ${vscode.version}`, + platform: `${process.platform}-${process.arch}`, + formatterExtension: `esbenp.prettier-vscode ${prettierExtension.packageJSON.version}`, + formatter: `Prettier ${prettier.version}`, + serverMode: "interop-only", + formatterOwner: "esbenp.prettier-vscode", + sharpTsHoverRequests: hoverRequests, + saves: 2, + exactFormattedOutput: true, + noServerFormattingCapability: true, + }, + null, + 2, + ) + "\n", + ); + } finally { + for (const registration of registrations) registration.dispose(); + try { + if (opened) { + client.notify("textDocument/didClose", { + textDocument: { uri: opened.uri.toString() }, + }); + } + } finally { + await client.close(); + } + } +}; diff --git a/scripts/lib/lsp-stdio.mjs b/scripts/lib/lsp-stdio.mjs new file mode 100644 index 000000000..bf96d29bc --- /dev/null +++ b/scripts/lib/lsp-stdio.mjs @@ -0,0 +1,133 @@ +import { spawn } from "node:child_process"; + +/** Small bounded stdio client for repository smoke tests, with no runtime dependencies. */ +export class LspStdioClient { + registeredMethods = []; + #process; + #pending = new Map(); + #nextId = 1; + #buffer = Buffer.alloc(0); + #stderr = ""; + #closing = false; + #exit; + #failure; + + constructor(command, args, { cwd, timeoutMs = 20_000 } = {}) { + this.timeoutMs = timeoutMs; + this.#process = spawn(command, args, { cwd, windowsHide: true, stdio: "pipe" }); + this.#exit = new Promise((resolve) => { + this.#process.once("close", (code, signal) => { + this.#fail(new Error(`LSP exited (${code ?? signal}): ${this.#stderr}`)); + resolve({ code, signal }); + }); + }); + this.#process.on("error", (error) => this.#fail(error)); + this.#process.stdin.on("error", (error) => this.#fail(error)); + this.#process.stderr.on("data", (chunk) => { + this.#stderr = (this.#stderr + chunk.toString("utf8")).slice(-65_536); + }); + this.#process.stdout.on("data", (chunk) => { + try { + this.#buffer = Buffer.concat([this.#buffer, chunk]); + if (this.#buffer.length > 16 * 1024 * 1024) throw new Error("LSP frame exceeds smoke limit"); + this.#readFrames(); + } catch (error) { + this.#fail(error); + this.#process.kill(); + } + }); + } + + #fail(error) { + this.#failure ??= error; + for (const { reject, timer } of this.#pending.values()) { + clearTimeout(timer); + reject(error); + } + this.#pending.clear(); + } + + #send(message) { + if (this.#failure) throw this.#failure; + const data = Buffer.from(JSON.stringify({ jsonrpc: "2.0", ...message }), "utf8"); + this.#process.stdin.write(Buffer.concat([ + Buffer.from(`Content-Length: ${data.length}\r\n\r\n`, "ascii"), data, + ])); + } + + #readFrames() { + for (;;) { + const headerEnd = this.#buffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + const header = this.#buffer.subarray(0, headerEnd).toString("ascii"); + const length = /^content-length:\s*(\d+)\s*$/im.exec(header); + if (!length) throw new Error(`Invalid LSP header: ${header}`); + const size = Number(length[1]); + if (size > 16 * 1024 * 1024) throw new Error("LSP payload exceeds smoke limit"); + const end = headerEnd + 4 + size; + if (this.#buffer.length < end) return; + const message = JSON.parse(this.#buffer.subarray(headerEnd + 4, end).toString("utf8")); + this.#buffer = this.#buffer.subarray(end); + if (message.method && message.id !== undefined) { + // Clients in these tests have no configuration or registration side effects. + if (message.method === "workspace/configuration") { + this.#send({ id: message.id, result: (message.params?.items ?? []).map(() => null) }); + } else if (message.method === "client/registerCapability") { + this.registeredMethods.push(...(message.params?.registrations ?? []).map((item) => item.method)); + this.#send({ id: message.id, result: null }); + } else { + this.#send({ id: message.id, error: { code: -32601, message: "Unsupported smoke client method" } }); + } + } else if (message.id !== undefined) { + const pending = this.#pending.get(message.id); + if (!pending) continue; + this.#pending.delete(message.id); + clearTimeout(pending.timer); + pending.resolve(message); + } + } + } + + notify(method, params) { this.#send({ method, params }); } + + request(method, params) { + if (this.#closing) return Promise.reject(new Error("LSP client is closing")); + if (this.#failure) return Promise.reject(this.#failure); + const id = this.#nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.#pending.delete(id); + reject(new Error(`LSP ${method} timed out: ${this.#stderr}`)); + }, this.timeoutMs); + this.#pending.set(id, { resolve, reject, timer }); + try { + this.#send({ id, method, params }); + } catch (error) { + clearTimeout(timer); + this.#pending.delete(id); + reject(error); + } + }); + } + + async close() { + try { + if (this.#process.exitCode === null && !this.#process.killed) { + const response = await this.request("shutdown", null); + if (response.error) throw new Error(JSON.stringify(response.error)); + this.#closing = true; + this.notify("exit", null); + let timer; + const exit = await Promise.race([ + this.#exit, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("LSP exit timed out")), 5_000); }), + ]).finally(() => clearTimeout(timer)); + if (exit.code !== 0) throw new Error(`LSP shutdown failed: ${this.#stderr}`); + } + } finally { + this.#closing = true; + if (this.#process.exitCode === null) this.#process.kill(); + this.#fail(new Error("LSP client closed")); + } + } +} diff --git a/scripts/test-formatter-interop.mjs b/scripts/test-formatter-interop.mjs new file mode 100644 index 000000000..e6f754b84 --- /dev/null +++ b/scripts/test-formatter-interop.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { LspStdioClient } from "./lib/lsp-stdio.mjs"; + +const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const configuration = process.argv[2] ?? "Release"; +const output = path.resolve(process.argv[3] ?? path.join(repository, "artifacts/formatter-interop", crypto.randomUUID())); +const sourceRoot = path.join(repository, "tests/fixtures/FormatterInterop"); +const formattedRoot = path.join(output, "formatted"); +const crlfRoot = path.join(output, "formatted-crlf"); +const prettier = path.join(repository, "tools/formatter-interop/node_modules/prettier/bin/prettier.cjs"); +const packageJson = JSON.parse(await readFile(path.join(repository, "tools/formatter-interop/package.json"), "utf8")); + +function run(command, args, input, timeout = 60_000) { + const result = spawnSync(command, args, { + cwd: repository, input, encoding: "utf8", timeout, + maxBuffer: 16 * 1024 * 1024, windowsHide: true, + }); + if (result.error) throw new Error(`${command}: ${result.error.message}\n${result.stderr}\n${result.stdout}`); + assert.equal(result.status, 0, `${command} ${args.join(" ")}\n${result.stderr}\n${result.stdout}`); + return result.stdout; +} + +async function runEvidence(args) { + const child = spawn("dotnet", args, { cwd: repository, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); + let timedOut = false; + const timer = setTimeout(() => { timedOut = true; child.kill(); }, 300_000); + child.stdout.on("data", (data) => process.stdout.write(data)); + child.stderr.on("data", (data) => process.stderr.write(data)); + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }).finally(() => clearTimeout(timer)); + assert.equal(timedOut, false, "Compiler evidence exceeded five minutes"); + assert.equal(code, 0, `Compiler evidence failed: dotnet ${args.join(" ")}`); +} + +await mkdir(formattedRoot, { recursive: true }); +await rm(path.join(output, "summary.json"), { force: true }); +const actualVersion = run(process.execPath, [prettier, "--version"]).trim(); +assert.equal(actualVersion, packageJson.devDependencies.prettier, "Use npm ci with the checked-in lock"); +const fixtures = JSON.parse(await readFile(path.join(sourceRoot, "fixtures.json"), "utf8")); +const results = []; +for (const fixture of fixtures) { + const sourcePath = path.resolve(sourceRoot, fixture.path); + assert.ok(sourcePath.startsWith(sourceRoot + path.sep), "Fixture path must stay within its source root"); + const text = await readFile(sourcePath, "utf8"); + const args = [prettier, "--stdin-filepath", sourcePath]; + const configPath = run(process.execPath, [prettier, "--find-config-path", sourcePath]).trim(); + assert.equal(path.resolve(repository, configPath), path.join(sourceRoot, ".prettierrc.json"), + `${fixture.name}: real source filename must discover the fixture configuration`); + const formatted = run(process.execPath, args, text.replace(/\r\n/g, "\n")); + assert.ok(formatted.length > 0, `${fixture.name}: formatter returned empty output`); + assert.ok(!formatted.includes("\r\n"), `${fixture.name}: endOfLine auto must retain LF`); + assert.equal(run(process.execPath, args, formatted), formatted, `${fixture.name}: format is not idempotent`); + const destination = path.join(formattedRoot, fixture.path); + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, formatted, "utf8"); + const crlfInput = text.replace(/\r?\n/g, "\r\n"); + const crlfFormatted = run(process.execPath, args, crlfInput); + assert.ok(!/(? 0, "No formatter fixtures were executed"); + +const harness = path.join(sourceRoot, "bin", configuration, "net10.0/SharpTS.FormatterInteropEvidence.dll"); +const semanticReport = path.join(output, "semantics.json"); +await runEvidence([harness, sourceRoot, formattedRoot, semanticReport]); +const crlfSemanticReport = path.join(output, "semantics-crlf.json"); +await runEvidence([harness, sourceRoot, crlfRoot, crlfSemanticReport]); + +const server = path.join(repository, "src/SharpTS.LanguageServer/bin", configuration, "net10.0/SharpTS.LanguageServer.dll"); +// Keep existing GUI metadata discovery inside the disposable protocol workspace. +await writeFile(path.join(output, "Smoke.csproj"), '\n'); +await mkdir(path.join(output, "obj/@sharpts/gui"), { recursive: true }); +await writeFile(path.join(output, "obj/@sharpts/gui/control-docs.generated.json"), '{"controls":[]}\n'); +const protocol = []; +for (const mode of ["full", "interop-only"]) { + const client = new LspStdioClient("dotnet", [server, "--language-features", mode], { cwd: output }); + try { + const textDocument = Object.fromEntries([ + "hover", "completion", "signatureHelp", "definition", "references", "documentSymbol", "rename", + "formatting", "rangeFormatting", "onTypeFormatting", + ].map((name) => [name, { dynamicRegistration: false }])); + textDocument.rename.prepareSupport = true; + const initialized = await client.request("initialize", { + processId: process.pid, rootUri: pathToFileURL(output).href, + capabilities: { textDocument }, + }); + assert.ok(!initialized.error, JSON.stringify(initialized.error)); + const caps = initialized.result.capabilities; + assert.ok(caps.hoverProvider && caps.completionProvider && caps.signatureHelpProvider, + "Positive interop capabilities must be present; no-format verification must not be vacuous"); + assert.equal(caps.definitionProvider != null && caps.definitionProvider !== false, mode === "full"); + assert.equal(caps.renameProvider != null && caps.renameProvider !== false, mode === "full"); + for (const provider of ["documentFormattingProvider", "documentRangeFormattingProvider", "documentOnTypeFormattingProvider"]) { + assert.ok(caps[provider] == null || caps[provider] === false, `${mode}: ${provider} must remain absent`); + } + client.notify("initialized", {}); + const uri = pathToFileURL(path.join(output, `interop-${mode}.ts`)).href; + client.notify("textDocument/didOpen", { textDocument: { + uri, languageId: "typescript", version: 1, + text: '@DotNetType("System.Text.StringBuilder")\ndeclare class Builder {}\n', + } }); + const hover = await client.request("textDocument/hover", { textDocument: { uri }, position: { line: 0, character: 5 } }); + assert.ok(!hover.error && JSON.stringify(hover.result).includes("StringBuilder"), `${mode}: interop hover must work`); + for (const method of ["formatting", "rangeFormatting", "onTypeFormatting"]) { + const formatting = await client.request(`textDocument/${method}`, { + textDocument: { uri }, options: { tabSize: 2, insertSpaces: true }, + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } }, + position: { line: 0, character: 10 }, ch: ";", + }); + assert.equal(formatting.error?.code, -32601, `${mode}: ${method} must not be served`); + } + assert.ok(!client.registeredMethods.some((method) => /textDocument\/(?:formatting|rangeFormatting|onTypeFormatting)$/.test(method)), + `${mode}: no formatting method may be dynamically registered`); + protocol.push({ mode, capabilities: caps, interopHover: true, formattingMethodsNotFound: true }); + } finally { + await client.close(); + } +} + +const report = { completed: true, prettier: actualVersion, node: process.version, + dotnet: run("dotnet", ["--version"]).trim(), configuration, fixtures: results, + semanticReport, crlfSemanticReport, protocol }; +await writeFile(path.join(output, "summary.json"), JSON.stringify(report, null, 2) + "\n"); +console.log(`Formatter interoperability passed: ${results.length} fixtures; both LSP modes. ${output}`); diff --git a/scripts/test-formatter-interop.ps1 b/scripts/test-formatter-interop.ps1 new file mode 100644 index 000000000..eeb6bd919 --- /dev/null +++ b/scripts/test-formatter-interop.ps1 @@ -0,0 +1,42 @@ +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', + [string]$ResultsDirectory = 'artifacts/formatter-interop', + [switch]$NoBuild +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$formatter = Join-Path $repositoryRoot 'tools/formatter-interop/node_modules/prettier/bin/prettier.cjs' +if (-not (Test-Path -LiteralPath $formatter)) { + throw 'Install the optional pinned formatter first: npm ci --prefix tools/formatter-interop --ignore-scripts' +} +$results = [IO.Path]::GetFullPath($ResultsDirectory, $repositoryRoot) +$runDirectory = Join-Path $results ([Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null + +Push-Location $repositoryRoot +try { + if (-not $NoBuild) { + foreach ($project in @('tests/fixtures/FormatterInterop/SharpTS.FormatterInteropEvidence.csproj', + 'src/SharpTS.LanguageServer/SharpTS.LanguageServer.csproj')) { + & dotnet build $project -c $Configuration --nologo --verbosity minimal + if ($LASTEXITCODE -ne 0) { throw "Build failed: $project" } + } + } + & node (Join-Path $PSScriptRoot 'test-formatter-interop.mjs') $Configuration $runDirectory + if ($LASTEXITCODE -ne 0) { throw "Formatter interoperability failed. Artifacts: $runDirectory" } + $summary = Get-Content -LiteralPath (Join-Path $runDirectory 'summary.json') -Raw | ConvertFrom-Json + if (-not $summary.completed -or $summary.fixtures.Count -eq 0 -or $summary.protocol.Count -ne 2) { + throw 'Formatter smoke did not execute the required nonempty fixture and protocol checks.' + } +} +catch { + $_ | Out-String | Set-Content -LiteralPath (Join-Path $runDirectory 'failure.txt') + throw +} +finally { + Pop-Location +} diff --git a/tests/fixtures/FormatterInterop/.gitattributes b/tests/fixtures/FormatterInterop/.gitattributes new file mode 100644 index 000000000..4cfc31378 --- /dev/null +++ b/tests/fixtures/FormatterInterop/.gitattributes @@ -0,0 +1,3 @@ +cases/*.ts text eol=lf +cases/*.tsx text eol=lf +cases/legacy-decorators.ts text eol=crlf diff --git a/tests/fixtures/FormatterInterop/.prettierrc.json b/tests/fixtures/FormatterInterop/.prettierrc.json new file mode 100644 index 000000000..7d49d7b19 --- /dev/null +++ b/tests/fixtures/FormatterInterop/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "semi": true, + "tabWidth": 2, + "endOfLine": "auto" +} diff --git a/tests/fixtures/FormatterInterop/InteropProbe.cs b/tests/fixtures/FormatterInterop/InteropProbe.cs new file mode 100644 index 000000000..350524eb6 --- /dev/null +++ b/tests/fixtures/FormatterInterop/InteropProbe.cs @@ -0,0 +1,7 @@ +namespace SharpTS.FormatterInteropEvidence; + +/// A real referenced CLR type, rather than an unresolved import accepted as any. +public sealed class InteropProbe +{ + public static string Join(string left, string right) => left + ":" + right; +} diff --git a/tests/fixtures/FormatterInterop/Program.cs b/tests/fixtures/FormatterInterop/Program.cs new file mode 100644 index 000000000..79822c61f --- /dev/null +++ b/tests/fixtures/FormatterInterop/Program.cs @@ -0,0 +1,236 @@ +#pragma warning disable SHARPTS_HOSTING001 + +using System.Security.Cryptography; +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; +using SharpTS.Compilation; +using SharpTS.Diagnostics; +using SharpTS.Execution; +using SharpTS.FormatterInteropEvidence; +using SharpTS.Modules; +using SharpTS.Parsing; +using SharpTS.References; +using SharpTS.TypeSystem; + +if (args.Length != 3) + throw new ArgumentException("Usage: "); + +string sourceRoot = Path.GetFullPath(args[0]); +string formattedRoot = Path.GetFullPath(args[1]); +string reportPath = Path.GetFullPath(args[2]); +var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }; +FixtureCase[] cases = JsonSerializer.Deserialize( + File.ReadAllText(Path.Combine(sourceRoot, "fixtures.json")), jsonOptions) + ?? throw new InvalidOperationException("The formatter fixture manifest is empty."); +if (cases.Length == 0 || cases.Select(item => item.Name).Distinct().Count() != cases.Length) + throw new InvalidOperationException("The fixture manifest must have unique, nonempty cases."); + +// Load an implementation assembly through the same reference seam as run/compile. The import +// fixture calls a real method on this type; an unresolved, any-typed import cannot pass execution. +string referencePath = typeof(InteropProbe).Assembly.Location; +DotNetReferences.Load(sourceRoot, [referencePath]); +var observations = new List(); +foreach (FixtureCase fixture in cases) +{ + if (!Enum.TryParse(fixture.DecoratorMode, out DecoratorMode decoratorMode) || + decoratorMode is not (DecoratorMode.Stage3 or DecoratorMode.Legacy)) + throw new InvalidOperationException($"Unsupported decorator mode for {fixture.Name}."); + if (fixture.Execute && fixture.ExpectedOutput is null) + throw new InvalidOperationException($"Executable fixture {fixture.Name} needs expectedOutput."); + + string originalPath = FixturePath(sourceRoot, fixture.Path); + string formattedPath = FixturePath(formattedRoot, fixture.Path); + string original = File.ReadAllText(originalPath); + string formatted = File.ReadAllText(formattedPath); + string[] lostComments = Regex.Matches(original, @"formatter-comment:[^\r\n*]+") + .Select(match => match.Value.Trim()) + .Where(marker => !formatted.Contains(marker, StringComparison.Ordinal)) + .ToArray(); + Console.WriteLine($"Checking {fixture.Name}: original"); + var before = Observe(originalPath, fixture, decoratorMode); + Console.WriteLine($"Checking {fixture.Name}: formatted"); + var after = Observe(formattedPath, fixture, decoratorMode); + bool equivalent = before.Success && after.Success && lostComments.Length == 0 && + (!fixture.Execute || + (before.InterpretedOutput == after.InterpretedOutput && + before.CompiledOutput == after.CompiledOutput)); + observations.Add(new CaseObservation( + fixture.Name, fixture.Path, fixture.DecoratorMode, + Hash(originalPath), Hash(formattedPath), LineEndings(original), LineEndings(formatted), + equivalent, lostComments, before, after)); +} + +bool success = observations.All(item => item.Equivalent); +Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!); +File.WriteAllText(reportPath, JsonSerializer.Serialize(new +{ + success, + recordedAtUtc = DateTimeOffset.UtcNow, + compilerAssembly = typeof(Interpreter).Assembly.GetName().FullName, + referenceAssembly = typeof(InteropProbe).Assembly.GetName().FullName, + programOptions = TypeScriptProgramOptions.Default, + checkerOptions = TypeCheckerOptions.Default with { Jsx = JsxMode.ReactJsx }, + jsxOptions = JsxParseOptions.Default, + cases = observations, +}, jsonOptions) + "\n"); +Console.WriteLine($"Formatter compiler evidence: {observations.Count} cases, " + + $"{observations.Count(item => item.Equivalent)} passed."); +Console.WriteLine(reportPath); +return success ? 0 : 1; + +static SourceObservation Observe(string path, FixtureCase fixture, DecoratorMode decoratorMode) +{ + string compiledPipeline = "not-run"; + int moduleCount = 0; + string? interpreted = null; + string? compiled = null; + try + { + var resolver = new ModuleResolver(path, TypeScriptProgramOptions.Default) + { + JsxOptions = JsxParseOptions.Default, + }; + Console.WriteLine(" load program"); + ParsedModule entry = resolver.LoadProgram(path, decoratorMode); + List modules = resolver.GetModulesInOrder(entry); + moduleCount = modules.Count; + Diagnostic[] parseErrors = modules.SelectMany(module => module.ParseDiagnostics) + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToArray(); + if (parseErrors.Length != 0) + { + Console.WriteLine(" parse errors: " + string.Join("; ", parseErrors.Select(Describe))); + return new(false, modules.Count, null, null, parseErrors.Select(Describe).ToArray()); + } + + var checker = new TypeChecker(TypeCheckerOptions.Default with { Jsx = JsxMode.ReactJsx }) + .WithFilePath(path); + checker.SetDecoratorMode(decoratorMode); + Console.WriteLine($" check {modules.Count} modules"); + TypeMap types = checker.CheckModules(modules, resolver); + var entryLexer = new Lexer(entry.Document!.Text) + { + JsxTolerant = path.EndsWith(".tsx", StringComparison.OrdinalIgnoreCase), + }; + entryLexer.ScanTokens(); + var allDiagnostics = checker.GetDiagnostics(); + var entryDiagnostics = allDiagnostics.Where(diagnostic => + string.Equals(diagnostic.FilePath, path, StringComparison.OrdinalIgnoreCase)); + var checkedDiagnostics = allDiagnostics.Where(diagnostic => + !string.Equals(diagnostic.FilePath, path, StringComparison.OrdinalIgnoreCase)) + .Concat(TypeCheckPolicy.ApplyLineDirectives(entryDiagnostics, entryLexer.Pragmas)); + Diagnostic[] typeErrors = checkedDiagnostics + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToArray(); + if (typeErrors.Length != 0) + { + Console.WriteLine(" type errors: " + string.Join("; ", typeErrors.Select(Describe))); + return new(false, modules.Count, null, null, typeErrors.Select(Describe).ToArray()); + } + if (!fixture.Execute) + return new(true, modules.Count, null, null, []); + + // Match the CLI: declaration libraries participate in checking, never in execution or + // emission. Executing the declaration graph can override real runtime globals. + List runtimeModules = resolver.GetRuntimeModulesInOrder(entry); + List emittedModules = modules.Where(module => !module.IsDeclarationFile).ToList(); + Console.WriteLine($" interpret {runtimeModules.Count} runtime modules"); + using var interpretedOutput = new StringWriter(); + using (var interpreter = new Interpreter(interpretedOutput, interpretedOutput)) + { + interpreter.SetDecoratorMode(decoratorMode); + var variableResolver = new VariableResolver(interpreter); + foreach (ParsedModule module in runtimeModules.Where(module => !module.IsBuiltIn)) + variableResolver.Resolve(module.Statements); + interpreter.InterpretModules(runtimeModules, resolver, types); + if (interpreter.LastUncaughtError is { } uncaught) + throw new InvalidOperationException("Interpreter: " + uncaught.Message); + } + interpreted = Normalize(interpretedOutput.ToString()); + + Console.WriteLine(" analyze dead code"); + bool compileAsScript = entry.IsScript && runtimeModules.Count == 1; + compiledPipeline = compileAsScript ? "script" : "modules"; + List statements = compileAsScript ? entry.Statements + : emittedModules.SelectMany(module => module.Statements).ToList(); + var deadCode = new DeadCodeAnalyzer(types).Analyze(statements); + var compiler = new ILCompiler("FormatterInterop_" + Guid.NewGuid().ToString("N")); + compiler.SetDecoratorMode(decoratorMode); + Console.WriteLine(compileAsScript ? " compile script" : " compile modules"); + if (compileAsScript) + compiler.Compile(statements, types, deadCode); + else + compiler.CompileModules(emittedModules, resolver, types, deadCode); + Console.WriteLine(" save assembly"); + byte[] assemblyBytes = compiler.SaveToBytes(); + using var compiledOutput = new StringWriter(); + Console.WriteLine(" execute assembly"); + ExecuteCompiled(assemblyBytes, compiledOutput); + compiled = Normalize(compiledOutput.ToString()); + string expected = Normalize(fixture.ExpectedOutput!); + var errors = new List(); + if (interpreted != expected) + errors.Add($"Interpreted output differs from expected: {JsonSerializer.Serialize(expected)}."); + if (compiled != expected) + errors.Add($"Compiled output differs from expected: {JsonSerializer.Serialize(expected)}."); + return new(errors.Count == 0, modules.Count, interpreted, compiled, errors.ToArray(), compiledPipeline); + } + catch (Exception error) + { + while (error is TargetInvocationException { InnerException: { } inner }) + error = inner; + Console.WriteLine(" failed: " + error.Message); + return new(false, moduleCount, interpreted, compiled, [error.ToString()], compiledPipeline); + } +} + +static string FixturePath(string root, string relative) +{ + string path = Path.GetFullPath(Path.Combine(root, relative)); + string suffix = Path.GetRelativePath(root, path); + if (Path.IsPathRooted(suffix) || suffix == ".." || + suffix.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + throw new InvalidOperationException("Fixture paths must stay inside their source root."); + return path; +} + +static void ExecuteCompiled(byte[] assemblyBytes, TextWriter output) +{ + // Match the ordinary compiler test path. The surrounding smoke runner supplies a hard + // process timeout; a cooperative cancellation flag cannot bound arbitrary emitted code. + Assembly assembly = Assembly.Load(assemblyBytes); + MethodInfo main = assembly.GetType("$Program")?.GetMethod("Main", BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidOperationException("Compiled assembly has no $Program.Main entry point."); + TextWriter priorOutput = Console.Out; + TextWriter priorError = Console.Error; + SynchronizationContext? priorContext = SynchronizationContext.Current; + Console.SetOut(output); + Console.SetError(output); + try + { + main.Invoke(null, null); + } + finally + { + SynchronizationContext.SetSynchronizationContext(priorContext); + Console.SetOut(priorOutput); + Console.SetError(priorError); + } +} + +static string Describe(Diagnostic diagnostic) => + $"{diagnostic.TsCode ?? diagnostic.Code.ToString()}: {diagnostic.Message}"; +static string Hash(string path) => Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path))); +static string Normalize(string value) => value.ReplaceLineEndings("\n"); +static string LineEndings(string value) => value.Contains("\r\n", StringComparison.Ordinal) + ? (value.Replace("\r\n", "", StringComparison.Ordinal).Contains('\n') ? "mixed" : "CRLF") + : "LF"; + +internal sealed record FixtureCase( + string Name, string Path, string DecoratorMode, bool Execute, string? ExpectedOutput); +internal sealed record SourceObservation( + bool Success, int ModuleCount, string? InterpretedOutput, string? CompiledOutput, string[] Errors, + string CompiledPipeline = "not-run"); +internal sealed record CaseObservation( + string Name, string Path, string DecoratorMode, string OriginalSha256, string FormattedSha256, + string OriginalLineEndings, string FormattedLineEndings, bool Equivalent, + string[] LostComments, SourceObservation Original, SourceObservation Formatted); diff --git a/tests/fixtures/FormatterInterop/README.md b/tests/fixtures/FormatterInterop/README.md new file mode 100644 index 000000000..b7cc73d74 --- /dev/null +++ b/tests/fixtures/FormatterInterop/README.md @@ -0,0 +1,72 @@ +# Formatter interoperability fixtures + +This opt-in evidence project is intentionally outside the solution and ordinary +test discovery. The formatter smoke runner supplies formatted copies; this +project never formats or overwrites the originals. + +`fixtures.json` records the decorator dialect and exact expected output for each +case. The corpus covers TypeScript, declaration files and TSX, namespace/generic syntax, private +members and brand checks, `dotnet:` imports, legacy `@DotNetType`, Stage 3 and legacy +class/method/parameter decorators, comments, a checked `@ts-expect-error` +directive, Unicode, and LF/CRLF inputs. The local `.gitattributes` preserves the +legacy decorator case as CRLF when checked out. + +The CLR ambient mapping uses the legacy dialect exercised by the existing +compiler/interpreter `@DotNetType` tests. The separate Stage 3 fixture exercises a +class decorator with its `(value, context)` contract. + +`syntax-private.ts` remains broad parse/typecheck-only coverage: its generic +namespace function call compiles but fails during baseline compiled execution +with `ContainsGenericParameters`. That failure occurs before formatting and is +not certified by the runtime comparison. The separate `runtime-private.ts` +keeps generic functions/classes, a namespace function, private fields/brand +checks and a checked directive in supported runtime call positions. + +Build the harness separately: + +```powershell +dotnet build tests/fixtures/FormatterInterop/SharpTS.FormatterInteropEvidence.csproj -c Release +``` + +Its arguments are ` `. Both source +roots contain the relative paths listed in `fixtures.json`; the original root +also contains the manifest. For a baseline-only check, pass the fixture directory +as both roots. For formatter evidence, pass the smoke runner's formatted output +directory as the second root. + +For each original and formatted case the harness loads the real module graph +with default TypeScript declaration libraries, explicitly sets the decorator and +JSX options, rejects parse/type errors, and applies the canonical line-directive +policy to fixture diagnostics. Runnable cases use the interpreter's runtime +module graph. Compilation uses the established compiler-test APIs: +`ILCompiler.Compile` for a script-only entry, and `ILCompiler.CompileModules` for +an imported module graph. Declaration libraries are checked and excluded from +execution/emission. Compiled assemblies load through the default assembly load +context used by ordinary compiler tests. Each report records the selected +compiled pipeline. This is representative compiler API evidence, not a claim +of exact CLI compilation equivalence; the CLI currently also uses its module +driver for script entries. Outputs must equal the manifest baseline and each other. A +real implementation assembly reference supplies `InteropProbe`; its successful +invocation proves the custom CLR import was resolved. Marker comments are also +checked for preservation. + +The JSON report contains source hashes, line-ending observations, selected +options, errors and outputs from both execution modes. Any failed case makes the +process exit nonzero. Run this trusted fixture harness under the smoke runner's +process timeout; it is an evidence collector, not an untrusted-code sandbox. + +Script decorator runtime evidence applies to the compiler-test route above. +The CLI module driver and collectible embedding loader are outside this +formatter certification. + +On October 6, 2026, the full smoke command passed with Prettier 3.9.9, +Node.js 25.5.0 and .NET SDK 10.0.401 on Windows ARM64. All eight cases formatted +idempotently and passed parsing/checking in both LF and CRLF. Six executable +cases matched their expected outputs in both interpreter and compiled modes, +before and after formatting. Both LSP feature modes advertised no formatting +providers, refused document/range/on-type formatting requests, and served CLR +decorator hover. No formatter syntax blocker required an alternate formatter. + +For the initial generic alias input, keep whitespace between the closing `>` +and `=` (`Label = {...}`). The unspaced `Label={...}` control fails the +current SharpTS parser before formatting; the spaced form is checked here. diff --git a/tests/fixtures/FormatterInterop/SharpTS.FormatterInteropEvidence.csproj b/tests/fixtures/FormatterInterop/SharpTS.FormatterInteropEvidence.csproj new file mode 100644 index 000000000..73e3dfe69 --- /dev/null +++ b/tests/fixtures/FormatterInterop/SharpTS.FormatterInteropEvidence.csproj @@ -0,0 +1,9 @@ + + + Exe + false + + + + + diff --git a/tests/fixtures/FormatterInterop/cases/declarations.d.ts b/tests/fixtures/FormatterInterop/cases/declarations.d.ts new file mode 100644 index 000000000..32fda06ca --- /dev/null +++ b/tests/fixtures/FormatterInterop/cases/declarations.d.ts @@ -0,0 +1,4 @@ +/** formatter-comment: declaration signatures and defaults survive formatting. */ +export interface FormatterOptions{readonly label:T;optional?:number;callback:(value:T)=>T;} +export type Pair = readonly[T,T]; +export declare namespace FormatterApi{function normalize(value:T,options?:FormatterOptions):T;function normalize(value:number):string;} diff --git a/tests/fixtures/FormatterInterop/cases/dotnet-imports.ts b/tests/fixtures/FormatterInterop/cases/dotnet-imports.ts new file mode 100644 index 000000000..3cb2741e7 --- /dev/null +++ b/tests/fixtures/FormatterInterop/cases/dotnet-imports.ts @@ -0,0 +1,6 @@ +import{StringBuilder as Builder}from "dotnet:System.Text.StringBuilder"; +import{InteropProbe}from "dotnet:SharpTS.FormatterInteropEvidence.InteropProbe"; +// formatter-comment: neither import scheme nor aliases are formatter syntax extensions. +console.log(InteropProbe.join("dotnet","reference")); +const builder:Builder=new Builder();builder.append("formatter ").append(42); +console.log(builder.toString()); diff --git a/tests/fixtures/FormatterInterop/cases/dotnettype-legacy.ts b/tests/fixtures/FormatterInterop/cases/dotnettype-legacy.ts new file mode 100644 index 000000000..f5ac47e7c --- /dev/null +++ b/tests/fixtures/FormatterInterop/cases/dotnettype-legacy.ts @@ -0,0 +1,4 @@ +@DotNetType("System.Text.StringBuilder") +declare class StringBuilder{constructor();append(value:string):StringBuilder;toString():string;} +const builder:StringBuilder=new StringBuilder();builder.append("dotnettype"); +console.log(builder.toString()); diff --git a/tests/fixtures/FormatterInterop/cases/jsx.tsx b/tests/fixtures/FormatterInterop/cases/jsx.tsx new file mode 100644 index 000000000..df940b28a --- /dev/null +++ b/tests/fixtures/FormatterInterop/cases/jsx.tsx @@ -0,0 +1,6 @@ +/** @jsxImportSource react */ +import{renderToString}from "react-dom/server"; +// formatter-comment: JSX whitespace, entities, attributes and embedded expressions are observable. +const view=

don't stop — 🧭 <tags>

; +console.log(renderToString(view)); +const number=7;console.log(renderToString(

{"value:"}{number}

)); diff --git a/tests/fixtures/FormatterInterop/cases/legacy-decorators.ts b/tests/fixtures/FormatterInterop/cases/legacy-decorators.ts new file mode 100644 index 000000000..1a2548520 --- /dev/null +++ b/tests/fixtures/FormatterInterop/cases/legacy-decorators.ts @@ -0,0 +1,6 @@ +// formatter-comment: this file deliberately uses CRLF and legacy parameter decorators. +function mark(target:any):void{console.log("class:legacy");} +function method(target:any,key:string,descriptor:any):void{console.log("method:"+key);} +function parameter(target:any,key:string|null,index:number):void{console.log("parameter:"+index+":"+key);} +@mark class Greeter{@method greet(@parameter name:string):void{console.log("Hello, "+name);}} +new Greeter().greet("formatter"); diff --git a/tests/fixtures/FormatterInterop/cases/runtime-private.ts b/tests/fixtures/FormatterInterop/cases/runtime-private.ts new file mode 100644 index 000000000..c92c0f846 --- /dev/null +++ b/tests/fixtures/FormatterInterop/cases/runtime-private.ts @@ -0,0 +1,10 @@ +"use strict"; +// formatter-comment: the runtime control keeps generics and namespaces in supported call positions. +namespace RuntimeFormatting{export function value():number{return 42;}} +function identity(value:T):T{return value;} +class RuntimeVault{#value:T;constructor(value:T){this.#value=value;}static has(value:any):boolean{return #value in value;}read():T{return this.#value;}} +const box=new RuntimeVault(identity(RuntimeFormatting.value())); +console.log(box.read());console.log(RuntimeVault.has(box));console.log(RuntimeVault.has({})); +// @ts-expect-error Keep this directive attached to its intentional missing-property error. +const intentionallyIncomplete:{required:number}={}; +console.log("directive 🧭"); diff --git a/tests/fixtures/FormatterInterop/cases/stage3-decorators.ts b/tests/fixtures/FormatterInterop/cases/stage3-decorators.ts new file mode 100644 index 000000000..c56c3f2a1 --- /dev/null +++ b/tests/fixtures/FormatterInterop/cases/stage3-decorators.ts @@ -0,0 +1,3 @@ +function logged(value:any,context:any):void{console.log(context.kind+":"+context.name);} +@logged class Decorated{value():string{return "stage3";}} +console.log(new Decorated().value()); diff --git a/tests/fixtures/FormatterInterop/cases/syntax-private.ts b/tests/fixtures/FormatterInterop/cases/syntax-private.ts new file mode 100644 index 000000000..bbc19be76 --- /dev/null +++ b/tests/fixtures/FormatterInterop/cases/syntax-private.ts @@ -0,0 +1,9 @@ +"use strict"; +// formatter-comment: ordinary comments survive. +namespace Formatting{export type Label = {value:T};export function label(value:T):Label{return {value /* formatter-comment: inline comment survives. */};}} +class Vault{#value:T;constructor(value:T){this.#value=value;}static has(value:any):boolean{return #value in value;}read():T{return this.#value;}} +const box=new Vault(Formatting.label(42)); +console.log(box.read().value);console.log(Vault.has(box));console.log(Vault.has({})); +// @ts-expect-error Keep this directive attached to its intentional type error. +const suppressed:number="directive 🧭"; +console.log(suppressed); diff --git a/tests/fixtures/FormatterInterop/fixtures.json b/tests/fixtures/FormatterInterop/fixtures.json new file mode 100644 index 000000000..612f75f39 --- /dev/null +++ b/tests/fixtures/FormatterInterop/fixtures.json @@ -0,0 +1,56 @@ +[ + { + "name": "namespaces-generics-private-directives-syntax", + "path": "cases/syntax-private.ts", + "decoratorMode": "Stage3", + "execute": false + }, + { + "name": "namespaces-generics-private-directives-runtime", + "path": "cases/runtime-private.ts", + "decoratorMode": "Stage3", + "execute": true, + "expectedOutput": "42\ntrue\nfalse\ndirective 🧭\n" + }, + { + "name": "dotnet-imports-and-real-reference", + "path": "cases/dotnet-imports.ts", + "decoratorMode": "Stage3", + "execute": true, + "expectedOutput": "dotnet:reference\nformatter 42\n" + }, + { + "name": "stage3-class-decorator", + "path": "cases/stage3-decorators.ts", + "decoratorMode": "Stage3", + "execute": true, + "expectedOutput": "class:Decorated\nstage3\n" + }, + { + "name": "legacy-dotnettype-ambient-mapping", + "path": "cases/dotnettype-legacy.ts", + "decoratorMode": "Legacy", + "execute": true, + "expectedOutput": "dotnettype\n" + }, + { + "name": "legacy-class-method-parameter-decorators", + "path": "cases/legacy-decorators.ts", + "decoratorMode": "Legacy", + "execute": true, + "expectedOutput": "parameter:0:greet\nmethod:greet\nclass:legacy\nHello, formatter\n" + }, + { + "name": "tsx-pragmas-text-and-expressions", + "path": "cases/jsx.tsx", + "decoratorMode": "Stage3", + "execute": true, + "expectedOutput": "

don't stop — 🧭 <tags>

\n

value:7

\n" + }, + { + "name": "declaration-signatures-and-generic-namespace", + "path": "cases/declarations.d.ts", + "decoratorMode": "Stage3", + "execute": false + } +] diff --git a/tools/formatter-interop/package-lock.json b/tools/formatter-interop/package-lock.json new file mode 100644 index 000000000..9ef2e1e5a --- /dev/null +++ b/tools/formatter-interop/package-lock.json @@ -0,0 +1,34 @@ +{ + "name": "sharpts-formatter-interop", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sharpts-formatter-interop", + "version": "1.0.0", + "devDependencies": { + "prettier": "3.9.9" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + } + } +} diff --git a/tools/formatter-interop/package.json b/tools/formatter-interop/package.json new file mode 100644 index 000000000..a3148b262 --- /dev/null +++ b/tools/formatter-interop/package.json @@ -0,0 +1,8 @@ +{ + "name": "sharpts-formatter-interop", + "version": "1.0.0", + "private": true, + "description": "Optional, pinned development tools for the SharpTS formatter interoperability smoke test", + "engines": { "node": ">=22" }, + "devDependencies": { "prettier": "3.9.9" } +} From 2a61314729d9ed759327582b970c70822247b3b0 Mon Sep 17 00:00:00 2001 From: nickna Date: Tue, 6 Oct 2026 22:20:18 -0700 Subject: [PATCH 02/15] Share versioned editor analysis snapshots (#1975) --- ARCHITECTURE.md | 41 + .../editor-analysis/EditorAnalysis.csproj | 16 + benchmarks/editor-analysis/Program.cs | 353 ++++++++ benchmarks/editor-analysis/README.md | 24 + .../editor-analysis/results-2026-10-07.json | 274 +++++++ benchmarks/editor-analysis/run.ps1 | 67 ++ docs/language-server.md | 52 ++ scripts/lib/lsp-stdio.mjs | 4 + scripts/test-analysis-snapshots.mjs | 113 +++ .../Documentation/XmlDocLoader.cs | 48 +- .../Handlers/AnalysisWorkspaceHandlers.cs | 47 ++ .../Handlers/CompletionHandler.cs | 14 +- .../Handlers/DefinitionHandler.cs | 33 +- .../Handlers/HoverHandler.cs | 17 +- .../Handlers/ReferencesHandler.cs | 19 +- .../Handlers/RenameHandler.cs | 38 +- .../Handlers/SignatureHelpHandler.cs | 18 +- .../Handlers/TextDocumentSyncHandler.cs | 10 +- src/SharpTS.LanguageServer/Program.cs | 36 +- .../Project/AnalysisMetadataProvider.cs | 267 ++++++ .../Project/CsprojParser.cs | 25 +- .../Services/AnalysisSnapshot.cs | 139 ++++ .../Services/DecoratorService.cs | 38 +- .../Services/DefinitionService.cs | 103 ++- .../Services/DiagnosticsCoordinator.cs | 29 +- .../Services/DiagnosticsService.cs | 336 ++++---- .../Services/EditorRequestGuard.cs | 45 + .../Services/GuiContractService.cs | 54 +- .../Services/NavigationModelBuilder.cs | 98 ++- .../Services/NavigationProjectCatalog.cs | 15 +- .../Services/NavigationWorkspaceContext.cs | 12 + .../Services/ObservedCompilerInputs.cs | 151 ++++ .../Services/ReferenceService.cs | 283 +++---- .../Services/RenameService.cs | 165 ++-- .../Services/SemanticAnalysisService.cs | 465 +++++++++++ .../SharpTSLanguageServer.cs | 21 +- .../Compilation/AssemblyReferenceLoader.cs | 154 +++- .../ReloadingAssemblyReferenceLoader.cs | 348 ++++++-- src/SharpTS/Configuration/FileDiscovery.cs | 4 +- .../TsConfigDeclarationResolver.cs | 20 +- .../Configuration/TsConfigFileMatcher.cs | 14 +- src/SharpTS/Configuration/TsConfigLoader.cs | 19 +- src/SharpTS/IO/ICompilerFileSystem.cs | 142 ++++ src/SharpTS/Modules/CommonJsDetector.cs | 44 +- src/SharpTS/Modules/ModuleResolver.cs | 114 ++- src/SharpTS/Modules/ParsedModule.cs | 6 + src/SharpTS/Parsing/Lexer.cs | 53 +- src/SharpTS/Parsing/Parser.Classes.cs | 8 + src/SharpTS/Parsing/Parser.Declarations.cs | 8 + src/SharpTS/Parsing/Parser.Expressions.cs | 15 + src/SharpTS/Parsing/Parser.Jsx.cs | 26 +- src/SharpTS/Parsing/Parser.Statements.cs | 4 + src/SharpTS/Parsing/Parser.Types.cs | 24 +- src/SharpTS/Parsing/Parser.cs | 39 + src/SharpTS/References/DotNetReferences.cs | 22 +- src/SharpTS/References/NuGetRestorer.cs | 18 + src/SharpTS/References/ProjectAssetsReader.cs | 5 +- .../References/SharpTsManifestLoader.cs | 8 +- src/SharpTS/TypeSystem/BindingIndex.cs | 194 +++++ .../TypeSystem/TypeChecker.TypeParsing.cs | 2 +- .../LanguageServer/AnalysisHandlerTests.cs | 469 +++++++++++ .../LanguageServer/AnalysisSnapshotTests.cs | 78 ++ .../LanguageServer/MetadataSnapshotTests.cs | 345 ++++++++ .../SemanticAnalysisServiceTests.cs | 768 ++++++++++++++++++ .../LanguageServer/SharedDiagnosticsTests.cs | 191 +++++ .../Modules/CompilerFileSystemTests.cs | 274 +++++++ .../ParserTests/ParsingCancellationTests.cs | 140 ++++ .../FrozenBindingIndexTests.cs | 112 +++ 68 files changed, 6368 insertions(+), 770 deletions(-) create mode 100644 benchmarks/editor-analysis/EditorAnalysis.csproj create mode 100644 benchmarks/editor-analysis/Program.cs create mode 100644 benchmarks/editor-analysis/README.md create mode 100644 benchmarks/editor-analysis/results-2026-10-07.json create mode 100644 benchmarks/editor-analysis/run.ps1 create mode 100644 scripts/test-analysis-snapshots.mjs create mode 100644 src/SharpTS.LanguageServer/Handlers/AnalysisWorkspaceHandlers.cs create mode 100644 src/SharpTS.LanguageServer/Project/AnalysisMetadataProvider.cs create mode 100644 src/SharpTS.LanguageServer/Services/AnalysisSnapshot.cs create mode 100644 src/SharpTS.LanguageServer/Services/EditorRequestGuard.cs create mode 100644 src/SharpTS.LanguageServer/Services/ObservedCompilerInputs.cs create mode 100644 src/SharpTS.LanguageServer/Services/SemanticAnalysisService.cs create mode 100644 src/SharpTS/IO/ICompilerFileSystem.cs create mode 100644 tests/SharpTS.Tests/LanguageServer/AnalysisHandlerTests.cs create mode 100644 tests/SharpTS.Tests/LanguageServer/AnalysisSnapshotTests.cs create mode 100644 tests/SharpTS.Tests/LanguageServer/MetadataSnapshotTests.cs create mode 100644 tests/SharpTS.Tests/LanguageServer/SemanticAnalysisServiceTests.cs create mode 100644 tests/SharpTS.Tests/LanguageServer/SharedDiagnosticsTests.cs create mode 100644 tests/SharpTS.Tests/Modules/CompilerFileSystemTests.cs create mode 100644 tests/SharpTS.Tests/ParserTests/ParsingCancellationTests.cs create mode 100644 tests/SharpTS.Tests/TypeCheckerTests/FrozenBindingIndexTests.cs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f5866e3e0..006945ca5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -113,6 +113,47 @@ User code imports only public specifiers. `primitive:` modules are private imple The user-facing declaration, interpreter export, and compiled emitter for a built-in must describe the same surface. See [`src/SharpTS/stdlib/CONTRIBUTING.md`](src/SharpTS/stdlib/CONTRIBUTING.md). +## Editor analysis + +`SharpTS.LanguageServer.Services.SemanticAnalysisService` owns completed editor analyses. +`DocumentStore.TryCapture` supplies one immutable open-buffer capture. The analysis key includes +that capture's versions/text, workspace roots, and service generation; each build records exact +compiler filesystem reads, probes, and inventories through the scoped `CompilerFileSystem` seam. +This includes negative import/configuration probes and disconnected configured roots, since a +closed root can become a reverse importer. Physical validation and CLR metadata generation checks +run before publication, reuse, and the final handler response. Lifecycle and file/folder +notifications invalidate the service and cancel obsolete builds. + +A completed `AnalysisSnapshot` owns source documents, tokens, recovered parser output, a private +TypeMap, frozen bindings, diagnostics, options, and graph/completeness facts. No checker or resolver +escapes the builder. Published ASTs are read-only inputs and must never be checked again. Embedded +standard-library ASTs remain shared read-only compiler inputs. Cursor-specific recovery must use +separate artifacts keyed by caret/query context and policy, never mutate a base snapshot. +Recovered syntax and partial semantics are explicit; recovered configured roots keep lexical +rename completeness false. Workspace expansion can reuse an already checked source component +without inserting a closed declaration into the open-buffer overlay. + +Identical in-flight requests coalesce. Caller cancellation stops only that caller's wait; +invalidation/disposal owns build cancellation. At most two builds execute and sixteen builds are +admitted concurrently. Completed entries use LRU eviction with an eight-entry bound and a 64 MiB +estimated source/semantic payload budget. This estimate is not a total heap bound: it excludes CLR +assembly image ownership and approximates AST/binding overhead. Active leases survive eviction; +cache/lease disposal releases metadata generations when their last owner finishes. Shared build +failures remain observable on stderr even if every caller has cancelled. + +`AnalysisMetadataProvider` captures read-only project/manifest inputs and reference-counted +`MetadataLoadContext` generations. Captured private assembly bytes permit replacing user DLLs +while older requests finish; content fingerprints detect same-size/timestamp changes. XML docs +and decorator type-name caches validate against their own content/generation. Query-time refresh +does not run restore or load user assemblies into the execution runtime. The installed default +framework is a fixed host input; an explicit SDK directory is a mutable observed input. + +Interop editor services use these metadata views. General checker CLR synthesis still follows +the runtime registry path: routing metadata-only Types through synthesis requires metadata-safe +attribute/primitive inspection and scoped synthesis identities. Missing custom-reference general +symbols remain partial and acquire no guessed source binding. The shared analysis service +conservatively invalidates on metadata changes without claiming that synthesis bridge. + ## Interpreter architecture The interpreter evaluates expressions to `RuntimeValue` and executes statements against a diff --git a/benchmarks/editor-analysis/EditorAnalysis.csproj b/benchmarks/editor-analysis/EditorAnalysis.csproj new file mode 100644 index 000000000..010e614d7 --- /dev/null +++ b/benchmarks/editor-analysis/EditorAnalysis.csproj @@ -0,0 +1,16 @@ + + + net10.0 + Exe + SharpTS.Tests + enable + enable + latest + false + $(DefineConstants);BASELINE + $(NoWarn);VSTHRD002 + + + + + diff --git a/benchmarks/editor-analysis/Program.cs b/benchmarks/editor-analysis/Program.cs new file mode 100644 index 000000000..8aff395e6 --- /dev/null +++ b/benchmarks/editor-analysis/Program.cs @@ -0,0 +1,353 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Runtime.CompilerServices; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using SharpTS.LanguageServer; +using SharpTS.LanguageServer.Conversions; +using SharpTS.LanguageServer.Services; +using SharpTS.TypeSystem; + +const int coldSamples = 11; +const int warmSamples = 31; +string artifactRoot = Path.GetFullPath(args[0]); +Directory.CreateDirectory(artifactRoot); +var reports = new List(); +foreach (string name in new[] { "small", "multi-project" }) +{ + Fixture fixture = Fixture.Create(Path.Combine(artifactRoot, "fixtures", name), name == "multi-project"); + // Process-local JIT and embedded resources are deliberately warmed before cache-cold samples. + using (var prewarm = new QuerySession(fixture)) + for (int i = 0; i < 3; i++) await prewarm.RunAsync(); + + var cold = new List(); + for (int i = 0; i < coldSamples; i++) + { + Collect(); + using var session = new QuerySession(fixture); + cold.Add(await Measure(session)); + } + + var warm = new List(); + object? validation; + using (var session = new QuerySession(fixture)) + { + await session.RunAsync(); + foreach (int _ in Enumerable.Range(0, warmSamples)) warm.Add(await Measure(session)); + validation = await session.MeasureValidationAsync(warmSamples); + } + Retention retention = MeasureRetention(fixture); + reports.Add(new + { + name, + fixture.SourceFiles, + fixture.Projects, + sourceBytes = fixture.SourceBytes, + cold = Summarize(cold), + warm = Summarize(warm), + retention, + validation, + }); +} +var result = new +{ +#if BASELINE + implementation = args.Length > 2 ? args[2] : "df4589b7", +#else + implementation = "shared-snapshots-current", +#endif + timestampUtc = DateTime.UtcNow, + runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, + os = System.Runtime.InteropServices.RuntimeInformation.OSDescription, + architecture = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString(), + processorCount = Environment.ProcessorCount, + coldSamples, + warmSamples, + methodology = "Same definition, references(includeDeclaration=true, workspace roots), statements, full diagnostics sequence. Fresh service cache for each cold sample; repeated unchanged capture for warm samples. Process/JIT/filesystem caches warmed. Deterministic noLib/types=[] fixtures isolate source graph. Services only, no transport or CLR metadata provider. Allocation measured process-wide across async workers. Baseline CheckModules entry counter is the only archived production source change; current uses Statistics.Checks. Retained heap is noisy process-wide full-GC live heap, cache bytes are implementation estimates.", + reports, +}; +string output = Path.Combine(artifactRoot, args[1]); +await File.WriteAllTextAsync(output, JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); +Console.WriteLine(output); + +static async Task Measure(QuerySession session) +{ + long beforeCount = session.Checks; + long allocatedBefore = GC.GetTotalAllocatedBytes(precise: true); + var watch = Stopwatch.StartNew(); + string result = await session.RunAsync(); + watch.Stop(); + return new(watch.Elapsed.TotalMilliseconds, GC.GetTotalAllocatedBytes(precise: true) - allocatedBefore, + session.Checks - beforeCount, result, session.LastFeatureTimes, session.LastFeatureAllocations); +} + +static object Summarize(List samples) +{ + if (samples.Select(sample => sample.Result).Distinct().Count() != 1) + throw new InvalidOperationException("Equivalent requests changed result within a measurement phase."); + double[] times = samples.Select(sample => sample.Milliseconds).Order().ToArray(); + long[] allocations = samples.Select(sample => sample.AllocatedBytes).Order().ToArray(); + return new + { + medianMilliseconds = times[times.Length / 2], + p90Milliseconds = times[(int)Math.Ceiling(times.Length * .9) - 1], + medianAllocatedBytes = allocations[allocations.Length / 2], + checksPerSequence = samples.Select(sample => sample.Checks).Distinct().Order().ToArray(), + resultFingerprint = samples[0].Result, + featureMedianMilliseconds = Enumerable.Range(0, 3).Select(index => + samples.Select(sample => sample.FeatureMilliseconds[index]).Order().ElementAt(samples.Count / 2)).ToArray(), + featureMedianAllocatedBytes = Enumerable.Range(0, 3).Select(index => + samples.Select(sample => sample.FeatureAllocatedBytes[index]).Order().ElementAt(samples.Count / 2)).ToArray(), + samples, + }; +} + +static void Collect() +{ + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); +} + +[MethodImpl(MethodImplOptions.NoInlining)] +static Retention MeasureRetention(Fixture fixture) +{ + using var session = new QuerySession(fixture); + PopulateAndDropTemporaryReferences(session); + Collect(); + long withCache = GC.GetTotalMemory(forceFullCollection: true); + long cacheEstimate = session.CachedBytes; + int snapshots = session.CachedSnapshots; + session.ClearCaches(); + Collect(); + long afterClear = GC.GetTotalMemory(forceFullCollection: true); + GC.KeepAlive(session); + return new(withCache, afterClear, withCache - afterClear, + cacheEstimate < 0 ? null : cacheEstimate, snapshots < 0 ? null : snapshots); +} + +[MethodImpl(MethodImplOptions.NoInlining)] +static void PopulateAndDropTemporaryReferences(QuerySession session) +{ + // Synchronous, separate frame so completed query tasks and AST locals cannot contaminate + // the full-GC comparison. No lease survives this method. + session.RunAsync().GetAwaiter().GetResult(); +} + +record Sample(double Milliseconds, long AllocatedBytes, long Checks, string Result, + double[] FeatureMilliseconds, long[] FeatureAllocatedBytes); +record Retention(long LiveHeapWithCacheBytes, long LiveHeapAfterCacheClearBytes, + long CacheRetainedHeapBytes, long? EstimatedCachedBytes, int? CachedSnapshots); + +sealed class QuerySession : IDisposable +{ + readonly Fixture _fixture; + readonly DefinitionService _definitions; + readonly ReferenceService _references; + readonly DiagnosticsService _diagnostics; + public double[] LastFeatureTimes { get; private set; } = []; + public long[] LastFeatureAllocations { get; private set; } = []; +#if !BASELINE + readonly SemanticAnalysisService _analysis; +#endif + public QuerySession(Fixture fixture) + { + _fixture = fixture; +#if BASELINE + _definitions = new(); + _references = new(); + _diagnostics = new(); +#else + var workspace = new NavigationWorkspaceContext(); + workspace.Initialize(new InitializeParams { RootUri = new Uri(fixture.Root).AbsoluteUri }); + _analysis = new(workspace); + _definitions = new(_analysis); + _references = new(_analysis); + _diagnostics = new(analysis: _analysis); +#endif + } + public long Checks => +#if BASELINE + Interlocked.Read(ref TypeChecker.BenchmarkChecks); +#else + _analysis.Statistics.Checks; +#endif + public long CachedBytes => +#if BASELINE + -1; +#else + _analysis.Statistics.EstimatedRetainedBytes; +#endif + public int CachedSnapshots => +#if BASELINE + -1; +#else + _analysis.Statistics.RetainedSnapshots; +#endif + public async Task RunAsync() + { + var featureTimes = new double[3]; + var featureAllocations = new long[3]; + long featureAllocatedBefore = GC.GetTotalAllocatedBytes(precise: true); + var featureWatch = Stopwatch.StartNew(); +#if BASELINE + var definitions = _definitions.FindDefinitions(_fixture.Path, _fixture.Text, + _fixture.Position, _fixture.Capture.TextOverlay); + featureTimes[0] = featureWatch.Elapsed.TotalMilliseconds; + featureAllocations[0] = GC.GetTotalAllocatedBytes(precise: true) - featureAllocatedBefore; + featureAllocatedBefore = GC.GetTotalAllocatedBytes(precise: true); + featureWatch.Restart(); + var references = _references.FindReferenceResult(_fixture.Path, _fixture.Text, + _fixture.Position, true, _fixture.Capture.TextOverlay, [_fixture.Root]); + featureTimes[1] = featureWatch.Elapsed.TotalMilliseconds; + featureAllocations[1] = GC.GetTotalAllocatedBytes(precise: true) - featureAllocatedBefore; + featureAllocatedBefore = GC.GetTotalAllocatedBytes(precise: true); + featureWatch.Restart(); + var statements = _diagnostics.GetStatements(_fixture.Capture.Document, CancellationToken.None); + var diagnostics = _diagnostics.Analyze(_fixture.Capture, _fixture.Capture.Document, + DiagnosticPublishMode.All, CancellationToken.None); + await Task.CompletedTask; +#else + var definitionResult = await _definitions.FindDefinitionsAsync(_fixture.Capture, + _fixture.Position, CancellationToken.None); + if (!definitionResult.IsCurrent()) throw new InvalidOperationException("Stale definition"); + var definitions = definitionResult.Locations; + featureTimes[0] = featureWatch.Elapsed.TotalMilliseconds; + featureAllocations[0] = GC.GetTotalAllocatedBytes(precise: true) - featureAllocatedBefore; + featureAllocatedBefore = GC.GetTotalAllocatedBytes(precise: true); + featureWatch.Restart(); + var references = await _references.FindReferenceResultAsync(_fixture.Capture, + _fixture.Position, true, [_fixture.Root], cancellationToken: CancellationToken.None); + if (!references.IsCurrent()) throw new InvalidOperationException("Stale references"); + featureTimes[1] = featureWatch.Elapsed.TotalMilliseconds; + featureAllocations[1] = GC.GetTotalAllocatedBytes(precise: true) - featureAllocatedBefore; + featureAllocatedBefore = GC.GetTotalAllocatedBytes(precise: true); + featureWatch.Restart(); + var statements = await _diagnostics.GetStatementsAsync(_fixture.Capture, + DiagnosticPublishMode.All, CancellationToken.None); + var diagnosticsResult = await _diagnostics.AnalyzeResultAsync(_fixture.Capture, + _fixture.Capture.Document, DiagnosticPublishMode.All, CancellationToken.None); + if (!diagnosticsResult.IsCurrent(CancellationToken.None)) throw new InvalidOperationException("Stale diagnostics"); + var diagnostics = diagnosticsResult.Diagnostics; +#endif + featureTimes[2] = featureWatch.Elapsed.TotalMilliseconds; + featureAllocations[2] = GC.GetTotalAllocatedBytes(precise: true) - featureAllocatedBefore; + LastFeatureTimes = featureTimes; + LastFeatureAllocations = featureAllocations; + if (definitions.Count != 1 || references.Locations.Count < 2 || !references.IsComplete || + diagnostics.Count != 0 || statements.Count < 2) + throw new InvalidOperationException($"Unexpected query output: defs={definitions.Count}; refs={references.Locations.Count}; complete={references.IsComplete}; diagnostics={diagnostics.Count}; stmts={statements.Count}; " + string.Join(" | ", diagnostics.Select(diagnostic => diagnostic.Message))); + string RelativeLocation(Location location) => string.Join(":", Path.GetRelativePath(_fixture.Root, + location.Uri.GetFileSystemPath()).Replace('\\', '/'), location.Range.Start.Line, + location.Range.Start.Character, location.Range.End.Line, location.Range.End.Character); + string output = string.Join("|", definitions.Select(RelativeLocation)) + ";" + + string.Join("|", references.Locations.Select(RelativeLocation)) + ";" + references.IsComplete + ";" + + statements.Count + ";" + diagnostics.Count; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(output))); + } + public void Dispose() + { +#if !BASELINE + _definitions.Dispose(); _references.Dispose(); _diagnostics.Dispose(); _analysis.Dispose(); +#endif + } + public void ClearCaches() + { + _diagnostics.Invalidate(_fixture.Path); +#if !BASELINE + _analysis.InvalidateAll(); +#endif + } + public async Task MeasureValidationAsync(int samples) + { +#if BASELINE + await Task.CompletedTask; + return null; +#else + using var lease = await _analysis.GetDocumentAsync(_fixture.Capture); + if (lease is null) throw new InvalidOperationException("Missing validation capture"); + int offset = lease.Model.Document.Lines.ToOffset( + (int)_fixture.Position.Line + 1, (int)_fixture.Position.Character + 1); + string anchorPath = lease.Model.Bindings.FindSymbols(lease.Model.Document, offset) + .SelectMany(symbol => symbol.Declarations).First().Document.Path; + using var workspace = await _analysis.GetWorkspaceAsync(_fixture.Capture, anchorPath, + lease, [_fixture.Root], CancellationToken.None); + if (workspace is null) throw new InvalidOperationException("Missing workspace validation capture"); + object MeasureProof(AnalysisLease captured) + { + var durations = new List(); + var allocations = new List(); + for (int i = 0; i < samples; i++) + { + long before = GC.GetTotalAllocatedBytes(precise: true); + var watch = Stopwatch.StartNew(); + if (!captured.Validation.IsCurrent()) throw new InvalidOperationException("Stale validation sample"); + watch.Stop(); + durations.Add(watch.Elapsed.TotalMilliseconds); + allocations.Add(GC.GetTotalAllocatedBytes(precise: true) - before); + } + return new + { + inputs = captured.Inputs.Count, + observedInputEstimatedBytes = captured.Inputs.EstimatedBytes, + medianMilliseconds = durations.Order().ElementAt(samples / 2), + p90Milliseconds = durations.Order().ElementAt((int)Math.Ceiling(samples * .9) - 1), + medianAllocatedBytes = allocations.Order().ElementAt(samples / 2), + }; + } + return new { document = MeasureProof(lease), workspace = MeasureProof(workspace) }; +#endif + } +} + +sealed record Fixture(string Root, string Path, string Text, Position Position, + DocumentRequestSnapshot Capture, int SourceFiles, int Projects, long SourceBytes) +{ + public static Fixture Create(string root, bool multi) + { + Directory.CreateDirectory(root); + void Write(string path, string text) + { + string absolute = System.IO.Path.Combine(root, path); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(absolute)!); + if (!File.Exists(absolute) || File.ReadAllText(absolute) != text) File.WriteAllText(absolute, text); + } + const string options = "\"compilerOptions\":{\"noLib\":true,\"types\":[]}"; + string path; + string text; + if (!multi) + { + text = "export const original = 1;\nconst result: number = original;\n"; + path = System.IO.Path.Combine(root, "main.ts"); + Write("main.ts", text); + Write("tsconfig.json", "{" + options + ",\"files\":[\"main.ts\"]}"); + } + else + { + Write("tsconfig.json", "{" + options + ",\"files\":[],\"references\":[{\"path\":\"packages/lib\"},{\"path\":\"apps/one\"},{\"path\":\"apps/two\"}]}"); + Write("packages/lib/src/dependency.ts", "export const original = 1;\n"); + Write("packages/lib/tsconfig.json", "{" + options + ",\"include\":[\"src/**/*.ts\"]}"); + for (int app = 1; app <= 2; app++) + { + string directory = app == 1 ? "apps/one" : "apps/two"; + Write(directory + "/tsconfig.json", "{" + options + ",\"include\":[\"src/**/*.ts\"],\"references\":[{\"path\":\"../../packages/lib\"}]}"); + for (int file = 0; file < 12; file++) + Write(directory + $"/src/importer{file}.ts", "import { original as first } from '../../../packages/lib/src/dependency';\nconst result: number = first;\n" + + string.Join("\n", Enumerable.Range(0, 30).Select(index => $"export function compute{index}(value: number): number {{ return value + result; }}")) + "\n"); + } + path = System.IO.Path.Combine(root, "apps/one/src/importer0.ts"); + text = File.ReadAllText(path); + } + // Match DocumentStore's canonical filesystem identities, including separator spelling. + path = System.IO.Path.GetFullPath(path); + string uri = new Uri(path).AbsoluteUri; + var snapshot = new DocumentSnapshot(uri, text, 1, path); + var capture = new DocumentRequestSnapshot(snapshot, 1, + new Dictionary(StringComparer.OrdinalIgnoreCase) { [path] = snapshot }); + string[] sources = Directory.GetFiles(root, "*.ts", SearchOption.AllDirectories); + return new(root, path, text, new Position(1, "const result: number = ".Length + 1), + capture, sources.Length, multi ? 4 : 1, sources.Sum(source => new FileInfo(source).Length)); + } +} diff --git a/benchmarks/editor-analysis/README.md b/benchmarks/editor-analysis/README.md new file mode 100644 index 000000000..a10c1e42c --- /dev/null +++ b/benchmarks/editor-analysis/README.md @@ -0,0 +1,24 @@ +# Checked editor analysis benchmark + +Optional console runner for issue #1975. It is outside the solution and ordinary test runs. +The executable uses the `SharpTS.Tests` friend assembly name to read the LSP's internal analysis counters; it is not a test assembly or a distributed tool. + +Run from the repository root with .NET 10 and PowerShell: + +```powershell +./benchmarks/editor-analysis/run.ps1 +# Offline replay using an already populated NuGet cache: +./benchmarks/editor-analysis/run.ps1 -PackageCache C:/Users/you/.nuget/packages +``` + +The script archives the exact baseline commit `df4589b7` beneath ignored `artifacts/analysis-benchmark`, adds one `Interlocked.Increment` counter at the archived `TypeChecker.CheckModules` entry point, and builds baseline and current Release binaries separately. No baseline service behavior is reconstructed. The runner compiles against each binary set using `LibraryDirectory` and `Baseline=true` for the old API. All fixtures, archived source, intermediate files and raw samples remain ignored. No package dependency is added to the language server. + +Each fixture runs the same definition, references (including declarations and workspace expansion), statement retrieval, and full diagnostics sequence. The runner verifies usable results and records a source-relative fingerprint; the script refuses unequal baseline/current fingerprints. The small fixture has one source file; the multi-project fixture has 25 source files, four configs, project references and two consumers with 30 functions per file. Both explicitly set `noLib: true` and `types: []` to isolate source graph work. This measures service queries, excluding stdio transport, editor UI and CLR metadata refresh. + +After three process-local prewarm sequences, 11 cache-cold samples use fresh services and 31 warm samples repeat an unchanged capture. “Cold” means an empty analysis cache; JIT, embedded resources and operating-system file caches are already warm. Reports include median/p90 latency, process-wide allocated bytes (including asynchronous workers), actual whole-graph checker calls, feature timing in the order definition/references/diagnostics, and current cache estimates. Document and expanded-workspace currentness validation are measured separately with their observed-input counts and byte estimates. + +Retained memory is measured in a separate non-inlined synchronous frame: populate one session, release all temporary query tasks/AST references, force full GC, invalidate its caches without holding a lease, and force GC again. Reports include both absolute live-heap values and their difference. These are process-wide managed heap observations, not a strict peak or private working-set limit. Cache byte estimates are approximate source/token/type costs and do not claim to include every runtime object or CLR metadata generation. + +Interpret individual regressions alongside the combined sequence. The old warm diagnostics cache returned without disk/config validation; the new path must read the observed inputs before publication. Cold definition also pays capture, immutable projection, input validation and bounded-cache admission that later features reuse. The acceptance target is shared trustworthy results and zero warm whole-project checks, not zero-cost validation. + +The [2026-10-07 compact report](results-2026-10-07.json) records matching query fingerprints, zero warm checks, and reduced combined cold/warm latency for both fixtures. Individual cold definition latency and allocation increase: 4.60→6.73 ms / 182→237 KB for the small fixture and 33.17→44.87 ms / 10.64→13.03 MB for the multi-project fixture. This includes the new completed snapshot capture and immutable projections plus input validation; it is not a measurement of freezing alone. Direct document validation costs 0.55 ms and 2.44 ms respectively in that run. Workspace validation costs 0.70 ms and 6.17 ms. The old warm diagnostics result was nearly free because it reused diagnostics without validating closed inputs; the current result deliberately pays this validation cost. Combined warm allocations fall from 534→117 KB and 40.53→3.32 MB, while retained cache data grows from 2.4→19.1 KB and 81.9 KB→3.42 MB to keep the reusable semantic graphs. Cache invalidation releases the measured data once requests and leases have finished. diff --git a/benchmarks/editor-analysis/results-2026-10-07.json b/benchmarks/editor-analysis/results-2026-10-07.json new file mode 100644 index 000000000..3f0292eb0 --- /dev/null +++ b/benchmarks/editor-analysis/results-2026-10-07.json @@ -0,0 +1,274 @@ +{ + "issue": 1975, + "baselineCommit": "df4589b7", + "comparison": "Equivalent existing service queries with exact matching result fingerprints", + "featureOrder": [ + "definition", + "references", + "statements+full diagnostics" + ], + "baseline": { + "implementation": "df4589b7", + "timestampUtc": "2026-10-07T05:15:11.0123089Z", + "runtime": ".NET 10.0.12", + "os": "Microsoft Windows 10.0.26220", + "architecture": "Arm64", + "processorCount": 12, + "coldSamples": 11, + "warmSamples": 31, + "methodology": "Same definition, references(includeDeclaration=true, workspace roots), statements, full diagnostics sequence. Fresh service cache for each cold sample; repeated unchanged capture for warm samples. Process/JIT/filesystem caches warmed. Deterministic noLib/types=[] fixtures isolate source graph. Services only, no transport or CLR metadata provider. Allocation measured process-wide across async workers. Baseline CheckModules entry counter is the only archived production source change; current uses Statistics.Checks. Retained heap is noisy process-wide full-GC live heap, cache bytes are implementation estimates.", + "reports": [ + { + "name": "small", + "SourceFiles": 1, + "Projects": 1, + "sourceBytes": 60, + "cold": { + "medianMilliseconds": 18.0292, + "p90Milliseconds": 19.6395, + "medianAllocatedBytes": 724768, + "checksPerSequence": [ + 4 + ], + "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB", + "featureMedianMilliseconds": [ + 4.6038, + 8.9513, + 4.5929 + ], + "featureMedianAllocatedBytes": [ + 181992, + 357224, + 181696 + ] + }, + "warm": { + "medianMilliseconds": 13.6526, + "p90Milliseconds": 14.3327, + "medianAllocatedBytes": 534448, + "checksPerSequence": [ + 3 + ], + "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB", + "featureMedianMilliseconds": [ + 4.6065, + 9.042, + 0.0037 + ], + "featureMedianAllocatedBytes": [ + 172544, + 357256, + 792 + ] + }, + "retention": { + "LiveHeapWithCacheBytes": 494880, + "LiveHeapAfterCacheClearBytes": 492464, + "CacheRetainedHeapBytes": 2416, + "EstimatedCachedBytes": null, + "CachedSnapshots": null + }, + "validation": null + }, + { + "name": "multi-project", + "SourceFiles": 25, + "Projects": 4, + "sourceBytes": 57003, + "cold": { + "medianMilliseconds": 188.7964, + "p90Milliseconds": 190.9428, + "medianAllocatedBytes": 55990232, + "checksPerSequence": [ + 6 + ], + "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0", + "featureMedianMilliseconds": [ + 33.1713, + 120.4202, + 34.7538 + ], + "featureMedianAllocatedBytes": [ + 10636496, + 34289728, + 10959048 + ] + }, + "warm": { + "medianMilliseconds": 117.6894, + "p90Milliseconds": 133.2203, + "medianAllocatedBytes": 40532368, + "checksPerSequence": [ + 5 + ], + "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0", + "featureMedianMilliseconds": [ + 23.5061, + 89.0481, + 0.0133 + ], + "featureMedianAllocatedBytes": [ + 9652360, + 30768656, + 6392 + ] + }, + "retention": { + "LiveHeapWithCacheBytes": 892048, + "LiveHeapAfterCacheClearBytes": 810104, + "CacheRetainedHeapBytes": 81944, + "EstimatedCachedBytes": null, + "CachedSnapshots": null + }, + "validation": null + } + ] + }, + "current": { + "implementation": "shared-snapshots-current", + "timestampUtc": "2026-10-07T05:15:15.6793508Z", + "runtime": ".NET 10.0.12", + "os": "Microsoft Windows 10.0.26220", + "architecture": "Arm64", + "processorCount": 12, + "coldSamples": 11, + "warmSamples": 31, + "methodology": "Same definition, references(includeDeclaration=true, workspace roots), statements, full diagnostics sequence. Fresh service cache for each cold sample; repeated unchanged capture for warm samples. Process/JIT/filesystem caches warmed. Deterministic noLib/types=[] fixtures isolate source graph. Services only, no transport or CLR metadata provider. Allocation measured process-wide across async workers. Baseline CheckModules entry counter is the only archived production source change; current uses Statistics.Checks. Retained heap is noisy process-wide full-GC live heap, cache bytes are implementation estimates.", + "reports": [ + { + "name": "small", + "SourceFiles": 1, + "Projects": 1, + "sourceBytes": 60, + "cold": { + "medianMilliseconds": 16.6515, + "p90Milliseconds": 17.2711, + "medianAllocatedBytes": 471216, + "checksPerSequence": [ + 1 + ], + "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB", + "featureMedianMilliseconds": [ + 6.7273, + 8.1286, + 1.6929 + ], + "featureMedianAllocatedBytes": [ + 237496, + 196680, + 33256 + ] + }, + "warm": { + "medianMilliseconds": 5.3849, + "p90Milliseconds": 5.576, + "medianAllocatedBytes": 117080, + "checksPerSequence": [ + 0 + ], + "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB", + "featureMedianMilliseconds": [ + 1.1133, + 2.5489, + 1.6735 + ], + "featureMedianAllocatedBytes": [ + 21968, + 58000, + 33256 + ] + }, + "retention": { + "LiveHeapWithCacheBytes": 552024, + "LiveHeapAfterCacheClearBytes": 532944, + "CacheRetainedHeapBytes": 19080, + "EstimatedCachedBytes": 4788, + "CachedSnapshots": 2 + }, + "validation": { + "document": { + "inputs": 14, + "observedInputEstimatedBytes": 546, + "medianMilliseconds": 0.548, + "p90Milliseconds": 0.6994, + "medianAllocatedBytes": 8728 + }, + "workspace": { + "inputs": 17, + "observedInputEstimatedBytes": 802, + "medianMilliseconds": 0.701, + "p90Milliseconds": 0.8896, + "medianAllocatedBytes": 10152 + } + } + }, + { + "name": "multi-project", + "SourceFiles": 25, + "Projects": 4, + "sourceBytes": 57003, + "cold": { + "medianMilliseconds": 136.1945, + "p90Milliseconds": 138.0312, + "medianAllocatedBytes": 31683360, + "checksPerSequence": [ + 3 + ], + "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0", + "featureMedianMilliseconds": [ + 44.8743, + 83.8138, + 6.5008 + ], + "featureMedianAllocatedBytes": [ + 13033408, + 17762904, + 775576 + ] + }, + "warm": { + "medianMilliseconds": 21.2132, + "p90Milliseconds": 23.2735, + "medianAllocatedBytes": 3320816, + "checksPerSequence": [ + 0 + ], + "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0", + "featureMedianMilliseconds": [ + 3.1244, + 12.9861, + 4.8504 + ], + "featureMedianAllocatedBytes": [ + 511664, + 1928840, + 775352 + ] + }, + "retention": { + "LiveHeapWithCacheBytes": 7846784, + "LiveHeapAfterCacheClearBytes": 4430816, + "CacheRetainedHeapBytes": 3415968, + "EstimatedCachedBytes": 2726372, + "CachedSnapshots": 2 + }, + "validation": { + "document": { + "inputs": 35, + "observedInputEstimatedBytes": 56358, + "medianMilliseconds": 2.4402, + "p90Milliseconds": 2.8914, + "medianAllocatedBytes": 252304 + }, + "workspace": { + "inputs": 93, + "observedInputEstimatedBytes": 121054, + "medianMilliseconds": 6.172, + "p90Milliseconds": 6.6782, + "medianAllocatedBytes": 549440 + } + } + } + ] + } +} diff --git a/benchmarks/editor-analysis/run.ps1 b/benchmarks/editor-analysis/run.ps1 new file mode 100644 index 000000000..84922c737 --- /dev/null +++ b/benchmarks/editor-analysis/run.ps1 @@ -0,0 +1,67 @@ +param( + [string]$BaselineCommit = 'df4589b7', + [string]$PackageCache = '', + [string]$OutputDirectory = '' +) +$ErrorActionPreference = 'Stop' +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) +if (!$OutputDirectory) { $OutputDirectory = Join-Path $repoRoot 'artifacts/analysis-benchmark' } +$outputRoot = [IO.Path]::GetFullPath($OutputDirectory) +New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null +$baselineRoot = Join-Path $outputRoot ('baseline-' + $BaselineCommit) +$baselineArchive = Join-Path $outputRoot ('baseline-' + $BaselineCommit + '.zip') +if (!(Test-Path -LiteralPath $baselineRoot)) { + & git -C $repoRoot archive --format=zip "--output=$baselineArchive" $BaselineCommit + if ($LASTEXITCODE) { throw 'git archive failed.' } + Expand-Archive -LiteralPath $baselineArchive -DestinationPath $baselineRoot +} + +# The archived baseline's only semantic-source change is a counter at CheckModules entry. +$checkerPath = Join-Path $baselineRoot 'src/SharpTS/TypeSystem/TypeChecker.cs' +$checker = [IO.File]::ReadAllText($checkerPath) +if (!$checker.Contains('internal static long BenchmarkChecks;')) { + $signature = 'public TypeMap CheckModules(List modules, ModuleResolver resolver)' + if (($checker.Split($signature).Length - 1) -ne 1) { throw 'Expected exactly one baseline CheckModules.' } + $checker = $checker.Replace($signature, 'internal static long BenchmarkChecks;' + [Environment]::NewLine + ' ' + $signature) + $checker = [regex]::Replace($checker, + '(public TypeMap CheckModules\(List modules, ModuleResolver resolver\)\s*\{)', + '$1' + [Environment]::NewLine + ' Interlocked.Increment(ref BenchmarkChecks);') + [IO.File]::WriteAllText($checkerPath, $checker) +} + +function Invoke-CheckedDotNet([string[]]$DotNetArguments) { + & dotnet @DotNetArguments + if ($LASTEXITCODE) { throw "dotnet failed: $($DotNetArguments[0])" } +} + +$restoreOptions = @('-p:NuGetAudit=false', '-p:MinVerVersionOverride=0.0.0-benchmark') +if ($PackageCache) { + # Optional offline replay against an already populated cache; never downloads into it. + $emptyFeed = Join-Path $outputRoot 'empty-feed' + New-Item -ItemType Directory -Path $emptyFeed -Force | Out-Null + $restoreOptions += @("-p:RestorePackagesPath=$([IO.Path]::GetFullPath($PackageCache))", '--source', $emptyFeed) +} +Invoke-CheckedDotNet (@('build', (Join-Path $baselineRoot 'src/SharpTS.LanguageServer/SharpTS.LanguageServer.csproj'), '-c', 'Release', '-v', 'minimal') + $restoreOptions) +# Build current separately so both measurements use exactly the requested source state. +Invoke-CheckedDotNet (@('build', (Join-Path $repoRoot 'src/SharpTS.LanguageServer/SharpTS.LanguageServer.csproj'), '-c', 'Release', '-v', 'minimal') + $restoreOptions) + +$runnerProject = Join-Path $PSScriptRoot 'EditorAnalysis.csproj' +foreach ($implementation in @('baseline', 'current')) { + $libraryRoot = if ($implementation -eq 'baseline') { $baselineRoot } else { $repoRoot } + $libraryDirectory = Join-Path $libraryRoot 'src/SharpTS.LanguageServer/bin/Release/net10.0' + $runnerOptions = @('build', $runnerProject, '-c', 'Release', '-v', 'minimal', + "-p:LibraryDirectory=$libraryDirectory", "-p:BaseOutputPath=$outputRoot/bin/$implementation/", + "-p:BaseIntermediateOutputPath=$outputRoot/obj/$implementation/") + if ($implementation -eq 'baseline') { $runnerOptions += '-p:Baseline=true' } + Invoke-CheckedDotNet ($runnerOptions + $restoreOptions) + Invoke-CheckedDotNet @((Join-Path $outputRoot "bin/$implementation/Release/net10.0/SharpTS.Tests.dll"), $outputRoot, "$implementation.json", $BaselineCommit) +} + +$before = Get-Content -LiteralPath (Join-Path $outputRoot 'baseline.json') -Raw | ConvertFrom-Json +$after = Get-Content -LiteralPath (Join-Path $outputRoot 'current.json') -Raw | ConvertFrom-Json +for ($index = 0; $index -lt $before.reports.Count; $index++) { + if ($before.reports[$index].cold.resultFingerprint -ne $after.reports[$index].cold.resultFingerprint) { + throw "Baseline/current result mismatch: $($before.reports[$index].name)" + } +} +Write-Output "Matching query results. Reports: $outputRoot/baseline.json and current.json" diff --git a/docs/language-server.md b/docs/language-server.md index a5633cbe2..0ac81d0da 100644 --- a/docs/language-server.md +++ b/docs/language-server.md @@ -115,6 +115,58 @@ sharpts-lsp --sdk-path /path/to/reference/assemblies A `sharpts.json` reference manifest found from the workspace root is also honored. Referenced assembly outputs are reloaded safely when they change. +Editor requests capture one CLR metadata generation and inspect assembly bytes without locking +the original DLLs. Project, manifest, and assembly changes are revalidated before a result is +returned. Package acquisition happens at startup; requests use already restored package assets. +If a manifest adds a package that has not been restored, restore it and restart the server. +An explicit `--sdk-path` is revalidated, while the automatically selected installed SDK/runtime +is fixed for the server lifetime; restart after replacing that installation. + +Interop hover, completion, and diagnostics honor these custom references. General checker +`dotnet:` synthesis still uses the existing runtime registry and may not resolve a custom +reference's ordinary TypeScript symbols. The server does not guess navigation or rename results +for unresolved symbols. + +## Shared analysis + +Definition, references, lexical rename, and full diagnostics share completed analyses for the +same captured open buffers and project state. File notifications invalidate analyses promptly; +clients without watching support still get physical dependency/configuration validation on every +reuse. Creating a missing import, changing a closed file, or changing project membership causes +a fresh check. Results that become stale during a request are discarded. + +The cache keeps at most eight completed entries and evicts the least recently used entries when +their estimated source/semantic payload exceeds 64 MiB. This estimate excludes CLR assembly +images and is not a limit on total process memory. Oversized analyses can serve active requests +without entering the cache. Concurrent identical requests share work, and cancelling one request +does not cancel another request's analysis. Interop-only with `sharpts-only` diagnostics keeps +general analysis lazy; explicitly selecting `all` still requests full parser/checker diagnostics. + +For a local protocol smoke test after a Release build, run: + +```bash +node scripts/test-analysis-snapshots.mjs +``` + +The test exercises real stdio navigation, watched closed-file changes, dirty overlays, reverse +importer creation, rename, and interop-only capability isolation. + +The [editor analysis benchmark](../benchmarks/editor-analysis/README.md) records a comparison with +`df4589b7` on Windows Arm64/.NET 10.0.12 (2026-10-07 UTC). The sequence contains definition, +workspace references, statement retrieval, and full diagnostics; JIT/filesystem caches are warm. + +| Fixture | Cache-cold median, before → shared | Warm median, before → shared | Checks, cold / warm before → shared | +| --- | --- | --- | --- | +| One source file | 18.03 → 16.65 ms | 13.65 → 5.38 ms | 4 / 3 → 1 / 0 | +| 25 source files, four projects | 188.80 → 136.19 ms | 117.69 → 21.21 ms | 6 / 5 → 3 / 0 | + +Cold definitions add about 2.12 ms and 11.70 ms respectively for capture, immutable publication, +and validation. Warm diagnostics also pay validation costs instead of trusting a stale cache. +Direct document validation medians were 0.55 ms and 2.44 ms; workspace validation was 0.70 ms +and 6.17 ms. Measured cache retention was about +19 KiB and 3.42 MB respectively; these fixtures exclude CLR metadata and do not predict large +project memory. The benchmark report includes allocations, percentiles, and measurement limits. + ## Rename safety Full mode produces a rename edit only when the server has loaded every configured project root and diff --git a/scripts/lib/lsp-stdio.mjs b/scripts/lib/lsp-stdio.mjs index bf96d29bc..f5ba440fd 100644 --- a/scripts/lib/lsp-stdio.mjs +++ b/scripts/lib/lsp-stdio.mjs @@ -3,6 +3,7 @@ import { spawn } from "node:child_process"; /** Small bounded stdio client for repository smoke tests, with no runtime dependencies. */ export class LspStdioClient { registeredMethods = []; + notifications = []; #process; #pending = new Map(); #nextId = 1; @@ -84,6 +85,9 @@ export class LspStdioClient { this.#pending.delete(message.id); clearTimeout(pending.timer); pending.resolve(message); + } else if (message.method) { + this.notifications.push(message); + if (this.notifications.length > 1024) this.notifications.shift(); } } } diff --git a/scripts/test-analysis-snapshots.mjs b/scripts/test-analysis-snapshots.mjs new file mode 100644 index 000000000..b1cfab9d5 --- /dev/null +++ b/scripts/test-analysis-snapshots.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, writeFile, stat, utimes } from "node:fs/promises"; +import { dirname, resolve, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; +import { LspStdioClient } from "./lib/lsp-stdio.mjs"; + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const server = resolve(repository, process.argv[2] ?? "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll"); +await mkdir(join(repository, "artifacts/analysis-smoke"), { recursive: true }); +const workspace = await mkdtemp(join(repository, "artifacts/analysis-smoke/run-")); +const source = "import { value } from './dep';\nconst result: number = value;\n"; +const original = "export const value: number = 123;\n"; +const changed = "export const value: string = 'x';\n"; +assert.equal(original.length, changed.length); +await writeFile(join(workspace, "tsconfig.json"), JSON.stringify({ + compilerOptions: { noLib: true, types: [] }, include: ["*.ts"], +})); +await writeFile(join(workspace, "main.ts"), source); +await writeFile(join(workspace, "dep.ts"), original); +const uri = (name) => pathToFileURL(join(workspace, name)).href; +const sameUri = (left, right) => process.platform === "win32" + ? left.toLowerCase() === right.toLowerCase() : left === right; +const asList = (value) => Array.isArray(value) ? value : value ? [value] : []; +const requestPosition = { textDocument: { uri: uri("main.ts") }, position: { line: 1, character: 23 } }; +const report = { workspace, server, full: {}, interopOnly: {} }; + +async function request(client, method, params) { + const response = await client.request(method, params); + assert.equal(response.error, undefined, `${method}: ${JSON.stringify(response.error)}`); + return response.result; +} +async function initialize(client) { + const result = await request(client, "initialize", { + processId: process.pid, rootUri: pathToFileURL(workspace).href, + workspaceFolders: [{ uri: pathToFileURL(workspace).href, name: "snapshot-smoke" }], + capabilities: { + workspace: { didChangeWatchedFiles: { dynamicRegistration: true }, workspaceFolders: true }, + textDocument: { definition: {}, references: {}, rename: { prepareSupport: true }, + hover: { contentFormat: ["markdown"] }, documentSymbol: {}, publishDiagnostics: { versionSupport: true } }, + }, + }); + client.notify("initialized", {}); + for (const key of ["documentFormattingProvider", "documentRangeFormattingProvider", "documentOnTypeFormattingProvider"]) + assert.ok(!result.capabilities[key], key); + return result.capabilities; +} +async function diagnosticsAfter(client, start, predicate) { + const end = performance.now() + 20_000; + while (performance.now() < end) { + const match = client.notifications.slice(start).find((item) => item.method === "textDocument/publishDiagnostics" && + sameUri(item.params.uri, uri("main.ts")) && item.params.version === 1 && predicate(item.params.diagnostics)); + if (match) return match.params.diagnostics; + await delay(25); + } + throw new Error(`Expected diagnostics were not published: ${JSON.stringify(client.notifications.slice(start))}`); +} + +const full = new LspStdioClient("dotnet", [server, "--language-features", "full", "--diagnostics", "all"], { cwd: workspace }); +try { + const caps = await initialize(full); + assert.ok(caps.definitionProvider && caps.referencesProvider && caps.renameProvider); + full.notify("textDocument/didOpen", { textDocument: { uri: uri("main.ts"), languageId: "typescript", version: 1, text: source } }); + await diagnosticsAfter(full, 0, (items) => items.length === 0); + const definitions = (await Promise.all(Array.from({ length: 4 }, () => request(full, "textDocument/definition", requestPosition)))).map(asList); + assert.equal(definitions[0].length, 1); + assert.ok(sameUri(definitions[0][0].uri, uri("dep.ts"))); + definitions.forEach((result) => assert.deepEqual(result, definitions[0])); + const references = await request(full, "textDocument/references", { ...requestPosition, context: { includeDeclaration: true } }); + assert.equal(references.length, 3); + assert.ok(await request(full, "textDocument/prepareRename", requestPosition)); + const rename = await request(full, "textDocument/rename", { ...requestPosition, newName: "nextValue" }); + assert.equal(Object.values(rename.changes).flat().length, 3); + + const stamp = await stat(join(workspace, "dep.ts")); + await writeFile(join(workspace, "dep.ts"), changed); + await utimes(join(workspace, "dep.ts"), stamp.atime, stamp.mtime); + const changedStart = full.notifications.length; + full.notify("workspace/didChangeWatchedFiles", { changes: [{ uri: uri("dep.ts"), type: 2 }] }); + const errors = await diagnosticsAfter(full, changedStart, (items) => items.some((item) => /not assignable/i.test(item.message))); + const dirtyStart = full.notifications.length; + full.notify("textDocument/didOpen", { textDocument: { uri: uri("dep.ts"), languageId: "typescript", version: 1, text: original } }); + await diagnosticsAfter(full, dirtyStart, (items) => items.length === 0); + const closeStart = full.notifications.length; + full.notify("textDocument/didClose", { textDocument: { uri: uri("dep.ts") } }); + await diagnosticsAfter(full, closeStart, (items) => items.some((item) => /not assignable/i.test(item.message))); + + await writeFile(join(workspace, "consumer.ts"), "import { value } from './dep'; value;\n"); + full.notify("workspace/didChangeWatchedFiles", { changes: [{ uri: uri("consumer.ts"), type: 1 }] }); + const expanded = await request(full, "textDocument/references", { ...requestPosition, context: { includeDeclaration: true } }); + assert.equal(expanded.length, 5); + assert.ok(full.registeredMethods.includes("workspace/didChangeWatchedFiles")); + assert.ok(!full.registeredMethods.some((method) => /formatting/i.test(method))); + report.full = { concurrentDefinitions: 4, references: references.length, expandedReferences: expanded.length, + renameEdits: 3, closedDiskErrors: errors.length, dirtyOverlayAndClose: "passed", watching: "registered" }; +} finally { await full.close(); } + +const interop = new LspStdioClient("dotnet", [server, "--language-features", "interop-only"], { cwd: workspace }); +try { + const caps = await initialize(interop); + for (const key of ["definitionProvider", "referencesProvider", "renameProvider", "documentSymbolProvider"]) + assert.ok(!caps[key], key); + const text = '@DotNetType("System.String")\ndeclare class NetString {}\n'; + interop.notify("textDocument/didOpen", { textDocument: { uri: uri("interop.ts"), languageId: "typescript", version: 1, text } }); + const hover = await request(interop, "textDocument/hover", { textDocument: { uri: uri("interop.ts") }, position: { line: 0, character: 4 } }); + assert.match(JSON.stringify(hover), /System\.String/); + const refused = await interop.request("textDocument/definition", { textDocument: { uri: uri("interop.ts") }, position: { line: 1, character: 15 } }); + assert.equal(refused.error?.code, -32601); + assert.ok(!interop.registeredMethods.some((method) => /formatting/i.test(method))); + report.interopOnly = { generalCapabilitiesAbsent: true, clrHover: "passed", definitionError: -32601 }; +} finally { await interop.close(); } +await writeFile(join(workspace, "result.json"), JSON.stringify(report, null, 2) + "\n"); +console.log(JSON.stringify(report, null, 2)); diff --git a/src/SharpTS.LanguageServer/Documentation/XmlDocLoader.cs b/src/SharpTS.LanguageServer/Documentation/XmlDocLoader.cs index b48615041..295f7888e 100644 --- a/src/SharpTS.LanguageServer/Documentation/XmlDocLoader.cs +++ b/src/SharpTS.LanguageServer/Documentation/XmlDocLoader.cs @@ -1,5 +1,7 @@ using System.Reflection; using System.Xml.Linq; +using SharpTS.IO; +using SharpTS.Compilation; namespace SharpTS.LanguageServer.Documentation; @@ -8,7 +10,9 @@ namespace SharpTS.LanguageServer.Documentation; /// public sealed class XmlDocLoader { - private readonly Dictionary _loadedDocs = new(); + private const int CacheLimit = 64; + private readonly object _gate = new(); + private readonly Dictionary _loadedDocs = new(StringComparer.OrdinalIgnoreCase); /// /// Gets the summary documentation for a type. @@ -92,33 +96,43 @@ public sealed class XmlDocLoader private XDocument? GetDocument(Assembly assembly) { - var location = assembly.Location; + var location = AssemblyReferenceLoader.GetSourcePath(assembly); if (string.IsNullOrEmpty(location)) return null; - if (_loadedDocs.TryGetValue(location, out var cached)) - return cached; - var xmlPath = Path.ChangeExtension(location, ".xml"); - if (!File.Exists(xmlPath)) - { - _loadedDocs[location] = null; - return null; - } - + string? text; try { - var doc = XDocument.Load(xmlPath); - _loadedDocs[location] = doc; - return doc; + text = CompilerFileSystem.FileExists(xmlPath) ? CompilerFileSystem.ReadAllText(xmlPath) : null; } - catch + catch (OperationCanceledException) { throw; } + catch (IOException) { return null; } + catch (UnauthorizedAccessException) { return null; } + + lock (_gate) { - _loadedDocs[location] = null; - return null; + if (_loadedDocs.TryGetValue(location, out CachedDocument? cached) && + string.Equals(cached.Text, text, StringComparison.Ordinal)) + { + return cached.Document; + } + + XDocument? document = null; + if (text is not null) + { + try { document = XDocument.Parse(text); } + catch (System.Xml.XmlException) { } + } + if (!_loadedDocs.ContainsKey(location) && _loadedDocs.Count >= CacheLimit) + _loadedDocs.Remove(_loadedDocs.Keys.First()); + _loadedDocs[location] = new CachedDocument(text, document); + return document; } } + private sealed record CachedDocument(string? Text, XDocument? Document); + private static string CleanupDocumentation(string text) { // Remove leading/trailing whitespace and normalize internal whitespace diff --git a/src/SharpTS.LanguageServer/Handlers/AnalysisWorkspaceHandlers.cs b/src/SharpTS.LanguageServer/Handlers/AnalysisWorkspaceHandlers.cs new file mode 100644 index 000000000..1301e0ddb --- /dev/null +++ b/src/SharpTS.LanguageServer/Handlers/AnalysisWorkspaceHandlers.cs @@ -0,0 +1,47 @@ +using MediatR; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Workspace; +using SharpTS.LanguageServer.Services; +using FileSystemWatcher = OmniSharp.Extensions.LanguageServer.Protocol.Models.FileSystemWatcher; + +namespace SharpTS.LanguageServer.Handlers; + +/// Uses client file notifications for prompt release; physical validation is the fallback. +public sealed class AnalysisWatchedFilesHandler( + SemanticAnalysisService analysis, DiagnosticsCoordinator diagnostics) : DidChangeWatchedFilesHandlerBase +{ + public override Task Handle(DidChangeWatchedFilesParams request, CancellationToken cancellationToken) + { + analysis.InvalidateAll(); + diagnostics.RepublishAll(); + return Unit.Task; + } + + protected override DidChangeWatchedFilesRegistrationOptions CreateRegistrationOptions( + DidChangeWatchedFilesCapability capability, ClientCapabilities clientCapabilities) => new() + { + Watchers = new Container(new FileSystemWatcher + { + GlobPattern = new GlobPattern("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json,csproj,dll,xml}"), + Kind = WatchKind.Create | WatchKind.Change | WatchKind.Delete, + }), + }; +} + +public sealed class AnalysisWorkspaceFoldersHandler( + NavigationWorkspaceContext workspace, SemanticAnalysisService analysis, + DiagnosticsCoordinator diagnostics) : DidChangeWorkspaceFoldersHandlerBase +{ + public override Task Handle(DidChangeWorkspaceFoldersParams request, CancellationToken cancellationToken) + { + workspace.Change(request.Event.Added.Select(folder => folder.Uri.GetFileSystemPath()), + request.Event.Removed.Select(folder => folder.Uri.GetFileSystemPath())); + analysis.InvalidateAll(); + diagnostics.RepublishAll(); + return Unit.Task; + } + + protected override DidChangeWorkspaceFolderRegistrationOptions CreateRegistrationOptions( + ClientCapabilities clientCapabilities) => new() { Supported = true, ChangeNotifications = true }; +} diff --git a/src/SharpTS.LanguageServer/Handlers/CompletionHandler.cs b/src/SharpTS.LanguageServer/Handlers/CompletionHandler.cs index bc49c4a1b..3db49f568 100644 --- a/src/SharpTS.LanguageServer/Handlers/CompletionHandler.cs +++ b/src/SharpTS.LanguageServer/Handlers/CompletionHandler.cs @@ -3,6 +3,7 @@ using OmniSharp.Extensions.LanguageServer.Protocol.Models; using OmniSharp.Extensions.LanguageServer.Protocol.Server.Capabilities; using SharpTS.LanguageServer.Services; +using SharpTS.LanguageServer.Project; namespace SharpTS.LanguageServer.Handlers; @@ -12,27 +13,32 @@ public sealed class CompletionHandler : CompletionHandlerBase private readonly DocumentStore _store; private readonly DecoratorService _decorators; private readonly GuiContractService _gui; + private readonly AnalysisMetadataProvider? _metadata; - public CompletionHandler(DocumentStore store, DecoratorService decorators, GuiContractService? gui = null) + public CompletionHandler(DocumentStore store, DecoratorService decorators, GuiContractService? gui = null, + AnalysisMetadataProvider? metadata = null) { _store = store; _decorators = decorators; _gui = gui ?? new GuiContractService(); + _metadata = metadata; } public override Task Handle(CompletionParams request, CancellationToken ct) { - if (!_store.TryGetSnapshot( + if (!_store.TryCapture( request.TextDocument.Uri.ToString(), - out DocumentSnapshot? snapshot)) + out DocumentRequestSnapshot? capture)) return Task.FromResult(new CompletionList()); + using var guard = new EditorRequestGuard(_store, capture, _metadata, ct); + DocumentSnapshot snapshot = capture.Document; var list = _gui.Completion(snapshot.FilePath, snapshot.Text, request.Position.Line, request.Position.Character) ?? _decorators.Completion( snapshot.Text, request.Position.Line, request.Position.Character); - return Task.FromResult(list ?? new CompletionList()); + return Task.FromResult(guard.IsCurrent(ct) ? list ?? new CompletionList() : new CompletionList()); } // No resolve step needed — items are fully populated up front. diff --git a/src/SharpTS.LanguageServer/Handlers/DefinitionHandler.cs b/src/SharpTS.LanguageServer/Handlers/DefinitionHandler.cs index b5ec6d20e..a28556aab 100644 --- a/src/SharpTS.LanguageServer/Handlers/DefinitionHandler.cs +++ b/src/SharpTS.LanguageServer/Handlers/DefinitionHandler.cs @@ -3,6 +3,7 @@ using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; using SharpTS.LanguageServer.Services; +using SharpTS.LanguageServer.Project; namespace SharpTS.LanguageServer.Handlers; @@ -18,38 +19,42 @@ public sealed class DefinitionHandler : DefinitionHandlerBase private readonly DocumentStore _store; private readonly DefinitionService _definitions; private readonly GuiContractService _gui; + private readonly AnalysisMetadataProvider? _metadata; - public DefinitionHandler(DocumentStore store, DefinitionService definitions, GuiContractService? gui = null) + public DefinitionHandler(DocumentStore store, DefinitionService definitions, GuiContractService? gui = null, + AnalysisMetadataProvider? metadata = null) { _store = store; _definitions = definitions; _gui = gui ?? new GuiContractService(); + _metadata = metadata; } - public override Task Handle( + public override async Task Handle( DefinitionParams request, CancellationToken ct) { string uri = request.TextDocument.Uri.ToString(); if (!_store.TryCapture(uri, out DocumentRequestSnapshot? snapshot)) - return Task.FromResult(null); + return null; ct.ThrowIfCancellationRequested(); + using var guard = new EditorRequestGuard(_store, snapshot, _metadata, ct); Location? guiLocation = _gui.Definition(snapshot.Document.FilePath, snapshot.Document.Text, request.Position.Line, request.Position.Character); if (guiLocation is not null) - return Task.FromResult( - new LocationOrLocationLinks(new[] { new LocationOrLocationLink(guiLocation) })); - - var locations = _definitions.FindDefinitions( - request.TextDocument.Uri.GetFileSystemPath(), - snapshot.Document.Text, - request.Position, - snapshot.TextOverlay) - .Select(location => new LocationOrLocationLink(location)); - return Task.FromResult( - new LocationOrLocationLinks(locations)); + { + ct.ThrowIfCancellationRequested(); + return guard.IsCurrent(ct) + ? new LocationOrLocationLinks(new[] { new LocationOrLocationLink(guiLocation) }) : null; + } + + var result = await _definitions.FindDefinitionsAsync(snapshot, request.Position, ct); + ct.ThrowIfCancellationRequested(); + if (!guard.IsCurrent(ct) || !result.IsCurrent(ct)) + return null; + return new LocationOrLocationLinks(result.Locations.Select(location => new LocationOrLocationLink(location))); } protected override DefinitionRegistrationOptions CreateRegistrationOptions( diff --git a/src/SharpTS.LanguageServer/Handlers/HoverHandler.cs b/src/SharpTS.LanguageServer/Handlers/HoverHandler.cs index 6360fa351..88a9fc0bb 100644 --- a/src/SharpTS.LanguageServer/Handlers/HoverHandler.cs +++ b/src/SharpTS.LanguageServer/Handlers/HoverHandler.cs @@ -3,6 +3,7 @@ using OmniSharp.Extensions.LanguageServer.Protocol.Models; using OmniSharp.Extensions.LanguageServer.Protocol.Server.Capabilities; using SharpTS.LanguageServer.Services; +using SharpTS.LanguageServer.Project; namespace SharpTS.LanguageServer.Handlers; @@ -13,28 +14,34 @@ public sealed class HoverHandler : HoverHandlerBase private readonly DecoratorService _decorators; private readonly MemberHoverService _members; private readonly GuiContractService _gui; + private readonly AnalysisMetadataProvider? _metadata; - public HoverHandler(DocumentStore store, DecoratorService decorators, MemberHoverService members, GuiContractService? gui = null) + public HoverHandler(DocumentStore store, DecoratorService decorators, MemberHoverService members, + GuiContractService? gui = null, AnalysisMetadataProvider? metadata = null) { _store = store; _decorators = decorators; _members = members; _gui = gui ?? new GuiContractService(); + _metadata = metadata; } public override Task Handle(HoverParams request, CancellationToken ct) { - if (!_store.TryGetSnapshot( + if (!_store.TryCapture( request.TextDocument.Uri.ToString(), - out DocumentSnapshot? snapshot)) + out DocumentRequestSnapshot? capture)) return Task.FromResult(null); + using var guard = new EditorRequestGuard(_store, capture, _metadata, ct); + DocumentSnapshot snapshot = capture.Document; int line = request.Position.Line, ch = request.Position.Character; // Decorator hover first (cursor on @DotNetType / a builtin); then .NET member hover. - return Task.FromResult( + Hover? result = _gui.Hover(snapshot.FilePath, snapshot.Text, line, ch) ?? _decorators.Hover(snapshot.Text, line, ch) ?? - _members.Hover(snapshot.Text, line, ch)); + _members.Hover(snapshot.Text, line, ch); + return Task.FromResult(guard.IsCurrent(ct) ? result : null); } protected override HoverRegistrationOptions CreateRegistrationOptions( diff --git a/src/SharpTS.LanguageServer/Handlers/ReferencesHandler.cs b/src/SharpTS.LanguageServer/Handlers/ReferencesHandler.cs index 571677153..e54614289 100644 --- a/src/SharpTS.LanguageServer/Handlers/ReferencesHandler.cs +++ b/src/SharpTS.LanguageServer/Handlers/ReferencesHandler.cs @@ -29,25 +29,26 @@ public ReferencesHandler( _workspace = workspace; } - public override Task Handle( + public override async Task Handle( ReferenceParams request, CancellationToken ct) { string uri = request.TextDocument.Uri.ToString(); if (!_store.TryCapture(uri, out DocumentRequestSnapshot? snapshot)) - return Task.FromResult(null); + return null; ct.ThrowIfCancellationRequested(); - var locations = _references.FindReferences( - request.TextDocument.Uri.GetFileSystemPath(), - snapshot.Document.Text, + long? workspaceVersion = _workspace?.Version; + var result = await _references.FindReferenceResultAsync( + snapshot, request.Position, request.Context.IncludeDeclaration, - snapshot.TextOverlay, - _workspace?.SnapshotRoots()); - return Task.FromResult( - new LocationContainer(locations)); + _workspace?.SnapshotRoots(), cancellationToken: ct); + ct.ThrowIfCancellationRequested(); + if (!_store.IsCurrent(uri, snapshot.Document.Version, snapshot.WorkspaceVersion) || + _workspace?.Version != workspaceVersion || !result.IsCurrent(ct)) return null; + return new LocationContainer(result.Locations); } protected override ReferenceRegistrationOptions CreateRegistrationOptions( diff --git a/src/SharpTS.LanguageServer/Handlers/RenameHandler.cs b/src/SharpTS.LanguageServer/Handlers/RenameHandler.cs index 22a5c302d..ab898bef1 100644 --- a/src/SharpTS.LanguageServer/Handlers/RenameHandler.cs +++ b/src/SharpTS.LanguageServer/Handlers/RenameHandler.cs @@ -24,23 +24,21 @@ public RenameHandler( _workspace = workspace; } - public override Task Handle( + public override async Task Handle( RenameParams request, CancellationToken ct) { string uri = request.TextDocument.Uri.ToString(); if (!_store.TryCapture(uri, out DocumentRequestSnapshot? snapshot)) - return Task.FromResult(null); + return null; ct.ThrowIfCancellationRequested(); - return Task.FromResult( - _rename.Rename( - request.TextDocument.Uri.GetFileSystemPath(), - snapshot.Document.Text, - request.Position, - request.NewName, - snapshot.TextOverlay, - _workspace.SnapshotRoots())); + long workspaceVersion = _workspace.Version; + var result = await _rename.RenameAsync(snapshot, request.Position, request.NewName, + _workspace.SnapshotRoots(), ct); + ct.ThrowIfCancellationRequested(); + return _store.IsCurrent(uri, snapshot.Document.Version, snapshot.WorkspaceVersion) && + _workspace.Version == workspaceVersion && result.Domain.IsCurrent(ct) ? result.Value : null; } protected override RenameRegistrationOptions CreateRegistrationOptions( @@ -77,25 +75,21 @@ public PrepareRenameHandler( _workspace = workspace; } - public override Task Handle( + public override async Task Handle( PrepareRenameParams request, CancellationToken ct) { string uri = request.TextDocument.Uri.ToString(); if (!_store.TryCapture(uri, out DocumentRequestSnapshot? snapshot)) - return Task.FromResult(null); + return null; ct.ThrowIfCancellationRequested(); - OmniSharp.Extensions.LanguageServer.Protocol.Models.Range? range = _rename.Prepare( - request.TextDocument.Uri.GetFileSystemPath(), - snapshot.Document.Text, - request.Position, - snapshot.TextOverlay, - _workspace.SnapshotRoots()); - return Task.FromResult( - range is null - ? null - : new RangeOrPlaceholderRange(range)); + long workspaceVersion = _workspace.Version; + var result = await _rename.PrepareAsync(snapshot, request.Position, _workspace.SnapshotRoots(), ct); + ct.ThrowIfCancellationRequested(); + if (!_store.IsCurrent(uri, snapshot.Document.Version, snapshot.WorkspaceVersion) || + _workspace.Version != workspaceVersion || !result.Domain.IsCurrent(ct)) return null; + return result.Value is null ? null : new RangeOrPlaceholderRange(result.Value); } protected override RenameRegistrationOptions CreateRegistrationOptions( diff --git a/src/SharpTS.LanguageServer/Handlers/SignatureHelpHandler.cs b/src/SharpTS.LanguageServer/Handlers/SignatureHelpHandler.cs index 44ab48431..6c1d3a1c5 100644 --- a/src/SharpTS.LanguageServer/Handlers/SignatureHelpHandler.cs +++ b/src/SharpTS.LanguageServer/Handlers/SignatureHelpHandler.cs @@ -3,6 +3,7 @@ using OmniSharp.Extensions.LanguageServer.Protocol.Models; using OmniSharp.Extensions.LanguageServer.Protocol.Server.Capabilities; using SharpTS.LanguageServer.Services; +using SharpTS.LanguageServer.Project; namespace SharpTS.LanguageServer.Handlers; @@ -11,25 +12,28 @@ public sealed class SignatureHelpHandler : SignatureHelpHandlerBase { private readonly DocumentStore _store; private readonly DecoratorService _decorators; + private readonly AnalysisMetadataProvider? _metadata; - public SignatureHelpHandler(DocumentStore store, DecoratorService decorators) + public SignatureHelpHandler(DocumentStore store, DecoratorService decorators, AnalysisMetadataProvider? metadata = null) { _store = store; _decorators = decorators; + _metadata = metadata; } public override Task Handle(SignatureHelpParams request, CancellationToken ct) { - if (!_store.TryGetSnapshot( + if (!_store.TryCapture( request.TextDocument.Uri.ToString(), - out DocumentSnapshot? snapshot)) + out DocumentRequestSnapshot? capture)) return Task.FromResult(null); + using var guard = new EditorRequestGuard(_store, capture, _metadata, ct); - return Task.FromResult( - _decorators.SignatureHelp( - snapshot.Text, + SignatureHelp? result = _decorators.SignatureHelp( + capture.Document.Text, request.Position.Line, - request.Position.Character)); + request.Position.Character); + return Task.FromResult(guard.IsCurrent(ct) ? result : null); } protected override SignatureHelpRegistrationOptions CreateRegistrationOptions( diff --git a/src/SharpTS.LanguageServer/Handlers/TextDocumentSyncHandler.cs b/src/SharpTS.LanguageServer/Handlers/TextDocumentSyncHandler.cs index fd971212a..c26cbd768 100644 --- a/src/SharpTS.LanguageServer/Handlers/TextDocumentSyncHandler.cs +++ b/src/SharpTS.LanguageServer/Handlers/TextDocumentSyncHandler.cs @@ -17,13 +17,16 @@ public sealed class TextDocumentSyncHandler : TextDocumentSyncHandlerBase { private readonly DocumentStore _store; private readonly DiagnosticsCoordinator _diagnostics; + private readonly SemanticAnalysisService? _analysis; public TextDocumentSyncHandler( DocumentStore store, - DiagnosticsCoordinator diagnostics) + DiagnosticsCoordinator diagnostics, + SemanticAnalysisService? analysis = null) { _store = store; _diagnostics = diagnostics; + _analysis = analysis; } public override TextDocumentAttributes GetTextDocumentAttributes(DocumentUri uri) => new(uri, "typescript"); @@ -36,6 +39,7 @@ public override Task Handle(DidOpenTextDocumentParams request, Cancellatio request.TextDocument.Text, request.TextDocument.Version ?? 0)) { + _analysis?.InvalidateAll(); _diagnostics.Queue(uri); } return Unit.Task; @@ -53,6 +57,7 @@ public override Task Handle(DidChangeTextDocumentParams request, Cancellat version, request.ContentChanges)) { + _analysis?.InvalidateAll(); _diagnostics.Queue(uri); } return Unit.Task; @@ -63,7 +68,10 @@ public override Task Handle(DidChangeTextDocumentParams request, Cancellat public override Task Handle(DidCloseTextDocumentParams request, CancellationToken ct) { if (_store.Remove(request.TextDocument.Uri.ToString()) is { } closed) + { + _analysis?.InvalidateAll(); _diagnostics.Close(closed); + } return Unit.Task; } diff --git a/src/SharpTS.LanguageServer/Program.cs b/src/SharpTS.LanguageServer/Program.cs index 4241b76ba..628880d50 100644 --- a/src/SharpTS.LanguageServer/Program.cs +++ b/src/SharpTS.LanguageServer/Program.cs @@ -1,4 +1,3 @@ -using SharpTS.Compilation; using SharpTS.LanguageServer; using SharpTS.LanguageServer.Conversions; using SharpTS.LanguageServer.Project; @@ -51,38 +50,31 @@ await Console.Error.WriteLineAsync( try { - // Resolve @DotNetType targets against the project's referenced assemblies (via - // MetadataLoadContext). With no project/refs the loader still resolves the BCL. - var paths = new List(references); - if (projectFile != null && File.Exists(projectFile)) - paths.AddRange(CsprojParser.Parse(projectFile)); - - // sharpts.json (walked up from the workspace root = CWD, matching the CLI's - // discovery from the entry script) supplies more paths for the SAME loader: - // Resolve, not Load — the editor process inspects workspace assemblies through - // the MetadataLoadContext and never executes their code. Read once at startup, - // like --project. A broken manifest must not kill the server: log and continue. + // Acquire manifest packages once at startup, preserving the existing startup behavior. + // All later configuration validation and metadata captures are read-only: they inspect + // already restored assets and never launch restore during editor requests. try { - var refSet = SharpTS.References.DotNetReferences.Resolve(Environment.CurrentDirectory, []); - paths.AddRange(refSet.References.Select(r => r.Path)); + SharpTS.References.DotNetReferences.Resolve(Environment.CurrentDirectory, []); } catch (Exception ex) { await Console.Error.WriteLineAsync($"[LSP] sharpts.json ignored: {ex.Message}"); } - using var loader = new ReloadingAssemblyReferenceLoader(paths, sdkPath); - Func> typeNames = () => loader.GetAllPublicTypes() - .Select(t => t.FullName) - .Where(n => !string.IsNullOrEmpty(n)) - .Cast(); + using var metadata = new AnalysisMetadataProvider( + projectFile, + references, + sdkPath, + Environment.CurrentDirectory, + message => Console.Error.WriteLine($"[LSP] {message}")); await SharpTSLanguageServer.RunAsync( - loader.TryResolve, - typeNames, + metadata.Resolve, + metadata.GetTypeNames, mode, - diagnosticMode); + diagnosticMode, + metadataProvider: metadata); } catch (Exception ex) { diff --git a/src/SharpTS.LanguageServer/Project/AnalysisMetadataProvider.cs b/src/SharpTS.LanguageServer/Project/AnalysisMetadataProvider.cs new file mode 100644 index 000000000..941c0e398 --- /dev/null +++ b/src/SharpTS.LanguageServer/Project/AnalysisMetadataProvider.cs @@ -0,0 +1,267 @@ +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Text; +using SharpTS.Compilation; +using SharpTS.IO; +using SharpTS.References; + +namespace SharpTS.LanguageServer.Project; + +/// +/// Captures read-only project/manifest inputs and one stable CLR metadata generation. Refreshing +/// never loads executable assemblies, restores packages, or launches an external process. +/// +public sealed class AnalysisMetadataProvider : IDisposable +{ + private readonly object _gate = new(); + private readonly AsyncLocal _ambient = new(); + private readonly string? _projectFile; + private readonly string[] _references; + private readonly string? _sdkPath; + private readonly string _startDirectory; + private readonly Action? _log; + private ReloadingAssemblyReferenceLoader? _loader; + private string? _inputKey; + private string? _lastErrors; + private int _generation; + private bool _disposed; + + public AnalysisMetadataProvider( + string? projectFile = null, + IEnumerable? references = null, + string? sdkPath = null, + string? startDirectory = null, + Action? log = null) + { + _projectFile = projectFile is null ? null : Path.GetFullPath(projectFile); + _references = (references ?? []).Select(Path.GetFullPath).ToArray(); + _sdkPath = sdkPath; + _startDirectory = Path.GetFullPath(startDirectory ?? Environment.CurrentDirectory); + _log = log; + } + + /// The ambient captured generation, suitable for a request-local query cache. + public int CurrentGeneration => _ambient.Value?.Generation ?? RefreshGeneration(); + + public AnalysisMetadataView Capture() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var inputs = new MetadataInputCapture(CompilerFileSystem.Current); + using var fileSystemScope = CompilerFileSystem.Use(inputs, CompilerFileSystem.CancellationToken); + var paths = new List(_references); + var errors = new List(); + if (_projectFile is not null) + { + try + { + paths.AddRange(CsprojParser.Parse(_projectFile)); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) { errors.Add($"project references unavailable: {ex.Message}"); } + } + try + { + ReferenceSet set = DotNetReferences.ResolveReadOnly(_startDirectory, []); + paths.AddRange(set.References.Select(reference => reference.Path)); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) { errors.Add($"sharpts.json references unavailable: {ex.Message}"); } + + string[] distinctPaths = paths.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + foreach (string path in distinctPaths) + { + try + { + if (!CompilerFileSystem.FileExists(path)) + errors.Add($"reference not found: {path}"); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { errors.Add($"reference unreadable: {path}: {error.Message}"); } + } + _loader ??= new ReloadingAssemblyReferenceLoader(distinctPaths, _sdkPath); + _loader.UpdateReferences(distinctPaths); + ReloadingAssemblyReferenceLoader.AssemblyReferenceSnapshot snapshot = _loader.AcquireSnapshot(); + try + { + errors.AddRange(snapshot.Failures); + string errorText = string.Join("\n", errors.Distinct(StringComparer.Ordinal)); + string key = $"{snapshot.Generation}:{inputs.Fingerprint()}:{errorText}"; + if (_inputKey is not null && !string.Equals(_inputKey, key, StringComparison.Ordinal)) + _generation = checked(_generation + 1); + _inputKey = key; + if (!string.Equals(_lastErrors, errorText, StringComparison.Ordinal)) + { + _lastErrors = errorText; + foreach (string error in errors) + _log?.Invoke(error); + } + return new AnalysisMetadataView(this, snapshot, _generation, errors.Count == 0, distinctPaths); + } + catch + { + snapshot.Dispose(); + throw; + } + } + } + + /// + /// Revalidates current configuration and assembly content even inside an older metadata scope. + /// Call outside any memoized compiler filesystem scope when checking physical currentness. + /// + public int RefreshGeneration() + { + using AnalysisMetadataView view = Capture(); + return view.Generation; + } + + /// Existing editor callbacks resolve through their ambient captured view. + public Type? Resolve(string fullName) + { + if (_ambient.Value is { } view) + return view.Resolve(fullName); + lock (_gate) + { + // Preserve raw-Type compatibility for unscoped consumers. Production requests enter + // a view scope and release their contexts explicitly instead of taking this path. + using AnalysisMetadataView capture = Capture(); + return _loader!.TryResolve(fullName); + } + } + + public IEnumerable GetTypeNames() + { + if (_ambient.Value is { } view) + return view.GetTypeNames(); + using AnalysisMetadataView capture = Capture(); + return capture.GetTypeNames().ToArray(); + } + + internal IDisposable EnterScope(AnalysisMetadataView view) + { + AnalysisMetadataView? previous = _ambient.Value; + AnalysisMetadataView retained = view.Retain(); + _ambient.Value = retained; + return new MetadataScope(this, previous, retained); + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + _loader?.Dispose(); + } + } + + private sealed class MetadataScope( + AnalysisMetadataProvider owner, + AnalysisMetadataView? previous, + AnalysisMetadataView retained) : IDisposable + { + private bool _disposed; + public void Dispose() + { + if (_disposed) return; + _disposed = true; + owner._ambient.Value = previous; + retained.Dispose(); + } + } + + /// + /// Memoizes only one capture's read-only inputs so repeated parser probes cannot join two + /// configurations. Its digest includes failed probes and inventories as well as text reads. + /// The enclosing analysis recorder still observes every underlying read. + /// + private sealed class MetadataInputCapture(ICompilerFileSystem inner) : ICompilerFileSystem + { + private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _directories = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _text = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _attributes = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _inventories = new(StringComparer.Ordinal); + + public bool FileExists(string path) => Memoize(_files, Path.GetFullPath(path), inner.FileExists); + public bool DirectoryExists(string path) => Memoize(_directories, Path.GetFullPath(path), inner.DirectoryExists); + public string ReadAllText(string path) => Memoize(_text, Path.GetFullPath(path), inner.ReadAllText); + public FileAttributes GetAttributes(string path) => Memoize(_attributes, Path.GetFullPath(path), inner.GetAttributes); + + public IReadOnlyList EnumerateFiles(string path, string searchPattern, EnumerationOptions options) => + Inventory("files", path, searchPattern, options, inner.EnumerateFiles); + public IReadOnlyList EnumerateDirectories(string path, string searchPattern, EnumerationOptions options) => + Inventory("directories", path, searchPattern, options, inner.EnumerateDirectories); + + private IReadOnlyList Inventory(string kind, string path, string pattern, EnumerationOptions options, + Func> read) + { + string absolute = Path.GetFullPath(path); + string key = $"{kind}:{absolute}:{pattern}:{options.RecurseSubdirectories}:" + + $"{options.IgnoreInaccessible}:{options.AttributesToSkip}:{options.MatchType}:" + + $"{options.MatchCasing}:{options.ReturnSpecialDirectories}:{options.MaxRecursionDepth}"; + if (!_inventories.TryGetValue(key, out IReadOnlyList? paths)) + { + paths = read(absolute, pattern, options).ToImmutableArray(); + _inventories.Add(key, paths); + } + return paths; + } + + public string Fingerprint() + { + IEnumerable records = _files.Select(pair => $"file:{pair.Key}:{pair.Value}") + .Concat(_directories.Select(pair => $"directory:{pair.Key}:{pair.Value}")) + .Concat(_text.Select(pair => $"text:{pair.Key}:{Hash(pair.Value)}")) + .Concat(_attributes.Select(pair => $"attributes:{pair.Key}:{pair.Value}")) + .Concat(_inventories.Select(pair => $"inventory:{pair.Key}:{Hash(string.Join('\n', pair.Value))}")); + return Hash(string.Join('\n', records.Order(StringComparer.Ordinal))); + } + + private static TValue Memoize(Dictionary values, string path, Func read) + { + if (!values.TryGetValue(path, out TValue? value)) + { + value = read(path); + values.Add(path, value); + } + return value; + } + + private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))); + } +} + +/// +/// One immutable CLR metadata view. Retain a lease for every cache entry or active operation that +/// may still use a reflected Type; dispose it when that operation or cache ownership ends. +/// +public sealed class AnalysisMetadataView : IDisposable +{ + private readonly AnalysisMetadataProvider _owner; + private readonly ReloadingAssemblyReferenceLoader.AssemblyReferenceSnapshot _snapshot; + + internal AnalysisMetadataView(AnalysisMetadataProvider owner, + ReloadingAssemblyReferenceLoader.AssemblyReferenceSnapshot snapshot, + int generation, bool isComplete, IReadOnlyList referencePaths) + { + _owner = owner; + _snapshot = snapshot; + Generation = generation; + IsComplete = isComplete; + ReferencePaths = referencePaths.ToImmutableArray(); + } + + public int Generation { get; } + public bool IsComplete { get; } + public IReadOnlyList ReferencePaths { get; } + public Type? Resolve(string fullName) => _snapshot.TryResolve(fullName); + public IEnumerable GetTypeNames() => _snapshot.GetAllPublicTypes() + .Select(type => type.FullName).OfType().ToImmutableArray(); + public IDisposable EnterScope() => _owner.EnterScope(this); + public AnalysisMetadataView Retain() => new(_owner, _snapshot.Retain(), Generation, IsComplete, ReferencePaths); + public void Dispose() => _snapshot.Dispose(); +} diff --git a/src/SharpTS.LanguageServer/Project/CsprojParser.cs b/src/SharpTS.LanguageServer/Project/CsprojParser.cs index 93e3377d3..c6d0c3b82 100644 --- a/src/SharpTS.LanguageServer/Project/CsprojParser.cs +++ b/src/SharpTS.LanguageServer/Project/CsprojParser.cs @@ -1,4 +1,5 @@ using System.Xml.Linq; +using SharpTS.IO; namespace SharpTS.LanguageServer.Project; @@ -12,16 +13,17 @@ public static class CsprojParser /// public static List Parse(string csprojPath) { - if (!File.Exists(csprojPath)) + if (!CompilerFileSystem.FileExists(csprojPath)) throw new FileNotFoundException($"Project file not found: {csprojPath}"); - var doc = XDocument.Load(csprojPath); + var doc = XDocument.Parse(CompilerFileSystem.ReadAllText(csprojPath)); var projectDir = Path.GetDirectoryName(Path.GetFullPath(csprojPath)) ?? "."; var references = new List(); // Parse elements (direct assembly references) foreach (var refElement in doc.Descendants("Reference")) { + CompilerFileSystem.ThrowIfCancellationRequested(); var hintPath = refElement.Element("HintPath")?.Value; if (!string.IsNullOrEmpty(hintPath)) { @@ -29,7 +31,7 @@ public static List Parse(string csprojPath) // (csproj files often use backslashes even when targeting Unix) hintPath = hintPath.Replace('\\', Path.DirectorySeparatorChar); var fullPath = Path.GetFullPath(Path.Combine(projectDir, hintPath)); - if (File.Exists(fullPath)) + if (CompilerFileSystem.FileExists(fullPath)) references.Add(fullPath); } } @@ -38,6 +40,7 @@ public static List Parse(string csprojPath) var packageReferences = new List<(string Id, string Version)>(); foreach (var pkgElement in doc.Descendants("PackageReference")) { + CompilerFileSystem.ThrowIfCancellationRequested(); var id = pkgElement.Attribute("Include")?.Value; var version = pkgElement.Attribute("Version")?.Value ?? pkgElement.Element("Version")?.Value; @@ -52,13 +55,14 @@ public static List Parse(string csprojPath) var nugetPath = GetNuGetPackagesPath(); foreach (var (id, version) in packageReferences) { + CompilerFileSystem.ThrowIfCancellationRequested(); var packagePath = Path.Combine(nugetPath, id.ToLowerInvariant(), version); - if (Directory.Exists(packagePath)) + if (CompilerFileSystem.DirectoryExists(packagePath)) { var libPath = FindBestTfmLib(packagePath); if (libPath != null) { - foreach (var dll in Directory.GetFiles(libPath, "*.dll")) + foreach (var dll in CompilerFileSystem.EnumerateFiles(libPath, "*.dll")) { references.Add(dll); } @@ -73,7 +77,7 @@ private static string GetNuGetPackagesPath() { // Check NUGET_PACKAGES environment variable first var envPath = Environment.GetEnvironmentVariable("NUGET_PACKAGES"); - if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath)) + if (!string.IsNullOrEmpty(envPath) && CompilerFileSystem.DirectoryExists(envPath)) return envPath; // Default to user profile location @@ -84,7 +88,7 @@ private static string GetNuGetPackagesPath() private static string? FindBestTfmLib(string packagePath) { var libPath = Path.Combine(packagePath, "lib"); - if (!Directory.Exists(libPath)) + if (!CompilerFileSystem.DirectoryExists(libPath)) return null; // Prefer newer .NET versions, then netstandard @@ -97,13 +101,14 @@ private static string GetNuGetPackagesPath() foreach (var tfm in tfmPreferences) { + CompilerFileSystem.ThrowIfCancellationRequested(); var tfmPath = Path.Combine(libPath, tfm); - if (Directory.Exists(tfmPath)) + if (CompilerFileSystem.DirectoryExists(tfmPath)) return tfmPath; } // Fall back to first available - var dirs = Directory.GetDirectories(libPath); - return dirs.Length > 0 ? dirs[0] : null; + var dirs = CompilerFileSystem.EnumerateDirectories(libPath); + return dirs.Count > 0 ? dirs[0] : null; } } diff --git a/src/SharpTS.LanguageServer/Services/AnalysisSnapshot.cs b/src/SharpTS.LanguageServer/Services/AnalysisSnapshot.cs new file mode 100644 index 000000000..4d1abcab1 --- /dev/null +++ b/src/SharpTS.LanguageServer/Services/AnalysisSnapshot.cs @@ -0,0 +1,139 @@ +using System.Collections.Frozen; +using System.Collections.Immutable; +using SharpTS.Parsing; +using SharpTS.TypeSystem; +using SharpDiagnostic = SharpTS.Diagnostics.Diagnostic; +using SharpDiagnosticSeverity = SharpTS.Diagnostics.DiagnosticSeverity; + +namespace SharpTS.LanguageServer.Services; + +/// +/// The parser and checker configuration used for one completed analysis. +/// +internal sealed record AnalysisOptions( + DecoratorMode DecoratorMode, + JsxParseOptions JsxOptions, + TypeCheckerOptions CheckerOptions); + +/// A captured edge in the checked module/reference graph. +internal sealed record AnalysisDependency(string SourcePath, string TargetPath); + +/// +/// One source capture and its parser output. Tokens and AST objects belong to this analysis; +/// collection copies prevent parser/module list mutations from changing the published shape. +/// +/// +/// AST nodes and source span tables preserve reference identity with the TypeMap and bindings. +/// They must never be parsed into, checked again, or otherwise mutated after publication. +/// Cursor-specific recovery must create separate artifacts instead of editing this document. +/// +internal sealed class AnalysisDocument +{ + public AnalysisDocument( + SourceDocument document, + IReadOnlyList tokens, + IReadOnlyList statements, + IReadOnlyList parseDiagnostics, + bool hitParseErrorLimit = false) + { + Document = document; + Tokens = tokens.ToImmutableArray(); + Statements = statements.ToImmutableArray(); + ParseDiagnostics = AnalysisSnapshot.CopyDiagnostics(parseDiagnostics); + HitParseErrorLimit = hitParseErrorLimit; + HasRecoveredSyntax = hitParseErrorLimit || + ParseDiagnostics.Any(diagnostic => diagnostic.Severity == SharpDiagnosticSeverity.Error); + } + + public SourceDocument Document { get; } + public IReadOnlyList Tokens { get; } + public IReadOnlyList Statements { get; } + public IReadOnlyList ParseDiagnostics { get; } + public bool HitParseErrorLimit { get; } + public bool HasRecoveredSyntax { get; } +} + +/// +/// Completed semantic data for one connected source component. No checker or resolver is retained +/// or exposed, and callers can only query the build-owned TypeMap. +/// +/// +/// Construction transfers ownership of the TypeMap, documents, and AST graph to the snapshot. +/// The builder must finish checking before construction and must not recheck or mutate those +/// objects afterward. Cache validation and caller cancellation belong to the analysis service, +/// allowing multiple requests to safely query the same completed capture. +/// +internal sealed class AnalysisSnapshot +{ + private readonly TypeMap _typeMap; + private readonly FrozenDictionary _documentsByPath; + + public AnalysisSnapshot( + FrozenBindingIndex bindings, + TypeMap typeMap, + IReadOnlyList documents, + IReadOnlyList diagnostics, + NavigationGraphScope scope, + bool hasRecoveredSyntax = false, + bool hasPartialSemantics = false, + AnalysisOptions? options = null, + IReadOnlyList? dependencies = null) + { + Bindings = bindings; + _typeMap = typeMap; + Documents = documents.ToImmutableArray(); + _documentsByPath = documents.ToFrozenDictionary( + document => NormalizeDocumentPath(document.Document.Path), + StringComparer.OrdinalIgnoreCase); + Diagnostics = CopyDiagnostics(diagnostics); + Scope = scope with { RootFiles = scope.RootFiles.ToImmutableArray() }; + HasRecoveredSyntax = hasRecoveredSyntax || + documents.Any(document => document.HasRecoveredSyntax); + HasPartialSemantics = hasPartialSemantics || HasRecoveredSyntax; + Options = options; + Dependencies = dependencies?.ToImmutableArray() ?? ImmutableArray.Empty; + ClassTypes = typeMap.ClassTypes.ToFrozenDictionary(StringComparer.Ordinal); + } + + public FrozenBindingIndex Bindings { get; } + public IReadOnlyList Documents { get; } + public IReadOnlyList Diagnostics { get; } + public NavigationGraphScope Scope { get; } + public bool HasRecoveredSyntax { get; } + public bool HasPartialSemantics { get; } + public AnalysisOptions? Options { get; } + public IReadOnlyList Dependencies { get; } + public IReadOnlyDictionary ClassTypes { get; } + public int TypeCount => _typeMap.Count; + + public bool TryGetDocument(string path, out AnalysisDocument? document) => + _documentsByPath.TryGetValue(NormalizeDocumentPath(path), out document); + + private static string NormalizeDocumentPath(string path) => + Path.IsPathFullyQualified(path) ? Path.GetFullPath(path) : path; + + public TypeInfo? GetType(Expr expression) => _typeMap.Get(expression); + + public bool TryGetType(Expr expression, out TypeInfo? type) => + _typeMap.TryGet(expression, out type); + + public TypeInfo.Class? GetClassType(Stmt.Class declaration) => + _typeMap.GetClassType(declaration); + + public TypeInfo.Class? GetClassType(string className) => _typeMap.GetClassType(className); + + public TypeInfo.Class? GetClassExprType(Expr.ClassExpr expression) => + _typeMap.GetClassExprType(expression); + + public TypeInfo.Function? GetFunctionType(string functionName) => + _typeMap.GetFunctionType(functionName); + + internal static IReadOnlyList CopyDiagnostics( + IReadOnlyList diagnostics) => + diagnostics.Select(diagnostic => diagnostic.Properties is null + ? diagnostic + : diagnostic with + { + Properties = diagnostic.Properties.ToFrozenDictionary(StringComparer.Ordinal), + }).ToImmutableArray(); +} diff --git a/src/SharpTS.LanguageServer/Services/DecoratorService.cs b/src/SharpTS.LanguageServer/Services/DecoratorService.cs index b40bbf41c..372dab23d 100644 --- a/src/SharpTS.LanguageServer/Services/DecoratorService.cs +++ b/src/SharpTS.LanguageServer/Services/DecoratorService.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using System.Collections.Immutable; using OmniSharp.Extensions.LanguageServer.Protocol.Models; using SharpTS.LanguageServer.Documentation; using SharpTS.Runtime.DotNet; @@ -15,19 +16,40 @@ public sealed class DecoratorService private readonly Func _resolve; private readonly Func>? _typeNamesProvider; private readonly XmlDocLoader _xmlDoc = new(); - private List? _typeNameCache; - - public DecoratorService(Func? resolve = null, Func>? typeNames = null) + private readonly Func? _metadataGeneration; + private readonly object _typeNameGate = new(); + private IReadOnlyList? _typeNameCache; + private int _typeNameGeneration; + + public DecoratorService( + Func? resolve = null, + Func>? typeNames = null, + Func? metadataGeneration = null) { _resolve = resolve ?? DotNetTypeRegistry.Resolve; _typeNamesProvider = typeNames; + _metadataGeneration = metadataGeneration; } - // Known public type names from the project's referenced assemblies (cached — the loaded - // set is fixed for the session). Empty when no resolver/loader supplies them. - private List KnownTypeNames() - => _typeNameCache ??= (_typeNamesProvider?.Invoke() ?? Enumerable.Empty()) - .Distinct().OrderBy(n => n, StringComparer.Ordinal).ToList(); + // A captured metadata generation defines the public type set. Custom providers without a + // generation callback are enumerated each time, so a changing provider cannot remain stale. + private IReadOnlyList KnownTypeNames() + { + int? generation = _metadataGeneration?.Invoke(); + lock (_typeNameGate) + { + if (generation is { } current && _typeNameCache is not null && current == _typeNameGeneration) + return _typeNameCache; + IReadOnlyList names = (_typeNamesProvider?.Invoke() ?? []) + .Distinct().OrderBy(name => name, StringComparer.Ordinal).ToImmutableArray(); + if (generation is { } next) + { + _typeNameCache = names; + _typeNameGeneration = next; + } + return names; + } + } private static readonly Regex DecoratorRx = new(@"@(\w+)(?:\s*\(\s*""([^""]*)""\s*\))?", RegexOptions.Compiled); diff --git a/src/SharpTS.LanguageServer/Services/DefinitionService.cs b/src/SharpTS.LanguageServer/Services/DefinitionService.cs index 6f6dc793c..c23dcfaa5 100644 --- a/src/SharpTS.LanguageServer/Services/DefinitionService.cs +++ b/src/SharpTS.LanguageServer/Services/DefinitionService.cs @@ -1,42 +1,81 @@ +using System.Diagnostics.CodeAnalysis; using OmniSharp.Extensions.LanguageServer.Protocol.Models; -using SharpTS.TypeSystem; namespace SharpTS.LanguageServer.Services; -/// -/// Resolves source positions to checker-bound declarations across a module graph. -/// -/// -/// The service intentionally returns no result for names the checker did not bind. In particular, -/// property/member navigation still needs its own complete semantic domain; guessing from matching -/// text would produce incorrect navigation under shadowing. -/// -public sealed class DefinitionService +internal sealed record NavigationDefinitionResult( + IReadOnlyList Locations, + AnalysisValidation? Validation = null) { - /// - /// Finds the value or type declaration selected by an LSP position. - /// + public bool IsCurrent(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Validation?.IsCurrent(cancellationToken) ?? true; + } +} + +/// Resolves source positions to checker-bound declarations across a module graph. +public sealed class DefinitionService : IDisposable +{ + private readonly SemanticAnalysisService _analysis; + private readonly bool _ownsAnalysis; + + public DefinitionService(SemanticAnalysisService? analysis = null) + { + _analysis = analysis ?? new SemanticAnalysisService(); + _ownsAnalysis = analysis is null; + } + + [SuppressMessage("Usage", "VSTHRD002", Justification = "Compatibility wrapper for existing synchronous callers.")] public IReadOnlyList FindDefinitions( - string path, - string text, - Position position, + string path, string text, Position position, IReadOnlyDictionary? openDocuments = null) { - if (NavigationModelBuilder.TryBuild(path, text, openDocuments) is not { } model) - return []; - - int offset = model.Document.Lines.ToOffset( - (int)position.Line + 1, - (int)position.Character + 1); - return model.Checker.Bindings.FindSymbols( - model.Document, - offset) - .SelectMany(symbol => symbol.Declarations) - .DistinctBy(declaration => ( - declaration.Document.Path, - declaration.Name.Start)) - .Select(declaration => - NavigationLocations.From(declaration.Document, declaration.Name)) - .ToArray(); + NavigationDefinitionResult result = FindDefinitionsLegacyAsync(path, text, position, openDocuments) + .GetAwaiter().GetResult(); + return result.IsCurrent() ? result.Locations : []; + } + + internal async Task FindDefinitionsAsync( + DocumentRequestSnapshot capture, Position position, CancellationToken cancellationToken) + { + if (capture.Document.FilePath is null) + return new NavigationDefinitionResult([]); + using AnalysisLease? lease = await _analysis.GetDocumentAsync(capture, cancellationToken).ConfigureAwait(false); + return Project(lease, position, cancellationToken); + } + + private async Task FindDefinitionsLegacyAsync( + string path, string text, Position position, IReadOnlyDictionary? openDocuments) + { + using AnalysisLease? lease = await _analysis.GetDocumentAsync(path, text, openDocuments).ConfigureAwait(false); + return Project(lease, position, CancellationToken.None); + } + + private static NavigationDefinitionResult Project( + AnalysisLease? lease, Position position, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (lease is null) return new NavigationDefinitionResult([]); + CheckedNavigationModel model = lease.Model; + int offset = model.Document.Lines.ToOffset((int)position.Line + 1, (int)position.Character + 1); + var locations = new List(); + var seen = new HashSet<(string Path, int Start)>(); + foreach (var symbol in model.Bindings.FindSymbols(model.Document, offset)) + { + cancellationToken.ThrowIfCancellationRequested(); + foreach (var declaration in symbol.Declarations) + { + cancellationToken.ThrowIfCancellationRequested(); + if (seen.Add((declaration.Document.Path, declaration.Name.Start))) + locations.Add(NavigationLocations.From(declaration.Document, declaration.Name)); + } + } + return new NavigationDefinitionResult(locations.ToArray(), lease.Validation); + } + + public void Dispose() + { + if (_ownsAnalysis) _analysis.Dispose(); } } diff --git a/src/SharpTS.LanguageServer/Services/DiagnosticsCoordinator.cs b/src/SharpTS.LanguageServer/Services/DiagnosticsCoordinator.cs index f81ac57c3..a7b358cd4 100644 --- a/src/SharpTS.LanguageServer/Services/DiagnosticsCoordinator.cs +++ b/src/SharpTS.LanguageServer/Services/DiagnosticsCoordinator.cs @@ -157,9 +157,10 @@ private async Task RunChangedDocumentAsync( return; cancellationToken.ThrowIfCancellationRequested(); - IReadOnlyList statements = _diagnostics.GetStatements( - snapshot.Document, - cancellationToken); + IReadOnlyList statements = await _diagnostics.GetStatementsAsync( + snapshot, + _settings.Mode, + cancellationToken).ConfigureAwait(false); IReadOnlySet affected = _graph.Update( snapshot.Document, snapshot.TextOverlay, @@ -184,11 +185,11 @@ private async Task RunAffectedDocumentsAsync( if (!_store.TryCapture(open.Uri, out DocumentRequestSnapshot? snapshot)) continue; - Publish(snapshot, snapshot.Document, cancellationToken); + await PublishAsync(snapshot, snapshot.Document, cancellationToken).ConfigureAwait(false); } } - private Task AnalyzeAndPublishAsync( + private async Task AnalyzeAndPublishAsync( DocumentRequestSnapshot workspace, IReadOnlySet affectedPaths, CancellationToken cancellationToken) @@ -202,23 +203,25 @@ private Task AnalyzeAndPublishAsync( continue; } - Publish(workspace, document, cancellationToken); + await PublishAsync(workspace, document, cancellationToken).ConfigureAwait(false); } - return Task.CompletedTask; } - private void Publish( + private async Task PublishAsync( DocumentRequestSnapshot workspace, DocumentSnapshot document, CancellationToken cancellationToken) { - var diagnostics = _diagnostics.Analyze( + DiagnosticsAnalysisResult result = await _diagnostics.AnalyzeResultAsync( workspace, document, _settings.Mode, - cancellationToken); + cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); + if (!result.IsCurrent(cancellationToken)) + return; + if (!_store.IsCurrent( document.Uri, document.Version, @@ -227,11 +230,15 @@ private void Publish( return; } + if (!result.IsCurrent(cancellationToken)) + return; + cancellationToken.ThrowIfCancellationRequested(); + _publish(new PublishDiagnosticsParams { Uri = DocumentUri.Parse(document.Uri), Version = document.Version, - Diagnostics = new Container(diagnostics), + Diagnostics = new Container(result.Diagnostics), }); } diff --git a/src/SharpTS.LanguageServer/Services/DiagnosticsService.cs b/src/SharpTS.LanguageServer/Services/DiagnosticsService.cs index 5d03ba3ad..378c78b5a 100644 --- a/src/SharpTS.LanguageServer/Services/DiagnosticsService.cs +++ b/src/SharpTS.LanguageServer/Services/DiagnosticsService.cs @@ -1,6 +1,9 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using SharpTS.Diagnostics; +using SharpTS.IO; using SharpTS.LanguageServer.Conversions; +using SharpTS.LanguageServer.Project; using SharpTS.Parsing; using SharpTS.TypeSystem; using LspDiagnostic = OmniSharp.Extensions.LanguageServer.Protocol.Models.Diagnostic; @@ -8,25 +11,59 @@ namespace SharpTS.LanguageServer.Services; +/// Diagnostics and the input validation required before they can be published. +internal sealed record DiagnosticsAnalysisResult( + List Diagnostics, + AnalysisValidation? Validation = null, + AnalysisMetadataProvider? Metadata = null, + int? MetadataGeneration = null) +{ + public bool IsCurrent(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (Validation is not null && !Validation.IsCurrent(cancellationToken)) + return false; + if (Metadata is null || MetadataGeneration is null) + return true; + // Validation must see physical inputs, even when called inside an older build scope. + using var scope = CompilerFileSystem.Use(CompilerFileSystem.Physical, cancellationToken); + return Metadata.RefreshGeneration() == MetadataGeneration; + } +} + /// -/// Produces the LSP diagnostics for a document. Phase 1 = the @DotNetType interop analyzer -/// (the tsserver-impossible value). Parse errors are left to the built-in TypeScript server. +/// Uses shared checked editor snapshots for full diagnostics and keeps SharpTS-only analysis +/// lazy so an interop-only editor never starts the general TypeScript checker. /// -public sealed class DiagnosticsService +public sealed class DiagnosticsService : IDisposable { private readonly InteropAnalyzer _interop; + private readonly SemanticAnalysisService _analysis; + private readonly bool _ownsAnalysis; + private readonly AnalysisMetadataProvider? _metadata; private readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _workspaceChecks = - new(StringComparer.OrdinalIgnoreCase); - /// CLR type resolver. Null = in-process registry (BCL only); - /// the server injects an AssemblyReferenceLoader when a project/references are configured - /// so the user's own @DotNetType targets resolve too. + /// CLR resolver, or the in-process registry when none is supplied. + /// Public CLR type names used for interop suggestions. + /// The server's shared checked analysis service. + /// Optional stable CLR metadata generations for interop diagnostics. public DiagnosticsService( Func? resolve = null, - Func>? typeNames = null) => + Func>? typeNames = null, + SemanticAnalysisService? analysis = null, + AnalysisMetadataProvider? metadata = null) + { + _analysis = analysis ?? new SemanticAnalysisService(); + _ownsAnalysis = analysis is null; + _metadata = metadata; + if (metadata is not null) + { + resolve ??= metadata.Resolve; + typeNames ??= metadata.GetTypeNames; + } _interop = new InteropAnalyzer(resolve, typeNames); + } public List Analyze( string text, @@ -34,232 +71,185 @@ public List Analyze( string? fileName = null) { var snapshot = new DocumentSnapshot( - fileName is null - ? "untitled:diagnostics" - : new Uri(Path.GetFullPath(fileName)).AbsoluteUri, + fileName is null ? "untitled:diagnostics" : new Uri(Path.GetFullPath(fileName)).AbsoluteUri, text, Version: 0, fileName is null ? null : Path.GetFullPath(fileName)); return Analyze(snapshot, mode, CancellationToken.None); } - /// - /// Analyzes one immutable text version. Lexing, AST/source spans, interop diagnostics, and - /// the optional full type-check result are retained together and reused only for that exact - /// version and text. - /// public List Analyze( DocumentSnapshot snapshot, DiagnosticPublishMode mode, CancellationToken cancellationToken) + { + IReadOnlyDictionary documents = snapshot.FilePath is null + ? new Dictionary() + : new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [snapshot.FilePath] = snapshot, + }; + return Analyze(new DocumentRequestSnapshot(snapshot, 0, documents), snapshot, mode, cancellationToken); + } + + [SuppressMessage("Usage", "VSTHRD002", Justification = "Compatibility wrapper; production publication awaits analysis.")] + public List Analyze( + DocumentRequestSnapshot workspace, + DocumentSnapshot snapshot, + DiagnosticPublishMode mode, + CancellationToken cancellationToken) + { + DiagnosticsAnalysisResult result = AnalyzeResultAsync(workspace, snapshot, mode, cancellationToken) + .GetAwaiter().GetResult(); + return result.IsCurrent(cancellationToken) ? result.Diagnostics : []; + } + + internal async Task AnalyzeResultAsync( + DocumentRequestSnapshot workspace, + DocumentSnapshot snapshot, + DiagnosticPublishMode mode, + CancellationToken cancellationToken) { if (mode == DiagnosticPublishMode.Off) - return []; + return new DiagnosticsAnalysisResult([]); - CachedAnalysis analysis = GetOrBuild(snapshot, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); + if (mode == DiagnosticPublishMode.All && snapshot.FilePath is not null) + { + using AnalysisLease? lease = await _analysis.GetDocumentAsync( + workspace with { Document = snapshot }, cancellationToken).ConfigureAwait(false); + if (lease is not null && TryGetSharedDocument(lease, snapshot, out AnalysisDocument? document)) + { + using var metadataScope = lease.EnterMetadataScope(); + var diagnostics = new List(); + if (!document!.HasRecoveredSyntax) + { + diagnostics.AddRange(_interop.Analyze( + document.Statements, new PositionMap(snapshot.Text), cancellationToken)); + } + diagnostics.AddRange(document.ParseDiagnostics); + diagnostics.AddRange(lease.Model.Snapshot.Diagnostics.Where(diagnostic => + BelongsToDocument(diagnostic, snapshot.FilePath))); + cancellationToken.ThrowIfCancellationRequested(); + return new DiagnosticsAnalysisResult( + LspConversions.ToLsp(diagnostics, snapshot.Text, mode), lease.Validation); + } + } - var diagnostics = new List(analysis.InteropDiagnostics); + // Untitled or unavailable semantic models retain standalone diagnostics. These ASTs + // are private to this cheap cache and are never a published semantic snapshot's AST. + using AnalysisMetadataView? metadata = _metadata?.Capture(); + using var scope = metadata?.EnterScope(); + CachedAnalysis analysis = GetOrBuild(snapshot, metadata?.Generation ?? 0, cancellationToken); + var standaloneDiagnostics = new List(analysis.InteropDiagnostics); if (mode == DiagnosticPublishMode.All) { - diagnostics.AddRange(analysis.ParseResult.Diagnostics); + standaloneDiagnostics.AddRange(analysis.ParseResult.Diagnostics); if (analysis.ParseResult.IsSuccess) { - TypeCheckDiagnosticResult result = - analysis.TypeCheckResult ?? - BuildTypeCheck(analysis, cancellationToken); - analysis.TypeCheckResult = result; - diagnostics.AddRange(result.Diagnostics); + lock (analysis.TypeCheckGate) + { + analysis.TypeCheckResult ??= BuildTypeCheck(analysis, cancellationToken); + standaloneDiagnostics.AddRange(analysis.TypeCheckResult.Diagnostics); + } } } + cancellationToken.ThrowIfCancellationRequested(); + return new DiagnosticsAnalysisResult( + LspConversions.ToLsp(standaloneDiagnostics, snapshot.Text, mode), + Metadata: _metadata, MetadataGeneration: metadata?.Generation); + } - return LspConversions.ToLsp(diagnostics, snapshot.Text, mode); + public void Invalidate(string uriOrPath) => _cache.TryRemove(uriOrPath, out _); + + internal IReadOnlyList GetStatements(DocumentSnapshot snapshot, CancellationToken cancellationToken) + { + using AnalysisMetadataView? metadata = _metadata?.Capture(); + using var scope = metadata?.EnterScope(); + return GetOrBuild(snapshot, metadata?.Generation ?? 0, cancellationToken).ParseResult.Statements; } - /// - /// Workspace-aware form used by diagnostic publication. In all mode it checks the - /// module graph from the same overlay snapshot and caches that result by workspace version. - /// - public List Analyze( + internal async Task> GetStatementsAsync( DocumentRequestSnapshot workspace, - DocumentSnapshot snapshot, DiagnosticPublishMode mode, CancellationToken cancellationToken) { - if (mode != DiagnosticPublishMode.All || - snapshot.FilePath is null) + if (mode == DiagnosticPublishMode.All && workspace.Document.FilePath is not null) { - return Analyze(snapshot, mode, cancellationToken); + using AnalysisLease? lease = await _analysis.GetDocumentAsync(workspace, cancellationToken) + .ConfigureAwait(false); + if (lease is not null && TryGetSharedDocument(lease, workspace.Document, out AnalysisDocument? document)) + return document!.Statements; } - - CachedAnalysis analysis = GetOrBuild(snapshot, cancellationToken); - var diagnostics = new List( - analysis.InteropDiagnostics); - diagnostics.AddRange(analysis.ParseResult.Diagnostics); - if (analysis.ParseResult.IsSuccess) - { - string path = snapshot.FilePath; - CachedWorkspaceCheck workspaceCheck = _workspaceChecks.AddOrUpdate( - path, - _ => BuildWorkspaceCheck( - workspace, - snapshot, - analysis, - cancellationToken), - (_, current) => - current.DocumentVersion == snapshot.Version && - current.WorkspaceVersion == workspace.WorkspaceVersion - ? current - : BuildWorkspaceCheck( - workspace, - snapshot, - analysis, - cancellationToken)); - diagnostics.AddRange(workspaceCheck.Diagnostics); - } - - cancellationToken.ThrowIfCancellationRequested(); - return LspConversions.ToLsp( - diagnostics, - snapshot.Text, - DiagnosticPublishMode.All); + return GetStatements(workspace.Document, cancellationToken); } - public void Invalidate(string uriOrPath) + private static bool TryGetSharedDocument( + AnalysisLease lease, DocumentSnapshot snapshot, out AnalysisDocument? document) { - _cache.TryRemove(uriOrPath, out _); - _workspaceChecks.TryRemove(uriOrPath, out _); + document = null; + return snapshot.FilePath is not null && + lease.Model.Snapshot.TryGetDocument(snapshot.FilePath, out document) && + document is not null && string.Equals(document.Document.Text, snapshot.Text, StringComparison.Ordinal); } - internal IReadOnlyList GetStatements( - DocumentSnapshot snapshot, - CancellationToken cancellationToken) => - GetOrBuild(snapshot, cancellationToken).ParseResult.Statements; + private static bool BelongsToDocument(SharpDiagnostic diagnostic, string path) => + diagnostic.FilePath is null || string.Equals( + Path.GetFullPath(diagnostic.FilePath), Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase); - private CachedAnalysis GetOrBuild( - DocumentSnapshot snapshot, - CancellationToken cancellationToken) + private CachedAnalysis GetOrBuild(DocumentSnapshot snapshot, int metadataGeneration, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); string key = snapshot.FilePath ?? snapshot.Uri; - return _cache.AddOrUpdate( - key, - _ => Build(snapshot, cancellationToken), - (_, current) => - current.Version == snapshot.Version && + return _cache.AddOrUpdate(key, + _ => Build(snapshot, metadataGeneration, cancellationToken), + (_, current) => current.Version == snapshot.Version && + current.MetadataGeneration == metadataGeneration && string.Equals(current.Text, snapshot.Text, StringComparison.Ordinal) - ? current - : Build(snapshot, cancellationToken)); + ? current : Build(snapshot, metadataGeneration, cancellationToken)); } - private CachedAnalysis Build( - DocumentSnapshot snapshot, - CancellationToken cancellationToken) + private CachedAnalysis Build(DocumentSnapshot snapshot, int metadataGeneration, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); string fileName = snapshot.FilePath ?? snapshot.Uri; - - // Stage3 decorators are the run-mode default and are required for @DotNetType to parse. - bool isTsx = - (fileName.EndsWith(".tsx", StringComparison.OrdinalIgnoreCase) || - fileName.EndsWith(".jsx", StringComparison.OrdinalIgnoreCase)); - List tokens = - new Lexer(snapshot.Text) { JsxTolerant = isTsx }.ScanTokens(); - cancellationToken.ThrowIfCancellationRequested(); - - var document = new SourceDocument( - fileName, - snapshot.Text, - isVirtual: snapshot.FilePath is null); + bool isTsx = fileName.EndsWith(".tsx", StringComparison.OrdinalIgnoreCase) || + fileName.EndsWith(".jsx", StringComparison.OrdinalIgnoreCase); + List tokens = new Lexer(snapshot.Text) { JsxTolerant = isTsx } + .WithCancellation(cancellationToken).ScanTokens(); + var document = new SourceDocument(fileName, snapshot.Text, isVirtual: snapshot.FilePath is null); var parser = new Parser(tokens, DecoratorMode.Stage3) - .WithSourceDocument(document); - if (isTsx) - { - parser.WithJsx(snapshot.Text, JsxParseOptions.Default); - } + .WithCancellation(cancellationToken).WithSourceDocument(document); + if (isTsx) parser.WithJsx(snapshot.Text, JsxParseOptions.Default); ParseDiagnosticResult parsed = parser.Parse(); cancellationToken.ThrowIfCancellationRequested(); - IReadOnlyList interopDiagnostics = parsed.IsSuccess - ? _interop.Analyze( - parsed.Statements, - new PositionMap(snapshot.Text), - cancellationToken) - : []; - - return new CachedAnalysis( - snapshot.Version, - snapshot.Text, - document, - tokens, - parsed, - interopDiagnostics); + ? _interop.Analyze(parsed.Statements, new PositionMap(snapshot.Text), cancellationToken) : []; + return new CachedAnalysis(snapshot.Version, metadataGeneration, snapshot.Text, document, parsed, interopDiagnostics); } - private static TypeCheckDiagnosticResult BuildTypeCheck( - CachedAnalysis analysis, - CancellationToken cancellationToken) + private static TypeCheckDiagnosticResult BuildTypeCheck(CachedAnalysis analysis, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var checker = new TypeChecker( - TypeCheckerOptions.Default with { MaxErrors = int.MaxValue }) - .WithFilePath(analysis.Document.Path) - .WithCancellation(cancellationToken); - return checker.CheckWithRecovery( - analysis.ParseResult.Statements, - analysis.Document); + var checker = new TypeChecker(TypeCheckerOptions.Default with { MaxErrors = int.MaxValue }) + .WithFilePath(analysis.Document.Path).WithCancellation(cancellationToken); + return checker.CheckWithRecovery(analysis.ParseResult.Statements, analysis.Document); } - private static CachedWorkspaceCheck BuildWorkspaceCheck( - DocumentRequestSnapshot workspace, - DocumentSnapshot snapshot, - CachedAnalysis analysis, - CancellationToken cancellationToken) + public void Dispose() { - cancellationToken.ThrowIfCancellationRequested(); - CheckedNavigationModel? model = NavigationModelBuilder.TryBuild( - snapshot.FilePath!, - snapshot.Text, - workspace.TextOverlay, - cancellationToken); - IReadOnlyList diagnostics; - if (model is null) - { - diagnostics = BuildTypeCheck( - analysis, - cancellationToken) - .Diagnostics; - } - else - { - diagnostics = model.Checker.GetDiagnostics() - .Where(diagnostic => - diagnostic.FilePath is null || - string.Equals( - Path.GetFullPath(diagnostic.FilePath), - snapshot.FilePath, - StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } - - return new CachedWorkspaceCheck( - snapshot.Version, - workspace.WorkspaceVersion, - diagnostics); + _cache.Clear(); + if (_ownsAnalysis) _analysis.Dispose(); } - internal sealed record CachedAnalysis( + private sealed record CachedAnalysis( int Version, + int MetadataGeneration, string Text, SourceDocument Document, - IReadOnlyList Tokens, ParseDiagnosticResult ParseResult, IReadOnlyList InteropDiagnostics) { + public object TypeCheckGate { get; } = new(); public TypeCheckDiagnosticResult? TypeCheckResult { get; set; } } - - private sealed record CachedWorkspaceCheck( - int DocumentVersion, - long WorkspaceVersion, - IReadOnlyList Diagnostics); } diff --git a/src/SharpTS.LanguageServer/Services/EditorRequestGuard.cs b/src/SharpTS.LanguageServer/Services/EditorRequestGuard.cs new file mode 100644 index 000000000..a0c271732 --- /dev/null +++ b/src/SharpTS.LanguageServer/Services/EditorRequestGuard.cs @@ -0,0 +1,45 @@ +using SharpTS.IO; +using SharpTS.LanguageServer.Project; + +namespace SharpTS.LanguageServer.Services; + +/// Captures interop/GUI inputs without starting general semantic analysis. +internal sealed class EditorRequestGuard : IDisposable +{ + private readonly ObservingCompilerFileSystem _inputs = new(); + private readonly IDisposable _fileScope; + private readonly AnalysisMetadataProvider? _metadata; + private readonly AnalysisMetadataView? _view; + private readonly IDisposable? _metadataScope; + private readonly DocumentStore _store; + private readonly DocumentRequestSnapshot _request; + + public EditorRequestGuard(DocumentStore store, DocumentRequestSnapshot request, + AnalysisMetadataProvider? metadata, CancellationToken cancellationToken) + { + _store = store; _request = request; _metadata = metadata; + _fileScope = CompilerFileSystem.Use(_inputs, cancellationToken); + try + { + _view = metadata?.Capture(); + _metadataScope = _view?.EnterScope(); + } + catch { _view?.Dispose(); _fileScope.Dispose(); throw; } + } + + public bool IsCurrent(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!_inputs.Complete().IsCurrent(cancellationToken)) return false; + using var physicalScope = CompilerFileSystem.Use(CompilerFileSystem.Physical, cancellationToken); + if (_metadata is not null && _metadata.RefreshGeneration() != _view!.Generation) return false; + return _store.IsCurrent(_request.Document.Uri, _request.Document.Version, _request.WorkspaceVersion); + } + + public void Dispose() + { + _metadataScope?.Dispose(); + _view?.Dispose(); + _fileScope.Dispose(); + } +} diff --git a/src/SharpTS.LanguageServer/Services/GuiContractService.cs b/src/SharpTS.LanguageServer/Services/GuiContractService.cs index 8e99206c3..b58d15214 100644 --- a/src/SharpTS.LanguageServer/Services/GuiContractService.cs +++ b/src/SharpTS.LanguageServer/Services/GuiContractService.cs @@ -2,6 +2,7 @@ using System.Text.RegularExpressions; using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using SharpTS.IO; namespace SharpTS.LanguageServer.Services; @@ -80,14 +81,21 @@ private sealed record Contract(string DeclarationPath, Control[] Controls); } public Location? Definition(string? documentPath, string text, int line, int character) + { + try { return FindDefinition(documentPath, text, line, character); } + catch (IOException) { return null; } + catch (UnauthorizedAccessException) { return null; } + } + + private static Location? FindDefinition(string? documentPath, string text, int line, int character) { Contract? contract = Load(documentPath); string? lineText = GetLine(text, line); - if (contract is null || lineText is null || !File.Exists(contract.DeclarationPath)) return null; + if (contract is null || lineText is null || !CompilerFileSystem.FileExists(contract.DeclarationPath)) return null; (string word, _, _) = WordAt(lineText, character); Control? control = contract.Controls.FirstOrDefault(item => item.Kind == word); if (control is null) return null; - string[] declarations = File.ReadAllLines(contract.DeclarationPath); + string[] declarations = CompilerFileSystem.ReadAllText(contract.DeclarationPath).Split('\n'); int declarationLine = Array.FindIndex(declarations, value => value.Contains($"export const {word} =", StringComparison.Ordinal)); if (declarationLine < 0) return null; int start = declarations[declarationLine].IndexOf(word, StringComparison.Ordinal); @@ -101,11 +109,11 @@ private sealed record Contract(string DeclarationPath, Control[] Controls); private static Contract? Load(string? documentPath) { - string? metadata = LocateMetadata(documentPath); - if (metadata is null) return null; try { - using JsonDocument json = JsonDocument.Parse(File.ReadAllText(metadata)); + string? metadata = LocateMetadata(documentPath); + if (metadata is null) return null; + using JsonDocument json = JsonDocument.Parse(CompilerFileSystem.ReadAllText(metadata)); var controls = json.RootElement.GetProperty("controls").EnumerateArray().Select(control => new Control( control.GetProperty("kind").GetString()!, control.GetProperty("documentation").GetString() ?? string.Empty, @@ -119,7 +127,10 @@ private sealed record Contract(string DeclarationPath, Control[] Controls); return new Contract(Path.Combine(Path.GetDirectoryName(metadata)!, "control-surface.generated.ts"), controls); } catch (IOException) { return null; } + catch (UnauthorizedAccessException) { return null; } catch (JsonException) { return null; } + catch (KeyNotFoundException) { return null; } + catch (InvalidOperationException) { return null; } } private static string? LocateMetadata(string? documentPath) @@ -128,41 +139,54 @@ private sealed record Contract(string DeclarationPath, Control[] Controls); string? directory = Path.GetDirectoryName(Path.GetFullPath(documentPath)); while (directory is not null) { - string[] projects = Directory.Exists(directory) - ? Directory.GetFiles(directory, "*.csproj", SearchOption.TopDirectoryOnly) + CompilerFileSystem.ThrowIfCancellationRequested(); + IReadOnlyList projects = CompilerFileSystem.DirectoryExists(directory) + ? CompilerFileSystem.EnumerateFiles(directory, "*.csproj", SearchOption.TopDirectoryOnly) : []; - string? guiProject = projects.FirstOrDefault(path => File.ReadAllText(path).Contains("SharpTS.Gui.Sdk", StringComparison.OrdinalIgnoreCase)); + string? guiProject = projects.FirstOrDefault(path => CompilerFileSystem.ReadAllText(path).Contains("SharpTS.Gui.Sdk", StringComparison.OrdinalIgnoreCase)); if (guiProject is not null) { string obj = Path.Combine(directory, "obj"); - if (Directory.Exists(obj)) + if (CompilerFileSystem.DirectoryExists(obj)) { - string? built = Directory.EnumerateFiles(obj, "control-docs.generated.json", SearchOption.AllDirectories) + string? built = CompilerFileSystem.EnumerateFiles(obj, "control-docs.generated.json", new EnumerationOptions + { RecurseSubdirectories = true, IgnoreInaccessible = true }) .FirstOrDefault(path => path.Contains("@sharpts", StringComparison.OrdinalIgnoreCase)); if (built is not null) return built; } - Match version = Regex.Match(File.ReadAllText(guiProject), @"SharpTS\.Gui\.Sdk/(?[^\""<]+)"); + Match version = Regex.Match(CompilerFileSystem.ReadAllText(guiProject), @"SharpTS\.Gui\.Sdk/(?[^\""<]+)"); if (version.Success) { string packages = Environment.GetEnvironmentVariable("NUGET_PACKAGES") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages"); string cached = Path.Combine(packages, "sharpts.gui.sdk", version.Groups["version"].Value, "gui", "control-docs.generated.json"); - if (File.Exists(cached)) return cached; + if (CompilerFileSystem.FileExists(cached)) return cached; } string? repository = directory; while (repository is not null) { string source = Path.Combine(repository, "src", "SharpTS.Gui.Sdk", "GuiPackage", "control-docs.generated.json"); - if (File.Exists(source)) return source; - repository = Path.GetDirectoryName(repository); + if (CompilerFileSystem.FileExists(source)) return source; + repository = AmbientParent(repository); } return null; } - directory = Path.GetDirectoryName(directory); + directory = AmbientParent(directory); } return null; } + private static string? AmbientParent(string directory) + { + string? parent = Path.GetDirectoryName(directory); + if (parent is null) return null; + string normalized = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parent)); + return new[] { Path.GetTempPath(), Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) } + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Any(path => string.Equals(normalized, Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)), + StringComparison.OrdinalIgnoreCase)) ? null : parent; + } + private static Hover Markdown(string value) => new() { Contents = new MarkedStringsOrMarkupContent(new MarkupContent { Kind = MarkupKind.Markdown, Value = value }), diff --git a/src/SharpTS.LanguageServer/Services/NavigationModelBuilder.cs b/src/SharpTS.LanguageServer/Services/NavigationModelBuilder.cs index aa0d5fa8e..3bb699cda 100644 --- a/src/SharpTS.LanguageServer/Services/NavigationModelBuilder.cs +++ b/src/SharpTS.LanguageServer/Services/NavigationModelBuilder.cs @@ -5,14 +5,18 @@ using SharpTS.Parsing; using SharpTS.TypeSystem; using SharpTS.TypeSystem.Exceptions; +using System.Collections.Immutable; using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace SharpTS.LanguageServer.Services; internal sealed record CheckedNavigationModel( - TypeChecker Checker, - SourceDocument Document, - NavigationGraphScope Scope); + AnalysisSnapshot Snapshot, + SourceDocument Document) +{ + public NavigationGraphScope Scope => Snapshot.Scope; + public FrozenBindingIndex Bindings => Snapshot.Bindings; +} /// /// Describes the bounded root set used to discover reverse navigation edges. @@ -36,7 +40,9 @@ internal static class NavigationModelBuilder string path, string text, IReadOnlyDictionary? openDocuments, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Action? beforeCheck = null, + bool hasPartialMetadata = false) { cancellationToken.ThrowIfCancellationRequested(); string absolutePath = Path.GetFullPath(path); @@ -69,7 +75,7 @@ internal static class NavigationModelBuilder overlay, configuredWorkspace, requireMembership: true, - cancellationToken); + cancellationToken, beforeCheck, hasPartialMetadata); if (configured.Model is not null) return configured.Model; } @@ -80,7 +86,7 @@ internal static class NavigationModelBuilder overlay, NavigationWorkspace.Unconfigured(absolutePath, configPath), requireMembership: false, - cancellationToken).Model; + cancellationToken, beforeCheck, hasPartialMetadata).Model; } public static CheckedNavigationWorkspace BuildWorkspace( @@ -88,7 +94,11 @@ public static CheckedNavigationWorkspace BuildWorkspace( string text, IReadOnlyDictionary? openDocuments, IReadOnlyList workspaceRoots, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Action? beforeCheck = null, + IReadOnlyList? reuseModels = null, + bool preserveCapturedOverlay = false, + bool hasPartialMetadata = false) { cancellationToken.ThrowIfCancellationRequested(); string absolutePath = Path.GetFullPath(path); @@ -96,6 +106,8 @@ public static CheckedNavigationWorkspace BuildWorkspace( absolutePath, text, openDocuments); + if (preserveCapturedOverlay && openDocuments?.ContainsKey(absolutePath) != true) + overlay.Remove(absolutePath); NavigationProjectCatalog catalog = NavigationProjectCatalog.Discover(workspaceRoots); var models = new List(); @@ -104,20 +116,29 @@ public static CheckedNavigationWorkspace BuildWorkspace( foreach (TsConfigResult project in catalog.Projects) { cancellationToken.ThrowIfCancellationRequested(); + CheckedNavigationModel? reused = reuseModels?.FirstOrDefault(model => + string.Equals(model.Scope.ConfigPath, project.ConfigPath, StringComparison.OrdinalIgnoreCase) && + model.Snapshot.TryGetDocument(absolutePath, out _)); + if (reused is not null && reused.Snapshot.TryGetDocument(absolutePath, out var reusedDocument)) + { + models.Add(new CheckedNavigationModel(reused.Snapshot, reusedDocument!.Document)); + isComplete &= reused.Scope.IsComplete; + continue; + } ProjectBuildResult result = TryBuildProject( absolutePath, overlay, NavigationWorkspace.FromProject(project), requireMembership: true, - cancellationToken); + cancellationToken, beforeCheck, hasPartialMetadata); isComplete &= result.IsComplete; if (result.Model is not null) models.Add(result.Model); } return new CheckedNavigationWorkspace( - models, - catalog.ConfigPaths, + models.ToImmutableArray(), + catalog.ConfigPaths.ToImmutableArray(), isComplete && models.Count > 0); } @@ -126,11 +147,15 @@ private static ProjectBuildResult TryBuildProject( IReadOnlyDictionary overlay, NavigationWorkspace workspace, bool requireMembership, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Action? beforeCheck, + bool hasPartialMetadata) { ModuleResolver resolver; List modulesToCheck; - CheckedNavigationModel model; + TypeChecker checker; + SourceDocument document; + NavigationGraphScope scope; bool isComplete; try { @@ -142,7 +167,8 @@ private static ProjectBuildResult TryBuildProject( virtualFilesFallBackToDisk: true) { JsxOptions = workspace.JsxOptions, - }; + RecoverParseErrors = true, + }.WithCancellation(cancellationToken); bool allRootsLoaded = true; List declarationRoots = []; @@ -151,9 +177,11 @@ private static ProjectBuildResult TryBuildProject( cancellationToken.ThrowIfCancellationRequested(); try { - declarationRoots.Add( - resolver.LoadModule(declarationPath, workspace.DecoratorMode)); + ParsedModule declaration = resolver.LoadModule(declarationPath, workspace.DecoratorMode); + declarationRoots.Add(declaration); + allRootsLoaded &= declaration.ParseDiagnostics.Count == 0; } + catch (OperationCanceledException) { throw; } catch { allRootsLoaded = false; @@ -172,9 +200,11 @@ private static ProjectBuildResult TryBuildProject( ParsedModule root = resolver.LoadProgram( rootPath, workspace.DecoratorMode); + allRootsLoaded &= root.ParseDiagnostics.Count == 0; if (!loadedRoots.Contains(root)) loadedRoots.Add(root); } + catch (OperationCanceledException) { throw; } catch { allRootsLoaded = false; @@ -187,8 +217,9 @@ private static ProjectBuildResult TryBuildProject( return new ProjectBuildResult(null, allRootsLoaded); entry ??= resolver.LoadProgram(absolutePath, workspace.DecoratorMode); - if (entry.Document is not { } document) + if (entry.Document is not { } entryDocument) return new ProjectBuildResult(null, IsComplete: false); + document = entryDocument; if (!loadedRoots.Contains(entry)) loadedRoots.Add(entry); @@ -217,6 +248,7 @@ private static ProjectBuildResult TryBuildProject( if (!loadedRoots.Contains(root)) loadedRoots.Add(root); } + catch (OperationCanceledException) { throw; } catch { // Configured roots, not unrelated dirty buffers, define completeness. @@ -227,23 +259,21 @@ private static ProjectBuildResult TryBuildProject( declarationRoots.Concat(loadedRoots)); HashSet component = FindConnectedComponent( entry, - loadedModules); + loadedModules, cancellationToken); List connectedRoots = loadedRoots .Where(component.Contains) .ToList(); - var checker = new TypeChecker(workspace.CheckerOptions) + checker = new TypeChecker(workspace.CheckerOptions) .WithFilePath(absolutePath) .WithCancellation(cancellationToken); checker.SetDecoratorMode(workspace.DecoratorMode); modulesToCheck = resolver.GetModulesInOrder(connectedRoots); - model = new CheckedNavigationModel( - checker, - document, - new NavigationGraphScope( + allRootsLoaded &= modulesToCheck.All(module => module.ParseDiagnostics.Count == 0); + scope = new NavigationGraphScope( workspace.ConfigPath, workspace.RootFiles, - workspace.IsConfigured && isMember && allRootsLoaded)); + workspace.IsConfigured && isMember && allRootsLoaded); isComplete = workspace.IsConfigured && allRootsLoaded; } catch (OperationCanceledException) @@ -257,9 +287,12 @@ private static ProjectBuildResult TryBuildProject( // The checker already recovers expected source errors. Internal checking failures must // reach the host's observation boundary instead of silently triggering a fallback model. + TypeMap typeMap; try { - model.Checker.CheckModules(modulesToCheck, resolver); + beforeCheck?.Invoke(); + cancellationToken.ThrowIfCancellationRequested(); + typeMap = checker.CheckModules(modulesToCheck, resolver); } catch (TypeCheckException) { @@ -267,16 +300,28 @@ private static ProjectBuildResult TryBuildProject( return new ProjectBuildResult(null, IsComplete: false); } cancellationToken.ThrowIfCancellationRequested(); - return new ProjectBuildResult(model, isComplete); + var documents = modulesToCheck.Where(module => module.Document is not null) + .Select(module => new AnalysisDocument(module.Document!, module.Tokens, + module.Statements, module.ParseDiagnostics, module.HitParseErrorLimit)).ToArray(); + var snapshot = new AnalysisSnapshot(checker.Bindings.Freeze(), typeMap, documents, + checker.GetDiagnostics(), scope, + hasPartialSemantics: hasPartialMetadata || checker.GetDiagnostics().Any(diagnostic => + diagnostic.Severity == SharpTS.Diagnostics.DiagnosticSeverity.Error), + options: new AnalysisOptions(workspace.DecoratorMode, workspace.JsxOptions, workspace.CheckerOptions), + dependencies: modulesToCheck.SelectMany(module => module.Dependencies.Concat(module.ReferencedScripts) + .Select(dependency => new AnalysisDependency(module.Path, dependency.Path))).ToArray()); + return new ProjectBuildResult(new CheckedNavigationModel(snapshot, document), isComplete); } private static HashSet FindConnectedComponent( ParsedModule entry, - IReadOnlyList modules) + IReadOnlyList modules, + CancellationToken cancellationToken) { var reverseEdges = new Dictionary>(); foreach (var module in modules) { + cancellationToken.ThrowIfCancellationRequested(); foreach (var dependency in module.Dependencies.Concat(module.ReferencedScripts)) { if (!reverseEdges.TryGetValue(dependency, out var importers)) @@ -293,6 +338,7 @@ private static HashSet FindConnectedComponent( while (pending.TryDequeue(out var current)) { + cancellationToken.ThrowIfCancellationRequested(); foreach (var adjacent in current.Dependencies.Concat(current.ReferencedScripts)) { if (component.Add(adjacent)) diff --git a/src/SharpTS.LanguageServer/Services/NavigationProjectCatalog.cs b/src/SharpTS.LanguageServer/Services/NavigationProjectCatalog.cs index 7024927a5..fb52412e3 100644 --- a/src/SharpTS.LanguageServer/Services/NavigationProjectCatalog.cs +++ b/src/SharpTS.LanguageServer/Services/NavigationProjectCatalog.cs @@ -1,3 +1,4 @@ +using SharpTS.IO; using SharpTS.Configuration; namespace SharpTS.LanguageServer.Services; @@ -43,6 +44,7 @@ public static NavigationProjectCatalog Discover( while (pending.TryDequeue(out string? configPath)) { + CompilerFileSystem.ThrowIfCancellationRequested(); string full = Path.GetFullPath(configPath); if (!visited.Add(full)) continue; @@ -54,6 +56,10 @@ public static NavigationProjectCatalog Discover( project = TsConfigLoader.Load(full); projects.Add(project); } + catch (OperationCanceledException) + { + throw; + } catch { isComplete = false; @@ -85,10 +91,11 @@ private static (IReadOnlyList Paths, bool IsComplete) FindConfigPaths( while (pending.TryDequeue(out string? directory)) { + CompilerFileSystem.ThrowIfCancellationRequested(); string fullDirectory = Path.GetFullPath(directory); if (!visitedDirectories.Add(fullDirectory)) continue; - if (!Directory.Exists(fullDirectory)) + if (!CompilerFileSystem.DirectoryExists(fullDirectory)) { isComplete = false; continue; @@ -96,7 +103,7 @@ private static (IReadOnlyList Paths, bool IsComplete) FindConfigPaths( try { - foreach (string config in Directory.EnumerateFiles( + foreach (string config in CompilerFileSystem.EnumerateFiles( fullDirectory, TsConfigLoader.FileName, SearchOption.TopDirectoryOnly)) @@ -104,14 +111,14 @@ private static (IReadOnlyList Paths, bool IsComplete) FindConfigPaths( paths.Add(Path.GetFullPath(config)); } - foreach (string child in Directory.EnumerateDirectories( + foreach (string child in CompilerFileSystem.EnumerateDirectories( fullDirectory, "*", SearchOption.TopDirectoryOnly)) { if (SkippedDirectoryNames.Contains(Path.GetFileName(child))) continue; - if ((File.GetAttributes(child) & FileAttributes.ReparsePoint) != 0) + if ((CompilerFileSystem.GetAttributes(child) & FileAttributes.ReparsePoint) != 0) continue; pending.Enqueue(child); } diff --git a/src/SharpTS.LanguageServer/Services/NavigationWorkspaceContext.cs b/src/SharpTS.LanguageServer/Services/NavigationWorkspaceContext.cs index 5517d174a..c848e4832 100644 --- a/src/SharpTS.LanguageServer/Services/NavigationWorkspaceContext.cs +++ b/src/SharpTS.LanguageServer/Services/NavigationWorkspaceContext.cs @@ -8,6 +8,8 @@ namespace SharpTS.LanguageServer.Services; public sealed class NavigationWorkspaceContext { private string[] _roots = []; + private long _version; + public long Version => Volatile.Read(ref _version); public IReadOnlyList SnapshotRoots() => [.. Volatile.Read(ref _roots)]; @@ -25,6 +27,16 @@ public void Initialize(InitializeParams request) .Distinct(StringComparer.OrdinalIgnoreCase) .Order(StringComparer.OrdinalIgnoreCase) .ToArray()); + Interlocked.Increment(ref _version); + } + + public void Change(IEnumerable added, IEnumerable removed) + { + var roots = SnapshotRoots().ToHashSet(StringComparer.OrdinalIgnoreCase); + roots.ExceptWith(removed.Select(Path.GetFullPath)); + roots.UnionWith(added.Select(Path.GetFullPath)); + Volatile.Write(ref _roots, roots.Order(StringComparer.OrdinalIgnoreCase).ToArray()); + Interlocked.Increment(ref _version); } private static IEnumerable RootFallback(InitializeParams request) diff --git a/src/SharpTS.LanguageServer/Services/ObservedCompilerInputs.cs b/src/SharpTS.LanguageServer/Services/ObservedCompilerInputs.cs new file mode 100644 index 000000000..f560f778d --- /dev/null +++ b/src/SharpTS.LanguageServer/Services/ObservedCompilerInputs.cs @@ -0,0 +1,151 @@ +using SharpTS.IO; + +namespace SharpTS.LanguageServer.Services; + +/// +/// Memoizes every disk read/probe made by one build. Repeated reads see the same captured value; +/// publication and reuse compare those values with disk, including failed resolution probes. +/// +internal sealed class ObservingCompilerFileSystem : ICompilerFileSystem +{ + private readonly Dictionary _inputs = []; + private bool _canCache = true; + + public bool FileExists(string path) => Capture( + new(InputKind.File, Normalize(path)), () => CompilerFileSystem.Physical.FileExists(path)); + + public bool DirectoryExists(string path) => Capture( + new(InputKind.Directory, Normalize(path)), () => CompilerFileSystem.Physical.DirectoryExists(path)); + + public string ReadAllText(string path) => Capture( + new(InputKind.Text, Normalize(path)), () => CompilerFileSystem.Physical.ReadAllText(path)); + + public FileAttributes GetAttributes(string path) => Capture( + new(InputKind.Attributes, Normalize(path)), () => CompilerFileSystem.Physical.GetAttributes(path)); + + public IReadOnlyList EnumerateFiles(string path, string searchPattern, EnumerationOptions options) => + Enumerate(InputKind.Files, path, searchPattern, options); + + public IReadOnlyList EnumerateDirectories(string path, string searchPattern, EnumerationOptions options) => + Enumerate(InputKind.Directories, path, searchPattern, options); + + public ObservedCompilerInputs Complete() => new(_inputs.Values.ToArray(), _canCache); + + public void Include(ObservedCompilerInputs inputs) + { + foreach (Observation observation in inputs.Observations) + { + if (_inputs.TryGetValue(observation.Key, out Observation? existing) && + !existing.SameValue(observation)) + { + // A reused semantic component and this build observed different disk states. + throw new AnalysisInputsChangedException(); + } + _inputs[observation.Key] = observation; + } + _canCache &= inputs.CanCache; + } + + private IReadOnlyList Enumerate( + InputKind kind, string path, string pattern, EnumerationOptions options) + { + var policy = EnumerationPolicy.From(options); + return Capture(new(kind, Normalize(path), pattern, policy), () => + { + IReadOnlyList values = kind == InputKind.Files + ? CompilerFileSystem.Physical.EnumerateFiles(path, pattern, policy.Create()) + : CompilerFileSystem.Physical.EnumerateDirectories(path, pattern, policy.Create()); + return Array.AsReadOnly(values.Order(StringComparer.Ordinal).ToArray()); + }); + } + + private T Capture(InputKey key, Func read) where T : notnull + { + CompilerFileSystem.ThrowIfCancellationRequested(); + if (_inputs.TryGetValue(key, out Observation? previous)) + return (T)previous.Value; + try + { + T value = read(); + _inputs.Add(key, new Observation(key, value, () => read())); + return value; + } + catch (OperationCanceledException) { throw; } + catch + { + // A recovered access/IO failure must never become a permanently cached assumption. + _canCache = false; + throw; + } + } + + private static string Normalize(string path) + { + string absolute = Path.GetFullPath(path); + return OperatingSystem.IsWindows() ? absolute.ToUpperInvariant() : absolute; + } + + internal enum InputKind { File, Directory, Text, Attributes, Files, Directories } + internal sealed record InputKey(InputKind Kind, string Path, string? Pattern = null, EnumerationPolicy? Policy = null); + + internal sealed record EnumerationPolicy( + bool Recurse, bool IgnoreInaccessible, FileAttributes Skip, MatchType MatchType, + MatchCasing MatchCasing, bool ReturnSpecial, int MaxDepth, int BufferSize) + { + public static EnumerationPolicy From(EnumerationOptions options) => new( + options.RecurseSubdirectories, options.IgnoreInaccessible, options.AttributesToSkip, + options.MatchType, options.MatchCasing, options.ReturnSpecialDirectories, + options.MaxRecursionDepth, options.BufferSize); + + public EnumerationOptions Create() => new() + { + RecurseSubdirectories = Recurse, IgnoreInaccessible = IgnoreInaccessible, + AttributesToSkip = Skip, MatchType = MatchType, MatchCasing = MatchCasing, + ReturnSpecialDirectories = ReturnSpecial, MaxRecursionDepth = MaxDepth, BufferSize = BufferSize, + }; + } + + internal sealed record Observation(InputKey Key, object Value, Func ReadCurrent) + { + public bool IsCurrent() => Equal(Value, ReadCurrent()); + public bool SameValue(Observation other) => Equal(Value, other.Value); + private static bool Equal(object left, object right) => + left is IReadOnlyList paths && right is IReadOnlyList otherPaths + ? paths.SequenceEqual(otherPaths, StringComparer.Ordinal) + : left.Equals(right); + } +} + +internal sealed class ObservedCompilerInputs( + IReadOnlyList observations, + bool canCache) +{ + internal IReadOnlyList Observations { get; } = observations; + public bool CanCache { get; } = canCache; + public int Count => Observations.Count; + public long EstimatedBytes => Observations.Sum(observation => observation.Value switch + { + string text => (long)text.Length * sizeof(char), + IReadOnlyList paths => paths.Sum(path => (long)path.Length * sizeof(char)), + _ => 32L, + }); + + public bool IsCurrent(CancellationToken cancellationToken = default) + { + try + { + using var scope = CompilerFileSystem.Use(CompilerFileSystem.Physical, cancellationToken); + foreach (var observation in Observations) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!observation.IsCurrent()) return false; + } + return true; + } + catch (OperationCanceledException) { throw; } + catch (IOException) { return false; } + catch (UnauthorizedAccessException) { return false; } + } +} + +internal sealed class AnalysisInputsChangedException : Exception { } diff --git a/src/SharpTS.LanguageServer/Services/ReferenceService.cs b/src/SharpTS.LanguageServer/Services/ReferenceService.cs index 4758109ef..0aeab901b 100644 --- a/src/SharpTS.LanguageServer/Services/ReferenceService.cs +++ b/src/SharpTS.LanguageServer/Services/ReferenceService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using OmniSharp.Extensions.LanguageServer.Protocol.Models; using SharpTS.TypeSystem; @@ -6,228 +7,176 @@ namespace SharpTS.LanguageServer.Services; internal sealed record NavigationReferenceResult( IReadOnlyList Locations, IReadOnlyList ConfigPaths, - bool IsComplete); + bool IsComplete, + IReadOnlyList? Validations = null) +{ + public bool IsCurrent(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Validations is null || Validations.All(validation => validation.IsCurrent(cancellationToken)); + } +} -/// -/// Resolves a source position to every checker-bound occurrence of the same semantic symbol. -/// -public sealed class ReferenceService +/// Finds every checker-bound occurrence of the selected semantic symbol. +public sealed class ReferenceService : IDisposable { + private readonly SemanticAnalysisService _analysis; + private readonly bool _ownsAnalysis; + + public ReferenceService(SemanticAnalysisService? analysis = null) + { + _analysis = analysis ?? new SemanticAnalysisService(); + _ownsAnalysis = analysis is null; + } + public IReadOnlyList FindReferences( - string path, - string text, - Position position, - bool includeDeclaration, + string path, string text, Position position, bool includeDeclaration, IReadOnlyDictionary? openDocuments = null, - IReadOnlyList? workspaceRoots = null) - => FindReferenceResult( - path, - text, - position, - includeDeclaration, - openDocuments, - workspaceRoots).Locations; + IReadOnlyList? workspaceRoots = null) => + FindReferenceResult(path, text, position, includeDeclaration, openDocuments, workspaceRoots).Locations; + [SuppressMessage("Usage", "VSTHRD002", Justification = "Compatibility wrapper for existing synchronous callers.")] internal NavigationReferenceResult FindReferenceResult( - string path, - string text, - Position position, - bool includeDeclaration, + string path, string text, Position position, bool includeDeclaration, IReadOnlyDictionary? openDocuments = null, - IReadOnlyList? workspaceRoots = null, - bool includeDeclarationFacets = false) + IReadOnlyList? workspaceRoots = null, bool includeDeclarationFacets = false) + { + NavigationReferenceResult result = FindLegacyAsync(path, text, position, includeDeclaration, + openDocuments, workspaceRoots, includeDeclarationFacets).GetAwaiter().GetResult(); + return result.IsCurrent() ? result : result with { Locations = [], IsComplete = false }; + } + + internal async Task FindReferenceResultAsync( + DocumentRequestSnapshot capture, Position position, bool includeDeclaration, + IReadOnlyList? workspaceRoots = null, bool includeDeclarationFacets = false, + CancellationToken cancellationToken = default) { - if (NavigationModelBuilder.TryBuild(path, text, openDocuments) is not { } model) + if (capture.Document.FilePath is null) return new NavigationReferenceResult([], [], IsComplete: false); + using AnalysisLease? seed = await _analysis.GetDocumentAsync(capture, cancellationToken).ConfigureAwait(false); + return await FindCoreAsync(seed, position, includeDeclaration, workspaceRoots, includeDeclarationFacets, + (anchor, retainedSeed, ct) => _analysis.GetWorkspaceAsync(capture, anchor, retainedSeed, + workspaceRoots!, ct), cancellationToken).ConfigureAwait(false); + } + + private async Task FindLegacyAsync( + string path, string text, Position position, bool includeDeclaration, + IReadOnlyDictionary? openDocuments, + IReadOnlyList? workspaceRoots, bool includeDeclarationFacets) + { + using AnalysisLease? seed = await _analysis.GetDocumentAsync(path, text, openDocuments).ConfigureAwait(false); + return await FindCoreAsync(seed, position, includeDeclaration, workspaceRoots, includeDeclarationFacets, + (anchor, retainedSeed, ct) => _analysis.GetWorkspaceAsync(path, text, openDocuments, anchor, + retainedSeed, workspaceRoots!, ct), CancellationToken.None).ConfigureAwait(false); + } - int offset = model.Document.Lines.ToOffset( - (int)position.Line + 1, - (int)position.Character + 1); - IReadOnlyList selectedSymbols = - model.Checker.Bindings.FindSymbols(model.Document, offset); + private static async Task FindCoreAsync( + AnalysisLease? seed, Position position, bool includeDeclaration, + IReadOnlyList? workspaceRoots, bool includeDeclarationFacets, + Func> buildWorkspace, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (seed is null) return new NavigationReferenceResult([], [], IsComplete: false); + CheckedNavigationModel model = seed.Model; + var validations = new List { seed.Validation }; + int offset = model.Document.Lines.ToOffset((int)position.Line + 1, (int)position.Character + 1); + IReadOnlyList selectedSymbols = model.Bindings.FindSymbols(model.Document, offset); if (includeDeclarationFacets) { - var expandedSymbols = new HashSet( - selectedSymbols, - ReferenceEqualityComparer.Instance); - foreach (BindingSymbol symbol in selectedSymbols) + var expandedSymbols = new HashSet(selectedSymbols, ReferenceEqualityComparer.Instance); + foreach (FrozenBindingSymbol symbol in selectedSymbols) { + cancellationToken.ThrowIfCancellationRequested(); foreach (BindingDeclaration declaration in symbol.Declarations) - { - expandedSymbols.UnionWith( - model.Checker.Bindings.FindSymbols( - declaration.Document, - declaration.Name.Start)); - } + expandedSymbols.UnionWith(model.Bindings.FindSymbols(declaration.Document, declaration.Name.Start)); } - selectedSymbols = expandedSymbols.ToArray(); } if (selectedSymbols.Count == 0) - { - return new NavigationReferenceResult( - [], - model.Scope.ConfigPath is null ? [] : [model.Scope.ConfigPath], - model.Scope.IsComplete); - } + return new NavigationReferenceResult([], model.Scope.ConfigPath is null ? [] : [model.Scope.ConfigPath], + model.Scope.IsComplete, validations); var locations = new Dictionary(); - AddOccurrences( - locations, - model.Checker.Bindings.FindReferences( - selectedSymbols, - includeDeclaration)); - + AddOccurrences(locations, model.Bindings.FindReferences(selectedSymbols, includeDeclaration), cancellationToken); if (workspaceRoots is not { Count: > 0 }) - { - return new NavigationReferenceResult( - Sort(locations.Values), - model.Scope.ConfigPath is null ? [] : [model.Scope.ConfigPath], - model.Scope.IsComplete); - } + return new NavigationReferenceResult(Sort(locations.Values), + model.Scope.ConfigPath is null ? [] : [model.Scope.ConfigPath], model.Scope.IsComplete, validations); - BindingAnchor[] anchors = selectedSymbols - .SelectMany(symbol => symbol.Declarations.Select(declaration => - new BindingAnchor( - Path.GetFullPath(declaration.Document.Path), - declaration.Name.Start, - symbol.Namespace))) - .Distinct() - .ToArray(); - bool isComplete = anchors.Length > 0 && - anchors.All(anchor => workspaceRoots.Any( - root => IsWithin(anchor.Path, root))); + BindingAnchor[] anchors = selectedSymbols.SelectMany(symbol => symbol.Declarations.Select(declaration => + new BindingAnchor(Path.GetFullPath(declaration.Document.Path), declaration.Name.Start, symbol.Namespace))) + .Distinct().ToArray(); + bool isComplete = anchors.Length > 0 && anchors.All(anchor => workspaceRoots.Any(root => IsWithin(anchor.Path, root))); var configPaths = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var pathAnchors in anchors.GroupBy( - anchor => anchor.Path, - StringComparer.OrdinalIgnoreCase)) + foreach (var pathAnchors in anchors.GroupBy(anchor => anchor.Path, StringComparer.OrdinalIgnoreCase)) { - string anchorPath = pathAnchors.Key; - string? anchorText = ReadText(anchorPath, openDocuments); - if (anchorText is null) + cancellationToken.ThrowIfCancellationRequested(); + using AnalysisLease? workspaceLease = await buildWorkspace(pathAnchors.Key, seed, cancellationToken) + .ConfigureAwait(false); + if (workspaceLease?.Workspace is not { } workspace) { isComplete = false; continue; } - - CheckedNavigationWorkspace workspace = - NavigationModelBuilder.BuildWorkspace( - anchorPath, - anchorText, - openDocuments, - workspaceRoots); + validations.Add(workspaceLease.Validation); isComplete &= workspace.IsComplete; configPaths.UnionWith(workspace.ConfigPaths); - foreach (CheckedNavigationModel projectModel in workspace.Models) { - var matchingSymbols = new HashSet( - ReferenceEqualityComparer.Instance); + cancellationToken.ThrowIfCancellationRequested(); + var matchingSymbols = new HashSet(ReferenceEqualityComparer.Instance); foreach (BindingAnchor anchor in pathAnchors) { - foreach (BindingSymbol candidate in - projectModel.Checker.Bindings.FindSymbols( - projectModel.Document, - anchor.Offset)) + cancellationToken.ThrowIfCancellationRequested(); + foreach (FrozenBindingSymbol candidate in projectModel.Bindings.FindSymbols(projectModel.Document, anchor.Offset)) { - if (candidate.Namespace == anchor.Namespace && - candidate.Declarations.Any(declaration => - declaration.Name.Start == anchor.Offset && - string.Equals( - Path.GetFullPath(declaration.Document.Path), - anchor.Path, - StringComparison.OrdinalIgnoreCase))) - { + if (candidate.Namespace == anchor.Namespace && candidate.Declarations.Any(declaration => + declaration.Name.Start == anchor.Offset && string.Equals( + Path.GetFullPath(declaration.Document.Path), anchor.Path, StringComparison.OrdinalIgnoreCase))) matchingSymbols.Add(candidate); - } } } - - AddOccurrences( - locations, - projectModel.Checker.Bindings.FindReferences( - matchingSymbols.ToArray(), - includeDeclaration)); + AddOccurrences(locations, projectModel.Bindings.FindReferences(matchingSymbols.ToArray(), includeDeclaration), + cancellationToken); } } - - return new NavigationReferenceResult( - Sort(locations.Values), - configPaths.Order(StringComparer.OrdinalIgnoreCase).ToArray(), - isComplete); + return new NavigationReferenceResult(Sort(locations.Values), + configPaths.Order(StringComparer.OrdinalIgnoreCase).ToArray(), isComplete, validations.Distinct().ToArray()); } - private static void AddOccurrences( - Dictionary locations, - IReadOnlyList occurrences) + private static void AddOccurrences(Dictionary locations, + IReadOnlyList occurrences, CancellationToken cancellationToken) { foreach (BindingOccurrence occurrence in occurrences) { - Location location = NavigationLocations.From( - occurrence.Document, - occurrence.Name); + cancellationToken.ThrowIfCancellationRequested(); + Location location = NavigationLocations.From(occurrence.Document, occurrence.Name); locations[LocationKey.From(location)] = location; } } private static IReadOnlyList Sort(IEnumerable locations) => - locations - .OrderBy(location => location.Uri.ToString(), StringComparer.OrdinalIgnoreCase) - .ThenBy(location => location.Range.Start.Line) - .ThenBy(location => location.Range.Start.Character) - .ToArray(); - - private static string? ReadText( - string path, - IReadOnlyDictionary? openDocuments) + locations.OrderBy(location => location.Uri.ToString(), StringComparer.OrdinalIgnoreCase) + .ThenBy(location => location.Range.Start.Line).ThenBy(location => location.Range.Start.Character).ToArray(); + + private static bool IsWithin(string path, string root) { - if (openDocuments is not null) - { - foreach (var (documentPath, documentText) in openDocuments) - { - if (string.Equals( - Path.GetFullPath(documentPath), - path, - StringComparison.OrdinalIgnoreCase)) - { - return documentText; - } - } - } - return File.Exists(path) ? File.ReadAllText(path) : null; + string relative = Path.GetRelativePath(Path.GetFullPath(root), Path.GetFullPath(path)); + return !Path.IsPathRooted(relative) && !relative.Equals("..", StringComparison.Ordinal) && + !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal); } - private static bool IsWithin(string path, string root) + public void Dispose() { - string relative = Path.GetRelativePath( - Path.GetFullPath(root), - Path.GetFullPath(path)); - return !Path.IsPathRooted(relative) && - !relative.Equals("..", StringComparison.Ordinal) && - !relative.StartsWith( - ".." + Path.DirectorySeparatorChar, - StringComparison.Ordinal); + if (_ownsAnalysis) _analysis.Dispose(); } - private sealed record BindingAnchor( - string Path, - int Offset, - BindingNamespace Namespace); - - private readonly record struct LocationKey( - string Uri, - int StartLine, - int StartCharacter, - int EndLine, - int EndCharacter) + private sealed record BindingAnchor(string Path, int Offset, BindingNamespace Namespace); + + private readonly record struct LocationKey(string Uri, int StartLine, int StartCharacter, int EndLine, int EndCharacter) { - public static LocationKey From(Location location) => - new( - location.Uri.ToString(), - (int)location.Range.Start.Line, - (int)location.Range.Start.Character, - (int)location.Range.End.Line, - (int)location.Range.End.Character); + public static LocationKey From(Location location) => new(location.Uri.ToString(), + (int)location.Range.Start.Line, (int)location.Range.Start.Character, + (int)location.Range.End.Line, (int)location.Range.End.Character); } -} +} \ No newline at end of file diff --git a/src/SharpTS.LanguageServer/Services/RenameService.cs b/src/SharpTS.LanguageServer/Services/RenameService.cs index bbede8297..ecffe951e 100644 --- a/src/SharpTS.LanguageServer/Services/RenameService.cs +++ b/src/SharpTS.LanguageServer/Services/RenameService.cs @@ -5,133 +5,98 @@ namespace SharpTS.LanguageServer.Services; -/// -/// Produces semantic workspace renames only when the complete affected project graph is known. -/// +/// Produces renames only when the complete affected project graph is known. public sealed class RenameService { private readonly ReferenceService _references; + public RenameService(ReferenceService references) => _references = references; - public RenameService(ReferenceService references) + public LspRange? Prepare(string path, string text, Position position, + IReadOnlyDictionary? openDocuments = null, IReadOnlyList? workspaceRoots = null) { - _references = references; + NavigationReferenceResult result = _references.FindReferenceResult(path, text, position, + includeDeclaration: true, openDocuments, workspaceRoots, includeDeclarationFacets: true); + return result.IsCurrent() ? PrepareValue(path, position, result) : null; } - public LspRange? Prepare( - string path, - string text, - Position position, - IReadOnlyDictionary? openDocuments = null, - IReadOnlyList? workspaceRoots = null) + internal async Task<(LspRange? Value, NavigationReferenceResult Domain)> PrepareAsync( + DocumentRequestSnapshot capture, Position position, IReadOnlyList? workspaceRoots, + CancellationToken cancellationToken) { - NavigationReferenceResult result = FindCompleteDomain( - path, - text, - position, - openDocuments, - workspaceRoots); - if (!result.IsComplete || result.Locations.Count == 0) - return null; + NavigationReferenceResult result = await _references.FindReferenceResultAsync(capture, position, + includeDeclaration: true, workspaceRoots, includeDeclarationFacets: true, cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return (capture.Document.FilePath is null ? null : PrepareValue(capture.Document.FilePath, position, result), result); + } - DocumentUri currentUri = DocumentUri.FromFileSystemPath( - Path.GetFullPath(path)); - return result.Locations - .Where(location => location.Uri == currentUri) - .Select(location => location.Range) - .FirstOrDefault(range => Contains(range, position)); + public WorkspaceEdit? Rename(string path, string text, Position position, string newName, + IReadOnlyDictionary? openDocuments = null, IReadOnlyList? workspaceRoots = null) + { + if (!IsBindingIdentifier(newName, CancellationToken.None)) return null; + NavigationReferenceResult result = _references.FindReferenceResult(path, text, position, + includeDeclaration: true, openDocuments, workspaceRoots, includeDeclarationFacets: true); + return result.IsCurrent() ? RenameValue(newName, result) : null; } - public WorkspaceEdit? Rename( - string path, - string text, - Position position, - string newName, - IReadOnlyDictionary? openDocuments = null, - IReadOnlyList? workspaceRoots = null) + internal async Task<(WorkspaceEdit? Value, NavigationReferenceResult Domain)> RenameAsync( + DocumentRequestSnapshot capture, Position position, string newName, + IReadOnlyList? workspaceRoots, CancellationToken cancellationToken) { - if (!IsBindingIdentifier(newName)) - return null; + if (!IsBindingIdentifier(newName, cancellationToken)) + return (null, new NavigationReferenceResult([], [], IsComplete: false)); + NavigationReferenceResult result = await _references.FindReferenceResultAsync(capture, position, + includeDeclaration: true, workspaceRoots, includeDeclarationFacets: true, cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return (RenameValue(newName, result), result); + } - NavigationReferenceResult result = FindCompleteDomain( - path, - text, - position, - openDocuments, - workspaceRoots); - if (!result.IsComplete || result.Locations.Count == 0) - return null; + private static LspRange? PrepareValue(string path, Position position, NavigationReferenceResult result) + { + if (!result.IsComplete || result.Locations.Count == 0) return null; + DocumentUri currentUri = DocumentUri.FromFileSystemPath(Path.GetFullPath(path)); + return result.Locations.Where(location => location.Uri == currentUri) + .Select(location => location.Range).FirstOrDefault(range => Contains(range, position)); + } + private static WorkspaceEdit? RenameValue(string newName, NavigationReferenceResult result) + { + if (!result.IsComplete || result.Locations.Count == 0) return null; return new WorkspaceEdit { - Changes = result.Locations - .GroupBy(location => location.Uri) - .ToDictionary( - group => group.Key, - group => group - .OrderByDescending(location => location.Range.Start.Line) - .ThenByDescending(location => location.Range.Start.Character) - .Select(location => new TextEdit - { - Range = location.Range, - NewText = newName, - }) - .AsEnumerable()), + Changes = result.Locations.GroupBy(location => location.Uri).ToDictionary(group => group.Key, + group => group.OrderByDescending(location => location.Range.Start.Line) + .ThenByDescending(location => location.Range.Start.Character).Select(location => new TextEdit + { + Range = location.Range, + NewText = newName, + }).AsEnumerable()), }; } - private NavigationReferenceResult FindCompleteDomain( - string path, - string text, - Position position, - IReadOnlyDictionary? openDocuments, - IReadOnlyList? workspaceRoots) - => _references.FindReferenceResult( - path, - text, - position, - includeDeclaration: true, - openDocuments, - workspaceRoots, - includeDeclarationFacets: true); - private static bool Contains(LspRange range, Position position) { - bool startsBefore = - position.Line > range.Start.Line || - position.Line == range.Start.Line && - position.Character >= range.Start.Character; - bool endsAfter = - position.Line < range.End.Line || - position.Line == range.End.Line && - position.Character < range.End.Character; + bool startsBefore = position.Line > range.Start.Line || + position.Line == range.Start.Line && position.Character >= range.Start.Character; + bool endsAfter = position.Line < range.End.Line || + position.Line == range.End.Line && position.Character < range.End.Character; return startsBefore && endsAfter; } - private static bool IsBindingIdentifier(string candidate) + private static bool IsBindingIdentifier(string candidate, CancellationToken cancellationToken) { - if (string.IsNullOrEmpty(candidate)) - return false; - + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrEmpty(candidate)) return false; try { - List statements = new Parser( - new Lexer($"let {candidate};").ScanTokens()) - .ParseOrThrow(); - return statements is - [ - Stmt.Var - { - Name.Lexeme: var parsedName, - Initializer: null, - }, - ] && string.Equals( - parsedName, - candidate, - StringComparison.Ordinal); - } - catch - { - return false; + List statements = new Parser(new Lexer($"let {candidate};") + .WithCancellation(cancellationToken).ScanTokens()) + .WithCancellation(cancellationToken).ParseOrThrow(); + return statements is [Stmt.Var { Name.Lexeme: var parsedName, Initializer: null }] && + string.Equals(parsedName, candidate, StringComparison.Ordinal); } + catch (OperationCanceledException) { throw; } + catch { return false; } } } diff --git a/src/SharpTS.LanguageServer/Services/SemanticAnalysisService.cs b/src/SharpTS.LanguageServer/Services/SemanticAnalysisService.cs new file mode 100644 index 000000000..16c705d1a --- /dev/null +++ b/src/SharpTS.LanguageServer/Services/SemanticAnalysisService.cs @@ -0,0 +1,465 @@ +using System.Security.Cryptography; +using System.Text; +using SharpTS.Configuration; +using SharpTS.IO; +using SharpTS.LanguageServer.Project; + +namespace SharpTS.LanguageServer.Services; + +internal sealed record AnalysisStatistics(long Builds, long Checks, long CacheHits, + int RetainedSnapshots, long EstimatedRetainedBytes); + +/// +/// A bounded cache of completed, build-owned semantic components. A caller cancels its wait; +/// only workspace invalidation or disposal cancels shared work. Every reuse verifies the exact +/// observed filesystem inputs, including missing resolution candidates and directory inventories. +/// +public sealed class SemanticAnalysisService : IDisposable +{ + private readonly object _gate = new(); + private readonly NavigationWorkspaceContext _workspace; + private readonly AnalysisMetadataProvider? _metadata; + private readonly int _maxSnapshots; + private readonly long _maxRetainedBytes; + private readonly SemaphoreSlim _buildSlots = new(2); + private readonly SemaphoreSlim _admission = new(16); + private readonly Dictionary _inflight = []; + private readonly Dictionary _cache = []; + private readonly LinkedList _lru = []; + private long _generation, _builds, _checks, _hits, _bytes; + private bool _disposed; + + public SemanticAnalysisService(NavigationWorkspaceContext? workspace = null, + AnalysisMetadataProvider? metadata = null, int maxSnapshots = 8, + long maxRetainedBytes = 64 * 1024 * 1024) + { + ArgumentOutOfRangeException.ThrowIfNegative(maxSnapshots); + ArgumentOutOfRangeException.ThrowIfNegative(maxRetainedBytes); + _workspace = workspace ?? new NavigationWorkspaceContext(); + _metadata = metadata; + _maxSnapshots = maxSnapshots; + _maxRetainedBytes = maxRetainedBytes; + } + + internal Action? BeforeCheck { get; set; } + internal AnalysisStatistics Statistics + { + get { lock (_gate) return new(_builds, _checks, _hits, _lru.Count, _bytes); } + } + + internal Task GetDocumentAsync(DocumentRequestSnapshot request, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return request.Document.FilePath is null ? Task.FromResult(null) : + GetAsync(AnalysisRequest.From(request, _workspace.SnapshotRoots()), null, null, cancellationToken); + } + + internal Task GetDocumentAsync(string path, string text, + IReadOnlyDictionary? openDocuments = null, + CancellationToken cancellationToken = default) => + GetAsync(AnalysisRequest.From(path, text, openDocuments, _workspace.SnapshotRoots()), + null, null, cancellationToken); + + internal Task GetWorkspaceAsync(DocumentRequestSnapshot request, string anchorPath, + AnalysisLease seed, IReadOnlyList roots, CancellationToken cancellationToken) => + GetAsync(AnalysisRequest.From(request, roots), anchorPath, seed, cancellationToken); + + internal Task GetWorkspaceAsync(string path, string text, + IReadOnlyDictionary? openDocuments, string anchorPath, + AnalysisLease seed, IReadOnlyList roots, CancellationToken cancellationToken) => + GetAsync(AnalysisRequest.From(path, text, openDocuments, roots), anchorPath, seed, cancellationToken); + + private async Task GetAsync(AnalysisRequest request, string? anchorPath, + AnalysisLease? seed, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + string selectedPath = anchorPath is null ? request.Path : Path.GetFullPath(anchorPath); + RequestKey key; + BuildState? state = null; + AnalysisLease? cached = null; + CacheEntry? capturedEntry = null; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + key = new(selectedPath, request.Stamp, anchorPath is not null, _generation); + if (_cache.TryGetValue(key, out capturedEntry)) cached = capturedEntry.Data.Acquire(selectedPath); + if (cached is null && _inflight.TryGetValue(key, out state)) state.Waiters++; + } + if (cached is not null) + { + bool current; + try { current = cached.IsCurrent(cancellationToken); } + catch { cached.Dispose(); throw; } + if (current) + { + lock (_gate) + { + _hits++; + if (_cache.TryGetValue(key, out var entry)) Touch(entry); + } + return cached; + } + cached.Dispose(); + lock (_gate) + if (_cache.TryGetValue(key, out var entry) && ReferenceEquals(entry, capturedEntry)) Remove(entry); + } + if (state is null) + { + await _admission.WaitAsync(cancellationToken).ConfigureAwait(false); + bool admitted = true; + bool retryCache = false; + try + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + key = key with { Generation = _generation }; + if (_cache.ContainsKey(key)) retryCache = true; + else if (_inflight.TryGetValue(key, out state)) state.Waiters++; + else + { + // Keep a seed lease for the entire shared build, independent of its caller. + AnalysisLease? retainedSeed = seed?.Retain(); + state = new BuildState(key, _workspace.Version) { Waiters = 1 }; + _inflight.Add(key, state); + BuildState captured = state; + state.Task = Task.Run(() => BuildAsync(captured, request, selectedPath, retainedSeed)); + _ = ObserveBuildFailureAsync(state.Task); + admitted = false; // FinishBuild releases the admission slot. + } + } + } + finally { if (admitted) _admission.Release(); } + if (retryCache) return await GetAsync(request, anchorPath, seed, cancellationToken).ConfigureAwait(false); + } + try + { + AnalysisData? data = await state!.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + AnalysisLease? lease = data?.Acquire(selectedPath); + try + { + if (lease is not null && !lease.IsCurrent(cancellationToken)) + { + lease.Dispose(); + return null; + } + } + catch { lease?.Dispose(); throw; } + return lease; + } + finally + { + lock (_gate) + { + state!.Waiters--; + ReleaseFinished(state); + } + } + } + + private async Task BuildAsync(BuildState state, AnalysisRequest request, + string selectedPath, AnalysisLease? seed) + { + AnalysisData? data = null; + AnalysisMetadataView? metadata = null; + bool slot = false; + try + { + CancellationToken ct = state.Cancellation.Token; + await _buildSlots.WaitAsync(ct).ConfigureAwait(false); + slot = true; + Interlocked.Increment(ref _builds); + var observer = new ObservingCompilerFileSystem(); + CheckedNavigationWorkspace? workspace = null; + CheckedNavigationModel? model; + var aliases = new List(); + using (CompilerFileSystem.Use(observer, ct)) + { + metadata = _metadata?.Capture(); + using var metadataScope = metadata?.EnterScope(); + void BeforeActualCheck() + { + BeforeCheck?.Invoke(); + ct.ThrowIfCancellationRequested(); + Interlocked.Increment(ref _checks); + } + if (state.Key.Workspace) + { + if (seed is null || !seed.Model.Snapshot.TryGetDocument(selectedPath, out var anchor)) return null; + if (!seed.IsCurrent(ct)) return null; + observer.Include(seed.Inputs); + workspace = NavigationModelBuilder.BuildWorkspace(selectedPath, anchor!.Document.Text, + request.Overlay, request.Roots, ct, BeforeActualCheck, + [seed.Model], preserveCapturedOverlay: true, + hasPartialMetadata: metadata?.IsComplete == false); + model = workspace.Models.FirstOrDefault(); + } + else + { + model = NavigationModelBuilder.TryBuild(request.Path, request.Text, + request.Overlay, ct, BeforeActualCheck, hasPartialMetadata: metadata?.IsComplete == false); + if (model is not null) + { + foreach (AnalysisDocument document in model.Snapshot.Documents) + { + ct.ThrowIfCancellationRequested(); + string path = document.Document.Path; + if (Path.IsPathFullyQualified(path) && string.Equals(model.Scope.ConfigPath, + TsConfigLoader.Discover(Path.GetDirectoryName(path)!), StringComparison.OrdinalIgnoreCase)) + aliases.Add(path); + } + } + } + } + if (model is null) return null; + ObservedCompilerInputs inputs = observer.Complete(); + int? metadataGeneration = metadata?.Generation; + var validation = new AnalysisValidation(this, state.Key.Generation, inputs, + _metadata, metadataGeneration, state.WorkspaceVersion, _workspace); + if (seed is not null && !seed.IsCurrent(state.Cancellation.Token)) return null; + if (!validation.IsCurrent(state.Cancellation.Token)) return null; + data = new AnalysisData(model, workspace, inputs, validation, metadata); + metadata = null; // ownership transferred + lock (_gate) + { + if (!_disposed && state.Key.Generation == _generation && inputs.CanCache && + _maxSnapshots > 0 && data.EstimatedBytes <= _maxRetainedBytes) + { + var keys = aliases.Select(path => state.Key with { Path = Path.GetFullPath(path) }) + .Append(state.Key).Distinct().ToArray(); + foreach (RequestKey key in keys) + if (_cache.TryGetValue(key, out CacheEntry? old)) Remove(old); + var entry = new CacheEntry(data, keys); + data.Retain(); + entry.Node = _lru.AddLast(entry); + foreach (RequestKey key in keys) _cache.Add(key, entry); + _bytes += data.EstimatedBytes; + while (_lru.Count > _maxSnapshots || _bytes > _maxRetainedBytes) Remove(_lru.First!.Value); + } + } + return data; + } + catch (AnalysisInputsChangedException) { return null; } + catch (OperationCanceledException) when (state.Cancellation.IsCancellationRequested) { return null; } + finally + { + metadata?.Dispose(); + seed?.Dispose(); + if (slot) _buildSlots.Release(); + lock (_gate) + { + state.Data = data; + state.Finished = true; + ReleaseFinished(state); + } + _admission.Release(); + } + } + + private void ReleaseFinished(BuildState state) + { + if (!state.Finished || state.Waiters != 0 || state.Released) return; + state.Released = true; + _inflight.Remove(state.Key); + state.Data?.Release(); + state.Cancellation.Dispose(); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD003", + Justification = "Observes the service-owned build task even when every request waiter cancels.")] + private static async Task ObserveBuildFailureAsync(Task task) + { + try { await task.ConfigureAwait(false); } + catch (OperationCanceledException) { } + catch (Exception exception) + { + // A cancelled waiter must not hide an unexpected failure in independently owned work. + await Console.Error.WriteLineAsync($"SharpTS semantic analysis failed: {exception}").ConfigureAwait(false); + } + } + + internal bool IsGenerationCurrent(long generation) + { + lock (_gate) return !_disposed && generation == _generation; + } + + public void InvalidateAll() + { + lock (_gate) + { + if (_disposed) return; + _generation++; + foreach (BuildState state in _inflight.Values) state.Cancellation.Cancel(); + while (_lru.First is { } node) Remove(node.Value); + } + } + + private void Touch(CacheEntry entry) + { + _lru.Remove(entry.Node!); + entry.Node = _lru.AddLast(entry); + } + + private void Remove(CacheEntry entry) + { + foreach (RequestKey key in entry.Keys) _cache.Remove(key); + _lru.Remove(entry.Node!); + _bytes -= entry.Data.EstimatedBytes; + entry.Data.Release(); + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + InvalidateAll(); + _disposed = true; + } + // Semaphores remain alive until any already-running waiters/builds finish. + } + + private sealed record RequestKey(string Path, string Stamp, bool Workspace, long Generation); + private sealed class BuildState(RequestKey key, long workspaceVersion) + { + public RequestKey Key { get; } = key; + public long WorkspaceVersion { get; } = workspaceVersion; + public CancellationTokenSource Cancellation { get; } = new(); + public Task Task { get; set; } = null!; + public int Waiters; + public bool Finished, Released; + public AnalysisData? Data; + } + private sealed class CacheEntry(AnalysisData data, RequestKey[] keys) + { + public AnalysisData Data { get; } = data; + public RequestKey[] Keys { get; } = keys; + public LinkedListNode? Node; + } + + private sealed record AnalysisRequest(string Path, string Text, + IReadOnlyDictionary Overlay, string[] Roots, string Stamp) + { + public static AnalysisRequest From(DocumentRequestSnapshot request, IReadOnlyList roots) => + Create(request.Document.FilePath!, request.Document.Text, request.TextOverlay, roots, + request.WorkspaceVersion.ToString(), request.FileSystemDocuments.OrderBy(pair => pair.Key, + StringComparer.OrdinalIgnoreCase).Select(pair => $"{pair.Key}:{pair.Value.Version}")); + + public static AnalysisRequest From(string path, string text, + IReadOnlyDictionary? overlay, IReadOnlyList roots) => + Create(path, text, overlay, roots, "legacy", []); + + private static AnalysisRequest Create(string path, string text, + IReadOnlyDictionary? overlay, IReadOnlyList roots, + string version, IEnumerable versions) + { + string absolute = System.IO.Path.GetFullPath(path); + var copy = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in overlay ?? new Dictionary()) + copy[System.IO.Path.GetFullPath(pair.Key)] = pair.Value; + copy[absolute] = text; + string[] rootCopy = roots.Select(System.IO.Path.GetFullPath).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + void Add(string value) + { + byte[] bytes = Encoding.UTF8.GetBytes(value); + hash.AppendData(BitConverter.GetBytes(bytes.Length)); + hash.AppendData(bytes); + } + Add(version); + foreach (string item in versions) Add(item); + foreach (string root in rootCopy) Add(root); + foreach (var pair in copy.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase)) + { + Add(pair.Key); Add(pair.Value); + } + return new(absolute, text, copy, rootCopy, Convert.ToHexString(hash.GetHashAndReset())); + } + } +} + +internal sealed class AnalysisValidation(SemanticAnalysisService owner, long generation, + ObservedCompilerInputs inputs, AnalysisMetadataProvider? metadata, int? metadataGeneration, + long workspaceVersion, NavigationWorkspaceContext workspace) +{ + public bool IsCurrent(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (workspace.Version != workspaceVersion || !owner.IsGenerationCurrent(generation) || + !inputs.IsCurrent(cancellationToken)) return false; + using var physicalScope = CompilerFileSystem.Use(CompilerFileSystem.Physical, cancellationToken); + bool metadataCurrent = metadata is null || metadata.RefreshGeneration() == metadataGeneration; + return metadataCurrent && workspace.Version == workspaceVersion && owner.IsGenerationCurrent(generation); + } +} + +internal sealed class AnalysisData +{ + private int _references = 1; + private readonly AnalysisMetadataView? _metadata; + public AnalysisData(CheckedNavigationModel model, CheckedNavigationWorkspace? workspace, + ObservedCompilerInputs inputs, AnalysisValidation validation, AnalysisMetadataView? metadata) + { + Model = model; Workspace = workspace; Inputs = inputs; Validation = validation; _metadata = metadata; + var snapshots = (workspace?.Models ?? [model]).Select(item => item.Snapshot).Distinct().ToArray(); + EstimatedBytes = inputs.EstimatedBytes + snapshots.Sum(snapshot => + snapshot.Documents.Sum(document => (long)document.Document.Text.Length * 2 + document.Tokens.Count * 96L) + + snapshot.TypeCount * 128L); + } + public CheckedNavigationModel Model { get; } + public CheckedNavigationWorkspace? Workspace { get; } + public ObservedCompilerInputs Inputs { get; } + public AnalysisValidation Validation { get; } + public long EstimatedBytes { get; } + public void Retain() + { + int current; + do + { + current = Volatile.Read(ref _references); + ObjectDisposedException.ThrowIf(current == 0, this); + } while (Interlocked.CompareExchange(ref _references, checked(current + 1), current) != current); + } + public void Release() { if (Interlocked.Decrement(ref _references) == 0) _metadata?.Dispose(); } + public AnalysisLease? Acquire(string path) + { + if (!Model.Snapshot.TryGetDocument(path, out var document)) return null; + Retain(); + return new(this, new CheckedNavigationModel(Model.Snapshot, document!.Document)); + } + public IDisposable EnterMetadataScope() => _metadata?.EnterScope() ?? EmptyScope.Instance; + private sealed class EmptyScope : IDisposable + { + public static EmptyScope Instance { get; } = new(); + public void Dispose() { } + } +} + +internal sealed class AnalysisLease : IDisposable +{ + private readonly AnalysisData _data; + private AnalysisData? _owned; + public AnalysisLease(AnalysisData data, CheckedNavigationModel model) + { + _data = data; _owned = data; Model = model; + } + public CheckedNavigationModel Model { get; } + public CheckedNavigationWorkspace? Workspace => _data.Workspace; + public ObservedCompilerInputs Inputs => _data.Inputs; + public AnalysisValidation Validation => _data.Validation; + public bool IsCurrent(CancellationToken cancellationToken = default) => Validation.IsCurrent(cancellationToken); + public IDisposable EnterMetadataScope() + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _owned) is null, this); + return _data.EnterMetadataScope(); + } + public AnalysisLease Retain() + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _owned) is null, this); + _data.Retain(); + return new(_data, Model); + } + public void Dispose() => Interlocked.Exchange(ref _owned, null)?.Release(); +} diff --git a/src/SharpTS.LanguageServer/SharpTSLanguageServer.cs b/src/SharpTS.LanguageServer/SharpTSLanguageServer.cs index 6a3fb42d9..b18da0bc1 100644 --- a/src/SharpTS.LanguageServer/SharpTSLanguageServer.cs +++ b/src/SharpTS.LanguageServer/SharpTSLanguageServer.cs @@ -3,6 +3,7 @@ using SharpTS.LanguageServer.Handlers; using SharpTS.LanguageServer.Conversions; using SharpTS.LanguageServer.Services; +using SharpTS.LanguageServer.Project; namespace SharpTS.LanguageServer; @@ -27,10 +28,12 @@ public static async Task RunAsync( Func>? typeNames = null, LanguageFeatureMode mode = LanguageFeatureMode.Full, DiagnosticPublishMode diagnosticMode = - DiagnosticPublishMode.SharpTsOnly) + DiagnosticPublishMode.SharpTsOnly, + AnalysisMetadataProvider? metadataProvider = null) { var workspaceContext = new NavigationWorkspaceContext(); var diagnosticsSettings = new DiagnosticsSettings(diagnosticMode); + using var analysis = new SemanticAnalysisService(workspaceContext, metadataProvider); var server = await OmniSharp.Extensions.LanguageServer.Server.LanguageServer.From(options => { options @@ -41,24 +44,32 @@ public static async Task RunAsync( }) .WithInput(Console.OpenStandardInput()) .WithOutput(Console.OpenStandardOutput()) - .WithServices(services => services + .WithServices(services => + { + services .AddSingleton(workspaceContext) .AddSingleton(diagnosticsSettings) + .AddSingleton(analysis) .AddSingleton() - .AddSingleton(new DiagnosticsService(resolve, typeNames)) + .AddSingleton(new DiagnosticsService(resolve, typeNames, analysis, metadataProvider)) .AddSingleton() .AddSingleton() - .AddSingleton(new DecoratorService(resolve, typeNames)) + .AddSingleton(new DecoratorService(resolve, typeNames, + metadataProvider is null ? null : () => metadataProvider.CurrentGeneration)) .AddSingleton(new MemberHoverService(resolve)) .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton()) + .AddSingleton(); + if (metadataProvider is not null) services.AddSingleton(metadataProvider); + }) // Served in both modes: this is the interop knowledge no other server has. .WithHandler() .WithHandler() + .WithHandler() + .WithHandler() .WithHandler() .WithHandler() .WithHandler() diff --git a/src/SharpTS/Compilation/AssemblyReferenceLoader.cs b/src/SharpTS/Compilation/AssemblyReferenceLoader.cs index 158ab0a7a..44acaa230 100644 --- a/src/SharpTS/Compilation/AssemblyReferenceLoader.cs +++ b/src/SharpTS/Compilation/AssemblyReferenceLoader.cs @@ -1,5 +1,8 @@ using System.Collections.Concurrent; using System.Reflection; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using SharpTS.IO; namespace SharpTS.Compilation; @@ -9,12 +12,14 @@ namespace SharpTS.Compilation; /// public sealed class AssemblyReferenceLoader : IDisposable { + private static readonly ConditionalWeakTable SourcePaths = new(); private const string MetadataLoadContextJustification = "The reflected assembly is a MetadataLoadContext input loaded from an external file, not code linked into the native host. Only its metadata is inspected."; private readonly MetadataLoadContext _mlc; private readonly List _loadedAssemblies = []; private readonly ConcurrentDictionary _typeCache = new(); + private readonly ConcurrentDictionary _metadataFailures = new(StringComparer.OrdinalIgnoreCase); private bool _disposed; /// @@ -28,13 +33,15 @@ public sealed class AssemblyReferenceLoader : IDisposable Justification = "Assembly.Location is only a managed-build fallback after SDK reference resolution; an empty single-file location is explicitly ignored.")] public AssemblyReferenceLoader(IEnumerable assemblyPaths, string? sdkPath = null) { - var paths = assemblyPaths.ToList(); + string[] references = assemblyPaths.ToArray(); + var paths = references.ToList(); + var frameworkPaths = new List(); // Add SDK reference assemblies for complete resolution var refAsmPath = sdkPath ?? SdkResolver.FindReferenceAssembliesPath(); if (refAsmPath != null && Directory.Exists(refAsmPath)) { - paths.AddRange(Directory.GetFiles(refAsmPath, "*.dll")); + frameworkPaths.AddRange(Directory.GetFiles(refAsmPath, "*.dll")); } else { @@ -42,15 +49,15 @@ public AssemblyReferenceLoader(IEnumerable assemblyPaths, string? sdkPat var runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location); if (runtimeDir != null) { - paths.AddRange(Directory.GetFiles(runtimeDir, "*.dll")); + frameworkPaths.AddRange(Directory.GetFiles(runtimeDir, "*.dll")); } } - var resolver = new PathAssemblyResolver(paths.Distinct()); - _mlc = new MetadataLoadContext(resolver, "System.Runtime"); + paths.AddRange(frameworkPaths); + _mlc = new MetadataLoadContext(new PathAssemblyResolver(paths.Distinct()), "System.Runtime"); // Pre-load referenced assemblies (user-provided ones, not runtime) - foreach (var path in assemblyPaths) + foreach (var path in references) { if (!File.Exists(path)) continue; @@ -66,6 +73,141 @@ public AssemblyReferenceLoader(IEnumerable assemblyPaths, string? sdkPat } } + internal AssemblyReferenceLoader(IEnumerable assemblyPaths, CapturedAssemblySet images) + { + // This constructor performs no filesystem reads: the manager fingerprints precisely + // these owned bytes before publishing the corresponding metadata generation. + foreach (var (path, error) in images.ReadErrors) + _metadataFailures[path] = error; + _mlc = new MetadataLoadContext(new CapturedAssemblyResolver(images, + (path, error) => _metadataFailures[path] = $"metadata unavailable: {path}: {error.Message}"), "System.Runtime"); + foreach (string path in assemblyPaths) + { + if (!images.Contains(path)) continue; + try { _loadedAssemblies.Add(images.Load(_mlc, path)); } + catch (Exception error) when (IsMetadataInputFailure(error)) + { _metadataFailures[path] = $"metadata unavailable: {path}: {error.Message}"; } + } + } + + internal IReadOnlyList MetadataFailures => _metadataFailures.Values.Order(StringComparer.Ordinal).ToArray(); + + internal static bool IsMetadataInputFailure(Exception error) => error is IOException or + UnauthorizedAccessException or BadImageFormatException; + + /// The original assembly path, including byte-backed editor metadata captures. + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "SingleFile", "IL3000", Justification = MetadataLoadContextJustification)] + public static string GetSourcePath(Assembly assembly) => + SourcePaths.TryGetValue(assembly, out SourceAssemblyPath? source) ? source.Path : assembly.Location; + + private sealed record SourceAssemblyPath(string Path); + + /// Private byte ownership prevents rebuilds from locking or mutating a retained view. + internal sealed class CapturedAssemblySet + { + private readonly Dictionary _images; + private readonly Dictionary _readErrors; + + private CapturedAssemblySet(Dictionary images, Dictionary readErrors) + { _images = images; _readErrors = readErrors; } + + public static CapturedAssemblySet Capture(IEnumerable paths) + { + var images = new Dictionary(StringComparer.OrdinalIgnoreCase); + var errors = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string path in paths.Distinct(StringComparer.OrdinalIgnoreCase)) + { + CompilerFileSystem.ThrowIfCancellationRequested(); + try + { + if (!CompilerFileSystem.FileExists(path)) + { errors[path] = $"reference not found: {path}"; continue; } + using var input = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + using var bytes = new MemoryStream(); + input.CopyTo(bytes); + images[Path.GetFullPath(path)] = bytes.ToArray(); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { errors[path] = ReadFailure(path, error); } + } + CompilerFileSystem.ThrowIfCancellationRequested(); + return new CapturedAssemblySet(images, errors); + } + + public CapturedAssemblySet WithFramework(CapturedAssemblySet framework) + { + var images = new Dictionary(_images, StringComparer.OrdinalIgnoreCase); + foreach (var (path, bytes) in framework._images) + images.TryAdd(path, bytes); + var errors = new Dictionary(_readErrors, StringComparer.OrdinalIgnoreCase); + foreach (var (path, error) in framework._readErrors) errors.TryAdd(path, error); + return new CapturedAssemblySet(images, errors); + } + + public bool Contains(string path) => _images.ContainsKey(Path.GetFullPath(path)); + + public CapturedAssemblySet WithFailure(string path, string error) + { + var errors = new Dictionary(_readErrors, StringComparer.OrdinalIgnoreCase) + { [path] = error }; + return new CapturedAssemblySet(_images, errors); + } + + public string? ContentHash(string path) => _images.TryGetValue(Path.GetFullPath(path), out byte[]? bytes) + ? Convert.ToHexString(SHA256.HashData(bytes)) + : _readErrors.TryGetValue(path, out string? error) && !error.StartsWith("reference not found:", StringComparison.Ordinal) + ? error : null; + + public IReadOnlyDictionary ReadErrors => _readErrors; + + internal static string ReadFailure(string path, Exception error) => + $"reference unreadable: {path}: {error.GetType().Name}: {error.HResult}: {error.Message}"; + + public Assembly Load(MetadataLoadContext context, string path) + { + string absolute = Path.GetFullPath(path); + Assembly assembly = context.LoadFromByteArray(_images[absolute]); + SourcePaths.GetValue(assembly, _ => new SourceAssemblyPath(absolute)); + return assembly; + } + + public IEnumerable Paths => _images.Keys; + } + + private sealed class CapturedAssemblyResolver(CapturedAssemblySet images, + Action reportFailure) : MetadataAssemblyResolver + { + public override Assembly? Resolve(MetadataLoadContext context, AssemblyName requested) + { + byte[] token = requested.GetPublicKeyToken() ?? []; + // Match PathAssemblyResolver's name/token/version policy while loading exclusively + // from captured bytes, including dependencies not yet inspected by the request. + var candidates = images.Paths + .Where(path => string.Equals(Path.GetFileNameWithoutExtension(path), requested.Name, + StringComparison.OrdinalIgnoreCase)) + .Select(path => TryLoad(context, path)).OfType() + .Select(assembly => (Assembly: assembly, Name: assembly.GetName())) + .Where(candidate => string.Equals(candidate.Name.Name, requested.Name, + StringComparison.OrdinalIgnoreCase)) + .Select(candidate => (candidate.Assembly, candidate.Name, + ExactToken: token.AsSpan().SequenceEqual(candidate.Name.GetPublicKeyToken() ?? []))) + .Where(candidate => candidate.ExactToken || token.Length == 0 || + requested.Flags.HasFlag(AssemblyNameFlags.Retargetable)) + .OrderByDescending(candidate => candidate.ExactToken) + .ThenByDescending(candidate => candidate.Name.Version); + return candidates.Select(candidate => candidate.Assembly).FirstOrDefault(); + } + + private Assembly? TryLoad(MetadataLoadContext context, string path) + { + try { return images.Load(context, path); } + catch (Exception error) when (IsMetadataInputFailure(error)) + { reportFailure(path, error); return null; } + } + } + /// /// Gets all loaded reference assemblies (user-provided, excluding runtime). /// diff --git a/src/SharpTS/Compilation/ReloadingAssemblyReferenceLoader.cs b/src/SharpTS/Compilation/ReloadingAssemblyReferenceLoader.cs index d0732b8d1..639eb2696 100644 --- a/src/SharpTS/Compilation/ReloadingAssemblyReferenceLoader.cs +++ b/src/SharpTS/Compilation/ReloadingAssemblyReferenceLoader.cs @@ -1,69 +1,245 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using SharpTS.IO; + namespace SharpTS.Compilation; /// -/// Atomically replaces metadata loaders when configured assembly files change. Retired metadata -/// contexts remain alive until shutdown so a returned to an in-flight request -/// can never be invalidated by a concurrent reload. +/// Publishes stable metadata generations when configured assembly contents change. A captured +/// snapshot keeps its metadata context alive until its last lease is released. /// public sealed class ReloadingAssemblyReferenceLoader : IDisposable { + private static readonly ConditionalWeakTable LegacyTypeOwners = new(); private readonly object _gate = new(); - private readonly string[] _assemblyPaths; - private readonly string? _sdkPath; - private readonly List _retired = []; - private AssemblyReferenceLoader _current; + private readonly string _sdkPath; + private readonly bool _observeSdkContents; + private AssemblyReferenceLoader.CapturedAssemblySet? _fixedFramework; + private string[] _assemblyPaths; + private LoaderGeneration? _current; private FileStamp[] _stamps; + private int _generation; private bool _disposed; - public ReloadingAssemblyReferenceLoader( - IEnumerable assemblyPaths, - string? sdkPath = null) + public ReloadingAssemblyReferenceLoader(IEnumerable assemblyPaths, string? sdkPath = null) { - _assemblyPaths = assemblyPaths - .Select(Path.GetFullPath) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - _sdkPath = sdkPath; - _stamps = CaptureStamps(); - _current = new AssemblyReferenceLoader(_assemblyPaths, _sdkPath); + _assemblyPaths = NormalizePaths(assemblyPaths); + // The installed default SDK/runtime is a host input fixed for this server lifetime. + // An explicitly configured SDK directory is a mutable project input and is refreshed. + _sdkPath = sdkPath is null + ? SdkResolver.FindReferenceAssembliesPath() ?? RuntimeEnvironment.GetRuntimeDirectory() + : Path.GetFullPath(sdkPath); + _observeSdkContents = sdkPath is not null; + _fixedFramework = _observeSdkContents ? null : CaptureFixedFramework(_sdkPath); + CapturedGeneration captured = CaptureGeneration(); + _stamps = captured.Stamps; + _current = new LoaderGeneration(CreateLoader(captured.Images), 0); } - public int Generation { get; private set; } + public int Generation => Volatile.Read(ref _generation); - public Type? TryResolve(string fullName) + private static AssemblyReferenceLoader.CapturedAssemblySet CaptureFixedFramework(string sdkPath) + { + // Host framework bytes are fixed, rather than re-entering the project input manifest on + // only the first capture and spuriously changing its fingerprint on the second request. + using var scope = CompilerFileSystem.Use(CompilerFileSystem.Physical, CompilerFileSystem.CancellationToken); + return AssemblyReferenceLoader.CapturedAssemblySet.Capture( + CompilerFileSystem.EnumerateFiles(sdkPath, "*.dll")); + } + + /// Refreshes reference paths as well as contents, without resolving a type. + public void UpdateReferences(IEnumerable assemblyPaths) + { + string[] paths = NormalizePaths(assemblyPaths); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_assemblyPaths.SequenceEqual(paths, StringComparer.OrdinalIgnoreCase)) + { + string[] previous = _assemblyPaths; + _assemblyPaths = paths; + try + { + ReplaceCurrent(); + } + catch + { + _assemblyPaths = previous; + throw; + } + } + } + } + + /// Observes content changes without requiring an arbitrary type lookup. + public int RefreshGeneration() { lock (_gate) { ReloadIfChanged(); - return _current.TryResolve(fullName); + return _generation; } } - public IReadOnlyList GetAllPublicTypes() + public AssemblyReferenceSnapshot AcquireSnapshot() { lock (_gate) { ReloadIfChanged(); - return _current.GetAllPublicTypes().ToArray(); + return Retain(_current!); + } + } + + /// + /// Compatibility API for callers that return raw Types without a lease. Weak associations + /// keep exactly the contexts whose returned Types remain reachable; no retired list grows + /// for the lifetime of the server. New analysis callers should use . + /// + public Type? TryResolve(string fullName) + { + using AssemblyReferenceSnapshot snapshot = AcquireSnapshot(); + Type? type = snapshot.TryResolve(fullName); + if (type is not null) + PreserveLegacyType(type, snapshot.State); + return type; + } + + public IReadOnlyList GetAllPublicTypes() + { + using AssemblyReferenceSnapshot snapshot = AcquireSnapshot(); + IReadOnlyList types = snapshot.GetAllPublicTypes(); + foreach (Type type in types) + PreserveLegacyType(type, snapshot.State); + return types; + } + + private void PreserveLegacyType(Type type, LoaderGeneration state) + { + lock (_gate) + { + state.HasLegacyTypes = true; + LegacyTypeOwners.GetValue(type, _ => state); } } private void ReloadIfChanged() { ObjectDisposedException.ThrowIf(_disposed, this); - FileStamp[] currentStamps = CaptureStamps(); - if (currentStamps.SequenceEqual(_stamps)) - return; + FileStamp[] stamps = CaptureStamps(); + if (!stamps.SequenceEqual(_stamps)) + ReplaceCurrent(); + } - var replacement = new AssemblyReferenceLoader(_assemblyPaths, _sdkPath); - _retired.Add(_current); + private void ReplaceCurrent() + { + CapturedGeneration captured = CaptureGeneration(); + var replacement = new LoaderGeneration( + CreateLoader(captured.Images), + checked(_generation + 1)); + LoaderGeneration previous = _current!; _current = replacement; - _stamps = currentStamps; - Generation++; + _stamps = captured.Stamps; + Volatile.Write(ref _generation, replacement.Generation); + Release(previous); + } + + private AssemblyReferenceLoader CreateLoader(AssemblyReferenceLoader.CapturedAssemblySet images) + { + try { return new AssemblyReferenceLoader(_assemblyPaths, images); } + catch (Exception error) when (_observeSdkContents && AssemblyReferenceLoader.IsMetadataInputFailure(error)) + { + // An unreadable/incomplete explicit SDK must not discard otherwise safe user/BCL + // analysis. Keep its failed inputs in the generation and mark the fallback partial. + _fixedFramework ??= CaptureFixedFramework(RuntimeEnvironment.GetRuntimeDirectory()); + return new AssemblyReferenceLoader(_assemblyPaths, + images.WithFramework(_fixedFramework).WithFailure(_sdkPath, + $"SDK metadata unavailable: {_sdkPath}: {error.Message}")); + } + } + + private (string[] Paths, List Stamps, bool HasSdkDirectory, string? SdkFailure) CaptureInputPaths() + { + IEnumerable paths = _assemblyPaths; + var stamps = new List(); + bool hasSdkDirectory = false; + string? sdkFailure = null; + if (_observeSdkContents) + { + // Record both a missing explicit directory and its complete DLL inventory. + try + { + hasSdkDirectory = CompilerFileSystem.DirectoryExists(_sdkPath); + stamps.Add(new FileStamp("sdk-directory:" + _sdkPath, hasSdkDirectory ? "exists" : "missing")); + if (hasSdkDirectory) paths = paths.Concat(CompilerFileSystem.EnumerateFiles(_sdkPath, "*.dll")); + else sdkFailure = $"SDK reference directory not found: {_sdkPath}"; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + hasSdkDirectory = false; + sdkFailure = $"SDK reference directory unreadable: {_sdkPath}: {error.Message}"; + stamps.Add(new FileStamp("sdk-directory:" + _sdkPath, sdkFailure)); + } + } + return (paths.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), stamps, hasSdkDirectory, sdkFailure); + } + + private CapturedGeneration CaptureGeneration() + { + var (paths, stamps, hasSdkDirectory, sdkFailure) = CaptureInputPaths(); + AssemblyReferenceLoader.CapturedAssemblySet images = AssemblyReferenceLoader.CapturedAssemblySet.Capture(paths); + // A stream-hash followed by another file read can join two versions of a rebuilding + // assembly. Fingerprint the exact bytes the captured resolver will subsequently load. + stamps.AddRange(paths.Select(path => new FileStamp(path, images.ContentHash(path)))); + if (!_observeSdkContents || !hasSdkDirectory) + { + _fixedFramework ??= CaptureFixedFramework(RuntimeEnvironment.GetRuntimeDirectory()); + images = images.WithFramework(_fixedFramework); + } + if (sdkFailure is not null) images = images.WithFailure(_sdkPath, sdkFailure); + return new CapturedGeneration(stamps.ToArray(), images); + } + + private FileStamp[] CaptureStamps() + { + var (paths, stamps, _, _) = CaptureInputPaths(); + stamps.AddRange(paths + .Select(path => + { + CompilerFileSystem.ThrowIfCancellationRequested(); + try + { + if (!CompilerFileSystem.FileExists(path)) + return new FileStamp(path, null); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + string hash = Convert.ToHexString(SHA256.HashData(stream)); + CompilerFileSystem.ThrowIfCancellationRequested(); + return new FileStamp(path, hash); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + return new FileStamp(path, AssemblyReferenceLoader.CapturedAssemblySet.ReadFailure(path, error)); + } + })); + return stamps.ToArray(); + } + + private static string[] NormalizePaths(IEnumerable paths) => paths + .Select(Path.GetFullPath).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + private AssemblyReferenceSnapshot Retain(LoaderGeneration state) + { + state.LeaseCount++; + return new AssemblyReferenceSnapshot(this, state); } - private FileStamp[] CaptureStamps() => - _assemblyPaths.Select(FileStamp.Capture).ToArray(); + private void Release(LoaderGeneration state) + { + state.LeaseCount--; + if (state.LeaseCount == 0 && !state.HasLegacyTypes) + state.Dispose(); + } public void Dispose() { @@ -72,27 +248,105 @@ public void Dispose() if (_disposed) return; _disposed = true; - _current.Dispose(); - foreach (AssemblyReferenceLoader loader in _retired) - loader.Dispose(); - _retired.Clear(); + LoaderGeneration previous = _current!; + _current = null; + _fixedFramework = null; + _assemblyPaths = []; + _stamps = []; + Release(previous); } } - private readonly record struct FileStamp( - bool Exists, - long Length, - long LastWriteUtcTicks) + private readonly record struct FileStamp(string Path, string? ContentHash); + private readonly record struct CapturedGeneration(FileStamp[] Stamps, + AssemblyReferenceLoader.CapturedAssemblySet Images); + + internal sealed class LoaderGeneration(AssemblyReferenceLoader loader, int generation) : IDisposable { - public static FileStamp Capture(string path) + public object Gate { get; } = new(); + public AssemblyReferenceLoader Loader { get; } = loader; + public int Generation { get; } = generation; + public int LeaseCount { get; set; } = 1; // Manager ownership, plus captured leases. + public bool HasLegacyTypes { get; set; } + private int _disposed; + + ~LoaderGeneration() => Dispose(); + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + lock (Gate) + Loader.Dispose(); + GC.SuppressFinalize(this); + } + } + + /// A reference-counted view of one completed metadata generation. + public sealed class AssemblyReferenceSnapshot : IDisposable + { + private readonly ReloadingAssemblyReferenceLoader _owner; + private bool _disposed; + internal LoaderGeneration State { get; } + + internal AssemblyReferenceSnapshot(ReloadingAssemblyReferenceLoader owner, LoaderGeneration state) + { + _owner = owner; + State = state; + } + + public int Generation => State.Generation; + + public IReadOnlyList Failures + { + get + { + lock (State.Gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return State.Loader.MetadataFailures; + } + } + } + + public Type? TryResolve(string fullName) + { + lock (State.Gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return State.Loader.TryResolve(fullName); + } + } + + public IReadOnlyList GetAllPublicTypes() + { + lock (State.Gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return State.Loader.GetAllPublicTypes().ToArray(); + } + } + + public AssemblyReferenceSnapshot Retain() + { + lock (_owner._gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _owner.Retain(State); + } + } + + public void Dispose() { - var file = new FileInfo(path); - return file.Exists - ? new FileStamp( - Exists: true, - file.Length, - file.LastWriteTimeUtc.Ticks) - : default; + lock (_owner._gate) + { + if (_disposed) + return; + // Synchronize with queries on this lease before releasing its last context. + lock (State.Gate) + _disposed = true; + _owner.Release(State); + } } } } diff --git a/src/SharpTS/Configuration/FileDiscovery.cs b/src/SharpTS/Configuration/FileDiscovery.cs index b7c9abe08..b9180c1a8 100644 --- a/src/SharpTS/Configuration/FileDiscovery.cs +++ b/src/SharpTS/Configuration/FileDiscovery.cs @@ -1,3 +1,4 @@ +using SharpTS.IO; using System.Text.Json; namespace SharpTS.Configuration; @@ -36,6 +37,7 @@ internal static class FileDiscovery bool isStartDirectory = true; while (dir != null) { + CompilerFileSystem.ThrowIfCancellationRequested(); var currentPath = NormalizeDir(dir.FullName); if (stopDirFullName != null && @@ -51,7 +53,7 @@ internal static class FileDiscovery } var candidate = Path.Combine(dir.FullName, fileName); - if (File.Exists(candidate)) + if (CompilerFileSystem.FileExists(candidate)) return Path.GetFullPath(candidate); isStartDirectory = false; diff --git a/src/SharpTS/Configuration/TsConfigDeclarationResolver.cs b/src/SharpTS/Configuration/TsConfigDeclarationResolver.cs index b3dd75a3f..eeab529be 100644 --- a/src/SharpTS/Configuration/TsConfigDeclarationResolver.cs +++ b/src/SharpTS/Configuration/TsConfigDeclarationResolver.cs @@ -1,3 +1,4 @@ +using SharpTS.IO; using System.Text.Json; namespace SharpTS.Configuration; @@ -28,6 +29,7 @@ public static IReadOnlyList Resolve( // Configuration loading records the selection even when packages // are not installed yet. Program loading produces the diagnostic. try { result.Add(ResolveTypePackage(type, roots)); } + catch (OperationCanceledException) { throw; } catch (Exception) { } } } @@ -35,9 +37,9 @@ public static IReadOnlyList Resolve( { foreach (string root in roots) { - if (!Directory.Exists(root)) + if (!CompilerFileSystem.DirectoryExists(root)) continue; - foreach (string directory in Directory.EnumerateDirectories(root)) + foreach (string directory in CompilerFileSystem.EnumerateDirectories(root)) { string name = Path.GetFileName(directory); if (name.StartsWith('.')) @@ -76,7 +78,7 @@ private static string ResolveLib(string configDirectory, string name) foreach (string directory in Ancestors(configDirectory)) { string candidate = Path.Combine(directory, "node_modules", "typescript", "lib", fileName); - if (File.Exists(candidate)) + if (CompilerFileSystem.FileExists(candidate)) return Path.GetFullPath(candidate); } @@ -106,16 +108,16 @@ private static string ResolveTypePackage(string type, IReadOnlyList root private static string? TryFindDeclarationEntry(string packageDirectory) { - if (!Directory.Exists(packageDirectory)) + if (!CompilerFileSystem.DirectoryExists(packageDirectory)) return null; string packageJson = Path.Combine(packageDirectory, "package.json"); - if (File.Exists(packageJson)) + if (CompilerFileSystem.FileExists(packageJson)) { try { using var document = JsonDocument.Parse( - File.ReadAllText(packageJson), + CompilerFileSystem.ReadAllText(packageJson), new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip }); var root = document.RootElement; foreach (string key in new[] { "types", "typings" }) @@ -125,7 +127,7 @@ private static string ResolveTypePackage(string type, IReadOnlyList root { string candidate = Path.GetFullPath( Path.Combine(packageDirectory, property.GetString()!)); - if (File.Exists(candidate)) + if (CompilerFileSystem.FileExists(candidate)) return candidate; } } @@ -137,7 +139,7 @@ private static string ResolveTypePackage(string type, IReadOnlyList root } string index = Path.Combine(packageDirectory, "index.d.ts"); - return File.Exists(index) ? Path.GetFullPath(index) : null; + return CompilerFileSystem.FileExists(index) ? Path.GetFullPath(index) : null; } private static IEnumerable FindVisibleTypeRoots(string startDirectory) @@ -145,7 +147,7 @@ private static IEnumerable FindVisibleTypeRoots(string startDirectory) foreach (string directory in Ancestors(startDirectory)) { string root = Path.Combine(directory, "node_modules", "@types"); - if (Directory.Exists(root)) + if (CompilerFileSystem.DirectoryExists(root)) yield return Path.GetFullPath(root); } } diff --git a/src/SharpTS/Configuration/TsConfigFileMatcher.cs b/src/SharpTS/Configuration/TsConfigFileMatcher.cs index fb967bd33..7bf605a85 100644 --- a/src/SharpTS/Configuration/TsConfigFileMatcher.cs +++ b/src/SharpTS/Configuration/TsConfigFileMatcher.cs @@ -1,3 +1,4 @@ +using SharpTS.IO; using System.Text; using System.Text.RegularExpressions; @@ -27,8 +28,9 @@ public static IReadOnlyList Resolve( { foreach (string file in files) { + CompilerFileSystem.ThrowIfCancellationRequested(); string full = Path.GetFullPath(file); - if (!File.Exists(full)) + if (!CompilerFileSystem.FileExists(full)) throw new Exception($"Error: tsconfig.json: file '{full}' listed in 'files' does not exist."); if (IsSupportedSource(full, allowJs)) roots.Add(full); @@ -56,9 +58,11 @@ public static IReadOnlyList Resolve( .ToArray(); foreach (string include in effectiveIncludes) { + CompilerFileSystem.ThrowIfCancellationRequested(); string pattern = NormalizeInclude(include); foreach (string candidate in EnumerateCandidates(pattern)) { + CompilerFileSystem.ThrowIfCancellationRequested(); string full = Path.GetFullPath(candidate); if (!IsSupportedSource(full, allowJs)) continue; @@ -88,7 +92,7 @@ private static string NormalizeInclude(string include) string full = Path.GetFullPath(include); if (ContainsWildcard(full)) return full; - if (File.Exists(full) || Path.HasExtension(full)) + if (CompilerFileSystem.FileExists(full) || Path.HasExtension(full)) return full; return Path.Combine(full, "**", "*"); } @@ -97,13 +101,13 @@ private static IEnumerable EnumerateCandidates(string pattern) { if (!ContainsWildcard(pattern)) { - if (File.Exists(pattern)) + if (CompilerFileSystem.FileExists(pattern)) yield return pattern; yield break; } string root = SearchRoot(pattern); - if (!Directory.Exists(root)) + if (!CompilerFileSystem.DirectoryExists(root)) yield break; var matches = CreateMatcher(pattern); @@ -113,7 +117,7 @@ private static IEnumerable EnumerateCandidates(string pattern) IgnoreInaccessible = true, AttributesToSkip = FileAttributes.ReparsePoint, }; - foreach (string file in Directory.EnumerateFiles(root, "*", enumeration)) + foreach (string file in CompilerFileSystem.EnumerateFiles(root, "*", enumeration)) { if (matches(file)) yield return file; diff --git a/src/SharpTS/Configuration/TsConfigLoader.cs b/src/SharpTS/Configuration/TsConfigLoader.cs index 735b58822..0ea323186 100644 --- a/src/SharpTS/Configuration/TsConfigLoader.cs +++ b/src/SharpTS/Configuration/TsConfigLoader.cs @@ -1,3 +1,4 @@ +using SharpTS.IO; using System.Text.Json; using SharpTS.Parsing; @@ -47,15 +48,15 @@ public static string ResolveProjectPath(string projectPath) { string full = Path.GetFullPath(projectPath); - if (Directory.Exists(full)) + if (CompilerFileSystem.DirectoryExists(full)) { string inDir = Path.Combine(full, FileName); - if (File.Exists(inDir)) return inDir; + if (CompilerFileSystem.FileExists(inDir)) return inDir; throw new Exception($"Error: -p/--project: no {FileName} in '{full}'."); } - if (File.Exists(full)) return full; + if (CompilerFileSystem.FileExists(full)) return full; throw new Exception($"Error: -p/--project: '{projectPath}' does not exist (resolved to '{full}')."); } @@ -67,7 +68,7 @@ public static string ResolveProjectPath(string projectPath) public static TsConfigResult Load(string path) { string full = Path.GetFullPath(path); - if (!File.Exists(full)) + if (!CompilerFileSystem.FileExists(full)) throw new FileNotFoundException($"{FileName} not found at: {full}", full); var chain = new List<(string Path, TsConfigJson Json)>(); @@ -83,6 +84,7 @@ public static TsConfigResult Load(string path) /// private static void LoadInto(string path, List<(string, TsConfigJson)> chain, List onStack) { + CompilerFileSystem.ThrowIfCancellationRequested(); string full = Path.GetFullPath(path); int existing = onStack.FindIndex(p => string.Equals(p, full, PathComparison)); @@ -106,8 +108,7 @@ private static TsConfigJson ParseFile(string full) { try { - using var stream = File.OpenRead(full); - return JsonSerializer.Deserialize(stream, TsConfigJsonContext.Default.TsConfigJson) + return JsonSerializer.Deserialize(CompilerFileSystem.ReadAllText(full), TsConfigJsonContext.Default.TsConfigJson) ?? throw new Exception($"Error: {FileName} ('{full}') is empty or null."); } catch (JsonException ex) @@ -156,8 +157,8 @@ private static string ResolveExtendsTarget(string spec, string declaringFile) if (isRelative) { string candidate = Path.GetFullPath(Path.Combine(declaringDir, spec)); - if (File.Exists(candidate)) return candidate; - if (!Path.HasExtension(candidate) && File.Exists(candidate + ".json")) return candidate + ".json"; + if (CompilerFileSystem.FileExists(candidate)) return candidate; + if (!Path.HasExtension(candidate) && CompilerFileSystem.FileExists(candidate + ".json")) return candidate + ".json"; throw new Exception( $"Error: {FileName} ('{declaringFile}'): cannot resolve 'extends' target '{spec}' " + @@ -171,7 +172,7 @@ private static string ResolveExtendsTarget(string spec, string declaringFile) string baseDir = Path.Combine(dir, "node_modules", spec); foreach (var candidate in new[] { baseDir, baseDir + ".json", Path.Combine(baseDir, FileName) }) { - if (File.Exists(candidate)) return Path.GetFullPath(candidate); + if (CompilerFileSystem.FileExists(candidate)) return Path.GetFullPath(candidate); } dir = FileDiscovery.AmbientParent(dir); } diff --git a/src/SharpTS/IO/ICompilerFileSystem.cs b/src/SharpTS/IO/ICompilerFileSystem.cs new file mode 100644 index 000000000..480fdb13c --- /dev/null +++ b/src/SharpTS/IO/ICompilerFileSystem.cs @@ -0,0 +1,142 @@ +namespace SharpTS.IO; + +/// Read-only filesystem inputs consumed by configuration and module loading. +public interface ICompilerFileSystem +{ + bool FileExists(string path); + bool DirectoryExists(string path); + string ReadAllText(string path); + FileAttributes GetAttributes(string path); + IReadOnlyList EnumerateFiles(string path, string searchPattern, EnumerationOptions options); + IReadOnlyList EnumerateDirectories(string path, string searchPattern, EnumerationOptions options); +} + +/// +/// Provides an execution-context-local filesystem for an analysis. Ordinary compiler calls +/// use the physical filesystem; editor analysis can observe and memoize its input reads. +/// +public static class CompilerFileSystem +{ + private static readonly AsyncLocal State = new(); + + public static ICompilerFileSystem Physical { get; } = new PhysicalFileSystem(); + public static ICompilerFileSystem Current => State.Value?.FileSystem ?? Physical; + public static CancellationToken CancellationToken => State.Value?.CancellationToken ?? default; + + /// Installs a reader for this asynchronous flow until the returned scope is disposed. + public static IDisposable Use(ICompilerFileSystem fileSystem, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(fileSystem); + var previous = State.Value; + State.Value = new ScopeState(fileSystem, cancellationToken); + return new RestoreScope(previous); + } + + public static void ThrowIfCancellationRequested() => CancellationToken.ThrowIfCancellationRequested(); + + public static bool FileExists(string path) + { + ThrowIfCancellationRequested(); + return Current.FileExists(path); + } + + public static bool DirectoryExists(string path) + { + ThrowIfCancellationRequested(); + return Current.DirectoryExists(path); + } + + public static string ReadAllText(string path) + { + ThrowIfCancellationRequested(); + string text = Current.ReadAllText(path); + ThrowIfCancellationRequested(); + return text; + } + + public static FileAttributes GetAttributes(string path) + { + ThrowIfCancellationRequested(); + return Current.GetAttributes(path); + } + + public static IReadOnlyList EnumerateFiles(string path, string searchPattern = "*", + SearchOption searchOption = SearchOption.TopDirectoryOnly) => + EnumerateFiles(path, searchPattern, CompatibleOptions(searchOption)); + + public static IReadOnlyList EnumerateFiles(string path, string searchPattern, EnumerationOptions options) + { + ThrowIfCancellationRequested(); + var files = Current.EnumerateFiles(path, searchPattern, options); + ThrowIfCancellationRequested(); + return files; + } + + public static IReadOnlyList EnumerateDirectories(string path, string searchPattern = "*", + SearchOption searchOption = SearchOption.TopDirectoryOnly) => + EnumerateDirectories(path, searchPattern, CompatibleOptions(searchOption)); + + public static IReadOnlyList EnumerateDirectories(string path, string searchPattern, EnumerationOptions options) + { + ThrowIfCancellationRequested(); + var directories = Current.EnumerateDirectories(path, searchPattern, options); + ThrowIfCancellationRequested(); + return directories; + } + + // Match the Directory APIs' SearchOption overloads, rather than EnumerationOptions' + // different defaults for hidden/system entries and inaccessible directories. + private static EnumerationOptions CompatibleOptions(SearchOption option) => new() + { + RecurseSubdirectories = option switch + { + SearchOption.TopDirectoryOnly => false, + SearchOption.AllDirectories => true, + _ => throw new ArgumentOutOfRangeException(nameof(option)), + }, + IgnoreInaccessible = false, + AttributesToSkip = 0, + MatchType = MatchType.Win32, + }; + + private sealed record ScopeState(ICompilerFileSystem FileSystem, CancellationToken CancellationToken); + + private sealed class RestoreScope(ScopeState? previous) : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (_disposed) return; + State.Value = previous; + _disposed = true; + } + } + + private sealed class PhysicalFileSystem : ICompilerFileSystem + { + private static StringComparer PathComparer => OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + public bool FileExists(string path) => File.Exists(path); + public bool DirectoryExists(string path) => Directory.Exists(path); + public string ReadAllText(string path) => File.ReadAllText(path); + public FileAttributes GetAttributes(string path) => File.GetAttributes(path); + public IReadOnlyList EnumerateFiles(string path, string searchPattern, EnumerationOptions options) => + Materialize(Directory.EnumerateFiles(path, searchPattern, options)); + public IReadOnlyList EnumerateDirectories(string path, string searchPattern, EnumerationOptions options) => + Materialize(Directory.EnumerateDirectories(path, searchPattern, options)); + + private static IReadOnlyList Materialize(IEnumerable paths) + { + var result = new List(); + foreach (string path in paths) + { + ThrowIfCancellationRequested(); + result.Add(path); + } + result.Sort(PathComparer); + return result.ToArray(); + } + } +} diff --git a/src/SharpTS/Modules/CommonJsDetector.cs b/src/SharpTS/Modules/CommonJsDetector.cs index 0bf0714e8..fe9326f7a 100644 --- a/src/SharpTS/Modules/CommonJsDetector.cs +++ b/src/SharpTS/Modules/CommonJsDetector.cs @@ -1,3 +1,4 @@ +using SharpTS.IO; using SharpTS.Configuration; namespace SharpTS.Modules; @@ -35,7 +36,13 @@ public enum ModuleKind /// /// Absolute path to the source file. /// The detected module kind. - public static ModuleKind Detect(string filePath) + public static ModuleKind Detect(string filePath) => + Detect(filePath, CompilerFileSystem.FileExists, CompilerFileSystem.ReadAllText); + + // ModuleResolver supplies its overlay-aware operations so detection consumes the same + // package/source inputs as resolution, including entirely virtual compiler programs. + internal static ModuleKind Detect(string filePath, + Func fileExists, Func readAllText) { var ext = Path.GetExtension(filePath).ToLowerInvariant(); @@ -55,7 +62,7 @@ public static ModuleKind Detect(string filePath) // Step 2: .js/.jsx — walk up to nearest package.json if (ext == ".js" || ext == ".jsx") { - var pkgType = FindNearestPackageJsonType(filePath); + var pkgType = FindNearestPackageJsonType(filePath, fileExists, readAllText); if (pkgType == "module") return ModuleKind.EsModule; if (pkgType == "commonjs") @@ -66,7 +73,7 @@ public static ModuleKind Detect(string filePath) return ModuleKind.CommonJs; // Step 3: no reachable package.json — fall back to content heuristic - return DetectFromContent(filePath); + return DetectFromContent(filePath, readAllText); } // Unknown extension — assume ESM (TS-first project default) @@ -77,15 +84,16 @@ public static ModuleKind Detect(string filePath) /// Walks up from looking for a package.json. /// Returns the value of the "type" field, an empty string if found but no type, or null if no package.json reachable. /// - private static string? FindNearestPackageJsonType(string filePath) + private static string? FindNearestPackageJsonType(string filePath, + Func fileExists, Func readAllText) { var dir = Path.GetDirectoryName(filePath); while (!string.IsNullOrEmpty(dir)) { var pkgPath = Path.Combine(dir, "package.json"); - if (File.Exists(pkgPath)) + if (fileExists(pkgPath)) { - var pkg = ModulePackageJson.TryLoad(pkgPath); + var pkg = TryLoadPackageJson(pkgPath, readAllText); if (pkg != null) { return pkg.Type ?? ""; @@ -96,15 +104,31 @@ public static ModuleKind Detect(string filePath) return null; } + private static ModulePackageJson? TryLoadPackageJson(string path, Func readAllText) + { + try + { + return ModulePackageJson.TryLoadFromContent(readAllText(path)); + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return null; + } + } + /// /// Heuristic content scan: classifies a file as CJS if it uses require/module.exports/exports /// without import/export syntax. Used only when no package.json is reachable. /// - private static ModuleKind DetectFromContent(string filePath) + private static ModuleKind DetectFromContent(string filePath, Func readAllText) { try { - var source = File.ReadAllText(filePath); + var source = readAllText(filePath); // Cheap token scan — not parsing, just looking for obvious markers bool hasEsm = @@ -121,6 +145,10 @@ private static ModuleKind DetectFromContent(string filePath) return hasCjs ? ModuleKind.CommonJs : ModuleKind.EsModule; } + catch (OperationCanceledException) + { + throw; + } catch { return ModuleKind.EsModule; diff --git a/src/SharpTS/Modules/ModuleResolver.cs b/src/SharpTS/Modules/ModuleResolver.cs index f499cacea..1d63a7c1d 100644 --- a/src/SharpTS/Modules/ModuleResolver.cs +++ b/src/SharpTS/Modules/ModuleResolver.cs @@ -1,3 +1,4 @@ +using SharpTS.IO; using System.Collections.Frozen; using SharpTS.Configuration; using System.Text.Json; @@ -54,6 +55,19 @@ public class ModuleResolver /// private readonly Dictionary? _virtualFiles; private readonly bool _virtualFilesFallBackToDisk; + private CancellationToken _cancellationToken; + + /// Observes editor cancellation during loading, parsing and graph traversal. + public ModuleResolver WithCancellation(CancellationToken cancellationToken) + { + _cancellationToken = cancellationToken; + return this; + } + + private CancellationToken EffectiveCancellationToken => _cancellationToken.CanBeCanceled + ? _cancellationToken : CompilerFileSystem.CancellationToken; + + private void ThrowIfCancellationRequested() => EffectiveCancellationToken.ThrowIfCancellationRequested(); /// /// Creates a new module resolver rooted at the given path. @@ -139,26 +153,32 @@ public ModuleResolver( private bool ResolverFileExists(string path) { - if (_virtualFiles is null) return File.Exists(path); + ThrowIfCancellationRequested(); + if (_virtualFiles is null) return CompilerFileSystem.FileExists(path); return _virtualFiles.ContainsKey(NormalizePath(path)) || - (_virtualFilesFallBackToDisk && File.Exists(path)); + (_virtualFilesFallBackToDisk && CompilerFileSystem.FileExists(path)); } private bool ResolverDirectoryExists(string path) { - if (_virtualFiles is null) return Directory.Exists(path); + ThrowIfCancellationRequested(); + if (_virtualFiles is null) return CompilerFileSystem.DirectoryExists(path); var canonical = NormalizePath(path); var prefix = canonical + Path.DirectorySeparatorChar; foreach (var k in _virtualFiles.Keys) + { + ThrowIfCancellationRequested(); if (k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return true; - return _virtualFilesFallBackToDisk && Directory.Exists(path); + } + return _virtualFilesFallBackToDisk && CompilerFileSystem.DirectoryExists(path); } private string ResolverReadAllText(string path) { + ThrowIfCancellationRequested(); if (_virtualFiles is not null && _virtualFiles.TryGetValue(NormalizePath(path), out var src)) return src; - return File.ReadAllText(path); + return CompilerFileSystem.ReadAllText(path); } /// @@ -222,6 +242,7 @@ private string ResolveModulePathCore( ResolutionKind kind, bool preferDeclarations) { + ThrowIfCancellationRequested(); string currentDir = Path.GetDirectoryName(currentModulePath) ?? _basePath; // dotnet: scheme — .NET interop imports resolve via reflection, not the file system. @@ -423,6 +444,7 @@ private bool TryResolvePaths(string specifier, out string? resolved) while (currentDir != null) { + ThrowIfCancellationRequested(); string packageDir = Path.Combine(currentDir, "node_modules", packageName); if (ResolverDirectoryExists(packageDir)) @@ -607,6 +629,7 @@ public static (string packageName, string subpath) ParsePackageSpecifier(string string? dir = startDir; while (dir != null) { + ThrowIfCancellationRequested(); string pkgPath = Path.Combine(dir, "package.json"); var pkg = LoadPackageJson(pkgPath); if (pkg != null) @@ -640,6 +663,7 @@ public static (string packageName, string subpath) ParsePackageSpecifier(string string? dir = startDir; while (dir != null) { + ThrowIfCancellationRequested(); string pkgPath = Path.Combine(dir, "package.json"); var pkg = LoadPackageJson(pkgPath); if (pkg?.Name == packageName && pkg.Exports != null) @@ -689,6 +713,7 @@ private string[] ConditionsFor(ResolutionKind kind, bool preferDeclarations) if (ResolverFileExists(path)) { try { pkg = ModulePackageJson.TryLoadFromContent(ResolverReadAllText(path)); } + catch (OperationCanceledException) { throw; } catch { pkg = null; } } _packageJsonCache[path] = pkg; @@ -777,6 +802,7 @@ private ParsedModule LoadScriptReference(string absolutePath, DecoratorMode deco /// If a circular dependency is detected public ParsedModule LoadModule(string absolutePath, DecoratorMode decoratorMode = DecoratorMode.None) { + ThrowIfCancellationRequested(); if (absolutePath.StartsWith(AmbientModulePrefix, StringComparison.Ordinal)) { return _moduleCache.TryGetValue(absolutePath, out var ambient) @@ -887,12 +913,14 @@ public ParsedModule LoadModule(string absolutePath, DecoratorMode decoratorMode string source = ResolverReadAllText(absolutePath); bool isJsxSource = IsJsxSourcePath(absolutePath); - var lexer = new Lexer(source) { JsxTolerant = isJsxSource }; + var lexer = new Lexer(source) { JsxTolerant = isJsxSource } + .WithCancellation(EffectiveCancellationToken); var tokens = lexer.ScanTokens(); // Parse into a document so the module keeps its text, checksum and statement spans — // what debug symbols and editor navigation both resolve positions against. var document = new SourceDocument(absolutePath, source); var parser = new Parser(tokens, decoratorMode) + .WithCancellation(EffectiveCancellationToken) .WithSourceDocument(document) .AsDeclarationFile(IsDeclarationFilePath(absolutePath)) .WithFilePath(absolutePath) @@ -916,6 +944,8 @@ public ParsedModule LoadModule(string absolutePath, DecoratorMode decoratorMode var statements = parseResult.Statements; var module = new ParsedModule(absolutePath, statements) { + Tokens = tokens.ToArray(), + HitParseErrorLimit = parseResult.HitErrorLimit, IsDeclarationFile = IsDeclarationFilePath(absolutePath), Document = document, }; @@ -925,7 +955,8 @@ public ParsedModule LoadModule(string absolutePath, DecoratorMode decoratorMode module.IsScript = ScriptDetector.IsScriptFile(statements); // Determine if this is a CommonJS module - module.IsCommonJs = CommonJsDetector.Detect(absolutePath) == CommonJsDetector.ModuleKind.CommonJs; + module.IsCommonJs = CommonJsDetector.Detect( + absolutePath, ResolverFileExists, ResolverReadAllText) == CommonJsDetector.ModuleKind.CommonJs; // CommonJS files are modules, not scripts — they have isolated scope and their own // synthetic require/module/exports bindings. Override the no-import/export heuristic. @@ -1012,6 +1043,7 @@ public ParsedModule LoadModule(string absolutePath, DecoratorMode decoratorMode // Recursively load imported modules foreach (var stmt in statements) { + ThrowIfCancellationRequested(); if (stmt is Stmt.Import import) { if (module.IsDeclarationFile @@ -1251,18 +1283,21 @@ private ParsedModule LoadStdlibModule(string virtualPath, DecoratorMode decorato _loadingModules.Add(virtualPath); try { - var lexer = new Lexer(tsSource.Text); + var lexer = new Lexer(tsSource.Text).WithCancellation(EffectiveCancellationToken); var tokens = lexer.ScanTokens(); // Marked virtual: the stdlib is embedded in the compiler, so there is no file on disk a // debugger could open — its text has to travel with the symbols instead. var document = new SourceDocument(virtualPath, tsSource.Text, isVirtual: true); - var parser = new Parser(tokens, decoratorMode).WithSourceDocument(document); + var parser = new Parser(tokens, decoratorMode) + .WithCancellation(EffectiveCancellationToken) + .WithSourceDocument(document); var parseResult = parser.Parse(); if (!parseResult.IsSuccess) throw new Exception(parseResult.Diagnostics.First().ToString()); var module = new ParsedModule(virtualPath, parseResult.Statements) { + Tokens = tokens.ToArray(), IsScript = false, IsCommonJs = false, Document = document, @@ -1275,6 +1310,7 @@ private ParsedModule LoadStdlibModule(string virtualPath, DecoratorMode decorato // both transparently. foreach (var stmt in parseResult.Statements) { + ThrowIfCancellationRequested(); if (stmt is Stmt.Import import) { var importedPath = ResolveModulePath(import.ModulePath, virtualPath); @@ -1317,14 +1353,16 @@ private void CollectCjsRequireDependencies( DecoratorMode decoratorMode, bool stdlibOnly = false) { - var walker = new CjsRequireWalker(); + var walker = new CjsRequireWalker(EffectiveCancellationToken); foreach (var stmt in statements) { + ThrowIfCancellationRequested(); walker.Visit(stmt); } foreach (var specifier in walker.Specifiers) { + ThrowIfCancellationRequested(); // Built-in modules: skip — they're not loaded via the file system. if (BuiltInModuleRegistry.IsBuiltIn(specifier.StartsWith("node:") ? specifier[5..] : specifier)) continue; @@ -1337,6 +1375,10 @@ private void CollectCjsRequireDependencies( requiredPath = ResolveRuntimeModulePath( specifier, absolutePath, ResolutionKind.Cjs); } + catch (OperationCanceledException) + { + throw; + } catch { // Optional dep / will be handled at runtime by the optional-dep throw path. @@ -1354,6 +1396,10 @@ private void CollectCjsRequireDependencies( { requiredModule = LoadModule(requiredPath, decoratorMode); } + catch (OperationCanceledException) + { + throw; + } catch { continue; @@ -1369,10 +1415,22 @@ private void CollectCjsRequireDependencies( /// /// AST walker that collects literal-specifier require() call arguments from a CJS body. /// - private sealed class CjsRequireWalker : AstVisitorBase + private sealed class CjsRequireWalker(CancellationToken cancellationToken) : AstVisitorBase { public List Specifiers { get; } = []; + public override void Visit(Expr expression) + { + cancellationToken.ThrowIfCancellationRequested(); + base.Visit(expression); + } + + public override void Visit(Stmt statement) + { + cancellationToken.ThrowIfCancellationRequested(); + base.Visit(statement); + } + protected override void VisitCall(Expr.Call expr) { if (expr.Callee is Expr.Variable v && @@ -1406,11 +1464,13 @@ public List GetRuntimeModulesInOrder(ParsedModule entryPoint) /// Returns the union of executable root graphs in dependency order. public List GetRuntimeModulesInOrder(IEnumerable entryPoints) { + ThrowIfCancellationRequested(); List result = []; HashSet visited = []; void Visit(ParsedModule module) { + ThrowIfCancellationRequested(); if (!visited.Add(module.Path)) return; @@ -1435,10 +1495,13 @@ void Visit(ParsedModule module) /// public void RegisterAmbientModuleDeclarations(IEnumerable declarationModules) { + ThrowIfCancellationRequested(); foreach (var declarationFile in declarationModules) { + ThrowIfCancellationRequested(); foreach (var statement in declarationFile.Statements) { + ThrowIfCancellationRequested(); if (statement is not Stmt.DeclareModule declaration || declaration.ModulePath.StartsWith("./", StringComparison.Ordinal) || declaration.ModulePath.StartsWith("../", StringComparison.Ordinal)) @@ -1465,6 +1528,7 @@ public void RegisterAmbientModuleDeclarations(IEnumerable declarat } foreach (Stmt member in declaration.Members) { + ThrowIfCancellationRequested(); if (!module.Statements.Any(existing => ReferenceEquals(existing, member))) module.Statements.Add(member); } @@ -1477,11 +1541,13 @@ public void RegisterAmbientModuleDeclarations(IEnumerable declarat /// Returns the union of several root graphs in dependency order. public List GetModulesInOrder(IEnumerable entryPoints) { + ThrowIfCancellationRequested(); List result = []; HashSet visited = []; void Visit(ParsedModule module) { + ThrowIfCancellationRequested(); if (visited.Contains(module.Path)) { return; @@ -1512,7 +1578,8 @@ void Visit(ParsedModule module) /// Real source files currently present in the resolver cache. public IReadOnlyList LoadedFilePaths => _moduleCache.Keys - .Where(File.Exists) + .Where(Path.IsPathFullyQualified) + .Where(CompilerFileSystem.FileExists) .OrderBy(path => path, StringComparer.Ordinal) .ToArray(); @@ -1521,6 +1588,7 @@ void Visit(ParsedModule module) /// public ParsedModule? GetCachedModule(string absolutePath) { + ThrowIfCancellationRequested(); // Don't normalize virtual paths (builtin: sentinels, stdlib: TS sources, dotnet: // interop modules, primitive: C# interop modules — none resolve to a real filesystem path). if (!absolutePath.StartsWith(BuiltInModuleRegistry.BuiltInPrefix) @@ -1542,6 +1610,7 @@ void Visit(ParsedModule module) /// public ParsedModule LoadProgram(string absolutePath, DecoratorMode decoratorMode = DecoratorMode.None) { + ThrowIfCancellationRequested(); var entry = LoadModule(absolutePath, decoratorMode); if (!_programOptions.NoLib && !entry.NoDefaultLib) { @@ -1551,13 +1620,14 @@ public ParsedModule LoadProgram(string absolutePath, DecoratorMode decoratorMode foreach (string name in requested) { + ThrowIfCancellationRequested(); ParsedModule library; if (TypeScriptLibProvider.TryGetParsed(name, out _)) { library = LoadTypeScriptLibModule( TypeScriptLibProvider.GetVirtualPath(name), decoratorMode); } - else if (_virtualFiles is null) + else if (_virtualFiles is null || _virtualFilesFallBackToDisk) { // Preserve support for project-local custom library files while // using embedded declarations for the standard TypeScript set. @@ -1578,6 +1648,7 @@ public ParsedModule LoadProgram(string absolutePath, DecoratorMode decoratorMode foreach (string typeName in GetAutomaticTypeDirectiveNames(absolutePath)) { + ThrowIfCancellationRequested(); string? declarationPath = ResolveTypeReferenceDirective(typeName, absolutePath); if (declarationPath is null) { @@ -1617,12 +1688,14 @@ private IEnumerable GetAutomaticTypeDirectiveNames(string containingFile var names = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (string root in GetEffectiveTypeRoots(containingFile)) { + ThrowIfCancellationRequested(); if (_virtualFiles is not null) { string prefix = NormalizePath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; foreach (string path in _virtualFiles.Keys) { + ThrowIfCancellationRequested(); if (!path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; string relative = path[prefix.Length..]; @@ -1635,14 +1708,15 @@ private IEnumerable GetAutomaticTypeDirectiveNames(string containingFile : package); } } - continue; + if (!_virtualFilesFallBackToDisk) + continue; } - if (!Directory.Exists(root)) + if (!CompilerFileSystem.DirectoryExists(root)) continue; try { - foreach (string directory in Directory.EnumerateDirectories(root)) + foreach (string directory in CompilerFileSystem.EnumerateDirectories(root)) { string package = Path.GetFileName(directory); names.Add(package.Contains("__", StringComparison.Ordinal) @@ -1664,6 +1738,7 @@ private IEnumerable GetAutomaticTypeDirectiveNames(string containingFile foreach (string root in GetEffectiveTypeRoots(containingFile)) { + ThrowIfCancellationRequested(); string packageDir = Path.Combine(root, packageDirectoryName); if (!ResolverDirectoryExists(packageDir)) continue; @@ -1817,6 +1892,7 @@ private IEnumerable GetEffectiveTypeRoots(string containingFile) string? directory = Path.GetDirectoryName(Path.GetFullPath(containingFile)); while (directory is not null) { + ThrowIfCancellationRequested(); roots.Add(Path.Combine(directory, "node_modules", "@types")); directory = FileDiscovery.AmbientParent(directory); } @@ -1825,6 +1901,7 @@ private IEnumerable GetEffectiveTypeRoots(string containingFile) private ParsedModule LoadTypeScriptLibModule(string virtualPath, DecoratorMode decoratorMode) { + ThrowIfCancellationRequested(); virtualPath = TypeScriptLibProvider.GetVirtualPath(virtualPath); if (_moduleCache.TryGetValue(virtualPath, out var cached)) return cached; @@ -1892,6 +1969,7 @@ public List LoadDynamicImportModules( foreach (var path in paths) { + ThrowIfCancellationRequested(); try { string resolvedPath = ResolveRuntimeModulePath(path, basePath); @@ -1915,6 +1993,10 @@ public List LoadDynamicImportModules( newModules.Add(loaded); } } + catch (OperationCanceledException) + { + throw; + } catch { // Dynamic imports may reference modules that don't exist yet diff --git a/src/SharpTS/Modules/ParsedModule.cs b/src/SharpTS/Modules/ParsedModule.cs index 597fd47fc..d99217fa9 100644 --- a/src/SharpTS/Modules/ParsedModule.cs +++ b/src/SharpTS/Modules/ParsedModule.cs @@ -29,6 +29,9 @@ public class ParsedModule /// public List Statements { get; } + /// Source tokens retained with this parse, empty for synthetic modules. + public IReadOnlyList Tokens { get; set; } = []; + /// /// Recoverable syntax diagnostics produced while parsing this module. Empty during /// normal product loading, which remains fail-fast; conformance and editor-style @@ -36,6 +39,9 @@ public class ParsedModule /// public List ParseDiagnostics { get; } = []; + /// Whether parser recovery stopped at its diagnostic limit. + public bool HitParseErrorLimit { get; set; } + /// /// True for a synthetic module created from declare module "name". /// Declarations inside these modules are implicitly exported by TypeScript. diff --git a/src/SharpTS/Parsing/Lexer.cs b/src/SharpTS/Parsing/Lexer.cs index ccfbee389..c3cfbdf38 100644 --- a/src/SharpTS/Parsing/Lexer.cs +++ b/src/SharpTS/Parsing/Lexer.cs @@ -22,6 +22,8 @@ public class Lexer(string source) private int _start = 0; private int _current = 0; private int _line = 1; + private CancellationToken _cancellationToken; + private int _cancellationCheckpoints; // Line where the current token starts. Snapshotted before each ScanToken so // that multi-line tokens (template literals) record their starting line, not // their ending line. Needed so `return