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 };