@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/README.md +31 -0
- package/lib/index.mjs +1404 -751
- package/lib/index.mjs.map +1 -1
- package/package.json +8 -4
- package/src/helpers/document-context.ts +36 -0
- package/src/helpers/document-elements.ts +119 -0
- package/src/helpers/document-info.ts +29 -0
- package/src/{modes → helpers}/markdown-regions.ts +25 -35
- package/src/helpers/merge-html-data.ts +79 -33
- package/src/helpers/regions.ts +101 -0
- package/src/helpers/syntax-tokens.ts +327 -0
- package/src/helpers/validation.ts +94 -0
- package/src/helpers/virtual-document.ts +109 -0
- package/src/index.ts +151 -25
- package/src/language-plugin.ts +67 -0
- package/src/projects.ts +305 -0
- package/src/services/staticbolt-service.ts +257 -0
- package/src/services/syntax-tokens-service.ts +127 -0
- package/src/services/typescript-service.ts +20 -0
- package/src/virtual-code.ts +196 -0
- package/src/helpers/config-loader.ts +0 -244
- package/src/helpers/find-projects.ts +0 -23
- package/src/html-server.ts +0 -191
- package/src/language-model-cache.ts +0 -91
- package/src/modes/embedded-support.ts +0 -150
- package/src/modes/html-mode.ts +0 -83
- package/src/modes/language-modes.ts +0 -156
- package/src/requests.ts +0 -72
- package/src/utils/arrays.ts +0 -73
- package/src/utils/document-context.ts +0 -44
- package/src/utils/find-project-root.ts +0 -24
- package/src/utils/node-fs.ts +0 -77
- package/src/utils/runner.ts +0 -56
- package/src/utils/strings.ts +0 -76
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
|
|
3
|
-
import
|
|
4
|
-
import
|
|
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 {
|
|
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/
|
|
19
|
-
/**
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
for (const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
107
|
+
return attributes;
|
|
187
108
|
}
|
|
188
109
|
|
|
189
110
|
//#endregion
|
|
190
|
-
//#region src/
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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/
|
|
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 (
|
|
282
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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/
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
451
|
+
id: HTML_ID,
|
|
452
|
+
languageId: "html",
|
|
453
|
+
snapshot: typescript.ScriptSnapshot.fromString(html),
|
|
454
|
+
mappings: [identityMapping(html.length)]
|
|
398
455
|
};
|
|
399
456
|
}
|
|
400
|
-
/**
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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"
|
|
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 = (
|
|
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
|
|
825
|
+
if (parts.length === 0) return;
|
|
473
826
|
const kind = mergeDescriptionKind(defined);
|
|
474
|
-
const
|
|
475
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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((
|
|
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/
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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/
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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/
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
849
|
-
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
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
|
-
|
|
883
|
-
|
|
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/
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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 { };
|