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_KEYWORDS = [ "page", "component", "layout", "props", "outputs", "state", "computed", "effect", "watch", "lifecycle", "load", "action", "api", "apis", "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, options: { includeTypes?: boolean } = {}, ) { 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) || options.includeTypes === false) 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( `(? ({ 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_KEYWORDS.map((label) => ({ label, kind: 14, detail: "WRNexus language keyword" })); } const semanticTokenTypes = ["variable"] as const; const semanticTokenModifiers = ["declaration", "modification"] as const; export const semanticTokensLegend = { tokenTypes: [...semanticTokenTypes], tokenModifiers: [...semanticTokenModifiers], }; /** Encode state declarations, reads, and writes using the LSP relative token format. */ export function semanticTokens(document: TextDocument): { data: number[] } { const declarations = new Map>(); for (const match of document.text.matchAll( /\bstate\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?==|;|\r?$)/gm, )) { const name = match[1]!; const offset = match.index! + match[0].lastIndexOf(name); const offsets = declarations.get(name) ?? new Set(); offsets.add(offset); declarations.set(name, offsets); } const comments = [...document.text.matchAll(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g)].map((match) => ({ start: match.index!, end: match.index! + match[0].length, })); const tokens: Array<{ line: number; character: number; length: number; modifiers: number }> = []; for (const [name, offsets] of declarations) { const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); for (const match of document.text.matchAll( new RegExp(`(? offset >= range.start && offset < range.end)) continue; const position = positionAt(document.text, offset); const after = document.text .slice(offset + name.length) .match(/^\s*(?:=|\+=|-=|\*=|\/=|%=|\+\+|--)/); const before = document.text.slice(Math.max(0, offset - 8), offset).match(/(?:\+\+|--)\s*$/); tokens.push({ ...position, length: name.length, modifiers: offsets.has(offset) ? 1 : after || before ? 2 : 0, }); } } tokens.sort((a, b) => a.line - b.line || a.character - b.character); const data: number[] = []; let previousLine = 0; let previousCharacter = 0; for (const token of tokens) { const deltaLine = token.line - previousLine; const deltaCharacter = deltaLine === 0 ? token.character - previousCharacter : token.character; data.push(deltaLine, deltaCharacter, token.length, 0, token.modifiers); previousLine = token.line; previousCharacter = token.character; } return { data }; } export * from "./workspace.ts";