244 lines
8.9 KiB
TypeScript
244 lines
8.9 KiB
TypeScript
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
import { basename, extname, join, relative, resolve } from "node:path";
|
|
import { parse } from "@wrnexus/syntax";
|
|
import type { Range, TextDocument } from "./index.ts";
|
|
|
|
const SKIPPED_DIRECTORIES = new Set([
|
|
".git",
|
|
".wrnexus",
|
|
".wrnfw",
|
|
"build",
|
|
"coverage",
|
|
"dist",
|
|
"node_modules",
|
|
"out",
|
|
]);
|
|
const MAX_INDEX_FILES = 5_000;
|
|
const MAX_SOURCE_BYTES = 1_048_576;
|
|
// Completion can be requested repeatedly while the list is visible. Avoid
|
|
// rescanning thousands of files on that hot path; document saves invalidate it.
|
|
const INDEX_TTL_MS = 5 * 60_000;
|
|
const MAX_CACHED_ROOTS = 8;
|
|
const workspaceIndexCache = new Map<
|
|
string,
|
|
{ expiresAt: number; items: WorkspaceCompletionItem[] }
|
|
>();
|
|
|
|
function walk(root: string, test: (file: string) => boolean): string[] {
|
|
if (!existsSync(root)) return [];
|
|
const files: string[] = [];
|
|
const pending = [root];
|
|
while (pending.length > 0 && files.length < MAX_INDEX_FILES) {
|
|
const directory = pending.pop()!;
|
|
let entries;
|
|
try {
|
|
entries = readdirSync(directory, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
if (files.length >= MAX_INDEX_FILES) break;
|
|
const file = join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (!SKIPPED_DIRECTORIES.has(entry.name)) pending.push(file);
|
|
} else if (entry.isFile() && test(file)) {
|
|
try {
|
|
if (statSync(file).size <= MAX_SOURCE_BYTES) files.push(file);
|
|
} catch {
|
|
// Files can disappear while the editor indexes a changing workspace.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
function flatten(value: unknown, prefix = ""): string[] {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return prefix ? [prefix] : [];
|
|
return Object.entries(value).flatMap(([key, child]) =>
|
|
flatten(child, prefix ? `${prefix}.${key}` : key),
|
|
);
|
|
}
|
|
function routeFor(root: string, file: string): string {
|
|
let value = relative(join(root, "app", "pages"), file)
|
|
.replace(/\\/g, "/")
|
|
.replace(/\.wrn$/, "");
|
|
value = value
|
|
.replace(/(?:^|\/)index$/, "")
|
|
.replace(/\[\.\.\.([^\]]+)\]/g, "*$1")
|
|
.replace(/\[([^\]]+)\]/g, ":$1");
|
|
return `/${value}`.replace(/\/$/, "") || "/";
|
|
}
|
|
|
|
export interface WorkspaceCompletionItem {
|
|
label: string;
|
|
kind: number;
|
|
detail: string;
|
|
insertText?: string;
|
|
data?: Record<string, unknown>;
|
|
}
|
|
|
|
function buildWorkspaceCompletionItems(root: string): WorkspaceCompletionItem[] {
|
|
const app = join(root, "app");
|
|
const items: WorkspaceCompletionItem[] = [];
|
|
for (const file of walk(join(app, "components"), (path) => extname(path) === ".wrn")) {
|
|
try {
|
|
const source = readFileSync(file, "utf8");
|
|
const ast = parse(source);
|
|
const slots = [...source.matchAll(/<slot(?:\s+name=["']([^"']+)["'])?/g)].map(
|
|
(match) => match[1] ?? "default",
|
|
);
|
|
items.push({
|
|
label: ast.name,
|
|
kind: 7,
|
|
detail: `Component · ${relative(root, file)}`,
|
|
insertText: `<${ast.name} />`,
|
|
data: { file, props: ast.props, outputs: ast.outputs, slots },
|
|
});
|
|
for (const prop of ast.props)
|
|
items.push({ label: prop.name, kind: 10, detail: `${ast.name} prop · ${prop.valueType}` });
|
|
for (const output of ast.outputs)
|
|
items.push({ label: `@${output.name}`, kind: 10, detail: `${ast.name} event` });
|
|
for (const slot of slots)
|
|
items.push({ label: `slot:${slot}`, kind: 10, detail: `${ast.name} slot` });
|
|
} catch {
|
|
// Diagnostics handle invalid components; indexing remains best-effort.
|
|
}
|
|
}
|
|
for (const file of walk(join(app, "pages"), (path) => extname(path) === ".wrn")) {
|
|
const route = routeFor(root, file);
|
|
items.push({
|
|
label: route,
|
|
kind: 12,
|
|
detail: `Application route · ${relative(root, file)}`,
|
|
data: { file },
|
|
});
|
|
}
|
|
for (const file of walk(join(app, "locales"), (path) => extname(path) === ".json")) {
|
|
try {
|
|
for (const key of flatten(JSON.parse(readFileSync(file, "utf8"))))
|
|
items.push({
|
|
label: key,
|
|
kind: 12,
|
|
detail: `Translation key · ${basename(file, ".json")}`,
|
|
});
|
|
} catch {
|
|
// Invalid locale JSON is reported by application diagnostics.
|
|
}
|
|
}
|
|
const sourceFiles = walk(app, (path) => /\.(?:ts|js|wrn)$/.test(path));
|
|
for (const file of sourceFiles) {
|
|
const source = readFileSync(file, "utf8");
|
|
for (const match of source.matchAll(
|
|
/\b(?:schema|model)\s+([A-Za-z_$][\w$]*)|\bexport\s+const\s+([A-Za-z_$][\w$]*Schema)\b/g,
|
|
)) {
|
|
const name = match[1] ?? match[2];
|
|
if (name)
|
|
items.push({
|
|
label: name,
|
|
kind: 7,
|
|
detail: `Database/validation schema · ${relative(root, file)}`,
|
|
data: { file },
|
|
});
|
|
}
|
|
for (const match of source.matchAll(/(?:class|className)\s*=\s*["']([^"']+)["']/g)) {
|
|
for (const name of match[1]!.split(/\s+/))
|
|
if (name) items.push({ label: name, kind: 12, detail: "Workspace CSS/Tailwind class" });
|
|
}
|
|
}
|
|
const unique = new Map(items.map((item) => [`${item.label}:${item.detail}`, item]));
|
|
return [...unique.values()].slice(0, 2_000);
|
|
}
|
|
|
|
export function workspaceCompletionItems(root: string): WorkspaceCompletionItem[] {
|
|
const normalizedRoot = resolve(root);
|
|
const now = Date.now();
|
|
const cached = workspaceIndexCache.get(normalizedRoot);
|
|
if (cached && cached.expiresAt > now) return cached.items;
|
|
const items = buildWorkspaceCompletionItems(normalizedRoot);
|
|
workspaceIndexCache.delete(normalizedRoot);
|
|
workspaceIndexCache.set(normalizedRoot, { expiresAt: now + INDEX_TTL_MS, items });
|
|
while (workspaceIndexCache.size > MAX_CACHED_ROOTS) {
|
|
const oldest = workspaceIndexCache.keys().next().value;
|
|
if (typeof oldest !== "string") break;
|
|
workspaceIndexCache.delete(oldest);
|
|
}
|
|
return items;
|
|
}
|
|
|
|
export function clearWorkspaceIndexCache(root?: string): void {
|
|
if (root) workspaceIndexCache.delete(resolve(root));
|
|
else workspaceIndexCache.clear();
|
|
}
|
|
|
|
export function workspaceSymbolLocations(root: string, name: string, limit = 1_000) {
|
|
if (!/^[A-Za-z_$][\w$]*$/.test(name)) return [];
|
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const pattern = new RegExp(`(?<![A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`, "g");
|
|
const locations: Array<{ uri: string; range: Range }> = [];
|
|
for (const file of walk(resolve(root), (path) => extname(path) === ".wrn").slice(0, limit)) {
|
|
let source: string;
|
|
try {
|
|
source = readFileSync(file, "utf8");
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const match of source.matchAll(pattern)) {
|
|
const before = source.slice(0, match.index!).split(/\r?\n/);
|
|
const start = { line: before.length - 1, character: before.at(-1)?.length ?? 0 };
|
|
locations.push({
|
|
uri: `file:///${file.replace(/\\/g, "/")}`,
|
|
range: { start, end: { line: start.line, character: start.character + name.length } },
|
|
});
|
|
}
|
|
}
|
|
return locations;
|
|
}
|
|
|
|
export function extractComponentRefactor(document: TextDocument, range: Range, name: string) {
|
|
if (!/^[A-Z][A-Za-z0-9_$]*$/.test(name)) throw new Error("Component name must use PascalCase");
|
|
const lines = document.text.split(/\r?\n/);
|
|
const start =
|
|
lines.slice(0, range.start.line).reduce((n, line) => n + line.length + 1, 0) +
|
|
range.start.character;
|
|
const end =
|
|
lines.slice(0, range.end.line).reduce((n, line) => n + line.length + 1, 0) +
|
|
range.end.character;
|
|
const selected = document.text.slice(start, end);
|
|
if (!selected.trim().startsWith("<")) throw new Error("Select WRN markup to extract");
|
|
const sourcePath = decodeURIComponent(document.uri.replace(/^file:\/\//, "")).replace(
|
|
/^\/([A-Za-z]:)/,
|
|
"$1",
|
|
);
|
|
const root = sourcePath.includes(`${join("app", "pages")}`)
|
|
? sourcePath.slice(0, sourcePath.indexOf(`${join("app", "pages")}`))
|
|
: resolve(".");
|
|
const target = join(root, "app", "components", `${name}.wrn`);
|
|
return {
|
|
documentChanges: [
|
|
{ kind: "create", uri: `file:///${target.replace(/\\/g, "/")}` },
|
|
{
|
|
textDocument: { uri: `file:///${target.replace(/\\/g, "/")}`, version: null },
|
|
edits: [
|
|
{
|
|
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
|
|
newText: `component ${name} {\n view {\n ${selected.trim()}\n }\n}\n`,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
textDocument: { uri: document.uri, version: document.version ?? null },
|
|
edits: [{ range, newText: `<${name} />` }],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
export function htmlToWrn(html: string, name = "ImportedPage"): string {
|
|
if (!/^[A-Z][A-Za-z0-9_$]*$/.test(name)) throw new Error("WRN name must use PascalCase");
|
|
const converted = html
|
|
.replace(/\sclass=/g, " class=")
|
|
.replace(/\son([a-z]+)=/gi, (_all, event) => ` @${String(event).toLowerCase()}=`)
|
|
.replace(/<!--([\s\S]*?)-->/g, "{/*$1*/}");
|
|
return `page ${name} {\n view {\n ${converted.trim()}\n }\n}\n`;
|
|
}
|