282 lines
9.4 KiB
JavaScript
282 lines
9.4 KiB
JavaScript
"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);
|
|
};
|
|
|
|
const watcher = vscode.workspace.createFileSystemWatcher("**/*.wrn");
|
|
const refreshOpenDocuments = () => {
|
|
invalidateCatalog();
|
|
for (const document of vscode.workspace.textDocuments) void update(document);
|
|
};
|
|
|
|
context.subscriptions.push(
|
|
diagnostics,
|
|
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,
|
|
};
|