release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-02 23:18:51 +05:30
parent 87507edf59
commit 586a6db8ff
625 changed files with 243608 additions and 11210 deletions
+238
View File
@@ -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";
+278
View File
@@ -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();
+217
View File
@@ -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`;
}