| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516 |
- 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<string, string> = {};
- let formatOptions: FormatOptions = { ...DEFAULT_FORMAT_OPTIONS };
- connection.onInitialize((params: InitializeParams): InitializeResult => {
- const options = (params.initializationOptions || {}) as {
- pascalDialect?: string; pascalValidators?: Record<string, string>;
- format?: Partial<FormatOptions>;
- };
- 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<string, string> };
- };
- };
- pascalDialect = settings.modula2?.pascal?.dialect || pascalDialect;
- pascalValidators = settings.modula2?.pascal?.validators || pascalValidators;
- const fmt = (settings.modula2?.pascal as { format?: Partial<FormatOptions> } | 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 `<base>.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<void> {
- 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<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 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<typeof parseUses>; 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<typeof parseUses>,
- 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<string, TextDocument>();
- 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();
|