webnav-ts-mcp 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  An MCP server that gives AI agents JS/TS/HTML/CSS navigation, plus a
4
4
  cross-file index of CSS custom properties and `#id`/`.class` selectors that
5
- single-file language servers can't provide. It needs only Node.js and installs
6
- with a web project's own package manager.
5
+ single-file language servers can't provide, and write tools that type-check an
6
+ edit before it is written. It needs only Node.js and installs with a web
7
+ project's own package manager.
7
8
 
8
9
  ## Quick start
9
10
 
@@ -92,6 +93,53 @@ Then use the position tools once you have a `path:line:col`:
92
93
  Positions are **1-indexed**; `column` is a UTF-16 character offset (a leading
93
94
  tab counts as one character).
94
95
 
96
+ ## Write tools: change code with a type check before every write
97
+
98
+ The navigation tools above are read-only. These change code, and share one rule:
99
+ **the edit is shown to the language servers as an in-memory overlay first, the
100
+ edited files and the script files importing them are re-checked, and the
101
+ response says which diagnostics the edit would add or fix. Nothing is written
102
+ until that is on the table.**
103
+
104
+ | Tool | Does |
105
+ |------|------|
106
+ | `edit` | Type-checked text edit of a JS/TS/HTML/CSS file: `old_string`/`new_string` or a whole `new_text` (also creates a file). For anything that is not a whole definition. Line endings follow the file |
107
+ | `edit_symbol` | `action="replace"` / `"insert"` / `"delete"` a function, class, interface, enum, const or member by name: re-indented to the file's own style (tabs or spaces), doc comment kept, `imports=[...]` merged into the file, blank-line spacing handled. Delete refuses while anything uses the symbol and prunes imports only the deleted code used |
108
+ | `rename_symbol` | JS/TS: rename through the TypeScript language server (imports, aliases, overriding members; `parameter=` for one parameter). CSS/HTML: a name starting with `--`, `#` or `.` renames that custom property / id / class across stylesheets, `<style>` and `style=""`, markup (`class`, `id`, `for`, `aria-*`, `href="#id"`) and scripts (`getElementById`, `classList`, `querySelector`, `className`, `setProperty`, markup in strings). Names built dynamically and script strings that merely equal the name are listed, not renamed; other places still using the old name (docs, `.vue`/`.scss`, strings) are listed too |
109
+ | `move` | With `name`: move a top-level function/class/interface/type/const to another file with the imports it needs, repoint every importer and re-export, drop imports only the moved code used, warn about import cycles. Without it: move or rename the file `file_path` (any file): imports (the language server's own rewrite), `<script src>`/`<link href>`/`<img src>` and CSS `url()`/`@import` follow, and the moved file's own relative references are re-based |
110
+ | `quick_fix` | Apply the language server's own fixes (missing imports, misspellings, ...) one by one, re-checking after each; or a source action: `organize_imports`, `remove_unused_imports`, `sort_imports`, `fix_all`. Ambiguous fixes are listed instead of guessed |
111
+ | `verify_changes` | After editing with any tool (your own included): which diagnostics the working tree gained or lost since a git revision (default `HEAD`) |
112
+ | `apply_edit` / `undo_edit` | Write a previewed edit by id; revert an applied one |
113
+
114
+ One rule for every write tool: it writes when the edit adds no more than
115
+ `max_new_errors` (default 0) errors in the edited files and their importers and
116
+ the result parses. Otherwise nothing is written and you get the diagnostics plus
117
+ an id for `apply_edit`. `apply=false` always previews. Hints and warnings are
118
+ shown but not counted. Syntax errors (TypeScript's 1xxx codes, any CSS error)
119
+ block a write even with `max_new_errors=null`.
120
+
121
+ Safety rules, enforced in code:
122
+
123
+ - Only `.js`/`.mjs`/`.cjs`/`.jsx`/`.ts`/`.mts`/`.cts`/`.tsx`/`.html`/`.css` files inside the
124
+ workspace are edited (`move` also carries any file, such as an image a stylesheet
125
+ uses); `node_modules`, `dist`, `.git` and the other skipped folders, generated output
126
+ (`WEBNAV_MCP_EXCLUDE`) and symlinks are refused.
127
+ - A preview remembers the exact bytes of every file; `apply_edit` refuses if any file
128
+ changed since. Files are replaced atomically (temp file + rename), and a failure
129
+ part-way restores the ones already written.
130
+ - Every applied edit is undoable (`undo_edit`) unless a file was changed after it.
131
+ - Line endings and a leading BOM are preserved.
132
+ - Edits touching more than 40 files need `allow_large=true`.
133
+ - `WEBNAV_MCP_READ_ONLY=1` turns every write into a refusal (previews still work).
134
+
135
+ Scope: the importers that get re-checked are found by relative `import`/`export
136
+ ... from`/`require()`/`import()` specifiers (`./a`, `./a.js` for `a.ts`, `./dir`
137
+ for `dir/index.ts`); a file reached only through a path alias is checked with its
138
+ own edit alone. `move`'s HTML/CSS rewrite understands relative and root-absolute
139
+ (`/src/main.ts`) references; bundler config, path aliases and string paths are
140
+ listed as mentions, not rewritten. HTML/CSS edits are checked by their language
141
+ servers (syntax and CSS lint), not against the cross-file index.
142
+
95
143
  ## Environment
96
144
 
97
145
  | Variable | Default | Purpose |
@@ -99,6 +147,7 @@ tab counts as one character).
99
147
  | `WEBNAV_MCP_WORKSPACE` | unset: follows the client's MCP roots when they name a worktree of the same git repository, else `CLAUDE_PROJECT_DIR`, else the working directory | Pins the project root (never overridden). See the `workspace` tool |
100
148
  | `WEBNAV_MCP_ROOTS` | the whole workspace as one root, labelled `web` | Comma-separated `label=relative/path` pairs to index separately, e.g. `app=src,prototypes=design` |
101
149
  | `WEBNAV_MCP_EXCLUDE` | nothing | Comma-separated workspace-relative paths of generated script output (e.g. the JS a TS build emits). Not opened, hidden from `search_symbol`, rejected by the position tools; the CSS/selector index still reads them |
150
+ | `WEBNAV_MCP_READ_ONLY` | unset | `1`/`true`: write tools refuse to write (previews and checks still work) |
102
151
  | `WEBNAV_MCP_PUBLIC` | nothing | Comma-separated workspace-relative stylesheets (files or directories) that are a public API, e.g. a design-token file consumed by other projects. `diagnostics` stops reporting their custom properties as "declared but never used" and their selectors as "never referenced"; undefined `var()` usages are still reported |
103
152
 
104
153
  ## Layout
@@ -106,13 +155,17 @@ tab counts as one character).
106
155
  ```
107
156
  src/
108
157
  cli.ts stdio entry point (exits when the host closes stdin)
109
- server.ts registers the twelve tools on the MCP SDK
158
+ server.ts registers the tools on the MCP SDK
110
159
  webnav.ts the tools as plain async functions returning text; owns all state
111
160
  webIndex.ts CSS var / selector index
112
161
  langCommand.ts how to launch tsc / html / css language servers
113
162
  glob.ts just enough glob for jsconfig `include`
114
163
  shared/ lspClient, format, resolve, workspace, notices, errors,
115
164
  params, exclude
165
+ write/ the write tools: edits (plans, diffs), transaction (guard,
166
+ journal, undo), simulate + diagnosticsDelta (the type check),
167
+ finish (preview/apply), one module per tool, webTokens
168
+ (CSS var/id/class finder), jsImports (import rewriting)
116
169
  tests/ vitest; fixtures/sample-app is the shared fixture
117
170
  ```
