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.
This commit is contained in:
18 files changed
+3611
-38
No files matched your search
@@ -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 <dir> -- <server> [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).
|
||||||
+20
-10
@@ -1,15 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "lsp-mcp",
|
"name": "lsp-mcp",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"description": "",
|
"description": "MCP server exposing a Language Server's capabilities to agents as a headless editor",
|
||||||
"main": "index.js",
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"lsp-mcp": "./src/main.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"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"
|
"prepare": "effect-tsgo patch --typescript --no-oxlint"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"devEngines": {
|
"devEngines": {
|
||||||
"packageManager": {
|
"packageManager": {
|
||||||
"name": "pnpm",
|
"name": "pnpm",
|
||||||
@@ -18,14 +22,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@12.4.2",
|
"packageManager": "pnpm@12.4.2",
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "4.0.0-rc.115",
|
"@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": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.14",
|
"@biomejs/biome": "2.5.14",
|
||||||
"@effect/tsgo": "0.45.0",
|
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+697
@@ -172,6 +172,15 @@ importers:
|
|||||||
effect:
|
effect:
|
||||||
specifier: 4.0.0-rc.115
|
specifier: 4.0.0-rc.115
|
||||||
version: 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:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.5.14
|
specifier: 2.5.14
|
||||||
@@ -179,9 +188,21 @@ importers:
|
|||||||
'@effect/tsgo':
|
'@effect/tsgo':
|
||||||
specifier: 0.45.0
|
specifier: 0.45.0
|
||||||
version: 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:
|
typescript:
|
||||||
specifier: ^7.0.2
|
specifier: ^7.0.2
|
||||||
version: 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:
|
packages:
|
||||||
|
|
||||||
@@ -294,6 +315,25 @@ packages:
|
|||||||
resolution: {integrity: sha512-gPG0ixVHgtYBUe5DbKX9vzHlhqd9+ZEfs4m/XWy0808av0A2XqMzaG3F5rnPBN7EUJRPlo9TluIi8WHx/pBV+A==}
|
resolution: {integrity: sha512-gPG0ixVHgtYBUe5DbKX9vzHlhqd9+ZEfs4m/XWy0808av0A2XqMzaG3F5rnPBN7EUJRPlo9TluIi8WHx/pBV+A==}
|
||||||
hasBin: true
|
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':
|
'@redis/bloom@6.2.1':
|
||||||
resolution: {integrity: sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==}
|
resolution: {integrity: sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==}
|
||||||
engines: {node: '>= 20.0.0'}
|
engines: {node: '>= 20.0.0'}
|
||||||
@@ -330,6 +370,114 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@redis/client': ^6.2.1
|
'@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':
|
'@types/node@26.6.1':
|
||||||
resolution: {integrity: sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==}
|
resolution: {integrity: sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==}
|
||||||
|
|
||||||
@@ -456,17 +604,194 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
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:
|
cluster-key-slot@1.1.2:
|
||||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
effect@4.0.0-rc.115:
|
effect@4.0.0-rc.115:
|
||||||
resolution: {integrity: sha512-ogYulZ5ffeOzrJqQrG0XOkDO5lKn2s9KNHhoxJy/6wKUkF0i12dlVXWA9TgsgQ+zV/JzizyG0+XMPepZEQX6vw==}
|
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:
|
redis@6.2.1:
|
||||||
resolution: {integrity: sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg==}
|
resolution: {integrity: sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg==}
|
||||||
engines: {node: '>= 20.0.0'}
|
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:
|
typescript@7.0.2:
|
||||||
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
|
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
|
||||||
engines: {node: '>=16.20.0'}
|
engines: {node: '>=16.20.0'}
|
||||||
@@ -479,6 +804,115 @@ packages:
|
|||||||
resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==}
|
resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==}
|
||||||
engines: {node: '>=22.19.0'}
|
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:
|
ws@8.21.3:
|
||||||
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
@@ -578,6 +1012,22 @@ snapshots:
|
|||||||
'@effect/tsgo-win32-arm64': 0.45.0
|
'@effect/tsgo-win32-arm64': 0.45.0
|
||||||
'@effect/tsgo-win32-x64': 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)':
|
'@redis/bloom@6.2.1(@redis/client@6.2.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@redis/client': 6.2.1
|
'@redis/client': 6.2.1
|
||||||
@@ -598,6 +1048,62 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@redis/client': 6.2.1
|
'@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':
|
'@types/node@26.6.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 8.9.0
|
undici-types: 8.9.0
|
||||||
@@ -666,10 +1172,109 @@ snapshots:
|
|||||||
'@typescript/typescript-win32-x64@7.0.2':
|
'@typescript/typescript-win32-x64@7.0.2':
|
||||||
optional: true
|
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: {}
|
cluster-key-slot@1.1.2: {}
|
||||||
|
|
||||||
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
effect@4.0.0-rc.115: {}
|
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:
|
redis@6.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@redis/bloom': 6.2.1(@redis/client@6.2.1)
|
'@redis/bloom': 6.2.1(@redis/client@6.2.1)
|
||||||
@@ -681,6 +1286,44 @@ snapshots:
|
|||||||
- '@node-rs/xxhash'
|
- '@node-rs/xxhash'
|
||||||
- '@opentelemetry/api'
|
- '@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:
|
typescript@7.0.2:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript/typescript-aix-ppc64': 7.0.2
|
'@typescript/typescript-aix-ppc64': 7.0.2
|
||||||
@@ -708,4 +1351,58 @@ snapshots:
|
|||||||
|
|
||||||
undici@8.10.2: {}
|
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: {}
|
ws@8.21.3: {}
|
||||||
+319
@@ -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 = <S extends Schema.Top>(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<DocumentSymbol> | undefined;
|
||||||
|
}
|
||||||
|
export const DocumentSymbol: Schema.Codec<DocumentSymbol> = 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> => 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;
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -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)),
|
||||||
|
);
|
||||||
+167
@@ -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;
|
||||||
@@ -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<string, PublishDiagnosticsParams>(),
|
||||||
|
);
|
||||||
|
const updates = yield* PubSub.unbounded<string>();
|
||||||
|
const settled = new Map<string, number>();
|
||||||
|
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<PublishDiagnosticsParams>,
|
||||||
|
LspError
|
||||||
|
>;
|
||||||
|
/** Diagnostics matching the document's current version. */
|
||||||
|
readonly forDocument: (
|
||||||
|
doc: TextDocument,
|
||||||
|
) => Effect.Effect<ReadonlyArray<Diagnostic>, LspError>;
|
||||||
|
/** URIs whose diagnostics changed. */
|
||||||
|
readonly changes: Stream.Stream<string>;
|
||||||
|
}
|
||||||
|
>()("lsp-mcp/lsp/Diagnostics") {
|
||||||
|
static readonly layer = Layer.effect(Diagnostics, make);
|
||||||
|
}
|
||||||
@@ -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<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocumentError extends Schema.TaggedError<DocumentError>()(
|
||||||
|
"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<TextDocument, DocumentError>;
|
||||||
|
/** Current content of a URI without opening it in the server. */
|
||||||
|
readonly peek: (uri: string) => Effect.Effect<TextDocument, DocumentError>;
|
||||||
|
/** Replace an open document's content on disk and in the server. */
|
||||||
|
readonly write: (
|
||||||
|
uri: string,
|
||||||
|
text: string,
|
||||||
|
) => Effect.Effect<void, DocumentError>;
|
||||||
|
/** Apply a server-provided edit to disk, in order, refusing stale versions. */
|
||||||
|
readonly applyEdit: (
|
||||||
|
edit: WorkspaceEdit,
|
||||||
|
) => Effect.Effect<void, DocumentError>;
|
||||||
|
readonly opened: Effect.Effect<ReadonlyArray<string>>;
|
||||||
|
}
|
||||||
|
>()("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<Change> =>
|
||||||
|
edit.documentChanges ??
|
||||||
|
Object.entries(edit.changes ?? {}).map(([uri, edits]) => ({
|
||||||
|
textDocument: { uri, version: null },
|
||||||
|
edits,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const textEdits = (edit: TextDocumentEdit): Array<TextEdit> =>
|
||||||
|
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<string, Entry, DocumentError>({
|
||||||
|
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<string, string> = {
|
||||||
|
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",
|
||||||
|
};
|
||||||
@@ -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<string, number>): Record<number, string> =>
|
||||||
|
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<Model.Severity>;
|
||||||
|
|
||||||
|
// --- 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(`(?<![\\p{L}\\p{N}_$])${escaped}(?![\\p{L}\\p{N}_$])`, "u"),
|
||||||
|
);
|
||||||
|
const found = index >= 0 ? index : text.indexOf(symbol);
|
||||||
|
return found >= 0 ? Effect.succeed(found) : Effect.fail(found);
|
||||||
|
};
|
||||||
|
|
||||||
|
const locations = Effect.fn("Editor.locations")(function* (
|
||||||
|
items: ReadonlyArray<Location | LocationLink>,
|
||||||
|
) {
|
||||||
|
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<typeof Model.FileEdits.Type> = [];
|
||||||
|
const operations: Array<string> = [];
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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<string>;
|
||||||
|
/** 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>()("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: <P, R>(
|
||||||
|
type: RequestType<P, R, unknown>,
|
||||||
|
params: P,
|
||||||
|
) => Effect.Effect<R, LspError>;
|
||||||
|
readonly notify: <P>(
|
||||||
|
type: NotificationType<P>,
|
||||||
|
params: P,
|
||||||
|
) => Effect.Effect<void>;
|
||||||
|
readonly notifications: <P>(type: NotificationType<P>) => Stream.Stream<P>;
|
||||||
|
/** Handle a request the server sends to us; unhandled methods answer `MethodNotFound`. */
|
||||||
|
readonly onRequest: <P, R>(
|
||||||
|
type: RequestType<P, R, unknown>,
|
||||||
|
handler: (params: P) => Effect.Effect<R, unknown>,
|
||||||
|
) => Effect.Effect<void, never, Scope.Scope>;
|
||||||
|
/** `window/logMessage` and `window/showMessage` traffic. */
|
||||||
|
readonly logs: Stream.Stream<LogMessage>;
|
||||||
|
}
|
||||||
|
>()("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<never>();
|
||||||
|
const runPromise = yield* FiberSet.makeRuntimePromise<never>();
|
||||||
|
const exited = yield* Deferred.make<never, LspError>();
|
||||||
|
const events = yield* PubSub.unbounded<{
|
||||||
|
readonly method: string;
|
||||||
|
readonly params: unknown;
|
||||||
|
}>();
|
||||||
|
const progress = yield* SubscriptionRef.make(
|
||||||
|
HashSet.empty<string | number>(),
|
||||||
|
);
|
||||||
|
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<Uint8Array, never>({
|
||||||
|
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 = <P, R>(type: RequestType<P, R, unknown>, params: P) =>
|
||||||
|
Effect.callback<R, LspError>((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 = <P, R>(type: RequestType<P, R, unknown>, 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 = <P>(type: NotificationType<P>, 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 = <P>(type: NotificationType<P>): Stream.Stream<P> =>
|
||||||
|
Stream.fromPubSub(events).pipe(
|
||||||
|
Stream.filter((event) => event.method === type.method),
|
||||||
|
Stream.map((event) => event.params as P),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRequest = <P, R>(
|
||||||
|
type: RequestType<P, R, unknown>,
|
||||||
|
handler: (params: P) => Effect.Effect<R, unknown>,
|
||||||
|
) =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.sync(() =>
|
||||||
|
connection.onRequest(
|
||||||
|
type.method,
|
||||||
|
(params: P): HandlerResult<R, unknown> =>
|
||||||
|
runPromise(
|
||||||
|
handler(params).pipe(
|
||||||
|
Effect.mapError((error) =>
|
||||||
|
error instanceof ResponseError
|
||||||
|
? error
|
||||||
|
: new ResponseError(
|
||||||
|
ErrorCodes.InternalError,
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) as Promise<never>,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(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<MessageType, LogLevel> = {
|
||||||
|
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<unknown>(
|
||||||
|
(value, key) => (value as Record<string, unknown> | 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);
|
||||||
|
}
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* CLI entry point.
|
||||||
|
*
|
||||||
|
* lsp-mcp [--stdio | --http <port>] [--root <dir>] -- <server> [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:<port>/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 }),
|
||||||
|
);
|
||||||
@@ -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<null, { open: number; closes: number }, void>(
|
||||||
|
"fixture/state",
|
||||||
|
);
|
||||||
|
const Progress = new RequestType<number, string, void>("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/,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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<string, string>();
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
Vendored
+160
@@ -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();
|
||||||
Vendored
+23
@@ -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<string, string>) =>
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
+5
-28
@@ -2,50 +2,27 @@
|
|||||||
"$schema": "./node_modules/@effect/tsgo/schema.json",
|
"$schema": "./node_modules/@effect/tsgo/schema.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
// File Layout
|
|
||||||
// "rootDir": "./src",
|
|
||||||
// "outDir": "./dist",
|
|
||||||
|
|
||||||
// Environment Settings
|
|
||||||
// See also https://aka.ms/tsconfig/module
|
|
||||||
"module": "nodenext",
|
"module": "nodenext",
|
||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
"types": [],
|
"types": ["node"],
|
||||||
// For nodejs:
|
"allowImportingTsExtensions": true,
|
||||||
// "lib": ["esnext"],
|
|
||||||
// "types": ["node"],
|
|
||||||
// and npm install -D @types/node
|
|
||||||
|
|
||||||
// Other Outputs
|
|
||||||
"sourceMap": true,
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
|
|
||||||
// Stricter Typechecking Options
|
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"exactOptionalPropertyTypes": true,
|
"exactOptionalPropertyTypes": true,
|
||||||
|
|
||||||
// Style Options
|
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
|
||||||
// Recommended Options
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"jsx": "react-jsx",
|
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"plugins": [
|
"plugins": [{ "name": "@effect/language-service" }]
|
||||||
{
|
|
||||||
"name": "@effect/language-service"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
|
"include": ["src", "test", "vitest.config.ts"],
|
||||||
"exclude": ["node_modules", "repos"]
|
"exclude": ["node_modules", "repos"]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["test/**/*.test.ts"],
|
||||||
|
testTimeout: 20_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user