@heroiclands/content-language-server 0.1.1 → 0.2.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
@@ -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
 
@@ -10,4 +10,10 @@ See the [language server guide](docs/content-language-server.md) for LSP request
10
10
 
11
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
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
+
13
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,34 @@ 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
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.
27
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 a `[[...` link searches the selected projects' saved indexes, including when an editor inserts the closing `]]` after the cursor. 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 preserves closing brackets immediately after the cursor and ignores positions within existing display text. A missing foreign index is reported through the usual LSP status message while other indexed projects remain available.
47
+
28
48
  ## Foreign content projects
29
49
 
30
50
  An LSP client selects foreign project roots through `initialize`:
@@ -39,8 +59,21 @@ An LSP client selects foreign project roots through `initialize`:
39
59
 
40
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`.
41
61
 
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.
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.
43
76
 
44
77
  ## Editor integration
45
78
 
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.
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.
@@ -9,21 +9,27 @@ import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import YAML from "yaml";
10
10
  import { parseAddress, renderAddress } from "@heroiclands/package-build/engine/address";
11
11
  import { addressPositions } from "@heroiclands/package-build/engine/note-addresses";
12
- import { parseWikilink, WIKILINK } from "@heroiclands/package-build/engine/wikilink-syntax";
12
+ import {
13
+ linkFindingMessage,
14
+ parseWikilink,
15
+ WIKILINK,
16
+ } from "@heroiclands/package-build/engine/wikilink-syntax";
13
17
  import {
14
18
  CONFIG_FILENAMES,
15
19
  configFromData,
16
20
  loadPackConfig,
17
21
  } from "@heroiclands/package-build/engine/pack-config";
18
22
  import { noteFile } from "@heroiclands/package-build/engine/index-records";
23
+ import { asciiName } from "@heroiclands/package-build/engine/content-index";
19
24
  import { NOTE_VOCABULARY } from "@heroiclands/package-build/engine/note-vocabulary";
20
25
  import {
21
26
  languageIndexDirectory,
22
27
  readLanguageIndex,
23
28
  rebuildLanguageIndex,
24
29
  } from "./content-language-index.mjs";
25
- import { matchAllOutsideCode } from "@heroiclands/package-build/engine/code-fences";
30
+ import { codeRegions, matchAllOutsideCode } from "@heroiclands/package-build/engine/code-fences";
26
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";
27
33
 
28
34
  const EMPTY_RANGE = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
29
35
  const require = createRequire(import.meta.url);
@@ -71,11 +77,78 @@ function noteName(record) {
71
77
  return typeof record.name === "string" ? record.name : (record.name?.full ?? record.shortcode);
72
78
  }
73
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
+ const after = text.slice(offset, lineEnd < 0 ? text.length : lineEnd);
134
+ return (
135
+ open >= 0 &&
136
+ !before.slice(open + 2).includes("]]") &&
137
+ (!after.includes("]]") || after.startsWith("]]"))
138
+ );
139
+ }
140
+
74
141
  /** An index read from the package's configured content tree. */
75
142
  export class ContentWorkspace {
76
143
  constructor(
77
144
  config = loadPackConfig(),
78
- { cacheBase, onStatus = () => {}, loadProjectConfig = loadForeignConfig } = {},
145
+ {
146
+ cacheBase,
147
+ onStatus = () => {},
148
+ onDiagnostics = () => {},
149
+ onIndexChanged,
150
+ loadProjectConfig = loadForeignConfig,
151
+ } = {},
79
152
  ) {
80
153
  this.config = config;
81
154
  this.cacheBase = cacheBase;
@@ -87,9 +160,13 @@ export class ContentWorkspace {
87
160
  this.started = false;
88
161
  this.rebuildTimer = null;
89
162
  this.onStatus = onStatus;
163
+ this.onDiagnostics = onDiagnostics;
164
+ this.onIndexChanged = onIndexChanged ?? (() => this.publishOpenDiagnostics());
165
+ this.diagnosticTimers = new Map();
90
166
  this.records = [];
91
167
  this.byAddress = new Map();
92
168
  this.byFile = new Map();
169
+ this.searchKeys = new Map();
93
170
  this.types = new Set(Object.keys(NOTE_VOCABULARY));
94
171
  this.documents = new Map();
95
172
  this.foreignRoots = [];
@@ -125,6 +202,7 @@ export class ContentWorkspace {
125
202
  if (message) this.onStatus(config.contentPackage + ": " + message);
126
203
  },
127
204
  loadProjectConfig: this.loadProjectConfig,
205
+ onIndexChanged: () => this.publishOpenDiagnostics(),
128
206
  });
129
207
  project.documents = this.documents;
130
208
  this.foreign.set(root, project);
@@ -132,13 +210,21 @@ export class ContentWorkspace {
132
210
  }
133
211
 
134
212
  /** Return usable indexes, reporting one failed root without hiding the others. */
135
- indexedWorkspaces(includeForeign = false) {
213
+ indexedWorkspaces(includeForeign = false, selectedPackage = null) {
136
214
  this.requireIndex();
137
- const projects = [this];
215
+ const projects =
216
+ selectedPackage && this.config.contentPackage.toLowerCase() !== selectedPackage ?
217
+ []
218
+ : [this];
138
219
  if (includeForeign)
139
220
  for (const root of this.foreignRoots) {
140
221
  try {
141
222
  const project = this.foreignWorkspace(root);
223
+ if (
224
+ selectedPackage &&
225
+ project.config.contentPackage.toLowerCase() !== selectedPackage
226
+ )
227
+ continue;
142
228
  project.start(true);
143
229
  project.requireIndex();
144
230
  projects.push(project);
@@ -152,16 +238,17 @@ export class ContentWorkspace {
152
238
  /** Find every indexed owner of a written Address for navigation. */
153
239
  resolveCandidates(value, defaults = {}, projects = [this]) {
154
240
  const packages = new Set(projects.map((project) => project.config.contentPackage));
241
+ const tuple = parseAddress(value, {
242
+ package: this.config.contentPackage,
243
+ system: "note",
244
+ types: new Set(projects.flatMap((project) => [...project.types])),
245
+ packages,
246
+ ...defaults,
247
+ });
248
+ if (tuple.reason) return [];
155
249
  const found = [];
156
250
  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;
251
+ if (project.config.contentPackage !== tuple.package) continue;
165
252
  const record = project.byAddress.get(renderAddress(tuple));
166
253
  if (record) found.push({ record, project });
167
254
  }
@@ -223,12 +310,14 @@ export class ContentWorkspace {
223
310
  const records = readLanguageIndex(this.cacheDirectory, this.config.contentPackage);
224
311
  this.loadRecords(records);
225
312
  this.indexState = state;
313
+ this.onIndexChanged();
226
314
  return true;
227
315
  }
228
316
 
229
317
  loadRecords(records) {
230
318
  const byAddress = new Map();
231
319
  const byFile = new Map();
320
+ const searchKeys = new Map();
232
321
  const types = new Set(Object.keys(NOTE_VOCABULARY));
233
322
  for (const record of records) {
234
323
  if (record.type) types.add(record.type);
@@ -238,10 +327,26 @@ export class ContentWorkspace {
238
327
  const file = noteFile(this.contentRoot, record);
239
328
  if (!byFile.has(file)) byFile.set(file, record);
240
329
  }
330
+ searchKeys.set(record, [
331
+ ...new Set(
332
+ [
333
+ noteName(record),
334
+ record.nameAscii,
335
+ ...(record.name?.aliases ?? []),
336
+ ...(record.aliasesAscii ?? []),
337
+ record.shortcode,
338
+ record.address?.slug,
339
+ record.address?.canonical,
340
+ ]
341
+ .map(searchKey)
342
+ .filter(Boolean),
343
+ ),
344
+ ]);
241
345
  }
242
346
  this.records = records;
243
347
  this.byAddress = byAddress;
244
348
  this.byFile = byFile;
349
+ this.searchKeys = searchKeys;
245
350
  this.types = types;
246
351
  }
247
352
 
@@ -252,6 +357,7 @@ export class ContentWorkspace {
252
357
  const stat = fs.statSync(path.join(this.cacheDirectory, "metadata.json"));
253
358
  this.indexState = `${stat.mtimeMs}:${stat.size}`;
254
359
  this.onStatus(null);
360
+ this.onIndexChanged();
255
361
  return true;
256
362
  } catch (error) {
257
363
  let stale = this.records.length > 0;
@@ -292,6 +398,8 @@ export class ContentWorkspace {
292
398
  close() {
293
399
  if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
294
400
  this.rebuildTimer = null;
401
+ for (const timer of this.diagnosticTimers.values()) clearTimeout(timer);
402
+ this.diagnosticTimers.clear();
295
403
  for (const project of this.foreign.values()) project.close();
296
404
  }
297
405
 
@@ -328,7 +436,7 @@ export class ContentWorkspace {
328
436
  resolve(value, defaults = {}, projects = [this]) {
329
437
  const tuple = parseAddress(value, {
330
438
  package: this.config.contentPackage,
331
- system: "none",
439
+ system: "note",
332
440
  types: new Set(projects.flatMap((project) => [...project.types])),
333
441
  packages: new Set(projects.map((project) => project.config.contentPackage)),
334
442
  ...defaults,
@@ -342,22 +450,233 @@ export class ContentWorkspace {
342
450
  return null;
343
451
  }
344
452
 
453
+ /** Spell TARGET with the shortest suffix that resolves to that exact indexed owner. */
454
+ shortestAddress(target, owner, defaults, projects) {
455
+ const canonical = target.address?.canonical?.toLowerCase();
456
+ if (!canonical) return null;
457
+ const parts = canonical.split("-");
458
+ if (parts.length !== 4) return null;
459
+ const suffixes = [
460
+ ...(defaults.type ? [parts[3]] : []),
461
+ parts.slice(2).join("-"),
462
+ parts.slice(1).join("-"),
463
+ canonical,
464
+ ];
465
+ const vocabulary = {
466
+ package: this.config.contentPackage,
467
+ system: defaults.system ?? "note",
468
+ type: defaults.type,
469
+ types: new Set(projects.flatMap((project) => [...project.types])),
470
+ packages: new Set(projects.map((project) => project.config.contentPackage)),
471
+ };
472
+ for (const written of suffixes) {
473
+ const tuple = parseAddress(written, vocabulary);
474
+ if (tuple.reason || renderAddress(tuple) !== canonical) continue;
475
+ const owners = projects.filter((project) => project.byAddress.has(canonical));
476
+ if (owners.length === 1 && owners[0] === owner) return written;
477
+ }
478
+ return null;
479
+ }
480
+
481
+ /** Complete an unfinished wikilink using saved metadata and open-buffer context. */
482
+ linkCompletion(text, offset, projects) {
483
+ const bodyStart = text.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/)?.[0].length ?? 0;
484
+ if (offset < bodyStart) return null;
485
+ const body = text.slice(bodyStart);
486
+ const bodyOffset = offset - bodyStart;
487
+ if (
488
+ codeRegions(body).some(
489
+ (region) => region.start <= bodyOffset && bodyOffset <= region.end,
490
+ )
491
+ )
492
+ return null;
493
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
494
+ const lineEnd = text.indexOf("\n", offset);
495
+ const before = text.slice(lineStart, offset);
496
+ const open = before.lastIndexOf("[[");
497
+ if (open < 0 || before.slice(open + 2).includes("]]")) return null;
498
+ const embed = open > 0 && before[open - 1] === "!";
499
+ const after = text.slice(offset, lineEnd < 0 ? text.length : lineEnd);
500
+ if (after.includes("]]") && !after.startsWith("]]")) return null;
501
+ const from = lineStart + open + 2;
502
+ const written = text.slice(from, offset);
503
+ if (written.includes("|") || written.includes("[") || written.includes("]")) return null;
504
+ const hash = written.indexOf("#");
505
+ const defaults = embed ? { system: "none", type: EMBED_DEFAULT_TYPE } : {};
506
+ if (hash < 0)
507
+ return {
508
+ kind: "address",
509
+ from,
510
+ to: offset,
511
+ query: written,
512
+ defaults,
513
+ accepts: embed ? [...ASSET_TYPE_NAMES] : null,
514
+ };
515
+ if (embed) return null;
516
+ const target = this.resolve(written.slice(0, hash), {}, projects);
517
+ if (!target) return null;
518
+ return {
519
+ kind: "anchor",
520
+ target,
521
+ from: from + hash + 1,
522
+ to: offset,
523
+ query: written.slice(hash + 1),
524
+ };
525
+ }
526
+
527
+ /** Find the declared Address scalar or key containing the cursor. */
528
+ frontmatterCompletion(text, offset) {
529
+ const header = /^---\r?\n/.exec(text)?.[0];
530
+ if (!header) return null;
531
+ const closing = /\r?\n---(?:\r?\n|$)/g;
532
+ closing.lastIndex = header.length;
533
+ const end = closing.exec(text)?.index;
534
+ if (end == null || offset < header.length || offset > end) return null;
535
+ const yamlText = text.slice(header.length, end);
536
+ let document;
537
+ let frontmatter;
538
+ try {
539
+ document = YAML.parseDocument(yamlText);
540
+ frontmatter = document.toJS();
541
+ } catch {
542
+ return null;
543
+ }
544
+ if (!frontmatter || typeof frontmatter !== "object") return null;
545
+ const cursor = offset - header.length;
546
+ let found = null;
547
+ const scalar = (node, position) => {
548
+ if (!YAML.isScalar(node) || !node.range || found) return;
549
+ let [start, finish] = node.range;
550
+ const raw = yamlText.slice(start, finish);
551
+ if (
552
+ (raw.startsWith('"') && raw.endsWith('"')) ||
553
+ (raw.startsWith("'") && raw.endsWith("'"))
554
+ ) {
555
+ start += 1;
556
+ finish -= 1;
557
+ }
558
+ if (cursor < start || cursor > finish) return;
559
+ found = {
560
+ kind: "address",
561
+ from: header.length + start,
562
+ to: header.length + finish,
563
+ query: yamlText.slice(start, cursor),
564
+ defaults: { system: position.system ?? "note", type: position.type },
565
+ accepts: position.accepts ?? (position.type ? [position.type] : null),
566
+ };
567
+ };
568
+ const visit = (node, segments, position) => {
569
+ if (!node || found) return;
570
+ if (segments.length) {
571
+ const [head, ...tail] = segments;
572
+ if (YAML.isMap(node))
573
+ for (const pair of node.items)
574
+ if (head === "*" || String(pair.key?.value) === String(head))
575
+ visit(pair.value, tail, position);
576
+ if (YAML.isSeq(node))
577
+ node.items.forEach((child, index) => {
578
+ if (head === "*" || String(index) === String(head))
579
+ visit(child, tail, position);
580
+ });
581
+ return;
582
+ }
583
+ if (position.shape === "keys" && YAML.isMap(node))
584
+ for (const pair of node.items) scalar(pair.key, position);
585
+ else if (position.shape === "list" && YAML.isSeq(node))
586
+ for (const child of node.items) scalar(child, position);
587
+ else if (position.shape === "scalar-or-map" && YAML.isMap(node))
588
+ for (const pair of node.items) scalar(pair.value, position);
589
+ else scalar(node, position);
590
+ };
591
+ for (const position of addressPositions(frontmatter, this.config)) {
592
+ visit(document.contents, position.path, position);
593
+ if (found) break;
594
+ }
595
+ return found;
596
+ }
597
+
598
+ completion(uri, position, projects = [this]) {
599
+ const text = this.text(uri);
600
+ if (text == null) return [];
601
+ const offset = offsetAt(text, position);
602
+ if (offset < 0) return [];
603
+ const context =
604
+ this.linkCompletion(text, offset, projects) ?? this.frontmatterCompletion(text, offset);
605
+ if (!context) return [];
606
+ const needle = searchKey(context.query);
607
+ if (context.kind === "anchor")
608
+ return (context.target.anchors ?? [])
609
+ .filter((anchor) => searchKey(`${anchor.slug} ${anchor.name}`).includes(needle))
610
+ .map((anchor) =>
611
+ completionItem(
612
+ anchor.slug,
613
+ anchor.name,
614
+ anchor.slug,
615
+ text,
616
+ context.from,
617
+ context.to,
618
+ context.query,
619
+ ),
620
+ );
621
+ const items = [];
622
+ const seen = new Set();
623
+ for (const project of projects)
624
+ for (const record of project.records) {
625
+ const canonical = record.address?.canonical?.toLowerCase();
626
+ if (!canonical || project.byAddress.get(canonical) !== record) continue;
627
+ if (seen.has(canonical)) continue;
628
+ if (
629
+ context.accepts &&
630
+ !context.accepts.some(
631
+ (accepted) => record.type === accepted || record.type === `doc${accepted}`,
632
+ )
633
+ )
634
+ continue;
635
+ if (!(project.searchKeys.get(record) ?? []).some((key) => key.includes(needle)))
636
+ continue;
637
+ const inserted = this.shortestAddress(record, project, context.defaults, projects);
638
+ if (!inserted) continue;
639
+ seen.add(canonical);
640
+ items.push(
641
+ completionItem(
642
+ `${noteName(record)} — ${canonical}`,
643
+ `${record.package} · ${canonical}`,
644
+ inserted,
645
+ text,
646
+ context.from,
647
+ context.to,
648
+ context.query,
649
+ ),
650
+ );
651
+ }
652
+ return items.sort((a, b) => a.label.localeCompare(b.label));
653
+ }
654
+
345
655
  /** Link and declared frontmatter targets, with exact source ranges. */
346
- referencesInText(text, file, projects = [this], includeFrontmatter = true) {
656
+ referencesInText(
657
+ text,
658
+ file,
659
+ projects = [this],
660
+ includeFrontmatter = true,
661
+ includeUnresolved = false,
662
+ ) {
347
663
  const found = [];
348
664
  const bodyStart = text.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/)?.[0].length ?? 0;
349
665
  const linkText = text.slice(bodyStart);
350
666
  for (const match of matchAllOutsideCode(linkText, new RegExp(WIKILINK.source, "g"))) {
667
+ if (match.index > 0 && linkText[match.index - 1] === "!") continue;
351
668
  const parsed = parseWikilink(match[1]);
352
669
  const record =
353
670
  parsed.target ? this.resolve(parsed.target, {}, projects) : this.byFile.get(file);
354
- if (!record) continue;
671
+ if (!record && !includeUnresolved) continue;
355
672
  const start = bodyStart + match.index + (parsed.target ? 2 : 3);
356
673
  found.push({
357
674
  record,
358
675
  anchor: parsed.anchor,
359
676
  written: parsed.target,
360
677
  defaults: {},
678
+ kind: "link",
679
+ labelled: parsed.labelled,
361
680
  location: location(
362
681
  file,
363
682
  text,
@@ -367,15 +686,17 @@ export class ContentWorkspace {
367
686
  });
368
687
  }
369
688
  for (const embed of embedsIn(linkText)) {
370
- const defaults = { type: EMBED_DEFAULT_TYPE };
689
+ const defaults = { system: "none", type: EMBED_DEFAULT_TYPE };
371
690
  const record = this.resolve(embed.written, defaults, projects);
372
- if (!record) continue;
691
+ if (!record && !includeUnresolved) continue;
373
692
  const start = bodyStart + embed.index + 3;
374
693
  found.push({
375
694
  record,
376
695
  anchor: "",
377
696
  written: embed.written,
378
697
  defaults,
698
+ kind: "embed",
699
+ labelled: embed.labelled,
379
700
  location: location(file, text, start, start + embed.written.length),
380
701
  });
381
702
  }
@@ -411,17 +732,19 @@ export class ContentWorkspace {
411
732
  const scalar = (part, value) => {
412
733
  if (!YAML.isScalar(part) || typeof value !== "string" || !part.range) return;
413
734
  const defaults = {
414
- system: position.system ?? "none",
735
+ system: position.system ?? "note",
415
736
  type: position.type,
416
737
  };
417
738
  const record = this.resolve(value, defaults, projects);
418
- if (!record) return;
739
+ if (!record && !includeUnresolved) return;
419
740
  const start = yamlStart + part.range[0];
420
741
  found.push({
421
742
  record,
422
743
  anchor: "",
423
744
  written: value,
424
745
  defaults,
746
+ kind: "frontmatter",
747
+ accepts: position.accepts,
425
748
  location: location(file, text, start, yamlStart + part.range[1]),
426
749
  });
427
750
  };
@@ -435,11 +758,133 @@ export class ContentWorkspace {
435
758
  scalar(node, node.value);
436
759
  }
437
760
  };
438
- for (const position of addressPositions(frontmatter))
761
+ for (const position of addressPositions(frontmatter, this.config))
439
762
  visit(document.contents, position.path, position);
440
763
  return found;
441
764
  }
442
765
 
766
+ /** Validate complete references using live ranges and saved target metadata. */
767
+ diagnostics(uri) {
768
+ const text = this.text(uri);
769
+ if (text == null) return [];
770
+ let projects;
771
+ let localIndexAvailable = true;
772
+ try {
773
+ projects = this.indexedWorkspaces(true);
774
+ } catch {
775
+ projects = [this];
776
+ localIndexAvailable = false;
777
+ }
778
+ const source = this.sourceWorkspace(uri, projects);
779
+ const declarations = ["systems", "requires", "recommends"]
780
+ .flatMap((key) => source.config.relationships?.[key] ?? [])
781
+ .map((entry) => [entry.contentPackage ?? entry.id, entry]);
782
+ const dependencies = new Map(declarations);
783
+ const available = new Set(projects.map((project) => project.config.contentPackage));
784
+ if (!localIndexAvailable) available.delete(this.config.contentPackage);
785
+ const types = new Set(projects.flatMap((project) => [...project.types]));
786
+ const candidates = source.referencesInText(text, fileURLToPath(uri), projects, true, true);
787
+ const diagnostics = [];
788
+ for (const candidate of candidates) {
789
+ const { written, defaults, record, anchor, kind, accepts, labelled } = candidate;
790
+ const target = written || `#${anchor}`;
791
+ let reason = null;
792
+ let message = null;
793
+ let severity = 1;
794
+ if (kind !== "frontmatter" && !labelled) reason = "unlabelled";
795
+ else {
796
+ const tuple =
797
+ written ?
798
+ parseAddress(
799
+ written,
800
+ {
801
+ package: source.config.contentPackage,
802
+ system: defaults.system ?? "note",
803
+ type: defaults.type,
804
+ types,
805
+ },
806
+ { declared: true },
807
+ )
808
+ : null;
809
+ const targetPackage = tuple?.package ?? source.config.contentPackage;
810
+ const dependency = dependencies.get(targetPackage);
811
+ if (tuple?.reason)
812
+ reason =
813
+ ["unknown-type", "not-lowercase"].includes(tuple.reason) ?
814
+ tuple.reason
815
+ : "not-an-address";
816
+ else if (targetPackage !== source.config.contentPackage && !dependency)
817
+ message = `Address ${target} names ${targetPackage}, which is not a declared content dependency`;
818
+ else if (dependency?.contentIndex === false) reason = "no-content-index";
819
+ else if (!available.has(targetPackage)) {
820
+ severity = 2;
821
+ message = `Content index for ${targetPackage} is unavailable; ${target} cannot be checked`;
822
+ } else if (!record) reason = "unresolved";
823
+ else if (kind === "embed" && !ASSET_TYPE_NAMES.has(record.type))
824
+ reason = "not-an-asset";
825
+ else if (
826
+ accepts?.length &&
827
+ !accepts.some((type) => record.type === type || record.type === `doc${type}`)
828
+ )
829
+ message = `Address ${target} targets ${record.type}, but this field accepts ${accepts.join(", ")}`;
830
+ else if (
831
+ kind === "frontmatter" &&
832
+ !accepts?.length &&
833
+ defaults.type &&
834
+ record.type !== defaults.type
835
+ )
836
+ message = `Address ${target} targets ${record.type}, but this field accepts ${defaults.type}`;
837
+ else if (
838
+ anchor &&
839
+ !record.anchors?.some(
840
+ (entry) => entry.slug.toLowerCase() === anchor.toLowerCase(),
841
+ )
842
+ )
843
+ reason = "unknown-anchor";
844
+ }
845
+ if (reason)
846
+ message = linkFindingMessage({
847
+ reason,
848
+ target,
849
+ anchor,
850
+ type: record?.type,
851
+ });
852
+ if (message)
853
+ diagnostics.push({
854
+ range: candidate.location.range,
855
+ severity,
856
+ source: "heroiclands",
857
+ message,
858
+ });
859
+ }
860
+ return diagnostics.sort(
861
+ (a, b) =>
862
+ a.range.start.line - b.range.start.line ||
863
+ a.range.start.character - b.range.start.character,
864
+ );
865
+ }
866
+
867
+ scheduleDiagnostics(uri) {
868
+ if (this.diagnosticTimers.has(uri)) clearTimeout(this.diagnosticTimers.get(uri));
869
+ this.diagnosticTimers.set(
870
+ uri,
871
+ setTimeout(() => {
872
+ this.diagnosticTimers.delete(uri);
873
+ if (this.documents.has(uri)) this.onDiagnostics(uri, this.diagnostics(uri));
874
+ }, 300),
875
+ );
876
+ }
877
+
878
+ publishOpenDiagnostics() {
879
+ for (const uri of this.documents.keys()) this.scheduleDiagnostics(uri);
880
+ }
881
+
882
+ clearDiagnostics(uri) {
883
+ if (this.diagnosticTimers.has(uri)) clearTimeout(this.diagnosticTimers.get(uri));
884
+ this.diagnosticTimers.delete(uri);
885
+ this.onDiagnostics(uri, []);
886
+ }
887
+
443
888
  targetAt(uri, position, projects = [this]) {
444
889
  const text = this.text(uri);
445
890
  if (text == null) return null;
@@ -501,33 +946,36 @@ export class ContentWorkspace {
501
946
  }
502
947
 
503
948
  symbols(query) {
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),
949
+ const filter = symbolQuery(query);
950
+ if (!filter) return [];
951
+ return this.indexedWorkspaces(filter.includeForeign, filter.selectedPackage).flatMap(
952
+ (project) => project.symbolMatches(filter),
508
953
  );
509
954
  }
