Files
WRNexusJS/packages/language-server/src/workspace.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

218 lines
7.8 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",
".wirefw",
"build",
"coverage",
"dist",
"node_modules",
"out",
]);
const MAX_INDEX_FILES = 5_000;
const MAX_SOURCE_BYTES = 1_048_576;
const INDEX_TTL_MS = 5_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 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`;
}