@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.
@@ -0,0 +1,327 @@
1
+ import { SemanticTokenModifiers, SemanticTokenTypes } from "vscode-languageserver-protocol";
2
+
3
+ import type * as ts from "typescript";
4
+ import type { TextDocument } from "vscode-languageserver-textdocument";
5
+
6
+ /** A token with the names of its type and modifiers. */
7
+ export interface SyntaxToken {
8
+ /** The zero-based line the token is on. */
9
+ line: number;
10
+
11
+ /** The zero-based character the token starts at. */
12
+ character: number;
13
+
14
+ /** The number of characters the token spans. */
15
+ length: number;
16
+
17
+ /** The name of the token type. */
18
+ type: string;
19
+
20
+ /** The names of the token modifiers. */
21
+ modifiers: readonly string[];
22
+ }
23
+
24
+ /** A standard token type with modifiers, what an editor that knows only the standard types is sent for a scoped type. */
25
+ interface StandardType {
26
+ /** The standard type. */
27
+ type: string;
28
+
29
+ /** Its modifiers. */
30
+ modifiers: readonly string[];
31
+ }
32
+
33
+ /** A member name, as TypeScript names the ones it knows. */
34
+ const PROPERTY: StandardType = { type: SemanticTokenTypes.property, modifiers: [] };
35
+
36
+ /** The literals of the language as the standard types see them: read-only variables of the library. */
37
+ const LITERAL: StandardType = {
38
+ type: SemanticTokenTypes.variable,
39
+ modifiers: [SemanticTokenModifiers.readonly, SemanticTokenModifiers.defaultLibrary],
40
+ };
41
+
42
+ /**
43
+ * The token types of what TypeScript's own tokens leave out, after the grammar scopes a TypeScript file gets them coloured by, so
44
+ * an editor that maps them to those scopes colours the code as it colours TypeScript. Split where themes tell the scopes apart: a
45
+ * `const` sits in `meta.var.expr`, a `class` in `meta.class`, an `import` in `meta.import`. The values are the standard types an
46
+ * editor that knows only those is sent instead.
47
+ */
48
+ export const SCOPED_TYPES = {
49
+ /** `if`, `return`, `await`: `keyword.control`. */
50
+ keywordControl: { type: SemanticTokenTypes.keyword, modifiers: [] },
51
+
52
+ /** `import`, `export`, `from`, `as`: `meta.import keyword.control.import`. */
53
+ keywordControlImport: { type: SemanticTokenTypes.keyword, modifiers: [] },
54
+
55
+ /** `const`, `let`, `var`: `meta.var.expr storage.type`. */
56
+ storageTypeVariable: { type: SemanticTokenTypes.modifier, modifiers: [] },
57
+
58
+ /** `function`: `meta.function storage.type.function`. */
59
+ storageTypeFunction: { type: SemanticTokenTypes.modifier, modifiers: [] },
60
+
61
+ /** `class`: `meta.class storage.type.class`. */
62
+ storageTypeClass: { type: SemanticTokenTypes.modifier, modifiers: [] },
63
+
64
+ /** `interface`, `type`, `enum`, `namespace`: `storage.type`. */
65
+ storageType: { type: SemanticTokenTypes.modifier, modifiers: [] },
66
+
67
+ /** `async`, `static`, `readonly`, `extends`: `storage.modifier`. */
68
+ storageModifier: { type: SemanticTokenTypes.modifier, modifiers: [] },
69
+
70
+ /** `typeof`, `instanceof`, `in`: `keyword.operator.expression`. */
71
+ keywordOperatorExpression: { type: SemanticTokenTypes.keyword, modifiers: [] },
72
+
73
+ /** `new`: `new.expr keyword.operator.new`. */
74
+ keywordOperatorNew: { type: SemanticTokenTypes.keyword, modifiers: [] },
75
+
76
+ /** `true`, `null`, `undefined`: `constant.language`. */
77
+ constantLanguage: LITERAL,
78
+
79
+ /** `this`, `super`: `variable.language`. */
80
+ variableLanguage: LITERAL,
81
+
82
+ /** `string`, `number`, `boolean`: `meta.type.annotation support.type.primitive`. */
83
+ supportTypePrimitive: { type: SemanticTokenTypes.type, modifiers: [SemanticTokenModifiers.defaultLibrary] },
84
+ } as const satisfies Record<string, StandardType>;
85
+
86
+ /** A scoped token type. */
87
+ type ScopedType = keyof typeof SCOPED_TYPES;
88
+
89
+ /** The scoped type of each keyword TypeScript tokenizes as one; the control flow keywords are the rest. */
90
+ type Keywords = Partial<Record<ts.SyntaxKind, ScopedType>>;
91
+
92
+ /** Names the tokens of a parsed TypeScript text. */
93
+ export type SyntaxTokenizer = (sourceFile: ts.SourceFile, document: TextDocument, checker?: ts.TypeChecker) => SyntaxToken[];
94
+
95
+ /** The punctuation that is an operator; the brackets, separators and accessors are left to the default colour. */
96
+ const OPERATORS = new Set(
97
+ (
98
+ "= == === != !== + - * / % ** ++ -- < > <= >= && || ?? ! ~ & | ^ << >> >>> ? : => ... += -= *= /= %= **= <<= >>= >>>= " +
99
+ "&= |= ^= &&= ||= ??="
100
+ ).split(" ")
101
+ );
102
+
103
+ /** The keyword table, with the `SyntaxKind` values of the TypeScript in use. */
104
+ function keywordsOf(typescript: typeof ts): Keywords {
105
+ const { SyntaxKind } = typescript;
106
+ const keywords: Keywords = {};
107
+
108
+ const table: [ts.SyntaxKind[], ScopedType][] = [
109
+ [[SyntaxKind.ImportKeyword, SyntaxKind.ExportKeyword, SyntaxKind.FromKeyword, SyntaxKind.AsKeyword], "keywordControlImport"],
110
+ [[SyntaxKind.ConstKeyword, SyntaxKind.LetKeyword, SyntaxKind.VarKeyword], "storageTypeVariable"],
111
+ [[SyntaxKind.FunctionKeyword], "storageTypeFunction"],
112
+ [[SyntaxKind.ClassKeyword], "storageTypeClass"],
113
+ [
114
+ [
115
+ SyntaxKind.InterfaceKeyword,
116
+ SyntaxKind.TypeKeyword,
117
+ SyntaxKind.EnumKeyword,
118
+ SyntaxKind.NamespaceKeyword,
119
+ SyntaxKind.ModuleKeyword,
120
+ ],
121
+ "storageType",
122
+ ],
123
+ [
124
+ [
125
+ SyntaxKind.AbstractKeyword,
126
+ SyntaxKind.AccessorKeyword,
127
+ SyntaxKind.AsyncKeyword,
128
+ SyntaxKind.DeclareKeyword,
129
+ SyntaxKind.ExtendsKeyword,
130
+ SyntaxKind.ImplementsKeyword,
131
+ SyntaxKind.OverrideKeyword,
132
+ SyntaxKind.PrivateKeyword,
133
+ SyntaxKind.ProtectedKeyword,
134
+ SyntaxKind.PublicKeyword,
135
+ SyntaxKind.ReadonlyKeyword,
136
+ SyntaxKind.StaticKeyword,
137
+ ],
138
+ "storageModifier",
139
+ ],
140
+ [
141
+ [
142
+ SyntaxKind.DeleteKeyword,
143
+ SyntaxKind.InKeyword,
144
+ SyntaxKind.InferKeyword,
145
+ SyntaxKind.InstanceOfKeyword,
146
+ SyntaxKind.IsKeyword,
147
+ SyntaxKind.KeyOfKeyword,
148
+ SyntaxKind.OfKeyword,
149
+ SyntaxKind.SatisfiesKeyword,
150
+ SyntaxKind.TypeOfKeyword,
151
+ ],
152
+ "keywordOperatorExpression",
153
+ ],
154
+ [[SyntaxKind.NewKeyword], "keywordOperatorNew"],
155
+ [[SyntaxKind.TrueKeyword, SyntaxKind.FalseKeyword, SyntaxKind.NullKeyword], "constantLanguage"],
156
+ [[SyntaxKind.ThisKeyword, SyntaxKind.SuperKeyword], "variableLanguage"],
157
+ [
158
+ [
159
+ SyntaxKind.AnyKeyword,
160
+ SyntaxKind.BigIntKeyword,
161
+ SyntaxKind.BooleanKeyword,
162
+ SyntaxKind.NeverKeyword,
163
+ SyntaxKind.NumberKeyword,
164
+ SyntaxKind.ObjectKeyword,
165
+ SyntaxKind.StringKeyword,
166
+ SyntaxKind.SymbolKeyword,
167
+ SyntaxKind.UndefinedKeyword,
168
+ SyntaxKind.UnknownKeyword,
169
+ ],
170
+ "supportTypePrimitive",
171
+ ],
172
+ ];
173
+
174
+ for (const [kinds, type] of table) {
175
+ for (const kind of kinds) {
176
+ keywords[kind] = type;
177
+ }
178
+ }
179
+
180
+ return keywords;
181
+ }
182
+
183
+ /**
184
+ * A tokenizer for what TypeScript's own tokens leave out of a parsed text: keywords by what they are where they stand, literals,
185
+ * comments and operators. The identifiers are left to TypeScript, which knows what each is, except the member names it knows
186
+ * nothing about, a key of a `Record` say, which it leaves out: those are properties all the same. Unless `isScoped`, the keywords
187
+ * are named as the nearest standard types.
188
+ */
189
+ export function createSyntaxTokenizer(typescript: typeof ts, isScoped: boolean): SyntaxTokenizer {
190
+ const keywords = keywordsOf(typescript);
191
+
192
+ return (sourceFile, document, checker) => {
193
+ const text = sourceFile.text;
194
+ const tokens: SyntaxToken[] = [];
195
+ const commentEnds = new Set<number>();
196
+
197
+ /** Whether an identifier names a member TypeScript has no symbol for, so it will not colour it. */
198
+ function isUnknownMember(node: ts.Node): boolean {
199
+ if (!checker || !typescript.isPropertyAccessExpression(node.parent) || node.parent.name !== node) {
200
+ return false;
201
+ }
202
+
203
+ return checker.getSymbolAtLocation(node) === undefined;
204
+ }
205
+
206
+ /** The comments before a token, each once. */
207
+ function collectComments(position: number): void {
208
+ const comments = typescript.getLeadingCommentRanges(text, position) ?? [];
209
+
210
+ for (const comment of comments) {
211
+ if (commentEnds.has(comment.end)) continue;
212
+
213
+ commentEnds.add(comment.end);
214
+ tokens.push(...splitLines(document, comment.pos, comment.end, { type: SemanticTokenTypes.comment, modifiers: [] }));
215
+ }
216
+ }
217
+
218
+ /** The tokens of a node and what is inside it. */
219
+ function visit(node: ts.Node): void {
220
+ const children = node.getChildren(sourceFile);
221
+
222
+ if (children.length === 0) {
223
+ collectComments(node.getFullStart());
224
+
225
+ const named = isUnknownMember(node) ? PROPERTY : nameOf(typescript, keywords, node, isScoped);
226
+ if (named) {
227
+ tokens.push(...splitLines(document, node.getStart(sourceFile), node.getEnd(), named));
228
+ }
229
+
230
+ return;
231
+ }
232
+
233
+ for (const child of children) {
234
+ visit(child);
235
+ }
236
+ }
237
+
238
+ visit(sourceFile);
239
+ collectComments(sourceFile.endOfFileToken.getFullStart());
240
+
241
+ return tokens;
242
+ };
243
+ }
244
+
245
+ /** The type and modifiers of a token, or nothing for the identifiers, plain punctuation and anything that is not a token. */
246
+ function nameOf(typescript: typeof ts, keywords: Keywords, node: ts.Node, isScoped: boolean): StandardType | undefined {
247
+ const { SyntaxKind } = typescript;
248
+ const kind = node.kind;
249
+
250
+ if (kind === SyntaxKind.Identifier) {
251
+ return node.getText() === "undefined" ? standardOrScoped("constantLanguage", isScoped) : undefined;
252
+ }
253
+
254
+ if (kind === SyntaxKind.StringLiteral || isTemplatePart(typescript, kind)) {
255
+ return { type: SemanticTokenTypes.string, modifiers: [] };
256
+ }
257
+
258
+ if (kind === SyntaxKind.NumericLiteral || kind === SyntaxKind.BigIntLiteral) {
259
+ return { type: SemanticTokenTypes.number, modifiers: [] };
260
+ }
261
+
262
+ if (kind === SyntaxKind.RegularExpressionLiteral) {
263
+ return { type: SemanticTokenTypes.regexp, modifiers: [] };
264
+ }
265
+
266
+ if (kind >= SyntaxKind.FirstPunctuation && kind <= SyntaxKind.LastPunctuation) {
267
+ const isOperator = OPERATORS.has(typescript.tokenToString(kind) ?? "");
268
+
269
+ return isOperator ? { type: SemanticTokenTypes.operator, modifiers: [] } : undefined;
270
+ }
271
+
272
+ if (kind < SyntaxKind.FirstKeyword || kind > SyntaxKind.LastKeyword) {
273
+ return undefined;
274
+ }
275
+
276
+ // `void 0` is an operator, `: void` a type
277
+ if (kind === SyntaxKind.VoidKeyword) {
278
+ const isOperator = node.parent.kind === SyntaxKind.VoidExpression;
279
+
280
+ return standardOrScoped(isOperator ? "keywordOperatorExpression" : "supportTypePrimitive", isScoped);
281
+ }
282
+
283
+ return standardOrScoped(keywords[kind] ?? "keywordControl", isScoped);
284
+ }
285
+
286
+ /** Whether a kind is one of the pieces a template literal is tokenized into. */
287
+ function isTemplatePart(typescript: typeof ts, kind: ts.SyntaxKind): boolean {
288
+ const { SyntaxKind } = typescript;
289
+
290
+ return (
291
+ kind === SyntaxKind.NoSubstitutionTemplateLiteral ||
292
+ kind === SyntaxKind.TemplateHead ||
293
+ kind === SyntaxKind.TemplateMiddle ||
294
+ kind === SyntaxKind.TemplateTail
295
+ );
296
+ }
297
+
298
+ /** A scoped type itself, or the standard type it stands for. */
299
+ function standardOrScoped(type: ScopedType, isScoped: boolean): StandardType {
300
+ if (isScoped) {
301
+ return { type, modifiers: [] };
302
+ }
303
+
304
+ return SCOPED_TYPES[type];
305
+ }
306
+
307
+ /** A token per line of a stretch of the document, since a token may not span lines. */
308
+ function splitLines(document: TextDocument, start: number, end: number, named: StandardType): SyntaxToken[] {
309
+ const tokens: SyntaxToken[] = [];
310
+ const first = document.positionAt(start);
311
+ const last = document.positionAt(end);
312
+
313
+ for (let line = first.line; line <= last.line; line++) {
314
+ const character = line === first.line ? first.character : 0;
315
+ const lineEnd =
316
+ line === last.line
317
+ ? last.character
318
+ : document.offsetAt({ line: line + 1, character: 0 }) - document.offsetAt({ line, character: 0 });
319
+ const length = lineEnd - character;
320
+
321
+ if (length > 0) {
322
+ tokens.push({ line, character, length, ...named });
323
+ }
324
+ }
325
+
326
+ return tokens;
327
+ }
@@ -0,0 +1,94 @@
1
+ import { DiagnosticSeverity } from "vscode-languageserver-protocol";
2
+
3
+ import type { Project } from "../projects.ts";
4
+ import type { AttributeInfo, DocumentInfo, ElementInfo, ProblemReporter, ProblemTarget, TextRange } from "@staticbolt/core";
5
+ import type { Diagnostic } from "vscode-languageserver-protocol";
6
+ import type { TextDocument } from "vscode-languageserver-textdocument";
7
+
8
+ /** What diagnostics from plugins are labelled with, followed by the plugin's name. */
9
+ const SOURCE = "staticbolt";
10
+
11
+ /** What validating a document takes. */
12
+ export interface ValidationInput {
13
+ /** The document as HTML, where the diagnostics go. */
14
+ document: TextDocument;
15
+
16
+ /** The document as the plugins see it. */
17
+ info: DocumentInfo;
18
+
19
+ /** The project whose plugins validate. */
20
+ project: Project;
21
+
22
+ /** Where a failing plugin is logged. */
23
+ console: Pick<Console, "error">;
24
+ }
25
+
26
+ /**
27
+ * Runs every validating plugin of the project over a document and gathers what they report as diagnostics. A plugin that throws
28
+ * is skipped, so the others still report, and logged the first time.
29
+ */
30
+ export async function validateDocument({ document, info, project, console }: ValidationInput): Promise<Diagnostic[]> {
31
+ const diagnostics: Diagnostic[] = [];
32
+
33
+ for (const validator of project.validators) {
34
+ const report = createReporter(document, validator.name, diagnostics);
35
+
36
+ try {
37
+ await validator.validate(info, report);
38
+ } catch (error) {
39
+ if (validator.hasFailed) continue;
40
+
41
+ validator.hasFailed = true;
42
+ console.error(`[staticbolt] the ${validator.name} plugin failed to validate ${info.file}:`, error);
43
+ }
44
+ }
45
+
46
+ return diagnostics;
47
+ }
48
+
49
+ /** A reporter adding to `diagnostics`, each one labelled with the plugin it comes from. */
50
+ function createReporter(document: TextDocument, pluginName: string, diagnostics: Diagnostic[]): ProblemReporter {
51
+ function reportAs(severity: DiagnosticSeverity) {
52
+ return (target: ProblemTarget, message: string) => {
53
+ const { start, end } = rangeOf(target);
54
+
55
+ diagnostics.push({
56
+ range: { start: document.positionAt(start), end: document.positionAt(end) },
57
+ message,
58
+ severity,
59
+ source: SOURCE,
60
+ code: pluginName,
61
+ });
62
+ };
63
+ }
64
+
65
+ return {
66
+ error: reportAs(DiagnosticSeverity.Error),
67
+ warn: reportAs(DiagnosticSeverity.Warning),
68
+ info: reportAs(DiagnosticSeverity.Information),
69
+ hint: reportAs(DiagnosticSeverity.Hint),
70
+ };
71
+ }
72
+
73
+ /** An element underlines its tag name, an attribute its value or else its name, a range itself. */
74
+ function rangeOf(target: ProblemTarget): TextRange {
75
+ if (isElement(target)) {
76
+ return target.nameRange;
77
+ }
78
+
79
+ if (isAttribute(target)) {
80
+ return target.valueRange ?? target.nameRange;
81
+ }
82
+
83
+ return target;
84
+ }
85
+
86
+ /** Only an element has children. */
87
+ function isElement(target: ProblemTarget): target is ElementInfo {
88
+ return "children" in target;
89
+ }
90
+
91
+ /** Only an attribute is on an element. */
92
+ function isAttribute(target: ProblemTarget): target is AttributeInfo {
93
+ return "element" in target;
94
+ }
@@ -0,0 +1,111 @@
1
+ import type { EmbeddedRegion, TextRange } from "@staticbolt/core";
2
+ import type * as ts from "typescript";
3
+
4
+ /**
5
+ * The text with everything outside the regions blanked, keeping line breaks, so every offset means the same thing as in the text.
6
+ * The character right after a region becomes a `;`, so neighbouring regions on one line stay separate statements.
7
+ */
8
+ export function blankAround(text: string, regions: readonly EmbeddedRegion[]): string {
9
+ let result = "";
10
+ let cursor = 0;
11
+
12
+ for (const region of regions) {
13
+ result += blank(text.slice(cursor, region.start)) + text.slice(region.start, region.end);
14
+ cursor = region.end;
15
+
16
+ const next = text[cursor];
17
+
18
+ if (next === undefined || next === "\n" || next === "\r") continue;
19
+
20
+ result += ";";
21
+ cursor++;
22
+ }
23
+
24
+ return result + blank(text.slice(cursor));
25
+ }
26
+
27
+ /** The text with the regions blanked, keeping line breaks, so every offset means the same thing as in the text. */
28
+ export function blankRegions(text: string, regions: readonly TextRange[]): string {
29
+ let result = "";
30
+ let cursor = 0;
31
+
32
+ for (const region of regions) {
33
+ result += text.slice(cursor, region.start) + blank(text.slice(region.start, region.end));
34
+ cursor = region.end;
35
+ }
36
+
37
+ return result + text.slice(cursor);
38
+ }
39
+
40
+ /**
41
+ * The holes masked, keeping line breaks, so the code around them still parses and types as it will once they are filled: a hole
42
+ * in a string literal makes the whole literal `("" + "")`, a `string` rather than a literal type; a hole in a template becomes a
43
+ * `${<any>0}` substitution, for the same reason; any other hole reads as `(<any>0)`, a value of a type nobody knows yet.
44
+ *
45
+ * A mask stands where an expression stands, so it is parenthesised: whatever surrounds it, `+"{{ n }}"` say, binds to the mask as
46
+ * 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.
47
+ */
48
+ export function mask(typescript: typeof ts, text: string, holes: readonly TextRange[]): string {
49
+ if (holes.length === 0) {
50
+ return text;
51
+ }
52
+
53
+ const sourceFile = typescript.createSourceFile("mask.ts", text, typescript.ScriptTarget.Latest, true);
54
+ let result = text;
55
+
56
+ for (const hole of holes) {
57
+ const token = tokenAt(sourceFile, hole.start);
58
+
59
+ if (token?.kind === typescript.SyntaxKind.StringLiteral) {
60
+ const range = { start: token.getStart(sourceFile), end: token.getEnd() };
61
+
62
+ result = replace(result, range, fill(text.slice(range.start, range.end), '("" + "")', '(""+"")', '("")', '""'));
63
+ continue;
64
+ }
65
+
66
+ if (token && typescript.isTemplateLiteralToken(token)) {
67
+ result = replace(result, hole, fill(text.slice(hole.start, hole.end), "${<any>0}", "${0}"));
68
+ continue;
69
+ }
70
+
71
+ result = replace(result, hole, fill(text.slice(hole.start, hole.end), "(<any>0)", "<any>0", "(0)", "0"));
72
+ }
73
+
74
+ return result;
75
+ }
76
+
77
+ /**
78
+ * The first replacement the original has room for, followed by the rest of the original blanked, so the length and the line
79
+ * breaks are kept; nothing but the blanks when even the shortest is too long.
80
+ */
81
+ function fill(original: string, ...replacements: readonly string[]): string {
82
+ const fitting = replacements.find(replacement => replacement.length <= original.length) ?? "";
83
+
84
+ return fitting + blank(original.slice(fitting.length));
85
+ }
86
+
87
+ /** The token of a parsed text an offset falls in: the deepest node there that has no children. */
88
+ function tokenAt(sourceFile: ts.SourceFile, offset: number): ts.Node | undefined {
89
+ let node: ts.Node = sourceFile;
90
+
91
+ while (true) {
92
+ const child = node
93
+ .getChildren(sourceFile)
94
+ .find(candidate => candidate.getStart(sourceFile) <= offset && offset < candidate.getEnd());
95
+ if (!child) {
96
+ return node === sourceFile ? undefined : node;
97
+ }
98
+
99
+ node = child;
100
+ }
101
+ }
102
+
103
+ /** The text with a range replaced by a replacement of the same length. */
104
+ function replace(text: string, range: TextRange, replacement: string): string {
105
+ return text.slice(0, range.start) + replacement + text.slice(range.end);
106
+ }
107
+
108
+ /** Every character but the line breaks replaced by a space. */
109
+ function blank(text: string): string {
110
+ return text.replaceAll(/[^\n\r]/g, " ");
111
+ }