| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508 |
- import {
- createConnection, ProposedFeatures, InitializeParams, InitializeResult,
- TextDocuments, TextDocumentSyncKind, Diagnostic, DiagnosticSeverity,
- DidChangeConfigurationParams, CompletionItem, CompletionItemKind,
- DocumentSymbol, SymbolKind, Hover, Location, TextEdit
- } from 'vscode-languageserver/node';
- import { TextDocument } from 'vscode-languageserver-textdocument';
- import * as path from 'path';
- import * as os from 'os';
- import { promises as fs, readFileSync } from 'fs';
- import { spawn } from 'child_process';
- import { pathToFileURL } from 'url';
- import { lex, Token } from './m2/lexer';
- import { M2Symbol, M2Unit, parseUnitText } from './m2/parser';
- import { analyseUnit } from './m2/analyse';
- import { computeSemanticTokens, semanticTokensLegend } from './m2/semantic';
- import { DEFAULT_FORMAT_OPTIONS, FormatOptions, formatDocument, normalizeFormatOptions } from './m2/format';
- import {
- moduleExports, recordFieldsOf, resolveName, toLocation, visibleSymbols,
- findReferencesInText, LocatedOccurrence,
- } from './m2/resolve';
- const connection = createConnection(ProposedFeatures.all);
- const documents = new TextDocuments(TextDocument);
- let validatorCommand = '';
- let validatorArguments: string[] = ['{file}'];
- let formatOptions: FormatOptions = { ...DEFAULT_FORMAT_OPTIONS };
- connection.onInitialize((params: InitializeParams): InitializeResult => {
- const options = (params.initializationOptions || {}) as {
- validatorCommand?: string; validatorArguments?: string[];
- format?: Partial<FormatOptions>;
- };
- validatorCommand = options.validatorCommand || '';
- validatorArguments = options.validatorArguments || ['{file}'];
- formatOptions = normalizeFormatOptions(options.format ?? {});
- return {
- capabilities: {
- textDocumentSync: TextDocumentSyncKind.Full,
- completionProvider: { triggerCharacters: ['.'] },
- hoverProvider: true,
- definitionProvider: true,
- documentSymbolProvider: true,
- referencesProvider: true,
- renameProvider: { prepareProvider: true },
- documentFormattingProvider: true,
- semanticTokensProvider: {
- legend: semanticTokensLegend(),
- full: true,
- },
- },
- };
- });
- connection.onDidChangeConfiguration((params: DidChangeConfigurationParams) => {
- const settings = (params.settings || {}) as {
- modula2?: {
- cocoR?: { validatorCommand?: string; validatorArguments?: string[] };
- format?: Partial<FormatOptions>;
- };
- };
- validatorCommand = settings.modula2?.cocoR?.validatorCommand || validatorCommand;
- validatorArguments = settings.modula2?.cocoR?.validatorArguments || validatorArguments;
- if (settings.modula2?.format) {
- formatOptions = normalizeFormatOptions(settings.modula2.format);
- }
- });
- documents.onDidOpen(e => validate(e.document));
- documents.onDidChangeContent(e => validate(e.document));
- documents.onDidClose(e => connection.sendDiagnostics({ uri: e.document.uri, diagnostics: [] }));
- async function validate(document: TextDocument): Promise<void> {
- const local = analyseDocument(document);
- if (!validatorCommand) {
- connection.sendDiagnostics({ uri: document.uri, diagnostics: local });
- return;
- }
- const filePath = uriToFilePath(document.uri);
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'modula2-lsp-'));
- const ext = path.extname(filePath) || '.mod';
- const tempFile = path.join(dir, path.basename(filePath, path.extname(filePath)) + ext);
- try {
- await fs.writeFile(tempFile, document.getText(), 'utf8');
- const args = validatorArguments.map(a => a.replace(/\{file\}/g, tempFile));
- const output = await execute(validatorCommand, args, path.dirname(filePath));
- connection.sendDiagnostics({ uri: document.uri, diagnostics: [...local, ...parseDiagnostics(output)] });
- } catch (error) {
- connection.sendDiagnostics({ uri: document.uri, diagnostics: [...local, {
- severity: DiagnosticSeverity.Warning,
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
- message: `Coco/R validator: ${error instanceof Error ? error.message : String(error)}`,
- source: 'Coco/R'
- }] });
- } finally {
- await fs.rm(dir, { recursive: true, force: true });
- }
- }
- function execute(command: string, args: string[], cwd: string): Promise<string> {
- return new Promise((resolve, reject) => {
- const child = spawn(command, args, { cwd, shell: false });
- let output = '';
- child.stdout.on('data', d => output += d.toString());
- child.stderr.on('data', d => output += d.toString());
- child.on('error', reject);
- child.on('close', () => resolve(output));
- });
- }
- function parseDiagnostics(output: string): Diagnostic[] {
- const result: Diagnostic[] = [];
- // CRQ/editor-oriented format: file (line,column) message
- const re = /^.*?\s*\((\d+),(\d+)\)\s+(.*)$/gm;
- let m: RegExpExecArray | null;
- while ((m = re.exec(output)) !== null) {
- const message = m[3].trim();
- if (!message || /^(Grammar tests|Undefined tests|Unreachable tests|Circular tests|Underivable tests|LL\(1\) tests)$/.test(message)) continue;
- const line = Math.max(0, Number(m[1]) - 1);
- const character = Math.max(0, Number(m[2]));
- result.push({ severity: DiagnosticSeverity.Error, range: {
- start: { line, character }, end: { line, character: character + 1 }
- }, message, source: 'Coco/R' });
- }
- return result;
- }
- function uriToFilePath(uri: string): string {
- if (uri.startsWith('file://')) return decodeURIComponent(new URL(uri).pathname);
- return uri;
- }
- /** Local semantic diagnostics (always on; merged with Coco/R output when configured). */
- function analyseDocument(document: TextDocument): Diagnostic[] {
- const text = document.getText();
- const filePath = uriToFilePath(document.uri);
- return analyseUnit(text, filePath, path.dirname(filePath)).map(issue => ({
- severity: issue.severity === 'error' ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning,
- range: {
- start: { line: issue.line, character: issue.ch },
- end: { line: issue.endLine, character: issue.endCh },
- },
- message: issue.message,
- source: 'modula2',
- }));
- }
- // ---- Phase 2: AST-backed language intelligence ----
- const SERVER_KEYWORDS = [
- 'AND', 'ARRAY', 'BEGIN', 'BY', 'CASE', 'CONST', 'DEFINITION', 'DIV',
- 'DO', 'ELSE', 'ELSIF', 'END', 'EXIT', 'EXPORT', 'FOR', 'FROM', 'IF',
- 'IMPLEMENTATION', 'IMPORT', 'IN', 'LOOP', 'MOD', 'MODULE', 'NOT', 'OF',
- 'OR', 'POINTER', 'PROCEDURE', 'QUALIFIED', 'RECORD', 'REPEAT', 'RETURN',
- 'SET', 'THEN', 'TO', 'TYPE', 'UNTIL', 'VAR', 'WHILE', 'WITH',
- ];
- function parseDocument(document: TextDocument): { unit: M2Unit; tokens: Token[] } {
- const text = document.getText();
- const filePath = uriToFilePath(document.uri);
- return { unit: parseUnitText(text, filePath), tokens: lex(text) };
- }
- function tokenAt(tokens: Token[], offset: number): Token | null {
- for (const t of tokens) {
- if (t.kind === 'eof') break;
- if (t.offset <= offset && offset <= t.endOffset) return t;
- }
- return null;
- }
- /** Extract `Qualifier.name` (or plain `name`) at a document offset. */
- function nameAt(tokens: Token[], offset: number): { qualifier: string | null; name: string } | null {
- for (let k = 0; k < tokens.length; k++) {
- const t = tokens[k];
- if (t.kind === 'eof') break;
- if (!(t.offset <= offset && offset <= t.endOffset)) continue;
- if (t.kind !== 'ident') return null;
- // Cursor on the qualifier part: A|.B
- if (tokens[k + 1]?.text === '.' && tokens[k + 2]?.kind === 'ident') {
- return { qualifier: null, name: t.text };
- }
- // Cursor on the qualified part: A.|B (or second ident after dot)
- if (t.text !== undefined && tokens[k - 1]?.text === '.' && tokens[k - 2]?.kind === 'ident') {
- return { qualifier: tokens[k - 2].text, name: t.text };
- }
- return { qualifier: null, name: t.text };
- }
- return null;
- }
- function lspKind(kind: M2Symbol['kind']): SymbolKind {
- switch (kind) {
- case 'module': return SymbolKind.Module;
- case 'procedure': return SymbolKind.Function;
- case 'variable': return SymbolKind.Variable;
- case 'parameter': return SymbolKind.Variable;
- case 'field': return SymbolKind.Field;
- case 'constant': return SymbolKind.Constant;
- case 'type': return SymbolKind.Class;
- }
- }
- function completionKind(kind: M2Symbol['kind']): CompletionItemKind {
- switch (kind) {
- case 'module': return CompletionItemKind.Module;
- case 'procedure': return CompletionItemKind.Function;
- case 'variable': return CompletionItemKind.Variable;
- case 'parameter': return CompletionItemKind.Variable;
- case 'field': return CompletionItemKind.Field;
- case 'constant': return CompletionItemKind.Constant;
- case 'type': return CompletionItemKind.Class;
- }
- }
- function toDocumentSymbol(s: M2Symbol): DocumentSymbol {
- return {
- name: s.name,
- kind: lspKind(s.kind),
- range: {
- start: { line: s.extent.startLine, character: s.extent.startCh },
- end: { line: s.extent.endLine, character: s.extent.endCh },
- },
- selectionRange: {
- start: { line: s.nameRange.startLine, character: s.nameRange.startCh },
- end: { line: s.nameRange.endLine, character: s.nameRange.endCh },
- },
- detail: s.detail,
- children: s.children.map(toDocumentSymbol),
- };
- }
- connection.onDocumentSymbol(params => {
- const document = documents.get(params.textDocument.uri);
- if (!document) return [];
- const { unit } = parseDocument(document);
- const out: DocumentSymbol[] = [];
- for (const s of unit.symbols) {
- for (const c of s.children) out.push(toDocumentSymbol(c));
- if (s.children.length === 0 && s.name) out.push(toDocumentSymbol(s));
- }
- return out;
- });
- connection.onCompletion(params => {
- const document = documents.get(params.textDocument.uri);
- if (!document) return [];
- const { unit } = parseDocument(document);
- const filePath = uriToFilePath(document.uri);
- const docDir = path.dirname(filePath);
- const offset = document.offsetAt(params.position);
- const prefix = document.getText().slice(0, offset);
- const dotMatch = /([A-Za-z_][A-Za-z0-9_]*)\.\s*[A-Za-z_0-9]*$/.exec(prefix);
- if (dotMatch) {
- const qualifier = dotMatch[1];
- // Imported module: offer its exports.
- const exps = moduleExports(docDir, qualifier);
- if (exps.length > 0) {
- return exps.map(s => ({
- label: s.name, kind: completionKind(s.kind), detail: s.detail,
- } as CompletionItem));
- }
- // Variable of record type: offer fields (inline or via named type).
- const local = unit.symbols.flatMap(s => s.children).find(c => c.name === qualifier);
- if (local) {
- if (local.children.length > 0) {
- return local.children.map(f => ({
- label: f.name, kind: CompletionItemKind.Field, detail: f.detail,
- } as CompletionItem));
- }
- const typeMatch = /:\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(local.detail);
- if (typeMatch) {
- const fields = recordFieldsOf(unit, docDir, typeMatch[1]);
- if (fields.length > 0) {
- return fields.map(f => ({
- label: f.name, kind: CompletionItemKind.Field, detail: f.detail,
- } as CompletionItem));
- }
- }
- }
- // Bare type name with record fields.
- const fields = recordFieldsOf(unit, docDir, qualifier);
- if (fields.length > 0) {
- return fields.map(f => ({
- label: f.name, kind: CompletionItemKind.Field, detail: f.detail,
- } as CompletionItem));
- }
- return [];
- }
- const items: CompletionItem[] = visibleSymbols(unit, params.position.line, params.position.character)
- .map(s => ({ label: s.name, kind: completionKind(s.kind), detail: s.detail } as CompletionItem));
- for (const kw of SERVER_KEYWORDS) {
- items.push({ label: kw, kind: CompletionItemKind.Keyword, detail: 'Modula-2 keyword' });
- }
- return items;
- });
- connection.onHover(params => {
- const document = documents.get(params.textDocument.uri);
- if (!document) return null;
- const { unit, tokens } = parseDocument(document);
- const offset = document.offsetAt(params.position);
- const found = nameAt(tokens, offset);
- if (!found) return null;
- const docDir = path.dirname(uriToFilePath(document.uri));
- const resolved = resolveName(unit, docDir, params.position.line, params.position.character, found.qualifier, found.name);
- if (!resolved) return null;
- const hover: Hover = {
- contents: {
- kind: 'markdown',
- value: [
- '```modula2',
- resolved.sym.detail || resolved.sym.name,
- '```',
- `*${resolved.sym.kind}* — \`${path.basename(resolved.filePath)}:${resolved.sym.nameRange.startLine + 1}\``,
- ].join('\n'),
- },
- };
- return hover;
- });
- connection.onDefinition(params => {
- const document = documents.get(params.textDocument.uri);
- if (!document) return null;
- const { unit, tokens } = parseDocument(document);
- const offset = document.offsetAt(params.position);
- const found = nameAt(tokens, offset);
- if (!found) return null;
- const filePath = uriToFilePath(document.uri);
- const resolved = resolveName(unit, path.dirname(filePath), params.position.line, params.position.character, found.qualifier, found.name);
- if (!resolved) return null;
- const loc = toLocation(resolved.filePath, resolved.sym);
- const targetUri = resolved.filePath === filePath ? document.uri : pathToFileURL(resolved.filePath).toString();
- const location: Location = {
- uri: targetUri,
- range: {
- start: { line: loc.startLine, character: loc.startCh },
- end: { line: loc.endLine, character: loc.endCh },
- },
- };
- return location;
- });
- /** Resolve the declaration for a rename/references request, or null. */
- function resolveRequest(
- document: TextDocument, line: number, character: number,
- ): { unit: M2Unit; filePath: string; qualifier: string | null; name: string } | null {
- const { unit, tokens } = parseDocument(document);
- const offset = document.offsetAt({ line, character });
- const found = nameAt(tokens, offset);
- if (!found) return null;
- return { unit, filePath: uriToFilePath(document.uri), qualifier: found.qualifier, name: found.name };
- }
- /** Reference occurrences across the open documents plus the declaration file. */
- function collectReferences(
- documents: TextDocument[], currentUri: string,
- unit: M2Unit, filePath: string, line: number, character: number,
- qualifier: string | null, name: string,
- includeDeclaration: boolean,
- ): { uri: string; occurrences: LocatedOccurrence[] }[] {
- const decl = resolveName(unit, path.dirname(filePath), line, character, qualifier, name);
- if (!decl) return [];
- const seen = new Map<string, TextDocument>();
- for (const doc of documents) {
- if (!seen.has(doc.uri)) seen.set(doc.uri, doc);
- }
- // The declaration may live in a .def that is not open.
- let extra: { uri: string; text: string } | null = null;
- const declOpen = seen.has(pathToFileURL(decl.filePath).toString());
- if (!declOpen) {
- try {
- extra = { uri: pathToFileURL(decl.filePath).toString(), text: readFileSync(decl.filePath, 'utf8') };
- } catch {
- extra = null;
- }
- }
- void currentUri;
- const out: { uri: string; occurrences: LocatedOccurrence[] }[] = [];
- for (const doc of seen.values()) {
- const fp = uriToFilePath(doc.uri);
- const occurrences = findReferencesInText(
- doc.getText(), fp, path.dirname(fp), decl, includeDeclaration,
- );
- if (occurrences.length > 0) out.push({ uri: doc.uri, occurrences });
- }
- if (extra) {
- const occurrences = findReferencesInText(
- extra.text, decl.filePath, path.dirname(decl.filePath), decl, includeDeclaration,
- );
- if (occurrences.length > 0) out.push({ uri: extra.uri, occurrences });
- }
- return out;
- }
- connection.onReferences(params => {
- const document = documents.get(params.textDocument.uri);
- if (!document) return [];
- const req = resolveRequest(document, params.position.line, params.position.character);
- if (!req) return [];
- const locations: Location[] = [];
- for (const { uri, occurrences } of collectReferences(
- documents.all(), document.uri, req.unit, req.filePath,
- params.position.line, params.position.character,
- req.qualifier, req.name, params.context.includeDeclaration,
- )) {
- for (const occ of occurrences) {
- locations.push({
- uri,
- range: {
- start: { line: occ.line, character: occ.ch },
- end: { line: occ.endLine, character: occ.endCh },
- },
- });
- }
- }
- return locations;
- });
- connection.onPrepareRename(params => {
- const document = documents.get(params.textDocument.uri);
- if (!document) return null;
- const req = resolveRequest(document, params.position.line, params.position.character);
- if (!req) return null;
- const decl = resolveName(
- req.unit, path.dirname(req.filePath),
- params.position.line, params.position.character, req.qualifier, req.name,
- );
- if (!decl) return null;
- const offset = document.offsetAt(params.position);
- const { tokens } = parseDocument(document);
- const token = tokenAt(tokens, offset);
- if (!token || token.kind !== 'ident') return null;
- return {
- start: { line: token.line, character: token.ch },
- end: { line: token.endLine, character: token.endCh },
- };
- });
- connection.onRenameRequest(params => {
- const document = documents.get(params.textDocument.uri);
- if (!document) return null;
- const req = resolveRequest(document, params.position.line, params.position.character);
- if (!req) return null;
- const changes: { [uri: string]: { range: Location['range']; newText: string }[] } = {};
- for (const { uri, occurrences } of collectReferences(
- documents.all(), document.uri, req.unit, req.filePath,
- params.position.line, params.position.character,
- req.qualifier, req.name, true,
- )) {
- changes[uri] = occurrences.map(occ => ({
- range: {
- start: { line: occ.line, character: occ.ch },
- end: { line: occ.endLine, character: occ.endCh },
- },
- newText: params.newName,
- }));
- }
- if (Object.keys(changes).length === 0) return null;
- return { changes };
- });
- connection.onDocumentFormatting(params => {
- const document = documents.get(params.textDocument.uri);
- if (!document) return null;
- const filePath = uriToFilePath(document.uri);
- let formatted: string;
- try {
- formatted = formatDocument(
- document.getText(), formatOptions, filePath, path.dirname(filePath),
- );
- } catch (error) {
- connection.console.error(`formatting failed: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- if (formatted === document.getText()) return [];
- const lines = document.getText().split('\n');
- const last = lines.length - 1;
- const edit: TextEdit = {
- range: {
- start: { line: 0, character: 0 },
- end: { line: last, character: lines[last]?.length ?? 0 },
- },
- newText: formatted,
- };
- return [edit];
- });
- connection.languages.semanticTokens.on(params => {
- const document = documents.get(params.textDocument.uri);
- if (!document) return { data: [] };
- const filePath = uriToFilePath(document.uri);
- try {
- return {
- data: computeSemanticTokens(document.getText(), filePath, path.dirname(filePath)),
- };
- } catch (error) {
- connection.console.error(`semantic tokens failed: ${error instanceof Error ? error.message : String(error)}`);
- return { data: [] };
- }
- });
- documents.listen(connection);
- connection.listen();
|