muddle/editors/vscode/src/extension.js
2026-10-23 09:12:49 -04:00

757 lines
18 KiB
JavaScript

const vscode = require("vscode");
const AT_RULES = new Set([
"each",
"for",
"from",
"through",
"include",
"responsive",
"media",
"supports",
"concentric",
"view-transition",
"predict",
"measure",
"property",
"mixin",
"stcss",
"import",
"charset",
"document",
"font-face",
"keyframes",
"layer",
"namespace",
"page",
"scope",
"starting-style",
"container",
]);
const STATE_WORDS = new Set([
"active",
"clicked",
"focus",
"hover",
"open",
"closed",
"selected",
"loading",
"visible",
"hidden",
]);
const AT_RULE_DOCS = new Map([
["mixin", "Declare a reusable STCSS mixin: `@mixin name($param) { … }`"],
["include", "Expand a mixin: `@include name(10%);`"],
["each", "Repeat rules over values: `@each $item in a b c { … }`"],
["for", "Count a range: `@for $i from 1 through 5 { … }`"],
["responsive", "Wrap rules in a named breakpoint from the config."],
["media", "Standard `@media` query wrapper."],
["supports", "Standard `@supports` feature wrapper."],
["concentric", "Emit concentric nested radii: `@concentric 0.75rem { … }`"],
["view-transition", "Emit `view-transition-name` on the attached rule."],
[
"predict",
"Declare which properties transition when prediction prefill changes (emits a smooth transition rule).",
],
[
"measure",
'Mark the attached rule (or listed selectors / `@anchor`s) as a prediction target. Prerender measures the elements and writes geometry. Bare `@anchor` arguments compile to `[data-muddle-anchor="anchor"]`.',
],
]);
const REFERENCE_DOC =
'Reads pre-computed geometry recorded from a `@sample` anchor: compiles to `var(--muddle-relate-…)`.\n\nMetrics: `left`, `top`, `width`, `height`. `@anchor` compiles to `[data-muddle-anchor="anchor"]`.';
const RELATE_DOC =
"Legacy relation value of an anchor (deprecated in favour of `$refer[metric](@anchor)`).";
const CSS_PROPERTIES = [
"background",
"background-color",
"background-image",
"border",
"border-color",
"border-radius",
"border-width",
"bottom",
"box-shadow",
"box-sizing",
"color",
"cursor",
"display",
"flex",
"flex-basis",
"flex-direction",
"flex-shrink",
"flex-wrap",
"font-family",
"font-size",
"font-weight",
"gap",
"grid-area",
"height",
"inset",
"justify-content",
"left",
"letter-spacing",
"line-height",
"margin",
"max-height",
"max-width",
"min-height",
"min-width",
"opacity",
"overflow",
"padding",
"pointer-events",
"position",
"right",
"text-decoration",
"text-transform",
"top",
"transform",
"transition",
"vertical-align",
"visibility",
"white-space",
"width",
"z-index",
];
const HTML_ATTRIBUTES = [
"alt",
"aria-controls",
"aria-current",
"aria-description",
"aria-expanded",
"aria-hidden",
"aria-label",
"aria-live",
"autocomplete",
"class",
"content",
"href",
"id",
"placeholder",
"rel",
"role",
"src",
"style",
"tabindex",
"target",
"title",
"type",
];
const INDEX = {
anchors: new Map(), // name -> [{ uri, line, kind }]
variables: new Map(), // $name -> [{ uri, line }]
spugFiles: [],
styleFiles: [],
};
let indexToken = 0;
function relativeToWorkspace(uri) {
const folder = vscode.workspace.getWorkspaceFolder(uri);
if (!folder) return uri.fsPath.split(/[\\/]/).pop();
return uri.fsPath.slice(folder.uri.fsPath.length + 1);
}
function debounce(callback, wait) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => callback(...args), wait);
};
}
async function readText(uri) {
try {
const bytes = await vscode.workspace.fs.readFile(uri);
return new TextDecoder().decode(bytes);
} catch {
return "";
}
}
function posFromOffset(text, offset) {
const before = text.slice(0, offset);
const newline = before.lastIndexOf("\n");
return new vscode.Position(
before.split("\n").length - 1,
offset - newline - 1,
);
}
function rangeOf(text, offset, length) {
return new vscode.Range(
posFromOffset(text, offset),
posFromOffset(text, offset + Math.max(length, 1)),
);
}
function isStcss(document) {
return document.languageId === "stcss";
}
function rememberAnchor(name, uri, line, kind) {
const list = INDEX.anchors.get(name) ?? [];
list.push({ uri, line, kind });
INDEX.anchors.set(name, list);
}
function indexSpugDocument(uri, text) {
const lines = text.split("\n");
const anchorRegex = /@([A-Za-z_][\w-]*)(?:\(([^(){}]*)\))?/g;
lines.forEach((line, index) => {
let match;
anchorRegex.lastIndex = 0;
while ((match = anchorRegex.exec(line))) {
if (AT_RULES.has(match[1]) || match[1] === "sample") continue;
rememberAnchor(match[1], uri, index + 1, "declared in SPUG");
}
});
}
function indexStcssDocument(uri, text) {
const variableRegex = /(\$[\w-]+)\s*:/g;
let match;
while ((match = variableRegex.exec(text))) {
const position = posFromOffset(text, match.index);
const list = INDEX.variables.get(match[1]) ?? [];
list.push({ uri, line: position.line + 1 });
INDEX.variables.set(match[1], list);
}
const anchorRegex = /@([A-Za-z_][\w-]*)/g;
while ((match = anchorRegex.exec(text))) {
if (AT_RULES.has(match[1])) continue;
rememberAnchor(
match[1],
uri,
posFromOffset(text, match.index).line + 1,
"referenced in STCSS",
);
}
}
async function indexWorkspace() {
const token = ++indexToken;
if (!vscode.workspace.workspaceFolders) return;
const [spugFiles, styleFiles] = await Promise.all([
vscode.workspace.findFiles("**/*.spug", "**/node_modules/**"),
vscode.workspace.findFiles("**/*.stcss", "**/node_modules/**"),
]);
if (token !== indexToken) return;
INDEX.anchors = new Map();
INDEX.variables = new Map();
INDEX.spugFiles = spugFiles;
INDEX.styleFiles = styleFiles;
await Promise.all([
...spugFiles.map(async (uri) =>
indexSpugDocument(uri, await readText(uri)),
),
...styleFiles.map(async (uri) =>
indexStcssDocument(uri, await readText(uri)),
),
]);
}
function anchorCompletions() {
return [...INDEX.anchors.keys()].map((name) => {
const item = new vscode.CompletionItem(
`@${name}`,
vscode.CompletionItemKind.Keyword,
);
item.detail = "relation anchor";
const first = INDEX.anchors.get(name)[0];
if (first)
item.documentation = new vscode.MarkdownString(
`${first.kind}: \`${relativeToWorkspace(first.uri)}:${first.line}\``,
);
return item;
});
}
function stcssCompletions() {
const atRules = [...AT_RULE_DOCS.keys()].map((name) => {
const item = new vscode.CompletionItem(
`@${name}`,
vscode.CompletionItemKind.Struct,
);
item.detail = "stcss at-rule";
item.insertText = new vscode.SnippetString(
name === "concentric" || name === "media"
? `${name} $1 {\n\t$0\n}`
: `${name};`,
);
item.documentation = new vscode.MarkdownString(AT_RULE_DOCS.get(name));
return item;
});
const metrics = ["left", "top", "width", "height"].map((metric) => {
const item = new vscode.CompletionItem(
`$refer[${metric}](@)`,
vscode.CompletionItemKind.Function,
);
item.insertText = new vscode.SnippetString(
`$refer[${metric}](@\${1:anchor})`,
);
item.documentation = new vscode.MarkdownString(REFERENCE_DOC);
return item;
});
const relate = new vscode.CompletionItem(
"$relate(@)",
vscode.CompletionItemKind.Function,
);
relate.insertText = new vscode.SnippetString("$relate(@${1:anchor})");
relate.documentation = new vscode.MarkdownString(RELATE_DOC);
const states = [...STATE_WORDS].map((state) => {
const item = new vscode.CompletionItem(
state,
vscode.CompletionItemKind.EnumMember,
);
item.detail = "anchor state";
return item;
});
const properties = CSS_PROPERTIES.map((name) => {
const item = new vscode.CompletionItem(
name,
vscode.CompletionItemKind.Property,
);
item.insertText = new vscode.SnippetString(`${name}: $0;`);
return item;
});
const variables = [...INDEX.variables.keys()].map((name) => {
const item = new vscode.CompletionItem(
name,
vscode.CompletionItemKind.Variable,
);
item.detail = "stcss variable";
return item;
});
return [
...atRules,
...metrics,
relate,
...states,
...properties,
...variables,
];
}
function spugCompletions() {
const keywords = [
"extends",
"include",
"block",
"append",
"prepend",
"mixin",
"slot",
"yield",
"island",
"component",
"render",
"doctype",
].map((word) => {
const item = new vscode.CompletionItem(
word,
vscode.CompletionItemKind.Keyword,
);
return item;
});
const javascript = new vscode.CompletionItem(
"javascript!",
vscode.CompletionItemKind.Keyword,
);
javascript.insertText = new vscode.SnippetString("javascript!\n\t$0\nend");
const control = [
"if",
"else",
"unless",
"each",
"while",
"case",
"when",
"default",
"props",
].map(
(word) =>
new vscode.CompletionItem(word, vscode.CompletionItemKind.Keyword),
);
const attributes = HTML_ATTRIBUTES.map((name) => {
const item = new vscode.CompletionItem(
name,
vscode.CompletionItemKind.Property,
);
item.insertText = new vscode.SnippetString(`${name}=$0`);
return item;
});
return [
...keywords,
javascript,
...control,
...attributes,
...anchorCompletions(),
];
}
function stcssHover(document, position) {
const line = document.lineAt(position.line).text;
const preceding = line.slice(0, position.character);
const anchor = /(?:^|[^\w$])@([A-Za-z_][\w-]*)$/.exec(preceding)?.[1];
if (anchor) return hoverAnchor(anchor);
const variable = /\$[\w-]+$/.exec(preceding)?.[0];
if (variable) {
const hits = INDEX.variables.get(variable) ?? [];
return new vscode.Hover(
new vscode.MarkdownString(
hits.length
? `**${variable}**: defined in ${hits.map((h) => `${relativeToWorkspace(h.uri)}:${h.line}`).join(", ")}`
: `**${variable}**`,
),
);
}
const refer = /(?:\$\w+(?:\[[\w-]+\])?)$/.exec(preceding)?.[0];
if (refer) {
if (refer.startsWith("$refer"))
return new vscode.Hover(new vscode.MarkdownString(REFERENCE_DOC));
if (refer.startsWith("$relate"))
return new vscode.Hover(new vscode.MarkdownString(RELATE_DOC));
}
return null;
}
function hoverAnchor(name) {
const hits = INDEX.anchors.get(name) ?? [];
if (!hits.length) return null;
const body = hits
.slice(0, 8)
.map(
(hit) => `- ${hit.kind}: \`${relativeToWorkspace(hit.uri)}:${hit.line}\``,
)
.join("\n");
return new vscode.Hover(
new vscode.MarkdownString(`**@${name}**: relation anchor\n\n${body}`),
);
}
function spugHover(document, position) {
const range = document.getWordRangeAtPosition(position, /@?[A-Za-z_][\w-]*/);
if (!range) return null;
const word = document.getText(range);
if (
word.startsWith("@") ||
document.getText(
new vscode.Range(
range.start.translate({ characterDelta: -1 }),
range.start,
),
) === "@"
) {
return hoverAnchor(word.replace(/^@/, ""));
}
return null;
}
function anchorDefinition(name) {
return (INDEX.anchors.get(name) ?? [])
.filter((hit) => hit.kind === "declared in SPUG")
.map(
(hit) =>
new vscode.Location(hit.uri, new vscode.Position(hit.line - 1, 0)),
);
}
function stcssDefinition(document, position) {
const line = document
.lineAt(position.line)
.text.slice(0, position.character + 1);
const anchor = /@([A-Za-z_][\w-]*)[^\w]?$/.exec(line)?.[1];
if (anchor) return anchorDefinition(anchor);
return null;
}
function spugDefinition(document, position) {
const before = document
.lineAt(position.line)
.text.slice(0, position.character + 1);
const anchor = /@([A-Za-z_][\w-]*)[^\w]?$/.exec(before)?.[1];
if (anchor) {
// jump from anchor to its STCSS references
const refs = (INDEX.anchors.get(anchor) ?? []).filter(
(hit) => hit.kind === "referenced in STCSS",
);
return refs.map(
(hit) =>
new vscode.Location(
hit.uri,
new vscode.Range(hit.line - 1, 0, hit.line - 1, 200),
),
);
}
return null;
}
function diagnosticCollection() {
return vscode.languages.createDiagnosticCollection("muddle");
}
function lintDocument(document, collection) {
if (document.languageId !== "stcss" && document.languageId !== "spug")
return [];
const text = document.getText();
const issues = [];
// balanced braces in both languages
let depth = 0;
let lastOpen = 0;
for (let index = 0; index < text.length && issues.length < 40; index += 1) {
const char = text[index];
if (char === "{") {
depth += 1;
lastOpen = index;
} else if (char === "}") {
depth -= 1;
if (depth < 0) {
issues.push(
new vscode.Diagnostic(
rangeOf(text, index, 1),
"Unexpected `}`.",
vscode.DiagnosticSeverity.Error,
),
);
depth = 0;
}
}
}
if (depth > 0) {
issues.push(
new vscode.Diagnostic(
rangeOf(text, lastOpen, 1),
"Unclosed `{` block.",
vscode.DiagnosticSeverity.Error,
),
);
}
if (document.languageId === "stcss") {
const anchorRegex = /@([A-Za-z_][\w-]*)/g;
let match;
while ((match = anchorRegex.exec(text))) {
const name = match[1];
const offset = match.index;
const previous = text[offset - 1];
const dynamic = text
.slice(
offset,
text.indexOf("\n", offset) === -1
? text.length
: text.indexOf("\n", offset),
)
.includes("(");
if (AT_RULES.has(name) || previous === "$" || dynamic) continue;
if (!INDEX.anchors.has(name)) {
issues.push(
new vscode.Diagnostic(
rangeOf(text, offset, match[0].length),
`No SPUG file declares relation anchor \`@${name}\`.`,
vscode.DiagnosticSeverity.Warning,
),
);
}
}
}
collection.set(document.uri, issues);
return issues;
}
function lintVisible() {
for (const editor of vscode.window.visibleTextEditors)
lintDocument(editor.document, diagnostics);
}
function symbolKindFor(document) {
return isStcss(document) ? vscode.SymbolKind.Class : vscode.SymbolKind.Module;
}
function symbolsOf(document) {
const flat = [];
for (let line = 0; line < document.lineCount; line += 1) {
const text = document.lineAt(line).text;
const match = /^(\s*)(@?[A-Za-z][^\n{;]*?|@[\w-]+)\s*\{?\s*$/.exec(text);
if (!match || !text.trim()) continue;
if (isStcss(document) && !text.includes("{")) continue;
if (!isStcss(document) && text.includes("{")) continue;
const label = match[2].trim() || "root";
const symbol = new vscode.DocumentSymbol(
label,
"",
symbolKindFor(document),
new vscode.Range(line, 0, line, text.length),
new vscode.Range(
line,
match[1].length,
line,
match[1].length + label.length,
),
);
flat.push({ symbol, indent: match[1].length });
}
const roots = [];
const stack = [];
for (const entry of flat) {
while (stack.length && entry.indent <= stack[stack.length - 1].indent)
stack.pop();
if (stack.length)
stack[stack.length - 1].symbol.children.push(entry.symbol);
else roots.push(entry.symbol);
stack.push(entry);
}
return roots;
}
function foldingOf(document) {
const ranges = [];
const stack = [];
for (let line = 0; line < document.lineCount; line += 1) {
const text = document.lineAt(line).text;
if (isStcss(document)) {
const opens = (text.match(/\{/g) ?? []).length;
const closes = (text.match(/\}/g) ?? []).length;
for (let i = 0; i < opens; i += 1) stack.push(line);
for (let i = 0; i < closes && stack.length; i += 1) {
const start = stack.pop();
if (line > start) ranges.push(new vscode.FoldingRange(start, line));
}
} else {
const indent = text.match(/^(\s*)/)[1].length;
if (!text.trim()) continue;
while (stack.length && indent <= stack[stack.length - 1].indent) {
const start = stack.pop();
if (line - 1 > start)
ranges.push(new vscode.FoldingRange(start, line - 1));
}
stack.push({ indent, line });
}
}
while (stack.length) {
const start =
typeof stack[stack.length - 1] === "number"
? stack.pop()
: stack.pop().line;
if (document.lineCount - 1 > start)
ranges.push(new vscode.FoldingRange(start, document.lineCount - 1));
}
return ranges;
}
let diagnostics;
function completionsFor(document, position) {
const line = document.lineAt(position.line);
const before = line.text.slice(0, position.character);
if (line.isEmptyOrWhitespace) return [];
const prefix = before.trim();
if (prefix.length === 0 && position.character > 0) return [];
// completions popped while just typing whitespace mid-line are noise
if (
before.length > 0 &&
/\s$/.test(before) &&
!/[$@:(\[=,{]\s*$/.test(before)
)
return [];
return isStcss(document) ? stcssCompletions(before) : spugCompletions(before);
}
function activate(context) {
diagnostics = diagnosticCollection();
const refresh = debounce(async () => {
await indexWorkspace();
lintVisible();
}, 500);
Promise.resolve(refresh());
context.subscriptions.push(
diagnostics,
vscode.languages.registerCompletionItemProvider(
["stcss", "spug"],
{
provideCompletionItems(document, position) {
return completionsFor(document, position);
},
},
"@",
"$",
".",
"-",
":",
"=",
),
vscode.languages.registerHoverProvider(["stcss", "spug"], {
provideHover(document, position) {
const word = document.getWordRangeAtPosition(position, /[@$]?[\w-]+/);
if (!word) return null;
try {
return isStcss(document)
? stcssHover(document, position)
: spugHover(document, position);
} catch {
return null;
}
},
}),
vscode.languages.registerDefinitionProvider(["stcss", "spug"], {
provideDefinition(document, position) {
try {
return isStcss(document)
? stcssDefinition(document, position)
: spugDefinition(document, position);
} catch {
return null;
}
},
}),
vscode.languages.registerDocumentSymbolProvider(["stcss", "spug"], {
provideDocumentSymbols(document) {
return symbolsOf(document);
},
}),
vscode.languages.registerFoldingRangeProvider(["stcss", "spug"], {
provideFoldingRanges(document) {
return foldingOf(document);
},
}),
vscode.workspace.onDidOpenTextDocument((document) =>
lintDocument(document, diagnostics),
),
vscode.workspace.onDidChangeTextDocument((event) => {
lintDocument(event.document, diagnostics);
refresh();
}),
vscode.workspace.onDidSaveTextDocument(refresh),
vscode.workspace.onDidCreateFiles(refresh),
vscode.workspace.onDidDeleteFiles(refresh),
);
lintVisible();
}
function deactivate() {
return undefined;
}
module.exports = { activate, deactivate };