@heroiclands/content-language-server 0.1.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.
@@ -0,0 +1,513 @@
1
+ /*
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ */
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import YAML from "yaml";
9
+ import { parseAddress, renderAddress } from "@heroiclands/package-build/engine/address";
10
+ 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";
13
+ import { noteFile } from "@heroiclands/package-build/engine/index-records";
14
+ import { NOTE_VOCABULARY } from "@heroiclands/package-build/engine/note-vocabulary";
15
+ import {
16
+ languageIndexDirectory,
17
+ readLanguageIndex,
18
+ rebuildLanguageIndex,
19
+ } from "./content-language-index.mjs";
20
+ import { matchAllOutsideCode } from "@heroiclands/package-build/engine/code-fences";
21
+ import { embedsIn, EMBED_DEFAULT_TYPE } from "@heroiclands/package-build/engine/content-embeds";
22
+
23
+ const EMPTY_RANGE = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
24
+
25
+ /** Return an LSP position for a UTF-16 offset in TEXT. */
26
+ function positionAt(text, offset) {
27
+ const before = text.slice(0, offset);
28
+ const line = before.split("\n").length - 1;
29
+ const lastNewline = before.lastIndexOf("\n");
30
+ return { line, character: offset - lastNewline - 1 };
31
+ }
32
+
33
+ /** Return a UTF-16 offset for an LSP position in TEXT. */
34
+ function offsetAt(text, position) {
35
+ const lines = text.split("\n");
36
+ if (position.line < 0 || position.line >= lines.length) return -1;
37
+ let offset = 0;
38
+ for (let i = 0; i < position.line; i++) offset += lines[i].length + 1;
39
+ return offset + Math.min(position.character, lines[position.line].length);
40
+ }
41
+
42
+ function location(file, text, start, end) {
43
+ return {
44
+ uri: pathToFileURL(file).href,
45
+ range: { start: positionAt(text, start), end: positionAt(text, end) },
46
+ };
47
+ }
48
+
49
+ function noteName(record) {
50
+ return typeof record.name === "string" ? record.name : (record.name?.full ?? record.shortcode);
51
+ }
52
+
53
+ /** An index read from the package's configured content tree. */
54
+ export class ContentWorkspace {
55
+ constructor(config = loadPackConfig(), { cacheBase, onStatus = () => {} } = {}) {
56
+ this.config = config;
57
+ this.contentRoot = config.paths.content;
58
+ this.cacheDirectory = languageIndexDirectory(config, cacheBase);
59
+ this.indexFile = path.join(this.cacheDirectory, "metadata.jsonl");
60
+ this.indexState = "";
61
+ this.started = false;
62
+ this.rebuildTimer = null;
63
+ this.onStatus = onStatus;
64
+ this.records = [];
65
+ this.byAddress = new Map();
66
+ this.byFile = new Map();
67
+ this.types = new Set(Object.keys(NOTE_VOCABULARY));
68
+ this.documents = new Map();
69
+ }
70
+
71
+ refresh() {
72
+ let stat;
73
+ try {
74
+ stat = fs.statSync(path.join(this.cacheDirectory, "metadata.json"));
75
+ } catch {
76
+ return false;
77
+ }
78
+ const state = `${stat.mtimeMs}:${stat.size}`;
79
+ if (state === this.indexState) return true;
80
+ const records = readLanguageIndex(this.cacheDirectory, this.config.contentPackage);
81
+ this.loadRecords(records);
82
+ this.indexState = state;
83
+ return true;
84
+ }
85
+
86
+ loadRecords(records) {
87
+ const byAddress = new Map();
88
+ const byFile = new Map();
89
+ const types = new Set(Object.keys(NOTE_VOCABULARY));
90
+ for (const record of records) {
91
+ if (record.type) types.add(record.type);
92
+ if (record.address?.canonical)
93
+ byAddress.set(record.address.canonical.toLowerCase(), record);
94
+ if (record.file?.path) {
95
+ const file = noteFile(this.contentRoot, record);
96
+ if (!byFile.has(file)) byFile.set(file, record);
97
+ }
98
+ }
99
+ this.records = records;
100
+ this.byAddress = byAddress;
101
+ this.byFile = byFile;
102
+ this.types = types;
103
+ }
104
+
105
+ rebuild() {
106
+ try {
107
+ const records = rebuildLanguageIndex(this.config, this.cacheDirectory);
108
+ this.loadRecords(records);
109
+ const stat = fs.statSync(path.join(this.cacheDirectory, "metadata.json"));
110
+ this.indexState = `${stat.mtimeMs}:${stat.size}`;
111
+ this.onStatus(null);
112
+ return true;
113
+ } catch (error) {
114
+ let stale = this.records.length > 0;
115
+ if (!stale) {
116
+ try {
117
+ stale = this.refresh();
118
+ } catch {
119
+ stale = false;
120
+ }
121
+ }
122
+ this.onStatus(
123
+ `Editor index rebuild failed: ${error.message}. ${stale ? "Results use the last complete snapshot." : "No index is available."}`,
124
+ );
125
+ return false;
126
+ }
127
+ }
128
+
129
+ start() {
130
+ if (this.started) return;
131
+ this.started = true;
132
+ this.rebuild();
133
+ }
134
+
135
+ scheduleRebuild() {
136
+ if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
137
+ this.rebuildTimer = setTimeout(() => {
138
+ this.rebuildTimer = null;
139
+ this.rebuild();
140
+ }, 300);
141
+ }
142
+
143
+ close() {
144
+ if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
145
+ this.rebuildTimer = null;
146
+ }
147
+
148
+ requireIndex() {
149
+ if (!this.started) this.start();
150
+ try {
151
+ this.refresh();
152
+ } catch (error) {
153
+ this.onStatus(
154
+ `Editor index could not be read: ${error.message}. Results use the last complete snapshot.`,
155
+ );
156
+ }
157
+ if (this.records.length === 0)
158
+ throw new Error(`No editor index at ${this.indexFile}; save a note to retry`);
159
+ }
160
+
161
+ fileFor(record) {
162
+ if (record.file?.path) return noteFile(this.contentRoot, record);
163
+ if (record.asset?.path)
164
+ return path.join(this.config.paths.assets, ...record.asset.path.split("/"));
165
+ return null;
166
+ }
167
+
168
+ text(uri) {
169
+ if (this.documents.has(uri)) return this.documents.get(uri);
170
+ try {
171
+ return fs.readFileSync(fileURLToPath(uri), "utf8");
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ /** Resolve a written Address using the same tuple grammar as the build. */
178
+ resolve(value, defaults = {}) {
179
+ const tuple = parseAddress(value, {
180
+ package: this.config.contentPackage,
181
+ system: "none",
182
+ types: this.types,
183
+ packages: new Set([this.config.contentPackage]),
184
+ ...defaults,
185
+ });
186
+ if (tuple.reason) return null;
187
+ return this.byAddress.get(renderAddress(tuple)) ?? null;
188
+ }
189
+
190
+ /** Link and declared frontmatter targets, with exact source ranges. */
191
+ referencesInText(text, file) {
192
+ const found = [];
193
+ const bodyStart = text.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/)?.[0].length ?? 0;
194
+ const linkText = text.slice(bodyStart);
195
+ for (const match of matchAllOutsideCode(linkText, new RegExp(WIKILINK.source, "g"))) {
196
+ const parsed = parseWikilink(match[1]);
197
+ const record = parsed.target ? this.resolve(parsed.target) : this.byFile.get(file);
198
+ if (!record) continue;
199
+ const start = bodyStart + match.index + (parsed.target ? 2 : 3);
200
+ found.push({
201
+ record,
202
+ anchor: parsed.anchor,
203
+ location: location(
204
+ file,
205
+ text,
206
+ start,
207
+ start + (parsed.target || parsed.anchor).length,
208
+ ),
209
+ });
210
+ }
211
+ for (const embed of embedsIn(linkText)) {
212
+ const record = this.resolve(embed.written, { type: EMBED_DEFAULT_TYPE });
213
+ if (!record) continue;
214
+ const start = bodyStart + embed.index + 3;
215
+ found.push({
216
+ record,
217
+ anchor: "",
218
+ location: location(file, text, start, start + embed.written.length),
219
+ });
220
+ }
221
+ if (!bodyStart) return found;
222
+ const yamlStart = text.indexOf("\n") + 1;
223
+ const yamlText = text.slice(yamlStart, bodyStart).replace(/\r?\n---(?:\r?\n)?$/, "");
224
+ let document;
225
+ try {
226
+ document = YAML.parseDocument(yamlText);
227
+ } catch {
228
+ return found;
229
+ }
230
+ if (document.errors.length) return found;
231
+ const frontmatter = document.toJS();
232
+ if (!frontmatter || typeof frontmatter !== "object") return found;
233
+ const visit = (node, segments, position) => {
234
+ if (!node) return;
235
+ if (segments.length) {
236
+ const [head, ...tail] = segments;
237
+ if (YAML.isMap(node)) {
238
+ for (const pair of node.items) {
239
+ if (head === "*" || String(pair.key?.value) === String(head))
240
+ visit(pair.value, tail, position);
241
+ }
242
+ } else if (YAML.isSeq(node)) {
243
+ node.items.forEach((child, index) => {
244
+ if (head === "*" || String(index) === String(head))
245
+ visit(child, tail, position);
246
+ });
247
+ }
248
+ return;
249
+ }
250
+ const scalar = (part, value) => {
251
+ if (!YAML.isScalar(part) || typeof value !== "string" || !part.range) return;
252
+ const record = this.resolve(value, {
253
+ system: position.system ?? "none",
254
+ type: position.type,
255
+ });
256
+ if (!record) return;
257
+ const start = yamlStart + part.range[0];
258
+ found.push({
259
+ record,
260
+ anchor: "",
261
+ location: location(file, text, start, yamlStart + part.range[1]),
262
+ });
263
+ };
264
+ if (position.shape === "keys" && YAML.isMap(node)) {
265
+ for (const pair of node.items) scalar(pair.key, pair.key?.value);
266
+ } else if (position.shape === "list" && YAML.isSeq(node)) {
267
+ for (const child of node.items) scalar(child, child?.value);
268
+ } else if (position.shape === "scalar-or-map" && YAML.isMap(node)) {
269
+ for (const pair of node.items) scalar(pair.value, pair.value?.value);
270
+ } else {
271
+ scalar(node, node.value);
272
+ }
273
+ };
274
+ for (const position of addressPositions(frontmatter))
275
+ visit(document.contents, position.path, position);
276
+ return found;
277
+ }
278
+
279
+ targetAt(uri, position) {
280
+ const text = this.text(uri);
281
+ if (text == null) return null;
282
+ const offset = offsetAt(text, position);
283
+ if (offset < 0) return null;
284
+ 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 };
291
+ const own = this.byFile.get(file);
292
+ 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./]+$/);
295
+ 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;
299
+ }
300
+
301
+ definition(uri, position) {
302
+ this.requireIndex();
303
+ const target = this.targetAt(uri, position);
304
+ 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);
318
+ }
319
+
320
+ symbols(query) {
321
+ this.requireIndex();
322
+ const tag = /^tag:(.*)$/i.exec(query);
323
+ const needle = (tag ? tag[1] : query).trim().toLowerCase();
324
+ const found = new Map();
325
+ for (const record of this.records) {
326
+ if (!record.file?.path) continue;
327
+ 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
+ ];
339
+ const match = values.find(
340
+ (value) => typeof value === "string" && value.toLowerCase().includes(needle),
341
+ );
342
+ if (!match) continue;
343
+ const file = noteFile(this.contentRoot, record);
344
+ if (found.has(file)) continue;
345
+ found.set(file, {
346
+ name: String(noteName(record)),
347
+ kind: 1,
348
+ containerName: `${record.address?.slug ?? [record.type, record.shortcode].filter(Boolean).join(" ")} · ${tag ? `tag: ${match}` : match}`,
349
+ location: { uri: pathToFileURL(file).href, range: EMPTY_RANGE },
350
+ });
351
+ }
352
+ return [...found.values()];
353
+ }
354
+
355
+ references(uri, position) {
356
+ this.requireIndex();
357
+ const target = this.targetAt(uri, position)?.record;
358
+ if (!target?.address?.canonical) return [];
359
+ const locations = [];
360
+ const needle = target.shortcode.toLowerCase();
361
+ const visit = (directory) => {
362
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
363
+ if (entry.name.startsWith(".")) continue;
364
+ const file = path.join(directory, entry.name);
365
+ if (entry.isDirectory()) {
366
+ visit(file);
367
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
368
+ 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)
373
+ locations.push(reference.location);
374
+ }
375
+ }
376
+ }
377
+ };
378
+ visit(this.contentRoot);
379
+ return locations;
380
+ }
381
+ }
382
+
383
+ /** Process one JSON-RPC request without writing protocol bytes. */
384
+ export function respond(workspace, message) {
385
+ const { method, params = {} } = message;
386
+ switch (method) {
387
+ case "initialize":
388
+ workspace.start();
389
+ return {
390
+ capabilities: {
391
+ positionEncoding: "utf-16",
392
+ textDocumentSync: { openClose: true, change: 2, save: true },
393
+ definitionProvider: true,
394
+ referencesProvider: true,
395
+ workspaceSymbolProvider: true,
396
+ workspace: {
397
+ fileOperations: {
398
+ didCreate: [{ scheme: "file", pattern: { glob: "**/*.md" } }],
399
+ didRename: [{ scheme: "file", pattern: { glob: "**/*.md" } }],
400
+ didDelete: [{ scheme: "file", pattern: { glob: "**/*.md" } }],
401
+ },
402
+ },
403
+ },
404
+ serverInfo: { name: "heroiclands-content" },
405
+ };
406
+ case "shutdown":
407
+ return null;
408
+ case "textDocument/didOpen":
409
+ workspace.documents.set(params.textDocument.uri, params.textDocument.text);
410
+ return undefined;
411
+ case "textDocument/didChange": {
412
+ const uri = params.textDocument.uri;
413
+ let text = workspace.text(uri) ?? "";
414
+ for (const change of params.contentChanges ?? []) {
415
+ if (!change.range) text = change.text;
416
+ else {
417
+ const start = offsetAt(text, change.range.start);
418
+ const end = offsetAt(text, change.range.end);
419
+ text = text.slice(0, start) + change.text + text.slice(end);
420
+ }
421
+ }
422
+ workspace.documents.set(uri, text);
423
+ return undefined;
424
+ }
425
+ case "textDocument/didClose":
426
+ workspace.documents.delete(params.textDocument.uri);
427
+ return undefined;
428
+ case "textDocument/didSave":
429
+ case "workspace/didChangeWatchedFiles":
430
+ case "workspace/didCreateFiles":
431
+ case "workspace/didRenameFiles":
432
+ case "workspace/didDeleteFiles":
433
+ workspace.scheduleRebuild();
434
+ return undefined;
435
+ case "textDocument/definition":
436
+ return workspace.definition(params.textDocument.uri, params.position);
437
+ case "textDocument/references":
438
+ return workspace.references(params.textDocument.uri, params.position);
439
+ case "workspace/symbol":
440
+ return workspace.symbols(params.query ?? "");
441
+ default:
442
+ return undefined;
443
+ }
444
+ }
445
+
446
+ /** Run the stdio language server. */
447
+ export function runLanguageServer(
448
+ input = process.stdin,
449
+ output = process.stdout,
450
+ workspace = new ContentWorkspace(),
451
+ ) {
452
+ let pending = Buffer.alloc(0);
453
+ const send = (message) => {
454
+ const body = Buffer.from(JSON.stringify(message));
455
+ output.write(`Content-Length: ${body.length}\r\n\r\n`);
456
+ output.write(body);
457
+ };
458
+ workspace.onStatus = (status) => {
459
+ if (status)
460
+ send({
461
+ jsonrpc: "2.0",
462
+ method: "window/showMessage",
463
+ params: { type: 1, message: status },
464
+ });
465
+ };
466
+ input.on("data", (chunk) => {
467
+ pending = Buffer.concat([pending, chunk]);
468
+ while (true) {
469
+ const separator = pending.indexOf("\r\n\r\n");
470
+ if (separator < 0) break;
471
+ const header = pending.subarray(0, separator).toString("ascii");
472
+ const length = /^content-length:\s*(\d+)\s*$/im.exec(header)?.[1];
473
+ if (!length) throw new Error("LSP message has no Content-Length");
474
+ const end = separator + 4 + Number(length);
475
+ if (pending.length < end) break;
476
+ const body = pending.subarray(separator + 4, end);
477
+ pending = pending.subarray(end);
478
+ let message;
479
+ try {
480
+ message = JSON.parse(body.toString("utf8"));
481
+ } catch (error) {
482
+ send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: String(error) } });
483
+ continue;
484
+ }
485
+ if (message.method === "exit") {
486
+ process.exitCode = 0;
487
+ workspace.close();
488
+ input.pause();
489
+ return;
490
+ }
491
+ try {
492
+ const result = respond(workspace, message);
493
+ if (message.id !== undefined)
494
+ send(
495
+ result === undefined ?
496
+ {
497
+ jsonrpc: "2.0",
498
+ id: message.id,
499
+ error: { code: -32601, message: "Method not found" },
500
+ }
501
+ : { jsonrpc: "2.0", id: message.id, result },
502
+ );
503
+ } catch (error) {
504
+ if (message.id !== undefined)
505
+ send({
506
+ jsonrpc: "2.0",
507
+ id: message.id,
508
+ error: { code: -32603, message: String(error) },
509
+ });
510
+ }
511
+ }
512
+ });
513
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@heroiclands/content-language-server",
3
+ "version": "0.1.0",
4
+ "description": "Language server for HeroicLands content projects",
5
+ "license": "GPL-3.0-or-later",
6
+ "type": "module",
7
+ "bin": {
8
+ "heroiclands-content-language-server": "./bin/heroiclands-content-language-server.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "engine",
13
+ "docs",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "test": "vitest run",
19
+ "format": "prettier --write .",
20
+ "format:check": "prettier --check .",
21
+ "changeset:version": "changeset version && npm install --package-lock-only"
22
+ },
23
+ "dependencies": {
24
+ "@heroiclands/package-build": "22.12.3",
25
+ "yaml": "^2.8.1"
26
+ },
27
+ "devDependencies": {
28
+ "@changesets/cli": "^3.0.0",
29
+ "prettier": "^3.9.6",
30
+ "vitest": "^5.0.0"
31
+ },
32
+ "engines": {
33
+ "node": ">=24.0.0"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public",
37
+ "provenance": true
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/HeroicLands/content-language-server.git"
42
+ }
43
+ }