118
171
 
@@ -133,10 +186,10 @@ Source: [github.com/illescasDaniel/webnav-ts-mcp](https://github.com/illescasDan
133
186
  `webnav` is glue: it spawns three Node language servers and formats their answers,
134
187
  plus a regex-grade index.
135
188
 
136
- **Tests:** vitest (unit tests for the index/format code, real language servers
137
- against the fixture, MCP protocol over an in-memory transport incl. worktree
138
- selection via client roots with a real `git worktree`, LSP client failure modes,
139
- and the built executable).
189
+ **Tests:** vitest (unit tests for the index/format code and the pure parts of the
190
+ write tools, real language servers against the fixture and temporary projects,
191
+ MCP protocol over an in-memory transport incl. worktree selection via client roots
192
+ with a real `git worktree`, LSP client failure modes, and the built executable).
140
193
 
141
194
  ### Things worth knowing
142
195
 
@@ -159,6 +212,20 @@ and the built executable).
159
212
  aren't followed).
160
213
  - **`callers` and `implementations`** are offered because the server advertises
161
214
  `callHierarchyProvider` and `implementationProvider`.
215
+ - **Type-checking an edit without writing it** relies on documents the client
216
+ opens with `didOpen`/`didChange` shadowing the disk (`LspClient.withOverlay`).
217
+ tsc's native server resolves imports of a file that exists only as an overlay,
218
+ but needs its *directory* to exist and remembers a failed resolution, so the
219
+ overlay opens new files first and creates (then removes) empty directories.
220
+ - **A file with no `import`/`export` is a global script** to TypeScript. Moving
221
+ the last export out of a module turns it into one, and every top-level name in
222
+ it becomes visible everywhere; the type check can then miss references that
223
+ should have broken.
224
+ - **Symbol ranges from `documentSymbol` are tight:** no doc comment, no
225
+ `export const` keyword, one symbol per declarator. `edit_symbol` and `move`
226
+ widen them (`extentOf`) and refuse declarators that share a statement.
227
+ - **The server's unused-import hints identify what a deletion orphaned:** hints
228
+ after the edit minus hints before it, so imports that were already unused stay.
162
229
 