510
955
 
511
- symbolMatches(query) {
512
- const tag = /^tag:(.*)$/i.exec(query);
513
- const needle = (tag ? tag[1] : query).trim().toLowerCase();
956
+ symbolMatches({ field, needle }) {
957
+ const normalized = field === "name" ? searchKey(needle) : needle.toLowerCase();
514
958
  const found = new Map();
515
959
  for (const record of this.records) {
516
960
  if (!record.file?.path) continue;
961
+ const names = [
962
+ record.name?.full,
963
+ record.nameAscii,
964
+ ...(record.name?.aliases ?? []),
965
+ ...(record.aliasesAscii ?? []),
966
+ ];
517
967
  const values =
518
- tag ?
519
- (record.tags ?? [])
520
- : [
521
- record.name?.full,
522
- record.nameAscii,
523
- ...(record.name?.aliases ?? []),
524
- ...(record.aliasesAscii ?? []),
525
- record.shortcode,
526
- record.address?.slug,
527
- record.address?.canonical,
528
- ];
968
+ field === "tag" ? (record.tags ?? [])
969
+ : field === "name" ? names
970
+ : field === "shortcode" ? [record.shortcode]
971
+ : field === "type" ? [record.type]
972
+ : [...names, record.shortcode, record.address?.slug, record.address?.canonical];
529
973
  const match = values.find(
530
- (value) => typeof value === "string" && value.toLowerCase().includes(needle),
974
+ (value) =>
975
+ typeof value === "string" &&
976
+ (field === "name" ? searchKey(value) : value.toLowerCase()).includes(
977
+ normalized,
978
+ ),
531
979
  );
532
980
  if (!match) continue;
533
981
  const file = noteFile(this.contentRoot, record);
@@ -535,7 +983,7 @@ export class ContentWorkspace {
535
983
  found.set(file, {
536
984
  name: String(noteName(record)),
537
985
  kind: 1,
538
- containerName: `${record.package} · ${record.address?.slug ?? [record.type, record.shortcode].filter(Boolean).join(" ")} · ${tag ? `tag: ${match}` : match}`,
986
+ containerName: `${record.package} · ${record.address?.slug ?? [record.type, record.shortcode].filter(Boolean).join(" ")} · ${field === "tag" ? `tag: ${match}` : match}`,
539
987
  location: { uri: pathToFileURL(file).href, range: EMPTY_RANGE },
540
988
  });
541
989
  }
@@ -610,6 +1058,7 @@ export function respond(workspace, message) {
610
1058
  positionEncoding: "utf-16",
611
1059
  textDocumentSync: { openClose: true, change: 2, save: true },
612
1060
  definitionProvider: true,
1061
+ completionProvider: { triggerCharacters: ["[", "#", "-"] },
613
1062
  referencesProvider: true,
614
1063
  workspaceSymbolProvider: true,
615
1064
  workspace: {
@@ -627,9 +1076,11 @@ export function respond(workspace, message) {
627
1076
  case "workspace/didChangeConfiguration":
628
1077
  if (params.settings?.heroiclands?.foreignRoots)
629
1078
  workspace.configureForeignRoots(params.settings.heroiclands.foreignRoots);
1079
+ workspace.publishOpenDiagnostics();
630
1080
  return undefined;
631
1081
  case "textDocument/didOpen":
632
1082
  workspace.documents.set(params.textDocument.uri, params.textDocument.text);
1083
+ workspace.scheduleDiagnostics(params.textDocument.uri);
633
1084
  return undefined;
634
1085
  case "textDocument/didChange": {
635
1086
  const uri = params.textDocument.uri;
@@ -643,13 +1094,16 @@ export function respond(workspace, message) {
643
1094
  }
644
1095
  }
645
1096
  workspace.documents.set(uri, text);
1097
+ workspace.scheduleDiagnostics(uri);
646
1098
  return undefined;
647
1099
  }
648
1100
  case "textDocument/didClose":
649
1101
  workspace.documents.delete(params.textDocument.uri);
1102
+ workspace.clearDiagnostics(params.textDocument.uri);
650
1103
  return undefined;
651
1104
  case "textDocument/didSave":
652
1105
  workspace.scheduleRebuildForUri(params.textDocument.uri);
1106
+ workspace.scheduleDiagnostics(params.textDocument.uri);
653
1107
  return undefined;
654
1108
  case "workspace/didChangeWatchedFiles":
655
1109
  case "workspace/didCreateFiles":
@@ -664,6 +1118,13 @@ export function respond(workspace, message) {
664
1118
  }
665
1119
  case "textDocument/definition":
666
1120
  return workspace.definition(params.textDocument.uri, params.position);
1121
+ case "textDocument/completion": {
1122
+ const text = workspace.text(params.textDocument.uri);
1123
+ if (!mayCompleteAddress(text, offsetAt(text ?? "", params.position))) return [];
1124
+ const projects = workspace.indexedWorkspaces(true);
1125
+ const source = workspace.sourceWorkspace(params.textDocument.uri, projects);
1126
+ return source.completion(params.textDocument.uri, params.position, projects);
1127
+ }
667
1128
  case "textDocument/references":
668
1129
  return workspace.references(params.textDocument.uri, params.position);
669
1130
  case "workspace/symbol":
@@ -693,6 +1154,12 @@ export function runLanguageServer(
693
1154
  params: { type: 1, message: status },
694
1155
  });
695
1156
  };
1157
+ workspace.onDiagnostics = (uri, diagnostics) =>
1158
+ send({
1159
+ jsonrpc: "2.0",
1160
+ method: "textDocument/publishDiagnostics",
1161
+ params: { uri, diagnostics },
1162
+ });
696
1163
  input.on("data", (chunk) => {
697
1164
  pending = Buffer.concat([pending, chunk]);
698
1165
  while (true) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/content-language-server",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
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": {