@heroiclands/content-language-server 0.1.0 → 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
@@ -1,6 +1,6 @@
1
1
  # HeroicLands content language server
2
2
 
3
- `@heroiclands/content-language-server` provides definition, reference, and workspace search for Markdown notes in HeroicLands content projects. It reads authored notes and project configuration through one exact `@heroiclands/package-build` dependency.
3
+ `@heroiclands/content-language-server` provides definition, reference, completion, diagnostics, and workspace search for Markdown notes in HeroicLands content projects. It reads authored notes and project configuration through one exact `@heroiclands/package-build` dependency.
4
4
 
5
5
  Start its `heroiclands-content-language-server` executable from the content project root. The server builds a private index when it starts and after saves. The index lives in the platform cache outside the project's `build/` directory, so a clean or package build does not replace editor navigation data.
6
6
 
@@ -8,4 +8,12 @@ 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
+
13
+ Workspace-symbol queries accept `name:`, `shortcode:`, `type:`, `tag:`, and `package:` filters. For example, `all:type:lore` searches every configured project, while `package:thalorna name:camel` searches names in one configured package.
14
+
15
+ Completion searches anywhere in indexed names, aliases, and Addresses. It inserts the shortest Address that identifies the selected target in the current note, including its system or package when needed. Ordinary `[[...]]` links default to readable `note` content; `![[...]]` embeds default to systemless `none` assets. Clients receive standard LSP completion items with exact text edits.
16
+
17
+ Open notes receive diagnostics for complete links, embeds, and declared Address fields. The server reads live text for ranges and checks targets against saved indexes and declared content dependencies. It waits briefly after typing before publishing findings.
18
+
11
19
  Maintainers use the [publishing guide](docs/publishing.md) for the first npm release and trusted publisher configuration.
@@ -1,6 +1,6 @@
1
1
  # Content language server
2
2
 
3
- `heroiclands-content-language-server` provides editor navigation for Markdown notes in a HeroicLands content project. It is a stdio Language Server Protocol process. Start it from the project root so it can read `package-build.config.yaml` and the saved content tree.
3
+ `heroiclands-content-language-server` provides editor navigation and reference diagnostics for Markdown notes in a HeroicLands content project. It is a stdio Language Server Protocol process. Start it from the project root so it can read `package-build.config.yaml` and the saved content tree.
4
4
 
5
5
  The server builds a private JSONL index during initialization, before answering navigation requests. It rebuilds after nearby save notifications settle. Unsaved buffer text identifies an Address under the cursor, but workspace search uses saved metadata. A new, renamed, or deleted note enters the index when the editor sends a save or file-operation notification. A successful rebuild replaces the complete snapshot; a failed rebuild reports an editor message and keeps the last complete snapshot available with a stale-results warning.
6
6
 
@@ -17,14 +17,63 @@ heroiclands-content-language-server --rebuild-index
17
17
 
18
18
  The first prints the private JSONL path. The second is manual recovery when a file operation did not trigger a rebuild; it prints the path on success and exits nonzero on failure. Restarting the server also rebuilds from saved source. A failed rebuild leaves the complete prior snapshot in place. If there is no valid prior snapshot, navigation reports that no index is available.
19
19
 
