release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
import { diagnose, formatWrn, type WrnDiagnostic } from "@wrnexus/syntax";
|
||||
import { checkWrnSource, virtualTypeScriptModule } from "@wrnexus/typecheck";
|
||||
|
||||
export interface Position {
|
||||
line: number;
|
||||
character: number;
|
||||
}
|
||||
export interface Range {
|
||||
start: Position;
|
||||
end: Position;
|
||||
}
|
||||
export interface TextDocument {
|
||||
uri: string;
|
||||
text: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export const WRN_COMPLETIONS = [
|
||||
"page",
|
||||
"component",
|
||||
"layout",
|
||||
"props",
|
||||
"outputs",
|
||||
"state",
|
||||
"computed",
|
||||
"effect",
|
||||
"watch",
|
||||
"lifecycle",
|
||||
"load",
|
||||
"action",
|
||||
"api",
|
||||
"realtime",
|
||||
"view",
|
||||
"style",
|
||||
"runtime",
|
||||
"hydrate",
|
||||
] as const;
|
||||
|
||||
export function offsetAt(text: string, position: Position): number {
|
||||
const lines = text.split(/\r?\n/);
|
||||
let offset = 0;
|
||||
for (let line = 0; line < Math.min(position.line, lines.length); line++)
|
||||
offset += (lines[line]?.length ?? 0) + 1;
|
||||
return Math.min(text.length, offset + Math.max(0, position.character));
|
||||
}
|
||||
|
||||
export function positionAt(text: string, requestedOffset: number): Position {
|
||||
const offset = Math.max(0, Math.min(text.length, requestedOffset));
|
||||
const prefix = text.slice(0, offset);
|
||||
const lines = prefix.split(/\r?\n/);
|
||||
return { line: lines.length - 1, character: lines.at(-1)?.length ?? 0 };
|
||||
}
|
||||
|
||||
export function wordAt(text: string, position: Position): { word: string; range: Range } | null {
|
||||
const offset = offsetAt(text, position);
|
||||
const left = text.slice(0, offset).match(/[A-Za-z_$][\w$]*$/)?.[0] ?? "";
|
||||
const right = text.slice(offset).match(/^[\w$]*/)?.[0] ?? "";
|
||||
const word = left + right;
|
||||
if (!word) return null;
|
||||
const start = offset - left.length;
|
||||
return {
|
||||
word,
|
||||
range: { start: positionAt(text, start), end: positionAt(text, start + word.length) },
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticRange(diagnostic: WrnDiagnostic): Range {
|
||||
const start = diagnostic.position ?? { line: 1, column: 1, offset: 0 };
|
||||
return {
|
||||
start: { line: Math.max(0, start.line - 1), character: Math.max(0, start.column - 1) },
|
||||
end: {
|
||||
line: Math.max(0, start.line - 1),
|
||||
character: Math.max(1, start.column - 1 + Math.max(1, diagnostic.received?.length ?? 1)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function documentDiagnostics(document: TextDocument) {
|
||||
if (Buffer.byteLength(document.text, "utf8") > 1_048_576) {
|
||||
return [
|
||||
{
|
||||
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
|
||||
severity: 2,
|
||||
code: "WRN-LSP-FILE-SIZE",
|
||||
source: "wrnexus",
|
||||
message: "Type analysis is disabled because this WRN document exceeds 1 MiB.",
|
||||
},
|
||||
];
|
||||
}
|
||||
const syntax = diagnose(document.text, { file: document.uri, accessibility: true }).map(
|
||||
(diagnostic) => ({
|
||||
range: diagnosticRange(diagnostic),
|
||||
severity: diagnostic.severity === "error" ? 1 : diagnostic.severity === "warning" ? 2 : 3,
|
||||
code: diagnostic.code,
|
||||
source: "wrnexus",
|
||||
message: diagnostic.message,
|
||||
}),
|
||||
);
|
||||
if (syntax.some((diagnostic) => diagnostic.severity === 1)) return syntax;
|
||||
const types = checkWrnSource(document.text, { filePath: documentPath(document.uri) }).map(
|
||||
(diagnostic) => ({
|
||||
range: {
|
||||
start: {
|
||||
line: Math.max(0, diagnostic.line - 1),
|
||||
character: Math.max(0, diagnostic.column - 1),
|
||||
},
|
||||
end: {
|
||||
line: Math.max(0, diagnostic.line - 1),
|
||||
character: Math.max(1, diagnostic.column - 1 + Math.max(1, diagnostic.length)),
|
||||
},
|
||||
},
|
||||
severity: diagnostic.category === "error" ? 1 : diagnostic.category === "warning" ? 2 : 3,
|
||||
code: diagnostic.code,
|
||||
source: "wrnexus-types",
|
||||
message: diagnostic.message,
|
||||
}),
|
||||
);
|
||||
const seen = new Set(
|
||||
syntax.map((item) => `${item.code}:${item.range.start.line}:${item.range.start.character}`),
|
||||
);
|
||||
return [
|
||||
...syntax,
|
||||
...types.filter(
|
||||
(item) => !seen.has(`${item.code}:${item.range.start.line}:${item.range.start.character}`),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function documentPath(uri: string): string {
|
||||
if (!uri.startsWith("file://")) return uri;
|
||||
const value = decodeURIComponent(uri.slice("file://".length));
|
||||
return /^\/[A-Za-z]:\//.test(value) ? value.slice(1) : value;
|
||||
}
|
||||
|
||||
/** TypeScript representation consumed by editor TypeScript plugins and safe refactoring tools. */
|
||||
export function virtualTypeScriptDocument(document: TextDocument): {
|
||||
uri: string;
|
||||
languageId: "typescript";
|
||||
text: string;
|
||||
mappings: Array<{
|
||||
virtualStartLine: number;
|
||||
virtualEndLine: number;
|
||||
sourceStartLine: number;
|
||||
sourceStartColumn: number;
|
||||
}>;
|
||||
} {
|
||||
const virtual = virtualTypeScriptModule(document.text, documentPath(document.uri));
|
||||
return {
|
||||
uri: `${document.uri}.ts`,
|
||||
languageId: "typescript",
|
||||
text: virtual.code,
|
||||
mappings: virtual.mappings,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDocument(document: TextDocument, tabSize = 4, insertSpaces = true) {
|
||||
const formatted = formatWrn(document.text, { tabSize, insertSpaces });
|
||||
if (formatted === document.text) return [];
|
||||
return [
|
||||
{
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: positionAt(document.text, document.text.length),
|
||||
},
|
||||
newText: formatted,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function documentSymbols(document: TextDocument) {
|
||||
const pattern =
|
||||
/\b(page|component|layout|state|computed|watch|effect|load|action|api)\s+([A-Za-z_$][\w$]*)/g;
|
||||
return [...document.text.matchAll(pattern)].map((match) => {
|
||||
const name = match[2]!;
|
||||
const start = match.index! + match[0].lastIndexOf(name);
|
||||
const range = {
|
||||
start: positionAt(document.text, start),
|
||||
end: positionAt(document.text, start + name.length),
|
||||
};
|
||||
return {
|
||||
name,
|
||||
kind: ["page", "component", "layout"].includes(match[1]!) ? 5 : 13,
|
||||
range,
|
||||
selectionRange: range,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function symbolLocations(document: TextDocument, position: Position) {
|
||||
const selected = wordAt(document.text, position);
|
||||
if (!selected) return [];
|
||||
const pattern = new RegExp(
|
||||
`(?<![A-Za-z0-9_$])${selected.word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_$])`,
|
||||
"g",
|
||||
);
|
||||
return [...document.text.matchAll(pattern)].map((match) => ({
|
||||
uri: document.uri,
|
||||
range: {
|
||||
start: positionAt(document.text, match.index!),
|
||||
end: positionAt(document.text, match.index! + selected.word.length),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function definitionLocation(document: TextDocument, position: Position) {
|
||||
const selected = wordAt(document.text, position);
|
||||
if (!selected) return null;
|
||||
const declaration = new RegExp(
|
||||
`\\b(?:state|computed|page|component|layout)\\s+${selected.word}\\b|\\b${selected.word}\\s*(?=[:?])`,
|
||||
).exec(document.text);
|
||||
if (!declaration) return null;
|
||||
const start = declaration.index + declaration[0].lastIndexOf(selected.word);
|
||||
return {
|
||||
uri: document.uri,
|
||||
range: {
|
||||
start: positionAt(document.text, start),
|
||||
end: positionAt(document.text, start + selected.word.length),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function hover(document: TextDocument, position: Position) {
|
||||
const selected = wordAt(document.text, position);
|
||||
if (!selected) return null;
|
||||
const declaration = new RegExp(
|
||||
`\\b(state|computed|prop|page|component|layout)\\s+${selected.word}\\b`,
|
||||
).exec(document.text);
|
||||
if (!declaration) return null;
|
||||
return {
|
||||
contents: { kind: "markdown", value: `\`\`\`wrn\n${declaration[0]}\n\`\`\`` },
|
||||
range: selected.range,
|
||||
};
|
||||
}
|
||||
|
||||
export function completionItems() {
|
||||
return WRN_COMPLETIONS.map((label) => ({ label, kind: 14, detail: "WRNexus language keyword" }));
|
||||
}
|
||||
export * from "./workspace.ts";
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env bun
|
||||
import {
|
||||
completionItems,
|
||||
definitionLocation,
|
||||
documentDiagnostics,
|
||||
documentSymbols,
|
||||
formatDocument,
|
||||
hover,
|
||||
symbolLocations,
|
||||
wordAt,
|
||||
virtualTypeScriptDocument,
|
||||
workspaceCompletionItems,
|
||||
extractComponentRefactor,
|
||||
htmlToWrn,
|
||||
type TextDocument,
|
||||
} from "./index.ts";
|
||||
|
||||
type JsonRpc = { jsonrpc?: string; id?: number | string; method?: string; params?: any };
|
||||
const documents = new Map<string, TextDocument>();
|
||||
const diagnosticTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const MAX_OPEN_DOCUMENTS = 256;
|
||||
const DIAGNOSTIC_DEBOUNCE_MS = 300;
|
||||
let buffer = Buffer.alloc(0);
|
||||
let workspaceRoot = process.cwd();
|
||||
|
||||
function rootFromUri(uri?: string): string {
|
||||
if (!uri?.startsWith("file://")) return workspaceRoot;
|
||||
return decodeURIComponent(uri.slice(7)).replace(/^\/([A-Za-z]:)/, "$1");
|
||||
}
|
||||
|
||||
function send(value: unknown): void {
|
||||
const body = Buffer.from(JSON.stringify(value));
|
||||
process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
|
||||
process.stdout.write(body);
|
||||
}
|
||||
function result(id: JsonRpc["id"], value: unknown): void {
|
||||
send({ jsonrpc: "2.0", id, result: value });
|
||||
}
|
||||
function publish(document: TextDocument): void {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "textDocument/publishDiagnostics",
|
||||
params: { uri: document.uri, diagnostics: documentDiagnostics(document) },
|
||||
});
|
||||
}
|
||||
|
||||
function clearDiagnosticTimer(uri: string): void {
|
||||
const timer = diagnosticTimers.get(uri);
|
||||
if (timer) clearTimeout(timer);
|
||||
diagnosticTimers.delete(uri);
|
||||
}
|
||||
|
||||
function schedulePublish(document: TextDocument): void {
|
||||
clearDiagnosticTimer(document.uri);
|
||||
const expectedVersion = document.version;
|
||||
diagnosticTimers.set(
|
||||
document.uri,
|
||||
setTimeout(() => {
|
||||
diagnosticTimers.delete(document.uri);
|
||||
const current = documents.get(document.uri);
|
||||
if (current && current.version === expectedVersion) publish(current);
|
||||
}, DIAGNOSTIC_DEBOUNCE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
function rememberDocument(document: TextDocument): void {
|
||||
documents.delete(document.uri);
|
||||
documents.set(document.uri, document);
|
||||
while (documents.size > MAX_OPEN_DOCUMENTS) {
|
||||
const oldest = documents.keys().next().value;
|
||||
if (typeof oldest !== "string") break;
|
||||
documents.delete(oldest);
|
||||
clearDiagnosticTimer(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
async function handle(message: JsonRpc): Promise<void> {
|
||||
const params = message.params ?? {};
|
||||
switch (message.method) {
|
||||
case "initialize":
|
||||
workspaceRoot = params.rootPath ?? rootFromUri(params.rootUri);
|
||||
result(message.id, {
|
||||
serverInfo: { name: "WRNexus Language Server", version: "0.8.0" },
|
||||
capabilities: {
|
||||
textDocumentSync: 1,
|
||||
documentFormattingProvider: true,
|
||||
completionProvider: { triggerCharacters: ["<", "@", ":", "."] },
|
||||
hoverProvider: true,
|
||||
definitionProvider: true,
|
||||
referencesProvider: true,
|
||||
renameProvider: { prepareProvider: true },
|
||||
documentSymbolProvider: true,
|
||||
codeActionProvider: {
|
||||
codeActionKinds: ["quickfix", "refactor.extract", "refactor.rewrite"],
|
||||
},
|
||||
experimental: { wrnexusVirtualTypeScript: true },
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "initialized":
|
||||
break;
|
||||
case "shutdown":
|
||||
for (const timer of diagnosticTimers.values()) clearTimeout(timer);
|
||||
diagnosticTimers.clear();
|
||||
result(message.id, null);
|
||||
break;
|
||||
case "exit":
|
||||
process.exit(0);
|
||||
break;
|
||||
case "textDocument/didOpen": {
|
||||
const item = params.textDocument;
|
||||
const document = { uri: item.uri, text: item.text, version: item.version };
|
||||
rememberDocument(document);
|
||||
publish(document);
|
||||
break;
|
||||
}
|
||||
case "textDocument/didChange": {
|
||||
const existing = documents.get(params.textDocument.uri);
|
||||
const text = params.contentChanges?.at(-1)?.text;
|
||||
if (existing && typeof text === "string") {
|
||||
existing.text = text;
|
||||
existing.version = params.textDocument.version;
|
||||
schedulePublish(existing);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "textDocument/didClose":
|
||||
clearDiagnosticTimer(params.textDocument.uri);
|
||||
documents.delete(params.textDocument.uri);
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "textDocument/publishDiagnostics",
|
||||
params: { uri: params.textDocument.uri, diagnostics: [] },
|
||||
});
|
||||
break;
|
||||
case "wrnexus/serverStatus":
|
||||
result(message.id, {
|
||||
openDocuments: documents.size,
|
||||
pendingDiagnostics: diagnosticTimers.size,
|
||||
memory: process.memoryUsage(),
|
||||
});
|
||||
break;
|
||||
case "textDocument/formatting": {
|
||||
const document = documents.get(params.textDocument.uri);
|
||||
result(
|
||||
message.id,
|
||||
document
|
||||
? formatDocument(document, params.options?.tabSize, params.options?.insertSpaces)
|
||||
: [],
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "textDocument/completion":
|
||||
result(message.id, [...completionItems(), ...workspaceCompletionItems(workspaceRoot)]);
|
||||
break;
|
||||
case "textDocument/documentSymbol": {
|
||||
const document = documents.get(params.textDocument.uri);
|
||||
result(message.id, document ? documentSymbols(document) : []);
|
||||
break;
|
||||
}
|
||||
case "textDocument/hover": {
|
||||
const document = documents.get(params.textDocument.uri);
|
||||
result(message.id, document ? hover(document, params.position) : null);
|
||||
break;
|
||||
}
|
||||
case "textDocument/definition": {
|
||||
const document = documents.get(params.textDocument.uri);
|
||||
result(message.id, document ? definitionLocation(document, params.position) : null);
|
||||
break;
|
||||
}
|
||||
case "textDocument/references": {
|
||||
const document = documents.get(params.textDocument.uri);
|
||||
result(message.id, document ? symbolLocations(document, params.position) : []);
|
||||
break;
|
||||
}
|
||||
case "textDocument/prepareRename": {
|
||||
const document = documents.get(params.textDocument.uri);
|
||||
result(message.id, document ? (wordAt(document.text, params.position)?.range ?? null) : null);
|
||||
break;
|
||||
}
|
||||
case "textDocument/rename": {
|
||||
const document = documents.get(params.textDocument.uri);
|
||||
const edits = document
|
||||
? symbolLocations(document, params.position).map(({ range }) => ({
|
||||
range,
|
||||
newText: params.newName,
|
||||
}))
|
||||
: [];
|
||||
result(message.id, document ? { changes: { [document.uri]: edits } } : null);
|
||||
break;
|
||||
}
|
||||
case "textDocument/codeAction": {
|
||||
const document = documents.get(params.textDocument.uri);
|
||||
if (!document) {
|
||||
result(message.id, []);
|
||||
break;
|
||||
}
|
||||
const actions: any[] = documentDiagnostics(document)
|
||||
.filter((item) => item.code === "WRNA11Y001")
|
||||
.map((item) => ({
|
||||
title: "Add empty alt attribute",
|
||||
kind: "quickfix",
|
||||
diagnostics: [item],
|
||||
edit: {
|
||||
changes: {
|
||||
[document.uri]: [
|
||||
{ range: { start: item.range.end, end: item.range.end }, newText: ' alt=""' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}));
|
||||
const selected = document.text.slice(
|
||||
document.text
|
||||
.split(/\r?\n/)
|
||||
.slice(0, params.range.start.line)
|
||||
.reduce((n, line) => n + line.length + 1, 0) + params.range.start.character,
|
||||
document.text
|
||||
.split(/\r?\n/)
|
||||
.slice(0, params.range.end.line)
|
||||
.reduce((n, line) => n + line.length + 1, 0) + params.range.end.character,
|
||||
);
|
||||
if (selected.trim().startsWith("<")) {
|
||||
try {
|
||||
actions.push({
|
||||
title: "Extract selection to WRN component",
|
||||
kind: "refactor.extract",
|
||||
edit: extractComponentRefactor(document, params.range, "ExtractedComponent"),
|
||||
});
|
||||
actions.push({
|
||||
title: "Convert selected HTML to WRN page",
|
||||
kind: "refactor.rewrite",
|
||||
edit: {
|
||||
changes: {
|
||||
[document.uri]: [
|
||||
{ range: params.range, newText: htmlToWrn(selected, "ImportedPage") },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* selection is not safely convertible */
|
||||
}
|
||||
}
|
||||
result(message.id, actions);
|
||||
break;
|
||||
}
|
||||
case "wrnexus/virtualDocument": {
|
||||
const document = documents.get(params.textDocument?.uri ?? params.uri);
|
||||
result(message.id, document ? virtualTypeScriptDocument(document) : null);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
if (message.id !== undefined) result(message.id, null);
|
||||
}
|
||||
}
|
||||
|
||||
function consume(): void {
|
||||
while (true) {
|
||||
const end = buffer.indexOf("\r\n\r\n");
|
||||
if (end < 0) return;
|
||||
const header = buffer.subarray(0, end).toString();
|
||||
const length = Number(/Content-Length:\s*(\d+)/i.exec(header)?.[1]);
|
||||
if (!Number.isFinite(length)) {
|
||||
buffer = Buffer.alloc(0);
|
||||
return;
|
||||
}
|
||||
const bodyStart = end + 4;
|
||||
if (buffer.length < bodyStart + length) return;
|
||||
const body = buffer.subarray(bodyStart, bodyStart + length).toString();
|
||||
buffer = buffer.subarray(bodyStart + length);
|
||||
void handle(JSON.parse(body));
|
||||
}
|
||||
}
|
||||
process.stdin.on("data", (chunk) => {
|
||||
buffer = Buffer.concat([buffer, Buffer.from(chunk)]);
|
||||
consume();
|
||||
});
|
||||
process.stdin.resume();
|
||||
@@ -0,0 +1,217 @@
|
||||
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`;
|
||||
}
|
||||
Reference in New Issue
Block a user