From 3dc7ca6c80f21d600d415d62fb8feb6f7179a0bc Mon Sep 17 00:00:00 2001 From: EFLKumo <96906415+EFLKumo@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:42:57 +0800 Subject: [PATCH] lsp-mcp: headless-editor MCP server over a language server Effect 4 MCP server (stdio/HTTP) bridging one LSP process: on-demand document sync with LRU and disk change detection, push/pull diagnostics reconciliation, capability-gated tools, diagnostics resources, tests against a fixture server. --- CLAUDE.md | 60 ++++ package.json | 30 +- pnpm-lock.yaml | 697 +++++++++++++++++++++++++++++++++++++ src/Model.ts | 319 +++++++++++++++++ src/Resources.ts | 90 +++++ src/Server.ts | 69 ++++ src/Tools.ts | 167 +++++++++ src/lsp/Diagnostics.ts | 134 +++++++ src/lsp/Documents.ts | 366 +++++++++++++++++++ src/lsp/Editor.ts | 608 ++++++++++++++++++++++++++++++++ src/lsp/LanguageServer.ts | 444 +++++++++++++++++++++++ src/main.ts | 126 +++++++ test/Editor.test.ts | 171 +++++++++ test/Server.test.ts | 144 ++++++++ test/fixtures/server.ts | 160 +++++++++ test/fixtures/workspace.ts | 23 ++ tsconfig.json | 33 +- vitest.config.ts | 8 + 18 files changed, 3611 insertions(+), 38 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/Model.ts create mode 100644 src/Resources.ts create mode 100644 src/Server.ts create mode 100644 src/Tools.ts create mode 100644 src/lsp/Diagnostics.ts create mode 100644 src/lsp/Documents.ts create mode 100644 src/lsp/Editor.ts create mode 100644 src/lsp/LanguageServer.ts create mode 100644 src/main.ts create mode 100644 test/Editor.test.ts create mode 100644 test/Server.test.ts create mode 100644 test/fixtures/server.ts create mode 100644 test/fixtures/workspace.ts create mode 100644 vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..653f88b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# lsp-mcp + +An MCP server that exposes one Language Server to agents as a headless editor. +Effect 4 (`effect/unstable/ai` for MCP, `effect/unstable/cli` for the CLI) plus +`vscode-languageserver-protocol` for LSP. TypeScript runs directly on Node 26 +(type stripping); there is no build step. + +## Commands + +- `pnpm tsc` — type-check (tsgo with the Effect language-service plugin) +- `pnpm test` — vitest; integration tests spawn `test/fixtures/server.ts`, a real LSP server +- `pnpm biome check --write --unsafe` — lint/format (run before committing; its rewrites are safe) +- `node src/main.ts --help` — the CLI; smoke-test with `node src/main.ts --root -- [args]` +- Commit with `jj commit -m "..."`, never `git` + +The shell may have an HTTP proxy configured; when curling the `--http` transport locally, pass `--noproxy '*'`. + +## Layout + +``` +src/Model.ts agent-facing Schemas (1-based positions); drives tool input/output schemas +src/Tools.ts Toolkit definition + handlers wiring + capability gating (supportedBy) +src/Resources.ts MCP resources (lsp://server, lsp://diagnostics[/{file}]) and notifications +src/Server.ts layer composition: LanguageServer → Documents+Diagnostics → transport → tools/resources +src/main.ts CLI entry (Command/Flag/Argument), logs to stderr, NodeRuntime.runMain +src/lsp/ the ONLY place vscode-* packages are used at runtime + LanguageServer.ts spawn + JSON-RPC + initialize/shutdown; request() waits for $/progress idle and retries ContentModified + Documents.ts on-demand didOpen, LRU via ScopedCache, disk change detection → didChange+didSave, applyEdit + Diagnostics.ts publishDiagnostics accumulation; pull (textDocument/diagnostic) or settled push per doc version + Editor.ts the operations = MCP tool handlers; LSP results → Model values +test/fixtures/server.ts fixture language server (hover/definition/references/rename/symbols/TODO diagnostics) +test/fixtures/workspace.ts temp workspace layer running the fixture server +``` + +Rule: outside `src/lsp/`, `vscode-*` may only be imported as types. + +## Design decisions worth knowing + +- Positions handed to agents are 1-based; `Where` accepts `column` or a `symbol` name to find on the line. +- Client capabilities are deliberately narrow (no dynamic registration, no watched files, UTF-16 only) so + servers fall back to their own file watching. Settings come from `--settings` via `workspace/configuration`. +- Tools are registered after LSP `initialize` so unsupported ones are never offered + (`Tools.supportedBy`). `diagnostics` is always offered. +- Tool failures are `LspError | DocumentError` (declared `failure` schema), so their messages reach the agent. +- Optional fields in `Model` use a local `optional` helper that omits the key when encoding, because MCP + `structuredContent` must be JSON (no `undefined`). +- Diagnostics waiting: pull if the server supports it; otherwise wait for a publish for the doc's version + plus a 250ms quiet period, bounded by 5s, memoized per (uri, version). +- Tests that talk to the fixture server use `it.layer(..., { excludeTestServices: true })` so the real + clock is used (Stream.debounce / timeouts would hang under TestClock). + +## Status + +Complete for the first release: 13 tools, 2 resources + 1 template, stdio and HTTP transports, +verified against the fixture server and `typescript-language-server`. `goal.md` is the original brief and +no longer authoritative; the code is. + +Ideas not done: inlay hints, type hierarchy, semantic tokens, partial results, workspace/didChangeWatchedFiles +when a server insists on it, restarting a crashed language server (currently requests fail with a clear +`LspError` until the MCP client restarts the process). diff --git a/package.json b/package.json index 1769385..88e27b0 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,19 @@ { "name": "lsp-mcp", - "version": "1.0.0", - "description": "", - "main": "index.js", + "version": "0.1.0", + "description": "MCP server exposing a Language Server's capabilities to agents as a headless editor", + "type": "module", + "license": "MIT", + "bin": { + "lsp-mcp": "./src/main.ts" + }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "start": "node src/main.ts", + "check": "tsc", + "test": "vitest run", + "lint": "biome check --write --unsafe", "prepare": "effect-tsgo patch --typescript --no-oxlint" }, - "keywords": [], - "author": "", - "license": "ISC", "devEngines": { "packageManager": { "name": "pnpm", @@ -18,14 +22,20 @@ } }, "packageManager": "pnpm@12.4.2", - "type": "module", "dependencies": { "@effect/platform-node": "4.0.0-rc.115", - "effect": "4.0.0-rc.115" + "effect": "4.0.0-rc.115", + "vscode-languageserver-protocol": "^3.18.3", + "vscode-languageserver-textdocument": "^1.0.14", + "vscode-uri": "^3.2.0" }, "devDependencies": { "@biomejs/biome": "2.5.14", "@effect/tsgo": "0.45.0", - "typescript": "^7.0.2" + "@effect/vitest": "4.0.0-rc.115", + "@types/node": "^26.6.1", + "typescript": "^7.0.2", + "vitest": "^5.0.1", + "vscode-languageserver": "^10.1.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 425c03d..dc8a1c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,15 @@ importers: effect: specifier: 4.0.0-rc.115 version: 4.0.0-rc.115 + vscode-languageserver-protocol: + specifier: ^3.18.3 + version: 3.18.3 + vscode-languageserver-textdocument: + specifier: ^1.0.14 + version: 1.0.14 + vscode-uri: + specifier: ^3.2.0 + version: 3.2.0 devDependencies: '@biomejs/biome': specifier: 2.5.14 @@ -179,9 +188,21 @@ importers: '@effect/tsgo': specifier: 0.45.0 version: 0.45.0 + '@effect/vitest': + specifier: 4.0.0-rc.115 + version: 4.0.0-rc.115(effect@4.0.0-rc.115)(vitest@5.0.1(@types/node@26.6.1)(vite@8.3.0(@types/node@26.6.1))) + '@types/node': + specifier: ^26.6.1 + version: 26.6.1 typescript: specifier: ^7.0.2 version: 7.0.2 + vitest: + specifier: ^5.0.1 + version: 5.0.1(@types/node@26.6.1)(vite@8.3.0(@types/node@26.6.1)) + vscode-languageserver: + specifier: ^10.1.1 + version: 10.1.1 packages: @@ -294,6 +315,25 @@ packages: resolution: {integrity: sha512-gPG0ixVHgtYBUe5DbKX9vzHlhqd9+ZEfs4m/XWy0808av0A2XqMzaG3F5rnPBN7EUJRPlo9TluIi8WHx/pBV+A==} hasBin: true + '@effect/vitest@4.0.0-rc.115': + resolution: {integrity: sha512-DJR0hfdr3HNXAkzW8AzfvUz6VmrNFLrKk9tZGDURi33faMyxkc7ZewxELMMZwGpOTGN4mfqYVbX2CXgI+ZJBeQ==} + peerDependencies: + effect: ^4.0.0-rc.115 + vitest: '>=5.0.0 <6.0.0' + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.150.0': + resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} + '@redis/bloom@6.2.1': resolution: {integrity: sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==} engines: {node: '>= 20.0.0'} @@ -330,6 +370,114 @@ packages: peerDependencies: '@redis/client': ^6.2.1 + '@rolldown/binding-android-arm-eabi@1.2.9': + resolution: {integrity: sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.9': + resolution: {integrity: sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.9': + resolution: {integrity: sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.9': + resolution: {integrity: sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.9': + resolution: {integrity: sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + resolution: {integrity: sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.9': + resolution: {integrity: sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.9': + resolution: {integrity: sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + resolution: {integrity: sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.9': + resolution: {integrity: sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.9': + resolution: {integrity: sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.9': + resolution: {integrity: sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.9': + resolution: {integrity: sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.9': + resolution: {integrity: sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.9': + resolution: {integrity: sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@26.6.1': resolution: {integrity: sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==} @@ -456,17 +604,194 @@ packages: cpu: [x64] os: [win32] + '@vitest/mocker@5.0.1': + resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.1': + resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + effect@4.0.0-rc.115: resolution: {integrity: sha512-ogYulZ5ffeOzrJqQrG0XOkDO5lKn2s9KNHhoxJy/6wKUkF0i12dlVXWA9TgsgQ+zV/JzizyG0+XMPepZEQX6vw==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@1.4.1: + resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + redis@6.2.1: resolution: {integrity: sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg==} engines: {node: '>= 20.0.0'} + rolldown@1.2.9: + resolution: {integrity: sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + tinybench@6.1.4: + resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -479,6 +804,115 @@ packages: resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} engines: {node: '>=22.19.0'} + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.1: + resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.1 + '@vitest/browser-preview': 5.0.1 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.1 + '@vitest/coverage-v8': 5.0.1 + '@vitest/ui': 5.0.1 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-jsonrpc@9.0.2: + resolution: {integrity: sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.18.3: + resolution: {integrity: sha512-DF49+WeV5py4zO5hhobp60jjsDSK0lAqA0OuKBLBvp423HPWQcCbhZz3JgyfIewsEz2f8U+X75xNIFHdiXZm2w==} + + vscode-languageserver-textdocument@1.0.14: + resolution: {integrity: sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==} + + vscode-languageserver-types@3.18.3: + resolution: {integrity: sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==} + + vscode-languageserver@10.1.1: + resolution: {integrity: sha512-/cG04Fiq99rEtoDrfPpv0k6NyUFlck5GNtZLQZv1rxkBkc/4FrJNvEKTN5k1Si3O1hRguP++ciYlX9cdjdYxFQ==} + hasBin: true + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + ws@8.21.3: resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} @@ -578,6 +1012,22 @@ snapshots: '@effect/tsgo-win32-arm64': 0.45.0 '@effect/tsgo-win32-x64': 0.45.0 + '@effect/vitest@4.0.0-rc.115(effect@4.0.0-rc.115)(vitest@5.0.1(@types/node@26.6.1)(vite@8.3.0(@types/node@26.6.1)))': + dependencies: + effect: 4.0.0-rc.115 + vitest: 5.0.1(@types/node@26.6.1)(vite@8.3.0(@types/node@26.6.1)) + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.150.0': {} + '@redis/bloom@6.2.1(@redis/client@6.2.1)': dependencies: '@redis/client': 6.2.1 @@ -598,6 +1048,62 @@ snapshots: dependencies: '@redis/client': 6.2.1 + '@rolldown/binding-android-arm-eabi@1.2.9': + optional: true + + '@rolldown/binding-android-arm64@1.2.9': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.9': + optional: true + + '@rolldown/binding-darwin-x64@1.2.9': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.9': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.9': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.9': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.9': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.9': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.9': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/node@26.6.1': dependencies: undici-types: 8.9.0 @@ -666,10 +1172,109 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@vitest/mocker@5.0.1(vite@8.3.0(@types/node@26.6.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.1 + estree-walker: 3.0.3 + magic-string: 1.4.1 + optionalDependencies: + vite: 8.3.0(@types/node@26.6.1) + + '@vitest/spy@5.0.1': {} + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + cluster-key-slot@1.1.2: {} + detect-libc@2.1.2: {} + effect@4.0.0-rc.115: {} + es-module-lexer@2.3.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@1.4.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + nanoid@3.3.19: {} + + obug@2.2.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + redis@6.2.1: dependencies: '@redis/bloom': 6.2.1(@redis/client@6.2.1) @@ -681,6 +1286,44 @@ snapshots: - '@node-rs/xxhash' - '@opentelemetry/api' + rolldown@1.2.9: + dependencies: + '@oxc-project/types': 0.150.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.9 + '@rolldown/binding-android-arm64': 1.2.9 + '@rolldown/binding-darwin-arm64': 1.2.9 + '@rolldown/binding-darwin-x64': 1.2.9 + '@rolldown/binding-freebsd-x64': 1.2.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.9 + '@rolldown/binding-linux-arm64-gnu': 1.2.9 + '@rolldown/binding-linux-arm64-musl': 1.2.9 + '@rolldown/binding-linux-ppc64-gnu': 1.2.9 + '@rolldown/binding-linux-s390x-gnu': 1.2.9 + '@rolldown/binding-linux-x64-gnu': 1.2.9 + '@rolldown/binding-linux-x64-musl': 1.2.9 + '@rolldown/binding-openharmony-arm64': 1.2.9 + '@rolldown/binding-win32-arm64-msvc': 1.2.9 + '@rolldown/binding-win32-x64-msvc': 1.2.9 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + tinybench@6.1.4: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -708,4 +1351,58 @@ snapshots: undici@8.10.2: {} + vite@8.3.0(@types/node@26.6.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.9 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.1 + fsevents: 2.3.3 + + vitest@5.0.1(@types/node@26.6.1)(vite@8.3.0(@types/node@26.6.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@26.6.1)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.1 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.3.0(@types/node@26.6.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.6.1 + transitivePeerDependencies: + - msw + + vscode-jsonrpc@9.0.2: {} + + vscode-languageserver-protocol@3.18.3: + dependencies: + vscode-jsonrpc: 9.0.2 + vscode-languageserver-types: 3.18.3 + + vscode-languageserver-textdocument@1.0.14: {} + + vscode-languageserver-types@3.18.3: {} + + vscode-languageserver@10.1.1: + dependencies: + vscode-languageserver-protocol: 3.18.3 + + vscode-uri@3.2.0: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + ws@8.21.3: {} diff --git a/src/Model.ts b/src/Model.ts new file mode 100644 index 0000000..edcfad5 --- /dev/null +++ b/src/Model.ts @@ -0,0 +1,319 @@ +/** + * The agent-facing vocabulary of the server. + * + * Everything an agent sends or receives is described here as `Schema`, so the + * same definitions drive MCP tool input/output schemas and the values the + * `lsp/` bridge produces. Positions are 1-based (line and column), the way + * agents and editors display them; the bridge converts to LSP's 0-based form. + */ +import { Option, Predicate, Schema, SchemaGetter } from "effect"; + +/** An optional field whose key is omitted, rather than set to `undefined`, in JSON output. */ +const optional = (schema: S) => + Schema.optional(schema).pipe( + Schema.encodeTo(Schema.optionalKey(schema), { + decode: SchemaGetter.passthrough(), + encode: SchemaGetter.transformOptional( + Option.filter(Predicate.isNotUndefined), + ), + }), + ); + +const positive = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); + +export const File = Schema.String.annotate({ + description: "File path, absolute or relative to the workspace root", +}); +export const Line = positive.annotate({ description: "1-based line number" }); +export const Column = positive.annotate({ + description: + "1-based column, counted in UTF-16 code units (plain characters for ASCII)", +}); + +export const Where = Schema.Struct({ + file: File, + line: Line, + column: optional( + positive.annotate({ + description: + "1-based column. Omit to place the cursor on `symbol`, or on the first non-blank character of the line", + }), + ), + symbol: optional( + Schema.String.annotate({ + description: + "Identifier on the line to place the cursor at; used when `column` is omitted", + }), + ), +}); +export type Where = typeof Where.Type; + +export const Range = Schema.Struct({ + line: Line, + column: Column, + endLine: Line, + endColumn: Column, +}); +export type Range = typeof Range.Type; + +export const Location = Schema.Struct({ + file: Schema.String, + ...Range.fields, + preview: Schema.String.annotate({ + description: "Trimmed source text of the starting line", + }), +}); +export type Location = typeof Location.Type; + +export const Hover = Schema.Struct({ + contents: Schema.String.annotate({ description: "Markdown" }), + range: optional(Range), +}); +export type Hover = typeof Hover.Type; + +export const GotoKind = Schema.Literals([ + "definition", + "typeDefinition", + "implementation", + "declaration", +]); +export const Goto = Schema.Struct({ + ...Where.fields, + kind: optional( + GotoKind.annotate({ + description: "What to navigate to (default: definition)", + }), + ), +}); +export type Goto = typeof Goto.Type; + +export const References = Schema.Struct({ + ...Where.fields, + includeDeclaration: optional( + Schema.Boolean.annotate({ description: "Default: true" }), + ), +}); +export type References = typeof References.Type; + +export interface DocumentSymbol extends Range { + readonly name: string; + readonly kind: string; + readonly detail?: string | undefined; + readonly container?: string | undefined; + readonly children?: ReadonlyArray | undefined; +} +export const DocumentSymbol: Schema.Codec = Schema.Struct({ + name: Schema.String, + kind: Schema.String, + detail: optional(Schema.String), + container: optional(Schema.String), + ...Range.fields, + children: optional( + Schema.Array( + Schema.suspend((): Schema.Codec => DocumentSymbol), + ), + ), +}); + +export const WorkspaceSymbol = Schema.Struct({ + name: Schema.String, + kind: Schema.String, + container: optional(Schema.String), + file: Schema.String, + line: optional(Line), + column: optional(Column), +}); +export type WorkspaceSymbol = typeof WorkspaceSymbol.Type; + +export const WorkspaceQuery = Schema.Struct({ + query: Schema.String.annotate({ + description: + "Symbol name or fragment; matching is fuzzy and server-defined", + }), + limit: optional(positive.annotate({ description: "Default: 50" })), +}); +export type WorkspaceQuery = typeof WorkspaceQuery.Type; + +export const Severity = Schema.Literals([ + "error", + "warning", + "information", + "hint", +]); +export type Severity = typeof Severity.Type; + +export const Diagnostic = Schema.Struct({ + file: Schema.String, + ...Range.fields, + severity: Severity, + code: optional(Schema.String), + source: optional(Schema.String), + message: Schema.String, + related: optional( + Schema.Array( + Schema.Struct({ + file: Schema.String, + line: Line, + column: Column, + message: Schema.String, + }), + ), + ), +}); +export type Diagnostic = typeof Diagnostic.Type; + +export const DiagnosticsQuery = Schema.Struct({ + file: optional( + File.annotate({ + description: + "Restrict to one file; omit for every file the server has reported on", + }), + ), + severity: optional( + Severity.annotate({ + description: + "Least severe level to include (default: hint, i.e. everything)", + }), + ), +}); +export type DiagnosticsQuery = typeof DiagnosticsQuery.Type; + +export const Completions = Schema.Struct({ + ...Where.fields, + prefix: optional( + Schema.String.annotate({ + description: "Keep only items whose label starts with this", + }), + ), + limit: optional(positive.annotate({ description: "Default: 20" })), +}); +export type Completions = typeof Completions.Type; + +export const Completion = Schema.Struct({ + label: Schema.String, + kind: Schema.String, + detail: optional(Schema.String), + documentation: optional(Schema.String), +}); +export type Completion = typeof Completion.Type; + +export const Signature = Schema.Struct({ + label: Schema.String, + documentation: optional(Schema.String), + parameters: Schema.Array(Schema.String), + activeParameter: optional(Schema.Int), +}); +export const SignatureHelp = Schema.Struct({ + signatures: Schema.Array(Signature), + active: Schema.Int, +}); +export type SignatureHelp = typeof SignatureHelp.Type; + +export const TextEdit = Schema.Struct({ + ...Range.fields, + newText: Schema.String, +}); +export const FileEdits = Schema.Struct({ + file: Schema.String, + edits: Schema.Array(TextEdit), +}); +export const WorkspaceEdit = Schema.Struct({ + applied: Schema.Boolean.annotate({ + description: "Whether the edits were written to disk", + }), + files: Schema.Array(FileEdits), + operations: Schema.Array(Schema.String).annotate({ + description: "File creations, renames, deletions and executed commands", + }), +}); +export type WorkspaceEdit = typeof WorkspaceEdit.Type; + +const apply = optional( + Schema.Boolean.annotate({ + description: + "Write the resulting edits to disk (default: false, preview only)", + }), +); + +export const Rename = Schema.Struct({ + ...Where.fields, + newName: Schema.String, + apply, +}); +export type Rename = typeof Rename.Type; + +export const Selection = Schema.Struct({ + file: File, + line: Line, + column: optional(Column.annotate({ description: "Default: start of line" })), + endLine: optional(Line.annotate({ description: "Default: same as line" })), + endColumn: optional( + Column.annotate({ description: "Default: end of endLine" }), + ), +}); +export type Selection = typeof Selection.Type; + +export const CodeActions = Schema.Struct({ + ...Selection.fields, + kind: optional( + Schema.String.annotate({ + description: + "Only actions of this kind prefix, e.g. quickfix, refactor, refactor.extract, source.organizeImports", + }), + ), +}); +export type CodeActions = typeof CodeActions.Type; + +export const CodeAction = Schema.Struct({ + title: Schema.String, + kind: optional(Schema.String), + preferred: Schema.Boolean, + fixes: Schema.Array(Schema.String).annotate({ + description: "Messages of the diagnostics this action addresses", + }), +}); +export type CodeAction = typeof CodeAction.Type; + +export const ApplyCodeAction = Schema.Struct({ + ...CodeActions.fields, + title: Schema.String.annotate({ + description: + "Exact title of an action returned by code_actions for the same selection", + }), +}); +export type ApplyCodeAction = typeof ApplyCodeAction.Type; + +export const Format = Schema.Struct({ + file: File, + tabSize: optional(positive.annotate({ description: "Default: 4" })), + insertSpaces: optional( + Schema.Boolean.annotate({ description: "Default: true" }), + ), + apply, +}); +export type Format = typeof Format.Type; + +export const CallDirection = Schema.Literals(["incoming", "outgoing"]); +export const CallHierarchy = Schema.Struct({ + ...Where.fields, + direction: optional( + CallDirection.annotate({ + description: + "incoming: who calls this; outgoing: what this calls (default: incoming)", + }), + ), +}); +export type CallHierarchy = typeof CallHierarchy.Type; + +export const Call = Schema.Struct({ + name: Schema.String, + kind: Schema.String, + detail: optional(Schema.String), + file: Schema.String, + line: Line, + column: Column, + sites: Schema.Array(Schema.Struct({ line: Line, column: Column })).annotate({ + description: "Call sites, located in the caller's file", + }), +}); +export type Call = typeof Call.Type; diff --git a/src/Resources.ts b/src/Resources.ts new file mode 100644 index 0000000..f9effb6 --- /dev/null +++ b/src/Resources.ts @@ -0,0 +1,90 @@ +/** + * MCP resources and notifications: the server's identity and capabilities, + * live diagnostics (subscribable), and the language server's log messages + * forwarded as MCP log notifications. + */ +import { Effect, Layer, Schema, Stream } from "effect"; +import { McpServer } from "effect/unstable/ai"; +import { Diagnostics } from "./lsp/Diagnostics.ts"; +import { Documents } from "./lsp/Documents.ts"; +import * as Editor from "./lsp/Editor.ts"; +import { LanguageServer } from "./lsp/LanguageServer.ts"; + +const json = (value: unknown) => JSON.stringify(value, null, 2); +const diagnosticsUri = "lsp://diagnostics"; +const fileDiagnosticsUri = (file: string) => + `${diagnosticsUri}/${encodeURIComponent(file)}`; + +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + const lsp = yield* LanguageServer; + const docs = yield* Documents; + const diagnostics = yield* Diagnostics; + const mcp = yield* McpServer.McpServer; + + yield* McpServer.registerResource({ + uri: "lsp://server", + name: "Language server", + description: + "Name, version, workspace root and capabilities of the connected language server", + mimeType: "application/json", + content: Effect.succeed( + json({ + ...lsp.serverInfo, + root: lsp.root, + capabilities: lsp.capabilities, + }), + ), + }); + + yield* McpServer.registerResource({ + uri: diagnosticsUri, + name: "Diagnostics", + description: + "All diagnostics the language server has reported. Subscribe to be told when they change.", + mimeType: "application/json", + content: Effect.map(Editor.diagnostics({}), json), + }); + + yield* McpServer.registerResource`lsp://diagnostics/${Schema.String}`({ + name: "File diagnostics", + description: + "Diagnostics of one file (path URL-encoded, relative to the workspace root)", + mimeType: "application/json", + completion: { + param0: (input) => + Effect.map(docs.opened, (uris) => + uris.map(docs.file).filter((file) => file.startsWith(input)), + ), + }, + content: (_, file) => + Effect.map( + Editor.diagnostics({ file: decodeURIComponent(file) }), + json, + ), + }); + + const updated = (uri: string) => + mcp.notifications["notifications/resources/updated"]({ uri }); + yield* diagnostics.changes.pipe( + Stream.runForEach((uri) => + Effect.all([ + updated(diagnosticsUri), + updated(fileDiagnosticsUri(docs.file(uri))), + ]), + ), + Effect.forkScoped, + ); + + yield* lsp.logs.pipe( + Stream.runForEach(({ level, message }) => + mcp.notifications["notifications/message"]({ + level, + logger: "lsp", + data: message, + }), + ), + Effect.forkScoped, + ); + }), +); diff --git a/src/Server.ts b/src/Server.ts new file mode 100644 index 0000000..6122984 --- /dev/null +++ b/src/Server.ts @@ -0,0 +1,69 @@ +/** + * Wires the language server, the headless editor and the MCP server together. + * + * Build order matters: the language server initializes first (so tool + * registration can consult its capabilities), then the editor services, then + * the MCP transport, then tools and resources are registered. + */ +import { createServer } from "node:http"; +import { NodeHttpServer } from "@effect/platform-node"; +import { Effect, Layer } from "effect"; +import { McpProtocol, McpServer } from "effect/unstable/ai"; +import { HttpRouter } from "effect/unstable/http"; +import { Diagnostics } from "./lsp/Diagnostics.ts"; +import * as Documents from "./lsp/Documents.ts"; +import * as LanguageServer from "./lsp/LanguageServer.ts"; +import * as Resources from "./Resources.ts"; +import * as Tools from "./Tools.ts"; + +export type Transport = + | { readonly _tag: "stdio" } + | { readonly _tag: "http"; readonly port: number }; + +export interface Options { + readonly transport: Transport; + readonly server: LanguageServer.Options; + readonly documents: Documents.Options; +} + +const info = { + name: "lsp-mcp", + version: "0.1.0", + protocols: [ + McpProtocol.v2025_11_25, + McpProtocol.v2025_06_18, + McpProtocol.v2025_03_26, + McpProtocol.v2024_11_05, + ], +} as const; + +const transport = (transport: Transport) => + transport._tag === "stdio" + ? McpServer.layerStdio(info) + : HttpRouter.serve(McpServer.layerHttp({ ...info, path: "/mcp" }), { + disableLogger: true, + }).pipe( + Layer.provide( + NodeHttpServer.layer(createServer, { port: transport.port }), + ), + ); + +/** Registers the tools the connected server can serve. */ +export const tools = Layer.effectDiscard( + Effect.gen(function* () { + const { capabilities } = yield* LanguageServer.LanguageServer; + yield* McpServer.registerToolkit(Tools.supportedBy(capabilities)); + }), +).pipe(Layer.provide(Tools.layer)); + +export const layer = (options: Options) => + Layer.mergeAll(tools, Resources.layer).pipe( + Layer.provide(transport(options.transport)), + Layer.provide( + Layer.mergeAll( + Documents.Documents.layer(options.documents), + Diagnostics.layer, + ), + ), + Layer.provide(LanguageServer.LanguageServer.layer(options.server)), + ); diff --git a/src/Tools.ts b/src/Tools.ts new file mode 100644 index 0000000..794436b --- /dev/null +++ b/src/Tools.ts @@ -0,0 +1,167 @@ +/** + * The MCP tools: one per editor operation, described for agents. Tools whose + * capability the connected language server lacks are not offered. + */ +import { Schema } from "effect"; +import { Tool, Toolkit } from "effect/unstable/ai"; +import type { ServerCapabilities } from "vscode-languageserver-protocol"; +import { Diagnostics } from "./lsp/Diagnostics.ts"; +import { DocumentError, Documents } from "./lsp/Documents.ts"; +import * as Editor from "./lsp/Editor.ts"; +import { LanguageServer, LspError } from "./lsp/LanguageServer.ts"; +import * as Model from "./Model.ts"; + +const Failure = Schema.Union([LspError, DocumentError]); + +const define = < + const Name extends string, + Parameters extends Schema.Constraint, + Success extends Schema.Constraint, +>( + name: Name, + options: { + readonly description: string; + readonly parameters: Parameters; + readonly success: Success; + readonly mutates?: boolean; + }, +) => + Tool.make(name, { + description: options.description, + parameters: options.parameters, + success: options.success, + failure: Failure, + dependencies: [LanguageServer, Documents, Diagnostics], + }) + .annotate(Tool.Readonly, !options.mutates) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, !options.mutates) + .annotate(Tool.OpenWorld, false); + +const positions = + "Positions are 1-based lines and columns; instead of a column you may name the `symbol` on that line to point at."; +const preview = + "Without `apply` the edits are only previewed; with `apply: true` they are written to disk."; + +export const Tools = Toolkit.make( + define("hover", { + description: `Type signature and documentation of the symbol at a position, as an editor shows on hover. ${positions}`, + parameters: Model.Where, + success: Schema.NullOr(Model.Hover), + }), + define("definition", { + description: `Where the symbol at a position is defined (or its type definition, implementations, or declaration). Returns locations with a preview of each target line. ${positions}`, + parameters: Model.Goto, + success: Schema.Array(Model.Location), + }), + define("references", { + description: `Every place the symbol at a position is used across the workspace, with a preview of each line. ${positions}`, + parameters: Model.References, + success: Schema.Array(Model.Location), + }), + define("document_symbols", { + description: + "Outline of a file: its functions, classes, methods, variables and so on, with their ranges, as a tree.", + parameters: Schema.Struct({ file: Model.File }), + success: Schema.Array(Model.DocumentSymbol), + }), + define("workspace_symbols", { + description: + "Find symbols by name across the whole workspace, including files you have not opened.", + parameters: Model.WorkspaceQuery, + success: Schema.Array(Model.WorkspaceSymbol), + }), + define("diagnostics", { + description: + "Errors, warnings and hints from the language server. Files are re-read from disk first, so call this right after editing to check your changes. Without `file`, returns everything reported so far.", + parameters: Model.DiagnosticsQuery, + success: Schema.Array(Model.Diagnostic), + }), + define("completions", { + description: `Completion items at a position: discover members, imports, or valid identifiers the server suggests. ${positions}`, + parameters: Model.Completions, + success: Schema.Array(Model.Completion), + }), + define("signature_help", { + description: `Signatures and parameters of the call surrounding a position. ${positions}`, + parameters: Model.Where, + success: Model.SignatureHelp, + }), + define("rename", { + description: `Rename the symbol at a position everywhere it is referenced, across files. ${positions} ${preview}`, + parameters: Model.Rename, + success: Model.WorkspaceEdit, + mutates: true, + }), + define("code_actions", { + description: + "Quick fixes and refactorings the server offers for a selection (whole lines unless columns are given), including fixes for the diagnostics there. Apply one with apply_code_action.", + parameters: Model.CodeActions, + success: Schema.Array(Model.CodeAction), + }), + define("apply_code_action", { + description: + "Apply a code action by title, for the same selection given to code_actions. Edits are written to disk.", + parameters: Model.ApplyCodeAction, + success: Model.WorkspaceEdit, + mutates: true, + }), + define("format", { + description: `Format a whole file with the language server's formatter. ${preview}`, + parameters: Model.Format, + success: Model.WorkspaceEdit, + mutates: true, + }), + define("call_hierarchy", { + description: `Callers of the function at a position (incoming) or the functions it calls (outgoing). ${positions}`, + parameters: Model.CallHierarchy, + success: Schema.Array(Model.Call), + }), +); + +export const layer = Tools.toLayer({ + hover: Editor.hover, + definition: Editor.definition, + references: Editor.references, + document_symbols: Editor.documentSymbols, + workspace_symbols: Editor.workspaceSymbols, + diagnostics: Editor.diagnostics, + completions: Editor.completions, + signature_help: Editor.signatureHelp, + rename: Editor.rename, + code_actions: Editor.codeActions, + apply_code_action: Editor.applyCodeAction, + format: Editor.format, + call_hierarchy: Editor.callHierarchy, +}); + +const requirements: Record< + keyof typeof Tools.tools, + (c: ServerCapabilities) => unknown +> = { + hover: (c) => c.hoverProvider, + definition: (c) => + c.definitionProvider ?? + c.typeDefinitionProvider ?? + c.implementationProvider ?? + c.declarationProvider, + references: (c) => c.referencesProvider, + document_symbols: (c) => c.documentSymbolProvider, + workspace_symbols: (c) => c.workspaceSymbolProvider, + diagnostics: () => true, + completions: (c) => c.completionProvider, + signature_help: (c) => c.signatureHelpProvider, + rename: (c) => c.renameProvider, + code_actions: (c) => c.codeActionProvider, + apply_code_action: (c) => c.codeActionProvider, + format: (c) => c.documentFormattingProvider, + call_hierarchy: (c) => c.callHierarchyProvider, +}; + +/** The subset of `Tools` the server can serve; handled by the same `layer`. */ +export const supportedBy = (capabilities: ServerCapabilities): typeof Tools => + Toolkit.make( + ...Object.values(Tools.tools).filter((tool) => + requirements[tool.name](capabilities), + ), + ) as typeof Tools; diff --git a/src/lsp/Diagnostics.ts b/src/lsp/Diagnostics.ts new file mode 100644 index 0000000..507aa10 --- /dev/null +++ b/src/lsp/Diagnostics.ts @@ -0,0 +1,134 @@ +/** + * Diagnostics reconcile LSP's push model with the agent's pull model. + * + * Published diagnostics are accumulated per URI. Asking for a document's + * diagnostics either pulls them (when the server supports + * `textDocument/diagnostic`) or waits for the push that follows the document's + * latest version to settle, bounded by a timeout. + */ +import { + Context, + Effect, + HashMap, + Layer, + Option, + PubSub, + Ref, + Stream, +} from "effect"; +import { + type Diagnostic, + DocumentDiagnosticReportKind, + DocumentDiagnosticRequest, + PublishDiagnosticsNotification, + type PublishDiagnosticsParams, + WorkspaceDiagnosticRequest, +} from "vscode-languageserver-protocol"; +import type { TextDocument } from "vscode-languageserver-textdocument"; +import { LanguageServer, type LspError } from "./LanguageServer.ts"; + +/** Quiet period after a publish before diagnostics count as settled. */ +const settle = "250 millis"; +/** How long to wait for a server that never publishes for a change. */ +const patience = "5 seconds"; + +const make = Effect.gen(function* () { + const lsp = yield* LanguageServer; + const state = yield* Ref.make( + HashMap.empty(), + ); + const updates = yield* PubSub.unbounded(); + const settled = new Map(); + const provider = lsp.capabilities.diagnosticProvider; + + const publish = (params: PublishDiagnosticsParams) => + Ref.update(state, HashMap.set(params.uri, params)).pipe( + Effect.andThen(PubSub.publish(updates, params.uri)), + ); + yield* lsp + .notifications(PublishDiagnosticsNotification.type) + .pipe(Stream.runForEach(publish), Effect.forkScoped); + + const current = (uri: string) => + Ref.get(state).pipe( + Effect.map((map) => Option.getOrUndefined(HashMap.get(map, uri))), + ); + + const pull = Effect.fn("Diagnostics.pull")(function* (doc: TextDocument) { + const report = yield* lsp.request(DocumentDiagnosticRequest.type, { + textDocument: { uri: doc.uri }, + }); + if (report.kind === DocumentDiagnosticReportKind.Full) { + yield* publish({ + uri: doc.uri, + version: doc.version, + diagnostics: report.items, + }); + } + return (yield* current(doc.uri))?.diagnostics ?? []; + }); + + const awaitPush = Effect.fn("Diagnostics.awaitPush")(function* ( + doc: TextDocument, + ) { + if (settled.get(doc.uri) !== doc.version) { + yield* Effect.scoped( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(updates); + const fresh = (yield* current(doc.uri))?.version === doc.version; + yield* Stream.fromSubscription(subscription).pipe( + Stream.filter((uri) => uri === doc.uri), + Stream.debounce(settle), + Stream.runHead, + Effect.timeoutOption(fresh ? settle : patience), + ); + }), + ); + settled.set(doc.uri, doc.version); + } + return (yield* current(doc.uri))?.diagnostics ?? []; + }); + + const all = Effect.gen(function* () { + if (provider?.workspaceDiagnostics) { + const report = yield* lsp.request(WorkspaceDiagnosticRequest.type, { + previousResultIds: [], + }); + for (const item of report.items) { + if (item.kind === DocumentDiagnosticReportKind.Full) { + yield* publish({ + uri: item.uri, + diagnostics: item.items, + ...(item.version === null ? {} : { version: item.version }), + }); + } + } + } + return Array.from(HashMap.values(yield* Ref.get(state))); + }); + + return Diagnostics.of({ + all, + forDocument: provider ? pull : awaitPush, + changes: Stream.fromPubSub(updates), + }); +}); + +export class Diagnostics extends Context.Service< + Diagnostics, + { + /** Everything the server has reported, per URI. */ + readonly all: Effect.Effect< + ReadonlyArray, + LspError + >; + /** Diagnostics matching the document's current version. */ + readonly forDocument: ( + doc: TextDocument, + ) => Effect.Effect, LspError>; + /** URIs whose diagnostics changed. */ + readonly changes: Stream.Stream; + } +>()("lsp-mcp/lsp/Diagnostics") { + static readonly layer = Layer.effect(Diagnostics, make); +} diff --git a/src/lsp/Documents.ts b/src/lsp/Documents.ts new file mode 100644 index 0000000..c7355c2 --- /dev/null +++ b/src/lsp/Documents.ts @@ -0,0 +1,366 @@ +/** + * The headless editor's document store. + * + * Documents are opened in the language server on demand and kept in an LRU + * set; the least recently used one is closed when the capacity is exceeded. + * Every access re-checks the file on disk and, if it changed, pushes the new + * content as `didChange` + `didSave`, so edits agents make with other tools + * are visible to the server without any explicit synchronization step. + */ +import { + Context, + Duration, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + type PlatformError, + Schema, + ScopedCache, + Semaphore, +} from "effect"; +import { + ApplyWorkspaceEditRequest, + CreateFile, + DeleteFile, + DidChangeTextDocumentNotification, + DidCloseTextDocumentNotification, + DidOpenTextDocumentNotification, + DidSaveTextDocumentNotification, + RenameFile, + type TextDocumentEdit, + type TextEdit, + type WorkspaceEdit, +} from "vscode-languageserver-protocol"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { URI } from "vscode-uri"; +import { LanguageServer } from "./LanguageServer.ts"; + +export interface Options { + /** How many documents stay open in the server before the least recently used is closed. */ + readonly capacity: number; + /** Extra file extension (without dot) to LSP `languageId` mappings. */ + readonly languages: Readonly>; +} + +export class DocumentError extends Schema.TaggedError()( + "DocumentError", + { + file: Schema.String, + message: Schema.String, + }, +) {} + +export type Change = TextDocumentEdit | CreateFile | RenameFile | DeleteFile; + +export class Documents extends Context.Service< + Documents, + { + /** URI for a path that is absolute or relative to the workspace root. */ + readonly uri: (file: string) => string; + /** Display path for a URI: relative to the root when inside it. */ + readonly file: (uri: string) => string; + /** Open (or refresh) a document in the server and return its current text. */ + readonly open: (file: string) => Effect.Effect; + /** Current content of a URI without opening it in the server. */ + readonly peek: (uri: string) => Effect.Effect; + /** Replace an open document's content on disk and in the server. */ + readonly write: ( + uri: string, + text: string, + ) => Effect.Effect; + /** Apply a server-provided edit to disk, in order, refusing stale versions. */ + readonly applyEdit: ( + edit: WorkspaceEdit, + ) => Effect.Effect; + readonly opened: Effect.Effect>; + } +>()("lsp-mcp/lsp/Documents") { + static readonly layer = (options: Options) => + Layer.effect(Documents, make(options)); +} + +/** The ordered list of changes described by a `WorkspaceEdit`. */ +export const changes = (edit: WorkspaceEdit): ReadonlyArray => + edit.documentChanges ?? + Object.entries(edit.changes ?? {}).map(([uri, edits]) => ({ + textDocument: { uri, version: null }, + edits, + })); + +const textEdits = (edit: TextDocumentEdit): Array => + edit.edits.filter((e): e is TextEdit => "newText" in e); + +interface Entry { + readonly doc: TextDocument; + stamp: string; +} + +const make = Effect.fn("Documents.make")(function* (options: Options) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const lsp = yield* LanguageServer; + const lock = yield* Semaphore.make(1); + const sync = lsp.capabilities.textDocumentSync; + const save = typeof sync === "object" ? sync.save : undefined; + + const uri = (file: string) => + URI.file(path.resolve(lsp.root, file)).toString(); + const fsPath = (uri: string) => URI.parse(uri).fsPath; + const file = (uri: string) => { + const parsed = URI.parse(uri); + if (parsed.scheme !== "file") return uri; + const relative = path.relative(lsp.root, parsed.fsPath); + return relative.startsWith("..") || path.isAbsolute(relative) + ? parsed.fsPath + : relative; + }; + const io = (uri: string) => (error: PlatformError.PlatformError) => + new DocumentError({ file: file(uri), message: error.message }); + const read = (uri: string) => + fs.readFileString(fsPath(uri)).pipe(Effect.mapError(io(uri))); + const stamp = (uri: string) => + fs.stat(fsPath(uri)).pipe( + Effect.map( + (info) => + `${Option.getOrUndefined(info.mtime)?.getTime()}:${info.size}`, + ), + Effect.mapError(io(uri)), + ); + + const languageId = (uri: string) => { + const name = path.basename(fsPath(uri)).toLowerCase(); + const ext = path.extname(name).slice(1); + return ( + options.languages[ext] ?? languages[ext] ?? languages[name] ?? "plaintext" + ); + }; + + const cache = yield* ScopedCache.makeWith({ + capacity: options.capacity, + timeToLive: (exit) => + Exit.isFailure(exit) ? Duration.zero : Duration.infinity, + lookup: (uri) => + Effect.gen(function* () { + const [text, current] = yield* Effect.all([read(uri), stamp(uri)]); + const doc = TextDocument.create(uri, languageId(uri), 1, text); + yield* Effect.acquireRelease( + lsp.notify(DidOpenTextDocumentNotification.type, { + textDocument: { + uri, + languageId: doc.languageId, + version: doc.version, + text, + }, + }), + () => + lsp.notify(DidCloseTextDocumentNotification.type, { + textDocument: { uri }, + }), + ); + return { doc, stamp: current } satisfies Entry; + }), + }); + + const change = Effect.fn("Documents.change")(function* ( + entry: Entry, + text: string, + ) { + const { uri } = entry.doc; + const version = entry.doc.version + 1; + TextDocument.update(entry.doc, [{ text }], version); + yield* lsp.notify(DidChangeTextDocumentNotification.type, { + textDocument: { uri, version }, + contentChanges: [{ text }], + }); + if (save) { + yield* lsp.notify(DidSaveTextDocumentNotification.type, { + textDocument: { uri }, + ...(typeof save === "object" && save.includeText ? { text } : {}), + }); + } + }); + + /** Bring an open document in line with the file on disk. */ + const refresh = Effect.fn("Documents.refresh")(function* (uri: string) { + const entry = yield* ScopedCache.get(cache, uri); + const current = yield* stamp(uri).pipe( + Effect.tapError(() => ScopedCache.invalidate(cache, uri)), + ); + if (current !== entry.stamp) { + const text = yield* read(uri); + entry.stamp = current; + if (text !== entry.doc.getText()) yield* change(entry, text); + } + return entry.doc; + }); + + const open = (file: string) => lock.withPermits(1)(refresh(uri(file))); + + const peek = (uri: string) => + Effect.flatMap(ScopedCache.has(cache, uri), (open) => + open + ? lock.withPermits(1)(refresh(uri)) + : Effect.map(read(uri), (text) => + TextDocument.create(uri, "", 0, text), + ), + ); + + /** Persist new content for an open document and tell the server. */ + const store = Effect.fn("Documents.store")(function* ( + uri: string, + text: string, + ) { + const entry = yield* ScopedCache.get(cache, uri); + yield* fs.writeFileString(fsPath(uri), text).pipe(Effect.mapError(io(uri))); + entry.stamp = yield* stamp(uri); + if (text !== entry.doc.getText()) yield* change(entry, text); + }); + + const write = (uri: string, text: string) => + lock.withPermits(1)(store(uri, text)); + + const forget = (uri: string) => ScopedCache.invalidate(cache, uri); + + const apply = Effect.fn("Documents.applyChange")(function* (change: Change) { + if (CreateFile.is(change)) { + const target = fsPath(change.uri); + if ( + change.options?.ignoreIfExists && + (yield* fs.exists(target).pipe(Effect.mapError(io(change.uri)))) + ) + return; + yield* fs + .makeDirectory(path.dirname(target), { recursive: true }) + .pipe( + Effect.andThen(fs.writeFileString(target, "")), + Effect.mapError(io(change.uri)), + ); + } else if (RenameFile.is(change)) { + yield* Effect.all([forget(change.oldUri), forget(change.newUri)]); + yield* fs + .rename(fsPath(change.oldUri), fsPath(change.newUri)) + .pipe(Effect.mapError(io(change.oldUri))); + } else if (DeleteFile.is(change)) { + yield* forget(change.uri); + yield* fs + .remove(fsPath(change.uri), { + recursive: change.options?.recursive ?? false, + }) + .pipe(Effect.mapError(io(change.uri))); + } else { + const { uri, version } = change.textDocument; + const doc = yield* refresh(uri); + if ( + version !== null && + version !== undefined && + version !== doc.version + ) { + return yield* new DocumentError({ + file: file(uri), + message: "document changed since the edit was computed", + }); + } + yield* store(uri, TextDocument.applyEdits(doc, textEdits(change))); + } + }); + + const applyEdit = (edit: WorkspaceEdit) => + lock.withPermits(1)( + Effect.forEach(changes(edit), apply, { discard: true }), + ); + + yield* lsp.onRequest(ApplyWorkspaceEditRequest.type, ({ edit }) => + applyEdit(edit).pipe( + Effect.as({ applied: true }), + Effect.catch((error) => + Effect.succeed({ applied: false, failureReason: error.message }), + ), + ), + ); + + return Documents.of({ + uri, + file, + open, + peek, + write, + applyEdit, + opened: ScopedCache.keys(cache), + }); +}); + +const languages: Record = { + ts: "typescript", + mts: "typescript", + cts: "typescript", + tsx: "typescriptreact", + js: "javascript", + mjs: "javascript", + cjs: "javascript", + jsx: "javascriptreact", + json: "json", + jsonc: "jsonc", + py: "python", + pyi: "python", + rs: "rust", + go: "go", + c: "c", + h: "c", + cc: "cpp", + cpp: "cpp", + cxx: "cpp", + hpp: "cpp", + hh: "cpp", + java: "java", + kt: "kotlin", + kts: "kotlin", + scala: "scala", + cs: "csharp", + fs: "fsharp", + swift: "swift", + rb: "ruby", + php: "php", + lua: "lua", + zig: "zig", + hs: "haskell", + ml: "ocaml", + mli: "ocaml", + ex: "elixir", + exs: "elixir", + erl: "erlang", + clj: "clojure", + cljs: "clojurescript", + dart: "dart", + elm: "elm", + nix: "nix", + r: "r", + jl: "julia", + vue: "vue", + svelte: "svelte", + astro: "astro", + html: "html", + css: "css", + scss: "scss", + less: "less", + md: "markdown", + yaml: "yaml", + yml: "yaml", + toml: "toml", + xml: "xml", + sql: "sql", + sh: "shellscript", + bash: "shellscript", + zsh: "shellscript", + fish: "fish", + tf: "terraform", + proto: "proto", + graphql: "graphql", + gql: "graphql", + tex: "latex", + vim: "vim", + dockerfile: "dockerfile", + makefile: "makefile", +}; diff --git a/src/lsp/Editor.ts b/src/lsp/Editor.ts new file mode 100644 index 0000000..f23834b --- /dev/null +++ b/src/lsp/Editor.ts @@ -0,0 +1,608 @@ +/** + * The operations an agent performs on the headless editor. + * + * Each function resolves agent-facing input (paths, 1-based positions, + * symbols on a line) to LSP requests and presents the results in the + * `Model` vocabulary. These functions are the MCP tool handlers. + */ +import { Effect, Option } from "effect"; +import { + CallHierarchyIncomingCallsRequest, + CallHierarchyOutgoingCallsRequest, + CallHierarchyPrepareRequest, + CodeAction, + CodeActionRequest, + CodeActionResolveRequest, + CodeActionTriggerKind, + type Command, + CompletionItemKind, + CompletionRequest, + CompletionTriggerKind, + DeclarationRequest, + DefinitionRequest, + type Diagnostic, + DocumentFormattingRequest, + type DocumentSymbol, + DocumentSymbolRequest, + ExecuteCommandRequest, + type Hover, + HoverRequest, + ImplementationRequest, + type Location, + type LocationLink, + type MarkupContent, + type Position, + type Range, + ReferencesRequest, + RenameRequest, + type ServerCapabilities, + SignatureHelpRequest, + SymbolKind, + TextDocumentEdit, + TypeDefinitionRequest, + type WorkspaceEdit, + WorkspaceSymbolRequest, +} from "vscode-languageserver-protocol"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import type * as Model from "../Model.ts"; +import { Diagnostics } from "./Diagnostics.ts"; +import { changes, Documents } from "./Documents.ts"; +import { LanguageServer, LspError } from "./LanguageServer.ts"; + +// --- Presentation ---------------------------------------------------------- + +const range = (r: Range): Model.Range => ({ + line: r.start.line + 1, + column: r.start.character + 1, + endLine: r.end.line + 1, + endColumn: r.end.character + 1, +}); + +const lineText = (doc: TextDocument, line: number) => + doc + .getText() + .slice( + doc.offsetAt({ line, character: 0 }), + doc.offsetAt({ line: line + 1, character: 0 }), + ) + .replace(/\r?\n$/, ""); + +const preview = (doc: TextDocument | undefined, line: number) => + doc === undefined ? "" : lineText(doc, line).trim(); + +const markup = (content: Hover["contents"] | MarkupContent | string): string => + typeof content === "string" + ? content + : Array.isArray(content) + ? content.map(markup).join("\n\n") + : "kind" in content + ? content.value + : `\`\`\`${content.language}\n${content.value}\n\`\`\``; + +/** `{ Class: 5 }` → `{ 5: "class" }`, `{ TypeParameter: 26 }` → `{ 26: "typeParameter" }`. */ +const kindNames = (kinds: Record): Record => + Object.fromEntries( + Object.entries(kinds).map(([name, value]) => [ + value, + name[0]?.toLowerCase() + name.slice(1), + ]), + ); +const symbolKinds = kindNames(SymbolKind); +const completionKinds = kindNames(CompletionItemKind); +const severities = [ + "error", + "warning", + "information", + "hint", +] as const satisfies ReadonlyArray; + +// --- Resolution ------------------------------------------------------------ + +const unsupported = (feature: string) => + new LspError({ + method: feature, + message: `the language server does not support ${feature}`, + }); + +const ensure = ( + capabilities: ServerCapabilities, + key: keyof ServerCapabilities, +) => (capabilities[key] ? Effect.void : Effect.fail(unsupported(key))); + +/** Turn agent-facing `Where` into an LSP text document position. */ +const locate = Effect.fn("Editor.locate")(function* (where: Model.Where) { + const docs = yield* Documents; + const doc = yield* docs.open(where.file); + const line = where.line - 1; + if (line >= doc.lineCount) { + return yield* new LspError({ + method: "locate", + message: `${where.file} has only ${doc.lineCount} lines`, + }); + } + const text = lineText(doc, line); + const character = + where.column !== undefined + ? where.column - 1 + : where.symbol !== undefined + ? yield* Effect.orElseSucceed(findSymbol(text, where.symbol), () => -1) + : Math.max(0, text.search(/\S/)); + if (character < 0) { + return yield* new LspError({ + method: "locate", + message: `"${where.symbol}" does not occur on line ${where.line}`, + }); + } + const position: Position = { + line, + character: Math.min(character, text.length), + }; + return { doc, position, textDocument: { uri: doc.uri } }; +}); + +const findSymbol = (text: string, symbol: string) => { + const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const index = text.search( + new RegExp(`(?= 0 ? index : text.indexOf(symbol); + return found >= 0 ? Effect.succeed(found) : Effect.fail(found); +}; + +const locations = Effect.fn("Editor.locations")(function* ( + items: ReadonlyArray, +) { + const docs = yield* Documents; + const targets = items.map((item) => + "targetUri" in item + ? { uri: item.targetUri, range: item.targetSelectionRange } + : item, + ); + const previews = new Map( + yield* Effect.forEach([...new Set(targets.map((t) => t.uri))], (uri) => + docs.peek(uri).pipe( + Effect.option, + Effect.map((doc) => [uri, Option.getOrUndefined(doc)] as const), + ), + ), + ); + return targets.map( + ({ uri, range: r }): Model.Location => ({ + file: docs.file(uri), + ...range(r), + preview: preview(previews.get(uri), r.start.line), + }), + ); +}); + +const workspaceEdit = Effect.fn("Editor.workspaceEdit")(function* ( + edit: WorkspaceEdit, + apply: boolean, +) { + const docs = yield* Documents; + const files: Array = []; + const operations: Array = []; + for (const change of changes(edit)) { + if (TextDocumentEdit.is(change)) { + files.push({ + file: docs.file(change.textDocument.uri), + edits: change.edits.flatMap((e) => + "newText" in e ? [{ ...range(e.range), newText: e.newText }] : [], + ), + }); + } else if (change.kind === "rename") { + operations.push( + `rename ${docs.file(change.oldUri)} -> ${docs.file(change.newUri)}`, + ); + } else { + operations.push(`${change.kind} ${docs.file(change.uri)}`); + } + } + if (apply) yield* docs.applyEdit(edit); + return { applied: apply, files, operations } satisfies Model.WorkspaceEdit; +}); + +// --- Operations ------------------------------------------------------------ + +export const hover = Effect.fn("Editor.hover")(function* (where: Model.Where) { + const lsp = yield* LanguageServer; + const { textDocument, position } = yield* locate(where); + const result = yield* lsp.request(HoverRequest.type, { + textDocument, + position, + }); + return result === null + ? null + : ({ + contents: markup(result.contents), + range: result.range && range(result.range), + } satisfies Model.Hover); +}); + +const gotoRequests = { + definition: DefinitionRequest.type, + typeDefinition: TypeDefinitionRequest.type, + implementation: ImplementationRequest.type, + declaration: DeclarationRequest.type, +}; + +export const definition = Effect.fn("Editor.definition")(function* ({ + kind = "definition", + ...where +}: Model.Goto) { + const lsp = yield* LanguageServer; + yield* ensure(lsp.capabilities, `${kind}Provider`); + const { textDocument, position } = yield* locate(where); + const result = yield* lsp.request( + gotoRequests[kind] as typeof DefinitionRequest.type, + { textDocument, position }, + ); + return yield* locations( + result === null ? [] : Array.isArray(result) ? result : [result], + ); +}); + +export const references = Effect.fn("Editor.references")(function* ({ + includeDeclaration = true, + ...where +}: Model.References) { + const lsp = yield* LanguageServer; + const { textDocument, position } = yield* locate(where); + const result = yield* lsp.request(ReferencesRequest.type, { + textDocument, + position, + context: { includeDeclaration }, + }); + return yield* locations(result ?? []); +}); + +export const documentSymbols = Effect.fn("Editor.documentSymbols")(function* ({ + file, +}: { + readonly file: string; +}) { + const lsp = yield* LanguageServer; + const docs = yield* Documents; + const doc = yield* docs.open(file); + const result = yield* lsp.request(DocumentSymbolRequest.type, { + textDocument: { uri: doc.uri }, + }); + const present = (symbol: DocumentSymbol): Model.DocumentSymbol => ({ + name: symbol.name, + kind: symbolKinds[symbol.kind] ?? "unknown", + detail: symbol.detail || undefined, + ...range(symbol.range), + children: symbol.children?.length + ? symbol.children.map(present) + : undefined, + }); + return (result ?? []).map( + (symbol): Model.DocumentSymbol => + "range" in symbol + ? present(symbol) + : { + name: symbol.name, + kind: symbolKinds[symbol.kind] ?? "unknown", + container: symbol.containerName || undefined, + ...range(symbol.location.range), + }, + ); +}); + +export const workspaceSymbols = Effect.fn("Editor.workspaceSymbols")( + function* ({ query, limit = 50 }: Model.WorkspaceQuery) { + const lsp = yield* LanguageServer; + const docs = yield* Documents; + const result = yield* lsp.request(WorkspaceSymbolRequest.type, { query }); + return (result ?? []).slice(0, limit).map( + (symbol): Model.WorkspaceSymbol => ({ + name: symbol.name, + kind: symbolKinds[symbol.kind] ?? "unknown", + container: symbol.containerName || undefined, + file: docs.file(symbol.location.uri), + ...("range" in symbol.location + ? { + line: symbol.location.range.start.line + 1, + column: symbol.location.range.start.character + 1, + } + : {}), + }), + ); + }, +); + +export const diagnostics = Effect.fn("Editor.diagnostics")(function* ({ + file, + severity = "hint", +}: Model.DiagnosticsQuery) { + const docs = yield* Documents; + const diags = yield* Diagnostics; + const present = + (uri: string) => + (d: Diagnostic): Model.Diagnostic => ({ + file: docs.file(uri), + ...range(d.range), + severity: severities[(d.severity ?? 1) - 1] ?? "error", + code: d.code === undefined ? undefined : String(d.code), + source: d.source, + message: markup(d.message), + related: d.relatedInformation?.map((r) => ({ + file: docs.file(r.location.uri), + line: r.location.range.start.line + 1, + column: r.location.range.start.character + 1, + message: r.message, + })), + }); + const items = + file === undefined + ? (yield* diags.all).flatMap((p) => p.diagnostics.map(present(p.uri))) + : yield* docs + .open(file) + .pipe( + Effect.flatMap((doc) => + Effect.map(diags.forDocument(doc), (ds) => + ds.map(present(doc.uri)), + ), + ), + ); + const threshold = severities.indexOf(severity); + return items + .filter((d) => severities.indexOf(d.severity) <= threshold) + .sort( + (a, b) => + a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column, + ); +}); + +export const completions = Effect.fn("Editor.completions")(function* ({ + prefix, + limit = 20, + ...where +}: Model.Completions) { + const lsp = yield* LanguageServer; + const { textDocument, position } = yield* locate(where); + const result = yield* lsp.request(CompletionRequest.type, { + textDocument, + position, + context: { triggerKind: CompletionTriggerKind.Invoked }, + }); + const items = Array.isArray(result) ? result : (result?.items ?? []); + const lower = prefix?.toLowerCase(); + return items + .filter( + (item) => + lower === undefined || + (item.filterText ?? item.label).toLowerCase().startsWith(lower), + ) + .sort((a, b) => + (a.sortText ?? a.label).localeCompare(b.sortText ?? b.label), + ) + .slice(0, limit) + .map( + (item): Model.Completion => ({ + label: item.label, + kind: completionKinds[item.kind ?? 1] ?? "text", + detail: item.detail, + documentation: + item.documentation === undefined + ? undefined + : markup(item.documentation), + }), + ); +}); + +export const signatureHelp = Effect.fn("Editor.signatureHelp")(function* ( + where: Model.Where, +) { + const lsp = yield* LanguageServer; + const { textDocument, position } = yield* locate(where); + const result = yield* lsp.request(SignatureHelpRequest.type, { + textDocument, + position, + }); + return { + active: result?.activeSignature ?? 0, + signatures: (result?.signatures ?? []).map((s) => ({ + label: s.label, + documentation: + s.documentation === undefined ? undefined : markup(s.documentation), + parameters: (s.parameters ?? []).map((p) => + typeof p.label === "string" + ? p.label + : s.label.slice(p.label[0], p.label[1]), + ), + activeParameter: + s.activeParameter ?? result?.activeParameter ?? undefined, + })), + } satisfies Model.SignatureHelp; +}); + +export const rename = Effect.fn("Editor.rename")(function* ({ + newName, + apply = false, + ...where +}: Model.Rename) { + const lsp = yield* LanguageServer; + const { textDocument, position } = yield* locate(where); + const edit = yield* lsp.request(RenameRequest.type, { + textDocument, + position, + newName, + }); + if (edit === null) + return yield* new LspError({ + method: "rename", + message: "nothing to rename at this position", + }); + return yield* workspaceEdit(edit, apply); +}); + +const selection = Effect.fn("Editor.selection")(function* ({ + file, + line, + column, + endLine, + endColumn, +}: Model.Selection) { + const docs = yield* Documents; + const doc = yield* docs.open(file); + const end = (endLine ?? line) - 1; + const range: Range = { + start: { line: line - 1, character: column === undefined ? 0 : column - 1 }, + end: { + line: end, + character: + endColumn === undefined ? lineText(doc, end).length : endColumn - 1, + }, + }; + return { doc, textDocument: { uri: doc.uri }, range }; +}); + +const overlaps = (a: Range, b: Range) => + (a.start.line < b.end.line || + (a.start.line === b.end.line && a.start.character <= b.end.character)) && + (b.start.line < a.end.line || + (b.start.line === a.end.line && b.start.character <= a.end.character)); + +const codeActionsAt = Effect.fn("Editor.codeActionsAt")(function* ({ + kind, + ...where +}: Model.CodeActions) { + const lsp = yield* LanguageServer; + const diags = yield* Diagnostics; + const { doc, textDocument, range } = yield* selection(where); + const diagnostics = (yield* diags.forDocument(doc)).filter((d) => + overlaps(d.range, range), + ); + const actions = yield* lsp.request(CodeActionRequest.type, { + textDocument, + range, + context: { + diagnostics, + triggerKind: CodeActionTriggerKind.Invoked, + ...(kind === undefined ? {} : { only: [kind] }), + }, + }); + return actions ?? []; +}); + +export const codeActions = Effect.fn("Editor.codeActions")(function* ( + params: Model.CodeActions, +) { + return (yield* codeActionsAt(params)).map( + (action): Model.CodeAction => ({ + title: action.title, + kind: CodeAction.is(action) ? action.kind : undefined, + preferred: CodeAction.is(action) && action.isPreferred === true, + fixes: CodeAction.is(action) + ? (action.diagnostics ?? []).map((d) => markup(d.message)) + : [], + }), + ); +}); + +export const applyCodeAction = Effect.fn("Editor.applyCodeAction")(function* ({ + title, + ...params +}: Model.ApplyCodeAction) { + const lsp = yield* LanguageServer; + const action = (yield* codeActionsAt(params)).find((a) => a.title === title); + if (action === undefined) + return yield* new LspError({ + method: "codeAction", + message: `no code action titled "${title}"`, + }); + const execute = (command: Command) => + lsp + .request(ExecuteCommandRequest.type, { + command: command.command, + ...(command.arguments && { arguments: command.arguments }), + }) + .pipe(Effect.as(`command ${command.command}`)); + if (!CodeAction.is(action)) { + return { + applied: true, + files: [], + operations: [yield* execute(action)], + } satisfies Model.WorkspaceEdit; + } + const resolved = + action.edit === undefined && + action.command === undefined && + lsp.capabilities.codeActionProvider + ? yield* lsp.request(CodeActionResolveRequest.type, action) + : action; + const result = yield* workspaceEdit(resolved.edit ?? {}, true); + const operations = resolved.command + ? [...result.operations, yield* execute(resolved.command)] + : result.operations; + return { ...result, operations }; +}); + +export const format = Effect.fn("Editor.format")(function* ({ + file, + tabSize = 4, + insertSpaces = true, + apply = false, +}: Model.Format) { + const lsp = yield* LanguageServer; + const docs = yield* Documents; + const doc = yield* docs.open(file); + const edits = + (yield* lsp.request(DocumentFormattingRequest.type, { + textDocument: { uri: doc.uri }, + options: { tabSize, insertSpaces }, + })) ?? []; + if (apply && edits.length > 0) + yield* docs.write(doc.uri, TextDocument.applyEdits(doc, edits)); + return { + applied: apply, + files: [ + { + file: docs.file(doc.uri), + edits: edits.map((e) => ({ ...range(e.range), newText: e.newText })), + }, + ], + operations: [], + } satisfies Model.WorkspaceEdit; +}); + +export const callHierarchy = Effect.fn("Editor.callHierarchy")(function* ({ + direction = "incoming", + ...where +}: Model.CallHierarchy) { + const lsp = yield* LanguageServer; + const docs = yield* Documents; + const { textDocument, position } = yield* locate(where); + const item = (yield* lsp.request(CallHierarchyPrepareRequest.type, { + textDocument, + position, + }))?.[0]; + if (item === undefined) return []; + const calls = + direction === "incoming" + ? ( + (yield* lsp.request(CallHierarchyIncomingCallsRequest.type, { + item, + })) ?? [] + ).map((c) => ({ item: c.from, sites: c.fromRanges })) + : ( + (yield* lsp.request(CallHierarchyOutgoingCallsRequest.type, { + item, + })) ?? [] + ).map((c) => ({ item: c.to, sites: c.fromRanges })); + return calls.map( + ({ item, sites }): Model.Call => ({ + name: item.name, + kind: symbolKinds[item.kind] ?? "unknown", + detail: item.detail, + file: docs.file(item.uri), + line: item.selectionRange.start.line + 1, + column: item.selectionRange.start.character + 1, + sites: sites.map((r) => ({ + line: r.start.line + 1, + column: r.start.character + 1, + })), + }), + ); +}); diff --git a/src/lsp/LanguageServer.ts b/src/lsp/LanguageServer.ts new file mode 100644 index 0000000..ad0ff20 --- /dev/null +++ b/src/lsp/LanguageServer.ts @@ -0,0 +1,444 @@ +/** + * A running language server: process, JSON-RPC connection and the LSP + * lifecycle, exposed as an Effect service. + * + * `request` waits for the server to become idle (no `$/progress` work in + * flight) and retries transient `ContentModified` / `ServerCancelled` + * failures, so callers can treat the server as if it were always ready. + */ +import { spawn } from "node:child_process"; +import { NodeStream } from "@effect/platform-node"; +import { + Context, + Deferred, + Effect, + FiberSet, + HashSet, + Layer, + PubSub, + Schedule, + Schema, + type Scope, + Stream, + SubscriptionRef, +} from "effect"; +import { + CancellationTokenSource, + type ClientCapabilities, + ConfigurationRequest, + createMessageConnection, + DidChangeConfigurationNotification, + ErrorCodes, + ExitNotification, + type HandlerResult, + InitializedNotification, + InitializeRequest, + LogMessageNotification, + type LSPAny, + LSPErrorCodes, + type MessageType, + type NotificationType, + RegistrationRequest, + type RequestType, + ResponseError, + type ServerCapabilities, + ShowMessageNotification, + ShutdownRequest, + StreamMessageReader, + StreamMessageWriter, + UnregistrationRequest, + WorkDoneProgressCreateRequest, + WorkspaceFoldersRequest, +} from "vscode-languageserver-protocol/node"; +import { URI } from "vscode-uri"; + +export interface Options { + readonly command: string; + readonly args: ReadonlyArray; + /** Absolute path of the workspace root. */ + readonly root: string; + readonly initializationOptions?: unknown; + /** Served as `workspace/configuration` and pushed via `didChangeConfiguration`. */ + readonly settings?: unknown; +} + +export class LspError extends Schema.TaggedError()("LspError", { + method: Schema.String, + message: Schema.String, + code: Schema.optional(Schema.Int), +}) { + get retryable() { + return ( + this.code === LSPErrorCodes.ContentModified || + this.code === LSPErrorCodes.ServerCancelled + ); + } +} + +export type LogLevel = "error" | "warning" | "info" | "debug"; +export interface LogMessage { + readonly level: LogLevel; + readonly message: string; +} + +export class LanguageServer extends Context.Service< + LanguageServer, + { + readonly root: string; + readonly capabilities: ServerCapabilities; + readonly serverInfo: + | { readonly name: string; readonly version?: string | undefined } + | undefined; + readonly request: ( + type: RequestType, + params: P, + ) => Effect.Effect; + readonly notify:

