@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.
@@ -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): TextRegion[] {
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: TextRegion[] = [];
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
- 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
-
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: TextRegion[]): TextRegion[] {
61
- if (!/[\uD800-\uDBFF]/.test(text)) return regions;
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
- 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;
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
- 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;
78
+ return offset + astralBefore;
89
79
  }
90
80
 
91
- return result + text.slice(cursor);
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
- /** Separates the documentation of two contributors. Rendered as a horizontal rule, unless the description is plain text. */
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 ordered;
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" || kind === "markdown") continue;
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
- // nothing to merge, keep the original as-is
57
- if (defined.length <= 1) return defined[0];
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 = (typeof description === "string" ? description : description.value).trim();
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) return undefined;
86
+ if (parts.length === 0) {
87
+ return undefined;
88
+ }
71
89
 
72
90
  const kind = mergeDescriptionKind(defined);
73
- const value = parts.join(kind === "plaintext" ? PLAINTEXT_SEPARATOR : MARKDOWN_SEPARATOR);
91
+ const separator = kind === "plaintext" ? PLAINTEXT_SEPARATOR : MARKDOWN_SEPARATOR;
92
+ const value = parts.join(separator);
74
93
 
75
- return kind ? { kind, value } : value;
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
- return merged.length > 0 ? merged : undefined;
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
- return merged.size > 0 ? [...merged] : undefined;
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
- if (!attribute.valueSet) return values;
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) return group[0];
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
- // nothing to merge and no value set to expand, keep the original as-is
143
- if (group.length === 1 && !group[0].valueSet) return group[0];
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
- // `valueSet` is dropped on purpose: every referenced set is now expanded into `values`
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) return { ...group[0], attributes };
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
- valueSets.set(valueSet.name, existing ? mergeValues([...existing, ...valueSet.values]) : valueSet.values);
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) return cached;
261
+ if (cached) {
262
+ return cached;
263
+ }
220
264
 
221
- // an unknown tag only sees the global attributes, which are merged already
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) return globalAttributes;
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
- // merged attributes already carry their expanded value sets
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
+ }