|
|
@@ -6,12 +6,17 @@ import {
|
|
|
import { TextDocument } from 'vscode-languageserver-textdocument';
|
|
|
import * as path from 'path';
|
|
|
import * as os from 'os';
|
|
|
-import { promises as fs } from 'fs';
|
|
|
+import { promises as fs, readFileSync } from 'fs';
|
|
|
import { spawn } from 'child_process';
|
|
|
+import { pathToFileURL } from 'url';
|
|
|
import { parseListing } from './pascalListing';
|
|
|
-import { parsePascal, visibleAt, PasSymbol } from './pascalSymbols';
|
|
|
+import { mask, parsePascal, parseUses, visibleAt, PasSymbol } from './pascalSymbols';
|
|
|
import {
|
|
|
- CompletionItem, CompletionItemKind, DocumentSymbol, SymbolKind, Hover
|
|
|
+ findReferencesInText, moduleExports, nameAtPosition, recordFieldsOf,
|
|
|
+ resolveAt, tokenAtPosition, unitAt, PasLocatedOccurrence, PasResolved
|
|
|
+} from './pascalResolve';
|
|
|
+import {
|
|
|
+ CompletionItem, CompletionItemKind, DocumentSymbol, SymbolKind, Hover, Location
|
|
|
} from 'vscode-languageserver/node';
|
|
|
|
|
|
const connection = createConnection(ProposedFeatures.all);
|
|
|
@@ -30,7 +35,10 @@ connection.onInitialize((params: InitializeParams): InitializeResult => {
|
|
|
textDocumentSync: TextDocumentSyncKind.Full,
|
|
|
completionProvider: { triggerCharacters: ['.'] },
|
|
|
hoverProvider: true,
|
|
|
- documentSymbolProvider: true
|
|
|
+ definitionProvider: true,
|
|
|
+ documentSymbolProvider: true,
|
|
|
+ referencesProvider: true,
|
|
|
+ renameProvider: { prepareProvider: true }
|
|
|
}
|
|
|
};
|
|
|
});
|
|
|
@@ -164,12 +172,64 @@ connection.onDocumentSymbol(params => {
|
|
|
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(document.getText());
|
|
|
+ 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_]*)/.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,
|
|
|
@@ -192,35 +252,188 @@ connection.onCompletion(params => {
|
|
|
connection.onHover(params => {
|
|
|
const document = documents.get(params.textDocument.uri);
|
|
|
if (!document) return null;
|
|
|
+ const text = document.getText();
|
|
|
let symbols: PasSymbol[] = [];
|
|
|
try {
|
|
|
- symbols = parsePascal(document.getText());
|
|
|
+ symbols = parsePascal(text);
|
|
|
} catch {
|
|
|
return null;
|
|
|
}
|
|
|
const offset = document.offsetAt(params.position);
|
|
|
- const text = document.getText();
|
|
|
- let start = offset;
|
|
|
- while (start > 0 && /[A-Za-z0-9_]/.test(text[start - 1] ?? '')) start--;
|
|
|
- let end = offset;
|
|
|
- while (end < text.length && /[A-Za-z0-9_]/.test(text[end] ?? '')) end++;
|
|
|
- const word = text.slice(start, end);
|
|
|
- if (!word) return null;
|
|
|
- const upper = word.toUpperCase();
|
|
|
- const hit = visibleAt(symbols, params.position.line, params.position.character)
|
|
|
- .find(s => s.name.toUpperCase() === upper);
|
|
|
- const show = hit ?? (PASCAL_BUILTINS.includes(upper)
|
|
|
- ? { name: upper, kind: 'procedure' as const, detail: 'Pascal predefined routine' }
|
|
|
+ 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.detail || show.name, '```', `*${show.kind}*`].join('\n')
|
|
|
+ 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 };
|
|
|
+});
|
|
|
+
|
|
|
documents.listen(connection);
|
|
|
connection.listen();
|