20
- | LSP request | Behavior |
21
- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
22
- | `textDocument/definition` | Follows an Address or wikilink to the indexed note. An anchor lands on its indexed line. |
23
- | `workspace/symbol` | Finds notes by name, alias, ASCII name, shortcode, or Address. `tag:myth` searches tags. One result appears per source note. |
24
- | `textDocument/references` | Finds authored wikilinks, embeds, and declared frontmatter Address values or keys. Ordinary prose is excluded. |
20
+ | LSP request | Behavior |
21
+ | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
22
+ | `textDocument/definition` | Follows an Address or wikilink to the indexed note. An anchor lands on its indexed line. |
23
+ | `textDocument/completion` | Completes unfinished wikilinks, anchors, and declared frontmatter Address values or keys. |
24
+ | `workspace/symbol` | Finds notes by name, alias, ASCII name, shortcode, or Address. Field and project prefixes narrow the saved index. One result appears per source note. |
25
+ | `textDocument/references` | Finds authored wikilinks, embeds, and declared frontmatter Address values or keys. Ordinary prose is excluded. |
26
+ | `textDocument/publishDiagnostics` | Reports invalid complete links, embeds, anchors, and declared frontmatter Address targets in open notes. |
25
27
 
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.
28
+ 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.
29
+
30
+ ### Reference diagnostics
31
+
32
+ The server checks a complete `[[Address|Text]]` link, `![[Address|Text]]` embed, or declared frontmatter Address against the saved private index. It checks section anchors against indexed headings and accepts only icon, image, or audio assets in embeds. A missing label, unresolved target, wrong target type, or missing anchor produces a finding at the written Address. Fenced code and unfinished links produce no reference finding. Ordinary prose and frontmatter fields that are not declared as Addresses are outside this check.
33
+
34
+ Open buffer text supplies the exact source positions, including UTF-16 offsets for non-ASCII text. The index supplies target metadata; unsaved edits in another note do not change resolution. The server waits 300 milliseconds after the latest edit before publishing diagnostics, then checks again after a save or index rebuild. Closing a document clears its findings.
35
+
36
+ An explicitly configured foreign project can be searched even when it is not a declared build dependency. A reference to that project's content is diagnosed until the citing package declares the dependency in `package-build.config.yaml`. When a declared foreign package's index is unavailable, the diagnostic says the index is unavailable; it does not claim the target is missing. A dependency with `contentIndex: false` cannot provide content targets.
37
+
38
+ ### Address completion
39
+
40
+ Completion inside an unfinished `[[...` link searches the selected projects' saved indexes. The query matches anywhere in a note's full name, aliases, shortcode, short Address, or canonical Address. The existing `nameAscii` and `aliasesAscii` fields supply typeable forms of names with non-ASCII characters. The server builds lowercase search keys in memory when it loads an index and transliterates the query with the same rule. The JSONL format carries no separate search field.
41
+
42
+ Each result identifies its owning package and canonical Address. Readable `note` content, systemless `none` targets, and game system documents stay selectable even when they share a Markdown file. An ordinary `[[link|text]]` defaults to `note`, while an embedded `![[link|text]]` defaults to `none` and uses `image` as the type of a bare shortcode. Stating a system selects that exact target: `[[macro-autoattack|Text]]` reaches the readable note, and `[[none-macro-autoattack|Text]]` reaches the Macro. An embed offers asset targets and can reach an icon with `![[icon-anvil|Anvil]]`.
43
+
44
+ The text edit inserts the shortest Address that parses to the selected target at the cursor. A local game system document can require `sohl-being-bctrncml`, while a target in another package requires a fully qualified Address. A `[[...#` query completes anchors from the selected note. Completion in a declared frontmatter Address field uses that field's type and system defaults: image, icon, and folder fields use `none`; other fields use their game system block or `note` outside one. Explicit system segments keep their stated meaning. Input `doc<type>` aliases select readable note records; generated records and completion candidates use the authored type.
45
+
46
+ The server reads open buffer text to locate the cursor and replacement range. Candidate metadata comes from the saved JSONL index; unsaved frontmatter changes do not add candidates. Completion leaves existing display text and closed wikilinks alone. A missing foreign index is reported through the usual LSP status message while other indexed projects remain available.
47
+
48
+ ## Foreign content projects
49
+
50
+ An LSP client selects foreign project roots through `initialize`:
51
+
52
+ ```json
53
+ {
54
+ "initializationOptions": {
55
+ "foreignRoots": ["/absolute/path/to/another/content/project"]
56
+ }
57
+ }
58
+ ```
59
+
60
+ 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`.
61
+
62
+ Plain `workspace/symbol` queries search names, aliases, shortcodes, and Addresses in the current project. The query prefixes below narrow matches using saved JSONL records. Searches do not read unsaved frontmatter or scan Markdown files.
63
+
64
+ | Query | Matches |
65
+ | ----------------------------- | --------------------------------------------------------------- |
66
+ | `name:alyra` | Full names and aliases, including their ASCII forms |
67
+ | `shortcode:alpha` | Shortcodes |
68
+ | `type:lore` | Note types |
69
+ | `tag:myth` | Tags |
70
+ | `all:camel` | Ordinary matches in the current and configured foreign projects |
71
+ | `all:type:lore` | A field filter across those projects |
72
+ | `package:thalorna` | All indexed notes in that configured package |
73
+ | `package:thalorna name:camel` | A field filter within that package |
74
+
75
+ `package:` selects the current project or an explicitly configured foreign project by its exact package name. It does not discover other caches or add a build dependency. A missing package or an unknown prefix returns no results. An empty unqualified query lists the current project's notes; an incomplete `name:`, `shortcode:`, `type:`, or `package:` query returns no results. 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 uses the citing project's package default. `textDocument/references` searches the current and configured foreign projects and reports exact saved source ranges.
27
76
 
28
77
  ## Editor integration
29
78
 
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.
79
+ 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, completion, references, diagnostics, and workspace symbols. It does not advertise rename or document symbols. An editor integration supplies its own installation, project discovery, and UI commands.
@@ -4,23 +4,50 @@
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
- import { parseWikilink, WIKILINK } from "@heroiclands/package-build/engine/wikilink-syntax";
12
- import { loadPackConfig } from "@heroiclands/package-build/engine/pack-config";
12
+ import {
13
+ linkFindingMessage,
14
+ parseWikilink,
15
+ WIKILINK,
16
+ } from "@heroiclands/package-build/engine/wikilink-syntax";
17
+ import {
18
+ CONFIG_FILENAMES,
19
+ configFromData,
20
+ loadPackConfig,
21
+ } from "@heroiclands/package-build/engine/pack-config";
13
22
  import { noteFile } from "@heroiclands/package-build/engine/index-records";
23
+ import { asciiName } from "@heroiclands/package-build/engine/content-index";
14
24
  import { NOTE_VOCABULARY } from "@heroiclands/package-build/engine/note-vocabulary";
15
25
  import {
16
26
  languageIndexDirectory,
17
27
  readLanguageIndex,
18
28
  rebuildLanguageIndex,
19
29
  } from "./content-language-index.mjs";
20
- import { matchAllOutsideCode } from "@heroiclands/package-build/engine/code-fences";
30
+ import { codeRegions, matchAllOutsideCode } from "@heroiclands/package-build/engine/code-fences";
21
31
  import { embedsIn, EMBED_DEFAULT_TYPE } from "@heroiclands/package-build/engine/content-embeds";
32
+ import { ASSET_TYPE_NAMES } from "@heroiclands/package-build/engine/asset-types";
22
33
 
23
34
  const EMPTY_RANGE = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
35
+ const require = createRequire(import.meta.url);
36
+
37
+ /** Load an explicitly selected project's configuration with this server's toolchain. */
38
+ function loadForeignConfig(root) {
39
+ const files = CONFIG_FILENAMES.map((name) => path.join(root, name)).filter((file) =>
40
+ fs.existsSync(file),
41
+ );
42
+ if (files.length !== 1)
43
+ throw new Error(root + " must contain exactly one package-build configuration");
44
+ const file = files[0];
45
+ if (file.endsWith(".mjs")) {
46
+ const module = require(file);
47
+ return module.default ?? module;
48
+ }
49
+ return configFromData(YAML.parse(fs.readFileSync(file, "utf8")), file);
50
+ }
24
51
 
25
52
  /** Return an LSP position for a UTF-16 offset in TEXT. */
26
53
  function positionAt(text, offset) {
@@ -50,10 +77,81 @@ function noteName(record) {
50
77
  return typeof record.name === "string" ? record.name : (record.name?.full ?? record.shortcode);
51
78
  }
52
79
 
80
+ /** Use the index generator's transliteration for both saved fields and typed queries. */
81
+ function searchKey(value) {
82
+ return typeof value === "string" ? (asciiName(value) ?? "").toLowerCase() : "";
83
+ }
84
+
85
+ /** Parse editor symbol filters without looking outside configured projects. */
86
+ function symbolQuery(query) {
87
+ let text = String(query ?? "").trim();
88
+ let includeForeign = false;
89
+ if (/^all:/i.test(text)) {
90
+ includeForeign = true;
91
+ text = text.slice(4).trim();
92
+ }
93
+ let selectedPackage = null;
94
+ if (/^package:/i.test(text)) {
95
+ const selected = /^package:([^\s]+)(?:\s+(.*))?$/i.exec(text);
96
+ if (!selected) return null;
97
+ selectedPackage = selected[1].toLowerCase();
98
+ text = (selected[2] ?? "").trim();
99
+ includeForeign = true;
100
+ }
101
+ const prefix = /^([a-z]+):(.*)$/is.exec(text);
102
+ if (prefix && !["name", "shortcode", "type", "tag"].includes(prefix[1].toLowerCase()))
103
+ return null;
104
+ const field = prefix?.[1].toLowerCase() ?? "all";
105
+ const needle = (prefix ? prefix[2] : text).trim();
106
+ if (field !== "all" && field !== "tag" && !needle) return null;
107
+ return { includeForeign, selectedPackage, field, needle };
108
+ }
109
+
110
+ /** A completion item changes the Address text while its label remains readable. */
111
+ function completionItem(label, detail, inserted, text, from, to, filterText) {
112
+ return {
113
+ label,
114
+ kind: 18,
115
+ detail,
116
+ filterText,
117
+ textEdit: {
118
+ range: { start: positionAt(text, from), end: positionAt(text, to) },
119
+ newText: inserted,
120
+ },
121
+ };
122
+ }
123
+
124
+ /** Ignore ordinary prose completion before loading any foreign project. */
125
+ function mayCompleteAddress(text, offset) {
126
+ if (text == null || offset < 0) return false;
127
+ const bodyStart = text.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/)?.[0].length ?? 0;
128
+ if (offset < bodyStart) return true;
129
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
130
+ const lineEnd = text.indexOf("\n", offset);
131
+ const before = text.slice(lineStart, offset);
132
+ const open = before.lastIndexOf("[[");
133
+ return (
134
+ open >= 0 &&
135
+ !before.slice(open + 2).includes("]]") &&
136
+ !text.slice(offset, lineEnd < 0 ? text.length : lineEnd).includes("]]")
137
+ );
138
+ }
139
+
53
140
  /** An index read from the package's configured content tree. */
54
141
  export class ContentWorkspace {
55
- constructor(config = loadPackConfig(), { cacheBase, onStatus = () => {} } = {}) {
142
+ constructor(
143
+ config = loadPackConfig(),
144
+ {
145
+ cacheBase,
146
+ onStatus = () => {},
147
+ onDiagnostics = () => {},
148
+ onIndexChanged,
149
+ loadProjectConfig = loadForeignConfig,
150
+ } = {},
151
+ ) {
56
152
  this.config = config;
153
+ this.cacheBase = cacheBase;
154
+ this.loadProjectConfig = loadProjectConfig;
57
155
  this.contentRoot = config.paths.content;
58
156
  this.cacheDirectory = languageIndexDirectory(config, cacheBase);
59
157
  this.indexFile = path.join(this.cacheDirectory, "metadata.jsonl");
@@ -61,11 +159,142 @@ export class ContentWorkspace {
61
159
  this.started = false;
62
160
  this.rebuildTimer = null;
63
161
  this.onStatus = onStatus;
162
+ this.onDiagnostics = onDiagnostics;
163
+ this.onIndexChanged = onIndexChanged ?? (() => this.publishOpenDiagnostics());
164
+ this.diagnosticTimers = new Map();
64
165
  this.records = [];
65
166
  this.byAddress = new Map();
66
167
  this.byFile = new Map();
168
+ this.searchKeys = new Map();
67
169
  this.types = new Set(Object.keys(NOTE_VOCABULARY));
68
170
  this.documents = new Map();
171
+ this.foreignRoots = [];
172
+ this.foreign = new Map();
173
+ }
174
+
175
+ /** Select foreign roots explicitly; a cache directory cannot add a project. */
176
+ configureForeignRoots(roots = []) {
177
+ if (!Array.isArray(roots) || roots.some((root) => typeof root !== "string"))
178
+ throw new Error("initializationOptions.foreignRoots must be an array of paths");
179
+ const ownRoot = fs.realpathSync(this.config.rootDir);
180
+ this.foreignRoots = [
181
+ ...new Set(
182
+ roots.map((root) => {
183
+ const absolute = path.resolve(root);
184
+ return fs.existsSync(absolute) ? fs.realpathSync(absolute) : absolute;
185
+ }),
186
+ ),
187
+ ].filter((root) => root !== ownRoot);
188
+ for (const [root, project] of this.foreign)
189
+ if (!this.foreignRoots.includes(root)) {
190
+ project.close();
191
+ this.foreign.delete(root);
192
+ }
193
+ }
194
+
195
+ foreignWorkspace(root) {
196
+ if (this.foreign.has(root)) return this.foreign.get(root);
197
+ const config = this.loadProjectConfig(root);
198
+ const project = new ContentWorkspace(config, {
199
+ cacheBase: this.cacheBase,
200
+ onStatus: (message) => {
201
+ if (message) this.onStatus(config.contentPackage + ": " + message);
202
+ },
203
+ loadProjectConfig: this.loadProjectConfig,
204
+ onIndexChanged: () => this.publishOpenDiagnostics(),
205
+ });
206
+ project.documents = this.documents;
207
+ this.foreign.set(root, project);
208
+ return project;
209
+ }
210
+
211
+ /** Return usable indexes, reporting one failed root without hiding the others. */
212
+ indexedWorkspaces(includeForeign = false, selectedPackage = null) {
213
+ this.requireIndex();
214
+ const projects =
215
+ selectedPackage && this.config.contentPackage.toLowerCase() !== selectedPackage ?
216
+ []
217
+ : [this];
218
+ if (includeForeign)
219
+ for (const root of this.foreignRoots) {
220
+ try {
221
+ const project = this.foreignWorkspace(root);
222
+ if (
223
+ selectedPackage &&
224
+ project.config.contentPackage.toLowerCase() !== selectedPackage
225
+ )
226
+ continue;
227
+ project.start(true);
228
+ project.requireIndex();
229
+ projects.push(project);
230
+ } catch (error) {
231
+ this.onStatus("Foreign content project " + root + ": " + error.message);
232
+ }
233
+ }
234
+ return projects;
235
+ }
236
+
237
+ /** Find every indexed owner of a written Address for navigation. */
238
+ resolveCandidates(value, defaults = {}, projects = [this]) {
239
+ const packages = new Set(projects.map((project) => project.config.contentPackage));
240
+ const tuple = parseAddress(value, {
241
+ package: this.config.contentPackage,
242
+ system: "note",
243
+ types: new Set(projects.flatMap((project) => [...project.types])),
244
+ packages,
245
+ ...defaults,
246
+ });
247
+ if (tuple.reason) return [];
248
+ const found = [];
249
+ for (const project of projects) {
250
+ if (project.config.contentPackage !== tuple.package) continue;
251
+ const record = project.byAddress.get(renderAddress(tuple));
252
+ if (record) found.push({ record, project });
253
+ }
254
+ return found;
255
+ }
256
+
257
+ /** Locate the workspace that owns an open source document. */
258
+ sourceWorkspace(uri, projects) {
259
+ const file = fileURLToPath(uri);
260
+ return (
261
+ projects.find((project) => {
262
+ const relative = path.relative(project.contentRoot, file);
263
+ return (
264
+ relative &&
265
+ !path.isAbsolute(relative) &&
266
+ relative !== ".." &&
267
+ !relative.startsWith(".." + path.sep)
268
+ );
269
+ }) ?? this
270
+ );
271
+ }
272
+
273
+ scheduleRebuildForUri(uri) {
274
+ const file = fileURLToPath(uri);
275
+ const inside = (directory) => {
276
+ const relative = path.relative(directory, file);
277
+ return (
278
+ relative &&
279
+ !path.isAbsolute(relative) &&
280
+ relative !== ".." &&
281
+ !relative.startsWith(".." + path.sep)
282
+ );
283
+ };
284
+ if (inside(this.contentRoot) || inside(this.config.paths.assets)) {
285
+ this.scheduleRebuild();
286
+ return;
287
+ }
288
+ for (const root of this.foreignRoots) {
289
+ if (!inside(root)) continue;
290
+ try {
291
+ const project = this.foreignWorkspace(root);
292
+ if (inside(project.contentRoot) || inside(project.config.paths.assets))
293
+ project.scheduleRebuild();
294
+ } catch (error) {
295
+ this.onStatus("Foreign content project " + root + ": " + error.message);
296
+ }
297
+ }
69
298
  }
70
299
 
71
300
  refresh() {
@@ -80,12 +309,14 @@ export class ContentWorkspace {
80
309
  const records = readLanguageIndex(this.cacheDirectory, this.config.contentPackage);
81
310
  this.loadRecords(records);
82
311
  this.indexState = state;
312
+ this.onIndexChanged();
83
313
  return true;
84
314
  }
85
315
 
86
316
  loadRecords(records) {
87
317
  const byAddress = new Map();
88
318
  const byFile = new Map();
319
+ const searchKeys = new Map();
89
320
  const types = new Set(Object.keys(NOTE_VOCABULARY));
90
321
  for (const record of records) {
91
322
  if (record.type) types.add(record.type);
@@ -95,10 +326,26 @@ export class ContentWorkspace {
95
326
  const file = noteFile(this.contentRoot, record);
96
327
  if (!byFile.has(file)) byFile.set(file, record);
97
328
  }
329
+ searchKeys.set(record, [
330
+ ...new Set(
331
+ [
332
+ noteName(record),
333
+ record.nameAscii,
334
+ ...(record.name?.aliases ?? []),
335
+ ...(record.aliasesAscii ?? []),
336
+ record.shortcode,
337
+ record.address?.slug,
338
+ record.address?.canonical,
339
+ ]
340
+ .map(searchKey)
341
+ .filter(Boolean),
342
+ ),
343
+ ]);
98
344
  }
99
345
  this.records = records;
100
346
  this.byAddress = byAddress;
101
347
  this.byFile = byFile;
348
+ this.searchKeys = searchKeys;
102
349
  this.types = types;
103
350
  }
104
351
 
@@ -109,6 +356,7 @@ export class ContentWorkspace {
109
356
  const stat = fs.statSync(path.join(this.cacheDirectory, "metadata.json"));
110
357
  this.indexState = `${stat.mtimeMs}:${stat.size}`;
111
358
  this.onStatus(null);
359
+ this.onIndexChanged();
112
360
  return true;
113
361
  } catch (error) {
114
362
  let stale = this.records.length > 0;
@@ -126,9 +374,15 @@ export class ContentWorkspace {
126
374
  }
127
375
  }
128
376
 
129
- start() {
377
+ start(acceptCompleteCache = false) {
130
378
  if (this.started) return;
131
379
  this.started = true;
380
+ if (acceptCompleteCache)
381
+ try {
382
+ if (this.refresh()) return;
383
+ } catch {
384
+ // A corrupt or incompatible snapshot is rebuilt from saved notes.
385
+ }
132
386
  this.rebuild();
133
387
  }
134
388
 
@@ -143,6 +397,9 @@ export class ContentWorkspace {
143
397
  close() {
144
398
  if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
145
399
  this.rebuildTimer = null;
400
+ for (const timer of this.diagnosticTimers.values()) clearTimeout(timer);
401
+ this.diagnosticTimers.clear();
402
+ for (const project of this.foreign.values()) project.close();
146
403
  }
147
404
 
148
405
  requireIndex() {
@@ -175,31 +432,249 @@ export class ContentWorkspace {
175
432
  }
176
433
 
177
434
  /** Resolve a written Address using the same tuple grammar as the build. */
178
- resolve(value, defaults = {}) {
435
+ resolve(value, defaults = {}, projects = [this]) {
179
436
  const tuple = parseAddress(value, {
180
437
  package: this.config.contentPackage,
181
- system: "none",
182
- types: this.types,
183
- packages: new Set([this.config.contentPackage]),
438
+ system: "note",
439
+ types: new Set(projects.flatMap((project) => [...project.types])),
440
+ packages: new Set(projects.map((project) => project.config.contentPackage)),
184
441
  ...defaults,
185
442
  });
186
443
  if (tuple.reason) return null;
187
- return this.byAddress.get(renderAddress(tuple)) ?? null;
444
+ for (const project of projects)
445
+ if (project.config.contentPackage === tuple.package) {
446
+ const record = project.byAddress.get(renderAddress(tuple));
447
+ if (record) return record;
448
+ }
449
+ return null;
450
+ }
451
+
452
+ /** Spell TARGET with the shortest suffix that resolves to that exact indexed owner. */
453
+ shortestAddress(target, owner, defaults, projects) {
454
+ const canonical = target.address?.canonical?.toLowerCase();
455
+ if (!canonical) return null;
456
+ const parts = canonical.split("-");
457
+ if (parts.length !== 4) return null;
458
+ const suffixes = [
459
+ ...(defaults.type ? [parts[3]] : []),
460
+ parts.slice(2).join("-"),
461
+ parts.slice(1).join("-"),
462
+ canonical,
463
+ ];
464
+ const vocabulary = {
465
+ package: this.config.contentPackage,
466
+ system: defaults.system ?? "note",
467
+ type: defaults.type,
468
+ types: new Set(projects.flatMap((project) => [...project.types])),
469
+ packages: new Set(projects.map((project) => project.config.contentPackage)),
470
+ };
471
+ for (const written of suffixes) {
472
+ const tuple = parseAddress(written, vocabulary);
473
+ if (tuple.reason || renderAddress(tuple) !== canonical) continue;
474
+ const owners = projects.filter((project) => project.byAddress.has(canonical));
475
+ if (owners.length === 1 && owners[0] === owner) return written;
476
+ }
477
+ return null;
478
+ }
479
+
480
+ /** Complete an unfinished wikilink using saved metadata and open-buffer context. */
481
+ linkCompletion(text, offset, projects) {
482
+ const bodyStart = text.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/)?.[0].length ?? 0;
483
+ if (offset < bodyStart) return null;
484
+ const body = text.slice(bodyStart);
485
+ const bodyOffset = offset - bodyStart;
486
+ if (
487
+ codeRegions(body).some(
488
+ (region) => region.start <= bodyOffset && bodyOffset <= region.end,
489
+ )
490
+ )
491
+ return null;
492
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
493
+ const lineEnd = text.indexOf("\n", offset);
494
+ const before = text.slice(lineStart, offset);
495
+ const open = before.lastIndexOf("[[");
496
+ if (open < 0 || before.slice(open + 2).includes("]]")) return null;
497
+ const embed = open > 0 && before[open - 1] === "!";
498
+ if (text.slice(offset, lineEnd < 0 ? text.length : lineEnd).includes("]]")) return null;
499
+ const from = lineStart + open + 2;
500
+ const written = text.slice(from, offset);
501
+ if (written.includes("|") || written.includes("[") || written.includes("]")) return null;
502
+ const hash = written.indexOf("#");
503
+ const defaults = embed ? { system: "none", type: EMBED_DEFAULT_TYPE } : {};
504
+ if (hash < 0)
505
+ return {
506
+ kind: "address",
507
+ from,
508
+ to: offset,
509
+ query: written,
510
+ defaults,
511
+ accepts: embed ? [...ASSET_TYPE_NAMES] : null,
512
+ };
513
+ if (embed) return null;
514
+ const target = this.resolve(written.slice(0, hash), {}, projects);
515
+ if (!target) return null;
516
+ return {
517
+ kind: "anchor",
518
+ target,
519
+ from: from + hash + 1,
520
+ to: offset,
521
+ query: written.slice(hash + 1),
522
+ };
523
+ }
524
+
525
+ /** Find the declared Address scalar or key containing the cursor. */
526
+ frontmatterCompletion(text, offset) {
527
+ const header = /^---\r?\n/.exec(text)?.[0];
528
+ if (!header) return null;
529
+ const closing = /\r?\n---(?:\r?\n|$)/g;
530
+ closing.lastIndex = header.length;
531
+ const end = closing.exec(text)?.index;
532
+ if (end == null || offset < header.length || offset > end) return null;
533
+ const yamlText = text.slice(header.length, end);
534
+ let document;
535
+ let frontmatter;
536
+ try {
537
+ document = YAML.parseDocument(yamlText);
538
+ frontmatter = document.toJS();
539
+ } catch {
540
+ return null;
541
+ }
542
+ if (!frontmatter || typeof frontmatter !== "object") return null;
543
+ const cursor = offset - header.length;
544
+ let found = null;
545
+ const scalar = (node, position) => {
546
+ if (!YAML.isScalar(node) || !node.range || found) return;
547
+ let [start, finish] = node.range;
548
+ const raw = yamlText.slice(start, finish);
549
+ if (
550
+ (raw.startsWith('"') && raw.endsWith('"')) ||
551
+ (raw.startsWith("'") && raw.endsWith("'"))
552
+ ) {
553
+ start += 1;
554
+ finish -= 1;
555
+ }
556
+ if (cursor < start || cursor > finish) return;
557
+ found = {
558
+ kind: "address",
559
+ from: header.length + start,
560
+ to: header.length + finish,
561
+ query: yamlText.slice(start, cursor),
562
+ defaults: { system: position.system ?? "note", type: position.type },
563
+ accepts: position.accepts ?? (position.type ? [position.type] : null),
564
+ };
565
+ };
566
+ const visit = (node, segments, position) => {
567
+ if (!node || found) return;
568
+ if (segments.length) {
569
+ const [head, ...tail] = segments;
570
+ if (YAML.isMap(node))
571
+ for (const pair of node.items)
572
+ if (head === "*" || String(pair.key?.value) === String(head))
573
+ visit(pair.value, tail, position);
574
+ if (YAML.isSeq(node))
575
+ node.items.forEach((child, index) => {
576
+ if (head === "*" || String(index) === String(head))
577
+ visit(child, tail, position);
578
+ });
579
+ return;
580
+ }
581
+ if (position.shape === "keys" && YAML.isMap(node))
582
+ for (const pair of node.items) scalar(pair.key, position);
583
+ else if (position.shape === "list" && YAML.isSeq(node))
584
+ for (const child of node.items) scalar(child, position);
585
+ else if (position.shape === "scalar-or-map" && YAML.isMap(node))
586
+ for (const pair of node.items) scalar(pair.value, position);
587
+ else scalar(node, position);
588
+ };
589
+ for (const position of addressPositions(frontmatter, this.config)) {
590
+ visit(document.contents, position.path, position);
591
+ if (found) break;
592
+ }
593
+ return found;
594
+ }
595
+
596
+ completion(uri, position, projects = [this]) {
597
+ const text = this.text(uri);
598
+ if (text == null) return [];
599
+ const offset = offsetAt(text, position);
600
+ if (offset < 0) return [];
601
+ const context =
602
+ this.linkCompletion(text, offset, projects) ?? this.frontmatterCompletion(text, offset);
603
+ if (!context) return [];
604
+ const needle = searchKey(context.query);
605
+ if (context.kind === "anchor")
606
+ return (context.target.anchors ?? [])
607
+ .filter((anchor) => searchKey(`${anchor.slug} ${anchor.name}`).includes(needle))
608
+ .map((anchor) =>
609
+ completionItem(
610
+ anchor.slug,
611
+ anchor.name,
612
+ anchor.slug,
613
+ text,
614
+ context.from,
615
+ context.to,
616
+ context.query,
617
+ ),
618
+ );
619
+ const items = [];
620
+ const seen = new Set();
621
+ for (const project of projects)
622
+ for (const record of project.records) {
623
+ const canonical = record.address?.canonical?.toLowerCase();
624
+ if (!canonical || project.byAddress.get(canonical) !== record) continue;
625
+ if (seen.has(canonical)) continue;
626
+ if (
627
+ context.accepts &&
628
+ !context.accepts.some(
629
+ (accepted) => record.type === accepted || record.type === `doc${accepted}`,
630
+ )
631
+ )
632
+ continue;
633
+ if (!(project.searchKeys.get(record) ?? []).some((key) => key.includes(needle)))
634
+ continue;
635
+ const inserted = this.shortestAddress(record, project, context.defaults, projects);
636
+ if (!inserted) continue;
637
+ seen.add(canonical);
638
+ items.push(
639
+ completionItem(
640
+ `${noteName(record)} — ${canonical}`,
641
+ `${record.package} · ${canonical}`,
642
+ inserted,
643
+ text,
644
+ context.from,
645
+ context.to,
646
+ context.query,
647
+ ),
648
+ );
649
+ }
650
+ return items.sort((a, b) => a.label.localeCompare(b.label));
188
651
  }
189
652
 
190
653
  /** Link and declared frontmatter targets, with exact source ranges. */
191
- referencesInText(text, file) {
654
+ referencesInText(
655
+ text,
656
+ file,
657
+ projects = [this],
658
+ includeFrontmatter = true,
659
+ includeUnresolved = false,
660
+ ) {
192
661
  const found = [];
193
662
  const bodyStart = text.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/)?.[0].length ?? 0;
194
663
  const linkText = text.slice(bodyStart);
195
664
  for (const match of matchAllOutsideCode(linkText, new RegExp(WIKILINK.source, "g"))) {
665
+ if (match.index > 0 && linkText[match.index - 1] === "!") continue;
196
666
  const parsed = parseWikilink(match[1]);
197
- const record = parsed.target ? this.resolve(parsed.target) : this.byFile.get(file);
198
- if (!record) continue;
667
+ const record =
668
+ parsed.target ? this.resolve(parsed.target, {}, projects) : this.byFile.get(file);
669
+ if (!record && !includeUnresolved) continue;
199
670
  const start = bodyStart + match.index + (parsed.target ? 2 : 3);
200
671
  found.push({
201
672
  record,
202
673
  anchor: parsed.anchor,
674
+ written: parsed.target,
675
+ defaults: {},
676
+ kind: "link",
677
+ labelled: parsed.labelled,
203
678
  location: location(
204
679
  file,
205
680
  text,
@@ -209,16 +684,21 @@ export class ContentWorkspace {
209
684
  });
210
685
  }
211
686
  for (const embed of embedsIn(linkText)) {
212
- const record = this.resolve(embed.written, { type: EMBED_DEFAULT_TYPE });
213
- if (!record) continue;
687
+ const defaults = { system: "none", type: EMBED_DEFAULT_TYPE };
688
+ const record = this.resolve(embed.written, defaults, projects);
689
+ if (!record && !includeUnresolved) continue;
214
690
  const start = bodyStart + embed.index + 3;
215
691
  found.push({
216
692
  record,
217
693
  anchor: "",
694
+ written: embed.written,
695
+ defaults,
696
+ kind: "embed",
697
+ labelled: embed.labelled,
218
698
  location: location(file, text, start, start + embed.written.length),
219
699
  });
220
700
  }
221
- if (!bodyStart) return found;
701
+ if (!bodyStart || !includeFrontmatter) return found;
222
702
  const yamlStart = text.indexOf("\n") + 1;
223
703
  const yamlText = text.slice(yamlStart, bodyStart).replace(/\r?\n---(?:\r?\n)?$/, "");
224
704
  let document;
@@ -249,15 +729,20 @@ export class ContentWorkspace {
249
729
  }
250
730
  const scalar = (part, value) => {
251
731
  if (!YAML.isScalar(part) || typeof value !== "string" || !part.range) return;
252
- const record = this.resolve(value, {
253
- system: position.system ?? "none",
732
+ const defaults = {
733
+ system: position.system ?? "note",
254
734
  type: position.type,
255
- });
256
- if (!record) return;
735
+ };
736
+ const record = this.resolve(value, defaults, projects);
737
+ if (!record && !includeUnresolved) return;
257
738
  const start = yamlStart + part.range[0];
258
739
  found.push({
259
740
  record,
260
741
  anchor: "",
742
+ written: value,
743
+ defaults,
744
+ kind: "frontmatter",
745
+ accepts: position.accepts,
261
746
  location: location(file, text, start, yamlStart + part.range[1]),
262
747
  });
263
748
  };
@@ -271,73 +756,224 @@ export class ContentWorkspace {
271
756
  scalar(node, node.value);
272
757
  }
273
758
  };
274
- for (const position of addressPositions(frontmatter))
759
+ for (const position of addressPositions(frontmatter, this.config))
275
760
  visit(document.contents, position.path, position);
276
761
  return found;
277
762
  }
278
763
 
279
- targetAt(uri, position) {
764
+ /** Validate complete references using live ranges and saved target metadata. */
765
+ diagnostics(uri) {
766
+ const text = this.text(uri);
767
+ if (text == null) return [];
768
+ let projects;
769
+ let localIndexAvailable = true;
770
+ try {
771
+ projects = this.indexedWorkspaces(true);
772
+ } catch {
773
+ projects = [this];
774
+ localIndexAvailable = false;
775
+ }
776
+ const source = this.sourceWorkspace(uri, projects);
777
+ const declarations = ["systems", "requires", "recommends"]
778
+ .flatMap((key) => source.config.relationships?.[key] ?? [])
779
+ .map((entry) => [entry.contentPackage ?? entry.id, entry]);
780
+ const dependencies = new Map(declarations);
781
+ const available = new Set(projects.map((project) => project.config.contentPackage));
782
+ if (!localIndexAvailable) available.delete(this.config.contentPackage);
783
+ const types = new Set(projects.flatMap((project) => [...project.types]));
784
+ const candidates = source.referencesInText(text, fileURLToPath(uri), projects, true, true);
785
+ const diagnostics = [];
786
+ for (const candidate of candidates) {
787
+ const { written, defaults, record, anchor, kind, accepts, labelled } = candidate;
788
+ const target = written || `#${anchor}`;
789
+ let reason = null;
790
+ let message = null;
791
+ let severity = 1;
792
+ if (kind !== "frontmatter" && !labelled) reason = "unlabelled";
793
+ else {
794
+ const tuple =
795
+ written ?
796
+ parseAddress(
797
+ written,
798
+ {
799
+ package: source.config.contentPackage,
800
+ system: defaults.system ?? "note",
801
+ type: defaults.type,
802
+ types,
803
+ },
804
+ { declared: true },
805
+ )
806
+ : null;
807
+ const targetPackage = tuple?.package ?? source.config.contentPackage;
808
+ const dependency = dependencies.get(targetPackage);
809
+ if (tuple?.reason)
810
+ reason =
811
+ ["unknown-type", "not-lowercase"].includes(tuple.reason) ?
812
+ tuple.reason
813
+ : "not-an-address";
814
+ else if (targetPackage !== source.config.contentPackage && !dependency)
815
+ message = `Address ${target} names ${targetPackage}, which is not a declared content dependency`;
816
+ else if (dependency?.contentIndex === false) reason = "no-content-index";
817
+ else if (!available.has(targetPackage)) {
818
+ severity = 2;
819
+ message = `Content index for ${targetPackage} is unavailable; ${target} cannot be checked`;
820
+ } else if (!record) reason = "unresolved";
821
+ else if (kind === "embed" && !ASSET_TYPE_NAMES.has(record.type))
822
+ reason = "not-an-asset";
823
+ else if (
824
+ accepts?.length &&
825
+ !accepts.some((type) => record.type === type || record.type === `doc${type}`)
826
+ )
827
+ message = `Address ${target} targets ${record.type}, but this field accepts ${accepts.join(", ")}`;
828
+ else if (
829
+ kind === "frontmatter" &&
830
+ !accepts?.length &&
831
+ defaults.type &&
832
+ record.type !== defaults.type
833
+ )
834
+ message = `Address ${target} targets ${record.type}, but this field accepts ${defaults.type}`;
835
+ else if (
836
+ anchor &&
837
+ !record.anchors?.some(
838
+ (entry) => entry.slug.toLowerCase() === anchor.toLowerCase(),
839
+ )
840
+ )
841
+ reason = "unknown-anchor";
842
+ }
843
+ if (reason)
844
+ message = linkFindingMessage({
845
+ reason,
846
+ target,
847
+ anchor,
848
+ type: record?.type,
849
+ });
850
+ if (message)
851
+ diagnostics.push({
852
+ range: candidate.location.range,
853
+ severity,
854
+ source: "heroiclands",
855
+ message,
856
+ });
857
+ }
858
+ return diagnostics.sort(
859
+ (a, b) =>
860
+ a.range.start.line - b.range.start.line ||
861
+ a.range.start.character - b.range.start.character,
862
+ );
863
+ }
864
+
865
+ scheduleDiagnostics(uri) {
866
+ if (this.diagnosticTimers.has(uri)) clearTimeout(this.diagnosticTimers.get(uri));
867
+ this.diagnosticTimers.set(
868
+ uri,
869
+ setTimeout(() => {
870
+ this.diagnosticTimers.delete(uri);
871
+ if (this.documents.has(uri)) this.onDiagnostics(uri, this.diagnostics(uri));
872
+ }, 300),
873
+ );
874
+ }
875
+
876
+ publishOpenDiagnostics() {
877
+ for (const uri of this.documents.keys()) this.scheduleDiagnostics(uri);
878
+ }
879
+
880
+ clearDiagnostics(uri) {
881
+ if (this.diagnosticTimers.has(uri)) clearTimeout(this.diagnosticTimers.get(uri));
882
+ this.diagnosticTimers.delete(uri);
883
+ this.onDiagnostics(uri, []);
884
+ }
885
+
886
+ targetAt(uri, position, projects = [this]) {
280
887
  const text = this.text(uri);
281
888
  if (text == null) return null;
282
889
  const offset = offsetAt(text, position);
283
890
  if (offset < 0) return null;
284
891
  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 };
