@heroiclands/content-language-server 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 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
+
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,77 @@ 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
+ return (
134
+ open >= 0 &&
135
+ !before.slice(open + 2).includes("]]") &&
136
+ !text.slice(offset, lineEnd < 0 ? text.length : lineEnd).includes("]]")
137
+ );
138
+ }
139
+
74
140
  /** An index read from the package's configured content tree. */
75
141
  export class ContentWorkspace {
76
142
  constructor(
77
143
  config = loadPackConfig(),
78
- { cacheBase, onStatus = () => {}, loadProjectConfig = loadForeignConfig } = {},
144
+ {
145
+ cacheBase,
146
+ onStatus = () => {},
147
+ onDiagnostics = () => {},
148
+ onIndexChanged,
149
+ loadProjectConfig = loadForeignConfig,
150
+ } = {},
79
151
  ) {
80
152
  this.config = config;
81
153
  this.cacheBase = cacheBase;
@@ -87,9 +159,13 @@ export class ContentWorkspace {
87
159
  this.started = false;
88
160
  this.rebuildTimer = null;
89
161
  this.onStatus = onStatus;
162
+ this.onDiagnostics = onDiagnostics;
163
+ this.onIndexChanged = onIndexChanged ?? (() => this.publishOpenDiagnostics());
164
+ this.diagnosticTimers = new Map();
90
165
  this.records = [];
91
166
  this.byAddress = new Map();
92
167
  this.byFile = new Map();
168
+ this.searchKeys = new Map();
93
169
  this.types = new Set(Object.keys(NOTE_VOCABULARY));
94
170
  this.documents = new Map();
95
171
  this.foreignRoots = [];
@@ -125,6 +201,7 @@ export class ContentWorkspace {
125
201
  if (message) this.onStatus(config.contentPackage + ": " + message);
126
202
  },
127
203
  loadProjectConfig: this.loadProjectConfig,
204
+ onIndexChanged: () => this.publishOpenDiagnostics(),
128
205
  });
129
206
  project.documents = this.documents;
130
207
  this.foreign.set(root, project);
@@ -132,13 +209,21 @@ export class ContentWorkspace {
132
209
  }
133
210
 
134
211
  /** Return usable indexes, reporting one failed root without hiding the others. */
135
- indexedWorkspaces(includeForeign = false) {
212
+ indexedWorkspaces(includeForeign = false, selectedPackage = null) {
136
213
  this.requireIndex();
137
- const projects = [this];
214
+ const projects =
215
+ selectedPackage && this.config.contentPackage.toLowerCase() !== selectedPackage ?
216
+ []
217
+ : [this];
138
218
  if (includeForeign)
139
219
  for (const root of this.foreignRoots) {
140
220
  try {
141
221
  const project = this.foreignWorkspace(root);
222
+ if (
223
+ selectedPackage &&
224
+ project.config.contentPackage.toLowerCase() !== selectedPackage
225
+ )
226
+ continue;
142
227
  project.start(true);
143
228
  project.requireIndex();
144
229
  projects.push(project);
@@ -152,16 +237,17 @@ export class ContentWorkspace {
152
237
  /** Find every indexed owner of a written Address for navigation. */
153
238
  resolveCandidates(value, defaults = {}, projects = [this]) {
154
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 [];
155
248
  const found = [];
156
249
  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;
250
+ if (project.config.contentPackage !== tuple.package) continue;
165
251
  const record = project.byAddress.get(renderAddress(tuple));
166
252
  if (record) found.push({ record, project });
167
253
  }
@@ -223,12 +309,14 @@ export class ContentWorkspace {
223
309
  const records = readLanguageIndex(this.cacheDirectory, this.config.contentPackage);
224
310
  this.loadRecords(records);
225
311
  this.indexState = state;
312
+ this.onIndexChanged();
226
313
  return true;
227
314
  }
228
315
 
229
316
  loadRecords(records) {
230
317
  const byAddress = new Map();
231
318
  const byFile = new Map();
319
+ const searchKeys = new Map();
232
320
  const types = new Set(Object.keys(NOTE_VOCABULARY));
233
321
  for (const record of records) {
234
322
  if (record.type) types.add(record.type);
@@ -238,10 +326,26 @@ export class ContentWorkspace {
238
326
  const file = noteFile(this.contentRoot, record);
239
327
  if (!byFile.has(file)) byFile.set(file, record);
240
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
+ ]);
241
344
  }
242
345
  this.records = records;
243
346
  this.byAddress = byAddress;
244
347
  this.byFile = byFile;
348
+ this.searchKeys = searchKeys;
245
349
  this.types = types;
246
350
  }
247
351
 
@@ -252,6 +356,7 @@ export class ContentWorkspace {
252
356
  const stat = fs.statSync(path.join(this.cacheDirectory, "metadata.json"));
253
357
  this.indexState = `${stat.mtimeMs}:${stat.size}`;
254
358
  this.onStatus(null);
359
+ this.onIndexChanged();
255
360
  return true;
256
361
  } catch (error) {
257
362
  let stale = this.records.length > 0;
@@ -292,6 +397,8 @@ export class ContentWorkspace {
292
397
  close() {
293
398
  if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
294
399
  this.rebuildTimer = null;
400
+ for (const timer of this.diagnosticTimers.values()) clearTimeout(timer);
401
+ this.diagnosticTimers.clear();
295
402
  for (const project of this.foreign.values()) project.close();
296
403
  }
297
404
 
@@ -328,7 +435,7 @@ export class ContentWorkspace {
328
435
  resolve(value, defaults = {}, projects = [this]) {
329
436
  const tuple = parseAddress(value, {
330
437
  package: this.config.contentPackage,
331
- system: "none",
438
+ system: "note",
332
439
  types: new Set(projects.flatMap((project) => [...project.types])),
333
440
  packages: new Set(projects.map((project) => project.config.contentPackage)),
334
441
  ...defaults,
@@ -342,22 +449,232 @@ export class ContentWorkspace {
342
449
  return null;
343
450
  }
344
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));
651
+ }
652
+
345
653
  /** Link and declared frontmatter targets, with exact source ranges. */
346
- referencesInText(text, file, projects = [this], includeFrontmatter = true) {
654
+ referencesInText(
655
+ text,
656
+ file,
657
+ projects = [this],
658
+ includeFrontmatter = true,
659
+ includeUnresolved = false,
660
+ ) {
347
661
  const found = [];
348
662
  const bodyStart = text.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/)?.[0].length ?? 0;
349
663
  const linkText = text.slice(bodyStart);
350
664
  for (const match of matchAllOutsideCode(linkText, new RegExp(WIKILINK.source, "g"))) {
665
+ if (match.index > 0 && linkText[match.index - 1] === "!") continue;
351
666
  const parsed = parseWikilink(match[1]);
352
667
  const record =
353
668
  parsed.target ? this.resolve(parsed.target, {}, projects) : this.byFile.get(file);
354
- if (!record) continue;
669
+ if (!record && !includeUnresolved) continue;
355
670
  const start = bodyStart + match.index + (parsed.target ? 2 : 3);
356
671
  found.push({
357
672
  record,
358
673
  anchor: parsed.anchor,
359
674
  written: parsed.target,
360
675
  defaults: {},
676
+ kind: "link",
677
+ labelled: parsed.labelled,
361
678
  location: location(
362
679
  file,
363
680
  text,
@@ -367,15 +684,17 @@ export class ContentWorkspace {
367
684
  });
368
685
  }
369
686
  for (const embed of embedsIn(linkText)) {
370
- const defaults = { type: EMBED_DEFAULT_TYPE };
687
+ const defaults = { system: "none", type: EMBED_DEFAULT_TYPE };
371
688
  const record = this.resolve(embed.written, defaults, projects);
372
- if (!record) continue;
689
+ if (!record && !includeUnresolved) continue;
373
690
  const start = bodyStart + embed.index + 3;
374
691
  found.push({
375
692
  record,
376
693
  anchor: "",
377
694
  written: embed.written,
378
695
  defaults,
696
+ kind: "embed",
697
+ labelled: embed.labelled,
379
698
  location: location(file, text, start, start + embed.written.length),
380
699
  });
381
700
  }
@@ -411,17 +730,19 @@ export class ContentWorkspace {
411
730
  const scalar = (part, value) => {
412
731
  if (!YAML.isScalar(part) || typeof value !== "string" || !part.range) return;
413
732
  const defaults = {
414
- system: position.system ?? "none",
733
+ system: position.system ?? "note",
415
734
  type: position.type,
416
735
  };
417
736
  const record = this.resolve(value, defaults, projects);
418
- if (!record) return;
737
+ if (!record && !includeUnresolved) return;
419
738
  const start = yamlStart + part.range[0];
420
739
  found.push({
421
740
  record,
422
741
  anchor: "",
423
742
  written: value,
424
743
  defaults,
744
+ kind: "frontmatter",
745
+ accepts: position.accepts,
425
746
  location: location(file, text, start, yamlStart + part.range[1]),
426
747
  });
427
748
  };
@@ -435,11 +756,133 @@ export class ContentWorkspace {
435
756
  scalar(node, node.value);
436
757
  }
437
758
  };
438
- for (const position of addressPositions(frontmatter))
759
+ for (const position of addressPositions(frontmatter, this.config))
439
760
  visit(document.contents, position.path, position);
440
761
  return found;
441
762
  }
442
763
 
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
+
443
886
  targetAt(uri, position, projects = [this]) {
444
887
  const text = this.text(uri);
445
888
  if (text == null) return null;
@@ -501,33 +944,36 @@ export class ContentWorkspace {
501
944
  }
502
945
 
503
946
  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),
947
+ const filter = symbolQuery(query);
948
+ if (!filter) return [];
949
+ return this.indexedWorkspaces(filter.includeForeign, filter.selectedPackage).flatMap(
950
+ (project) => project.symbolMatches(filter),
508
951
  );
509
952
  }
510
953
 
511
- symbolMatches(query) {
512
- const tag = /^tag:(.*)$/i.exec(query);
513
- const needle = (tag ? tag[1] : query).trim().toLowerCase();
954
+ symbolMatches({ field, needle }) {
955
+ const normalized = field === "name" ? searchKey(needle) : needle.toLowerCase();
514
956
  const found = new Map();
515
957
  for (const record of this.records) {
516
958
  if (!record.file?.path) continue;
959
+ const names = [
960
+ record.name?.full,
961
+ record.nameAscii,
962
+ ...(record.name?.aliases ?? []),
963
+ ...(record.aliasesAscii ?? []),
964
+ ];
517
965
  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
- ];
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];
529
971
  const match = values.find(
530
- (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
+ ),
531
977
  );
532
978
  if (!match) continue;
533
979
  const file = noteFile(this.contentRoot, record);
@@ -535,7 +981,7 @@ export class ContentWorkspace {
535
981
  found.set(file, {
536
982
  name: String(noteName(record)),
537
983
  kind: 1,
538
- containerName: `${record.package} · ${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}`,
539
985
  location: { uri: pathToFileURL(file).href, range: EMPTY_RANGE },
540
986
  });
541
987
  }
@@ -610,6 +1056,7 @@ export function respond(workspace, message) {
610
1056
  positionEncoding: "utf-16",
611
1057
  textDocumentSync: { openClose: true, change: 2, save: true },
612
1058
  definitionProvider: true,
1059
+ completionProvider: { triggerCharacters: ["[", "#", "-"] },
613
1060
  referencesProvider: true,
614
1061
  workspaceSymbolProvider: true,
615
1062
  workspace: {
@@ -627,9 +1074,11 @@ export function respond(workspace, message) {
627
1074
  case "workspace/didChangeConfiguration":
628
1075
  if (params.settings?.heroiclands?.foreignRoots)
629
1076
  workspace.configureForeignRoots(params.settings.heroiclands.foreignRoots);
1077
+ workspace.publishOpenDiagnostics();
630
1078
  return undefined;
631
1079
  case "textDocument/didOpen":
632
1080
  workspace.documents.set(params.textDocument.uri, params.textDocument.text);
1081
+ workspace.scheduleDiagnostics(params.textDocument.uri);
633
1082
  return undefined;
634
1083
  case "textDocument/didChange": {
635
1084
  const uri = params.textDocument.uri;
@@ -643,13 +1092,16 @@ export function respond(workspace, message) {
643
1092
  }
644
1093
  }
645
1094
  workspace.documents.set(uri, text);
1095
+ workspace.scheduleDiagnostics(uri);
646
1096
  return undefined;
647
1097
  }
648
1098
  case "textDocument/didClose":
649
1099
  workspace.documents.delete(params.textDocument.uri);
1100
+ workspace.clearDiagnostics(params.textDocument.uri);
650
1101
  return undefined;
651
1102
  case "textDocument/didSave":
652
1103
  workspace.scheduleRebuildForUri(params.textDocument.uri);
1104
+ workspace.scheduleDiagnostics(params.textDocument.uri);
653
1105
  return undefined;
654
1106
  case "workspace/didChangeWatchedFiles":
655
1107
  case "workspace/didCreateFiles":
@@ -664,6 +1116,13 @@ export function respond(workspace, message) {
664
1116
  }
665
1117
  case "textDocument/definition":
666
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
+ }
667
1126
  case "textDocument/references":
668
1127
  return workspace.references(params.textDocument.uri, params.position);
669
1128
  case "workspace/symbol":
@@ -693,6 +1152,12 @@ export function runLanguageServer(
693
1152
  params: { type: 1, message: status },
694
1153
  });
695
1154
  };
1155
+ workspace.onDiagnostics = (uri, diagnostics) =>
1156
+ send({
1157
+ jsonrpc: "2.0",
1158
+ method: "textDocument/publishDiagnostics",
1159
+ params: { uri, diagnostics },
1160
+ });
696
1161
  input.on("data", (chunk) => {
697
1162
  pending = Buffer.concat([pending, chunk]);
698
1163
  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.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": {