129 lines
4.6 KiB
TypeScript
129 lines
4.6 KiB
TypeScript
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 `<Name attr="x" /` just before `position` and completes the `>`.
|
|
*
|
|
* `vscode-html-languageservice`'s own `doTagComplete` only reacts to a typed
|
|
* `/` when it opens an end tag (`</`); it has no notion of a self-closing
|
|
* start tag, since plain HTML has no such elements outside its fixed void-element
|
|
* list. WRNexus components (`<Card />`) 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);
|
|
}
|