892
+ const reference = this.referencesInText(text, file, projects).find(
893
+ ({ location: source }) => {
894
+ const start = offsetAt(text, source.range.start);
895
+ const end = offsetAt(text, source.range.end);
896
+ return start <= offset && offset <= end;
897
+ },
898
+ );
899
+ if (reference) return reference;
291
900
  const own = this.byFile.get(file);
292
901
  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./]+$/);
902
+ return { record: own, anchor: "", written: own.address?.canonical, defaults: {} };
903
+ const start = text.slice(0, offset).search(/[A-Za-z0-9./-]+$/);
295
904
  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;
905
+ const end = offset + (text.slice(offset).match(/^[A-Za-z0-9./-]+/)?.[0].length ?? 0);
906
+ const written = text.slice(start, end);
907
+ const record = this.resolve(written, {}, projects);
908
+ return { record, anchor: "", written, defaults: {} };
299
909
  }
300
910
 
301
911
  definition(uri, position) {
302
- this.requireIndex();
303
- const target = this.targetAt(uri, position);
912
+ const projects = this.indexedWorkspaces(true);
913
+ const source = this.sourceWorkspace(uri, projects);
914
+ const target = source.targetAt(uri, position, projects);
304
915
  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);
916
+ const candidates =
917
+ target.written ?
918
+ source.resolveCandidates(target.written, target.defaults, projects)
919
+ : [{ record: target.record, project: source }];
920
+ const locations = [];
921
+ for (const { record, project } of candidates) {
922
+ const file = project.fileFor(record);
923
+ if (!file) continue;
924
+ if (record.asset) {
925
+ locations.push({ uri: pathToFileURL(file).href, range: EMPTY_RANGE });
926
+ continue;
927
+ }
928
+ const line =
929
+ target.anchor ?
930
+ record.anchors?.find(
931
+ (entry) => entry.slug.toLowerCase() === target.anchor.toLowerCase(),
932
+ )?.line
933
+ : null;
934
+ if (target.anchor && !line) continue;
935
+ const targetText = fs.readFileSync(file, "utf8");
936
+ const offset = line ? offsetAt(targetText, { line: line - 1, character: 0 }) : 0;
937
+ locations.push(location(file, targetText, offset, offset));
938
+ }
939
+ return (
940
+ locations.length === 1 ? locations[0]
941
+ : locations.length ? locations
942
+ : null
943
+ );
318
944
  }
