@heroiclands/content-language-server 0.1.0 → 0.1.1

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
@@ -8,4 +8,6 @@ Install an exact released package version in a directory managed by your editor
8
8
 
9
9
  See the [language server guide](docs/content-language-server.md) for LSP requests, cache locations, error handling, and manual recovery commands.
10
10
 
11
+ Editors can pass explicit foreign content roots in LSP initialization options. A normal workspace-symbol query stays in the current project; an `all:` query searches the configured roots as well. The server returns standard LSP symbols and locations, so the editor controls how results are presented.
12
+
11
13
  Maintainers use the [publishing guide](docs/publishing.md) for the first npm release and trusted publisher configuration.
@@ -23,8 +23,24 @@ The first prints the private JSONL path. The second is manual recovery when a fi
23
23
  | `workspace/symbol` | Finds notes by name, alias, ASCII name, shortcode, or Address. `tag:myth` searches tags. One result appears per source note. |
24
24
  | `textDocument/references` | Finds authored wikilinks, embeds, and declared frontmatter Address values or keys. Ordinary prose is excluded. |
25
25
 
26
- Reference search scans saved Markdown notes in the configured content tree. The server writes only LSP messages to stdout and uses UTF-16 positions.
26
+ Reference search uses indexed frontmatter to select candidates and reads saved Markdown source for exact ranges and body links. Unsaved edits identify the target under the cursor but do not enter workspace search. The server writes only LSP messages to stdout and uses UTF-16 positions.
27
+
28
+ ## Foreign content projects
29
+
30
+ An LSP client selects foreign project roots through `initialize`:
31
+
32
+ ```json
33
+ {
34
+ "initializationOptions": {
35
+ "foreignRoots": ["/absolute/path/to/another/content/project"]
36
+ }
37
+ }
38
+ ```
39
+
40
+ The paths name repositories containing their own `package-build.config.yaml`, `.yml`, or `.mjs`. They are editor search configuration, independent of a package's build dependencies. The server validates each foreign project's private index when first used and rebuilds it from saved source if missing or incompatible. A valid complete cache is reusable. An unconfigured cache never adds a project to search. A failed foreign root produces an LSP status message while available projects remain searchable. Save and file-operation notifications refresh the affected project. Clients can update the root list with `workspace/didChangeConfiguration` using `settings.heroiclands.foreignRoots`.
41
+
42
+ Plain `workspace/symbol` queries search the current project. Prefix the query with `all:` to include configured foreign projects; `all:tag:myth` searches tags in that scope. Results name the owning package and open its source note. Package-qualified definitions open a note or asset from its owning root. A bare Address with matches in multiple configured projects returns all destinations for the client to present. `textDocument/references` searches the current and configured foreign projects and reports exact saved source ranges.
27
43
 
28
44
  ## Editor integration
29
45
 
30
- An LSP client starts the executable with the content project as its working directory and associates it with Markdown notes under the configured content directory. The process handles `initialize`, `shutdown`, `exit`, full and incremental document synchronization, save and file-operation notifications, definition, references, and workspace symbols. It does not advertise completion, diagnostics, rename, or document symbols. An editor integration supplies its own installation, project discovery, and UI commands.
46
+ An LSP client starts the executable with the content project as its working directory and associates it with Markdown notes under the configured content directory. The process handles `initialize`, `shutdown`, `exit`, full and incremental document synchronization, save and file-operation notifications, configuration changes, definition, references, and workspace symbols. It does not advertise completion, diagnostics, rename, or document symbols. An editor integration supplies its own installation, project discovery, and UI commands.
@@ -4,12 +4,17 @@
4
4
 
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
+ import { createRequire } from "node:module";
7
8
  import { fileURLToPath, pathToFileURL } from "node:url";
8
9
  import YAML from "yaml";
9
10
  import { parseAddress, renderAddress } from "@heroiclands/package-build/engine/address";
10
11
  import { addressPositions } from "@heroiclands/package-build/engine/note-addresses";
11
12
  import { parseWikilink, WIKILINK } from "@heroiclands/package-build/engine/wikilink-syntax";
12
- import { loadPackConfig } from "@heroiclands/package-build/engine/pack-config";
13
+ import {
14
+ CONFIG_FILENAMES,
15
+ configFromData,
16
+ loadPackConfig,
17
+ } from "@heroiclands/package-build/engine/pack-config";
13
18
  import { noteFile } from "@heroiclands/package-build/engine/index-records";