163
230
  ### Costs
164
231
 
package/dist/server.js CHANGED
@@ -11,6 +11,17 @@ import { z } from "zod";
11
11
  import { INSTRUCTIONS, Webnav } from "./webnav.js";
12
12
  const POSITION_DOC = "`line` and `column` are 1-indexed. `column` is a UTF-16 character offset on the line " +
13
13
  "(not a visual/display column): a leading tab counts as one character.";
14
+ const WRITE_RULE = "The edit is shown to the language servers as an in-memory overlay first and the edited files and the " +
15
+ "files importing them are re-checked: it is written only when that adds no more errors than " +
16
+ "`max_new_errors` (default 0; null = no limit) and the result parses. Otherwise nothing is written and " +
17
+ "you get the new diagnostics plus a preview id for `apply_edit`. `apply=false` always previews. " +
18
+ "`undo_edit` reverts an applied edit.";
19
+ const WRITES = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
20
+ const READS = { readOnlyHint: true, openWorldHint: false };
21
+ const writeParams = {
22
+ apply: z.boolean().default(true),
23
+ max_new_errors: z.number().int().min(0).nullable().optional(),
24
+ };
14
25
  const text = (value) => ({
15
26
  content: [{ type: "text", text: value }],
16
27
  });
@@ -160,5 +171,189 @@ export function createServer(version) {
160
171
  "accepted as an alias for `name`.",
161
172
  inputSchema: { name: z.string().optional(), query: z.string().optional() },
162
173
  }, async (args) => text(await webnav.selectorLookup(args)));
174
+ registerWriteTools(server, webnav);
163
175
  return { server, webnav };
164
176
  }