319
945
 
320
946
  symbols(query) {
321
- this.requireIndex();
322
- const tag = /^tag:(.*)$/i.exec(query);
323
- const needle = (tag ? tag[1] : query).trim().toLowerCase();
947
+ const filter = symbolQuery(query);
948
+ if (!filter) return [];
949
+ return this.indexedWorkspaces(filter.includeForeign, filter.selectedPackage).flatMap(
950
+ (project) => project.symbolMatches(filter),
951
+ );
952
+ }
953
+
954
+ symbolMatches({ field, needle }) {
955
+ const normalized = field === "name" ? searchKey(needle) : needle.toLowerCase();
324
956
  const found = new Map();
325
957
  for (const record of this.records) {
326
958
  if (!record.file?.path) continue;
959
+ const names = [
960
+ record.name?.full,
961
+ record.nameAscii,
962
+ ...(record.name?.aliases ?? []),
963
+ ...(record.aliasesAscii ?? []),
964
+ ];
327
965
  const values =
328
- tag ?
329
- (record.tags ?? [])
330
- : [
331
- record.name?.full,
332
- record.nameAscii,
333
- ...(record.name?.aliases ?? []),
334
- ...(record.aliasesAscii ?? []),
335
- record.shortcode,
336
- record.address?.slug,
337
- record.address?.canonical,
338
- ];
966
+ field === "tag" ? (record.tags ?? [])
967
+ : field === "name" ? names
968
+ : field === "shortcode" ? [record.shortcode]
969
+ : field === "type" ? [record.type]
970
+ : [...names, record.shortcode, record.address?.slug, record.address?.canonical];
339
971
  const match = values.find(
340
- (value) => typeof value === "string" && value.toLowerCase().includes(needle),
972
+ (value) =>
973
+ typeof value === "string" &&
974
+ (field === "name" ? searchKey(value) : value.toLowerCase()).includes(
975
+ normalized,
976
+ ),
341
977
  );
342
978
  if (!match) continue;
343
979
  const file = noteFile(this.contentRoot, record);
@@ -345,7 +981,7 @@ export class ContentWorkspace {
345
981
  found.set(file, {
346
982
  name: String(noteName(record)),
347
983
  kind: 1,
348
- containerName: `${record.address?.slug ?? [record.type, record.shortcode].filter(Boolean).join(" ")} · ${tag ? `tag: ${match}` : match}`,
984
+ containerName: `${record.package} · ${record.address?.slug ?? [record.type, record.shortcode].filter(Boolean).join(" ")} · ${field === "tag" ? `tag: ${match}` : match}`,
349
985
  location: { uri: pathToFileURL(file).href, range: EMPTY_RANGE },
350
986
  });
351
987
  }
@@ -353,29 +989,57 @@ export class ContentWorkspace {
353
989
  }
354
990
 
355
991
  references(uri, position) {
356
- this.requireIndex();
357
- const target = this.targetAt(uri, position)?.record;
358
- if (!target?.address?.canonical) return [];
992
+ const projects = this.indexedWorkspaces(true);
993
+ const source = this.sourceWorkspace(uri, projects);
994
+ const target = source.targetAt(uri, position, projects);
995
+ if (!target) return [];
996
+ const candidates =
997
+ target.record ?
998
+ [target.record]
999
+ : source
1000
+ .resolveCandidates(target.written, target.defaults, projects)
1001
+ .map(({ record }) => record);
1002
+ const addresses = new Set(
1003
+ candidates.map((record) => record.address?.canonical).filter(Boolean),
1004
+ );
1005
+ if (!addresses.size) return [];
359
1006
  const locations = [];
360
- const needle = target.shortcode.toLowerCase();
361
- const visit = (directory) => {
1007
+ const needles = candidates.map((record) => record.shortcode.toLowerCase());
1008
+ const visit = (project, directory, frontmatterCandidates) => {
362
1009
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
363
1010
  if (entry.name.startsWith(".")) continue;
364
1011
  const file = path.join(directory, entry.name);
365
1012
  if (entry.isDirectory()) {
366
- visit(file);
1013
+ visit(project, file, frontmatterCandidates);
367
1014
  } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
368
1015
  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)
1016
+ if (!needles.some((needle) => savedText.toLowerCase().includes(needle)))
1017
+ continue;
1018
+ for (const reference of project.referencesInText(
1019
+ savedText,
1020
+ file,
1021
+ projects,
1022
+ frontmatterCandidates.has(file),
1023
+ )) {
1024
+ if (addresses.has(reference.record.address?.canonical))
373
1025
  locations.push(reference.location);
374
1026
  }
375
1027
  }
376
1028
  }
377
1029
  };
378
- visit(this.contentRoot);
1030
+ for (const project of projects) {
1031
+ const frontmatterCandidates = new Set(
1032
+ project.records
1033
+ .filter((record) =>
1034
+ needles.some((needle) =>
1035
+ JSON.stringify(record).toLowerCase().includes(needle),
1036
+ ),
1037
+ )
1038
+ .filter((record) => record.file?.path)
1039
+ .map((record) => noteFile(project.contentRoot, record)),
1040
+ );
1041
+ visit(project, project.contentRoot, frontmatterCandidates);
1042
+ }
379
1043
  return locations;
380
1044
  }