( + type: NotificationType

, + params: P, + ) => Effect.Effect; + readonly notifications:

(type: NotificationType

) => Stream.Stream

; + /** Handle a request the server sends to us; unhandled methods answer `MethodNotFound`. */ + readonly onRequest: ( + type: RequestType, + handler: (params: P) => Effect.Effect, + ) => Effect.Effect; + /** `window/logMessage` and `window/showMessage` traffic. */ + readonly logs: Stream.Stream; + } +>()("lsp-mcp/lsp/LanguageServer") { + static readonly layer = (options: Options) => + Layer.effect(LanguageServer, make(options)); +} + +const make = Effect.fn("LanguageServer.make")(function* (options: Options) { + const run = yield* FiberSet.makeRuntime(); + const runPromise = yield* FiberSet.makeRuntimePromise(); + const exited = yield* Deferred.make(); + const events = yield* PubSub.unbounded<{ + readonly method: string; + readonly params: unknown; + }>(); + const progress = yield* SubscriptionRef.make( + HashSet.empty(), + ); + const exit = (message: string) => + Deferred.doneUnsafe( + exited, + Effect.fail(new LspError({ method: "connection", message })), + ); + + const child = yield* Effect.acquireRelease( + Effect.sync(() => + spawn(options.command, options.args, { + cwd: options.root, + stdio: "pipe", + }), + ), + (child) => + Effect.sync(() => void (child.exitCode === null && child.kill())), + ); + child.on("error", (error) => + exit(`failed to start ${options.command}: ${error.message}`), + ); + child.on("exit", (code, signal) => + exit(`${options.command} exited (${signal ?? code})`), + ); + yield* NodeStream.fromReadable({ + evaluate: () => child.stderr, + onError: () => undefined as never, + }).pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runForEach((line) => Effect.logDebug(line)), + Effect.annotateLogs("source", `${options.command}:stderr`), + Effect.forkScoped, + ); + + const connection = createMessageConnection( + new StreamMessageReader(child.stdout), + new StreamMessageWriter(child.stdin), + { + error: (m) => run(Effect.logError(m)), + warn: (m) => run(Effect.logWarning(m)), + info: (m) => run(Effect.logInfo(m)), + log: (m) => run(Effect.logDebug(m)), + }, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => connection.dispose())); + connection.onClose(() => exit(`${options.command} closed the connection`)); + connection.onNotification((method, params) => { + PubSub.publishUnsafe(events, { method, params }); + }); + connection.onUnhandledProgress(({ token, value }) => + run( + SubscriptionRef.update(progress, (active) => + value.kind === "begin" + ? HashSet.add(active, token) + : value.kind === "end" + ? HashSet.remove(active, token) + : active, + ), + ), + ); + connection.onRequest(WorkDoneProgressCreateRequest.type, () => undefined); + connection.onRequest(RegistrationRequest.type, () => undefined); + connection.onRequest(UnregistrationRequest.type, () => undefined); + connection.onRequest(ConfigurationRequest.type, ({ items }) => + items.map((item) => section(options.settings, item.section)), + ); + const rootUri = URI.file(options.root).toString(); + const workspaceFolders = [ + { uri: rootUri, name: options.root.split(/[\\/]/).pop() ?? options.root }, + ]; + connection.onRequest(WorkspaceFoldersRequest.type, () => workspaceFolders); + connection.listen(); + + const failure = (method: string, error: unknown) => + error instanceof ResponseError + ? new LspError({ method, message: error.message, code: error.code }) + : new LspError({ + method, + message: error instanceof Error ? error.message : String(error), + }); + + const send = (type: RequestType, params: P) => + Effect.callback((resume) => { + const cancellation = new CancellationTokenSource(); + try { + connection.sendRequest(type, params as never, cancellation.token).then( + (result) => resume(Effect.succeed(result)), + (error) => resume(Effect.fail(failure(type.method, error))), + ); + } catch (error) { + resume(Effect.fail(failure(type.method, error))); + } + return Effect.ignore(Effect.try(() => cancellation.cancel())); + }).pipe(Effect.raceFirst(Deferred.await(exited))); + + const idle = SubscriptionRef.changes(progress).pipe( + Stream.filter(HashSet.isEmpty), + Stream.runHead, + Effect.timeoutOption("15 seconds"), + Effect.asVoid, + ); + + const request = (type: RequestType, params: P) => + idle.pipe( + Effect.andThen(send(type, params)), + Effect.retry({ + while: (error) => error.retryable, + schedule: Schedule.exponential("100 millis").pipe( + Schedule.upTo({ times: 5 }), + ), + }), + ); + + const notify =