177
+ function registerWriteTools(server, webnav) {
178
+ server.registerTool("edit", {
179
+ description: 'Edit text in a JS/TS/HTML/CSS file, type-checked: `edit(file_path="src/app.ts", old_string="x = 1", new_string="x = 2")`.\n\n' +
180
+ "For changes that are not a whole definition (a few lines, an import, a rule, markup) or for rewriting a " +
181
+ "whole file (`new_text`, which also creates a new file). To replace, add or delete a function/class/method " +
182
+ "by name use `edit_symbol`. `old_string` must match exactly once unless `replace_all=true` " +
183
+ "(line endings follow the file). `include_dependents=false` skips checking the importers. " +
184
+ WRITE_RULE,
185
+ inputSchema: {
186
+ file_path: z.string().optional(),
187
+ old_string: z.string().optional(),
188
+ new_string: z.string().optional(),
189
+ replace_all: z.boolean().default(false),
190
+ new_text: z.string().optional(),
191
+ include_dependents: z.boolean().default(true),
192
+ ...writeParams,
193
+ },
194
+ annotations: WRITES,
195
+ }, async ({ max_new_errors, ...a }) => text(await webnav.edit({
196
+ filePath: a.file_path,
197
+ oldString: a.old_string,
198
+ newString: a.new_string,
199
+ replaceAll: a.replace_all,
200
+ newText: a.new_text,
201
+ includeDependents: a.include_dependents,
202
+ apply: a.apply,
203
+ maxNewErrors: max_new_errors,
204
+ })));
205
+ server.registerTool("edit_symbol", {
206
+ description: "Replace, add or delete a JS/TS function, class, interface, enum, const or member by name. Examples: " +
207
+ '`edit_symbol(action="replace", name="Cart.total", source="total(): number { ... }")`, ' +
208
+ '`edit_symbol(action="insert", file_path="src/utils.ts", name="parse", source="function helper() {}")`, ' +
209
+ '`edit_symbol(action="delete", name="legacyParse")`.\n\n' +
210
+ '- `action="replace"`: `name` (dotted `Class.method` ok) and `source` (the complete new definition, ' +
211
+ "decorators included; re-indented to the old one's place in the file's own indent style). The doc comment " +
212
+ "above is kept unless `source` brings its own. A changed signature shows up as errors at call sites.\n" +
213
+ '- `action="insert"`: `source` and `file_path`. `position` is relative to `name`: "after" (default when ' +
214
+ '`name` is given) / "before" a symbol, "into" a class/interface/enum/namespace (appended to its body), or ' +
215
+ '"end" of the file (default without `name`; creates the file if missing). Refuses a name that already exists there.\n' +
216
+ '- `action="delete"`: `name` only. Refuses while anything still uses it and lists the users (`force=true` ' +
217
+ "deletes anyway and shows what breaks). Imports only the deleted code used are removed (`prune_imports`). " +
218
+ "Strings, comments and docs naming it are listed and the deletion is previewed instead of applied.\n\n" +
219
+ "`file_path` narrows `name` when several symbols share it. `imports` (replace/insert) is a list of " +
220
+ 'import statements the new code needs (`["import { Money } from \\"./money\\""]`), merged into the file if missing. ' +
221
+ WRITE_RULE,
222
+ inputSchema: {
223
+ action: z.enum(["replace", "insert", "delete"]).optional(),
224
+ name: z.string().optional(),
225
+ source: z.string().optional(),
226
+ file_path: z.string().optional(),
227
+ position: z.enum(["after", "before", "into", "end"]).optional(),
228
+ imports: z.array(z.string()).optional(),
229
+ force: z.boolean().default(false),
230
+ prune_imports: z.boolean().default(true),
231
+ ...writeParams,
232
+ },
233
+ annotations: WRITES,
234
+ }, async ({ max_new_errors, ...a }) => text(await webnav.editSymbol({
235
+ action: a.action,
236
+ name: a.name,
237
+ source: a.source,
238
+ filePath: a.file_path,
239
+ position: a.position,
240
+ imports: a.imports,
241
+ force: a.force,
242
+ pruneImports: a.prune_imports,
243
+ apply: a.apply,
244
+ maxNewErrors: max_new_errors,
245
+ })));
246
+ server.registerTool("rename_symbol", {
247
+ description: 'Rename a symbol everywhere it is used. Examples: `rename_symbol(name="Cart.total", new_name="sum")`, ' +
248
+ '`rename_symbol(name="--accent", new_name="--brand")`, `rename_symbol(name=".card-title", new_name="heading")`.\n\n' +
249
+ "JS/TS: give `name` (dotted `Class.method` ok; `file_path` narrows it) or `file_path` + `line` + `column` " +
250
+ "(1-indexed). Built on the TypeScript language server's rename (imports, aliases, overriding members); " +
251
+ '`parameter="x"` with a function\'s name renames one parameter. Places that still say the old name that ' +
252
+ "the type checker cannot link (strings, comments, docs, other languages) are listed.\n\n" +
253
+ "CSS/HTML: a `name` starting with `--`, `#` or `.` renames that custom property / id / class across " +
254
+ "stylesheets (declarations, `var()`, selectors), HTML (`class`, `id`, and the attributes that refer to ids: " +
255
+ '`for`, `aria-*`, `href="#id"`) and scripts (`getElementById`, `classList`, `querySelector`, `className`, ' +
256
+ '`setProperty`/`getPropertyValue`, markup inside strings). Names built dynamically (`"row-" + id`) are listed, ' +
257
+ "not renamed; script string literals that merely equal the name are listed and renamed only with " +
258
+ "`include_string_literals=true`. Refuses a new name that already exists (`force=true` merges). With several " +
259
+ "`WEBNAV_MCP_ROOTS` pass `file_path` to say which root. " +
260
+ WRITE_RULE,
261
+ inputSchema: {
262
+ new_name: z.string().optional(),
263
+ name: z.string().optional(),
264
+ file_path: z.string().optional(),
265
+ line: z.number().int().optional(),
266
+ column: z.number().int().optional(),
267
+ parameter: z.string().optional(),
268
+ force: z.boolean().default(false),
269
+ include_string_literals: z.boolean().default(false),
270
+ allow_large: z.boolean().default(false),
271
+ ...writeParams,
272
+ },
273
+ annotations: WRITES,
274
+ }, async ({ max_new_errors, ...a }) => text(await webnav.renameSymbol({
275
+ newName: a.new_name,
276
+ name: a.name,
277
+ filePath: a.file_path,
278
+ line: a.line,
279
+ column: a.column,
280
+ parameter: a.parameter,
281
+ force: a.force,
282
+ includeStringLiterals: a.include_string_literals,
283
+ allowLarge: a.allow_large,
284
+ apply: a.apply,
285
+ maxNewErrors: max_new_errors,
286
+ })));
287
+ server.registerTool("move", {
288
+ description: "Move a declaration to another file, or a whole file, and fix every reference. Examples: " +
289
+ '`move(name="parseDate", to_file="src/app/dates.ts")`, `move(file_path="src/old/util.ts", to_file="src/lib/util.ts")`.\n\n' +
290
+ "With `name`: moves that top-level function/class/interface/enum/type/const (`file_path` narrows which). " +
291
+ "The destination is created if missing; the code gets the imports it needs (found by the language server), " +
292
+ '`export` is added if it was private, every `import { name } from "old"` elsewhere is repointed, the old ' +
293
+ "file imports it from the new place if it still uses it (`keep_reexport=true` also leaves `export { name } " +
294
+ 'from "new"`), and imports only the moved code used are dropped. Import cycles are warned about.\n\n' +
295
+ "Without `name`: `file_path` is the file to move or rename (any file; `to_file` may be a directory). Every " +
296
+ "import/re-export in JS/TS (the language server's own rewrite), `<script src>`/`<link href>`/`<img src>` in " +
297
+ "HTML (relative and root-absolute), and `url()`/`@import` in CSS follow, and the moved file's own relative " +
298
+ "references are re-based. Configs, path aliases and string paths are listed, not rewritten. " +
299
+ WRITE_RULE,
300
+ inputSchema: {
301
+ to_file: z.string().optional(),
302
+ name: z.string().optional(),
303
+ file_path: z.string().optional(),
304
+ keep_reexport: z.boolean().default(false),
305
+ allow_large: z.boolean().default(false),
306
+ ...writeParams,
307
+ },
308
+ annotations: WRITES,
309
+ }, async ({ max_new_errors, ...a }) => text(await webnav.move({
310
+ toFile: a.to_file,
311
+ name: a.name,
312
+ filePath: a.file_path,
313
+ keepReexport: a.keep_reexport,
314
+ allowLarge: a.allow_large,
315
+ apply: a.apply,
316
+ maxNewErrors: max_new_errors,
317
+ })));
318
+ server.registerTool("quick_fix", {
319
+ description: 'Apply the TypeScript language server\'s own fixes to a JS/TS file: `quick_fix(file_path="src/app.ts")`.\n\n' +
320
+ "`action=\"fix\"` (default) fixes the file's errors one by one with the server's code actions (missing " +
321
+ "imports, misspelled names, ...), re-checking after each; `line` limits it to errors starting on that line. " +
322
+ "A fix with several candidates is listed, not guessed. The other actions are source actions on the whole " +
323
+ "file: `organize_imports`, `remove_unused_imports`, `sort_imports`, `fix_all`. " +
324
+ WRITE_RULE,
325
+ inputSchema: {
326
+ file_path: z.string().optional(),
327
+ line: z.number().int().optional(),
328
+ action: z.enum(["fix", "organize_imports", "remove_unused_imports", "sort_imports", "fix_all"]).optional(),
329
+ ...writeParams,
330
+ },
331
+ annotations: WRITES,
332
+ }, async ({ max_new_errors, ...a }) => text(await webnav.quickFix({
333
+ filePath: a.file_path,
334
+ line: a.line,
335
+ action: a.action,
336
+ apply: a.apply,
337
+ maxNewErrors: max_new_errors,
338
+ })));
339
+ server.registerTool("verify_changes", {
340
+ description: "What did the edits since a git revision break? Compares the language servers' diagnostics now against " +
341
+ "`since` (default `HEAD`).\n\nRun it after editing with any tool (your own Edit/Write included): the " +
342
+ "changed JS/TS/HTML/CSS files and the script files importing them are checked, with the committed versions " +
343
+ 'substituted in for the "before" side. Read-only.',
344
+ inputSchema: { since: z.string().default("HEAD"), include_dependents: z.boolean().default(true) },
345
+ annotations: READS,
346
+ }, async ({ since, include_dependents }) => text(await webnav.verifyChanges({ since, includeDependents: include_dependents })));
347
+ server.registerTool("apply_edit", {
348
+ description: "Write a previewed edit (the id comes from a write tool's preview). Fails, writing nothing, if any file " +
349
+ "changed since the preview.",
350
+ inputSchema: { id: z.string().optional(), allow_large: z.boolean().default(false) },
351
+ annotations: WRITES,
352
+ }, async ({ id, allow_large }) => text(await webnav.applyEdit(id ?? "", allow_large)));
353
+ server.registerTool("undo_edit", {
354
+ description: "Revert an edit applied by the write tools (the latest one by default). Refuses when any of its files " +
355
+ "changed after the edit, so later work is never overwritten.",
356
+ inputSchema: { id: z.string().optional() },
357
+ annotations: WRITES,
358
+ }, async ({ id }) => text(await webnav.undoEdit(id)));
359
+ }
@@ -18,6 +18,7 @@ import { InvalidPositionError, LanguageServerExitedError, LspRequestError, LspTi
18
18
  import { EXCLUDED_DIR_NAMES } from "./exclude.js";
19
19
  import { uriToPath } from "./format.js";
20
20
  import { debug } from "./log.js";
21
+ import { canonicalPath, removeEmptyDirChain } from "./paths.js";
21
22
  import { LINE_BREAK_RE, readTextStrict } from "./text.js";
22
23
  /** Max ms to wait for a push-only server's publishDiagnostics after a sync. */
23
24
  export const PUSH_DIAGNOSTICS_TIMEOUT_MS = 5000;
@@ -40,6 +41,8 @@ const DEFAULT_TIMEOUT_MS = 20_000;
40
41
  const FILE_CREATED = 1;
41
42
  const FILE_CHANGED = 2;
42
43
  const FILE_DELETED = 3;
44
+ // `mtimeNs` of a document whose text came from `withOverlay`, never equal to a real stamp.
45
+ const OVERLAY_STAMP = -2n;
43
46
  function deferred() {
44
47
  const d = { done: false };
45
48
  d.promise = new Promise((resolve) => {
@@ -84,6 +87,8 @@ export class LspClient {
84
87
  configSnapshot;
85
88
  configHashes = new Map();
86
89
  refreshChain = Promise.resolve();
90
+ // uri -> text shown to the server instead of the disk's (a simulated edit, see `withOverlay`).
91
+ overlays = new Map();
87
92
  constructor(options) {
88
93
  this.workspaceRoot = options.workspaceRoot;
89
94
  this.command = options.command;
@@ -176,8 +181,20 @@ export class LspClient {
176
181
  documentSymbol: { hierarchicalDocumentSymbolSupport: true },
177
182
  callHierarchy: {},
178
183
  typeHierarchy: {},
184
+ rename: { prepareSupport: true },
185
+ codeAction: {
186
+ codeActionLiteralSupport: {
187
+ codeActionKind: { valueSet: ["quickfix", "source", "source.organizeImports"] },
188
+ },
189
+ resolveSupport: { properties: ["edit"] },
190
+ },
191
+ },
192
+ workspace: {
193
+ workspaceFolders: true,
194
+ didChangeWatchedFiles: { dynamicRegistration: true },
195
+ workspaceEdit: { documentChanges: true, resourceOperations: ["create", "rename", "delete"] },
196
+ fileOperations: { willRename: true },
179
197
  },
180
- workspace: { workspaceFolders: true, didChangeWatchedFiles: { dynamicRegistration: true } },
181
198
  },
182
199
  workspaceFolders: [{ uri: rootUri, name: path.basename(this.workspaceRoot) }],
183
200
  }),
@@ -229,6 +246,7 @@ export class LspClient {
229
246
  this.openFiles = new Map();
230
247
  this.diagEvents = new Map();
231
248
  this.symbolCache = new Map();
249
+ this.overlays = new Map();
232
250
  await this.start();
233
251
  if (this.onRestart) {
234
252
  await this.onRestart(this);
@@ -320,13 +338,7 @@ export class LspClient {
320
338
  }
321
339
  // -- document sync -------------------------------------------------------
322
340
  toPath(filePath) {
323
- const abs = path.isAbsolute(filePath) ? filePath : path.join(this.workspaceRoot, filePath);
324
- try {
325
- return fs.realpathSync(abs);
326
- }
327
- catch {
328
- return path.resolve(abs);
329
- }
341
+ return canonicalPath(path.isAbsolute(filePath) ? filePath : path.join(this.workspaceRoot, filePath));
330
342
  }
331
343
  languageIdFor(p) {
332
344
  return this.languageIds[path.extname(p).toLowerCase()] ?? this.languageId;
@@ -334,12 +346,24 @@ export class LspClient {
334
346
  async ensureOpen(filePath) {
335
347
  const abs = this.toPath(filePath);
336
348
  const uri = pathToFileURL(abs).href;
337
- const stamp = statStamp(abs);
338
349
  const known = this.openFiles.get(uri);
339
- if (known && known.mtimeNs === stamp.mtimeNs && known.size === stamp.size) {
340
- return uri;
350
+ const overlay = this.overlays.get(uri);
351
+ let stamp;
352
+ let text;
353
+ if (overlay !== undefined) {
354
+ if (known && known.overlay === overlay) {
355
+ return uri;
356
+ }
357
+ stamp = { mtimeNs: OVERLAY_STAMP, size: overlay.length };
358
+ text = overlay;
359
+ }
360
+ else {
361
+ stamp = statStamp(abs);
362
+ if (known && known.overlay === undefined && known.mtimeNs === stamp.mtimeNs && known.size === stamp.size) {
363
+ return uri;
364
+ }
365
+ text = readTextStrict(abs);
341
366
  }
342
- const text = readTextStrict(abs);
343
367
  this.diagEvents.set(uri, deferred());
344
368
  // The previous version's pushed diagnostics describe text that no longer
345
369
  // exists; keeping them would resurface fixed errors as a "cache fallback".
@@ -350,7 +374,7 @@ export class LspClient {
350
374
  this.notify("textDocument/didOpen", {
351
375
  textDocument: { uri, languageId: this.languageIdFor(abs), version: 1, text },
352
376
  });
353
- this.openFiles.set(uri, { uri, version: 1, ...stamp });
377
+ this.openFiles.set(uri, { uri, version: 1, ...stamp, overlay });
354
378
  }
355
379
  else {
356
380
  const version = known.version + 1;
@@ -358,10 +382,74 @@ export class LspClient {
358
382
  textDocument: { uri, version },
359
383
  contentChanges: [{ text }],
360
384
  });
361
- this.openFiles.set(uri, { uri, version, ...stamp });
385
+ this.openFiles.set(uri, { uri, version, ...stamp, overlay });
362
386
  }
363
387
  return uri;
364
388
  }
389
+ /** Change the text of a file shown through `withOverlay` (only valid while one is active). */
390
+ updateOverlay(filePath, text) {
391
+ if (this.overlays.size === 0) {
392
+ throw new Error("updateOverlay needs an active overlay");
393
+ }
394
+ this.overlays.set(pathToFileURL(this.toPath(filePath)).href, text);
395
+ }
396
+ /**
397
+ * Run `body` with the language server shown `texts` (absolute path -> text) in
398
+ * place of those files' disk content, files that don't exist yet included.
399
+ * Nothing is written: this is how an edit is type-checked before it is
400
+ * applied. Afterwards every overlaid file is resynced from the disk (or
401
+ * closed when it has none), so later calls see the real project again.
402
+ * One overlay at a time: callers must not nest or overlap it.
403
+ */
404
+ async withOverlay(texts, body) {
405
+ if (this.overlays.size > 0) {
406
+ throw new Error("overlays cannot be nested");
407
+ }
408
+ const paths = [...texts.keys()].map((p) => this.toPath(p));
409
+ for (const [p, text] of texts) {
410
+ this.overlays.set(pathToFileURL(this.toPath(p)).href, text);
411
+ }
412
+ // The server finds modules through the file system, so a module that would live in a directory
413
+ // that doesn't exist yet needs the (empty) directory for the few moments of the check.
414
+ const madeDirs = [];
415
+ for (const p of paths) {
416
+ if (!fs.existsSync(p)) {
417
+ const top = fs.mkdirSync(path.dirname(p), { recursive: true });
418
+ if (top !== undefined) {
419
+ madeDirs.push({ leaf: path.dirname(p), top });
420
+ }
421
+ }
422
+ }
423
+ try {
424
+ // Open files that don't exist yet first: the server remembers a failed module resolution, so an
425
+ // importer opened before the module it imports would keep reporting it missing.
426
+ for (const p of [...paths].sort((a, b) => Number(fs.existsSync(a)) - Number(fs.existsSync(b)))) {
427
+ await this.ensureOpen(p);
428
+ }
429
+ return await body();
430
+ }
431
+ finally {
432
+ this.overlays = new Map();
433
+ for (const p of paths) {
434
+ const uri = pathToFileURL(p).href;
435
+ if (fs.existsSync(p)) {
436
+ try {
437
+ await this.ensureOpen(p);
438
+ }
439
+ catch (error) {
440
+ debug(`could not resync ${p} after an overlay`, error);
441
+ this.closeDocument(uri);
442
+ }
443
+ }
444
+ else if (this.openFiles.has(uri)) {
445
+ this.closeDocument(uri);
446
+ }
447
+ }
448
+ for (const { leaf, top } of madeDirs) {
449
+ removeEmptyDirChain(leaf, top);
450
+ }
451
+ }
452
+ }
365
453
  closeDocument(uri) {
366
454
  this.notify("textDocument/didClose", { textDocument: { uri } });
367
455
  this.openFiles.delete(uri);
@@ -522,8 +610,8 @@ export class LspClient {
522
610
  }
523
611
  }
524
612
  for (const [uri, known] of [...this.openFiles]) {
525
- if (known.mtimeNs === -1n) {
526
- continue; // scratch document: never on disk
613
+ if (known.mtimeNs === -1n || this.overlays.has(uri)) {
614
+ continue; // scratch or overlaid document: not what the disk says
527
615
  }
528
616
  const file = uriToPath(uri);
529
617
  if (!fs.existsSync(file)) {
@@ -639,6 +727,49 @@ export class LspClient {
639
727
  async incomingCalls(item) {
640
728
  return (await this.request("callHierarchy/incomingCalls", { item })) ?? [];
641
729
  }
730
+ // -- edit-producing calls ----------------------------------------------------
731
+ /** The range `textDocument/rename` would rewrite at a position, or `null` when the symbol can't be renamed. */
732
+ async prepareRename(filePath, line, column) {
733
+ let result;
734
+ try {
735
+ result = await this.positionRequest("textDocument/prepareRename", filePath, line, column);
736
+ }
737
+ catch (error) {
738
+ if (error instanceof LspRequestError) {
739
+ return null;
740
+ }
741
+ throw error;
742
+ }
743
+ if (result === null || result === undefined) {
744
+ return null;
745
+ }
746
+ return "start" in result ? { range: result } : result;
747
+ }
748
+ async rename(filePath, line, column, newName) {
749
+ return ((await this.positionRequest("textDocument/rename", filePath, line, column, {
750
+ newName,
751
+ })) ?? {});
752
+ }
753
+ /** The import rewrites the server proposes for moving files (`workspace/willRenameFiles`). */
754
+ async willRenameFiles(files) {
755
+ const params = {
756
+ files: files.map((f) => ({
757
+ oldUri: pathToFileURL(this.toPath(f.oldPath)).href,
758
+ newUri: pathToFileURL(this.toPath(f.newPath)).href,
759
+ })),
760
+ };
761
+ return (await this.request("workspace/willRenameFiles", params)) ?? {};
762
+ }
763
+ /** Code actions of the given kinds at a range (`diagnostics` are the ones the range overlaps). */
764
+ async codeActions(filePath, range, diagnostics, only) {
765
+ const uri = await this.ensureOpen(filePath);
766
+ const result = await this.request("textDocument/codeAction", {
767
+ textDocument: { uri },
768
+ range,
769
+ context: { diagnostics, only },
770
+ });
771
+ return (result ?? []).filter((action) => "title" in action);
772
+ }
642
773
  }
643
774
  function sameStampMaps(a, b) {
644
775
  if (a.size !== b.size) {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Keeps simulated edits away from everything else.
3
+ *
4
+ * While a write tool shows the language server unsaved text (`LspClient.withOverlay`), no other
5
+ * tool may query that server, or it would answer from text that is not on disk. Every tool call
6
+ * runs as a shared holder; an overlay section is exclusive: it waits for the other holders to
7
+ * finish and holds new ones back until it is done. Exclusive sections queue first-come.
8
+ */
9
+ export class OverlayGate {
10
+ shared = 0;
11
+ exclusiveActive = false;
12
+ waiting = [];
13
+ wake() {
14
+ const queue = this.waiting;
15
+ this.waiting = [];
16
+ for (const resume of queue) {
17
+ resume();
18
+ }
19
+ }
20
+ enterShared() {
21
+ if (this.exclusiveActive) {
22
+ return false;
23
+ }
24
+ this.shared++;
25
+ return true;
26
+ }
27
+ /** Wait until `take()` succeeds; it checks and claims in one synchronous step, so waiters never pass together. */
28
+ async until(take) {
29
+ while (!take()) {
30
+ await new Promise((resolve) => this.waiting.push(resolve));
31
+ }
32
+ }
33
+ /** Run `body` as a shared holder (a tool call). */
34
+ async hold(body) {
35
+ await this.until(() => this.enterShared());
36
+ try {
37
+ return await body();
38
+ }
39
+ finally {
40
+ this.shared--;
41
+ this.wake();
42
+ }
43
+ }
44
+ /**
45
+ * Run `body` alone. Called from inside a shared holder (`holding`): that holder steps aside for
46
+ * the duration, so it does not wait for itself, and takes its place back afterwards.
47
+ */
48
+ async exclusive(body, holding = true) {
49
+ if (holding) {
50
+ this.shared--;
51
+ this.wake();
52
+ }
53
+ try {
54
+ await this.until(() => {
55
+ if (this.exclusiveActive || this.shared > 0) {
56
+ return false;
57
+ }
58
+ this.exclusiveActive = true;
59
+ return true;
60
+ });
61
+ try {
62
+ return await body();
63
+ }
64
+ finally {
65
+ this.exclusiveActive = false;
66
+ this.wake();
67
+ }
68
+ }
69
+ finally {
70
+ if (holding) {
71
+ await this.until(() => this.enterShared());
72
+ }
73
+ }
74
+ }
75
+ }