Files
WRNexusJS/packages/language-server/src/html-regions.ts
T

121 lines
3.7 KiB
TypeScript

import type { TextDocument } from "./index.ts";
export interface HtmlRegion {
start: number;
end: number;
}
/**
* Byte ranges of the markup inside each `view { }` block.
*
* This is a tolerant scanner rather than the parser: completion fires while
* the document is being typed, which is exactly when it does not parse.
*/
export function viewRegions(text: string): HtmlRegion[] {
const regions: HtmlRegion[] = [];
const pattern = /\bview\s*\{/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text))) {
const bodyStart = match.index + match[0].length;
const end = matchingBrace(text, bodyStart);
regions.push({ start: bodyStart, end });
pattern.lastIndex = end;
}
return regions;
}
/**
* Offset of the brace closing the block that starts at `from`, or the end of
* the text when it is never closed (an unterminated block is normal mid-edit).
*
* Quotes are only tracked inside a tag, never in text content: `<p>it's</p>`
* would otherwise open a string that never closes and swallow the rest of the
* file.
*/
function matchingBrace(text: string, from: number): number {
let depth = 1;
let inTag = false;
let quote: string | null = null;
for (let index = from; index < text.length; index += 1) {
const char = text[index]!;
if (quote) {
if (char === quote) quote = null;
continue;
}
if (inTag && (char === '"' || char === "'")) {
quote = char;
continue;
}
if (char === "<") inTag = true;
else if (char === ">") inTag = false;
else if (char === "{") depth += 1;
else if (char === "}") {
depth -= 1;
if (depth === 0) return index;
}
}
return text.length;
}
/**
* A parallel document containing only the markup.
*
* Everything outside a view block becomes whitespace of the same length, and
* newlines are preserved, so an offset in the source is the same offset here.
* That removes the need for a mapping table entirely.
*/
export function virtualHtmlDocument(document: TextDocument): {
uri: string;
languageId: "html";
text: string;
} {
const source = document.text;
const keep = new Array<boolean>(source.length).fill(false);
for (const region of viewRegions(source)) {
for (let index = region.start; index < region.end; index += 1) keep[index] = true;
}
let text = "";
for (let index = 0; index < source.length; index += 1) {
const char = source[index]!;
text += keep[index] || char === "\n" ? char : char === "\r" ? "\r" : " ";
}
return { uri: `${document.uri}.html`, languageId: "html", text };
}
export function isInsideHtml(document: TextDocument, offset: number): boolean {
return regionsFor(document).some((region) => offset >= region.start && offset <= region.end);
}
/**
* Regions for a document, cached by uri and version.
*
* A single keystroke produces a burst of completion, hover, and tag-close
* requests; without this each one rescans the file.
*/
const regionCache = new Map<string, { version: number; regions: HtmlRegion[] }>();
function regionsFor(document: TextDocument): HtmlRegion[] {
// Documents without a version have no way to signal changes, so bypass cache.
if (document.version === undefined) {
return viewRegions(document.text);
}
const cached = regionCache.get(document.uri);
if (cached && cached.version === document.version) return cached.regions;
const regions = viewRegions(document.text);
regionCache.set(document.uri, { version: document.version, regions });
return regions;
}
/** Drops a document's cached regions. Call when a document closes. */
export function clearHtmlRegionCache(uri?: string): void {
if (uri) regionCache.delete(uri);
else regionCache.clear();
}