"use strict"; const vscode = require("vscode"); const path = require("node:path"); const { parseComponentMetadata, parseComponentTags, validateComponentTags, } = require("./component-metadata"); let catalogPromise = null; async function readMetadata(uri) { try { const document = await vscode.workspace.openTextDocument(uri); return parseComponentMetadata(document.getText(), uri); } catch { return null; } } async function installedUiUris() { const uris = []; for (const folder of vscode.workspace.workspaceFolders || []) { const directory = vscode.Uri.joinPath( folder.uri, "node_modules", "@wrnexus", "ui", "components", ); try { for (const [name, type] of await vscode.workspace.fs.readDirectory(directory)) { if (type === vscode.FileType.File && name.endsWith(".wrn")) { uris.push(vscode.Uri.joinPath(directory, name)); } } } catch { // The UI package is optional; app-local components still work. } } return uris; } async function loadComponentCatalog() { const localUris = await vscode.workspace.findFiles( "**/*.wrn", "**/{node_modules,dist,.wrnexus,.git}/**", ); const uiUris = await installedUiUris(); const metadata = await Promise.all([...uiUris, ...localUris].map(readMetadata)); const catalog = new Map(); for (const component of metadata) { if (component && (component.kind === "component" || component.kind === "layout")) { catalog.set(component.name, component); } } return catalog; } function componentCatalog() { catalogPromise ||= loadComponentCatalog(); return catalogPromise; } function invalidateCatalog() { catalogPromise = null; } function openingTagAt(source, offset) { const opening = source.lastIndexOf("<", offset); const closing = source.lastIndexOf(">", offset); if (opening <= closing) return null; const fragment = source.slice(opening, offset); const match = /^<([A-Z][A-Za-z0-9_$]*)\b/.exec(fragment); return match ? { name: match[1], fragment } : null; } function componentAlreadyImported(source, name) { const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return ( new RegExp(`\\bimport[\\s\\S]*?\\b${escaped}\\b[\\s\\S]*?\\bfrom\\b`).test(source) || new RegExp(`\\b(?:component|layout)\\s+${escaped}\\b`).test(source) ); } function importEditForComponent(document, component) { if (!component?.uri || componentAlreadyImported(document.getText(), component.name)) return null; const target = component.uri.fsPath.replace(/\\/g, "/"); const current = document.uri.fsPath.replace(/\\/g, "/"); let statement; if ( target.includes("/node_modules/@wrnexus/ui/components/") || target.includes("/packages/ui/components/") ) { statement = `import { ${component.name} } from "@wrnexus/ui"\n`; } else { const appMarker = "/app/"; const appIndex = target.lastIndexOf(appMarker); if (appIndex !== -1) { statement = `import ${component.name} from "@/${target.slice(appIndex + appMarker.length)}"\n`; } else { let relative = path.posix.relative(path.posix.dirname(current), target); if (!relative.startsWith(".")) relative = `./${relative}`; statement = `import ${component.name} from "${relative}"\n`; } } return vscode.TextEdit.insert(new vscode.Position(0, 0), statement); } function propSnippet(prop) { if (prop.type === "boolean") return `${prop.name}="{\${1:false}}"`; if (prop.type === "number") return `${prop.name}="{\${1:0}}"`; if (prop.type === "array") return `${prop.name}="{[\${1}]}"`; if (prop.type === "object") return `${prop.name}="{{ \${1} }}"`; if (prop.options.length > 0) return `${prop.name}="\${1|${prop.options.join(",")}|}"`; return `${prop.name}="\${1}"`; } function propDocumentation(prop) { const lines = [ `**${prop.name}** — \`${prop.type}\` ${prop.required ? "**required**" : "optional"}`, ]; if (!prop.required) lines.push(`Default: \`${prop.defaultValue}\``); if (prop.options.length > 0) lines.push(`Allowed: ${prop.options.map((v) => `\`${v}\``).join(", ")}`); return lines.join("\n\n"); } async function provideComponentCompletions(document, position) { const source = document.getText(); const offset = document.offsetAt(position); const tag = openingTagAt(source, offset); const catalog = await componentCatalog(); const items = []; if (tag) { const component = catalog.get(tag.name); if (!component) return items; const used = new Set([...tag.fragment.matchAll(/\s([^\s=/>]+)\s*=/g)].map((m) => m[1])); for (const prop of component.props) { if (used.has(prop.name)) continue; const item = new vscode.CompletionItem(prop.name, vscode.CompletionItemKind.Property); item.detail = `${prop.required ? "required" : "optional"} · ${prop.type}`; item.documentation = new vscode.MarkdownString(propDocumentation(prop)); item.insertText = new vscode.SnippetString(propSnippet(prop)); item.sortText = `${prop.required ? "0" : "1"}-${prop.name}`; items.push(item); } return items; } const prefix = source.slice(Math.max(0, offset - 80), offset); if (!/<[A-Za-z0-9_$]*$/.test(prefix)) return items; for (const component of catalog.values()) { const item = new vscode.CompletionItem(component.name, vscode.CompletionItemKind.Class); const required = component.props.filter((prop) => prop.required); item.detail = `WRN ${component.kind} · ${component.props.length} props`; item.documentation = new vscode.MarkdownString(componentMarkdown(component)); item.insertText = new vscode.SnippetString( `${component.name}${required.map((prop, index) => ` ${prop.name}="\${${index + 1}}"`).join("")} />`, ); const importEdit = importEditForComponent(document, component); if (importEdit) item.additionalTextEdits = [importEdit]; items.push(item); } return items; } function componentMarkdown(component) { const lines = [ `### <${component.name}>`, "", "| Prop | Type | Required | Default / options |", "|---|---|---:|---|", ]; for (const prop of component.props) { const detail = prop.options.length > 0 ? prop.options.join(" \\| ") : prop.required ? "—" : prop.defaultValue; lines.push( `| \`${prop.name}\` | \`${prop.type}\` | ${prop.required ? "yes" : "no"} | ${detail} |`, ); } return lines.join("\n"); } async function provideComponentHover(document, position) { const source = document.getText(); const offset = document.offsetAt(position); const catalog = await componentCatalog(); const tag = parseComponentTags(source).find( (candidate) => offset >= candidate.start && offset <= candidate.end, ); if (!tag) return null; const component = catalog.get(tag.name); if (!component) return null; const attribute = tag.attributes.find( (candidate) => offset >= candidate.nameStart && offset <= candidate.nameEnd, ); const markdown = attribute ? propDocumentation( component.props.find((prop) => prop.name === attribute.name) || { name: attribute.name, type: "unknown", required: false, defaultValue: "unknown", options: [], }, ) : componentMarkdown(component); return new vscode.Hover(new vscode.MarkdownString(markdown)); } function registerComponentIntelligence(context) { const diagnostics = vscode.languages.createDiagnosticCollection("wrnexus-components"); const update = async (document) => { if (document.languageId !== "wrn") return; const catalog = await componentCatalog(); const results = validateComponentTags(document.getText(), catalog).map((result) => { const diagnostic = new vscode.Diagnostic( new vscode.Range(document.positionAt(result.start), document.positionAt(result.end)), result.message, result.severity === "error" ? vscode.DiagnosticSeverity.Error : vscode.DiagnosticSeverity.Warning, ); diagnostic.source = "WRNexus Components"; diagnostic.code = result.code; return diagnostic; }); diagnostics.set(document.uri, results); }; // Watch only application sources. A workspace-wide **/*.wrn watcher can // recursively subscribe to dependency and generated trees in large repos. const watchers = (vscode.workspace.workspaceFolders || []).map((folder) => vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(folder, "app/**/*.wrn")), ); const refreshOpenDocuments = () => { invalidateCatalog(); for (const document of vscode.workspace.textDocuments) void update(document); }; context.subscriptions.push( diagnostics, ...watchers, ...watchers.flatMap((watcher) => [ watcher.onDidCreate(refreshOpenDocuments), watcher.onDidChange(refreshOpenDocuments), watcher.onDidDelete(refreshOpenDocuments), ]), vscode.workspace.onDidOpenTextDocument(update), vscode.workspace.onDidChangeTextDocument((event) => void update(event.document)), vscode.workspace.onDidSaveTextDocument((document) => { invalidateCatalog(); void update(document); }), vscode.workspace.onDidCloseTextDocument((document) => diagnostics.delete(document.uri)), vscode.languages.registerCompletionItemProvider( { language: "wrn" }, { provideCompletionItems: provideComponentCompletions }, "<", " ", ), vscode.languages.registerHoverProvider( { language: "wrn" }, { provideHover: provideComponentHover }, ), ); for (const document of vscode.workspace.textDocuments) void update(document); } module.exports = { componentAlreadyImported, componentMarkdown, importEditForComponent, openingTagAt, propDocumentation, propSnippet, registerComponentIntelligence, };