@staticbolt/lsp 1.0.0-beta.29 → 1.0.0-beta.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/lib/index.mjs +1404 -751
- package/lib/index.mjs.map +1 -1
- package/package.json +8 -4
- package/src/helpers/document-context.ts +36 -0
- package/src/helpers/document-elements.ts +119 -0
- package/src/helpers/document-info.ts +29 -0
- package/src/{modes → helpers}/markdown-regions.ts +25 -35
- package/src/helpers/merge-html-data.ts +79 -33
- package/src/helpers/regions.ts +101 -0
- package/src/helpers/syntax-tokens.ts +327 -0
- package/src/helpers/validation.ts +94 -0
- package/src/helpers/virtual-document.ts +109 -0
- package/src/index.ts +151 -25
- package/src/language-plugin.ts +67 -0
- package/src/projects.ts +305 -0
- package/src/services/staticbolt-service.ts +257 -0
- package/src/services/syntax-tokens-service.ts +127 -0
- package/src/services/typescript-service.ts +20 -0
- package/src/virtual-code.ts +196 -0
- package/src/helpers/config-loader.ts +0 -244
- package/src/helpers/find-projects.ts +0 -23
- package/src/html-server.ts +0 -191
- package/src/language-model-cache.ts +0 -91
- package/src/modes/embedded-support.ts +0 -150
- package/src/modes/html-mode.ts +0 -83
- package/src/modes/language-modes.ts +0 -156
- package/src/requests.ts +0 -72
- package/src/utils/arrays.ts +0 -73
- package/src/utils/document-context.ts +0 -44
- package/src/utils/find-project-root.ts +0 -24
- package/src/utils/node-fs.ts +0 -77
- package/src/utils/runner.ts +0 -56
- package/src/utils/strings.ts +0 -76
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import vscodeHtml from "vscode-html-languageservice";
|
|
2
|
+
|
|
3
|
+
import type { AttributeInfo, ElementInfo, TextRange } from "@staticbolt/core";
|
|
4
|
+
import type { HTMLDocument, LanguageService, Node } from "vscode-html-languageservice";
|
|
5
|
+
|
|
6
|
+
/** The token kinds the HTML scanner reports. */
|
|
7
|
+
const TokenType = vscodeHtml.TokenType;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every element of a parsed document in document order, with the attribute offsets the parser leaves out: each start tag is
|
|
11
|
+
* scanned again for them.
|
|
12
|
+
*/
|
|
13
|
+
export function parseElements(languageService: LanguageService, text: string, htmlDocument: HTMLDocument): ElementInfo[] {
|
|
14
|
+
const elements: ElementInfo[] = [];
|
|
15
|
+
|
|
16
|
+
for (const root of htmlDocument.roots) {
|
|
17
|
+
collectElements(languageService, text, root, undefined, elements);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return elements;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The element for a node and, after it, the ones for its children; the tree only ever holds elements. */
|
|
24
|
+
function collectElements(
|
|
25
|
+
languageService: LanguageService,
|
|
26
|
+
text: string,
|
|
27
|
+
node: Node,
|
|
28
|
+
parent: ElementInfo | undefined,
|
|
29
|
+
elements: ElementInfo[]
|
|
30
|
+
): void {
|
|
31
|
+
const element = createElement(languageService, text, node, parent);
|
|
32
|
+
|
|
33
|
+
elements.push(element);
|
|
34
|
+
parent?.children.push(element);
|
|
35
|
+
|
|
36
|
+
for (const child of node.children) {
|
|
37
|
+
collectElements(languageService, text, child, element, elements);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The element for a node, with its attributes scanned from its start tag. */
|
|
42
|
+
function createElement(languageService: LanguageService, text: string, node: Node, parent: ElementInfo | undefined): ElementInfo {
|
|
43
|
+
const tag = node.tag ?? "";
|
|
44
|
+
const startTagEnd = node.startTagEnd ?? node.end;
|
|
45
|
+
const attributes: AttributeInfo[] = [];
|
|
46
|
+
|
|
47
|
+
const findAttribute = (name: string) => {
|
|
48
|
+
const wanted = name.toLowerCase();
|
|
49
|
+
|
|
50
|
+
return attributes.find(attribute => attribute.name.toLowerCase() === wanted);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const element: ElementInfo = {
|
|
54
|
+
name: tag.toLowerCase(),
|
|
55
|
+
attributes,
|
|
56
|
+
parent,
|
|
57
|
+
children: [],
|
|
58
|
+
range: { start: node.start, end: node.end },
|
|
59
|
+
nameRange: { start: node.start + 1, end: node.start + 1 + tag.length },
|
|
60
|
+
contentRange: contentRangeOf(node, startTagEnd),
|
|
61
|
+
attribute: findAttribute,
|
|
62
|
+
has: name => findAttribute(name) !== undefined,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
attributes.push(...scanAttributes(languageService, text, element, node.start, startTagEnd));
|
|
66
|
+
|
|
67
|
+
return element;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 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
|
|
72
|
+
* element that is over with its start tag, void or self-closing.
|
|
73
|
+
*/
|
|
74
|
+
function contentRangeOf(node: Node, startTagEnd: number): TextRange | undefined {
|
|
75
|
+
const end = node.endTagStart ?? node.end;
|
|
76
|
+
|
|
77
|
+
if (end <= startTagEnd) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { start: startTagEnd, end };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The attributes in a start tag, with where their names and values sit. */
|
|
85
|
+
function scanAttributes(
|
|
86
|
+
languageService: LanguageService,
|
|
87
|
+
text: string,
|
|
88
|
+
element: ElementInfo,
|
|
89
|
+
start: number,
|
|
90
|
+
end: number
|
|
91
|
+
): AttributeInfo[] {
|
|
92
|
+
const attributes: AttributeInfo[] = [];
|
|
93
|
+
const scanner = languageService.createScanner(text.slice(start, end));
|
|
94
|
+
let pending: AttributeInfo | undefined;
|
|
95
|
+
|
|
96
|
+
for (let token = scanner.scan(); token !== TokenType.EOS; token = scanner.scan()) {
|
|
97
|
+
const range: TextRange = { start: start + scanner.getTokenOffset(), end: start + scanner.getTokenEnd() };
|
|
98
|
+
|
|
99
|
+
if (token === TokenType.AttributeName) {
|
|
100
|
+
pending = { name: scanner.getTokenText(), value: undefined, element, nameRange: range, valueRange: undefined };
|
|
101
|
+
attributes.push(pending);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (token !== TokenType.AttributeValue || !pending) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const raw = scanner.getTokenText();
|
|
110
|
+
const isQuoted = raw.startsWith('"') || raw.startsWith("'");
|
|
111
|
+
const quote = isQuoted ? 1 : 0;
|
|
112
|
+
|
|
113
|
+
pending.value = raw.slice(quote, raw.length - quote);
|
|
114
|
+
pending.valueRange = { start: range.start + quote, end: range.end - quote };
|
|
115
|
+
pending = undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return attributes;
|
|
119
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DocumentInfo, ElementInfo, Resolver } from "@staticbolt/core";
|
|
2
|
+
|
|
3
|
+
/** The document as a plugin sees it: its text and elements, with lookups over them and the project's resolver. */
|
|
4
|
+
export function describeDocument(text: string, file: string, elements: ElementInfo[], resolver: Resolver): DocumentInfo {
|
|
5
|
+
return {
|
|
6
|
+
file,
|
|
7
|
+
text,
|
|
8
|
+
elements,
|
|
9
|
+
|
|
10
|
+
select(...names) {
|
|
11
|
+
const wanted = new Set(names.map(name => name.toLowerCase()));
|
|
12
|
+
|
|
13
|
+
return elements.filter(element => wanted.has(element.name));
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
textOf(range) {
|
|
17
|
+
return text.slice(range.start, range.end);
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
resolve(source) {
|
|
21
|
+
const resolved = resolver.resolve(source, file);
|
|
22
|
+
if (!resolved) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { path: resolved.path, exists: resolved.exists };
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -5,18 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { markdownToMdast } from "satteri";
|
|
7
7
|
|
|
8
|
+
import type { TextRange } from "@staticbolt/core";
|
|
8
9
|
import type { MdastNode } from "satteri";
|
|
9
10
|
|
|
10
|
-
export interface TextRegion {
|
|
11
|
-
start: number;
|
|
12
|
-
end: number;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
11
|
/** None of these nest, so the regions they produce never overlap. */
|
|
16
12
|
const NON_HTML_NODES = new Set(["code", "inlineCode", "yaml", "toml"]);
|
|
17
13
|
|
|
18
14
|
/** The regions of a markdown document that must not be treated as HTML, sorted by start offset. */
|
|
19
|
-
export function findMarkdownNonHtmlRegions(text: string):
|
|
15
|
+
export function findMarkdownNonHtmlRegions(text: string): TextRange[] {
|
|
20
16
|
let tree: MdastNode;
|
|
21
17
|
|
|
22
18
|
try {
|
|
@@ -26,26 +22,26 @@ export function findMarkdownNonHtmlRegions(text: string): TextRegion[] {
|
|
|
26
22
|
return [];
|
|
27
23
|
}
|
|
28
24
|
|
|
29
|
-
const regions:
|
|
25
|
+
const regions: TextRange[] = [];
|
|
30
26
|
const pending: MdastNode[] = [tree];
|
|
31
27
|
|
|
32
28
|
while (pending.length > 0) {
|
|
33
29
|
const node = pending.pop()!;
|
|
34
30
|
|
|
35
|
-
|
|
36
|
-
const start = node.position?.start.offset;
|
|
37
|
-
const end = node.position?.end.offset;
|
|
38
|
-
|
|
39
|
-
if (start !== undefined && end !== undefined) {
|
|
40
|
-
regions.push({ start, end });
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
|
|
31
|
+
// The nodes looked for are leaves, so a node with children is never one of them
|
|
46
32
|
if ("children" in node) {
|
|
47
33
|
pending.push(...node.children);
|
|
34
|
+
continue;
|
|
48
35
|
}
|
|
36
|
+
|
|
37
|
+
if (!NON_HTML_NODES.has(node.type)) continue;
|
|
38
|
+
|
|
39
|
+
const start = node.position?.start.offset;
|
|
40
|
+
const end = node.position?.end.offset;
|
|
41
|
+
|
|
42
|
+
if (start === undefined || end === undefined) continue;
|
|
43
|
+
|
|
44
|
+
regions.push({ start, end });
|
|
49
45
|
}
|
|
50
46
|
|
|
51
47
|
const sorted = regions.toSorted((a, b) => a.start - b.start);
|
|
@@ -57,8 +53,12 @@ export function findMarkdownNonHtmlRegions(text: string): TextRegion[] {
|
|
|
57
53
|
* The parser counts code points, the editor counts UTF-16 code units. The two only drift apart once a character outside the basic
|
|
58
54
|
* plane, an emoji most of the time, sits before a region.
|
|
59
55
|
*/
|
|
60
|
-
function toUtf16Offsets(text: string, regions:
|
|
61
|
-
|
|
56
|
+
function toUtf16Offsets(text: string, regions: TextRange[]): TextRange[] {
|
|
57
|
+
const hasAstral = /[\uD800-\uDBFF]/.test(text);
|
|
58
|
+
|
|
59
|
+
if (!hasAstral) {
|
|
60
|
+
return regions;
|
|
61
|
+
}
|
|
62
62
|
|
|
63
63
|
const astral: number[] = [];
|
|
64
64
|
let codePoint = 0;
|
|
@@ -71,22 +71,12 @@ function toUtf16Offsets(text: string, regions: TextRegion[]): TextRegion[] {
|
|
|
71
71
|
codePoint++;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Replaces every region with spaces, keeping the length of the text and its line breaks. Regions must be sorted and disjoint. */
|
|
80
|
-
export function blankRegions(text: string, regions: readonly TextRegion[]): string {
|
|
81
|
-
if (regions.length === 0) return text;
|
|
74
|
+
/** Shifts a code point offset by the number of astral characters before it. */
|
|
75
|
+
function toUtf16(offset: number): number {
|
|
76
|
+
const astralBefore = astral.filter(position => position < offset).length;
|
|
82
77
|
|
|
83
|
-
|
|
84
|
-
let cursor = 0;
|
|
85
|
-
|
|
86
|
-
for (const region of regions) {
|
|
87
|
-
result += text.slice(cursor, region.start) + text.slice(region.start, region.end).replaceAll(/[^\n\r]/g, " ");
|
|
88
|
-
cursor = region.end;
|
|
78
|
+
return offset + astralBefore;
|
|
89
79
|
}
|
|
90
80
|
|
|
91
|
-
return
|
|
81
|
+
return regions.map(region => ({ start: toUtf16(region.start), end: toUtf16(region.end) }));
|
|
92
82
|
}
|
|
@@ -8,33 +8,37 @@ import type {
|
|
|
8
8
|
MarkupContent,
|
|
9
9
|
} from "vscode-html-languageservice";
|
|
10
10
|
|
|
11
|
+
/** A description as the data format allows it: plain, marked up, or absent. */
|
|
11
12
|
type Description = string | MarkupContent | undefined;
|
|
13
|
+
|
|
14
|
+
/** Plain text or markdown. */
|
|
12
15
|
type DescriptionKind = MarkupContent["kind"];
|
|
16
|
+
|
|
17
|
+
/** The value sets by name. */
|
|
13
18
|
type ValueSets = Map<string, IValueData[]>;
|
|
14
19
|
|
|
15
|
-
/**
|
|
20
|
+
/** The `valueSet` the HTML language service reads as "a boolean attribute". */
|
|
21
|
+
const BOOLEAN_VALUE_SET = "v";
|
|
22
|
+
|
|
23
|
+
/** Separates the documentation of two contributors, rendered as a horizontal rule. */
|
|
16
24
|
const MARKDOWN_SEPARATOR = "\n\n---\n\n";
|
|
25
|
+
|
|
26
|
+
/** The same, for descriptions that are plain text. */
|
|
17
27
|
const PLAINTEXT_SEPARATOR = "\n\n";
|
|
18
28
|
|
|
19
29
|
/** Groups entries by key, preserving the order of first appearance. */
|
|
20
30
|
function groupBy<T>(items: T[], toKey: (item: T) => string): T[][] {
|
|
21
31
|
const groups = new Map<string, T[]>();
|
|
22
|
-
const ordered: T[][] = [];
|
|
23
32
|
|
|
24
33
|
for (const item of items) {
|
|
25
34
|
const key = toKey(item);
|
|
26
|
-
|
|
27
|
-
let group = groups.get(key);
|
|
28
|
-
if (!group) {
|
|
29
|
-
group = [];
|
|
30
|
-
groups.set(key, group);
|
|
31
|
-
ordered.push(group);
|
|
32
|
-
}
|
|
35
|
+
const group = groups.get(key) ?? [];
|
|
33
36
|
|
|
34
37
|
group.push(item);
|
|
38
|
+
groups.set(key, group);
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
return
|
|
41
|
+
return groups.values().toArray();
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
/** Markdown wins over plain text, so a contributor asking for rich text still gets it. Plain strings imply no preference. */
|
|
@@ -42,39 +46,59 @@ function mergeDescriptionKind(descriptions: Exclude<Description, undefined>[]):
|
|
|
42
46
|
let kind: DescriptionKind | undefined;
|
|
43
47
|
|
|
44
48
|
for (const description of descriptions) {
|
|
45
|
-
if (typeof description === "string"
|
|
49
|
+
if (typeof description === "string") continue;
|
|
50
|
+
if (kind === "markdown") break;
|
|
51
|
+
|
|
46
52
|
kind = description.kind;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
return kind;
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
/** The text of a description, whichever shape it has. */
|
|
59
|
+
function textOf(description: Exclude<Description, undefined>): string {
|
|
60
|
+
if (typeof description === "string") {
|
|
61
|
+
return description;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return description.value;
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
/** Concatenates every distinct description, so no contributor's documentation gets lost. */
|
|
53
68
|
function mergeDescriptions(descriptions: Description[]): Description {
|
|
54
69
|
const defined = descriptions.filter(description => description !== undefined);
|
|
55
70
|
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
if (defined.length <= 1) {
|
|
72
|
+
return defined[0];
|
|
73
|
+
}
|
|
58
74
|
|
|
59
75
|
const parts: string[] = [];
|
|
60
76
|
const seen = new Set<string>();
|
|
61
77
|
|
|
62
78
|
for (const description of defined) {
|
|
63
|
-
const text = (
|
|
79
|
+
const text = textOf(description).trim();
|
|
64
80
|
if (!text || seen.has(text)) continue;
|
|
65
81
|
|
|
66
82
|
seen.add(text);
|
|
67
83
|
parts.push(text);
|
|
68
84
|
}
|
|
69
85
|
|
|
70
|
-
if (parts.length === 0)
|
|
86
|
+
if (parts.length === 0) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
71
89
|
|
|
72
90
|
const kind = mergeDescriptionKind(defined);
|
|
73
|
-
const
|
|
91
|
+
const separator = kind === "plaintext" ? PLAINTEXT_SEPARATOR : MARKDOWN_SEPARATOR;
|
|
92
|
+
const value = parts.join(separator);
|
|
74
93
|
|
|
75
|
-
|
|
94
|
+
if (!kind) {
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { kind, value };
|
|
76
99
|
}
|
|
77
100
|
|
|
101
|
+
/** Every distinct reference, a reference being the same as another when both name and url match. */
|
|
78
102
|
function mergeReferences(references: (IReference[] | undefined)[]): IReference[] | undefined {
|
|
79
103
|
const merged: IReference[] = [];
|
|
80
104
|
const seen = new Set<string>();
|
|
@@ -91,9 +115,14 @@ function mergeReferences(references: (IReference[] | undefined)[]): IReference[]
|
|
|
91
115
|
}
|
|
92
116
|
}
|
|
93
117
|
|
|
94
|
-
|
|
118
|
+
if (merged.length === 0) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return merged;
|
|
95
123
|
}
|
|
96
124
|
|
|
125
|
+
/** Every distinct browser. */
|
|
97
126
|
function mergeBrowsers(browsers: (string[] | undefined)[]): string[] | undefined {
|
|
98
127
|
const merged = new Set<string>();
|
|
99
128
|
|
|
@@ -105,13 +134,20 @@ function mergeBrowsers(browsers: (string[] | undefined)[]): string[] | undefined
|
|
|
105
134
|
}
|
|
106
135
|
}
|
|
107
136
|
|
|
108
|
-
|
|
137
|
+
if (merged.size === 0) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return [...merged];
|
|
109
142
|
}
|
|
110
143
|
|
|
111
144
|
/** The values an attribute contributes, with its `valueSet` reference expanded. */
|
|
112
145
|
function resolveValues(attribute: IAttributeData, valueSets: ValueSets): IValueData[] {
|
|
113
146
|
const values = attribute.values ?? [];
|
|
114
|
-
|
|
147
|
+
|
|
148
|
+
if (!attribute.valueSet) {
|
|
149
|
+
return values;
|
|
150
|
+
}
|
|
115
151
|
|
|
116
152
|
return [...(valueSets.get(attribute.valueSet) ?? []), ...values];
|
|
117
153
|
}
|
|
@@ -119,7 +155,9 @@ function resolveValues(attribute: IAttributeData, valueSets: ValueSets): IValueD
|
|
|
119
155
|
/** Merges entries sharing the same value name into one. Unlike tags and attributes, value names are case-sensitive. */
|
|
120
156
|
function mergeValues(values: IValueData[]): IValueData[] {
|
|
121
157
|
return groupBy(values, value => value.name).map(group => {
|
|
122
|
-
if (group.length === 1)
|
|
158
|
+
if (group.length === 1) {
|
|
159
|
+
return group[0];
|
|
160
|
+
}
|
|
123
161
|
|
|
124
162
|
return {
|
|
125
163
|
name: group[0].name,
|
|
@@ -139,15 +177,17 @@ function mergeValues(values: IValueData[]): IValueData[] {
|
|
|
139
177
|
*/
|
|
140
178
|
function mergeAttributes(attributes: IAttributeData[], valueSets: ValueSets): IAttributeData[] {
|
|
141
179
|
return groupBy(attributes, attribute => attribute.name.toLowerCase()).map(group => {
|
|
142
|
-
|
|
143
|
-
|
|
180
|
+
if (group.length === 1 && !group[0].valueSet) {
|
|
181
|
+
return group[0];
|
|
182
|
+
}
|
|
144
183
|
|
|
145
184
|
const values = mergeValues(group.flatMap(attribute => resolveValues(attribute, valueSets)));
|
|
185
|
+
const isBoolean = group.some(attribute => attribute.valueSet === BOOLEAN_VALUE_SET);
|
|
146
186
|
|
|
147
187
|
return {
|
|
148
188
|
name: group[0].name,
|
|
149
189
|
description: mergeDescriptions(group.map(attribute => attribute.description)),
|
|
150
|
-
|
|
190
|
+
valueSet: isBoolean ? BOOLEAN_VALUE_SET : undefined,
|
|
151
191
|
values: values.length > 0 ? values : undefined,
|
|
152
192
|
references: mergeReferences(group.map(attribute => attribute.references)),
|
|
153
193
|
browsers: mergeBrowsers(group.map(attribute => attribute.browsers)),
|
|
@@ -164,7 +204,9 @@ function mergeTags(tags: ITagData[], valueSets: ValueSets): ITagData[] {
|
|
|
164
204
|
valueSets
|
|
165
205
|
);
|
|
166
206
|
|
|
167
|
-
if (group.length === 1)
|
|
207
|
+
if (group.length === 1) {
|
|
208
|
+
return { ...group[0], attributes };
|
|
209
|
+
}
|
|
168
210
|
|
|
169
211
|
return {
|
|
170
212
|
name: group[0].name,
|
|
@@ -184,8 +226,9 @@ function collectValueSets(htmlData: HTMLDataV1[]): ValueSets {
|
|
|
184
226
|
const collected = htmlData.flatMap(data => data.valueSets ?? []);
|
|
185
227
|
|
|
186
228
|
for (const valueSet of collected) {
|
|
187
|
-
const existing = valueSets.get(valueSet.name);
|
|
188
|
-
|
|
229
|
+
const existing = valueSets.get(valueSet.name) ?? [];
|
|
230
|
+
|
|
231
|
+
valueSets.set(valueSet.name, mergeValues([...existing, ...valueSet.values]));
|
|
189
232
|
}
|
|
190
233
|
|
|
191
234
|
return valueSets;
|
|
@@ -198,7 +241,6 @@ function collectValueSets(htmlData: HTMLDataV1[]): ValueSets {
|
|
|
198
241
|
export function createMergedHtmlDataProvider(id: string, htmlData: HTMLDataV1[]): IHTMLDataProvider {
|
|
199
242
|
const valueSets = collectValueSets(htmlData);
|
|
200
243
|
|
|
201
|
-
// the language service asks for tags on every parse, and for attributes on every completion/hover, so merging happens once here
|
|
202
244
|
const tags = mergeTags(
|
|
203
245
|
htmlData.flatMap(data => data.tags ?? []),
|
|
204
246
|
valueSets
|
|
@@ -216,11 +258,15 @@ export function createMergedHtmlDataProvider(id: string, htmlData: HTMLDataV1[])
|
|
|
216
258
|
const key = tag.toLowerCase();
|
|
217
259
|
|
|
218
260
|
const cached = attributesByTag.get(key);
|
|
219
|
-
if (cached)
|
|
261
|
+
if (cached) {
|
|
262
|
+
return cached;
|
|
263
|
+
}
|
|
220
264
|
|
|
221
|
-
//
|
|
265
|
+
// An unknown tag only sees the global attributes, which are merged already
|
|
222
266
|
const tagAttributes = tagsByName.get(key)?.attributes;
|
|
223
|
-
if (!tagAttributes || tagAttributes.length === 0)
|
|
267
|
+
if (!tagAttributes || tagAttributes.length === 0) {
|
|
268
|
+
return globalAttributes;
|
|
269
|
+
}
|
|
224
270
|
|
|
225
271
|
const attributes = mergeAttributes([...globalAttributes, ...tagAttributes], valueSets);
|
|
226
272
|
attributesByTag.set(key, attributes);
|
|
@@ -245,9 +291,9 @@ export function createMergedHtmlDataProvider(id: string, htmlData: HTMLDataV1[])
|
|
|
245
291
|
|
|
246
292
|
provideValues(tag, attribute) {
|
|
247
293
|
const name = attribute.toLowerCase();
|
|
294
|
+
const match = provideAttributes(tag).find(candidate => candidate.name.toLowerCase() === name);
|
|
248
295
|
|
|
249
|
-
|
|
250
|
-
return provideAttributes(tag).find(a => a.name.toLowerCase() === name)?.values ?? [];
|
|
296
|
+
return match?.values ?? [];
|
|
251
297
|
},
|
|
252
298
|
};
|
|
253
299
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { DocumentInfo, EmbeddedLanguage, EmbeddedRegion, TextRange } from "@staticbolt/core";
|
|
2
|
+
|
|
3
|
+
/** A region of a plugin language in a document. */
|
|
4
|
+
export interface PluginRegion extends EmbeddedRegion {
|
|
5
|
+
/** The plugin language. */
|
|
6
|
+
language: EmbeddedLanguage;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The regions of one plugin language that share a file: the classic ones together, a module on its own. */
|
|
10
|
+
export interface LanguageRegions {
|
|
11
|
+
/** The file's id: the language's name, with the module's number for a module. */
|
|
12
|
+
id: string;
|
|
13
|
+
|
|
14
|
+
/** The plugin language. */
|
|
15
|
+
language: EmbeddedLanguage;
|
|
16
|
+
|
|
17
|
+
/** Whether the file is a module of one region, with a top level of its own, rather than a script whose top level is global. */
|
|
18
|
+
isModule: boolean;
|
|
19
|
+
|
|
20
|
+
/** Its regions, in text order. */
|
|
21
|
+
regions: EmbeddedRegion[];
|
|
22
|
+
|
|
23
|
+
/** The regions of other plugin languages inside its own, in text order: those languages serve them, this one sees them masked. */
|
|
24
|
+
holes: TextRange[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Regions in text order. */
|
|
28
|
+
const byStart = (a: TextRange, b: TextRange) => a.start - b.start;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The regions of every plugin language that claims the document, in text order. A region inside one a language earlier in the
|
|
32
|
+
* config claimed already is left to that language: a plugin that runs a script elsewhere claims it before the core plugin, last
|
|
33
|
+
* in the config, sees the browser in it. A region of another language inside this one is a hole, not a claim.
|
|
34
|
+
*/
|
|
35
|
+
export function findPluginRegions(document: DocumentInfo, languages: readonly EmbeddedLanguage[]): PluginRegion[] {
|
|
36
|
+
const regions: PluginRegion[] = [];
|
|
37
|
+
|
|
38
|
+
for (const language of languages) {
|
|
39
|
+
if (!language.filter(document.file)) continue;
|
|
40
|
+
|
|
41
|
+
for (const region of language.findRegions(document)) {
|
|
42
|
+
if (regions.some(claimed => isInside(region, claimed))) continue;
|
|
43
|
+
|
|
44
|
+
regions.push({ ...region, language });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return regions.toSorted(byStart);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether a range lies within another. */
|
|
52
|
+
function isInside(range: TextRange, outer: TextRange): boolean {
|
|
53
|
+
return outer.start <= range.start && range.end <= outer.end;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The plugin regions by the file they share: the classic regions of a language together, each module on its own. Every group is
|
|
58
|
+
* in text order, with the regions of other languages inside its own as its holes: a placeholder inside a build-time script is
|
|
59
|
+
* the placeholder language's.
|
|
60
|
+
*/
|
|
61
|
+
export function groupByFile(regions: readonly PluginRegion[]): LanguageRegions[] {
|
|
62
|
+
const groups = new Map<string, LanguageRegions>();
|
|
63
|
+
let modules = 0;
|
|
64
|
+
|
|
65
|
+
for (const region of regions) {
|
|
66
|
+
const id = region.isModule ? `${region.language.name}.module${modules++}` : region.language.name;
|
|
67
|
+
const group = groups.get(id) ?? { id, language: region.language, isModule: region.isModule === true, regions: [], holes: [] };
|
|
68
|
+
|
|
69
|
+
group.regions.push({ start: region.start, end: region.end });
|
|
70
|
+
group.holes.push(...holesOf(region, regions));
|
|
71
|
+
groups.set(id, group);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return groups.values().toArray();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The whole construct a region sits in, delimiters included, or the region itself when it has no more. */
|
|
78
|
+
function extentOf(region: EmbeddedRegion): TextRange {
|
|
79
|
+
return region.extent ?? { start: region.start, end: region.end };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether an offset falls in any of the regions, their ends included. */
|
|
83
|
+
export function isInRegions(regions: readonly TextRange[], offset: number): boolean {
|
|
84
|
+
return regions.some(region => region.start <= offset && offset <= region.end);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The regions of other languages lying inside a region, as their whole constructs. */
|
|
88
|
+
function holesOf(region: PluginRegion, regions: readonly PluginRegion[]): TextRange[] {
|
|
89
|
+
const holes: TextRange[] = [];
|
|
90
|
+
|
|
91
|
+
for (const other of regions) {
|
|
92
|
+
if (other.language === region.language) continue;
|
|
93
|
+
|
|
94
|
+
const hole = extentOf(other);
|
|
95
|
+
if (hole.start < region.start || hole.end > region.end) continue;
|
|
96
|
+
|
|
97
|
+
holes.push(hole);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return holes;
|
|
101
|
+
}
|