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: `

it's

` * 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(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(); 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(); }