import { createConnection, ProposedFeatures, InitializeParams, InitializeResult, TextDocuments, TextDocumentSyncKind, Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams } 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 { parseListing } from './pascalListing'; import { mask, parsePascal, parseUses, visibleAt, PasSymbol } from './pascalSymbols'; import { findReferencesInText, moduleExports, nameAtPosition, recordFieldsOf, resolveAt, tokenAtPosition, unitAt, PasLocatedOccurrence, PasResolved } from './pascalResolve'; import { DEFAULT_FORMAT_OPTIONS, FormatOptions, formatDocument, formatRangeEdits, normalizeFormatOptions } from './pascalFormat'; import { computeSemanticTokens, semanticTokensLegend } from './pascalSemantic'; import { CompletionItem, CompletionItemKind, DocumentSymbol, SymbolKind, Hover, Location, TextEdit } from 'vscode-languageserver/node'; const connection = createConnection(ProposedFeatures.all); const documents = new TextDocuments(TextDocument); let pascalDialect = 'freepascal'; let pascalValidators: Record = {}; let formatOptions: FormatOptions = { ...DEFAULT_FORMAT_OPTIONS }; connection.onInitialize((params: InitializeParams): InitializeResult => { const options = (params.initializationOptions || {}) as { pascalDialect?: string; pascalValidators?: Record; format?: Partial; }; pascalDialect = options.pascalDialect || 'freepascal'; pascalValidators = options.pascalValidators || {}; formatOptions = normalizeFormatOptions(options.format ?? {}); return { capabilities: { textDocumentSync: TextDocumentSyncKind.Full, completionProvider: { triggerCharacters: ['.'] }, hoverProvider: true, definitionProvider: true, documentSymbolProvider: true, referencesProvider: true, renameProvider: { prepareProvider: true }, documentFormattingProvider: true, documentRangeFormattingProvider: true, semanticTokensProvider: { legend: semanticTokensLegend(), full: true, }, } }; }); connection.onDidChangeConfiguration((params: DidChangeConfigurationParams) => { const settings = (params.settings || {}) as { modula2?: { pascal?: { dialect?: string; validators?: Record }; }; }; pascalDialect = settings.modula2?.pascal?.dialect || pascalDialect; pascalValidators = settings.modula2?.pascal?.validators || pascalValidators; const fmt = (settings.modula2?.pascal as { format?: Partial } | undefined)?.format; if (fmt) { formatOptions = normalizeFormatOptions(fmt); } }); documents.onDidOpen(e => validate(e.document)); documents.onDidChangeContent(e => validate(e.document)); documents.onDidClose(e => connection.sendDiagnostics({ uri: e.document.uri, diagnostics: [] })); /** Validate a Pascal document with the configured dialect validator. * * Each pilot validator takes the source file as its single argument and * writes `.LST` next to it; diagnostics come from that listing, whose * `***** ^ message` lines point at the preceding source line. * No validator configured (or dialect unknown) means no diagnostics. */ async function validate(document: TextDocument): Promise { const command = pascalValidators[pascalDialect]; if (!command) { connection.sendDiagnostics({ uri: document.uri, diagnostics: [] }); return; } const filePath = uriToFilePath(document.uri); const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'pascal-lsp-')); const base = path.basename(filePath, path.extname(filePath)); const tempFile = path.join(dir, base + '.pas'); try { await fs.writeFile(tempFile, document.getText(), 'utf8'); await execute(command, [tempFile], path.dirname(filePath)); let listing = ''; try { listing = await fs.readFile(path.join(dir, base + '.LST'), 'utf8'); } catch { listing = ''; } connection.sendDiagnostics({ uri: document.uri, diagnostics: parseListing(listing) }); } catch (error) { connection.sendDiagnostics({ uri: document.uri, diagnostics: [{ severity: DiagnosticSeverity.Warning, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, message: `Pascal validator: ${error instanceof Error ? error.message : String(error)}`, source: 'Pascal' }] }); } finally { await fs.rm(dir, { recursive: true, force: true }); } } function execute(command: string, args: string[], cwd: string): Promise { 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 uriToFilePath(uri: string): string { if (uri.startsWith('file://')) return decodeURIComponent(new URL(uri).pathname); return uri; } // ---- Local intelligence (Outline, completion, hover) ---- const PASCAL_KEYWORDS = [ 'AND', 'ARRAY', 'BEGIN', 'CASE', 'CONST', 'DIV', 'DO', 'DOWNTO', 'ELSE', 'END', 'FILE', 'FOR', 'FUNCTION', 'GOTO', 'IF', 'IN', 'LABEL', 'MOD', 'NIL', 'NOT', 'OF', 'OR', 'PACKED', 'PROCEDURE', 'PROGRAM', 'RECORD', 'REPEAT', 'SET', 'THEN', 'TO', 'TYPE', 'UNIT', 'UNTIL', 'USES', 'VAR', 'WHILE', 'WITH', 'FORWARD' ]; const PASCAL_BUILTINS = [ 'READ', 'READLN', 'WRITE', 'WRITELN', 'EOF', 'EOLN', 'ODD', 'ORD', 'CHR', 'ABS', 'SQR', 'TRUNC', 'NEW', 'SIN', 'COS', 'EXP', 'LN' ]; function symbolKind(kind: PasSymbol['kind']): SymbolKind { switch (kind) { case 'module': return SymbolKind.Module; case 'procedure': case 'function': return SymbolKind.Function; case 'constant': return SymbolKind.Constant; case 'type': return SymbolKind.Class; case 'variable': case 'parameter': case 'field': return SymbolKind.Variable; } } function toDocumentSymbol(s: PasSymbol): DocumentSymbol { const endLine = s.scopeEndLine ?? s.endLine; const endCh = s.scopeEndCh ?? s.endCh; return { name: s.name, kind: symbolKind(s.kind), range: { start: { line: s.line, character: s.ch }, end: { line: endLine, character: endCh } }, selectionRange: { start: { line: s.line, character: s.ch }, end: { line: s.endLine, character: s.endCh } }, detail: s.detail, children: s.children.map(toDocumentSymbol) }; } connection.onDocumentSymbol(params => { const document = documents.get(params.textDocument.uri); if (!document) return []; try { return parsePascal(document.getText()).map(toDocumentSymbol); } catch { return []; } }); connection.onCompletion(params => { const document = documents.get(params.textDocument.uri); if (!document) return []; const text = document.getText(); const filePath = uriToFilePath(document.uri); const docDir = path.dirname(filePath); let symbols: PasSymbol[] = []; try { symbols = parsePascal(text); } catch { symbols = []; } const uses = parseUses(text); const offset = document.offsetAt(params.position); const dotMatch = /([A-Za-z_][A-Za-z0-9_]*)\.\s*[A-Za-z_0-9]*$/.exec(text.slice(0, offset)); if (dotMatch) { const qualifier = dotMatch[1]; // Used unit: offer its interface names. const exps = moduleExports(docDir, qualifier); if (exps.length > 0) { return exps.map(s => ({ label: s.name, kind: s.kind === 'procedure' || s.kind === 'function' ? CompletionItemKind.Function : s.kind === 'constant' ? CompletionItemKind.Constant : s.kind === 'type' ? CompletionItemKind.Class : CompletionItemKind.Variable, detail: s.detail } as CompletionItem)); } // Variable of record type: inline fields first, then the named type. const resolved = resolveAt(symbols, uses, filePath, docDir, params.position.line, params.position.character, null, qualifier); const head = resolved?.sym; if (head && (head.kind === 'variable' || head.kind === 'parameter' || head.kind === 'constant')) { if (head.children.length > 0) { return head.children.map(f => ({ label: f.name, kind: CompletionItemKind.Field, detail: f.detail } as CompletionItem)); } const typeMatch = /:\s*((?:[A-Za-z_][A-Za-z0-9_]*)(?:\.[A-Za-z_][A-Za-z0-9_]*)?)/.exec(head.detail); if (typeMatch) { const rec = recordFieldsOf(symbols, uses, docDir, typeMatch[1]); if (rec) { return rec.fields.map(f => ({ label: f.name, kind: CompletionItemKind.Field, detail: f.detail } as CompletionItem)); } } } // Bare type name with record fields. const rec = recordFieldsOf(symbols, uses, docDir, qualifier); if (rec) { return rec.fields.map(f => ({ label: f.name, kind: CompletionItemKind.Field, detail: f.detail } as CompletionItem)); } return []; } const vis = visibleAt(symbols, params.position.line, params.position.character); const items: CompletionItem[] = vis.map(s => ({ label: s.name, kind: s.kind === 'procedure' || s.kind === 'function' ? CompletionItemKind.Function : s.kind === 'constant' ? CompletionItemKind.Constant : s.kind === 'type' ? CompletionItemKind.Class : CompletionItemKind.Variable, detail: s.detail } as CompletionItem)); for (const kw of PASCAL_KEYWORDS) { items.push({ label: kw, kind: CompletionItemKind.Keyword, detail: 'Pascal keyword' } as CompletionItem); } for (const b of PASCAL_BUILTINS) { items.push({ label: b, kind: CompletionItemKind.Function, detail: 'Pascal predefined routine' } as CompletionItem); } return items; }); connection.onHover(params => { const document = documents.get(params.textDocument.uri); if (!document) return null; const text = document.getText(); let symbols: PasSymbol[] = []; try { symbols = parsePascal(text); } catch { return null; } const offset = document.offsetAt(params.position); const found = nameAtPosition(mask(text), offset); if (!found) return null; const filePath = uriToFilePath(document.uri); const docDir = path.dirname(filePath); const resolved = resolveAt(symbols, parseUses(text), filePath, docDir, params.position.line, params.position.character, found.qualifier, found.name); const show = resolved ?? (PASCAL_BUILTINS.includes(found.name.toUpperCase()) ? { sym: { name: found.name.toUpperCase(), kind: 'procedure', detail: 'Pascal predefined routine' } as PasSymbol, filePath } : null); if (!show) return null; const origin = show.filePath === filePath ? '' : ` — \`${path.basename(show.filePath)}:${show.sym.line + 1}\``; const hover: Hover = { contents: { kind: 'markdown', value: ['```pascal', show.sym.detail || show.sym.name, '```', `*${show.sym.kind}*${origin}`].join('\n') } }; return hover; }); connection.onDefinition(params => { const document = documents.get(params.textDocument.uri); if (!document) return null; const text = document.getText(); let symbols: PasSymbol[] = []; try { symbols = parsePascal(text); } catch { return null; } const filePath = uriToFilePath(document.uri); const docDir = path.dirname(filePath); const uses = parseUses(text); // USES-clause unit name: jump to the unit file. const unit = unitAt(uses, docDir, params.position.line, params.position.character); if (unit) { return { uri: pathToFileURL(unit.filePath).toString(), range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } } as Location; } const offset = document.offsetAt(params.position); const found = nameAtPosition(mask(text), offset); if (!found) return null; const resolved = resolveAt(symbols, uses, filePath, docDir, params.position.line, params.position.character, found.qualifier, found.name); if (!resolved) return null; const targetUri = resolved.filePath === filePath ? document.uri : pathToFileURL(resolved.filePath).toString(); return { uri: targetUri, range: { start: { line: resolved.sym.line, character: resolved.sym.ch }, end: { line: resolved.sym.endLine, character: resolved.sym.endCh } } } as Location; }); /** Resolve the declaration for a rename/references request, or null. */ function resolveRequest( document: TextDocument, line: number, character: number, ): { symbols: PasSymbol[]; uses: ReturnType; filePath: string; docDir: string; qualifier: string | null; name: string } | null { const text = document.getText(); let symbols: PasSymbol[]; try { symbols = parsePascal(text); } catch { return null; } const offset = document.offsetAt({ line, character }); const found = nameAtPosition(mask(text), offset); if (!found) return null; const filePath = uriToFilePath(document.uri); return { symbols, uses: parseUses(text), filePath, docDir: path.dirname(filePath), qualifier: found.qualifier, name: found.name }; } /** Reference occurrences across the open documents plus the declaration file. */ function collectReferences( documents: TextDocument[], symbols: PasSymbol[], uses: ReturnType, filePath: string, docDir: string, line: number, character: number, qualifier: string | null, name: string, includeDeclaration: boolean, ): { uri: string; occurrences: PasLocatedOccurrence[] }[] { const decl = resolveAt(symbols, uses, filePath, docDir, line, character, qualifier, name); if (!decl) return []; const seen = new Map(); for (const doc of documents) { if (!seen.has(doc.uri)) seen.set(doc.uri, doc); } // The declaration may live in a used unit that is not open. let extra: { uri: string; text: string } | null = null; if (!seen.has(pathToFileURL(decl.filePath).toString())) { try { extra = { uri: pathToFileURL(decl.filePath).toString(), text: readFileSync(decl.filePath, 'utf8') }; } catch { extra = null; } } const out: { uri: string; occurrences: PasLocatedOccurrence[] }[] = []; 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(), req.symbols, req.uses, req.filePath, req.docDir, 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 = resolveAt(req.symbols, req.uses, req.filePath, req.docDir, params.position.line, params.position.character, req.qualifier, req.name); if (!decl) return null; const token = tokenAtPosition(document.getText(), params.position.line, params.position.character); if (!token) 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(), req.symbols, req.uses, req.filePath, req.docDir, 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.onDocumentRangeFormatting(params => { const document = documents.get(params.textDocument.uri); if (!document) return null; const filePath = uriToFilePath(document.uri); try { const edits = formatRangeEdits( document.getText(), params.range.start.line, params.range.end.line, formatOptions, filePath, path.dirname(filePath), ); return edits.map((e): TextEdit => ({ range: { start: { line: e.startLine, character: e.startCh }, end: { line: e.endLine, character: e.endCh }, }, newText: e.newText, })); } catch (error) { connection.console.error(`range formatting failed: ${error instanceof Error ? error.message : String(error)}`); return null; } }); 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();