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