14
19
  import { NOTE_VOCABULARY } from "@heroiclands/package-build/engine/note-vocabulary";
15
20
  import {
@@ -21,6 +26,22 @@ import { matchAllOutsideCode } from "@heroiclands/package-build/engine/code-fenc
21
26
  import { embedsIn, EMBED_DEFAULT_TYPE } from "@heroiclands/package-build/engine/content-embeds";
22
27
 
23
28
  const EMPTY_RANGE = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
29
+ const require = createRequire(import.meta.url);
30
+
31
+ /** Load an explicitly selected project's configuration with this server's toolchain. */
32
+ function loadForeignConfig(root) {
33
+ const files = CONFIG_FILENAMES.map((name) => path.join(root, name)).filter((file) =>
34
+ fs.existsSync(file),
35
+ );
36
+ if (files.length !== 1)
37
+ throw new Error(root + " must contain exactly one package-build configuration");
38
+ const file = files[0];
39
+ if (file.endsWith(".mjs")) {
40
+ const module = require(file);
41
+ return module.default ?? module;
42
+ }
43
+ return configFromData(YAML.parse(fs.readFileSync(file, "utf8")), file);
44
+ }
24
45
 
25
46
  /** Return an LSP position for a UTF-16 offset in TEXT. */
26
47
  function positionAt(text, offset) {
@@ -52,8 +73,13 @@ function noteName(record) {
52
73
 
53
74
  /** An index read from the package's configured content tree. */
54
75
  export class ContentWorkspace {
55
- constructor(config = loadPackConfig(), { cacheBase, onStatus = () => {} } = {}) {
76
+ constructor(
77
+ config = loadPackConfig(),
78
+ { cacheBase, onStatus = () => {}, loadProjectConfig = loadForeignConfig } = {},
79
+ ) {
56
80
  this.config = config;
81
+ this.cacheBase = cacheBase;
82
+ this.loadProjectConfig = loadProjectConfig;
57
83
  this.contentRoot = config.paths.content;
58
84
  this.cacheDirectory = languageIndexDirectory(config, cacheBase);
59
85
  this.indexFile = path.join(this.cacheDirectory, "metadata.jsonl");
@@ -66,6 +92,123 @@ export class ContentWorkspace {
66
92
  this.byFile = new Map();
67
93
  this.types = new Set(Object.keys(NOTE_VOCABULARY));
68
94
  this.documents = new Map();
95
+ this.foreignRoots = [];
96
+ this.foreign = new Map();
97
+ }
98
+
99
+ /** Select foreign roots explicitly; a cache directory cannot add a project. */
100
+ configureForeignRoots(roots = []) {
101
+ if (!Array.isArray(roots) || roots.some((root) => typeof root !== "string"))
102
+ throw new Error("initializationOptions.foreignRoots must be an array of paths");
103
+ const ownRoot = fs.realpathSync(this.config.rootDir);
104
+ this.foreignRoots = [
105
+ ...new Set(
106
+ roots.map((root) => {
107
+ const absolute = path.resolve(root);
108
+ return fs.existsSync(absolute) ? fs.realpathSync(absolute) : absolute;
109
+ }),
110
+ ),
111
+ ].filter((root) => root !== ownRoot);
112
+ for (const [root, project] of this.foreign)
113
+ if (!this.foreignRoots.includes(root)) {
114
+ project.close();
115
+ this.foreign.delete(root);
116
+ }
117
+ }
118
+
119
+ foreignWorkspace(root) {
120
+ if (this.foreign.has(root)) return this.foreign.get(root);
121
+ const config = this.loadProjectConfig(root);
122
+ const project = new ContentWorkspace(config, {
123
+ cacheBase: this.cacheBase,
124
+ onStatus: (message) => {
125
+ if (message) this.onStatus(config.contentPackage + ": " + message);
126
+ },
127
+ loadProjectConfig: this.loadProjectConfig,
128
+ });
129
+ project.documents = this.documents;
130
+ this.foreign.set(root, project);
131
+ return project;
132
+ }
133
+
134
+ /** Return usable indexes, reporting one failed root without hiding the others. */
135
+ indexedWorkspaces(includeForeign = false) {
136
+ this.requireIndex();
137
+ const projects = [this];
138
+ if (includeForeign)
139
+ for (const root of this.foreignRoots) {
140
+ try {
141
+ const project = this.foreignWorkspace(root);
142
+ project.start(true);
143
+ project.requireIndex();
144
+ projects.push(project);
145
+ } catch (error) {
146
+ this.onStatus("Foreign content project " + root + ": " + error.message);
147
+ }
148
+ }
149
+ return projects;
150
+ }
151
+
152
+ /** Find every indexed owner of a written Address for navigation. */
153
+ resolveCandidates(value, defaults = {}, projects = [this]) {
154
+ const packages = new Set(projects.map((project) => project.config.contentPackage));
155
+ const found = [];
156
+ for (const project of projects) {
157
+ const tuple = parseAddress(value, {
158
+ package: project.config.contentPackage,
159
+ system: "none",
160
+ types: project.types,
161
+ packages,
162
+ ...defaults,
163
+ });
164
+ if (tuple.reason) continue;
165
+ const record = project.byAddress.get(renderAddress(tuple));
166
+ if (record) found.push({ record, project });
167
+ }
168
+ return found;
169
+ }
170
+
171
+ /** Locate the workspace that owns an open source document. */
172
+ sourceWorkspace(uri, projects) {
173
+ const file = fileURLToPath(uri);
174
+ return (
175
+ projects.find((project) => {
176
+ const relative = path.relative(project.contentRoot, file);
177
+ return (
178
+ relative &&
179
+ !path.isAbsolute(relative) &&
180
+ relative !== ".." &&
181
+ !relative.startsWith(".." + path.sep)
182
+ );
183
+ }) ?? this
184
+ );
185
+ }
186
+
187
+ scheduleRebuildForUri(uri) {
188
+ const file = fileURLToPath(uri);
189
+ const inside = (directory) => {
190
+ const relative = path.relative(directory, file);
191
+ return (
192
+ relative &&
193
+ !path.isAbsolute(relative) &&
194
+ relative !== ".." &&
195
+ !relative.startsWith(".." + path.sep)
196
+ );
197
+ };
198
+ if (inside(this.contentRoot) || inside(this.config.paths.assets)) {
199
+ this.scheduleRebuild();
200
+ return;
201
+ }
202
+ for (const root of this.foreignRoots) {
203
+ if (!inside(root)) continue;
204
+ try {
205
+ const project = this.foreignWorkspace(root);
206
+ if (inside(project.contentRoot) || inside(project.config.paths.assets))
207
+ project.scheduleRebuild();
208
+ } catch (error) {
209
+ this.onStatus("Foreign content project " + root + ": " + error.message);
210
+ }
211
+ }
69
212
  }
70
213
 
71
214
  refresh() {
@@ -126,9 +269,15 @@ export class ContentWorkspace {
126
269
  }
127
270
  }
128
271
 
129
- start() {
272
+ start(acceptCompleteCache = false) {
130
273
  if (this.started) return;
131
274
  this.started = true;
275
+ if (acceptCompleteCache)
276
+ try {
277
+ if (this.refresh()) return;
278
+ } catch {
279
+ // A corrupt or incompatible snapshot is rebuilt from saved notes.
280
+ }
132
281
  this.rebuild();
133
282
  }
134
283
 
@@ -143,6 +292,7 @@ export class ContentWorkspace {
143
292
  close() {
144
293
  if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
145
294
  this.rebuildTimer = null;
295
+ for (const project of this.foreign.values()) project.close();
146
296
  }
147
297
 
148
298
  requireIndex() {
@@ -175,31 +325,39 @@ export class ContentWorkspace {
175
325
  }
176
326
 
177
327
  /** Resolve a written Address using the same tuple grammar as the build. */
178
- resolve(value, defaults = {}) {
328
+ resolve(value, defaults = {}, projects = [this]) {
179
329
  const tuple = parseAddress(value, {
180
330
  package: this.config.contentPackage,
181
331
  system: "none",
182
- types: this.types,
183
- packages: new Set([this.config.contentPackage]),
332
+ types: new Set(projects.flatMap((project) => [...project.types])),
333
+ packages: new Set(projects.map((project) => project.config.contentPackage)),
184
334
  ...defaults,
185
335
  });
186
336
  if (tuple.reason) return null;
187
- return this.byAddress.get(renderAddress(tuple)) ?? null;
337
+ for (const project of projects)
338
+ if (project.config.contentPackage === tuple.package) {
339
+ const record = project.byAddress.get(renderAddress(tuple));
340
+ if (record) return record;
341
+ }
342
+ return null;
188
343
  }
189
344
 
190
345
  /** Link and declared frontmatter targets, with exact source ranges. */
191
- referencesInText(text, file) {
346
+ referencesInText(text, file, projects = [this], includeFrontmatter = true) {
192
347
  const found = [];
193
348
  const bodyStart = text.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/)?.[0].length ?? 0;
194
349
  const linkText = text.slice(bodyStart);
195
350
  for (const match of matchAllOutsideCode(linkText, new RegExp(WIKILINK.source, "g"))) {
196
351
  const parsed = parseWikilink(match[1]);
197
- const record = parsed.target ? this.resolve(parsed.target) : this.byFile.get(file);
352
+ const record =
353
+ parsed.target ? this.resolve(parsed.target, {}, projects) : this.byFile.get(file);
198
354
  if (!record) continue;
199
355
  const start = bodyStart + match.index + (parsed.target ? 2 : 3);
200
356
  found.push({
201
357
  record,
202
358
  anchor: parsed.anchor,
359
+ written: parsed.target,
360
+ defaults: {},
203
361
  location: location(
204
362
  file,
205
363
  text,
@@ -209,16 +367,19 @@ export class ContentWorkspace {
209
367
  });
210
368
  }
211
369
  for (const embed of embedsIn(linkText)) {
212
- const record = this.resolve(embed.written, { type: EMBED_DEFAULT_TYPE });
370
+ const defaults = { type: EMBED_DEFAULT_TYPE };
371
+ const record = this.resolve(embed.written, defaults, projects);
213
372
  if (!record) continue;
214
373
  const start = bodyStart + embed.index + 3;
215
374
  found.push({
216
375
  record,
217
376
  anchor: "",
377
+ written: embed.written,
378
+ defaults,
218
379
  location: location(file, text, start, start + embed.written.length),
219
380
  });
220
381
  }
221
- if (!bodyStart) return found;
382
+ if (!bodyStart || !includeFrontmatter) return found;
222
383
  const yamlStart = text.indexOf("\n") + 1;
223
384
  const yamlText = text.slice(yamlStart, bodyStart).replace(/\r?\n---(?:\r?\n)?$/, "");
224
385
  let document;
@@ -249,15 +410,18 @@ export class ContentWorkspace {
249
410
  }
250
411
  const scalar = (part, value) => {
251
412
  if (!YAML.isScalar(part) || typeof value !== "string" || !part.range) return;
252
- const record = this.resolve(value, {
413
+ const defaults = {
253
414
  system: position.system ?? "none",
254
415
  type: position.type,
255
- });
416
+ };
417
+ const record = this.resolve(value, defaults, projects);
256
418
  if (!record) return;
257
419
  const start = yamlStart + part.range[0];
258
420
  found.push({
259
421
  record,
260
422
  anchor: "",
423
+ written: value,
424
+ defaults,
261
425
  location: location(file, text, start, yamlStart + part.range[1]),
262
426
  });
263
427
  };
@@ -276,49 +440,75 @@ export class ContentWorkspace {
276
440
  return found;
277
441
  }
278
442
 
279
- targetAt(uri, position) {
443
+ targetAt(uri, position, projects = [this]) {
280
444
  const text = this.text(uri);
281
445
  if (text == null) return null;
282
446
  const offset = offsetAt(text, position);
283
447
  if (offset < 0) return null;
284
448
  const file = fileURLToPath(uri);
285
- const reference = this.referencesInText(text, file).find(({ location: source }) => {
286
- const start = offsetAt(text, source.range.start);
287
- const end = offsetAt(text, source.range.end);
288
- return start <= offset && offset <= end;
289
- });
290
- if (reference) return { record: reference.record, anchor: reference.anchor };
449
+ const reference = this.referencesInText(text, file, projects).find(
450
+ ({ location: source }) => {
451
+ const start = offsetAt(text, source.range.start);
452
+ const end = offsetAt(text, source.range.end);
453
+ return start <= offset && offset <= end;
454
+ },
455
+ );
456
+ if (reference) return reference;
291
457
  const own = this.byFile.get(file);
292
458
  if (own && /^shortcode:\s*/.test(text.split("\n")[position.line] ?? ""))
293
- return { record: own, anchor: "" };
294
- const start = text.slice(0, offset).search(/[A-Za-z0-9./]+$/);
459
+ return { record: own, anchor: "", written: own.address?.canonical, defaults: {} };
460
+ const start = text.slice(0, offset).search(/[A-Za-z0-9./-]+$/);
295
461
  if (start < 0) return null;
296
- const end = offset + (text.slice(offset).match(/^[A-Za-z0-9./]+/)?.[0].length ?? 0);
297
- const record = this.resolve(text.slice(start, end));
298
- return record ? { record, anchor: "" } : null;
462
+ const end = offset + (text.slice(offset).match(/^[A-Za-z0-9./-]+/)?.[0].length ?? 0);
463
+ const written = text.slice(start, end);
464
+ const record = this.resolve(written, {}, projects);
465
+ return { record, anchor: "", written, defaults: {} };
299
466
  }
300
467
 
301
468
  definition(uri, position) {
302
- this.requireIndex();
303
- const target = this.targetAt(uri, position);
469
+ const projects = this.indexedWorkspaces(true);
470
+ const source = this.sourceWorkspace(uri, projects);
471
+ const target = source.targetAt(uri, position, projects);
304
472
  if (!target) return null;
305
- const { record, anchor } = target;
306
- const file = this.fileFor(record);
307
- if (!file) return null;
308
- if (record.asset) return { uri: pathToFileURL(file).href, range: EMPTY_RANGE };
309
- const line =
310
- anchor ?
311
- record.anchors?.find((entry) => entry.slug.toLowerCase() === anchor.toLowerCase())
312
- ?.line
313
- : null;
314
- if (anchor && !line) return null;
315
- const targetText = fs.readFileSync(file, "utf8");
316
- const offset = line ? offsetAt(targetText, { line: line - 1, character: 0 }) : 0;
317
- return location(file, targetText, offset, offset);
473
+ const candidates =
474
+ target.written ?
475
+ source.resolveCandidates(target.written, target.defaults, projects)
476
+ : [{ record: target.record, project: source }];
477
+ const locations = [];
478
+ for (const { record, project } of candidates) {
479
+ const file = project.fileFor(record);
480
+ if (!file) continue;
481
+ if (record.asset) {
482
+ locations.push({ uri: pathToFileURL(file).href, range: EMPTY_RANGE });
483
+ continue;
484
+ }
485
+ const line =
486
+ target.anchor ?
487
+ record.anchors?.find(
488
+ (entry) => entry.slug.toLowerCase() === target.anchor.toLowerCase(),
489
+ )?.line
490
+ : null;
491
+ if (target.anchor && !line) continue;
492
+ const targetText = fs.readFileSync(file, "utf8");
493
+ const offset = line ? offsetAt(targetText, { line: line - 1, character: 0 }) : 0;
494
+ locations.push(location(file, targetText, offset, offset));
495
+ }
496
+ return (
497
+ locations.length === 1 ? locations[0]
498
+ : locations.length ? locations
499
+ : null
500
+ );
318
501
  }
319
502
 
320
503
  symbols(query) {
321
- this.requireIndex();
504
+ const includeForeign = /^all:/i.test(query);
505
+ const search = includeForeign ? query.slice(4) : query;
506
+ return this.indexedWorkspaces(includeForeign).flatMap((project) =>
507
+ project.symbolMatches(search),
508
+ );
509
+ }
510
+
511
+ symbolMatches(query) {
322
512
  const tag = /^tag:(.*)$/i.exec(query);
323
513
  const needle = (tag ? tag[1] : query).trim().toLowerCase();
324
514
  const found = new Map();
@@ -345,7 +535,7 @@ export class ContentWorkspace {
345
535
  found.set(file, {
346
536
  name: String(noteName(record)),
347
537
  kind: 1,
348
- containerName: `${record.address?.slug ?? [record.type, record.shortcode].filter(Boolean).join(" ")} · ${tag ? `tag: ${match}` : match}`,
538
+ containerName: `${record.package} · ${record.address?.slug ?? [record.type, record.shortcode].filter(Boolean).join(" ")} · ${tag ? `tag: ${match}` : match}`,
349
539
  location: { uri: pathToFileURL(file).href, range: EMPTY_RANGE },
350
540
  });
351
541
  }
@@ -353,29 +543,57 @@ export class ContentWorkspace {
353
543
  }
354
544
 
355
545
  references(uri, position) {
356
- this.requireIndex();
357
- const target = this.targetAt(uri, position)?.record;
358
- if (!target?.address?.canonical) return [];
546
+ const projects = this.indexedWorkspaces(true);
547
+ const source = this.sourceWorkspace(uri, projects);
548
+ const target = source.targetAt(uri, position, projects);
549
+ if (!target) return [];
550
+ const candidates =
551
+ target.record ?
552
+ [target.record]
553
+ : source
554
+ .resolveCandidates(target.written, target.defaults, projects)
555
+ .map(({ record }) => record);
556
+ const addresses = new Set(
557
+ candidates.map((record) => record.address?.canonical).filter(Boolean),
558
+ );
559
+ if (!addresses.size) return [];
359
560
  const locations = [];
360
- const needle = target.shortcode.toLowerCase();
361
- const visit = (directory) => {
561
+ const needles = candidates.map((record) => record.shortcode.toLowerCase());
562
+ const visit = (project, directory, frontmatterCandidates) => {
362
563
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
363
564
  if (entry.name.startsWith(".")) continue;
364
565
  const file = path.join(directory, entry.name);
365
566
  if (entry.isDirectory()) {
366
- visit(file);
567
+ visit(project, file, frontmatterCandidates);
367
568
  } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
368
569
  const savedText = fs.readFileSync(file, "utf8");
369
- if (!savedText.toLowerCase().includes(needle)) continue;
370
- const text = this.documents.get(pathToFileURL(file).href) ?? savedText;
371
- for (const reference of this.referencesInText(text, file)) {
372
- if (reference.record.address?.canonical === target.address.canonical)
570
+ if (!needles.some((needle) => savedText.toLowerCase().includes(needle)))
571
+ continue;
572
+ for (const reference of project.referencesInText(
573
+ savedText,
574
+ file,
575
+ projects,
576
+ frontmatterCandidates.has(file),
577
+ )) {
578
+ if (addresses.has(reference.record.address?.canonical))
373
579
  locations.push(reference.location);
374
580
  }
375
581
  }
376
582
  }
377
583
  };
378
- visit(this.contentRoot);
584
+ for (const project of projects) {
585
+ const frontmatterCandidates = new Set(
586
+ project.records
587
+ .filter((record) =>
588
+ needles.some((needle) =>
589
+ JSON.stringify(record).toLowerCase().includes(needle),
590
+ ),
591
+ )
592
+ .filter((record) => record.file?.path)
593
+ .map((record) => noteFile(project.contentRoot, record)),
594
+ );
595
+ visit(project, project.contentRoot, frontmatterCandidates);
596
+ }
379
597
  return locations;
380
598
  }
381
599
  }
@@ -385,6 +603,7 @@ export function respond(workspace, message) {
385
603
  const { method, params = {} } = message;
386
604
  switch (method) {
387
605
  case "initialize":
606
+ workspace.configureForeignRoots(params.initializationOptions?.foreignRoots ?? []);
388
607
  workspace.start();
389
608
  return {
390
609
  capabilities: {
@@ -405,6 +624,10 @@ export function respond(workspace, message) {
405
624
  };
406
625
  case "shutdown":
407
626
  return null;
627
+ case "workspace/didChangeConfiguration":
628
+ if (params.settings?.heroiclands?.foreignRoots)
629
+ workspace.configureForeignRoots(params.settings.heroiclands.foreignRoots);
630
+ return undefined;
408
631
  case "textDocument/didOpen":
409
632
  workspace.documents.set(params.textDocument.uri, params.textDocument.text);
410
633
  return undefined;
@@ -426,12 +649,19 @@ export function respond(workspace, message) {
426
649
  workspace.documents.delete(params.textDocument.uri);
427
650
  return undefined;
428
651
  case "textDocument/didSave":
652
+ workspace.scheduleRebuildForUri(params.textDocument.uri);
653
+ return undefined;
429
654
  case "workspace/didChangeWatchedFiles":
430
655
  case "workspace/didCreateFiles":
431
656
  case "workspace/didRenameFiles":
432
- case "workspace/didDeleteFiles":
433
- workspace.scheduleRebuild();
657
+ case "workspace/didDeleteFiles": {
658
+ const files = params.changes ?? params.files ?? [];
659
+ if (!files.length) workspace.scheduleRebuild();
660
+ for (const item of files)
661
+ for (const uri of [item.uri, item.oldUri, item.newUri].filter(Boolean))
662
+ workspace.scheduleRebuildForUri(uri);
434
663
  return undefined;
664
+ }
435
665
  case "textDocument/definition":
436
666
  return workspace.definition(params.textDocument.uri, params.position);
437
667
  case "textDocument/references":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/content-language-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Language server for HeroicLands content projects",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",