import { getLanguageService, TextDocument as HtmlTextDocument } from "vscode-html-languageservice"; import { isInsideHtml, virtualHtmlDocument } from "./html-regions.ts"; import { offsetAt, type Position, type TextDocument } from "./index.ts"; export interface HtmlCompletionItem { label: string; kind: number; detail?: string; documentation?: string; sortText?: string; insertText?: string; } const service = getLanguageService(); /** The virtual document as the HTML service's own document type. */ function htmlDocument(document: TextDocument) { const virtual = virtualHtmlDocument(document); return HtmlTextDocument.create(virtual.uri, "html", document.version ?? 1, virtual.text); } function markdown(value: unknown): string { if (typeof value === "string") return value; if (value && typeof value === "object" && "value" in value) { return String((value as { value: unknown }).value); } return ""; } /** * HTML completions for a position inside a view block. * * Every item carries the `1` sortText prefix so the server can rank WRNexus * entries above these without filtering either list. */ export function htmlCompletions(document: TextDocument, position: Position): HtmlCompletionItem[] { if (!isInsideHtml(document, offsetAt(document.text, position))) return []; const virtual = htmlDocument(document); const parsed = service.parseHTMLDocument(virtual); const list = service.doComplete(virtual, position, parsed); return list.items.map((item) => ({ label: item.label, kind: typeof item.kind === "number" ? item.kind : 1, detail: item.detail, documentation: markdown(item.documentation), sortText: `1${item.sortText ?? item.label}`, insertText: item.textEdit && "newText" in item.textEdit ? item.textEdit.newText : undefined, })); } export function htmlHover(document: TextDocument, position: Position): { contents: string } | null { if (!isInsideHtml(document, offsetAt(document.text, position))) return null; const virtual = htmlDocument(document); const result = service.doHover(virtual, position, service.parseHTMLDocument(virtual)); if (!result) return null; const contents = markdown(result.contents); return contents ? { contents } : null; } export function htmlFoldingRanges( document: TextDocument, ): Array<{ startLine: number; endLine: number }> { return service .getFoldingRanges(htmlDocument(document)) .map((range) => ({ startLine: range.startLine, endLine: range.endLine })); } /** * Detects ``. * * `vscode-html-languageservice`'s own `doTagComplete` only reacts to a typed * `/` when it opens an end tag (``) are exactly that case, so we complete * it ourselves rather than relying on the library. * * The scan tracks quote state from the tag's opening `<` up to `offset` (quotes * are only meaningful inside a tag) so a `/` inside an attribute value — e.g. the * first slash of `href="https://..."` — never misfires as a self-close: the * library already returns `null` there on purpose, because the cursor sits in an * attribute value, not a tag-close position. */ function selfClosingTagCompletion(text: string, offset: number): string | null { if (text.charAt(offset - 1) !== "/") return null; if (text.charAt(offset) === ">") return null; const tagStart = text.lastIndexOf("<", offset - 1); if (tagStart < 0) return null; if (!/^<[A-Za-z][\w-]*/.test(text.slice(tagStart))) return null; let quote: '"' | "'" | null = null; for (let i = tagStart + 1; i < offset - 1; i++) { const ch = text[i]; if (quote) { if (ch === quote) quote = null; continue; } if (ch === '"' || ch === "'") { quote = ch; continue; } if (ch === "<" || ch === ">") return null; } if (quote) return null; return ">"; } /** * The snippet that closes the tag being typed, or null. * * Void elements and already-closed tags return null, which is why this decision * belongs here rather than in the editor client. */ export function htmlTagComplete(document: TextDocument, position: Position): string | null { const offset = offsetAt(document.text, position); if (!isInsideHtml(document, offset)) return null; const virtual = htmlDocument(document); const result = service.doTagComplete(virtual, position, service.parseHTMLDocument(virtual)); if (result) return result; return selfClosingTagCompletion(document.text, offset); }