(type: NotificationType

, params: P) => + Effect.tryPromise({ + try: () => connection.sendNotification(type, params as never), + catch: (error) => failure(type.method, error), + }).pipe(Effect.catch((error) => Effect.logWarning(error.message))); + + const notifications =

(type: NotificationType

): Stream.Stream

=> + Stream.fromPubSub(events).pipe( + Stream.filter((event) => event.method === type.method), + Stream.map((event) => event.params as P), + ); + + const onRequest = ( + type: RequestType, + handler: (params: P) => Effect.Effect, + ) => + Effect.acquireRelease( + Effect.sync(() => + connection.onRequest( + type.method, + (params: P): HandlerResult => + runPromise( + handler(params).pipe( + Effect.mapError((error) => + error instanceof ResponseError + ? error + : new ResponseError( + ErrorCodes.InternalError, + error instanceof Error ? error.message : String(error), + ), + ), + ), + ) as Promise, + ), + ), + (disposable) => Effect.sync(() => disposable.dispose()), + ).pipe(Effect.asVoid); + + const initialized = yield* send(InitializeRequest.type, { + processId: process.pid, + clientInfo: { name: "lsp-mcp", version: "0.1.0" }, + locale: "en", + rootPath: options.root, + rootUri, + workspaceFolders, + capabilities: clientCapabilities, + initializationOptions: options.initializationOptions as LSPAny, + }); + yield* notify(InitializedNotification.type, {}); + if (options.settings !== undefined) { + yield* notify(DidChangeConfigurationNotification.type, { + settings: options.settings as LSPAny, + }); + } + yield* Effect.addFinalizer(() => + Effect.tryPromise(() => connection.sendRequest(ShutdownRequest.type)).pipe( + Effect.timeout("3 seconds"), + Effect.andThen( + Effect.tryPromise(() => + connection.sendNotification(ExitNotification.type), + ), + ), + Effect.andThen(Deferred.await(exited).pipe(Effect.timeout("2 seconds"))), + Effect.ignore, + ), + ); + + const logs = Stream.merge( + notifications(LogMessageNotification.type), + notifications(ShowMessageNotification.type), + ).pipe( + Stream.map( + ({ type, message }): LogMessage => ({ + level: logLevels[type] ?? "info", + message, + }), + ), + ); + + return LanguageServer.of({ + root: options.root, + capabilities: initialized.capabilities, + serverInfo: initialized.serverInfo, + request, + notify, + notifications, + onRequest, + logs, + }); +}); + +const logLevels: Record = { + 1: "error", + 2: "warning", + 3: "info", + 4: "debug", + 5: "debug", +}; + +/** Resolve a dotted `section` path inside the configured settings. */ +const section = (settings: unknown, path: string | undefined): LSPAny => + (path === undefined + ? settings + : path + .split(".") + .reduce( + (value, key) => (value as Record | null)?.[key], + settings, + )) as LSPAny; + +/** + * What we tell the server we can do. Deliberately narrow: no dynamic + * registration, no snippets, no watched files (servers fall back to their + * own watchers), UTF-16 positions only. + */ +const clientCapabilities: ClientCapabilities = { + general: { + positionEncodings: ["utf-16"], + markdown: { parser: "marked", version: "1.1.0" }, + }, + workspace: { + applyEdit: true, + workspaceEdit: { + documentChanges: true, + resourceOperations: ["create", "rename", "delete"], + failureHandling: "abort", + }, + configuration: true, + workspaceFolders: true, + symbol: { symbolKind: { valueSet: allSymbolKinds() } }, + didChangeConfiguration: { dynamicRegistration: false }, + didChangeWatchedFiles: { dynamicRegistration: false }, + }, + window: { + workDoneProgress: true, + showMessage: {}, + showDocument: { support: false }, + }, + textDocument: { + synchronization: { + didSave: true, + willSave: false, + willSaveWaitUntil: false, + }, + publishDiagnostics: { + relatedInformation: true, + versionSupport: true, + codeDescriptionSupport: true, + dataSupport: true, + }, + diagnostic: { dynamicRegistration: false, relatedDocumentSupport: false }, + hover: { contentFormat: ["markdown", "plaintext"] }, + completion: { + completionItem: { + snippetSupport: false, + documentationFormat: ["markdown", "plaintext"], + deprecatedSupport: true, + }, + completionItemKind: { + valueSet: Array.from({ length: 25 }, (_, i) => (i + 1) as never), + }, + contextSupport: true, + }, + signatureHelp: { + signatureInformation: { + documentationFormat: ["markdown", "plaintext"], + parameterInformation: { labelOffsetSupport: true }, + activeParameterSupport: true, + }, + }, + definition: { linkSupport: true }, + typeDefinition: { linkSupport: true }, + implementation: { linkSupport: true }, + declaration: { linkSupport: true }, + references: {}, + documentSymbol: { + hierarchicalDocumentSymbolSupport: true, + symbolKind: { valueSet: allSymbolKinds() }, + }, + codeAction: { + codeActionLiteralSupport: { + codeActionKind: { + valueSet: [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + "source.fixAll", + ], + }, + }, + isPreferredSupport: true, + dataSupport: true, + resolveSupport: { properties: ["edit"] }, + }, + rename: { prepareSupport: true }, + formatting: {}, + callHierarchy: {}, + }, +}; + +function allSymbolKinds() { + return Array.from({ length: 26 }, (_, i) => (i + 1) as never); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..dafca71 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env node +/** + * CLI entry point. + * + * lsp-mcp [--stdio | --http ] [--root

] -- [args...] + */ +import { NodeRuntime, NodeServices } from "@effect/platform-node"; +import { Cause, Effect, Layer, Option, Path, References, Schema } from "effect"; +import { Argument, Command, Flag } from "effect/unstable/cli"; +import * as Server from "./Server.ts"; + +const Json = Flag.withSchema(Schema.fromJsonString(Schema.Json)); + +const command = Command.make( + "lsp-mcp", + { + transport: Flag.Int("http").pipe( + Flag.withDescription( + "Serve MCP over Streamable HTTP at http://localhost:/mcp", + ), + Flag.map((port) => ({ _tag: "http", port }) as const), + Flag.orElse(() => + Flag.Boolean("stdio").pipe( + Flag.withDescription("Serve MCP over stdio (the default)"), + Flag.withDefault(false), + Flag.map(() => ({ _tag: "stdio" }) as const), + ), + ), + ), + root: Flag.Directory("root", { mustExist: true }).pipe( + Flag.withAlias("r"), + Flag.withDescription("Workspace root handed to the language server"), + Flag.withDefault("."), + ), + initOptions: Flag.String("init-options").pipe( + Json, + Flag.withDescription( + "JSON passed to the language server as initializationOptions", + ), + Flag.optional, + ), + settings: Flag.String("settings").pipe( + Json, + Flag.withDescription( + "JSON served as workspace/configuration and sent as didChangeConfiguration", + ), + Flag.optional, + ), + languages: Flag.KeyValuePair("language").pipe( + Flag.withDescription( + "Extra file extension to languageId mapping, e.g. --language vue=vue", + ), + Flag.withDefault({}), + ), + capacity: Flag.Int("open-documents").pipe( + Flag.withDescription( + "Documents kept open in the language server before the least recently used is closed", + ), + Flag.withDefault(32), + ), + command: Argument.String("server").pipe( + Argument.withDescription("Language server executable"), + ), + args: Argument.String("args").pipe( + Argument.withDescription( + "Arguments for the language server; put `--` before them", + ), + Argument.variadic(), + ), + }, + Effect.fn(function* ({ + transport, + root, + initOptions, + settings, + languages, + capacity, + command, + args, + }) { + const path = yield* Path.Path; + return yield* Layer.launch( + Server.layer({ + transport, + server: { + // a relative executable path is relative to where lsp-mcp was started, not to the root + command: command.includes("/") ? path.resolve(command) : command, + args, + root: path.resolve(root), + initializationOptions: Option.getOrUndefined(initOptions), + settings: Option.getOrUndefined(settings), + }, + documents: { capacity, languages }, + }), + ); + }), +).pipe( + Command.withDescription( + "Expose a Language Server to MCP clients as a headless editor", + ), + Command.withExamples([ + { + command: "lsp-mcp -- typescript-language-server --stdio", + description: "TypeScript over stdio", + }, + { + command: "lsp-mcp --http 3000 --root ./crate -- rust-analyzer", + description: "Rust over HTTP", + }, + { + command: `lsp-mcp --settings '{"gopls":{"staticcheck":true}}' -- gopls`, + description: "Go with workspace settings", + }, + ]), +); + +command.pipe( + Command.run({ version: "0.1.0" }), + Effect.tapCause((cause) => + Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logError(cause), + ), + // stdout may be the MCP channel, so every log line goes to stderr + Effect.provideService(References.LogToStderr, true), + Effect.provide(NodeServices.layer), + NodeRuntime.runMain({ disableErrorReporting: true }), +); diff --git a/test/Editor.test.ts b/test/Editor.test.ts new file mode 100644 index 0000000..caae794 --- /dev/null +++ b/test/Editor.test.ts @@ -0,0 +1,171 @@ +import { NodeServices } from "@effect/platform-node"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer } from "effect"; +import { RequestType } from "vscode-languageserver-protocol"; +import { Diagnostics } from "../src/lsp/Diagnostics.ts"; +import { Documents } from "../src/lsp/Documents.ts"; +import * as Editor from "../src/lsp/Editor.ts"; +import { LanguageServer } from "../src/lsp/LanguageServer.ts"; +import { workspace } from "./fixtures/workspace.ts"; + +const State = new RequestType( + "fixture/state", +); +const Progress = new RequestType("fixture/progress"); + +const TestLayer = Layer.mergeAll( + Documents.layer({ capacity: 2, languages: {} }), + Diagnostics.layer, +).pipe( + Layer.provideMerge( + workspace({ + "a.ts": "const alpha = 1\nexport const beta = alpha + alpha\n", + "b.ts": "// TODO fix\n", + "c.ts": "", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +describe("Editor", () => { + it.layer(TestLayer, { excludeTestServices: true })( + "with the fixture server", + (it) => { + it.effect("addresses positions by 1-based line and symbol name", () => + Effect.gen(function* () { + const byColumn = yield* Editor.hover({ + file: "a.ts", + line: 2, + column: 22, + }); + const bySymbol = yield* Editor.hover({ + file: "a.ts", + line: 2, + symbol: "alpha", + }); + assert.strictEqual(byColumn?.contents, "word **alpha**"); + assert.strictEqual(bySymbol?.contents, "word **alpha**"); + + const refs = yield* Editor.references({ + file: "a.ts", + line: 1, + symbol: "alpha", + }); + assert.deepStrictEqual( + refs.map((r) => [r.line, r.column, r.preview]), + [ + [1, 7, "const alpha = 1"], + [2, 21, "export const beta = alpha + alpha"], + [2, 29, "export const beta = alpha + alpha"], + ], + ); + }), + ); + + it.effect("sees edits made on disk without being told", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const docs = yield* Documents; + const lsp = yield* LanguageServer; + const before = yield* Editor.diagnostics({ file: "b.ts" }); + assert.deepStrictEqual( + before.map((d) => [d.severity, d.line]), + [["warning", 1]], + ); + + yield* fs.writeFileString( + docs.uri("b.ts").replace("file://", ""), + "// done\n// TODO more\n", + ); + const after = yield* Editor.diagnostics({ file: "b.ts" }); + assert.deepStrictEqual( + after.map((d) => [d.severity, d.line]), + [["warning", 2]], + ); + assert.strictEqual((yield* lsp.request(State, null)).open, 2); + }), + ); + + it.effect("closes the least recently used document beyond capacity", () => + Effect.gen(function* () { + const lsp = yield* LanguageServer; + const docs = yield* Documents; + yield* docs.open("a.ts"); + yield* docs.open("b.ts"); + yield* docs.open("c.ts"); + yield* Effect.sleep("50 millis"); + const state = yield* lsp.request(State, null); + assert.strictEqual(state.open, 2); + assert.isAtLeast(state.closes, 1); + assert.deepStrictEqual((yield* docs.opened).map(docs.file).sort(), [ + "b.ts", + "c.ts", + ]); + }), + ); + + it.effect("previews a rename, and applies it only when asked", () => + Effect.gen(function* () { + const docs = yield* Documents; + const preview = yield* Editor.rename({ + file: "a.ts", + line: 1, + symbol: "alpha", + newName: "first", + }); + assert.isFalse(preview.applied); + assert.strictEqual(preview.files[0]?.edits.length, 3); + assert.include((yield* docs.open("a.ts")).getText(), "alpha"); + + const applied = yield* Editor.rename({ + file: "a.ts", + line: 1, + symbol: "alpha", + newName: "first", + apply: true, + }); + assert.isTrue(applied.applied); + const text = (yield* docs.peek(docs.uri("a.ts"))).getText(); + assert.strictEqual( + text, + "const first = 1\nexport const beta = first + first\n", + ); + assert.strictEqual( + (yield* Editor.hover({ file: "a.ts", line: 1, symbol: "first" })) + ?.contents, + "word **first**", + ); + }), + ); + + it.effect("waits for the server's background work before asking", () => + Effect.gen(function* () { + const lsp = yield* LanguageServer; + yield* lsp.request(Progress, 300); + const started = Date.now(); + const symbols = yield* Editor.documentSymbols({ file: "c.ts" }); + assert.isAtLeast(Date.now() - started, 250); + assert.deepStrictEqual(symbols, []); + }), + ); + + it.effect("explains what the server cannot do", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + Editor.definition({ + file: "a.ts", + line: 1, + symbol: "alpha", + kind: "implementation", + }), + ); + assert.strictEqual(error._tag, "LspError"); + assert.match( + error.message, + /does not support implementationProvider/, + ); + }), + ); + }, + ); +}); diff --git a/test/Server.test.ts b/test/Server.test.ts new file mode 100644 index 0000000..6d66527 --- /dev/null +++ b/test/Server.test.ts @@ -0,0 +1,144 @@ +import { NodeServices } from "@effect/platform-node"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpRouter, +} from "effect/unstable/http"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import { Diagnostics } from "../src/lsp/Diagnostics.ts"; +import { Documents } from "../src/lsp/Documents.ts"; +import * as Resources from "../src/Resources.ts"; +import * as Server from "../src/Server.ts"; +import { workspace } from "./fixtures/workspace.ts"; + +const mcp = McpServer.layerHttp({ + name: "test", + version: "0.0.0", + path: "/mcp", + protocols: [McpProtocol.v2025_06_18], +}); + +const app = Layer.mergeAll(Server.tools, Resources.layer).pipe( + Layer.provide(mcp), + Layer.provide( + Layer.mergeAll( + Documents.layer({ capacity: 4, languages: {} }), + Diagnostics.layer, + ), + ), + Layer.provide(workspace({ "a.ts": "const alpha = 1\n" })), + Layer.provide(NodeServices.layer), +); + +/** An MCP client talking to the app over in-memory HTTP. */ +const client = Effect.gen(function* () { + const { dispose, handler } = HttpRouter.toWebHandler(app, { + disableLogger: true, + }); + yield* Effect.addFinalizer(() => Effect.promise(dispose)); + const headers = new Map(); + const fetch: typeof globalThis.fetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + for (const [name, value] of headers) request.headers.set(name, value); + const response = await handler(request); + for (const name of ["Mcp-Session-Id", "Mcp-Protocol-Version"]) { + const value = response.headers.get(name); + if (value !== null) headers.set(name, value); + } + return response; + }, + { preconnect() {} }, + ); + const transport = RpcClient.layerProtocolHttp({ + url: "http://localhost/mcp", + transformClient: HttpClient.mapRequest( + HttpClientRequest.setHeader( + "accept", + "application/json, text/event-stream", + ), + ), + }).pipe( + Layer.provideMerge([ + FetchHttpClient.layer, + RpcSerialization.layerJsonRpc(), + ]), + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch)), + ); + const client = yield* RpcClient.make(McpSchema.ClientRpcs).pipe( + Effect.provide(transport), + ); + yield* client.initialize({ + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test", version: "0" }, + }); + return client; +}); + +describe("MCP server", () => { + it.live( + "offers only the tools the language server supports, and serves them", + () => + Effect.gen(function* () { + const mcp = yield* client; + const { tools } = yield* mcp["tools/list"]({}); + assert.deepStrictEqual(tools.map((t) => t.name).sort(), [ + "definition", + "diagnostics", + "document_symbols", + "hover", + "references", + "rename", + ]); + assert.isTrue( + tools.find((t) => t.name === "hover")?.annotations?.readOnlyHint, + ); + assert.isFalse( + tools.find((t) => t.name === "rename")?.annotations?.readOnlyHint, + ); + + const result = yield* mcp["tools/call"]({ + name: "hover", + arguments: { file: "a.ts", line: 1, symbol: "alpha" }, + }); + assert.deepStrictEqual(result.structuredContent, { + contents: "word **alpha**", + }); + + const failure = yield* mcp["tools/call"]({ + name: "hover", + arguments: { file: "missing.ts", line: 1 }, + }); + assert.isTrue(failure.isError); + assert.match(JSON.stringify(failure.content), /missing\.ts/); + }).pipe(Effect.scoped), + ); + + it.live("exposes the server and its diagnostics as resources", () => + Effect.gen(function* () { + const mcp = yield* client; + const { resources } = yield* mcp["resources/list"]({}); + assert.deepStrictEqual(resources.map((r) => r.uri).sort(), [ + "lsp://diagnostics", + "lsp://server", + ]); + const server = yield* mcp["resources/read"]({ uri: "lsp://server" }); + const contents = server.contents[0]; + assert.isTrue( + contents !== undefined && + "text" in contents && + JSON.parse(contents.text).name === "fixture", + ); + const { resourceTemplates } = yield* mcp["resources/templates/list"]({}); + assert.deepStrictEqual( + resourceTemplates.map((t) => t.uriTemplate), + ["lsp://diagnostics/{param0}"], + ); + }).pipe(Effect.scoped), + ); +}); diff --git a/test/fixtures/server.ts b/test/fixtures/server.ts new file mode 100644 index 0000000..a117a4f --- /dev/null +++ b/test/fixtures/server.ts @@ -0,0 +1,160 @@ +/** + * A miniature language server used by the tests. Words are symbols: hover + * describes the word under the cursor, definition is its first occurrence, + * references and rename cover every occurrence, and each line containing + * "TODO" yields a warning. `fixture/state` reports open/close counts. + */ +import { + createConnection, + DiagnosticSeverity, + type Position, + TextDocumentSyncKind, + TextDocuments, + WorkDoneProgress, +} from "vscode-languageserver/node"; +import { TextDocument } from "vscode-languageserver-textdocument"; + +const connection = createConnection(process.stdin, process.stdout); +const documents = new TextDocuments(TextDocument); +let closes = 0; +let progressToken = 0; + +const wordAt = (doc: TextDocument, position: Position) => { + const text = doc.getText(); + const offset = doc.offsetAt(position); + let start = offset; + let end = offset; + while (start > 0 && /\w/.test(text.charAt(start - 1))) start--; + while (end < text.length && /\w/.test(text.charAt(end))) end++; + return start === end ? undefined : text.slice(start, end); +}; + +const occurrences = (doc: TextDocument, word: string) => { + const text = doc.getText(); + const ranges = []; + const pattern = new RegExp(`\\b${word}\\b`, "g"); + for (const match of text.matchAll(pattern)) { + ranges.push({ + start: doc.positionAt(match.index), + end: doc.positionAt(match.index + word.length), + }); + } + return ranges; +}; + +documents.onDidClose(() => closes++); +documents.onDidChangeContent(({ document }) => { + const diagnostics = document + .getText() + .split("\n") + .flatMap((line, i) => + line.includes("TODO") + ? [ + { + range: { + start: { line: i, character: 0 }, + end: { line: i, character: line.length }, + }, + severity: DiagnosticSeverity.Warning, + message: "unresolved TODO", + source: "fixture", + }, + ] + : [], + ); + void connection.sendDiagnostics({ + uri: document.uri, + version: document.version, + diagnostics, + }); +}); + +connection.onInitialize(() => ({ + serverInfo: { name: "fixture", version: "1.0.0" }, + capabilities: { + textDocumentSync: { + openClose: true, + change: TextDocumentSyncKind.Full, + save: { includeText: true }, + }, + hoverProvider: true, + definitionProvider: true, + referencesProvider: true, + renameProvider: true, + documentSymbolProvider: true, + }, +})); + +connection.onHover(({ textDocument, position }) => { + const doc = documents.get(textDocument.uri); + const word = doc && wordAt(doc, position); + return word + ? { contents: { kind: "markdown", value: `word **${word}**` } } + : null; +}); + +connection.onDefinition(({ textDocument, position }) => { + const doc = documents.get(textDocument.uri); + const word = doc && wordAt(doc, position); + const range = word && occurrences(doc, word)[0]; + return range ? { uri: textDocument.uri, range } : null; +}); + +connection.onReferences(({ textDocument, position }) => { + const doc = documents.get(textDocument.uri); + const word = doc && wordAt(doc, position); + return word + ? occurrences(doc, word).map((range) => ({ uri: textDocument.uri, range })) + : []; +}); + +connection.onRenameRequest(({ textDocument, position, newName }) => { + const doc = documents.get(textDocument.uri); + const word = doc && wordAt(doc, position); + if (!word) return null; + return { + documentChanges: [ + { + textDocument: { uri: textDocument.uri, version: doc.version }, + edits: occurrences(doc, word).map((range) => ({ + range, + newText: newName, + })), + }, + ], + }; +}); + +connection.onDocumentSymbol(({ textDocument }) => { + const doc = documents.get(textDocument.uri); + if (!doc) return []; + const words = new Set(doc.getText().match(/\b[A-Za-z_]\w*\b/g) ?? []); + return [...words].flatMap((name) => + occurrences(doc, name) + .slice(0, 1) + .map((range) => ({ name, kind: 13, range, selectionRange: range })), + ); +}); + +connection.onRequest("fixture/state", () => ({ + open: documents.all().length, + closes, +})); +connection.onRequest("fixture/progress", async (ms: number) => { + const token = `work-${progressToken++}`; + void connection.sendProgress(WorkDoneProgress.type, token, { + kind: "begin", + title: "indexing", + }); + setTimeout( + () => + void connection.sendProgress(WorkDoneProgress.type, token, { + kind: "end", + }), + ms, + ); + return token; +}); + +documents.listen(connection); +connection.listen(); diff --git a/test/fixtures/workspace.ts b/test/fixtures/workspace.ts new file mode 100644 index 0000000..77a4fa0 --- /dev/null +++ b/test/fixtures/workspace.ts @@ -0,0 +1,23 @@ +import { fileURLToPath } from "node:url"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import { LanguageServer } from "../../src/lsp/LanguageServer.ts"; + +const server = fileURLToPath(new URL("./server.ts", import.meta.url)); + +/** A temporary workspace with the given files, served by the fixture language server. */ +export const workspace = (files: Record) => + Layer.unwrap( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "lsp-mcp-" }); + for (const [name, content] of Object.entries(files)) { + yield* fs.writeFileString(path.join(root, name), content); + } + return LanguageServer.layer({ + command: process.execPath, + args: [server], + root, + }); + }), + ); diff --git a/tsconfig.json b/tsconfig.json index b466d7f..f69c328 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,50 +2,27 @@ "$schema": "./node_modules/@effect/tsgo/schema.json", "compilerOptions": { "noEmit": true, - // File Layout - // "rootDir": "./src", - // "outDir": "./dist", - - // Environment Settings - // See also https://aka.ms/tsconfig/module "module": "nodenext", "target": "esnext", - "types": [], - // For nodejs: - // "lib": ["esnext"], - // "types": ["node"], - // and npm install -D @types/node - - // Other Outputs - "sourceMap": true, - "declaration": true, - "declarationMap": true, - - // Stricter Typechecking Options + "types": ["node"], + "allowImportingTsExtensions": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, - - // Style Options "noImplicitReturns": true, "noImplicitOverride": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noPropertyAccessFromIndexSignature": true, - - // Recommended Options "strict": true, - "jsx": "react-jsx", "verbatimModuleSyntax": true, "isolatedModules": true, + "erasableSyntaxOnly": true, "noUncheckedSideEffectImports": true, "moduleDetection": "force", "skipLibCheck": true, - "plugins": [ - { - "name": "@effect/language-service" - } - ] + "plugins": [{ "name": "@effect/language-service" }] }, + "include": ["src", "test", "vitest.config.ts"], "exclude": ["node_modules", "repos"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..8085e12 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + testTimeout: 20_000, + }, +});