@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 +7 -1
- package/docs/content-language-server.md +41 -8
- package/engine/content-language-server.mjs +508 -41
- package/package.json +2 -2
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
|
|
21
|
-
|
|
|
22
|
-
| `textDocument/definition`
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
{
|
|
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 =
|
|
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
|
-
|
|
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: "
|
|
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(
|
|
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 ?? "
|
|
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
|
|
505
|
-
|
|
506
|
-
return this.indexedWorkspaces(includeForeign).flatMap(
|
|
507
|
-
project.symbolMatches(
|
|
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(
|
|
512
|
-
const
|
|
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
|
-
|
|
520
|
-
:
|
|
521
|
-
|
|
522
|
-
|
|
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) =>
|
|
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.
|
|
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.
|
|
24
|
+
"@heroiclands/package-build": "22.14.4",
|
|
25
25
|
"yaml": "^2.8.1"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|