381
1045
  }
@@ -385,12 +1049,14 @@ export function respond(workspace, message) {
385
1049
  const { method, params = {} } = message;
386
1050
  switch (method) {
387
1051
  case "initialize":
1052
+ workspace.configureForeignRoots(params.initializationOptions?.foreignRoots ?? []);
388
1053
  workspace.start();
389
1054
  return {
390
1055
  capabilities: {
391
1056
  positionEncoding: "utf-16",
392
1057
  textDocumentSync: { openClose: true, change: 2, save: true },
393
1058
  definitionProvider: true,
1059
+ completionProvider: { triggerCharacters: ["[", "#", "-"] },
394
1060
  referencesProvider: true,
395
1061
  workspaceSymbolProvider: true,
396
1062
  workspace: {
@@ -405,8 +1071,14 @@ export function respond(workspace, message) {
405
1071
  };
406
1072
  case "shutdown":
407
1073
  return null;
1074
+ case "workspace/didChangeConfiguration":
1075
+ if (params.settings?.heroiclands?.foreignRoots)
1076
+ workspace.configureForeignRoots(params.settings.heroiclands.foreignRoots);
1077
+ workspace.publishOpenDiagnostics();
1078
+ return undefined;
408
1079
  case "textDocument/didOpen":
409
1080
  workspace.documents.set(params.textDocument.uri, params.textDocument.text);
1081
+ workspace.scheduleDiagnostics(params.textDocument.uri);
410
1082
  return undefined;
411
1083
  case "textDocument/didChange": {
412
1084
  const uri = params.textDocument.uri;
@@ -420,20 +1092,37 @@ export function respond(workspace, message) {
420
1092
  }
421
1093
  }
422
1094
  workspace.documents.set(uri, text);
1095
+ workspace.scheduleDiagnostics(uri);
423
1096
  return undefined;
424
1097
  }
425
1098
  case "textDocument/didClose":
426
1099
  workspace.documents.delete(params.textDocument.uri);
1100
+ workspace.clearDiagnostics(params.textDocument.uri);
427
1101
  return undefined;
428
1102
  case "textDocument/didSave":
1103
+ workspace.scheduleRebuildForUri(params.textDocument.uri);
1104
+ workspace.scheduleDiagnostics(params.textDocument.uri);
1105
+ return undefined;
429
1106
  case "workspace/didChangeWatchedFiles":
430
1107
  case "workspace/didCreateFiles":
431
1108
  case "workspace/didRenameFiles":
432
- case "workspace/didDeleteFiles":
433
- workspace.scheduleRebuild();
1109
+ case "workspace/didDeleteFiles": {
1110
+ const files = params.changes ?? params.files ?? [];
1111
+ if (!files.length) workspace.scheduleRebuild();
1112
+ for (const item of files)
1113
+ for (const uri of [item.uri, item.oldUri, item.newUri].filter(Boolean))
1114
+ workspace.scheduleRebuildForUri(uri);
434
1115
  return undefined;
1116
+ }
435
1117
  case "textDocument/definition":
436
1118
  return workspace.definition(params.textDocument.uri, params.position);
1119
+ case "textDocument/completion": {
1120
+ const text = workspace.text(params.textDocument.uri);
1121
+ if (!mayCompleteAddress(text, offsetAt(text ?? "", params.position))) return [];
1122
+ const projects = workspace.indexedWorkspaces(true);
1123
+ const source = workspace.sourceWorkspace(params.textDocument.uri, projects);
1124
+ return source.completion(params.textDocument.uri, params.position, projects);
1125
+ }
437
1126
  case "textDocument/references":
438
1127
  return workspace.references(params.textDocument.uri, params.position);
439
1128
  case "workspace/symbol":
@@ -463,6 +1152,12 @@ export function runLanguageServer(
463
1152
  params: { type: 1, message: status },
464
1153
  });
465
1154
  };
1155
+ workspace.onDiagnostics = (uri, diagnostics) =>
1156
+ send({
1157
+ jsonrpc: "2.0",
1158
+ method: "textDocument/publishDiagnostics",
1159
+ params: { uri, diagnostics },
1160
+ });
466
1161
  input.on("data", (chunk) => {
467
1162
  pending = Buffer.concat([pending, chunk]);
468
1163
  while (true) {
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.2.0",
4
4
  "description": "Language server for HeroicLands content projects",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  "changeset:version": "changeset version && npm install --package-lock-only"
22
22
  },
23
23
  "dependencies": {
24
- "@heroiclands/package-build": "22.12.3",
24
+ "@heroiclands/package-build": "22.14.4",
25
25
  "yaml": "^2.8.1"
26
26
  },
27
27
  "devDependencies": {