@staticbolt/lsp 1.0.0-beta.26 → 1.0.0-beta.27

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.
@@ -1,40 +1,41 @@
1
1
  import vscodeHtml from "vscode-html-languageservice";
2
2
  import { TextDocument } from "vscode-languageserver-textdocument";
3
3
 
4
- import type { LanguageService, Range } from "vscode-html-languageservice";
4
+ import { blankRegions, findMarkdownNonHtmlRegions } from "./markdown-regions.ts";
5
5
 
6
- export interface LanguageRange extends Range {
7
- languageId: string | undefined;
8
- attributeValue?: boolean;
9
- }
6
+ import type { LanguageService } from "vscode-html-languageservice";
10
7
 
11
8
  export interface HTMLDocumentRegions {
12
- getEmbeddedDocument(languageId: string, shouldIgnoreAttributeValues?: boolean): TextDocument;
13
- getLanguageRanges(range: Range): LanguageRange[];
9
+ /** The document to serve HTML features from: itself for an HTML file, a copy declared as HTML for a markdown one. */
10
+ getHtmlDocument(): TextDocument;
14
11
  getLanguageAtPosition(position: Position): string | undefined;
15
12
  getLanguagesInDocument(): string[];
16
- getImportedScripts(): string[];
17
13
  }
18
14
 
19
- const CSS_STYLE_RULE = "__";
20
15
  const TokenType = vscodeHtml.TokenType;
21
- const Position = vscodeHtml.Position;
22
16
  type Position = vscodeHtml.Position;
23
17
 
24
18
  interface EmbeddedRegion {
25
19
  languageId: string | undefined;
26
20
  start: number;
27
21
  end: number;
28
- attributeValue?: boolean;
29
22
  }
30
23
 
