@heroiclands/content-language-server 0.1.0 → 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 +9 -1
- package/docs/content-language-server.md +57 -8
- package/engine/content-language-server.mjs +772 -77
- 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
|
|
|
@@ -8,4 +8,12 @@ Install an exact released package version in a directory managed by your editor
|
|
|
8
8
|
|
|
9
9
|
See the [language server guide](docs/content-language-server.md) for LSP requests, cache locations, error handling, and manual recovery commands.
|
|
10
10
|
|
|
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
|
+
|
|
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
|
+
|
|
11
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,63 @@ 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
|
-
Reference search
|
|
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.
|
|
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
|
+
|
|
48
|
+
## Foreign content projects
|
|
49
|
+
|
|
50
|
+
An LSP client selects foreign project roots through `initialize`:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"initializationOptions": {
|
|
55
|
+
"foreignRoots": ["/absolute/path/to/another/content/project"]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
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`.
|
|
61
|
+
|
|
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.
|
|
27
76
|
|
|
28
77
|
## Editor integration
|
|
29
78
|
|
|
30
|
-
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, 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.
|
|
@@ -4,23 +4,50 @@
|
|
|
4
4
|
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
7
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
9
|
import YAML from "yaml";
|
|
9
10
|
import { parseAddress, renderAddress } from "@heroiclands/package-build/engine/address";
|
|
10
11
|
import { addressPositions } from "@heroiclands/package-build/engine/note-addresses";
|
|
11
|
-
import {
|
|
12
|
-
|
|
12
|
+
import {
|
|
13
|
+
linkFindingMessage,
|
|
14
|
+
parseWikilink,
|
|
15
|
+
WIKILINK,
|
|
16
|
+
} from "@heroiclands/package-build/engine/wikilink-syntax";
|
|
17
|
+
import {
|
|
18
|
+
CONFIG_FILENAMES,
|
|
19
|
+
configFromData,
|
|
20
|
+
loadPackConfig,
|
|
21
|
+
} from "@heroiclands/package-build/engine/pack-config";
|
|
13
22
|
import { noteFile } from "@heroiclands/package-build/engine/index-records";
|
|
23
|
+
import { asciiName } from "@heroiclands/package-build/engine/content-index";
|
|
14
24
|
import { NOTE_VOCABULARY } from "@heroiclands/package-build/engine/note-vocabulary";
|
|
15
25
|
import {
|
|
16
26
|
languageIndexDirectory,
|
|
17
27
|
readLanguageIndex,
|
|
18
28
|
rebuildLanguageIndex,
|
|
19
29
|
} from "./content-language-index.mjs";
|
|
20
|
-
import { matchAllOutsideCode } from "@heroiclands/package-build/engine/code-fences";
|
|
30
|
+
import { codeRegions, matchAllOutsideCode } from "@heroiclands/package-build/engine/code-fences";
|
|
21
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";
|
|
22
33
|
|
|
23
34
|
const EMPTY_RANGE = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
|
|
37
|
+
/** Load an explicitly selected project's configuration with this server's toolchain. */
|
|
38
|
+
function loadForeignConfig(root) {
|
|
39
|
+
const files = CONFIG_FILENAMES.map((name) => path.join(root, name)).filter((file) =>
|
|
40
|
+
fs.existsSync(file),
|
|
41
|
+
);
|
|
42
|
+
if (files.length !== 1)
|
|
43
|
+
throw new Error(root + " must contain exactly one package-build configuration");
|
|
44
|
+
const file = files[0];
|
|
45
|
+
if (file.endsWith(".mjs")) {
|
|
46
|
+
const module = require(file);
|
|
47
|
+
return module.default ?? module;
|
|
48
|
+
}
|
|
49
|
+
return configFromData(YAML.parse(fs.readFileSync(file, "utf8")), file);
|
|
50
|
+
}
|
|
24
51
|
|
|
25
52
|
/** Return an LSP position for a UTF-16 offset in TEXT. */
|
|
26
53
|
function positionAt(text, offset) {
|
|
@@ -50,10 +77,81 @@ function noteName(record) {
|
|
|
50
77
|
return typeof record.name === "string" ? record.name : (record.name?.full ?? record.shortcode);
|
|
51
78
|
}
|
|
52
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
|
+
|
|
53
140
|
/** An index read from the package's configured content tree. */
|
|
54
141
|
export class ContentWorkspace {
|
|
55
|
-
constructor(
|
|
142
|
+
constructor(
|
|
143
|
+
config = loadPackConfig(),
|
|
144
|
+
{
|
|
145
|
+
cacheBase,
|
|
146
|
+
onStatus = () => {},
|
|
147
|
+
onDiagnostics = () => {},
|
|
148
|
+
onIndexChanged,
|
|
149
|
+
loadProjectConfig = loadForeignConfig,
|
|
150
|
+
} = {},
|
|
151
|
+
) {
|
|
56
152
|
this.config = config;
|
|
153
|
+
this.cacheBase = cacheBase;
|
|
154
|
+
this.loadProjectConfig = loadProjectConfig;
|
|
57
155
|
this.contentRoot = config.paths.content;
|
|
58
156
|
this.cacheDirectory = languageIndexDirectory(config, cacheBase);
|
|
59
157
|
this.indexFile = path.join(this.cacheDirectory, "metadata.jsonl");
|
|
@@ -61,11 +159,142 @@ export class ContentWorkspace {
|
|
|
61
159
|
this.started = false;
|
|
62
160
|
this.rebuildTimer = null;
|
|
63
161
|
this.onStatus = onStatus;
|
|
162
|
+
this.onDiagnostics = onDiagnostics;
|
|
163
|
+
this.onIndexChanged = onIndexChanged ?? (() => this.publishOpenDiagnostics());
|
|
164
|
+
this.diagnosticTimers = new Map();
|
|
64
165
|
this.records = [];
|
|
65
166
|
this.byAddress = new Map();
|
|
66
167
|
this.byFile = new Map();
|
|
168
|
+
this.searchKeys = new Map();
|
|
67
169
|
this.types = new Set(Object.keys(NOTE_VOCABULARY));
|
|
68
170
|
this.documents = new Map();
|
|
171
|
+
this.foreignRoots = [];
|
|
172
|
+
this.foreign = new Map();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Select foreign roots explicitly; a cache directory cannot add a project. */
|
|
176
|
+
configureForeignRoots(roots = []) {
|
|
177
|
+
if (!Array.isArray(roots) || roots.some((root) => typeof root !== "string"))
|
|
178
|
+
throw new Error("initializationOptions.foreignRoots must be an array of paths");
|
|
179
|
+
const ownRoot = fs.realpathSync(this.config.rootDir);
|
|
180
|
+
this.foreignRoots = [
|
|
181
|
+
...new Set(
|
|
182
|
+
roots.map((root) => {
|
|
183
|
+
const absolute = path.resolve(root);
|
|
184
|
+
return fs.existsSync(absolute) ? fs.realpathSync(absolute) : absolute;
|
|
185
|
+
}),
|
|
186
|
+
),
|
|
187
|
+
].filter((root) => root !== ownRoot);
|
|
188
|
+
for (const [root, project] of this.foreign)
|
|
189
|
+
if (!this.foreignRoots.includes(root)) {
|
|
190
|
+
project.close();
|
|
191
|
+
this.foreign.delete(root);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
foreignWorkspace(root) {
|
|
196
|
+
if (this.foreign.has(root)) return this.foreign.get(root);
|
|
197
|
+
const config = this.loadProjectConfig(root);
|
|
198
|
+
const project = new ContentWorkspace(config, {
|
|
199
|
+
cacheBase: this.cacheBase,
|
|
200
|
+
onStatus: (message) => {
|
|
201
|
+
if (message) this.onStatus(config.contentPackage + ": " + message);
|
|
202
|
+
},
|
|
203
|
+
loadProjectConfig: this.loadProjectConfig,
|
|
204
|
+
onIndexChanged: () => this.publishOpenDiagnostics(),
|
|
205
|
+
});
|
|
206
|
+
project.documents = this.documents;
|
|
207
|
+
this.foreign.set(root, project);
|
|
208
|
+
return project;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Return usable indexes, reporting one failed root without hiding the others. */
|
|
212
|
+
indexedWorkspaces(includeForeign = false, selectedPackage = null) {
|
|
213
|
+
this.requireIndex();
|
|
214
|
+
const projects =
|
|
215
|
+
selectedPackage && this.config.contentPackage.toLowerCase() !== selectedPackage ?
|
|
216
|
+
[]
|
|
217
|
+
: [this];
|
|
218
|
+
if (includeForeign)
|
|
219
|
+
for (const root of this.foreignRoots) {
|
|
220
|
+
try {
|
|
221
|
+
const project = this.foreignWorkspace(root);
|
|
222
|
+
if (
|
|
223
|
+
selectedPackage &&
|
|
224
|
+
project.config.contentPackage.toLowerCase() !== selectedPackage
|
|
225
|
+
)
|
|
226
|
+
continue;
|
|
227
|
+
project.start(true);
|
|
228
|
+
project.requireIndex();
|
|
229
|
+
projects.push(project);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
this.onStatus("Foreign content project " + root + ": " + error.message);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return projects;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Find every indexed owner of a written Address for navigation. */
|
|
238
|
+
resolveCandidates(value, defaults = {}, projects = [this]) {
|
|
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 [];
|
|
248
|
+
const found = [];
|
|
249
|
+
for (const project of projects) {
|
|
250
|
+
if (project.config.contentPackage !== tuple.package) continue;
|
|
251
|
+
const record = project.byAddress.get(renderAddress(tuple));
|
|
252
|
+
if (record) found.push({ record, project });
|
|
253
|
+
}
|
|
254
|
+
return found;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Locate the workspace that owns an open source document. */
|
|
258
|
+
sourceWorkspace(uri, projects) {
|
|
259
|
+
const file = fileURLToPath(uri);
|
|
260
|
+
return (
|
|
261
|
+
projects.find((project) => {
|
|
262
|
+
const relative = path.relative(project.contentRoot, file);
|
|
263
|
+
return (
|
|
264
|
+
relative &&
|
|
265
|
+
!path.isAbsolute(relative) &&
|
|
266
|
+
relative !== ".." &&
|
|
267
|
+
!relative.startsWith(".." + path.sep)
|
|
268
|
+
);
|
|
269
|
+
}) ?? this
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
scheduleRebuildForUri(uri) {
|
|
274
|
+
const file = fileURLToPath(uri);
|
|
275
|
+
const inside = (directory) => {
|
|
276
|
+
const relative = path.relative(directory, file);
|
|
277
|
+
return (
|
|
278
|
+
relative &&
|
|
279
|
+
!path.isAbsolute(relative) &&
|
|
280
|
+
relative !== ".." &&
|
|
281
|
+
!relative.startsWith(".." + path.sep)
|
|
282
|
+
);
|
|
283
|
+
};
|
|
284
|
+
if (inside(this.contentRoot) || inside(this.config.paths.assets)) {
|
|
285
|
+
this.scheduleRebuild();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
for (const root of this.foreignRoots) {
|
|
289
|
+
if (!inside(root)) continue;
|
|
290
|
+
try {
|
|
291
|
+
const project = this.foreignWorkspace(root);
|
|
292
|
+
if (inside(project.contentRoot) || inside(project.config.paths.assets))
|
|
293
|
+
project.scheduleRebuild();
|
|
294
|
+
} catch (error) {
|
|
295
|
+
this.onStatus("Foreign content project " + root + ": " + error.message);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
69
298
|
}
|
|
70
299
|
|
|
71
300
|
refresh() {
|
|
@@ -80,12 +309,14 @@ export class ContentWorkspace {
|
|
|
80
309
|
const records = readLanguageIndex(this.cacheDirectory, this.config.contentPackage);
|
|
81
310
|
this.loadRecords(records);
|
|
82
311
|
this.indexState = state;
|
|
312
|
+
this.onIndexChanged();
|
|
83
313
|
return true;
|
|
84
314
|
}
|
|
85
315
|
|
|
86
316
|
loadRecords(records) {
|
|
87
317
|
const byAddress = new Map();
|
|
88
318
|
const byFile = new Map();
|
|
319
|
+
const searchKeys = new Map();
|
|
89
320
|
const types = new Set(Object.keys(NOTE_VOCABULARY));
|
|
90
321
|
for (const record of records) {
|
|
91
322
|
if (record.type) types.add(record.type);
|
|
@@ -95,10 +326,26 @@ export class ContentWorkspace {
|
|
|
95
326
|
const file = noteFile(this.contentRoot, record);
|
|
96
327
|
if (!byFile.has(file)) byFile.set(file, record);
|
|
97
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
|
+
]);
|
|
98
344
|
}
|
|
99
345
|
this.records = records;
|
|
100
346
|
this.byAddress = byAddress;
|
|
101
347
|
this.byFile = byFile;
|
|
348
|
+
this.searchKeys = searchKeys;
|
|
102
349
|
this.types = types;
|
|
103
350
|
}
|
|
104
351
|
|
|
@@ -109,6 +356,7 @@ export class ContentWorkspace {
|
|
|
109
356
|
const stat = fs.statSync(path.join(this.cacheDirectory, "metadata.json"));
|
|
110
357
|
this.indexState = `${stat.mtimeMs}:${stat.size}`;
|
|
111
358
|
this.onStatus(null);
|
|
359
|
+
this.onIndexChanged();
|
|
112
360
|
return true;
|
|
113
361
|
} catch (error) {
|
|
114
362
|
let stale = this.records.length > 0;
|
|
@@ -126,9 +374,15 @@ export class ContentWorkspace {
|
|
|
126
374
|
}
|
|
127
375
|
}
|
|
128
376
|
|
|
129
|
-
start() {
|
|
377
|
+
start(acceptCompleteCache = false) {
|
|
130
378
|
if (this.started) return;
|
|
131
379
|
this.started = true;
|
|
380
|
+
if (acceptCompleteCache)
|
|
381
|
+
try {
|
|
382
|
+
if (this.refresh()) return;
|
|
383
|
+
} catch {
|
|
384
|
+
// A corrupt or incompatible snapshot is rebuilt from saved notes.
|
|
385
|
+
}
|
|
132
386
|
this.rebuild();
|
|
133
387
|
}
|
|
134
388
|
|
|
@@ -143,6 +397,9 @@ export class ContentWorkspace {
|
|
|
143
397
|
close() {
|
|
144
398
|
if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
|
|
145
399
|
this.rebuildTimer = null;
|
|
400
|
+
for (const timer of this.diagnosticTimers.values()) clearTimeout(timer);
|
|
401
|
+
this.diagnosticTimers.clear();
|
|
402
|
+
for (const project of this.foreign.values()) project.close();
|
|
146
403
|
}
|
|
147
404
|
|
|
148
405
|
requireIndex() {
|
|
@@ -175,31 +432,249 @@ export class ContentWorkspace {
|
|
|
175
432
|
}
|
|
176
433
|
|
|
177
434
|
/** Resolve a written Address using the same tuple grammar as the build. */
|
|
178
|
-
resolve(value, defaults = {}) {
|
|
435
|
+
resolve(value, defaults = {}, projects = [this]) {
|
|
179
436
|
const tuple = parseAddress(value, {
|
|
180
437
|
package: this.config.contentPackage,
|
|
181
|
-
system: "
|
|
182
|
-
types:
|
|
183
|
-
packages: new Set(
|
|
438
|
+
system: "note",
|
|
439
|
+
types: new Set(projects.flatMap((project) => [...project.types])),
|
|
440
|
+
packages: new Set(projects.map((project) => project.config.contentPackage)),
|
|
184
441
|
...defaults,
|
|
185
442
|
});
|
|
186
443
|
if (tuple.reason) return null;
|
|
187
|
-
|
|
444
|
+
for (const project of projects)
|
|
445
|
+
if (project.config.contentPackage === tuple.package) {
|
|
446
|
+
const record = project.byAddress.get(renderAddress(tuple));
|
|
447
|
+
if (record) return record;
|
|
448
|
+
}
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
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));
|
|
188
651
|
}
|
|
189
652
|
|
|
190
653
|
/** Link and declared frontmatter targets, with exact source ranges. */
|
|
191
|
-
referencesInText(
|
|
654
|
+
referencesInText(
|
|
655
|
+
text,
|
|
656
|
+
file,
|
|
657
|
+
projects = [this],
|
|
658
|
+
includeFrontmatter = true,
|
|
659
|
+
includeUnresolved = false,
|
|
660
|
+
) {
|
|
192
661
|
const found = [];
|
|
193
662
|
const bodyStart = text.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/)?.[0].length ?? 0;
|
|
194
663
|
const linkText = text.slice(bodyStart);
|
|
195
664
|
for (const match of matchAllOutsideCode(linkText, new RegExp(WIKILINK.source, "g"))) {
|
|
665
|
+
if (match.index > 0 && linkText[match.index - 1] === "!") continue;
|
|
196
666
|
const parsed = parseWikilink(match[1]);
|
|
197
|
-
const record =
|
|
198
|
-
|
|
667
|
+
const record =
|
|
668
|
+
parsed.target ? this.resolve(parsed.target, {}, projects) : this.byFile.get(file);
|
|
669
|
+
if (!record && !includeUnresolved) continue;
|
|
199
670
|
const start = bodyStart + match.index + (parsed.target ? 2 : 3);
|
|
200
671
|
found.push({
|
|
201
672
|
record,
|
|
202
673
|
anchor: parsed.anchor,
|
|
674
|
+
written: parsed.target,
|
|
675
|
+
defaults: {},
|
|
676
|
+
kind: "link",
|
|
677
|
+
labelled: parsed.labelled,
|
|
203
678
|
location: location(
|
|
204
679
|
file,
|
|
205
680
|
text,
|
|
@@ -209,16 +684,21 @@ export class ContentWorkspace {
|
|
|
209
684
|
});
|
|
210
685
|
}
|
|
211
686
|
for (const embed of embedsIn(linkText)) {
|
|
212
|
-
const
|
|
213
|
-
|
|
687
|
+
const defaults = { system: "none", type: EMBED_DEFAULT_TYPE };
|
|
688
|
+
const record = this.resolve(embed.written, defaults, projects);
|
|
689
|
+
if (!record && !includeUnresolved) continue;
|
|
214
690
|
const start = bodyStart + embed.index + 3;
|
|
215
691
|
found.push({
|
|
216
692
|
record,
|
|
217
693
|
anchor: "",
|
|
694
|
+
written: embed.written,
|
|
695
|
+
defaults,
|
|
696
|
+
kind: "embed",
|
|
697
|
+
labelled: embed.labelled,
|
|
218
698
|
location: location(file, text, start, start + embed.written.length),
|
|
219
699
|
});
|
|
220
700
|
}
|
|
221
|
-
if (!bodyStart) return found;
|
|
701
|
+
if (!bodyStart || !includeFrontmatter) return found;
|
|
222
702
|
const yamlStart = text.indexOf("\n") + 1;
|
|
223
703
|
const yamlText = text.slice(yamlStart, bodyStart).replace(/\r?\n---(?:\r?\n)?$/, "");
|
|
224
704
|
let document;
|
|
@@ -249,15 +729,20 @@ export class ContentWorkspace {
|
|
|
249
729
|
}
|
|
250
730
|
const scalar = (part, value) => {
|
|
251
731
|
if (!YAML.isScalar(part) || typeof value !== "string" || !part.range) return;
|
|
252
|
-
const
|
|
253
|
-
system: position.system ?? "
|
|
732
|
+
const defaults = {
|
|
733
|
+
system: position.system ?? "note",
|
|
254
734
|
type: position.type,
|
|
255
|
-
}
|
|
256
|
-
|
|
735
|
+
};
|
|
736
|
+
const record = this.resolve(value, defaults, projects);
|
|
737
|
+
if (!record && !includeUnresolved) return;
|
|
257
738
|
const start = yamlStart + part.range[0];
|
|
258
739
|
found.push({
|
|
259
740
|
record,
|
|
260
741
|
anchor: "",
|
|
742
|
+
written: value,
|
|
743
|
+
defaults,
|
|
744
|
+
kind: "frontmatter",
|
|
745
|
+
accepts: position.accepts,
|
|
261
746
|
location: location(file, text, start, yamlStart + part.range[1]),
|
|
262
747
|
});
|
|
263
748
|
};
|
|
@@ -271,73 +756,224 @@ export class ContentWorkspace {
|
|
|
271
756
|
scalar(node, node.value);
|
|
272
757
|
}
|
|
273
758
|
};
|
|
274
|
-
for (const position of addressPositions(frontmatter))
|
|
759
|
+
for (const position of addressPositions(frontmatter, this.config))
|
|
275
760
|
visit(document.contents, position.path, position);
|
|
276
761
|
return found;
|
|
277
762
|
}
|
|
278
763
|
|
|
279
|
-
|
|
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
|
+
|
|
886
|
+
targetAt(uri, position, projects = [this]) {
|
|
280
887
|
const text = this.text(uri);
|
|
281
888
|
if (text == null) return null;
|
|
282
889
|
const offset = offsetAt(text, position);
|
|
283
890
|
if (offset < 0) return null;
|
|
284
891
|
const file = fileURLToPath(uri);
|
|
285
|
-
const reference = this.referencesInText(text, file).find(
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
892
|
+
const reference = this.referencesInText(text, file, projects).find(
|
|
893
|
+
({ location: source }) => {
|
|
894
|
+
const start = offsetAt(text, source.range.start);
|
|
895
|
+
const end = offsetAt(text, source.range.end);
|
|
896
|
+
return start <= offset && offset <= end;
|
|
897
|
+
},
|
|
898
|
+
);
|
|
899
|
+
if (reference) return reference;
|
|
291
900
|
const own = this.byFile.get(file);
|
|
292
901
|
if (own && /^shortcode:\s*/.test(text.split("\n")[position.line] ?? ""))
|
|
293
|
-
return { record: own, anchor: "" };
|
|
294
|
-
const start = text.slice(0, offset).search(/[A-Za-z0-9
|
|
902
|
+
return { record: own, anchor: "", written: own.address?.canonical, defaults: {} };
|
|
903
|
+
const start = text.slice(0, offset).search(/[A-Za-z0-9./-]+$/);
|
|
295
904
|
if (start < 0) return null;
|
|
296
|
-
const end = offset + (text.slice(offset).match(/^[A-Za-z0-9
|
|
297
|
-
const
|
|
298
|
-
|
|
905
|
+
const end = offset + (text.slice(offset).match(/^[A-Za-z0-9./-]+/)?.[0].length ?? 0);
|
|
906
|
+
const written = text.slice(start, end);
|
|
907
|
+
const record = this.resolve(written, {}, projects);
|
|
908
|
+
return { record, anchor: "", written, defaults: {} };
|
|
299
909
|
}
|
|
300
910
|
|
|
301
911
|
definition(uri, position) {
|
|
302
|
-
this.
|
|
303
|
-
const
|
|
912
|
+
const projects = this.indexedWorkspaces(true);
|
|
913
|
+
const source = this.sourceWorkspace(uri, projects);
|
|
914
|
+
const target = source.targetAt(uri, position, projects);
|
|
304
915
|
if (!target) return null;
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
916
|
+
const candidates =
|
|
917
|
+
target.written ?
|
|
918
|
+
source.resolveCandidates(target.written, target.defaults, projects)
|
|
919
|
+
: [{ record: target.record, project: source }];
|
|
920
|
+
const locations = [];
|
|
921
|
+
for (const { record, project } of candidates) {
|
|
922
|
+
const file = project.fileFor(record);
|
|
923
|
+
if (!file) continue;
|
|
924
|
+
if (record.asset) {
|
|
925
|
+
locations.push({ uri: pathToFileURL(file).href, range: EMPTY_RANGE });
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
const line =
|
|
929
|
+
target.anchor ?
|
|
930
|
+
record.anchors?.find(
|
|
931
|
+
(entry) => entry.slug.toLowerCase() === target.anchor.toLowerCase(),
|
|
932
|
+
)?.line
|
|
933
|
+
: null;
|
|
934
|
+
if (target.anchor && !line) continue;
|
|
935
|
+
const targetText = fs.readFileSync(file, "utf8");
|
|
936
|
+
const offset = line ? offsetAt(targetText, { line: line - 1, character: 0 }) : 0;
|
|
937
|
+
locations.push(location(file, targetText, offset, offset));
|
|
938
|
+
}
|
|
939
|
+
return (
|
|
940
|
+
locations.length === 1 ? locations[0]
|
|
941
|
+
: locations.length ? locations
|
|
942
|
+
: null
|
|
943
|
+
);
|
|
318
944
|
}
|
|
319
945
|
|
|
320
946
|
symbols(query) {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
947
|
+
const filter = symbolQuery(query);
|
|
948
|
+
if (!filter) return [];
|
|
949
|
+
return this.indexedWorkspaces(filter.includeForeign, filter.selectedPackage).flatMap(
|
|
950
|
+
(project) => project.symbolMatches(filter),
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
symbolMatches({ field, needle }) {
|
|
955
|
+
const normalized = field === "name" ? searchKey(needle) : needle.toLowerCase();
|
|
324
956
|
const found = new Map();
|
|
325
957
|
for (const record of this.records) {
|
|
326
958
|
if (!record.file?.path) continue;
|
|
959
|
+
const names = [
|
|
960
|
+
record.name?.full,
|
|
961
|
+
record.nameAscii,
|
|
962
|
+
...(record.name?.aliases ?? []),
|
|
963
|
+
...(record.aliasesAscii ?? []),
|
|
964
|
+
];
|
|
327
965
|
const values =
|
|
328
|
-
tag ?
|
|
329
|
-
|
|
330
|
-
:
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
...(record.name?.aliases ?? []),
|
|
334
|
-
...(record.aliasesAscii ?? []),
|
|
335
|
-
record.shortcode,
|
|
336
|
-
record.address?.slug,
|
|
337
|
-
record.address?.canonical,
|
|
338
|
-
];
|
|
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];
|
|
339
971
|
const match = values.find(
|
|
340
|
-
(value) =>
|
|
972
|
+
(value) =>
|
|
973
|
+
typeof value === "string" &&
|
|
974
|
+
(field === "name" ? searchKey(value) : value.toLowerCase()).includes(
|
|
975
|
+
normalized,
|
|
976
|
+
),
|
|
341
977
|
);
|
|
342
978
|
if (!match) continue;
|
|
343
979
|
const file = noteFile(this.contentRoot, record);
|
|
@@ -345,7 +981,7 @@ export class ContentWorkspace {
|
|
|
345
981
|
found.set(file, {
|
|
346
982
|
name: String(noteName(record)),
|
|
347
983
|
kind: 1,
|
|
348
|
-
containerName: `${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}`,
|
|
349
985
|
location: { uri: pathToFileURL(file).href, range: EMPTY_RANGE },
|
|
350
986
|
});
|
|
351
987
|
}
|
|
@@ -353,29 +989,57 @@ export class ContentWorkspace {
|
|
|
353
989
|
}
|
|
354
990
|
|
|
355
991
|
references(uri, position) {
|
|
356
|
-
this.
|
|
357
|
-
const
|
|
358
|
-
|
|
992
|
+
const projects = this.indexedWorkspaces(true);
|
|
993
|
+
const source = this.sourceWorkspace(uri, projects);
|
|
994
|
+
const target = source.targetAt(uri, position, projects);
|
|
995
|
+
if (!target) return [];
|
|
996
|
+
const candidates =
|
|
997
|
+
target.record ?
|
|
998
|
+
[target.record]
|
|
999
|
+
: source
|
|
1000
|
+
.resolveCandidates(target.written, target.defaults, projects)
|
|
1001
|
+
.map(({ record }) => record);
|
|
1002
|
+
const addresses = new Set(
|
|
1003
|
+
candidates.map((record) => record.address?.canonical).filter(Boolean),
|
|
1004
|
+
);
|
|
1005
|
+
if (!addresses.size) return [];
|
|
359
1006
|
const locations = [];
|
|
360
|
-
const
|
|
361
|
-
const visit = (directory) => {
|
|
1007
|
+
const needles = candidates.map((record) => record.shortcode.toLowerCase());
|
|
1008
|
+
const visit = (project, directory, frontmatterCandidates) => {
|
|
362
1009
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
363
1010
|
if (entry.name.startsWith(".")) continue;
|
|
364
1011
|
const file = path.join(directory, entry.name);
|
|
365
1012
|
if (entry.isDirectory()) {
|
|
366
|
-
visit(file);
|
|
1013
|
+
visit(project, file, frontmatterCandidates);
|
|
367
1014
|
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
|
|
368
1015
|
const savedText = fs.readFileSync(file, "utf8");
|
|
369
|
-
if (!savedText.toLowerCase().includes(needle))
|
|
370
|
-
|
|
371
|
-
for (const reference of
|
|
372
|
-
|
|
1016
|
+
if (!needles.some((needle) => savedText.toLowerCase().includes(needle)))
|
|
1017
|
+
continue;
|
|
1018
|
+
for (const reference of project.referencesInText(
|
|
1019
|
+
savedText,
|
|
1020
|
+
file,
|
|
1021
|
+
projects,
|
|
1022
|
+
frontmatterCandidates.has(file),
|
|
1023
|
+
)) {
|
|
1024
|
+
if (addresses.has(reference.record.address?.canonical))
|
|
373
1025
|
locations.push(reference.location);
|
|
374
1026
|
}
|
|
375
1027
|
}
|
|
376
1028
|
}
|
|
377
1029
|
};
|
|
378
|
-
|
|
1030
|
+
for (const project of projects) {
|
|
1031
|
+
const frontmatterCandidates = new Set(
|
|
1032
|
+
project.records
|
|
1033
|
+
.filter((record) =>
|
|
1034
|
+
needles.some((needle) =>
|
|
1035
|
+
JSON.stringify(record).toLowerCase().includes(needle),
|
|
1036
|
+
),
|
|
1037
|
+
)
|
|
1038
|
+
.filter((record) => record.file?.path)
|
|
1039
|
+
.map((record) => noteFile(project.contentRoot, record)),
|
|
1040
|
+
);
|
|
1041
|
+
visit(project, project.contentRoot, frontmatterCandidates);
|
|
1042
|
+
}
|
|
379
1043
|
return locations;
|
|
380
1044
|
}
|
|
381
1045
|
}
|
|
@@ -385,12 +1049,14 @@ export function respond(workspace, message) {
|
|
|
385
1049
|
const { method, params = {} } = message;
|
|
386
1050
|
switch (method) {
|
|
387
1051
|
case "initialize":
|
|
1052
|
+
workspace.configureForeignRoots(params.initializationOptions?.foreignRoots ?? []);
|
|
388
1053
|
workspace.start();
|
|
389
1054
|
return {
|
|
390
1055
|
capabilities: {
|
|
391
1056
|
positionEncoding: "utf-16",
|
|
392
1057
|
textDocumentSync: { openClose: true, change: 2, save: true },
|
|
393
1058
|
definitionProvider: true,
|
|
1059
|
+
completionProvider: { triggerCharacters: ["[", "#", "-"] },
|
|
394
1060
|
referencesProvider: true,
|
|
395
1061
|
workspaceSymbolProvider: true,
|
|
396
1062
|
workspace: {
|
|
@@ -405,8 +1071,14 @@ export function respond(workspace, message) {
|
|
|
405
1071
|
};
|
|
406
1072
|
case "shutdown":
|
|
407
1073
|
return null;
|
|
1074
|
+
case "workspace/didChangeConfiguration":
|
|
1075
|
+
if (params.settings?.heroiclands?.foreignRoots)
|
|
1076
|
+
workspace.configureForeignRoots(params.settings.heroiclands.foreignRoots);
|
|
1077
|
+
workspace.publishOpenDiagnostics();
|
|
1078
|
+
return undefined;
|
|
408
1079
|
case "textDocument/didOpen":
|
|
409
1080
|
workspace.documents.set(params.textDocument.uri, params.textDocument.text);
|
|
1081
|
+
workspace.scheduleDiagnostics(params.textDocument.uri);
|
|
410
1082
|
return undefined;
|
|
411
1083
|
case "textDocument/didChange": {
|
|
412
1084
|
const uri = params.textDocument.uri;
|
|
@@ -420,20 +1092,37 @@ export function respond(workspace, message) {
|
|
|
420
1092
|
}
|
|
421
1093
|
}
|
|
422
1094
|
workspace.documents.set(uri, text);
|
|
1095
|
+
workspace.scheduleDiagnostics(uri);
|
|
423
1096
|
return undefined;
|
|
424
1097
|
}
|
|
425
1098
|
case "textDocument/didClose":
|
|
426
1099
|
workspace.documents.delete(params.textDocument.uri);
|
|
1100
|
+
workspace.clearDiagnostics(params.textDocument.uri);
|
|
427
1101
|
return undefined;
|
|
428
1102
|
case "textDocument/didSave":
|
|
1103
|
+
workspace.scheduleRebuildForUri(params.textDocument.uri);
|
|
1104
|
+
workspace.scheduleDiagnostics(params.textDocument.uri);
|
|
1105
|
+
return undefined;
|
|
429
1106
|
case "workspace/didChangeWatchedFiles":
|
|
430
1107
|
case "workspace/didCreateFiles":
|
|
431
1108
|
case "workspace/didRenameFiles":
|
|
432
|
-
case "workspace/didDeleteFiles":
|
|
433
|
-
|
|
1109
|
+
case "workspace/didDeleteFiles": {
|
|
1110
|
+
const files = params.changes ?? params.files ?? [];
|
|
1111
|
+
if (!files.length) workspace.scheduleRebuild();
|
|
1112
|
+
for (const item of files)
|
|
1113
|
+
for (const uri of [item.uri, item.oldUri, item.newUri].filter(Boolean))
|
|
1114
|
+
workspace.scheduleRebuildForUri(uri);
|
|
434
1115
|
return undefined;
|
|
1116
|
+
}
|
|
435
1117
|
case "textDocument/definition":
|
|
436
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
|
+
}
|
|
437
1126
|
case "textDocument/references":
|
|
438
1127
|
return workspace.references(params.textDocument.uri, params.position);
|
|
439
1128
|
case "workspace/symbol":
|
|
@@ -463,6 +1152,12 @@ export function runLanguageServer(
|
|
|
463
1152
|
params: { type: 1, message: status },
|
|
464
1153
|
});
|
|
465
1154
|
};
|
|
1155
|
+
workspace.onDiagnostics = (uri, diagnostics) =>
|
|
1156
|
+
send({
|
|
1157
|
+
jsonrpc: "2.0",
|
|
1158
|
+
method: "textDocument/publishDiagnostics",
|
|
1159
|
+
params: { uri, diagnostics },
|
|
1160
|
+
});
|
|
466
1161
|
input.on("data", (chunk) => {
|
|
467
1162
|
pending = Buffer.concat([pending, chunk]);
|
|
468
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": {
|