@staticbolt/lsp 1.0.0-beta.30 → 1.0.0-beta.31

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/lib/index.mjs CHANGED
@@ -1,258 +1,140 @@
1
+ import { existsSync, globSync, watch } from "node:fs";
2
+ import path from "node:path";
1
3
  import { format, stripVTControlCharacters } from "node:util";
2
- import * as vs from "vscode-languageserver/node";
3
- import * as vscode$1 from "vscode-languageserver";
4
- import vscode, { RequestType } from "vscode-languageserver";
5
- import { TextDocument } from "vscode-languageserver-textdocument";
6
- import * as vscodeUri from "vscode-uri";
7
- import { EventEmitter } from "node:events";
8
- import * as fs from "node:fs";
9
- import { globSync, watch } from "node:fs";
10
- import { access, constants } from "node:fs/promises";
11
- import * as path$1 from "node:path";
12
- import path, { join } from "node:path";
13
- import { pathToFileURL } from "node:url";
4
+ import { createConnection, createServer, createTypeScriptProject, loadTsdkByPath } from "@volar/language-server/node.js";
5
+ import { forEachEmbeddedCode } from "@volar/language-core";
6
+ import { Resolver } from "@staticbolt/core";
14
7
  import vscodeHtml from "vscode-html-languageservice";
8
+ import { TextDocument } from "vscode-languageserver-textdocument";
15
9
  import { markdownToMdast } from "satteri";
16
- import { Resolver } from "@staticbolt/core";
10
+ import { pathToFileURL } from "node:url";
11
+ import * as vscodeUri from "vscode-uri";
12
+ import { URI } from "vscode-uri";
13
+ import { CompletionItemKind, DiagnosticSeverity, SemanticTokenModifiers, SemanticTokenTypes, TextEdit } from "vscode-languageserver-protocol";
14
+ import { create } from "volar-service-typescript";
17
15
 
