diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 29c4e6fb..93db8744 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "wrnexus", "displayName": "WRNexus Language Support", "description": "Syntax highlighting, formatting, diagnostics, snippets and completions for WrNexus .wrn files.", - "version": "0.2.2", + "version": "0.2.3", "publisher": "wrnexus", "private": true, "license": "SEE LICENSE IN LICENSE", diff --git a/editors/vscode/src/extension.js b/editors/vscode/src/extension.js index a85b0af3..bf3b6d43 100644 --- a/editors/vscode/src/extension.js +++ b/editors/vscode/src/extension.js @@ -2,7 +2,7 @@ "use strict"; const vscode = require("vscode"); -const { formatWrn } = require("./formatter.js"); +const { formatWrn } = require("./formatter"); // The .wrn compiler, bundled to CJS by `bun run build:compiler`. Loaded // defensively so the rest of the extension (highlighting, snippets, completion) @@ -115,13 +115,17 @@ function activate(context) { context.subscriptions.push( vscode.languages.registerDocumentFormattingEditProvider("wrn", { provideDocumentFormattingEdits(document, options) { - if (!vscode.workspace.getConfiguration("wrnexus").get("format.enable", true)) return []; - const formatted = formatWrn(document.getText(), options); - if (formatted === document.getText()) return []; - const end = document.positionAt(document.getText().length); - return [ - vscode.TextEdit.replace(new vscode.Range(new vscode.Position(0, 0), end), formatted), - ]; + const source = document.getText(); + + const formatted = formatWrn(source, { + tabSize: options.tabSize, + insertSpaces: options.insertSpaces, + printWidth: 100, + }); + + const range = new vscode.Range(document.positionAt(0), document.positionAt(source.length)); + + return [vscode.TextEdit.replace(range, formatted)]; }, }), vscode.languages.registerCompletionItemProvider( diff --git a/editors/vscode/src/formatter.js b/editors/vscode/src/formatter.js index d5398dab..d5e9a92d 100644 --- a/editors/vscode/src/formatter.js +++ b/editors/vscode/src/formatter.js @@ -17,152 +17,271 @@ const VOID_ELEMENTS = new Set([ "wbr", ]); -/** Count structural curly braces while ignoring quoted strings and line comments. */ -function curlyDelta(line, state) { - let delta = 0; +function findOpeningTagEnd(value) { let quote = null; - let escaped = false; - for (let index = 0; index < line.length; index++) { - const char = line[index]; - const next = line[index + 1]; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; - if (state.blockComment) { - if (char === "*" && next === "/") { - state.blockComment = false; - index++; + if (quote !== null) { + if (char === quote && value[index - 1] !== "\\") { + quote = null; } + continue; } - if (state.template) { - if (!escaped && char === "`") state.template = false; - escaped = !escaped && char === "\\"; - if (char !== "\\") escaped = false; - continue; - } - if (quote) { - if (!escaped && char === quote) quote = null; - escaped = !escaped && char === "\\"; - if (char !== "\\") escaped = false; - continue; - } - if (char === "/" && next === "*") { - state.blockComment = true; - index++; - } else if (char === "/" && next === "/") { - break; - } else if (char === "`") { - state.template = true; - } else if (char === '"' || char === "'") { + + if (char === '"' || char === "'") { quote = char; - } else if (char === "{") { - delta++; - } else if (char === "}") { - delta--; + continue; + } + + if (char === ">") { + return index; } } - return delta; + + return -1; } -/** Return the net nesting introduced by HTML-like tags on this line. */ -function htmlDelta(line) { - let delta = 0; - const tags = line.matchAll(/<\s*(\/?)\s*([A-Za-z][\w:-]*)\b[^>]*>/g); - for (const match of tags) { - const full = match[0]; - const closing = match[1] === "/"; - const name = match[2].toLowerCase(); - if (closing) delta--; - else if (!VOID_ELEMENTS.has(name) && !/\/\s*>$/.test(full)) delta++; - } - return delta; -} +function parseAttributes(value) { + const attributes = []; + const pattern = + /[^\s"'=<>`]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g; -function leadingClosers(line) { - let count = /^\s*}/.test(line) ? 1 : 0; - const tags = line.match(/^\s*((?:<\/\s*[A-Za-z][\w:-]*\s*>\s*)+)/); - if (tags) count += [...tags[1].matchAll(/<\//g)].length; - return count; -} + let match; -function formatOpeningTag(source, unit, baseDepth) { - if ( - !source.startsWith("<") || - source.startsWith("$/.exec(source); + return attributes; +} + +function parseOpeningTag(value) { + const endIndex = findOpeningTagEnd(value); + + if (endIndex === -1) { + return null; + } + + const openingPart = value.slice(0, endIndex + 1); + const remainder = value.slice(endIndex + 1).trim(); + + const match = /^<([A-Za-z][\w:-]*)([\s\S]*?)(\/?)>$/.exec(openingPart); if (!match) { - return source; + return null; } - const tag = match[1]; - const rawAttributes = match[2].trim(); + const tagName = match[1]; + const attributes = parseAttributes(match[2].trim()); const selfClosing = match[3] === "/"; + const inlineClosing = remainder === ``; - if (!rawAttributes) { - return source; - } - - const attributes = []; - const pattern = /(?:[^\s"'=<>`]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'))?/g; - - for (const attribute of rawAttributes.matchAll(pattern)) { - attributes.push(attribute[0]); - } - - if (attributes.length < 2) { - return source; - } - - const base = unit.repeat(baseDepth); - const child = unit.repeat(baseDepth + 1); - - return [ - `${base}<${tag}`, - ...attributes.map((attribute) => `${child}${attribute}`), - `${base}${selfClosing ? "/>" : ">"}`, - ].join("\n"); + return { + tagName, + attributes, + selfClosing, + inlineClosing, + remainder, + }; } -/** - * Format WRN source conservatively: normalize structural indentation and - * trailing whitespace without rewriting expressions, HTML, CSS, or JS. - */ -function formatWrn(text, options = {}) { - const tabSize = Math.max(1, Number(options.tabSize) || 2); - const unit = options.insertSpaces === false ? "\t" : " ".repeat(tabSize); - const hadFinalNewline = /\r?\n$/.test(text); - const lines = text.replace(/\r\n/g, "\n").split("\n"); - if (hadFinalNewline) lines.pop(); +function formatOpeningTag(value, unit, depth, printWidth = 100) { + const parsed = parseOpeningTag(value); - let depth = 0; - const state = { blockComment: false, template: false }; - const formatted = lines.map((original) => { - const line = original.trimEnd(); - if (!line.trim()) return ""; + if (!parsed) { + return { + lines: [`${unit.repeat(depth)}${value.trim()}`], + opensElement: false, + }; + } - // Preserve multiline template-string content because its whitespace can be data. - const wasTemplate = state.template; - const trimmed = line.trimStart(); - const indent = Math.max(0, depth - leadingClosers(trimmed)); - const formattedLine = formatOpeningTag(trimmed, unit, indent); + const baseIndent = unit.repeat(depth); + const attributeIndent = unit.repeat(depth + 1); + const normalizedSingleLine = value.replace(/\s+/g, " ").trim(); - const output = wasTemplate - ? line - : formattedLine.startsWith(unit.repeat(indent)) - ? formattedLine - : unit.repeat(indent) + formattedLine; - depth = Math.max(0, depth + curlyDelta(trimmed, state) + htmlDelta(trimmed)); - return output; - }); + const shouldBreak = + parsed.attributes.length > 1 || + normalizedSingleLine.length > printWidth || + value.includes("\n"); - return formatted.join("\n") + (hadFinalNewline ? "\n" : ""); + const opensElement = + !parsed.selfClosing && + !parsed.inlineClosing && + !VOID_ELEMENTS.has(parsed.tagName.toLowerCase()); + + if (!shouldBreak) { + return { + lines: [`${baseIndent}${normalizedSingleLine}`], + opensElement, + }; + } + + const lines = [ + `${baseIndent}<${parsed.tagName}`, + ...parsed.attributes.map( + (attribute) => `${attributeIndent}${attribute}`, + ), + ]; + + if (parsed.inlineClosing) { + lines.push(`${baseIndent}>`); + } else if (parsed.selfClosing) { + lines.push(`${baseIndent}/>`); + } else { + lines.push(`${baseIndent}>`); + + if (parsed.remainder) { + lines.push(`${baseIndent}${parsed.remainder}`); + } + } + + return { + lines, + opensElement, + }; } -module.exports = { formatWrn }; +function isMultilineOpeningTagStart(value) { + if (!value.startsWith("<")) { + return false; + } + + if ( + value.startsWith("/.test(value); +} + +function isInlineElement(value) { + return /^<([A-Za-z][\w:-]*)\b[^>]*>[\s\S]*<\/\1\s*>$/.test(value); +} + +function isWrnBlockClosing(value) { + return value === "}" || value.startsWith("} "); +} + +function isWrnBlockOpening(value) { + if (!value.endsWith("{")) { + return false; + } + + return !value.startsWith("{"); +} + +function formatWrn(source, options = {}) { + const unit = + options.insertSpaces === false + ? "\t" + : " ".repeat(options.tabSize || 4); + + const printWidth = options.printWidth || 100; + const inputLines = source.replace(/\r\n/g, "\n").split("\n"); + const output = []; + + let wrnDepth = 0; + let htmlDepth = 0; + let index = 0; + let previousWasBlank = false; + + while (index < inputLines.length) { + const originalLine = inputLines[index]; + const trimmed = originalLine.trim(); + + if (trimmed === "") { + if (!previousWasBlank && output.length > 0) { + output.push(""); + } + + previousWasBlank = true; + index += 1; + continue; + } + + previousWasBlank = false; + + let value = trimmed; + + if (isMultilineOpeningTagStart(value)) { + const collected = [value]; + let cursor = index + 1; + + while (cursor < inputLines.length) { + const nextPart = inputLines[cursor].trim(); + collected.push(nextPart); + + const joined = collected.join(" "); + + if (findOpeningTagEnd(joined) !== -1) { + break; + } + + cursor += 1; + } + + value = collected.join(" "); + index = cursor; + } + + if (isWrnBlockClosing(value)) { + wrnDepth = Math.max(0, wrnDepth - 1); + } + + if (isClosingTag(value)) { + htmlDepth = Math.max(0, htmlDepth - 1); + } + + const depth = wrnDepth + htmlDepth; + + if ( + value.startsWith("<") && + !value.startsWith(" 0 && output[output.length - 1] === "") { + output.pop(); + } + + return `${output.join("\n")}\n`; +} + +module.exports = { + formatOpeningTag, + formatWrn, +}; \ No newline at end of file diff --git a/editors/vscode/wrnexus-0.2.2.vsix b/editors/vscode/wrnexus-0.2.2.vsix index 46acb413..ab349418 100644 Binary files a/editors/vscode/wrnexus-0.2.2.vsix and b/editors/vscode/wrnexus-0.2.2.vsix differ diff --git a/editors/vscode/wrnexus-0.2.3.vsix b/editors/vscode/wrnexus-0.2.3.vsix new file mode 100644 index 00000000..608866ad Binary files /dev/null and b/editors/vscode/wrnexus-0.2.3.vsix differ