239 lines
7.4 KiB
TypeScript
239 lines
7.4 KiB
TypeScript
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";
|