18
- //#region src/helpers/config-loader.ts
19
- /** Iterated in order, so the first one that exists wins. */
20
- const CONFIG_CANDIDATES = /* @__PURE__ */ new Set([".staticbolt.ts", ".staticbolt.js"]);
21
- async function findConfigFile(workspaceRoot) {
22
- for (const name of CONFIG_CANDIDATES) {
23
- const full = join(workspaceRoot, name);
24
- try {
25
- await access(full, constants.R_OK);
26
- return full;
27
- } catch {}
28
- }
29
- return null;
30
- }
31
- async function importFresh(absolutePath) {
32
- const module_ = await import(`${pathToFileURL(absolutePath).href}?t=${Date.now()}`);
33
- if (!("default" in module_)) throw new Error(`Config file "${absolutePath}" has no default export. Use \`export default { … }\`.`);
34
- return module_.default;
35
- }
36
- var ConfigLoader = class extends EventEmitter {
37
- workspaceRoot;
38
- configPath = null;
39
- watcher = null;
40
- current = null;
41
- debounceTimer = null;
42
- DEBOUNCE_MS = 150;
43
- constructor(workspaceRoot) {
44
- super();
45
- this.workspaceRoot = workspaceRoot;
46
- }
47
- /** Start watching. Resolves with the initial config (or null if absent). */
48
- async start() {
49
- this.configPath = await findConfigFile(this.workspaceRoot);
50
- if (!this.configPath) return null;
51
- await this.load();
52
- this.watch();
53
- return this.current;
54
- }
55
- /** Stop watching and release all resources. */
56
- dispose() {
57
- if (this.debounceTimer !== null) clearTimeout(this.debounceTimer);
58
- this.watcher?.close();
59
- this.watcher = null;
60
- }
61
- /** The last successfully loaded config, or null if none loaded yet. */
62
- get config() {
63
- return this.current;
64
- }
65
- async load() {
66
- if (!this.configPath) return;
67
- try {
68
- this.current = await importFresh(this.configPath);
69
- this.emit("change", this.current);
70
- } catch (error) {
71
- this.emit("error", error instanceof Error ? error : new Error(String(error)));
72
- }
73
- }
74
- /**
75
- * The directory is watched rather than the file. A watch on the file itself is bound to the inode, so an editor saving through
76
- * a temporary file and a rename leaves it watching a replaced file, and on macOS it never reports a plain write either.
77
- */
78
- watch() {
79
- this.watcher = watch(this.workspaceRoot, { persistent: false }, (_event, filename) => {
80
- if (typeof filename === "string" && CONFIG_CANDIDATES.has(filename)) this.scheduleReload();
81
- });
82
- this.watcher.on("error", (error) => this.emit("error", error));
83
- }
84
- scheduleReload() {
85
- if (this.debounceTimer !== null) clearTimeout(this.debounceTimer);
86
- this.debounceTimer = setTimeout(async () => {
87
- this.debounceTimer = null;
88
- this.configPath = await findConfigFile(this.workspaceRoot);
89
- if (!this.configPath) {
90
- this.emit("error", /* @__PURE__ */ new Error(`Config file was removed from: ${this.workspaceRoot}`));
91
- return;
92
- }
93
- await this.load();
94
- }, this.DEBOUNCE_MS);
95
- }
96
- };
97
- var ConfigManager = class {
98
- console;
99
- configs = /* @__PURE__ */ new Map();
100
- lspHtmlData = [];
101
- constructor(console) {
102
- this.console = console;
103
- }
104
- async get(workspaceRoot) {
105
- const configLoader = this.configs.get(workspaceRoot);
106
- if (configLoader) {
107
- if (!configLoader.config) {
108
- this.configs.delete(workspaceRoot);
109
- return null;
110
- }
111
- return configLoader.config;
112
- }
113
- const newConfigLoader = new ConfigLoader(workspaceRoot);
114
- this.configs.set(workspaceRoot, newConfigLoader);
115
- newConfigLoader.on("error", (error) => {
116
- this.console.error(`Failed to load config file: ${error.message}`);
117
- });
118
- newConfigLoader.on("change", async (config) => {
119
- await this.collectData(config);
120
- });
121
- await newConfigLoader.start();
122
- if (!newConfigLoader.config) return null;
123
- return newConfigLoader.config;
124
- }
125
- dispose() {
126
- for (const configLoader of this.configs.values()) configLoader.dispose();
127
- }
128
- async collectData(config) {
129
- const collected = [];
130
- const plugins = config.plugins ?? [];
131
- for (const pluginOrArray of plugins) {
132
- const plugins = Array.isArray(pluginOrArray) ? pluginOrArray : [pluginOrArray];
133
- for (const plugin of plugins) {
134
- if (!plugin.lspHtmlData) continue;
135
- const htmlData = await plugin.lspHtmlData();
136
- if (htmlData) {
137
- htmlDataInjectPluginName(htmlData, plugin.name);
138
- collected.push(htmlData);
139
- }
140
- }
141
- }
142
- this.lspHtmlData = collected;
143
- }
144
- };
145
- function htmlDataInjectPluginName(htmlData, pluginName) {
146
- const tags = htmlData.tags ?? [];
147
- for (const tag of tags) {
148
- tag.description = replaceDescription(tag.description, pluginName);
149
- const attributes = tag.attributes ?? [];
150
- for (const attribute of attributes) attribute.description = replaceDescription(attribute.description, pluginName);
151
- }
152
- const globalAttributes = htmlData.globalAttributes ?? [];
153
- for (const attribute of globalAttributes) attribute.description = replaceDescription(attribute.description, pluginName);
154
- }
155
- function replaceDescription(description, pluginName) {
156
- const info = `_Provided by **staticbolt** \`${pluginName}\` plugin._`;
157
- if (!description) return info;
158
- if (typeof description === "string") {
159
- if (description.includes(info)) return description;
160
- return `${description}\n\n${info}`;
161
- }
162
- if (description.value.includes(info)) return description;
163
- description.value = `${description.value}\n\n${info}`;
164
- return description;
16
+ //#region src/helpers/document-elements.ts
17
+ /** The token kinds the HTML scanner reports. */
18
+ const TokenType = vscodeHtml.TokenType;
19
+ /**
20
+ * Every element of a parsed document in document order, with the attribute offsets the parser leaves out: each start tag is
21
+ * scanned again for them.
22
+ */
23
+ function parseElements(languageService, text, htmlDocument) {
24
+ const elements = [];
25
+ for (const root of htmlDocument.roots) collectElements(languageService, text, root, void 0, elements);
26
+ return elements;
165
27
  }
166
-
167
- //#endregion
168
- //#region src/helpers/find-projects.ts
169
- function findStaticboltProjects(searchRoots) {
170
- const results = [];
171
- for (const root of searchRoots) {
172
- const rootPath = vscodeUri.URI.parse(root.uri).fsPath;
173
- const configs = globSync("**/.staticbolt.{ts,js}", {
174
- cwd: rootPath,
175
- exclude: ["**/node_modules/**", "**/.git/**"]
176
- });
177
- for (const configPath of configs) {
178
- const absDirectory = path$1.join(rootPath, path$1.dirname(configPath));
179
- const projectUri = vscodeUri.URI.file(absDirectory).toString();
180
- results.push({
181
- name: path$1.basename(absDirectory),
182
- uri: projectUri
183
- });
28
+ /** The element for a node and, after it, the ones for its children; the tree only ever holds elements. */
29
+ function collectElements(languageService, text, node, parent, elements) {
30
+ const element = createElement(languageService, text, node, parent);
31
+ elements.push(element);
32
+ parent?.children.push(element);
33
+ for (const child of node.children) collectElements(languageService, text, child, element, elements);
34
+ }
35
+ /** The element for a node, with its attributes scanned from its start tag. */
36
+ function createElement(languageService, text, node, parent) {
37
+ const tag = node.tag ?? "";
38
+ const startTagEnd = node.startTagEnd ?? node.end;
39
+ const attributes = [];
40
+ const findAttribute = (name) => {
41
+ const wanted = name.toLowerCase();
42
+ return attributes.find((attribute) => attribute.name.toLowerCase() === wanted);
43
+ };
44
+ const element = {
45
+ name: tag.toLowerCase(),
46
+ attributes,
47
+ parent,
48
+ children: [],
49
+ range: {
50
+ start: node.start,
51
+ end: node.end
52
+ },
53
+ nameRange: {
54
+ start: node.start + 1,
55
+ end: node.start + 1 + tag.length
56
+ },
57
+ contentRange: contentRangeOf(node, startTagEnd),
58
+ attribute: findAttribute,
59
+ has: (name) => findAttribute(name) !== void 0
60
+ };
61
+ attributes.push(...scanAttributes(languageService, text, element, node.start, startTagEnd));
62
+ return element;
63
+ }
64
+ /**
65
+ * From the end of the start tag to the end tag, or to wherever the parser closed an element missing its end tag; nothing for an
66
+ * element that is over with its start tag, void or self-closing.
67
+ */
68
+ function contentRangeOf(node, startTagEnd) {
69
+ const end = node.endTagStart ?? node.end;
70
+ if (end <= startTagEnd) return;
71
+ return {
72
+ start: startTagEnd,
73
+ end
74
+ };
75
+ }
76
+ /** The attributes in a start tag, with where their names and values sit. */
77
+ function scanAttributes(languageService, text, element, start, end) {
78
+ const attributes = [];
79
+ const scanner = languageService.createScanner(text.slice(start, end));
80
+ let pending;
81
+ for (let token = scanner.scan(); token !== TokenType.EOS; token = scanner.scan()) {
82
+ const range = {
83
+ start: start + scanner.getTokenOffset(),
84
+ end: start + scanner.getTokenEnd()
85
+ };
86
+ if (token === TokenType.AttributeName) {
87
+ pending = {
88
+ name: scanner.getTokenText(),
89
+ value: void 0,
90
+ element,
91
+ nameRange: range,
92
+ valueRange: void 0
93
+ };
94
+ attributes.push(pending);
95
+ continue;
184
96
  }
97
+ if (token !== TokenType.AttributeValue || !pending) continue;
98
+ const raw = scanner.getTokenText();
99
+ const quote = raw.startsWith("\"") || raw.startsWith("'") ? 1 : 0;
100
+ pending.value = raw.slice(quote, raw.length - quote);
101
+ pending.valueRange = {
102
+ start: range.start + quote,
103
+ end: range.end - quote
104
+ };
105
+ pending = void 0;
185
106
  }
186
- return results;
107
+ return attributes;
187
108
  }
188
109
 
189
110
  //#endregion
190
- //#region src/language-model-cache.ts
191
- function getLanguageModelCache(maxEntries, cleanupIntervalTimeInSec, parse) {
192
- let languageModels = {};
193
- let nModels = 0;
194
- let cleanupInterval;
195
- if (cleanupIntervalTimeInSec > 0) cleanupInterval = setInterval(() => {
196
- const cutoffTime = Date.now() - cleanupIntervalTimeInSec * 1e3;
197
- const uris = Object.keys(languageModels);
198
- for (const uri of uris) if (languageModels[uri].cTime < cutoffTime) {
199
- delete languageModels[uri];
200
- nModels--;
201
- }
202
- }, cleanupIntervalTimeInSec * 1e3);
111
+ //#region src/helpers/document-info.ts
112
+ /** The document as a plugin sees it: its text and elements, with lookups over them and the project's resolver. */
113
+ function describeDocument(text, file, elements, resolver) {
203
114
  return {
204
- get(document) {
205
- const version = document.version;
206
- const languageId = document.languageId;
207
- const languageModelInfo = languageModels[document.uri];
208
- if (languageModelInfo && languageModelInfo.version === version && languageModelInfo.languageId === languageId) {
209
- languageModelInfo.cTime = Date.now();
210
- return languageModelInfo.languageModel;
211
- }
212
- const languageModel = parse(document);
213
- languageModels[document.uri] = {
214
- languageModel,
215
- version,
216
- languageId,
217
- cTime: Date.now()
218
- };
219
- if (!languageModelInfo) nModels++;
220
- if (nModels === maxEntries) {
221
- let oldestTime = Number.MAX_VALUE;
222
- let oldestUri = null;
223
- for (const uri in languageModels) {
224
- const languageModelInfo = languageModels[uri];
225
- if (languageModelInfo.cTime < oldestTime) {
226
- oldestUri = uri;
227
- oldestTime = languageModelInfo.cTime;
228
- }
229
- }
230
- if (oldestUri) {
231
- delete languageModels[oldestUri];
232
- nModels--;
233
- }
234
- }
235
- return languageModel;
115
+ file,
116
+ text,
117
+ elements,
118
+ select(...names) {
119
+ const wanted = new Set(names.map((name) => name.toLowerCase()));
120
+ return elements.filter((element) => wanted.has(element.name));
236
121
  },
237
- onDocumentRemoved(document) {
238
- const uri = document.uri;
239
- if (Object.hasOwn(languageModels, uri)) {
240
- delete languageModels[uri];
241
- nModels--;
242
- }
122
+ textOf(range) {
123
+ return text.slice(range.start, range.end);
243
124
  },
244
- dispose() {
245
- if (cleanupInterval === void 0) return;
246
- clearInterval(cleanupInterval);
247
- cleanupInterval = void 0;
248
- languageModels = {};
249
- nModels = 0;
125
+ resolve(source) {
126
+ const resolved = resolver.resolve(source, file);
127
+ if (!resolved) return;
128
+ return {
129
+ path: resolved.path,
130
+ exists: resolved.exists
131
+ };
250
132
  }
251
133
  };
252
134
  }
253
135
 
254
136
  //#endregion
255
- //#region src/modes/markdown-regions.ts
137
+ //#region src/helpers/markdown-regions.ts
256
138
  /**
257
139
  * Markdown allows raw HTML anywhere, so a ".md" file is served as HTML with the parts that can never be HTML — front matter, code
258
140
  * blocks and code spans — blanked out first. Blanking keeps every offset, so positions still point at the same place in the
@@ -278,16 +160,18 @@ function findMarkdownNonHtmlRegions(text) {
278
160
  const pending = [tree];
279
161
  while (pending.length > 0) {
280
162
  const node = pending.pop();
281
- if (NON_HTML_NODES.has(node.type)) {
282
- const start = node.position?.start.offset;
283
- const end = node.position?.end.offset;
284
- if (start !== void 0 && end !== void 0) regions.push({
285
- start,
286
- end
287
- });
163
+ if ("children" in node) {
164
+ pending.push(...node.children);
288
165
  continue;
289
166
  }
290
- if ("children" in node) pending.push(...node.children);
167
+ if (!NON_HTML_NODES.has(node.type)) continue;
168
+ const start = node.position?.start.offset;
169
+ const end = node.position?.end.offset;
170
+ if (start === void 0 || end === void 0) continue;
171
+ regions.push({
172
+ start,
173
+ end
174
+ });
291
175
  }
292
176
  return toUtf16Offsets(text, regions.toSorted((a, b) => a.start - b.start));
293
177
  }
@@ -303,160 +187,629 @@ function toUtf16Offsets(text, regions) {
303
187
  if (character.length === 2) astral.push(codePoint);
304
188
  codePoint++;
305
189
  }
306
- const toUtf16 = (offset) => offset + astral.filter((position) => position < offset).length;
190
+ /** Shifts a code point offset by the number of astral characters before it. */
191
+ function toUtf16(offset) {
192
+ return offset + astral.filter((position) => position < offset).length;
193
+ }
307
194
  return regions.map((region) => ({
308
195
  start: toUtf16(region.start),
309
196
  end: toUtf16(region.end)
310
197
  }));
311
198
  }
312
- /** Replaces every region with spaces, keeping the length of the text and its line breaks. Regions must be sorted and disjoint. */
199
+
200
+ //#endregion
201
+ //#region src/helpers/regions.ts
202
+ /** Regions in text order. */
203
+ const byStart = (a, b) => a.start - b.start;
204
+ /**
205
+ * The regions of every plugin language that claims the document, in text order. A region inside one a language earlier in the
206
+ * config claimed already is left to that language: a plugin that runs a script elsewhere claims it before the core plugin, last
207
+ * in the config, sees the browser in it. A region of another language inside this one is a hole, not a claim.
208
+ */
209
+ function findPluginRegions(document, languages) {
210
+ const regions = [];
211
+ for (const language of languages) {
212
+ if (!language.filter(document.file)) continue;
213
+ for (const region of language.findRegions(document)) {
214
+ if (regions.some((claimed) => isInside(region, claimed))) continue;
215
+ regions.push({
216
+ ...region,
217
+ language
218
+ });
219
+ }
220
+ }
221
+ return regions.toSorted(byStart);
222
+ }
223
+ /** Whether a range lies within another. */
224
+ function isInside(range, outer) {
225
+ return outer.start <= range.start && range.end <= outer.end;
226
+ }
227
+ /**
228
+ * The plugin regions by the file they share: the classic regions of a language together, each module on its own. Every group is
229
+ * in text order, with the regions of other languages inside its own as its holes: a placeholder inside a build-time script is
230
+ * the placeholder language's.
231
+ */
232
+ function groupByFile(regions) {
233
+ const groups = /* @__PURE__ */ new Map();
234
+ let modules = 0;
235
+ for (const region of regions) {
236
+ const id = region.isModule ? `${region.language.name}.module${modules++}` : region.language.name;
237
+ const group = groups.get(id) ?? {
238
+ id,
239
+ language: region.language,
240
+ isModule: region.isModule === true,
241
+ regions: [],
242
+ holes: []
243
+ };
244
+ group.regions.push({
245
+ start: region.start,
246
+ end: region.end
247
+ });
248
+ group.holes.push(...holesOf(region, regions));
249
+ groups.set(id, group);
250
+ }
251
+ return groups.values().toArray();
252
+ }
253
+ /** The whole construct a region sits in, delimiters included, or the region itself when it has no more. */
254
+ function extentOf(region) {
255
+ return region.extent ?? {
256
+ start: region.start,
257
+ end: region.end
258
+ };
259
+ }
260
+ /** Whether an offset falls in any of the regions, their ends included. */
261
+ function isInRegions(regions, offset) {
262
+ return regions.some((region) => region.start <= offset && offset <= region.end);
263
+ }
264
+ /** The regions of other languages lying inside a region, as their whole constructs. */
265
+ function holesOf(region, regions) {
266
+ const holes = [];
267
+ for (const other of regions) {
268
+ if (other.language === region.language) continue;
269
+ const hole = extentOf(other);
270
+ if (hole.start < region.start || hole.end > region.end) continue;
271
+ holes.push(hole);
272
+ }
273
+ return holes;
274
+ }
275
+
276
+ //#endregion
277
+ //#region src/helpers/virtual-document.ts
278
+ /**
279
+ * The text with everything outside the regions blanked, keeping line breaks, so every offset means the same thing as in the text.
280
+ * The character right after a region becomes a `;`, so neighbouring regions on one line stay separate statements.
281
+ */
282
+ function blankAround(text, regions) {
283
+ let result = "";
284
+ let cursor = 0;
285
+ for (const region of regions) {
286
+ result += blank(text.slice(cursor, region.start)) + text.slice(region.start, region.end);
287
+ cursor = region.end;
288
+ const next = text[cursor];
289
+ if (next === void 0 || next === "\n" || next === "\r") continue;
290
+ result += ";";
291
+ cursor++;
292
+ }
293
+ return result + blank(text.slice(cursor));
294
+ }
295
+ /** The text with the regions blanked, keeping line breaks, so every offset means the same thing as in the text. */
313
296
  function blankRegions(text, regions) {
314
- if (regions.length === 0) return text;
315
297
  let result = "";
316
298
  let cursor = 0;
317
299
  for (const region of regions) {
318
- result += text.slice(cursor, region.start) + text.slice(region.start, region.end).replaceAll(/[^\n\r]/g, " ");
300
+ result += text.slice(cursor, region.start) + blank(text.slice(region.start, region.end));
319
301
  cursor = region.end;
320
302
  }
321
303
  return result + text.slice(cursor);
322
304
  }
305
+ /**
306
+ * The holes masked, keeping line breaks, so the code around them still parses and types as it will once they are filled: a hole
307
+ * in a string literal makes the whole literal `"" + ""`, a `string` rather than a literal type; a hole in a template becomes a
308
+ * `${<any>0}` substitution, for the same reason; any other hole reads as `<any>0`, a value of a type nobody knows yet. A hole too
309
+ * short for its mask gets the `0` alone.
310
+ */
311
+ function mask(typescript, text, holes) {
312
+ if (holes.length === 0) return text;
313
+ const sourceFile = typescript.createSourceFile("mask.ts", text, typescript.ScriptTarget.Latest, true);
314
+ let result = text;
315
+ for (const hole of holes) {
316
+ const token = tokenAt(sourceFile, hole.start);
317
+ if (token?.kind === typescript.SyntaxKind.StringLiteral) {
318
+ const range = {
319
+ start: token.getStart(sourceFile),
320
+ end: token.getEnd()
321
+ };
322
+ result = replace(result, range, fill("\"\" + \"\"", text.slice(range.start, range.end)));
323
+ continue;
324
+ }
325
+ if (token && typescript.isTemplateLiteralToken(token)) {
326
+ result = replace(result, hole, fill("${<any>0}", text.slice(hole.start, hole.end), "${0}"));
327
+ continue;
328
+ }
329
+ result = replace(result, hole, fill("<any>0", text.slice(hole.start, hole.end), "0"));
330
+ }
331
+ return result;
332
+ }
333
+ /**
334
+ * The replacement where the original was, followed by the original blanked, so the length and the line breaks are kept; the
335
+ * shorter fallback when the original is too short for the replacement.
336
+ */
337
+ function fill(replacement, original, fallback = replacement) {
338
+ const fitting = original.length >= replacement.length ? replacement : fallback;
339
+ return fitting + blank(original.slice(fitting.length));
340
+ }
341
+ /** The token of a parsed text an offset falls in: the deepest node there that has no children. */
342
+ function tokenAt(sourceFile, offset) {
343
+ let node = sourceFile;
344
+ while (true) {
345
+ const child = node.getChildren(sourceFile).find((candidate) => candidate.getStart(sourceFile) <= offset && offset < candidate.getEnd());
346
+ if (!child) return node === sourceFile ? void 0 : node;
347
+ node = child;
348
+ }
349
+ }
350
+ /** The text with a range replaced by a replacement of the same length. */
351
+ function replace(text, range, replacement) {
352
+ return text.slice(0, range.start) + replacement + text.slice(range.end);
353
+ }
354
+ /** Every character but the line breaks replaced by a space. */
355
+ function blank(text) {
356
+ return text.replaceAll(/[^\n\r]/g, " ");
357
+ }
323
358
 
324
359
  //#endregion
325
- //#region src/modes/embedded-support.ts
326
- const TokenType = vscodeHtml.TokenType;
327
- function getDocumentRegions(languageService, document) {
328
- const source = document.getText();
329
- const isMarkdown = document.languageId === "markdown";
330
- const markdownRegions = isMarkdown ? findMarkdownNonHtmlRegions(source).map((region) => ({
331
- ...region,
332
- languageId: void 0
333
- })) : [];
334
- const htmlText = isMarkdown ? blankRegions(source, markdownRegions) : source;
335
- const regions = [];
336
- const scanner = languageService.createScanner(htmlText);
337
- let lastTagName = "";
338
- let lastAttributeName = null;
339
- let languageIdFromType;
340
- let token = scanner.scan();
341
- while (token !== TokenType.EOS) {
342
- switch (token) {
343
- case TokenType.StartTag:
344
- lastTagName = scanner.getTokenText();
345
- lastAttributeName = null;
346
- languageIdFromType = "javascript";
347
- break;
348
- case TokenType.Styles:
349
- regions.push({
350
- languageId: "css",
351
- start: scanner.getTokenOffset(),
352
- end: scanner.getTokenEnd()
353
- });
354
- break;
355
- case TokenType.Script:
356
- regions.push({
357
- languageId: languageIdFromType,
358
- start: scanner.getTokenOffset(),
359
- end: scanner.getTokenEnd()
360
- });
361
- break;
362
- case TokenType.AttributeName:
363
- lastAttributeName = scanner.getTokenText();
364
- break;
365
- case TokenType.AttributeValue:
366
- if (lastAttributeName === "type" && lastTagName.toLowerCase() === "script") {
367
- const token = scanner.getTokenText();
368
- if (/["'](module|(text|application)\/(java|ecma)script|text\/babel)["']/.test(token) || token === "module") languageIdFromType = "javascript";
369
- else if (/["']text\/typescript["']/.test(token)) languageIdFromType = "typescript";
370
- else languageIdFromType = void 0;
371
- } else {
372
- const attributeLanguageId = getAttributeLanguage(lastAttributeName);
373
- if (attributeLanguageId) {
374
- let start = scanner.getTokenOffset();
375
- let end = scanner.getTokenEnd();
376
- const firstChar = htmlText[start];
377
- if (firstChar === "'" || firstChar === "\"") {
378
- start++;
379
- end--;
380
- }
381
- regions.push({
382
- languageId: attributeLanguageId,
383
- start,
384
- end
385
- });
386
- }
387
- }
388
- lastAttributeName = null;
389
- }
390
- token = scanner.scan();
360
+ //#region src/virtual-code.ts
361
+ /** The id of the root code, and of the HTML copy a markdown document is served through. */
362
+ const ROOT_ID = "root";
363
+ /** The id of the embedded code holding the HTML of a document that is not HTML itself. */
364
+ const HTML_ID = "html";
365
+ /** The TypeScript file name an embedded code of a document is served under. */
366
+ function embeddedFileName(documentFileName, codeId) {
367
+ return `${documentFileName}.${codeId}.ts`;
368
+ }
369
+ /** What every feature is allowed to do on a mapped stretch of code. */
370
+ const ALL_FEATURES = {
371
+ verification: true,
372
+ completion: true,
373
+ semantic: true,
374
+ navigation: true,
375
+ structure: true,
376
+ format: false
377
+ };
378
+ /** The HTML language service the codes parse with; no data provider, only the tree is wanted here. */
379
+ const htmlLanguageService = vscodeHtml.getLanguageService({ useDefaultDataProvider: false });
380
+ /**
381
+ * A document as the server sees it: the HTML (a markdown document's with everything that cannot be HTML blanked out), the regions
382
+ * plugins embed in it, and an embedded TypeScript code per plugin language, served through the project's TypeScript.
383
+ */
384
+ var StaticboltCode = class {
385
+ /** The root is the document. */
386
+ id = ROOT_ID;
387
+ /** `html` or `markdown`. */
388
+ languageId;
389
+ /** The document's text. */
390
+ snapshot;
391
+ /** The whole document maps onto itself. */
392
+ mappings;
393
+ /** The HTML copy of a markdown document, then the TypeScript code of every plugin language with regions. */
394
+ embeddedCodes;
395
+ /** The document's uri. */
396
+ uri;
397
+ /** The project the document belongs to, or nothing when it is outside every loaded one. */
398
+ project;
399
+ /** The document's path relative to its project, or its whole path outside one. */
400
+ file;
401
+ /** The document as HTML: the text itself, or for markdown the blanked copy. */
402
+ html;
403
+ /** The regions the plugins embed, in text order. */
404
+ regions;
405
+ /** The regions by plugin language. */
406
+ languages;
407
+ /** The parsed HTML, on first use. */
408
+ #htmlDocument;
409
+ /** The document as the plugins see it, on first use. */
410
+ #info;
411
+ constructor(typescript, uri, languageId, snapshot, project) {
412
+ const text = snapshot.getText(0, snapshot.getLength());
413
+ this.uri = uri;
414
+ this.languageId = languageId;
415
+ this.snapshot = snapshot;
416
+ this.project = project;
417
+ this.file = project ? path.relative(project.root, uri.fsPath) : uri.fsPath;
418
+ this.mappings = [identityMapping(text.length)];
419
+ this.html = languageId === "markdown" ? blankRegions(text, findMarkdownNonHtmlRegions(text)) : text;
420
+ this.regions = project ? findPluginRegions(this.info, project.embeddedLanguages) : [];
421
+ this.languages = groupByFile(this.regions);
422
+ this.embeddedCodes = this.languages.map((group) => createTypeScriptCode(typescript, this.info, group));
423
+ if (languageId === "markdown") this.embeddedCodes.unshift(createHtmlCode(typescript, this.html));
424
+ }
425
+ /** The parsed HTML. */
426
+ get htmlDocument() {
427
+ this.#htmlDocument ??= htmlLanguageService.parseHTMLDocument(TextDocument.create(this.uri.toString(), "html", 0, this.html));
428
+ return this.#htmlDocument;
429
+ }
430
+ /**
431
+ * The document as the plugins see it: its elements with their attributes and where everything sits, and the project's resolver.
432
+ * Outside a project, a resolver of the document's own directory.
433
+ */
434
+ get info() {
435
+ this.#info ??= describeDocument(this.html, this.file, parseElements(htmlLanguageService, this.html, this.htmlDocument), this.project?.resolver ?? new Resolver(path.dirname(this.uri.fsPath), false, {}, false));
436
+ return this.#info;
391
437
  }
392
- const allRegions = mergeRegions(regions, markdownRegions);
393
- const htmlDocument = isMarkdown ? TextDocument.create(document.uri, "html", document.version, htmlText) : document;
438
+ };
439
+ /** A mapping of a whole text onto itself. */
440
+ function identityMapping(length) {
441
+ return {
442
+ sourceOffsets: [0],
443
+ generatedOffsets: [0],
444
+ lengths: [length],
445
+ data: ALL_FEATURES
446
+ };
447
+ }
448
+ /** A markdown document's HTML copy, at the same offsets. */
449
+ function createHtmlCode(typescript, html) {
394
450
  return {
395
- getLanguageAtPosition: (position) => getLanguageAtPosition(document, allRegions, position),
396
- getLanguagesInDocument: () => getLanguagesInDocument(allRegions),
397
- getHtmlDocument: () => htmlDocument
451
+ id: HTML_ID,
452
+ languageId: "html",
453
+ snapshot: typescript.ScriptSnapshot.fromString(html),
454
+ mappings: [identityMapping(html.length)]
398
455
  };
399
456
  }
400
- /** The lookups below read the regions in order, so an overlap is dropped: a scanned region always wins over a markdown one. */
401
- function mergeRegions(scanned, markdown) {
402
- if (markdown.length === 0) return scanned;
403
- const merged = [...scanned, ...markdown].toSorted((a, b) => a.start - b.start || b.end - a.end);
404
- const result = [];
405
- for (const region of merged) {
406
- const previous = result.at(-1);
407
- if (previous && region.start < previous.end) continue;
408
- result.push(region);
457
+ /**
458
+ * The TypeScript code of one file of a plugin language: the document with everything outside the file's regions blanked, so every
459
+ * offset means the same thing in both, the regions of other languages masked, and the language's prelude appended at the end. A
460
+ * module is made one with an `export {}`, so its top level is its own; a script's top level is the global scope, as it is in the
461
+ * browser. The regions map back to the document; what lies before the first and after the last maps onto their edges, so what
462
+ * TypeScript puts at the top or the bottom of the file, an import or a declaration it adds say, lands in the code.
463
+ */
464
+ function createTypeScriptCode(typescript, info, group) {
465
+ const { id, language, isModule, regions, holes } = group;
466
+ const prelude = typeof language.prelude === "function" ? language.prelude(info) : language.prelude;
467
+ const suffix = [isModule ? "export {};" : "", prelude ?? ""].filter(Boolean).join("\n");
468
+ const text = `${mask(typescript, blankAround(info.text, regions), holes)}\n${suffix}\n`;
469
+ const first = regions[0];
470
+ const last = regions.at(-1) ?? first;
471
+ return {
472
+ id,
473
+ languageId: "typescript",
474
+ snapshot: typescript.ScriptSnapshot.fromString(text),
475
+ mappings: [
476
+ {
477
+ sourceOffsets: regions.map((region) => region.start),
478
+ generatedOffsets: regions.map((region) => region.start),
479
+ lengths: regions.map((region) => region.end - region.start),
480
+ data: ALL_FEATURES
481
+ },
482
+ edgeMapping(topOf(info.text, first), 0, first.start),
483
+ edgeMapping(last.end, last.end, text.length - last.end)
484
+ ]
485
+ };
486
+ }
487
+ /**
488
+ * A stretch of the generated text outside the regions mapped onto one spot of the document, for the edits of completions and code
489
+ * actions only: nothing there is verified, coloured or folded.
490
+ */
491
+ function edgeMapping(sourceOffset, generatedOffset, generatedLength) {
492
+ return {
493
+ sourceOffsets: [sourceOffset],
494
+ generatedOffsets: [generatedOffset],
495
+ lengths: [0],
496
+ generatedLengths: [generatedLength],
497
+ data: {
498
+ completion: true,
499
+ navigation: true,
500
+ verification: false,
501
+ semantic: false,
502
+ structure: false,
503
+ format: false
504
+ }
505
+ };
506
+ }
507
+ /** Where the top of a file's code is in the document: the first region's start, past the line break a script body opens with. */
508
+ function topOf(text, first) {
509
+ const lineBreak = /^\r?\n/.exec(text.slice(first.start, first.end));
510
+ return first.start + (lineBreak?.[0].length ?? 0);
511
+ }
512
+
513
+ //#endregion
514
+ //#region src/language-plugin.ts
515
+ /** The language ids by file extension. */
516
+ const LANGUAGE_IDS = {
517
+ ".html": "html",
518
+ ".md": "markdown"
519
+ };
520
+ /**
521
+ * Tells Volar what an HTML or markdown document is: the document itself, and a TypeScript file per plugin language, named after
522
+ * the document and the language and served by the project's TypeScript next to it.
523
+ */
524
+ function createLanguagePlugin(typescript, projects) {
525
+ return {
526
+ getLanguageId(uri) {
527
+ return LANGUAGE_IDS[path.extname(uri.path).toLowerCase()];
528
+ },
529
+ createVirtualCode(uri, languageId, snapshot) {
530
+ if (uri.scheme !== "file") return;
531
+ if (languageId !== "html" && languageId !== "markdown") return;
532
+ return new StaticboltCode(typescript, uri, languageId, snapshot, projects.of(uri.toString()));
533
+ },
534
+ typescript: {
535
+ extraFileExtensions: [{
536
+ extension: "html",
537
+ isMixedContent: true,
538
+ scriptKind: typescript.ScriptKind.Deferred
539
+ }, {
540
+ extension: "md",
541
+ isMixedContent: true,
542
+ scriptKind: typescript.ScriptKind.Deferred
543
+ }],
544
+ getServiceScript() {},
545
+ getExtraServiceScripts(fileName, root) {
546
+ const scripts = [];
547
+ for (const code of forEachEmbeddedCode(root)) {
548
+ if (code.languageId !== "typescript") continue;
549
+ scripts.push({
550
+ fileName: embeddedFileName(fileName, code.id),
551
+ code,
552
+ extension: ".ts",
553
+ scriptKind: typescript.ScriptKind.TS
554
+ });
555
+ }
556
+ return scripts;
557
+ }
558
+ }
559
+ };
560
+ }
561
+
562
+ //#endregion
563
+ //#region src/projects.ts
564
+ /** In order, so the first one that exists wins. */
565
+ const CONFIG_NAMES = [".staticbolt.ts", ".staticbolt.js"];
566
+ /** A save may come as several writes; the config is loaded once they have stopped. */
567
+ const RELOAD_DELAY_MS = 150;
568
+ /** The directory of the nearest config file above a directory, which is the project it belongs to. */
569
+ function findProjectRoot(directory) {
570
+ while (true) {
571
+ if (CONFIG_NAMES.some((name) => existsSync(path.join(directory, name)))) return directory;
572
+ const parent = path.dirname(directory);
573
+ if (parent === directory) return;
574
+ directory = parent;
409
575
  }
410
- return result;
411
576
  }
412
- function getLanguagesInDocument(regions) {
413
- const languages = /* @__PURE__ */ new Set(["html"]);
414
- for (const region of regions) if (region.languageId) languages.add(region.languageId);
415
- return [...languages];
577
+ /** A resolver for a root that does not log missing files, with the config's aliases on top of the tsconfig's. */
578
+ function createResolver(root, aliases) {
579
+ return new Resolver(root, false, aliases, false);
416
580
  }
417
- function getLanguageAtPosition(document, regions, position) {
418
- const offset = document.offsetAt(position);
419
- for (const region of regions) if (region.start <= offset) {
420
- if (offset <= region.end) return region.languageId;
421
- } else break;
422
- return "html";
581
+ /** Every project under the workspace folders, for the startup log. */
582
+ function findProjectRoots(folders) {
583
+ return folders.flatMap((folder) => {
584
+ const cwd = vscodeUri.URI.parse(folder.uri).fsPath;
585
+ return globSync(`**/{${CONFIG_NAMES.join(",")}}`, {
586
+ cwd,
587
+ exclude: ["**/node_modules/**", "**/.git/**"]
588
+ }).map((config) => path.join(cwd, path.dirname(config)));
589
+ });
423
590
  }
424
- function getAttributeLanguage(attributeName) {
425
- const match = attributeName.match(/^(style)$|^(on\w+)$/i);
426
- if (!match) return null;
427
- return match[1] ? "css" : "javascript";
591
+ /**
592
+ * A staticbolt project as the server sees it: its config, kept current while the config file changes, and what its plugins
593
+ * contribute to the editor.
594
+ */
595
+ var Project = class {
596
+ /** The directory holding the config file. */
597
+ root;
598
+ /** The last config that loaded, or nothing when none did yet. */
599
+ config;
600
+ /** The tags and attributes the plugins contribute to HTML. A new array on every load, so consumers may cache on its identity. */
601
+ htmlData = [];
602
+ /** The languages the plugins embed in HTML. */
603
+ embeddedLanguages = [];
604
+ /** The plugins that check documents, by name. */
605
+ validators = [];
606
+ /** Resolves paths the way the project's build does: its tsconfig paths and config aliases. New on every load. */
607
+ resolver;
608
+ /** Where to log. */
609
+ #console;
610
+ /** Follows the root directory for saves of the config file. */
611
+ #watcher;
612
+ /** The reload waiting for the save to finish. */
613
+ #reload;
614
+ /** Told whenever the config loaded. */
615
+ #onLoad;
616
+ /** Nothing is loaded until `start` is called. */
617
+ constructor(root, console, onLoad) {
618
+ this.root = root;
619
+ this.resolver = createResolver(root);
620
+ this.#console = console;
621
+ this.#onLoad = onLoad;
622
+ }
623
+ /** Loads the config and starts following the config file. */
624
+ async start() {
625
+ this.#watcher = watch(this.root, { persistent: false }, (_event, filename) => {
626
+ if (typeof filename !== "string") return;
627
+ if (!CONFIG_NAMES.includes(filename)) return;
628
+ this.#scheduleReload();
629
+ });
630
+ this.#watcher.on("error", (error) => this.#console.error(`[staticbolt] watching ${this.root}: ${error.message}`));
631
+ await this.#load();
632
+ }
633
+ /** Stops following the config file. */
634
+ dispose() {
635
+ clearTimeout(this.#reload);
636
+ this.#watcher?.close();
637
+ }
638
+ /** Loads the config again once the save has stopped writing. */
639
+ #scheduleReload() {
640
+ clearTimeout(this.#reload);
641
+ this.#reload = setTimeout(() => void this.#load(), RELOAD_DELAY_MS);
642
+ }
643
+ /** Loads the config file and takes what its plugins contribute; a failure is logged and leaves the last config in place. */
644
+ async #load() {
645
+ const configPath = CONFIG_NAMES.map((name) => path.join(this.root, name)).find((candidate) => existsSync(candidate));
646
+ if (!configPath) {
647
+ this.#console.error(`[staticbolt] the config file of ${this.root} is gone`);
648
+ return;
649
+ }
650
+ try {
651
+ const module = await import(`${pathToFileURL(configPath).href}?t=${Date.now()}`);
652
+ if (!module.default) throw new Error("it has no default export, use `export default { … }`");
653
+ await this.#collectPluginData(module.default);
654
+ this.config = module.default;
655
+ this.resolver = createResolver(this.root, module.default.aliases);
656
+ this.#onLoad();
657
+ } catch (error) {
658
+ const reason = error instanceof Error ? error.message : String(error);
659
+ this.#console.error(`[staticbolt] failed to load ${configPath}: ${reason}`);
660
+ }
661
+ }
662
+ /** Asks every plugin of the config what it contributes to the editor. */
663
+ async #collectPluginData(config) {
664
+ const plugins = (config.plugins ?? []).flat();
665
+ const embeddedLanguages = [];
666
+ const validators = [];
667
+ const htmlData = [];
668
+ for (const plugin of plugins) {
669
+ embeddedLanguages.push(...plugin.lspEmbeddedLanguages?.() ?? []);
670
+ if (plugin.lspValidate) validators.push({
671
+ name: plugin.name,
672
+ validate: plugin.lspValidate,
673
+ hasFailed: false
674
+ });
675
+ const data = await plugin.lspHtmlData?.();
676
+ if (data) htmlData.push(creditPlugin(data, plugin.name));
677
+ }
678
+ this.embeddedLanguages = embeddedLanguages;
679
+ this.validators = validators;
680
+ this.htmlData = htmlData;
681
+ }
682
+ };
683
+ /** The projects the server has been asked about, each started on first request. */
684
+ var Projects = class {
685
+ /** Where to log. */
686
+ #console;
687
+ /** By root, from the moment a project was asked for. */
688
+ #starting = /* @__PURE__ */ new Map();
689
+ /** By root, once the project's config loaded. */
690
+ #loaded = /* @__PURE__ */ new Map();
691
+ /** The project root of every directory a document was served from. */
692
+ #roots = /* @__PURE__ */ new Map();
693
+ /** Told whenever any project's config loaded. */
694
+ #onLoad;
695
+ /** Starts with no projects; each is started the first time `get` asks for it. */
696
+ constructor(console, onLoad) {
697
+ this.#console = console;
698
+ this.#onLoad = onLoad;
699
+ }
700
+ /**
701
+ * The project at a root, or nothing while its config cannot be loaded. The project keeps following its config file either way,
702
+ * so a fixed config loads on its own.
703
+ */
704
+ async get(root) {
705
+ let project = this.#starting.get(root);
706
+ if (!project) {
707
+ project = this.#start(root);
708
+ this.#starting.set(root, project);
709
+ }
710
+ const started = await project;
711
+ return started.config ? started : void 0;
712
+ }
713
+ /**
714
+ * The project a document belongs to, if it has loaded already. One that has not is started, and the document is served again
715
+ * once it loads; a document outside any project gets nothing.
716
+ */
717
+ of(documentUri) {
718
+ const directory = path.dirname(vscodeUri.URI.parse(documentUri).fsPath);
719
+ const root = this.#roots.get(directory) ?? findProjectRoot(directory);
720
+ if (root === void 0) return;
721
+ this.#roots.set(directory, root);
722
+ const loaded = this.#loaded.get(root);
723
+ if (!loaded) this.get(root);
724
+ return loaded;
725
+ }
726
+ /** Stops following every project. */
727
+ async dispose() {
728
+ const projects = await Promise.all(this.#starting.values());
729
+ for (const project of projects) project.dispose();
730
+ this.#starting.clear();
731
+ this.#loaded.clear();
732
+ this.#roots.clear();
733
+ }
734
+ /** A project at a root, counted as loaded from the first time its config loads. */
735
+ async #start(root) {
736
+ const project = new Project(root, this.#console, () => {
737
+ this.#loaded.set(root, project);
738
+ this.#onLoad();
739
+ });
740
+ await project.start();
741
+ return project;
742
+ }
743
+ };
744
+ /** The data with every description saying which plugin it comes from. */
745
+ function creditPlugin(htmlData, pluginName) {
746
+ const credit = `_Provided by **staticbolt** \`${pluginName}\` plugin._`;
747
+ const withCredit = (description) => {
748
+ if (!description) return credit;
749
+ const text = typeof description === "string" ? description : description.value;
750
+ if (text.includes(credit)) return description;
751
+ const credited = `${text}\n\n${credit}`;
752
+ if (typeof description === "string") return credited;
753
+ return {
754
+ ...description,
755
+ value: credited
756
+ };
757
+ };
758
+ const creditAttributes = (attributes) => {
759
+ return attributes.map((attribute) => ({
760
+ ...attribute,
761
+ description: withCredit(attribute.description)
762
+ }));
763
+ };
764
+ const tags = htmlData.tags?.map((tag) => {
765
+ return {
766
+ ...tag,
767
+ description: withCredit(tag.description),
768
+ attributes: creditAttributes(tag.attributes)
769
+ };
770
+ });
771
+ const globalAttributes = htmlData.globalAttributes ? creditAttributes(htmlData.globalAttributes) : void 0;
772
+ return {
773
+ ...htmlData,
774
+ tags,
775
+ globalAttributes
776
+ };
428
777
  }
429
778
 
430
779
  //#endregion
431
780
  //#region src/helpers/merge-html-data.ts
432
- /** Separates the documentation of two contributors. Rendered as a horizontal rule, unless the description is plain text. */
781
+ /** The `valueSet` the HTML language service reads as "a boolean attribute". */
782
+ const BOOLEAN_VALUE_SET = "v";
783
+ /** Separates the documentation of two contributors, rendered as a horizontal rule. */
433
784
  const MARKDOWN_SEPARATOR = "\n\n---\n\n";
785
+ /** The same, for descriptions that are plain text. */
434
786
  const PLAINTEXT_SEPARATOR = "\n\n";
435
787
  /** Groups entries by key, preserving the order of first appearance. */
436
788
  function groupBy(items, toKey) {
437
789
  const groups = /* @__PURE__ */ new Map();
438
- const ordered = [];
439
790
  for (const item of items) {
440
791
  const key = toKey(item);
441
- let group = groups.get(key);
442
- if (!group) {
443
- group = [];
444
- groups.set(key, group);
445
- ordered.push(group);
446
- }
792
+ const group = groups.get(key) ?? [];
447
793
  group.push(item);
794
+ groups.set(key, group);
448
795
  }
449
- return ordered;
796
+ return groups.values().toArray();
450
797
  }
451
798
  /** Markdown wins over plain text, so a contributor asking for rich text still gets it. Plain strings imply no preference. */
452
799
  function mergeDescriptionKind(descriptions) {
453
800
  let kind;
454
801
  for (const description of descriptions) {
455
- if (typeof description === "string" || kind === "markdown") continue;
802
+ if (typeof description === "string") continue;
803
+ if (kind === "markdown") break;
456
804
  kind = description.kind;
457
805
  }
458
806
  return kind;
459
807
  }
808
+ /** The text of a description, whichever shape it has. */
809
+ function textOf(description) {
810
+ if (typeof description === "string") return description;
811
+ return description.value;
812
+ }
460
813
  /** Concatenates every distinct description, so no contributor's documentation gets lost. */
461
814
  function mergeDescriptions(descriptions) {
462
815
  const defined = descriptions.filter((description) => description !== void 0);
@@ -464,19 +817,22 @@ function mergeDescriptions(descriptions) {
464
817
  const parts = [];
465
818
  const seen = /* @__PURE__ */ new Set();
466
819
  for (const description of defined) {
467
- const text = (typeof description === "string" ? description : description.value).trim();
820
+ const text = textOf(description).trim();
468
821
  if (!text || seen.has(text)) continue;
469
822
  seen.add(text);
470
823
  parts.push(text);
471
824
  }
472
- if (parts.length === 0) return void 0;
825
+ if (parts.length === 0) return;
473
826
  const kind = mergeDescriptionKind(defined);
474
- const value = parts.join(kind === "plaintext" ? PLAINTEXT_SEPARATOR : MARKDOWN_SEPARATOR);
475
- return kind ? {
827
+ const separator = kind === "plaintext" ? PLAINTEXT_SEPARATOR : MARKDOWN_SEPARATOR;
828
+ const value = parts.join(separator);
829
+ if (!kind) return value;
830
+ return {
476
831
  kind,
477
832
  value
478
- } : value;
833
+ };
479
834
  }
835
+ /** Every distinct reference, a reference being the same as another when both name and url match. */
480
836
  function mergeReferences(references) {
481
837
  const merged = [];
482
838
  const seen = /* @__PURE__ */ new Set();
@@ -489,15 +845,18 @@ function mergeReferences(references) {
489
845
  merged.push(reference);
490
846
  }
491
847
  }
492
- return merged.length > 0 ? merged : void 0;
848
+ if (merged.length === 0) return;
849
+ return merged;
493
850
  }
851
+ /** Every distinct browser. */
494
852
  function mergeBrowsers(browsers) {
495
853
  const merged = /* @__PURE__ */ new Set();
496
854
  for (const list of browsers) {
497
855
  const entries = list ?? [];
498
856
  for (const browser of entries) merged.add(browser);
499
857
  }
500
- return merged.size > 0 ? [...merged] : void 0;
858
+ if (merged.size === 0) return;
859
+ return [...merged];
501
860
  }
502
861
  /** The values an attribute contributes, with its `valueSet` reference expanded. */
503
862
  function resolveValues(attribute, valueSets) {
@@ -528,9 +887,11 @@ function mergeAttributes(attributes, valueSets) {
528
887
  return groupBy(attributes, (attribute) => attribute.name.toLowerCase()).map((group) => {
529
888
  if (group.length === 1 && !group[0].valueSet) return group[0];
530
889
  const values = mergeValues(group.flatMap((attribute) => resolveValues(attribute, valueSets)));
890
+ const isBoolean = group.some((attribute) => attribute.valueSet === BOOLEAN_VALUE_SET);
531
891
  return {
532
892
  name: group[0].name,
533
893
  description: mergeDescriptions(group.map((attribute) => attribute.description)),
894
+ valueSet: isBoolean ? BOOLEAN_VALUE_SET : void 0,
534
895
  values: values.length > 0 ? values : void 0,
535
896
  references: mergeReferences(group.map((attribute) => attribute.references)),
536
897
  browsers: mergeBrowsers(group.map((attribute) => attribute.browsers)),
@@ -562,8 +923,8 @@ function collectValueSets(htmlData) {
562
923
  const valueSets = /* @__PURE__ */ new Map();
563
924
  const collected = htmlData.flatMap((data) => data.valueSets ?? []);
564
925
  for (const valueSet of collected) {
565
- const existing = valueSets.get(valueSet.name);
566
- valueSets.set(valueSet.name, existing ? mergeValues([...existing, ...valueSet.values]) : valueSet.values);
926
+ const existing = valueSets.get(valueSet.name) ?? [];
927
+ valueSets.set(valueSet.name, mergeValues([...existing, ...valueSet.values]));
567
928
  }
568
929
  return valueSets;
569
930
  }
@@ -600,453 +961,745 @@ function createMergedHtmlDataProvider(id, htmlData) {
600
961
  provideAttributes,
601
962
  provideValues(tag, attribute) {
602
963
  const name = attribute.toLowerCase();
603
- return provideAttributes(tag).find((a) => a.name.toLowerCase() === name)?.values ?? [];
964
+ return provideAttributes(tag).find((candidate) => candidate.name.toLowerCase() === name)?.values ?? [];
604
965
  }
605
966
  };
606
967
  }
607
968
 
608
969
  //#endregion
609
- //#region src/modes/html-mode.ts
610
- function getHTMLMode(htmlLanguageService) {
611
- const htmlDocuments = getLanguageModelCache(10, 60, (document) => htmlLanguageService.parseHTMLDocument(document));
612
- let lastHtmlData;
613
- let lastProvider;
614
- function setHtmlDataProviders(htmlData) {
615
- if (!lastProvider || lastHtmlData !== htmlData) {
616
- lastHtmlData = htmlData;
617
- lastProvider = createMergedHtmlDataProvider(FILE_PROTOCOL, htmlData);
970
+ //#region src/helpers/validation.ts
971
+ /** What diagnostics from plugins are labelled with, followed by the plugin's name. */
972
+ const SOURCE = "staticbolt";
973
+ /**
974
+ * Runs every validating plugin of the project over a document and gathers what they report as diagnostics. A plugin that throws
975
+ * is skipped, so the others still report, and logged the first time.
976
+ */
977
+ async function validateDocument({ document, info, project, console }) {
978
+ const diagnostics = [];
979
+ for (const validator of project.validators) {
980
+ const report = createReporter(document, validator.name, diagnostics);
981
+ try {
982
+ await validator.validate(info, report);
983
+ } catch (error) {
984
+ if (validator.hasFailed) continue;
985
+ validator.hasFailed = true;
986
+ console.error(`[staticbolt] the ${validator.name} plugin failed to validate ${info.file}:`, error);
618
987
  }
619
- htmlLanguageService.setDataProviders(false, [lastProvider]);
988
+ }
989
+ return diagnostics;
990
+ }
991
+ /** A reporter adding to `diagnostics`, each one labelled with the plugin it comes from. */
992
+ function createReporter(document, pluginName, diagnostics) {
993
+ function reportAs(severity) {
994
+ return (target, message) => {
995
+ const { start, end } = rangeOf(target);
996
+ diagnostics.push({
997
+ range: {
998
+ start: document.positionAt(start),
999
+ end: document.positionAt(end)
1000
+ },
1001
+ message,
1002
+ severity,
1003
+ source: SOURCE,
1004
+ code: pluginName
1005
+ });
1006
+ };
620
1007
  }
621
1008
  return {
622
- getId() {
623
- return "html";
624
- },
625
- async doComplete(document, position, documentContext, htmlData) {
626
- setHtmlDataProviders(htmlData);
627
- const htmlDocument = htmlDocuments.get(document);
628
- const completionList = await htmlLanguageService.doComplete2(document, position, htmlDocument, documentContext);
629
- for (const item of completionList.items) item.sortText = "0_" + item.label;
630
- return completionList;
631
- },
632
- async doHover(document, position, htmlData) {
633
- setHtmlDataProviders(htmlData);
634
- return htmlLanguageService.doHover(document, position, htmlDocuments.get(document));
635
- },
636
- async onDocumentRemoved(document) {
637
- htmlDocuments.onDocumentRemoved(document);
638
- },
639
- async findDocumentLinks(document, documentContext, projectRoot) {
640
- const resolver = new Resolver(projectRoot);
641
- const documentFs = vscodeUri.URI.parse(document.uri).fsPath;
642
- const projectRootRelative = path.relative(projectRoot, documentFs);
643
- const links = htmlLanguageService.findDocumentLinks(document, documentContext);
644
- for (const link of links) {
645
- if (!link.target) continue;
646
- const linkFs = vscodeUri.URI.parse(link.target ?? "").fsPath;
647
- const source = path.relative(path.dirname(documentFs), linkFs);
648
- const resolved = resolver.resolve(source, projectRootRelative);
649
- if (resolved) link.target = vscodeUri.URI.file(resolved.path).toString();
650
- }
651
- return links;
652
- },
653
- dispose() {
654
- htmlDocuments.dispose();
655
- }
1009
+ error: reportAs(DiagnosticSeverity.Error),
1010
+ warn: reportAs(DiagnosticSeverity.Warning),
1011
+ info: reportAs(DiagnosticSeverity.Information),
1012
+ hint: reportAs(DiagnosticSeverity.Hint)
656
1013
  };
657
1014
  }
1015
+ /** An element underlines its tag name, an attribute its value or else its name, a range itself. */
1016
+ function rangeOf(target) {
1017
+ if (isElement(target)) return target.nameRange;
1018
+ if (isAttribute(target)) return target.valueRange ?? target.nameRange;
1019
+ return target;
1020
+ }
1021
+ /** Only an element has children. */
1022
+ function isElement(target) {
1023
+ return "children" in target;
1024
+ }
1025
+ /** Only an attribute is on an element. */
1026
+ function isAttribute(target) {
1027
+ return "element" in target;
1028
+ }
658
1029
 
659
1030
  //#endregion
660
- //#region src/modes/language-modes.ts
661
- function isCompletionItemData(value) {
662
- return value && typeof value.languageId === "string" && typeof value.uri === "string" && typeof value.offset === "number";
663
- }
664
- const FILE_PROTOCOL = "staticbolt-server";
665
- function getLanguageModes(clientCapabilities, requestService) {
666
- const htmlLanguageService = vscodeHtml.getLanguageService({
667
- clientCapabilities,
668
- fileSystemProvider: requestService,
669
- customDataProviders: [{
670
- getId() {
671
- return FILE_PROTOCOL;
672
- },
673
- isApplicable(languageId) {
674
- return languageId === "html";
675
- },
676
- provideValues(tag, attribute) {
677
- return [{ name: `tag:${tag} attribute:${attribute}` }];
678
- },
679
- provideTags() {
680
- return [{
681
- name: "staticbolt",
682
- description: "# staticbolt-description",
683
- attributes: []
684
- }];
685
- },
686
- provideAttributes(tag) {
687
- if (tag === "staticbolt") return [{
688
- name: "staticbolt-attribute",
689
- description: "# staticbolt-attribute-description",
690
- values: [{ name: "staticbolt-attribute-value" }]
691
- }];
692
- return [{ name: "global-attribute" }];
693
- }
694
- }],
695
- useDefaultDataProvider: false
696
- });
697
- const documentRegions = getLanguageModelCache(10, 60, (document) => getDocumentRegions(htmlLanguageService, document));
698
- let modelCaches = [documentRegions];
699
- let modes = Object.create(null);
700
- modes["html"] = getHTMLMode(htmlLanguageService);
1031
+ //#region src/helpers/document-context.ts
1032
+ /** A reference that carries its own scheme, `https:` or `mailto:` say. */
1033
+ const WITH_SCHEME = /^[a-z][\w+.-]*:/i;
1034
+ /**
1035
+ * How references in a document map to files: path aliases through the project's resolver, absolute paths against the project
1036
+ * root, everything else relative to the document.
1037
+ */
1038
+ function getDocumentContext(documentUri, resolver) {
1039
+ return { resolveReference(reference, base = documentUri) {
1040
+ if (WITH_SCHEME.test(reference)) return reference;
1041
+ const aliased = resolver.resolveAlias(reference);
1042
+ if (aliased !== void 0) return vscodeUri.URI.file(path.join(resolver.root, aliased)).toString(true);
1043
+ if (reference.startsWith("/")) return vscodeUri.URI.file(path.join(resolver.root, reference)).toString(true);
1044
+ const baseUri = vscodeUri.URI.parse(base);
1045
+ const baseDirectory = baseUri.path.endsWith("/") ? baseUri : vscodeUri.Utils.dirname(baseUri);
1046
+ return vscodeUri.Utils.resolvePath(baseDirectory, reference).toString(true);
1047
+ } };
1048
+ }
1049
+
1050
+ //#endregion
1051
+ //#region src/services/staticbolt-service.ts
1052
+ /** The id the merged data provider registers under with the language service. */
1053
+ const DATA_PROVIDER_ID = "staticbolt";
1054
+ /** The cursor inside a `src` or `href` value before any slash, capturing what is typed of its first segment. */
1055
+ const PATH_VALUE_START = /(?:src|href)\s*=\s*["']([^"'/\s]*)$/;
1056
+ /** Has the editor open the completions again, right after an item is taken. */
1057
+ const SUGGEST = {
1058
+ title: "Suggest",
1059
+ command: "editor.action.triggerSuggest"
1060
+ };
1061
+ /**
1062
+ * What the plugins add to HTML: their tags and attributes for completion and hover, path completion that knows the project's
1063
+ * aliases, links resolved the way the build resolves them, and the problems the plugins find. The editor's own HTML support
1064
+ * covers the standard elements, and stays out of the regions the plugins embed.
1065
+ */
1066
+ function createStaticboltService() {
701
1067
  return {
702
- async updateDataProviders(dataProviders) {
703
- htmlLanguageService.setDataProviders(true, dataProviders);
704
- },
705
- getHtmlDocument(document) {
706
- return documentRegions.get(document).getHtmlDocument();
707
- },
708
- getModeAtPosition(document, position) {
709
- const languageId = documentRegions.get(document).getLanguageAtPosition(position);
710
- if (languageId) return modes[languageId];
1068
+ name: "staticbolt",
1069
+ capabilities: {
1070
+ completionProvider: { triggerCharacters: [
1071
+ ".",
1072
+ ":",
1073
+ "<",
1074
+ "\"",
1075
+ "=",
1076
+ "/"
1077
+ ] },
1078
+ hoverProvider: true,
1079
+ documentLinkProvider: {},
1080
+ diagnosticProvider: {
1081
+ interFileDependencies: false,
1082
+ workspaceDiagnostics: false
1083
+ },
1084
+ semanticTokensProvider: { legend: {
1085
+ tokenTypes: [SemanticTokenTypes.operator],
1086
+ tokenModifiers: []
1087
+ } }
711
1088
  },
712
- getAllModesInDocument(document) {
713
- const result = [];
714
- for (const languageId of documentRegions.get(document).getLanguagesInDocument()) {
715
- const mode = modes[languageId];
716
- if (mode) result.push(mode);
1089
+ create(context) {
1090
+ const languageServices = /* @__PURE__ */ new WeakMap();
1091
+ /** The HTML language service that knows a project's tags and attributes. */
1092
+ function languageServiceOf(project) {
1093
+ let languageService = languageServices.get(project.htmlData);
1094
+ if (!languageService) {
1095
+ languageService = vscodeHtml.getLanguageService({
1096
+ clientCapabilities: context.env.clientCapabilities,
1097
+ fileSystemProvider: fileSystemOf(context),
1098
+ useDefaultDataProvider: false,
1099
+ customDataProviders: [createMergedHtmlDataProvider(DATA_PROVIDER_ID, project.htmlData)]
1100
+ });
1101
+ languageServices.set(project.htmlData, languageService);
1102
+ }
1103
+ return languageService;
717
1104
  }
718
- return result;
719
- },
720
- getMode(languageId) {
721
- return modes[languageId];
722
- },
723
- onDocumentRemoved(document) {
724
- for (const mc of modelCaches) mc.onDocumentRemoved(document);
725
- for (const mode in modes) modes[mode].onDocumentRemoved(document);
726
- },
727
- dispose() {
728
- for (const mc of modelCaches) mc.dispose();
729
- modelCaches = [];
730
- for (const mode in modes) modes[mode].dispose();
731
- modes = {};
1105
+ /** The document's code and project, or nothing when the document is no HTML of a loaded project. */
1106
+ function find(document) {
1107
+ if (document.languageId !== "html") return;
1108
+ const code = codeOf(context, document);
1109
+ if (!code?.project) return;
1110
+ return {
1111
+ code,
1112
+ project: code.project,
1113
+ languageService: languageServiceOf(code.project)
1114
+ };
1115
+ }
1116
+ return {
1117
+ async provideCompletionItems(document, position) {
1118
+ const found = find(document);
1119
+ if (!found) return;
1120
+ if (isInRegions(found.code.regions, document.offsetAt(position))) return;
1121
+ const { code, project, languageService } = found;
1122
+ const documentContext = getDocumentContext(code.uri.toString(), project.resolver);
1123
+ const list = await languageService.doComplete2(document, position, code.htmlDocument, documentContext);
1124
+ list.items.push(...aliasCompletions(document, position, project.resolver.aliases));
1125
+ for (const item of list.items) item.sortText = "0_" + item.label;
1126
+ return list;
1127
+ },
1128
+ provideHover(document, position) {
1129
+ const found = find(document);
1130
+ if (!found) return;
1131
+ if (isInRegions(found.code.regions, document.offsetAt(position))) return;
1132
+ return found.languageService.doHover(document, position, found.code.htmlDocument);
1133
+ },
1134
+ provideDocumentLinks(document) {
1135
+ const found = find(document);
1136
+ if (!found) return;
1137
+ const { code, project, languageService } = found;
1138
+ const documentPath = code.uri.fsPath;
1139
+ const documentContext = getDocumentContext(code.uri.toString(), project.resolver);
1140
+ const links = languageService.findDocumentLinks(document, documentContext);
1141
+ for (const link of links) {
1142
+ if (!link.target) continue;
1143
+ const source = path.relative(path.dirname(documentPath), vscodeUri.URI.parse(link.target).fsPath);
1144
+ const resolved = project.resolver.resolve(source, code.file);
1145
+ if (!resolved) continue;
1146
+ link.target = vscodeUri.URI.file(resolved.path).toString();
1147
+ }
1148
+ return links;
1149
+ },
1150
+ /**
1151
+ * Colours the delimiters of the plugins' regions, the `{{` and `}}` of a placeholder: they are outside the code, so
1152
+ * nothing else colours them, and they would take the colour of whatever they sit in, an attribute's string say.
1153
+ */
1154
+ provideDocumentSemanticTokens(document, _range, legend) {
1155
+ const found = find(document);
1156
+ if (!found) return;
1157
+ const type = legend.tokenTypes.indexOf(SemanticTokenTypes.operator);
1158
+ const tokens = [];
1159
+ for (const region of found.code.regions) {
1160
+ if (!region.extent) continue;
1161
+ for (const [start, end] of [[region.extent.start, region.start], [region.end, region.extent.end]]) {
1162
+ if (end <= start) continue;
1163
+ const { line, character } = document.positionAt(start);
1164
+ tokens.push([
1165
+ line,
1166
+ character,
1167
+ end - start,
1168
+ type,
1169
+ 0
1170
+ ]);
1171
+ }
1172
+ }
1173
+ return tokens;
1174
+ },
1175
+ provideDiagnostics(document) {
1176
+ const found = find(document);
1177
+ if (!found) return;
1178
+ return validateDocument({
1179
+ document,
1180
+ info: found.code.info,
1181
+ project: found.project,
1182
+ console: context.env.console ?? console
1183
+ });
1184
+ }
1185
+ };
732
1186
  }
733
1187
  };
734
1188
  }
735
-
736
- //#endregion
737
- //#region src/requests.ts
738
- const FsStatRequest = { type: new RequestType("fs/stat") };
739
- const FsReadDirectoryRequest = { type: new RequestType("fs/readDir") };
740
- const FileType$1 = Object.freeze({
741
- /** The file type is unknown. */
742
- Unknown: 0,
743
- /** A regular file. */
744
- File: 1,
745
- /** A directory. */
746
- Directory: 2,
747
- /** A symbolic link to a file. */
748
- SymbolicLink: 64
749
- });
750
- function getFileSystemProvider(handledSchemas, connection, runtime) {
751
- const fileFs = runtime.fileFs && handledSchemas.includes("file") ? runtime.fileFs : void 0;
1189
+ /** The editor's file system, as the HTML language service reads it for path completions; nothing is there without one. */
1190
+ function fileSystemOf(context) {
1191
+ const missing = {
1192
+ type: vscodeHtml.FileType.Unknown,
1193
+ ctime: -1,
1194
+ mtime: -1,
1195
+ size: -1
1196
+ };
752
1197
  return {
753
1198
  async stat(uri) {
754
- if (fileFs && uri.startsWith("file:")) return fileFs.stat(uri);
755
- return await connection.sendRequest(FsStatRequest.type, uri);
1199
+ return await context.env.fs?.stat(vscodeUri.URI.parse(uri)) ?? missing;
756
1200
  },
757
- readDirectory(uri) {
758
- if (fileFs && uri.startsWith("file:")) return fileFs.readDirectory(uri);
759
- return connection.sendRequest(FsReadDirectoryRequest.type, uri);
1201
+ async readDirectory(uri) {
1202
+ return await context.env.fs?.readDirectory(vscodeUri.URI.parse(uri)) ?? [];
760
1203
  }
761
1204
  };
762
1205
  }
763
-
764
- //#endregion
765
- //#region src/utils/arrays.ts
766
- function pushAll(to, from) {
767
- if (from) for (const item of from) to.push(item);
1206
+ /** The root code of the document a service is asked about, whether it is the document itself or its embedded HTML copy. */
1207
+ function codeOf(context, document) {
1208
+ const uri = vscodeUri.URI.parse(document.uri);
1209
+ const [sourceUri] = context.decodeEmbeddedDocumentUri(uri) ?? [uri];
1210
+ const root = context.language.scripts.get(sourceUri)?.generated?.root;
1211
+ if (!(root instanceof StaticboltCode)) return;
1212
+ return root;
768
1213
  }
769
-
770
- //#endregion
771
- //#region src/utils/strings.ts
772
- function isStartingWith(haystack, needle) {
773
- if (haystack.length < needle.length) return false;
774
- for (let index = 0; index < needle.length; index++) if (haystack[index] !== needle[index]) return false;
775
- return true;
776
- }
777
- function isEndingWith(haystack, needle) {
778
- const diff = haystack.length - needle.length;
779
- if (diff > 0) return haystack.indexOf(needle, diff) === diff;
780
- if (diff === 0) return haystack === needle;
781
- return false;
782
- }
783
- const CR = "\r".codePointAt(0);
784
- const NL = "\n".codePointAt(0);
785
-
786
- //#endregion
787
- //#region src/utils/document-context.ts
788
- function getDocumentContext(documentUri, workspaceFolders) {
789
- function getRootFolder() {
790
- for (const folder of workspaceFolders) {
791
- let folderURI = folder.uri;
792
- if (!isEndingWith(folderURI, "/")) folderURI += "/";
793
- if (isStartingWith(documentUri, folderURI)) return folderURI;
794
- }
795
- }
796
- return { resolveReference: (reference, base = documentUri) => {
797
- if (/^\w[\w\d+.-]*:/.test(reference)) return reference;
798
- if (reference[0] === "/") {
799
- const folderUri = getRootFolder();
800
- if (folderUri) return folderUri + reference.slice(1);
801
- }
802
- const baseUri = vscodeUri.URI.parse(base);
803
- const baseUriDirectory = baseUri.path.endsWith("/") ? baseUri : vscodeUri.Utils.dirname(baseUri);
804
- return vscodeUri.Utils.resolvePath(baseUriDirectory, reference).toString(true);
805
- } };
1214
+ /**
1215
+ * The path aliases, offered at the start of a `src` or `href` value: a partly typed one completes, and a directory alias opens
1216
+ * its listing right away.
1217
+ */
1218
+ function aliasCompletions(document, position, aliases) {
1219
+ const lineBeforeCursor = document.getText({
1220
+ start: {
1221
+ line: position.line,
1222
+ character: 0
1223
+ },
1224
+ end: position
1225
+ });
1226
+ const typed = PATH_VALUE_START.exec(lineBeforeCursor)?.[1];
1227
+ if (typed === void 0) return [];
1228
+ const range = {
1229
+ start: {
1230
+ line: position.line,
1231
+ character: position.character - typed.length
1232
+ },
1233
+ end: position
1234
+ };
1235
+ return Object.keys(aliases).map((alias) => {
1236
+ const textEdit = TextEdit.replace(range, alias);
1237
+ if (alias.endsWith("/")) return {
1238
+ label: alias,
1239
+ kind: CompletionItemKind.Folder,
1240
+ textEdit,
1241
+ command: SUGGEST
1242
+ };
1243
+ return {
1244
+ label: alias,
1245
+ kind: CompletionItemKind.File,
1246
+ textEdit
1247
+ };
1248
+ });
806
1249
  }
807
1250
 
808
1251
  //#endregion
809
- //#region src/utils/find-project-root.ts
810
- function findProjectRoot(fileUri) {
811
- let directory = path$1.dirname(vscodeUri.URI.parse(fileUri).fsPath);
812
- while (true) {
813
- for (const config of [".staticbolt.ts", ".staticbolt.js"]) if (fs.existsSync(path$1.join(directory, config))) return directory;
814
- const parent = path$1.dirname(directory);
815
- if (parent === directory) return null;
816
- directory = parent;
1252
+ //#region src/helpers/syntax-tokens.ts
1253
+ /** A member name, as TypeScript names the ones it knows. */
1254
+ const PROPERTY = {
1255
+ type: SemanticTokenTypes.property,
1256
+ modifiers: []
1257
+ };
1258
+ /** The literals of the language as the standard types see them: read-only variables of the library. */
1259
+ const LITERAL = {
1260
+ type: SemanticTokenTypes.variable,
1261
+ modifiers: [SemanticTokenModifiers.readonly, SemanticTokenModifiers.defaultLibrary]
1262
+ };
1263
+ /**
1264
+ * The token types of what TypeScript's own tokens leave out, after the grammar scopes a TypeScript file gets them coloured by, so
1265
+ * an editor that maps them to those scopes colours the code as it colours TypeScript. Split where themes tell the scopes apart: a
1266
+ * `const` sits in `meta.var.expr`, a `class` in `meta.class`, an `import` in `meta.import`. The values are the standard types an
1267
+ * editor that knows only those is sent instead.
1268
+ */
1269
+ const SCOPED_TYPES = {
1270
+ /** `if`, `return`, `await`: `keyword.control`. */
1271
+ keywordControl: {
1272
+ type: SemanticTokenTypes.keyword,
1273
+ modifiers: []
1274
+ },
1275
+ /** `import`, `export`, `from`, `as`: `meta.import keyword.control.import`. */
1276
+ keywordControlImport: {
1277
+ type: SemanticTokenTypes.keyword,
1278
+ modifiers: []
1279
+ },
1280
+ /** `const`, `let`, `var`: `meta.var.expr storage.type`. */
1281
+ storageTypeVariable: {
1282
+ type: SemanticTokenTypes.modifier,
1283
+ modifiers: []
1284
+ },
1285
+ /** `function`: `meta.function storage.type.function`. */
1286
+ storageTypeFunction: {
1287
+ type: SemanticTokenTypes.modifier,
1288
+ modifiers: []
1289
+ },
1290
+ /** `class`: `meta.class storage.type.class`. */
1291
+ storageTypeClass: {
1292
+ type: SemanticTokenTypes.modifier,
1293
+ modifiers: []
1294
+ },
1295
+ /** `interface`, `type`, `enum`, `namespace`: `storage.type`. */
1296
+ storageType: {
1297
+ type: SemanticTokenTypes.modifier,
1298
+ modifiers: []
1299
+ },
1300
+ /** `async`, `static`, `readonly`, `extends`: `storage.modifier`. */
1301
+ storageModifier: {
1302
+ type: SemanticTokenTypes.modifier,
1303
+ modifiers: []
1304
+ },
1305
+ /** `typeof`, `instanceof`, `in`: `keyword.operator.expression`. */
1306
+ keywordOperatorExpression: {
1307
+ type: SemanticTokenTypes.keyword,
1308
+ modifiers: []
1309
+ },
1310
+ /** `new`: `new.expr keyword.operator.new`. */
1311
+ keywordOperatorNew: {
1312
+ type: SemanticTokenTypes.keyword,
1313
+ modifiers: []
1314
+ },
1315
+ /** `true`, `null`, `undefined`: `constant.language`. */
1316
+ constantLanguage: LITERAL,
1317
+ /** `this`, `super`: `variable.language`. */
1318
+ variableLanguage: LITERAL,
1319
+ /** `string`, `number`, `boolean`: `meta.type.annotation support.type.primitive`. */
1320
+ supportTypePrimitive: {
1321
+ type: SemanticTokenTypes.type,
1322
+ modifiers: [SemanticTokenModifiers.defaultLibrary]
817
1323
  }
1324
+ };
1325
+ /** The punctuation that is an operator; the brackets, separators and accessors are left to the default colour. */
1326
+ const OPERATORS = new Set("= == === != !== + - * / % ** ++ -- < > <= >= && || ?? ! ~ & | ^ << >> >>> ? : => ... += -= *= /= %= **= <<= >>= >>>= &= |= ^= &&= ||= ??=".split(" "));
1327
+ /** The keyword table, with the `SyntaxKind` values of the TypeScript in use. */
1328
+ function keywordsOf(typescript) {
1329
+ const { SyntaxKind } = typescript;
1330
+ const keywords = {};
1331
+ const table = [
1332
+ [[
1333
+ SyntaxKind.ImportKeyword,
1334
+ SyntaxKind.ExportKeyword,
1335
+ SyntaxKind.FromKeyword,
1336
+ SyntaxKind.AsKeyword
1337
+ ], "keywordControlImport"],
1338
+ [[
1339
+ SyntaxKind.ConstKeyword,
1340
+ SyntaxKind.LetKeyword,
1341
+ SyntaxKind.VarKeyword
1342
+ ], "storageTypeVariable"],
1343
+ [[SyntaxKind.FunctionKeyword], "storageTypeFunction"],
1344
+ [[SyntaxKind.ClassKeyword], "storageTypeClass"],
1345
+ [[
1346
+ SyntaxKind.InterfaceKeyword,
1347
+ SyntaxKind.TypeKeyword,
1348
+ SyntaxKind.EnumKeyword,
1349
+ SyntaxKind.NamespaceKeyword,
1350
+ SyntaxKind.ModuleKeyword
1351
+ ], "storageType"],
1352
+ [[
1353
+ SyntaxKind.AbstractKeyword,
1354
+ SyntaxKind.AccessorKeyword,
1355
+ SyntaxKind.AsyncKeyword,
1356
+ SyntaxKind.DeclareKeyword,
1357
+ SyntaxKind.ExtendsKeyword,
1358
+ SyntaxKind.ImplementsKeyword,
1359
+ SyntaxKind.OverrideKeyword,
1360
+ SyntaxKind.PrivateKeyword,
1361
+ SyntaxKind.ProtectedKeyword,
1362
+ SyntaxKind.PublicKeyword,
1363
+ SyntaxKind.ReadonlyKeyword,
1364
+ SyntaxKind.StaticKeyword
1365
+ ], "storageModifier"],
1366
+ [[
1367
+ SyntaxKind.DeleteKeyword,
1368
+ SyntaxKind.InKeyword,
1369
+ SyntaxKind.InferKeyword,
1370
+ SyntaxKind.InstanceOfKeyword,
1371
+ SyntaxKind.IsKeyword,
1372
+ SyntaxKind.KeyOfKeyword,
1373
+ SyntaxKind.OfKeyword,
1374
+ SyntaxKind.SatisfiesKeyword,
1375
+ SyntaxKind.TypeOfKeyword
1376
+ ], "keywordOperatorExpression"],
1377
+ [[SyntaxKind.NewKeyword], "keywordOperatorNew"],
1378
+ [[
1379
+ SyntaxKind.TrueKeyword,
1380
+ SyntaxKind.FalseKeyword,
1381
+ SyntaxKind.NullKeyword
1382
+ ], "constantLanguage"],
1383
+ [[SyntaxKind.ThisKeyword, SyntaxKind.SuperKeyword], "variableLanguage"],
1384
+ [[
1385
+ SyntaxKind.AnyKeyword,
1386
+ SyntaxKind.BigIntKeyword,
1387
+ SyntaxKind.BooleanKeyword,
1388
+ SyntaxKind.NeverKeyword,
1389
+ SyntaxKind.NumberKeyword,
1390
+ SyntaxKind.ObjectKeyword,
1391
+ SyntaxKind.StringKeyword,
1392
+ SyntaxKind.SymbolKeyword,
1393
+ SyntaxKind.UndefinedKeyword,
1394
+ SyntaxKind.UnknownKeyword
1395
+ ], "supportTypePrimitive"]
1396
+ ];
1397
+ for (const [kinds, type] of table) for (const kind of kinds) keywords[kind] = type;
1398
+ return keywords;
818
1399
  }
819
-
820
- //#endregion
821
- //#region src/utils/runner.ts
822
- function formatError(message, error) {
823
- if (error instanceof Error) return `${message}: ${error.message}\n${error.stack}`;
824
- if (typeof error === "string") return `${message}: ${error}`;
825
- if (error) return `${message}: ${error}`;
826
- return message;
827
- }
828
- function runSafe(runtime, function_, errorValue, errorMessage, token) {
829
- return new Promise((resolve) => {
830
- runtime.timer.setImmediate(() => {
831
- if (token.isCancellationRequested) {
832
- resolve(cancelValue());
1400
+ /**
1401
+ * A tokenizer for what TypeScript's own tokens leave out of a parsed text: keywords by what they are where they stand, literals,
1402
+ * comments and operators. The identifiers are left to TypeScript, which knows what each is, except the member names it knows
1403
+ * nothing about, a key of a `Record` say, which it leaves out: those are properties all the same. Unless `isScoped`, the keywords
1404
+ * are named as the nearest standard types.
1405
+ */
1406
+ function createSyntaxTokenizer(typescript, isScoped) {
1407
+ const keywords = keywordsOf(typescript);
1408
+ return (sourceFile, document, checker) => {
1409
+ const text = sourceFile.text;
1410
+ const tokens = [];
1411
+ const commentEnds = /* @__PURE__ */ new Set();
1412
+ /** Whether an identifier names a member TypeScript has no symbol for, so it will not colour it. */
1413
+ function isUnknownMember(node) {
1414
+ if (!checker || !typescript.isPropertyAccessExpression(node.parent) || node.parent.name !== node) return false;
1415
+ return checker.getSymbolAtLocation(node) === void 0;
1416
+ }
1417
+ /** The comments before a token, each once. */
1418
+ function collectComments(position) {
1419
+ const comments = typescript.getLeadingCommentRanges(text, position) ?? [];
1420
+ for (const comment of comments) {
1421
+ if (commentEnds.has(comment.end)) continue;
1422
+ commentEnds.add(comment.end);
1423
+ tokens.push(...splitLines(document, comment.pos, comment.end, {
1424
+ type: SemanticTokenTypes.comment,
1425
+ modifiers: []
1426
+ }));
1427
+ }
1428
+ }
1429
+ /** The tokens of a node and what is inside it. */
1430
+ function visit(node) {
1431
+ const children = node.getChildren(sourceFile);
1432
+ if (children.length === 0) {
1433
+ collectComments(node.getFullStart());
1434
+ const named = isUnknownMember(node) ? PROPERTY : nameOf(typescript, keywords, node, isScoped);
1435
+ if (named) tokens.push(...splitLines(document, node.getStart(sourceFile), node.getEnd(), named));
833
1436
  return;
834
1437
  }
835
- return function_().then((result) => {
836
- if (token.isCancellationRequested) {
837
- resolve(cancelValue());
838
- return;
839
- }
840
- resolve(result);
841
- }, (error) => {
842
- console.error(formatError(errorMessage, error));
843
- resolve(errorValue);
844
- });
845
- });
846
- });
1438
+ for (const child of children) visit(child);
1439
+ }
1440
+ visit(sourceFile);
1441
+ collectComments(sourceFile.endOfFileToken.getFullStart());
1442
+ return tokens;
1443
+ };
1444
+ }
1445
+ /** The type and modifiers of a token, or nothing for the identifiers, plain punctuation and anything that is not a token. */
1446
+ function nameOf(typescript, keywords, node, isScoped) {
1447
+ const { SyntaxKind } = typescript;
1448
+ const kind = node.kind;
1449
+ if (kind === SyntaxKind.Identifier) return node.getText() === "undefined" ? standardOrScoped("constantLanguage", isScoped) : void 0;
1450
+ if (kind === SyntaxKind.StringLiteral || isTemplatePart(typescript, kind)) return {
1451
+ type: SemanticTokenTypes.string,
1452
+ modifiers: []
1453
+ };
1454
+ if (kind === SyntaxKind.NumericLiteral || kind === SyntaxKind.BigIntLiteral) return {
1455
+ type: SemanticTokenTypes.number,
1456
+ modifiers: []
1457
+ };
1458
+ if (kind === SyntaxKind.RegularExpressionLiteral) return {
1459
+ type: SemanticTokenTypes.regexp,
1460
+ modifiers: []
1461
+ };
1462
+ if (kind >= SyntaxKind.FirstPunctuation && kind <= SyntaxKind.LastPunctuation) return OPERATORS.has(typescript.tokenToString(kind) ?? "") ? {
1463
+ type: SemanticTokenTypes.operator,
1464
+ modifiers: []
1465
+ } : void 0;
1466
+ if (kind < SyntaxKind.FirstKeyword || kind > SyntaxKind.LastKeyword) return;
1467
+ if (kind === SyntaxKind.VoidKeyword) return standardOrScoped(node.parent.kind === SyntaxKind.VoidExpression ? "keywordOperatorExpression" : "supportTypePrimitive", isScoped);
1468
+ return standardOrScoped(keywords[kind] ?? "keywordControl", isScoped);
847
1469
  }
848
- function cancelValue() {
849
- return new vscode.ResponseError(vscode.LSPErrorCodes.RequestCancelled, "Request cancelled");
1470
+ /** Whether a kind is one of the pieces a template literal is tokenized into. */
1471
+ function isTemplatePart(typescript, kind) {
1472
+ const { SyntaxKind } = typescript;
1473
+ return kind === SyntaxKind.NoSubstitutionTemplateLiteral || kind === SyntaxKind.TemplateHead || kind === SyntaxKind.TemplateMiddle || kind === SyntaxKind.TemplateTail;
850
1474
  }
851
-
852
- //#endregion
853
- //#region src/html-server.ts
854
- function startServer(connection, runtime) {
855
- const documents = new vscode$1.TextDocuments(TextDocument);
856
- documents.listen(connection);
857
- let lspSearchRoots = [];
858
- let workspaceFolders = [];
859
- let configManager;
860
- let languageModes;
861
- connection.onInitialize((parameters) => {
862
- if (Array.isArray(parameters.workspaceFolders)) lspSearchRoots = parameters.workspaceFolders;
863
- else {
864
- lspSearchRoots = [];
865
- if (parameters.rootPath) lspSearchRoots.push({
866
- name: "",
867
- uri: vscodeUri.URI.file(parameters.rootPath).toString()
868
- });
869
- }
870
- workspaceFolders = findStaticboltProjects(lspSearchRoots);
871
- connection.console.log(`[staticbolt] discovered projects:\n` + workspaceFolders.map((f) => ` - [${f.name}]: ${f.uri}`).join("\n"));
872
- const fileSystemProvider = getFileSystemProvider(["file"], connection, runtime);
873
- languageModes = getLanguageModes(parameters.capabilities, fileSystemProvider);
874
- configManager = new ConfigManager(connection.console);
875
- documents.onDidClose((document) => {
876
- languageModes.onDocumentRemoved(document.document);
877
- });
878
- connection.onShutdown(() => {
879
- languageModes.dispose();
880
- configManager.dispose();
1475
+ /** A scoped type itself, or the standard type it stands for. */
1476
+ function standardOrScoped(type, isScoped) {
1477
+ if (isScoped) return {
1478
+ type,
1479
+ modifiers: []
1480
+ };
1481
+ return SCOPED_TYPES[type];
1482
+ }
1483
+ /** A token per line of a stretch of the document, since a token may not span lines. */
1484
+ function splitLines(document, start, end, named) {
1485
+ const tokens = [];
1486
+ const first = document.positionAt(start);
1487
+ const last = document.positionAt(end);
1488
+ for (let line = first.line; line <= last.line; line++) {
1489
+ const character = line === first.line ? first.character : 0;
1490
+ const length = (line === last.line ? last.character : document.offsetAt({
1491
+ line: line + 1,
1492
+ character: 0
1493
+ }) - document.offsetAt({
1494
+ line,
1495
+ character: 0
1496
+ })) - character;
1497
+ if (length > 0) tokens.push({
1498
+ line,
1499
+ character,
1500
+ length,
1501
+ ...named
881
1502
  });
882
- return { capabilities: {
883
- textDocumentSync: vscode$1.TextDocumentSyncKind.Incremental,
884
- completionProvider: {
885
- resolveProvider: true,
886
- triggerCharacters: [
887
- ".",
888
- ":",
889
- "<",
890
- "\"",
891
- "=",
892
- "/"
893
- ]
894
- },
895
- hoverProvider: true,
896
- documentLinkProvider: { resolveProvider: false }
897
- } };
898
- });
899
- connection.onInitialized(() => {});
900
- connection.onCompletion(async (textDocumentPosition, token) => {
901
- return runSafe(runtime, async () => {
902
- const projectRoot = findProjectRoot(textDocumentPosition.textDocument.uri);
903
- if (!projectRoot) return null;
904
- const document = documents.get(textDocumentPosition.textDocument.uri);
905
- if (!document) return null;
906
- const mode = languageModes.getModeAtPosition(document, textDocumentPosition.position);
907
- if (!mode?.doComplete) return {
908
- isIncomplete: true,
909
- items: []
910
- };
911
- if (!await configManager.get(projectRoot)) return {
912
- isIncomplete: true,
913
- items: []
914
- };
915
- const htmlDocument = languageModes.getHtmlDocument(document);
916
- const documentContext = getDocumentContext(document.uri, workspaceFolders);
917
- return mode.doComplete(htmlDocument, textDocumentPosition.position, documentContext, configManager.lspHtmlData);
918
- }, null, `Error while computing completions for ${textDocumentPosition.textDocument.uri}`, token);
919
- });
920
- connection.onCompletionResolve((item, token) => {
921
- return runSafe(runtime, async () => {
922
- const data = item.data;
923
- if (!isCompletionItemData(data)) return item;
924
- const document = documents.get(data.uri);
925
- if (!document) return item;
926
- const mode = languageModes.getMode(data.languageId);
927
- if (!mode?.doResolve) return item;
928
- return mode.doResolve(languageModes.getHtmlDocument(document), item);
929
- }, item, `Error while resolving completion proposal`, token);
930
- });
931
- connection.onHover((textDocumentPosition, token) => {
932
- return runSafe(runtime, async () => {
933
- const projectRoot = findProjectRoot(textDocumentPosition.textDocument.uri);
934
- if (!projectRoot) return null;
935
- const document = documents.get(textDocumentPosition.textDocument.uri);
936
- if (!document) return null;
937
- const mode = languageModes.getModeAtPosition(document, textDocumentPosition.position);
938
- if (!mode?.doHover) return null;
939
- if (!await configManager.get(projectRoot)) return null;
940
- return mode.doHover(languageModes.getHtmlDocument(document), textDocumentPosition.position, configManager.lspHtmlData);
941
- }, null, `Error while computing hover for ${textDocumentPosition.textDocument.uri}`, token);
942
- });
943
- connection.onDocumentLinks((documentLinkParameter, token) => {
944
- return runSafe(runtime, async () => {
945
- const projectRoot = findProjectRoot(documentLinkParameter.textDocument.uri);
946
- if (!projectRoot) return null;
947
- const document = documents.get(documentLinkParameter.textDocument.uri);
948
- if (!document) return [];
949
- const links = [];
950
- const htmlDocument = languageModes.getHtmlDocument(document);
951
- const documentContext = getDocumentContext(document.uri, workspaceFolders);
952
- for (const mode of languageModes.getAllModesInDocument(document)) if (mode.findDocumentLinks) pushAll(links, await mode.findDocumentLinks(htmlDocument, documentContext, projectRoot));
953
- return links;
954
- }, [], `Error while document links for ${documentLinkParameter.textDocument.uri}`, token);
955
- });
956
- connection.listen();
1503
+ }
1504
+ return tokens;
957
1505
  }
958
1506
 
959
1507
  //#endregion
960
- //#region src/utils/node-fs.ts
961
- const FileType = Object.freeze({
962
- /** The file type is unknown. */
963
- Unknown: 0,
964
- /** A regular file. */
965
- File: 1,
966
- /** A directory. */
967
- Directory: 2,
968
- /** A symbolic link to a file. */
969
- SymbolicLink: 64
970
- });
971
- function getNodeFileFS() {
972
- function ensureFileUri(location) {
973
- if (!location.startsWith("file:")) throw new Error("fileSystemProvider can only handle file URLs");
974
- }
1508
+ //#region src/services/syntax-tokens-service.ts
1509
+ /**
1510
+ * Colours what TypeScript's own tokens leave out of the embedded code the editor's grammar cannot see, a placeholder say:
1511
+ * keywords, literals, operators and comments, named from TypeScript's parse of it. TypeScript names the identifiers; together
1512
+ * they colour the code the way a TypeScript file is coloured. The languages the editor colours itself get only the identifiers.
1513
+ * With `isScoped`, the keywords are sent as the scoped types an editor maps to grammar scopes, see `SCOPED_TYPES`; otherwise as
1514
+ * the nearest standard types.
1515
+ */
1516
+ function createSyntaxTokensService(typescript, isScoped) {
1517
+ const tokenize = createSyntaxTokenizer(typescript, isScoped);
975
1518
  return {
976
- stat(location) {
977
- ensureFileUri(location);
978
- return new Promise((c, error_) => {
979
- const uri = vscodeUri.URI.parse(location);
980
- fs.stat(uri.fsPath, (error, stats) => {
981
- if (error) return error.code === "ENOENT" ? c({
982
- type: FileType.Unknown,
983
- ctime: -1,
984
- mtime: -1,
985
- size: -1
986
- }) : error_(error);
987
- let type = FileType.Unknown;
988
- if (stats.isFile()) type = FileType.File;
989
- else if (stats.isDirectory()) type = FileType.Directory;
990
- else if (stats.isSymbolicLink()) type = FileType.SymbolicLink;
991
- c({
992
- type,
993
- ctime: stats.ctime.getTime(),
994
- mtime: stats.mtime.getTime(),
995
- size: stats.size
1519
+ name: "staticbolt-syntax-tokens",
1520
+ capabilities: { semanticTokensProvider: { legend: {
1521
+ tokenTypes: [...Object.values(SemanticTokenTypes), ...Object.keys(SCOPED_TYPES)],
1522
+ tokenModifiers: Object.values(SemanticTokenModifiers)
1523
+ } } },
1524
+ create(context) {
1525
+ /**
1526
+ * The parsed file of an embedded document as the project's TypeScript holds it, with the checker that knows its symbols, or
1527
+ * a fresh parse alone when the project has none.
1528
+ */
1529
+ function parse(document, fileName) {
1530
+ const program = context.inject("typescript/languageService")?.getProgram();
1531
+ const parsed = program?.getSourceFile(fileName);
1532
+ if (program && parsed && parsed.text === document.getText()) return [parsed, program.getTypeChecker()];
1533
+ return [typescript.createSourceFile(fileName, document.getText(), typescript.ScriptTarget.Latest, true), void 0];
1534
+ }
1535
+ return { provideDocumentSemanticTokens(document, _range, legend) {
1536
+ if (document.languageId !== "typescript") return;
1537
+ const embedded = embeddedOf(context, document);
1538
+ if (!embedded || embedded.group.language.isColouredByEditor) return;
1539
+ const [sourceFile, checker] = parse(document, embedded.fileName);
1540
+ const tokens = [];
1541
+ for (const token of tokenize(sourceFile, document, checker)) {
1542
+ const type = legend.tokenTypes.indexOf(token.type);
1543
+ if (type === -1) continue;
1544
+ const offset = document.offsetAt({
1545
+ line: token.line,
1546
+ character: token.character
996
1547
  });
997
- });
998
- });
999
- },
1000
- readDirectory(location) {
1001
- ensureFileUri(location);
1002
- return new Promise((c, error_) => {
1003
- const path = vscodeUri.URI.parse(location).fsPath;
1004
- fs.readdir(path, { withFileTypes: true }, (error, children) => {
1005
- if (error) return error_(error);
1006
- c(children.map((stat) => {
1007
- if (stat.isSymbolicLink()) return [stat.name, FileType.SymbolicLink];
1008
- if (stat.isDirectory()) return [stat.name, FileType.Directory];
1009
- return stat.isFile() ? [stat.name, FileType.File] : [stat.name, FileType.Unknown];
1010
- }));
1011
- });
1012
- });
1548
+ if (isInRegions(embedded.group.holes, offset)) continue;
1549
+ let modifiers = 0;
1550
+ for (const modifier of token.modifiers) {
1551
+ const bit = legend.tokenModifiers.indexOf(modifier);
1552
+ if (bit === -1) continue;
1553
+ modifiers |= 1 << bit;
1554
+ }
1555
+ tokens.push([
1556
+ token.line,
1557
+ token.character,
1558
+ token.length,
1559
+ type,
1560
+ modifiers
1561
+ ]);
1562
+ }
1563
+ return tokens;
1564
+ } };
1013
1565
  }
1014
1566
  };
1015
1567
  }
1568
+ /** The plugin language an embedded document belongs to and its TypeScript file name, as `getExtraServiceScripts` names it. */
1569
+ function embeddedOf(context, document) {
1570
+ const decoded = context.decodeEmbeddedDocumentUri(URI.parse(document.uri));
1571
+ if (!decoded) return;
1572
+ const [sourceUri, codeId] = decoded;
1573
+ const root = context.language.scripts.get(sourceUri)?.generated?.root;
1574
+ if (!(root instanceof StaticboltCode)) return;
1575
+ const group = root.languages.find((candidate) => candidate.id === codeId);
1576
+ if (!group) return;
1577
+ const documentFileName = context.project.typescript?.uriConverter.asFileName(sourceUri) ?? sourceUri.fsPath;
1578
+ return {
1579
+ group,
1580
+ fileName: embeddedFileName(documentFileName, codeId)
1581
+ };
1582
+ }
1583
+
1584
+ //#endregion
1585
+ //#region src/services/typescript-service.ts
1586
+ /**
1587
+ * TypeScript's features for the embedded codes, through Volar's TypeScript service: completion, hover, diagnostics, semantic
1588
+ * tokens, definitions, references, rename, folding and the rest. Formatting is left out: a document is HTML to the editor, and
1589
+ * its own formatter takes care of the whole of it.
1590
+ */
1591
+ function createTypeScriptServices(typescript) {
1592
+ return create(typescript).map((plugin) => ({
1593
+ ...plugin,
1594
+ capabilities: {
1595
+ ...plugin.capabilities,
1596
+ documentFormattingProvider: void 0,
1597
+ documentOnTypeFormattingProvider: void 0
1598
+ }
1599
+ }));
1600
+ }
1016
1601
 
1017
1602
  //#endregion
1018
1603
  //#region src/index.ts
1019
- const connection = vs.createConnection();
1604
+ /** The initialization options, with anything that is not an object read as none. */
1605
+ function initializationOptionsOf(parameters) {
1606
+ const options = parameters.initializationOptions;
1607
+ if (typeof options !== "object" || options === null) return {};
1608
+ return options;
1609
+ }
1610
+ /** The LSP connection to the editor, over stdio. */
1611
+ const connection = createConnection();
1612
+ /** Volar's server on top of the connection: documents, projects and the language features. */
1613
+ const server = createServer(connection);
1020
1614
  /**
1021
1615
  * `RemoteConsole` takes a single string, but the shared logger calls `console` with several arguments and colours them with
1022
1616
  * chalk. Passing the methods straight through drops everything after the first argument, and the output panel renders no ANSI —
1023
1617
  * so format the arguments the way `console` would, then strip the escapes.
1024
1618
  */
1025
1619
  function forward(write) {
1026
- return (...messages) => write(stripVTControlCharacters(format(...messages)));
1620
+ return (...messages) => {
1621
+ write(stripVTControlCharacters(format(...messages)));
1622
+ };
1027
1623
  }
1028
1624
  console.log = forward(connection.console.log.bind(connection.console));
1029
1625
  console.info = forward(connection.console.info.bind(connection.console));
1030
1626
  console.warn = forward(connection.console.warn.bind(connection.console));
1031
1627
  console.error = forward(connection.console.error.bind(connection.console));
1032
1628
  process.on("unhandledRejection", (error) => {
1033
- connection.console.error(formatError(`Unhandled exception`, error));
1629
+ console.error("[staticbolt] unhandled rejection:", error);
1630
+ });
1631
+ /** Whether a directory holds TypeScript with its API, which the native builds do not ship. */
1632
+ function hasTypeScriptApi(tsdk) {
1633
+ return existsSync(path.join(tsdk, "typescript.js"));
1634
+ }
1635
+ /**
1636
+ * The directory of the TypeScript to run: the editor's choice from the initialization options, the `--tsdk` argument, or the
1637
+ * nearest `typescript` package with an API installed above the working directory.
1638
+ */
1639
+ function findTsdk(parameters) {
1640
+ const options = initializationOptionsOf(parameters);
1641
+ if (options.typescript?.tsdk) return options.typescript.tsdk;
1642
+ const argument = process.argv.find((value) => value.startsWith("--tsdk="));
1643
+ if (argument) return argument.slice(7);
1644
+ let directory = process.cwd();
1645
+ while (true) {
1646
+ const tsdk = path.join(directory, "node_modules", "typescript", "lib");
1647
+ if (hasTypeScriptApi(tsdk)) return tsdk;
1648
+ const parent = path.dirname(directory);
1649
+ if (parent === directory) return;
1650
+ directory = parent;
1651
+ }
1652
+ }
1653
+ /** The workspace folders, or the root uri of an editor that has no folders. */
1654
+ function foldersOf(parameters) {
1655
+ if (parameters.workspaceFolders) return parameters.workspaceFolders;
1656
+ if (parameters.rootUri) return [{
1657
+ name: "",
1658
+ uri: parameters.rootUri
1659
+ }];
1660
+ return [];
1661
+ }
1662
+ /** The staticbolt projects of the workspace, from initialization on. */
1663
+ let projects;
1664
+ connection.listen();
1665
+ connection.onInitialize(async (parameters) => {
1666
+ const tsdk = findTsdk(parameters);
1667
+ if (tsdk === void 0 || !hasTypeScriptApi(tsdk)) throw new Error(`[staticbolt] no TypeScript with an API ${tsdk ? `at ${tsdk}` : "found"}; point typescript.tsdk or --tsdk at one, 6.x say`);
1668
+ const { typescript, diagnosticMessages } = loadTsdkByPath(tsdk, parameters.locale);
1669
+ console.log(`[staticbolt] TypeScript ${typescript.version} from ${tsdk}`);
1670
+ let isInitialized = false;
1671
+ const workspace = new Projects(connection.console, () => {
1672
+ if (!isInitialized) return;
1673
+ server.project.reload();
1674
+ });
1675
+ projects = workspace;
1676
+ const roots = findProjectRoots(foldersOf(parameters));
1677
+ console.log(`[staticbolt] discovered projects:\n${roots.map((root) => ` - ${root}`).join("\n")}`);
1678
+ await Promise.all(roots.map((root) => workspace.get(root)));
1679
+ isInitialized = true;
1680
+ const languagePlugin = createLanguagePlugin(typescript, workspace);
1681
+ const project = createTypeScriptProject(typescript, diagnosticMessages, () => ({ languagePlugins: [languagePlugin] }));
1682
+ const services = [
1683
+ createStaticboltService(),
1684
+ createSyntaxTokensService(typescript, initializationOptionsOf(parameters).scopedTokens === true),
1685
+ ...createTypeScriptServices(typescript)
1686
+ ];
1687
+ return server.initialize(parameters, project, services);
1688
+ });
1689
+ /** A TypeScript config, or one of those a config extends. */
1690
+ const TS_CONFIG = /\/(?:tsconfig|jsconfig)[^/]*\.json$/;
1691
+ connection.onInitialized(() => {
1692
+ server.initialized();
1693
+ server.fileWatcher.watchFiles(["**/*.{ts,mts,cts,js,mjs,cjs}", "**/{tsconfig,jsconfig}*.json"]);
1694
+ server.fileWatcher.onDidChangeWatchedFiles(({ changes }) => {
1695
+ if (changes.every((change) => !TS_CONFIG.test(change.uri))) return;
1696
+ server.project.reload();
1697
+ });
1698
+ });
1699
+ connection.onShutdown(async () => {
1700
+ await projects?.dispose();
1701
+ server.shutdown();
1034
1702
  });
1035
- const runtime = {
1036
- timer: {
1037
- setImmediate(callback, ...arguments_) {
1038
- const handle = setImmediate(callback, ...arguments_);
1039
- return { dispose: () => clearImmediate(handle) };
1040
- },
1041
- setTimeout(callback, ms, ...arguments_) {
1042
- const handle = setTimeout(callback, ms, ...arguments_);
1043
- return { dispose: () => clearTimeout(handle) };
1044
- }
1045
- },
1046
- fileFs: getNodeFileFS()
1047
- };
1048
- connection.console.log("[staticbolt] starting server");
1049
- startServer(connection, runtime);
1050
1703
 
1051
1704
  //#endregion
1052
1705
  export { };