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