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; } 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(/ 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(`(? = []; 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(//g, "{/*$1*/}"); return `page ${name} {\n view {\n ${converted.trim()}\n }\n}\n`; }