31
24
  export function getDocumentRegions(languageService: LanguageService, document: TextDocument): HTMLDocumentRegions {
25
+ const source = document.getText();
26
+ const isMarkdown = document.languageId === "markdown";
27
+
28
+ const markdownRegions: EmbeddedRegion[] = isMarkdown
29
+ ? findMarkdownNonHtmlRegions(source).map(region => ({ ...region, languageId: undefined }))
30
+ : [];
31
+
32
+ const htmlText = isMarkdown ? blankRegions(source, markdownRegions) : source;
33
+
32
34
  const regions: EmbeddedRegion[] = [];
33
- const scanner = languageService.createScanner(document.getText());
35
+ const scanner = languageService.createScanner(htmlText);
34
36
  let lastTagName: string = "";
35
37
  let lastAttributeName: string | null = null;
36
38
  let languageIdFromType: string | undefined;
37
- const importedScripts: string[] = [];
38
39
 
39
40
  let token = scanner.scan();
40
41
 
@@ -59,13 +60,7 @@ export function getDocumentRegions(languageService: LanguageService, document: T
59
60
  break;
60
61
  }
61
62
  case TokenType.AttributeValue: {
62
- if (lastAttributeName === "src" && lastTagName.toLowerCase() === "script") {
63
- let value = scanner.getTokenText();
64
- if (value[0] === "'" || value[0] === '"') {
65
- value = value.slice(1, -1);
66
- }
67
- importedScripts.push(value);
68
- } else if (lastAttributeName === "type" && lastTagName.toLowerCase() === "script") {
63
+ if (lastAttributeName === "type" && lastTagName.toLowerCase() === "script") {
69
64
  const token = scanner.getTokenText();
70
65
  if (/["'](module|(text|application)\/(java|ecma)script|text\/babel)["']/.test(token) || token === "module") {
71
66
  languageIdFromType = "javascript";
@@ -79,12 +74,12 @@ export function getDocumentRegions(languageService: LanguageService, document: T
79
74
  if (attributeLanguageId) {
80
75
  let start = scanner.getTokenOffset();
81
76
  let end = scanner.getTokenEnd();
82
- const firstChar = document.getText()[start];
77
+ const firstChar = htmlText[start];
83
78
  if (firstChar === "'" || firstChar === '"') {
84
79
  start++;
85
80
  end--;
86
81
  }
87
- regions.push({ languageId: attributeLanguageId, start, end, attributeValue: true });
82
+ regions.push({ languageId: attributeLanguageId, start, end });
88
83
  }
89
84
  }
90
85
  lastAttributeName = null;
@@ -93,76 +88,43 @@ export function getDocumentRegions(languageService: LanguageService, document: T
93
88
  }
94
89
  token = scanner.scan();
95
90
  }
91
+
92
+ const allRegions = mergeRegions(regions, markdownRegions);
93
+ const htmlDocument = isMarkdown ? TextDocument.create(document.uri, "html", document.version, htmlText) : document;
94
+
96
95
  return {
97
- getLanguageRanges: (range: Range) => getLanguageRanges(document, regions, range),
98
- getEmbeddedDocument: (languageId: string, shouldIgnoreAttributeValues: boolean) =>
99
- getEmbeddedDocument(document, regions, languageId, shouldIgnoreAttributeValues),
100
- getLanguageAtPosition: (position: Position) => getLanguageAtPosition(document, regions, position),
101
- getLanguagesInDocument: () => getLanguagesInDocument(document, regions),
102
- getImportedScripts: () => importedScripts,
96
+ getLanguageAtPosition: (position: Position) => getLanguageAtPosition(document, allRegions, position),
97
+ getLanguagesInDocument: () => getLanguagesInDocument(allRegions),
98
+ getHtmlDocument: () => htmlDocument,
103
99
  };
104
100
  }
105
101
 
106
- function getLanguageRanges(document: TextDocument, regions: EmbeddedRegion[], range: Range): LanguageRange[] {
107
- const result: LanguageRange[] = [];
108
- let currentPos = range ? range.start : Position.create(0, 0);
109
- let currentOffset = range ? document.offsetAt(range.start) : 0;
110
- const endOffset = range ? document.offsetAt(range.end) : document.getText().length;
111
- for (const region of regions) {
112
- if (!(region.end > currentOffset && region.start < endOffset)) {
113
- continue;
114
- }
102
+ /** The lookups below read the regions in order, so an overlap is dropped: a scanned region always wins over a markdown one. */
103
+ function mergeRegions(scanned: EmbeddedRegion[], markdown: EmbeddedRegion[]): EmbeddedRegion[] {
104
+ if (markdown.length === 0) return scanned;
115
105
 
116
- const start = Math.max(region.start, currentOffset);
117
- const startPos = document.positionAt(start);
118
- if (currentOffset < region.start) {
119
- result.push({
120
- start: currentPos,
121
- end: startPos,
122
- languageId: "html",
123
- });
124
- }
125
- const end = Math.min(region.end, endOffset);
126
- const endPos = document.positionAt(end);
127
- if (end > region.start) {
128
- result.push({
129
- start: startPos,
130
- end: endPos,
131
- languageId: region.languageId,
132
- attributeValue: region.attributeValue,
133
- });
134
- }
135
- currentOffset = end;
136
- currentPos = endPos;
137
- }
138
- if (currentOffset < endOffset) {
139
- const endPos = range ? range.end : document.positionAt(endOffset);
140
- result.push({
141
- start: currentPos,
142
- end: endPos,
143
- languageId: "html",
144
- });
106
+ const merged = [...scanned, ...markdown].toSorted((a, b) => a.start - b.start || b.end - a.end);
107
+ const result: EmbeddedRegion[] = [];
108
+
109
+ for (const region of merged) {
110
+ const previous = result.at(-1);
111
+ if (previous && region.start < previous.end) continue;
112
+ result.push(region);
145
113
  }
114
+
146
115
  return result;
147
116
  }
148
117
 
149
- function getLanguagesInDocument(_document: TextDocument, regions: EmbeddedRegion[]): string[] {
150
- const result: string[] = [];
118
+ function getLanguagesInDocument(regions: EmbeddedRegion[]): string[] {
119
+ const languages = new Set(["html"]);
151
120
 
152
121
  for (const region of regions) {
153
- if (!(region.languageId && !result.includes(region.languageId))) {
154
- continue;
155
- }
156
-
157
- result.push(region.languageId);
158
- if (result.length === 3) {
159
- return result;
122
+ if (region.languageId) {
123
+ languages.add(region.languageId);
160
124
  }
161
125
  }
162
126
 
163
- result.push("html");
164
-
165
- return result;
127
+ return [...languages];
166
128
  }
167
129
 
168
130
  function getLanguageAtPosition(document: TextDocument, regions: EmbeddedRegion[], position: Position): string | undefined {
@@ -179,100 +141,6 @@ function getLanguageAtPosition(document: TextDocument, regions: EmbeddedRegion[]
179
141
  return "html";
180
142
  }
181
143
 
182
- function getEmbeddedDocument(
183
- document: TextDocument,
184
- contents: EmbeddedRegion[],
185
- languageId: string,
186
- shouldIgnoreAttributeValues: boolean
187
- ): TextDocument {
188
- let currentPos = 0;
189
- const oldContent = document.getText();
190
- let result = "";
191
- let lastSuffix = "";
192
- for (const c of contents) {
193
- if (!(c.languageId === languageId && (!shouldIgnoreAttributeValues || !c.attributeValue))) {
194
- continue;
195
- }
196
-
197
- result = substituteWithWhitespace(result, currentPos, c.start, oldContent, lastSuffix, getPrefix(c));
198
- result += updateContent(c, oldContent.slice(c.start, c.end));
199
- currentPos = c.end;
200
- lastSuffix = getSuffix(c);
201
- }
202
- result = substituteWithWhitespace(result, currentPos, oldContent.length, oldContent, lastSuffix, "");
203
- return TextDocument.create(document.uri, languageId, document.version, result);
204
- }
205
-
206
- function getPrefix(c: EmbeddedRegion) {
207
- if (c.attributeValue) {
208
- switch (c.languageId) {
209
- case "css": {
210
- return CSS_STYLE_RULE + "{";
211
- }
212
- }
213
- }
214
- return "";
215
- }
216
- function getSuffix(c: EmbeddedRegion) {
217
- if (c.attributeValue) {
218
- switch (c.languageId) {
219
- case "css": {
220
- return "}";
221
- }
222
- case "javascript": {
223
- return ";";
224
- }
225
- }
226
- }
227
- return "";
228
- }
229
- function updateContent(c: EmbeddedRegion, content: string): string {
230
- if (!c.attributeValue && c.languageId === "javascript") {
231
- return content.replace(`<!--`, `/* `).replace(`-->`, ` */`);
232
- }
233
- if (c.languageId === "css") {
234
- const quoteEscape = /(&quot;|&#34;)/g;
235
- return content.replace(quoteEscape, (match, _, offset: number) => {
236
- const spaces = " ".repeat(match.length - 1);
237
- const afterChar = content[offset + match.length];
238
- if (!afterChar || afterChar.includes(" ")) {
239
- return `${spaces}"`;
240
- }
241
- return `"${spaces}`;
242
- });
243
- }
244
- return content;
245
- }
246
-
247
- function substituteWithWhitespace(result: string, start: number, end: number, oldContent: string, before: string, after: string) {
248
- result += before;
249
- let accumulatedWS = -before.length; // start with a negative value to account for the before string
250
- for (let index = start; index < end; index++) {
251
- const ch = oldContent[index];
252
- if (ch === "\n" || ch === "\r") {
253
- // only write new lines, skip the whitespace
254
- accumulatedWS = 0;
255
- result += ch;
256
- } else {
257
- accumulatedWS++;
258
- }
259
- }
260
- result = append(result, " ", accumulatedWS - after.length);
261
- result += after;
262
- return result;
263
- }
264
-
265
- function append(result: string, string_: string, n: number): string {
266
- while (n > 0) {
267
- if (n & 1) {
268
- result += string_;
269
- }
270
- n >>= 1;
271
- string_ += string_;
272
- }
273
- return result;
274
- }
275
-
276
144
  function getAttributeLanguage(attributeName: string): string | null {
277
145
  const match = attributeName.match(/^(style)$|^(on\w+)$/i);
278
146
  if (!match) {
@@ -7,44 +7,10 @@ import { getHTMLMode } from "./html-mode.ts";
7
7
  import type { LanguageModelCache } from "../language-model-cache.ts";
8
8
  import type { FileSystemProvider } from "../requests.ts";
9
9
  import type { HTMLDocumentRegions } from "./embedded-support.ts";
10
- import type {
11
- ClientCapabilities,
12
- CompletionConfiguration,
13
- DocumentContext,
14
- HoverSettings,
15
- HTMLDataV1,
16
- IHTMLDataProvider,
17
- } from "vscode-html-languageservice";
18
- import type {
19
- CompletionItem,
20
- CompletionList,
21
- DocumentLink,
22
- Hover,
23
- Position,
24
- Range,
25
- WorkspaceFolder,
26
- } from "vscode-languageserver";
10
+ import type { ClientCapabilities, DocumentContext, HTMLDataV1, IHTMLDataProvider } from "vscode-html-languageservice";
11
+ import type { CompletionItem, CompletionList, DocumentLink, Hover, Position } from "vscode-languageserver";
27
12
  import type { TextDocument } from "vscode-languageserver-textdocument";
28
13
 
29
- export interface Settings {
30
- hover?: HoverSettings;
31
- autoClosingTags?: boolean;
32
- completion?: CompletionConfiguration;
33
- suggest?: CompletionConfiguration;
34
- }
35
-
36
- export interface Workspace {
37
- readonly settings: Settings;
38
- readonly folders: WorkspaceFolder[];
39
- }
40
-
41
- export interface SemanticTokenData {
42
- start: Position;
43
- length: number;
44
- typeIdx: number;
45
- modifierSet: number;
46
- }
47
-
48
14
  export type CompletionItemData = {
49
15
  languageId: string;
50
16
  uri: string;
@@ -72,20 +38,15 @@ export interface LanguageMode {
72
38
 
73
39
  export interface LanguageModes {
74
40
  updateDataProviders(dataProviders: IHTMLDataProvider[]): void;
41
+ /** The document to hand to the HTML mode: itself for an HTML file, a copy holding only the raw HTML for a markdown one. */
42
+ getHtmlDocument(document: TextDocument): TextDocument;
75
43
  getModeAtPosition(document: TextDocument, position: Position): LanguageMode | undefined;
76
- getModesInRange(document: TextDocument, range: Range): LanguageModeRange[];
77
- getAllModes(): LanguageMode[];
78
44
  getAllModesInDocument(document: TextDocument): LanguageMode[];
79
45
  getMode(languageId: string): LanguageMode | undefined;
80
46
  onDocumentRemoved(document: TextDocument): void;
81
47
  dispose(): void;
82
48
  }
83
49
 
84
- export interface LanguageModeRange extends Range {
85
- mode: LanguageMode | undefined;
86
- attributeValue?: boolean;
87
- }
88
-
89
50
  export const FILE_PROTOCOL = "staticbolt-server";
90
51
 
91
52
  export function getLanguageModes(clientCapabilities: ClientCapabilities, requestService: FileSystemProvider): LanguageModes {
@@ -140,6 +101,10 @@ export function getLanguageModes(clientCapabilities: ClientCapabilities, request
140
101
  htmlLanguageService.setDataProviders(true, dataProviders);
141
102
  },
142
103
 
104
+ getHtmlDocument(document: TextDocument): TextDocument {
105
+ return documentRegions.get(document).getHtmlDocument();
106
+ },
107
+
143
108
  getModeAtPosition(document: TextDocument, position: Position): LanguageMode | undefined {
144
109
  const languageId = documentRegions.get(document).getLanguageAtPosition(position);
145
110
  if (languageId) {
@@ -149,20 +114,6 @@ export function getLanguageModes(clientCapabilities: ClientCapabilities, request
149
114
  return undefined;
150
115
  },
151
116
 
152
- getModesInRange(document: TextDocument, range: Range): LanguageModeRange[] {
153
- return documentRegions
154
- .get(document)
155
- .getLanguageRanges(range)
156
- .map((r): LanguageModeRange => {
157
- return {
158
- start: r.start,
159
- end: r.end,
160
- mode: r.languageId ? modes[r.languageId] : undefined,
161
- attributeValue: r.attributeValue,
162
- };
163
- });
164
- },
165
-
166
117
  getAllModesInDocument(document: TextDocument): LanguageMode[] {
167
118
  const result: LanguageMode[] = [];
168
119
  for (const languageId of documentRegions.get(document).getLanguagesInDocument()) {
@@ -175,18 +126,6 @@ export function getLanguageModes(clientCapabilities: ClientCapabilities, request
175
126
  return result;
176
127
  },
177
128
 
178
- getAllModes(): LanguageMode[] {
179
- const result = [];
180
- for (const languageId in modes) {
181
- const mode = modes[languageId];
182
- if (mode) {
183
- result.push(mode);
184
- }
185
- }
186
-
187
- return result;
188
- },
189
-
190
129
  getMode(languageId: string): LanguageMode {
191
130
  return modes[languageId];
192
131
  },
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Markdown allows raw HTML anywhere, so a ".md" file is served as HTML with the parts that can never be HTML — front matter, code
3
+ * blocks and code spans — blanked out first. Blanking keeps every offset, so positions still point at the same place in the
4
+ * file.
5
+ */
6
+ import { markdownToMdast } from "satteri";
7
+
8
+ import type { MdastNode } from "satteri";
9
+
10
+ export interface TextRegion {
11
+ start: number;
12
+ end: number;
13
+ }
14
+
15
+ /** None of these nest, so the regions they produce never overlap. */
16
+ const NON_HTML_NODES = new Set(["code", "inlineCode", "yaml", "toml"]);
17
+
18
+ /** The regions of a markdown document that must not be treated as HTML, sorted by start offset. */
19
+ export function findMarkdownNonHtmlRegions(text: string): TextRegion[] {
20
+ let tree: MdastNode;
21
+
22
+ try {
23
+ tree = markdownToMdast(text);
24
+ } catch (error) {
25
+ console.warn("[staticbolt] could not parse markdown, its whole content is handled as HTML:", error);
26
+ return [];
27
+ }
28
+
29
+ const regions: TextRegion[] = [];
30
+ const pending: MdastNode[] = [tree];
31
+
32
+ while (pending.length > 0) {
33
+ const node = pending.pop()!;
34
+
35
+ if (NON_HTML_NODES.has(node.type)) {
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
+
46
+ if ("children" in node) {
47
+ pending.push(...node.children);
48
+ }
49
+ }
50
+
51
+ const sorted = regions.toSorted((a, b) => a.start - b.start);
52
+
53
+ return toUtf16Offsets(text, sorted);
54
+ }
55
+
56
+ /**
57
+ * The parser counts code points, the editor counts UTF-16 code units. The two only drift apart once a character outside the basic
58
+ * plane, an emoji most of the time, sits before a region.
59
+ */
60
+ function toUtf16Offsets(text: string, regions: TextRegion[]): TextRegion[] {
61
+ if (!/[\uD800-\uDBFF]/.test(text)) return regions;
62
+
63
+ const astral: number[] = [];
64
+ let codePoint = 0;
65
+
66
+ for (const character of text) {
67
+ if (character.length === 2) {
68
+ astral.push(codePoint);
69
+ }
70
+
71
+ codePoint++;
72
+ }
73
+
74
+ const toUtf16 = (offset: number) => offset + astral.filter(position => position < offset).length;
75
+
76
+ return regions.map(region => ({ start: toUtf16(region.start), end: toUtf16(region.end) }));
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;
82
+
83
+ let result = "";
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;
89
+ }
90
+
91
+ return result + text.slice(cursor);
92
+ }