121 lines
3.7 KiB
TypeScript
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();
|
|
}
|