server.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. import {
  2. createConnection, ProposedFeatures, InitializeParams, InitializeResult,
  3. TextDocuments, TextDocumentSyncKind, Diagnostic, DiagnosticSeverity,
  4. DidChangeConfigurationParams
  5. } from 'vscode-languageserver/node';
  6. import { TextDocument } from 'vscode-languageserver-textdocument';
  7. import * as path from 'path';
  8. import * as os from 'os';
  9. import { promises as fs, readFileSync } from 'fs';
  10. import { spawn } from 'child_process';
  11. import { pathToFileURL } from 'url';
  12. import { parseListing } from './pascalListing';
  13. import { mask, parsePascal, parseUses, visibleAt, PasSymbol } from './pascalSymbols';
  14. import {
  15. findReferencesInText, moduleExports, nameAtPosition, recordFieldsOf,
  16. resolveAt, tokenAtPosition, unitAt, PasLocatedOccurrence, PasResolved
  17. } from './pascalResolve';
  18. import { DEFAULT_FORMAT_OPTIONS, FormatOptions, formatDocument, formatRangeEdits, normalizeFormatOptions } from './pascalFormat';
  19. import { computeSemanticTokens, semanticTokensLegend } from './pascalSemantic';
  20. import {
  21. CompletionItem, CompletionItemKind, DocumentSymbol, SymbolKind, Hover, Location, TextEdit
  22. } from 'vscode-languageserver/node';
  23. const connection = createConnection(ProposedFeatures.all);
  24. const documents = new TextDocuments(TextDocument);
  25. let pascalDialect = 'freepascal';
  26. let pascalValidators: Record<string, string> = {};
  27. let formatOptions: FormatOptions = { ...DEFAULT_FORMAT_OPTIONS };
  28. connection.onInitialize((params: InitializeParams): InitializeResult => {
  29. const options = (params.initializationOptions || {}) as {
  30. pascalDialect?: string; pascalValidators?: Record<string, string>;
  31. format?: Partial<FormatOptions>;
  32. };
  33. pascalDialect = options.pascalDialect || 'freepascal';
  34. pascalValidators = options.pascalValidators || {};
  35. formatOptions = normalizeFormatOptions(options.format ?? {});
  36. return {
  37. capabilities: {
  38. textDocumentSync: TextDocumentSyncKind.Full,
  39. completionProvider: { triggerCharacters: ['.'] },
  40. hoverProvider: true,
  41. definitionProvider: true,
  42. documentSymbolProvider: true,
  43. referencesProvider: true,
  44. renameProvider: { prepareProvider: true },
  45. documentFormattingProvider: true,
  46. documentRangeFormattingProvider: true,
  47. semanticTokensProvider: {
  48. legend: semanticTokensLegend(),
  49. full: true,
  50. },
  51. }
  52. };
  53. });
  54. connection.onDidChangeConfiguration((params: DidChangeConfigurationParams) => {
  55. const settings = (params.settings || {}) as {
  56. modula2?: {
  57. pascal?: { dialect?: string; validators?: Record<string, string> };
  58. };
  59. };
  60. pascalDialect = settings.modula2?.pascal?.dialect || pascalDialect;
  61. pascalValidators = settings.modula2?.pascal?.validators || pascalValidators;
  62. const fmt = (settings.modula2?.pascal as { format?: Partial<FormatOptions> } | undefined)?.format;
  63. if (fmt) {
  64. formatOptions = normalizeFormatOptions(fmt);
  65. }
  66. });
  67. documents.onDidOpen(e => validate(e.document));
  68. documents.onDidChangeContent(e => validate(e.document));
  69. documents.onDidClose(e => connection.sendDiagnostics({ uri: e.document.uri, diagnostics: [] }));
  70. /** Validate a Pascal document with the configured dialect validator.
  71. *
  72. * Each pilot validator takes the source file as its single argument and
  73. * writes `<base>.LST` next to it; diagnostics come from that listing, whose
  74. * `***** ^ message` lines point at the preceding source line.
  75. * No validator configured (or dialect unknown) means no diagnostics.
  76. */
  77. async function validate(document: TextDocument): Promise<void> {
  78. const command = pascalValidators[pascalDialect];
  79. if (!command) {
  80. connection.sendDiagnostics({ uri: document.uri, diagnostics: [] });
  81. return;
  82. }
  83. const filePath = uriToFilePath(document.uri);
  84. const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'pascal-lsp-'));
  85. const base = path.basename(filePath, path.extname(filePath));
  86. const tempFile = path.join(dir, base + '.pas');
  87. try {
  88. await fs.writeFile(tempFile, document.getText(), 'utf8');
  89. await execute(command, [tempFile], path.dirname(filePath));
  90. let listing = '';
  91. try {
  92. listing = await fs.readFile(path.join(dir, base + '.LST'), 'utf8');
  93. } catch {
  94. listing = '';
  95. }
  96. connection.sendDiagnostics({ uri: document.uri, diagnostics: parseListing(listing) });
  97. } catch (error) {
  98. connection.sendDiagnostics({ uri: document.uri, diagnostics: [{
  99. severity: DiagnosticSeverity.Warning,
  100. range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
  101. message: `Pascal validator: ${error instanceof Error ? error.message : String(error)}`,
  102. source: 'Pascal'
  103. }] });
  104. } finally {
  105. await fs.rm(dir, { recursive: true, force: true });
  106. }
  107. }
  108. function execute(command: string, args: string[], cwd: string): Promise<string> {
  109. return new Promise((resolve, reject) => {
  110. const child = spawn(command, args, { cwd, shell: false });
  111. let output = '';
  112. child.stdout.on('data', d => output += d.toString());
  113. child.stderr.on('data', d => output += d.toString());
  114. child.on('error', reject);
  115. child.on('close', () => resolve(output));
  116. });
  117. }
  118. function uriToFilePath(uri: string): string {
  119. if (uri.startsWith('file://')) return decodeURIComponent(new URL(uri).pathname);
  120. return uri;
  121. }
  122. // ---- Local intelligence (Outline, completion, hover) ----
  123. const PASCAL_KEYWORDS = [
  124. 'AND', 'ARRAY', 'BEGIN', 'CASE', 'CONST', 'DIV', 'DO', 'DOWNTO',
  125. 'ELSE', 'END', 'FILE', 'FOR', 'FUNCTION', 'GOTO', 'IF', 'IN',
  126. 'LABEL', 'MOD', 'NIL', 'NOT', 'OF', 'OR', 'PACKED', 'PROCEDURE',
  127. 'PROGRAM', 'RECORD', 'REPEAT', 'SET', 'THEN', 'TO', 'TYPE', 'UNIT',
  128. 'UNTIL', 'USES', 'VAR', 'WHILE', 'WITH', 'FORWARD'
  129. ];
  130. const PASCAL_BUILTINS = [
  131. 'READ', 'READLN', 'WRITE', 'WRITELN', 'EOF', 'EOLN', 'ODD', 'ORD',
  132. 'CHR', 'ABS', 'SQR', 'TRUNC', 'NEW', 'SIN', 'COS', 'EXP', 'LN'
  133. ];
  134. function symbolKind(kind: PasSymbol['kind']): SymbolKind {
  135. switch (kind) {
  136. case 'module': return SymbolKind.Module;
  137. case 'procedure':
  138. case 'function': return SymbolKind.Function;
  139. case 'constant': return SymbolKind.Constant;
  140. case 'type': return SymbolKind.Class;
  141. case 'variable':
  142. case 'parameter':
  143. case 'field': return SymbolKind.Variable;
  144. }
  145. }
  146. function toDocumentSymbol(s: PasSymbol): DocumentSymbol {
  147. const endLine = s.scopeEndLine ?? s.endLine;
  148. const endCh = s.scopeEndCh ?? s.endCh;
  149. return {
  150. name: s.name,
  151. kind: symbolKind(s.kind),
  152. range: {
  153. start: { line: s.line, character: s.ch },
  154. end: { line: endLine, character: endCh }
  155. },
  156. selectionRange: {
  157. start: { line: s.line, character: s.ch },
  158. end: { line: s.endLine, character: s.endCh }
  159. },
  160. detail: s.detail,
  161. children: s.children.map(toDocumentSymbol)
  162. };
  163. }
  164. connection.onDocumentSymbol(params => {
  165. const document = documents.get(params.textDocument.uri);
  166. if (!document) return [];
  167. try {
  168. return parsePascal(document.getText()).map(toDocumentSymbol);
  169. } catch {
  170. return [];
  171. }
  172. });
  173. connection.onCompletion(params => {
  174. const document = documents.get(params.textDocument.uri);
  175. if (!document) return [];
  176. const text = document.getText();
  177. const filePath = uriToFilePath(document.uri);
  178. const docDir = path.dirname(filePath);
  179. let symbols: PasSymbol[] = [];
  180. try {
  181. symbols = parsePascal(text);
  182. } catch {
  183. symbols = [];
  184. }
  185. const uses = parseUses(text);
  186. const offset = document.offsetAt(params.position);
  187. const dotMatch = /([A-Za-z_][A-Za-z0-9_]*)\.\s*[A-Za-z_0-9]*$/.exec(text.slice(0, offset));
  188. if (dotMatch) {
  189. const qualifier = dotMatch[1];
  190. // Used unit: offer its interface names.
  191. const exps = moduleExports(docDir, qualifier);
  192. if (exps.length > 0) {
  193. return exps.map(s => ({
  194. label: s.name,
  195. kind: s.kind === 'procedure' || s.kind === 'function'
  196. ? CompletionItemKind.Function
  197. : s.kind === 'constant' ? CompletionItemKind.Constant
  198. : s.kind === 'type' ? CompletionItemKind.Class
  199. : CompletionItemKind.Variable,
  200. detail: s.detail
  201. } as CompletionItem));
  202. }
  203. // Variable of record type: inline fields first, then the named type.
  204. const resolved = resolveAt(symbols, uses, filePath, docDir,
  205. params.position.line, params.position.character, null, qualifier);
  206. const head = resolved?.sym;
  207. if (head && (head.kind === 'variable' || head.kind === 'parameter' || head.kind === 'constant')) {
  208. if (head.children.length > 0) {
  209. return head.children.map(f => ({
  210. label: f.name, kind: CompletionItemKind.Field, detail: f.detail
  211. } as CompletionItem));
  212. }
  213. const typeMatch = /:\s*((?:[A-Za-z_][A-Za-z0-9_]*)(?:\.[A-Za-z_][A-Za-z0-9_]*)?)/.exec(head.detail);
  214. if (typeMatch) {
  215. const rec = recordFieldsOf(symbols, uses, docDir, typeMatch[1]);
  216. if (rec) {
  217. return rec.fields.map(f => ({
  218. label: f.name, kind: CompletionItemKind.Field, detail: f.detail
  219. } as CompletionItem));
  220. }
  221. }
  222. }
  223. // Bare type name with record fields.
  224. const rec = recordFieldsOf(symbols, uses, docDir, qualifier);
  225. if (rec) {
  226. return rec.fields.map(f => ({
  227. label: f.name, kind: CompletionItemKind.Field, detail: f.detail
  228. } as CompletionItem));
  229. }
  230. return [];
  231. }
  232. const vis = visibleAt(symbols, params.position.line, params.position.character);
  233. const items: CompletionItem[] = vis.map(s => ({
  234. label: s.name,
  235. kind: s.kind === 'procedure' || s.kind === 'function'
  236. ? CompletionItemKind.Function
  237. : s.kind === 'constant' ? CompletionItemKind.Constant
  238. : s.kind === 'type' ? CompletionItemKind.Class
  239. : CompletionItemKind.Variable,
  240. detail: s.detail
  241. } as CompletionItem));
  242. for (const kw of PASCAL_KEYWORDS) {
  243. items.push({ label: kw, kind: CompletionItemKind.Keyword, detail: 'Pascal keyword' } as CompletionItem);
  244. }
  245. for (const b of PASCAL_BUILTINS) {
  246. items.push({ label: b, kind: CompletionItemKind.Function, detail: 'Pascal predefined routine' } as CompletionItem);
  247. }
  248. return items;
  249. });
  250. connection.onHover(params => {
  251. const document = documents.get(params.textDocument.uri);
  252. if (!document) return null;
  253. const text = document.getText();
  254. let symbols: PasSymbol[] = [];
  255. try {
  256. symbols = parsePascal(text);
  257. } catch {
  258. return null;
  259. }
  260. const offset = document.offsetAt(params.position);
  261. const found = nameAtPosition(mask(text), offset);
  262. if (!found) return null;
  263. const filePath = uriToFilePath(document.uri);
  264. const docDir = path.dirname(filePath);
  265. const resolved = resolveAt(symbols, parseUses(text), filePath, docDir,
  266. params.position.line, params.position.character, found.qualifier, found.name);
  267. const show = resolved ?? (PASCAL_BUILTINS.includes(found.name.toUpperCase())
  268. ? { sym: { name: found.name.toUpperCase(), kind: 'procedure', detail: 'Pascal predefined routine' } as PasSymbol, filePath }
  269. : null);
  270. if (!show) return null;
  271. const origin = show.filePath === filePath
  272. ? ''
  273. : ` — \`${path.basename(show.filePath)}:${show.sym.line + 1}\``;
  274. const hover: Hover = {
  275. contents: {
  276. kind: 'markdown',
  277. value: ['```pascal', show.sym.detail || show.sym.name, '```', `*${show.sym.kind}*${origin}`].join('\n')
  278. }
  279. };
  280. return hover;
  281. });
  282. connection.onDefinition(params => {
  283. const document = documents.get(params.textDocument.uri);
  284. if (!document) return null;
  285. const text = document.getText();
  286. let symbols: PasSymbol[] = [];
  287. try {
  288. symbols = parsePascal(text);
  289. } catch {
  290. return null;
  291. }
  292. const filePath = uriToFilePath(document.uri);
  293. const docDir = path.dirname(filePath);
  294. const uses = parseUses(text);
  295. // USES-clause unit name: jump to the unit file.
  296. const unit = unitAt(uses, docDir, params.position.line, params.position.character);
  297. if (unit) {
  298. return {
  299. uri: pathToFileURL(unit.filePath).toString(),
  300. range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }
  301. } as Location;
  302. }
  303. const offset = document.offsetAt(params.position);
  304. const found = nameAtPosition(mask(text), offset);
  305. if (!found) return null;
  306. const resolved = resolveAt(symbols, uses, filePath, docDir,
  307. params.position.line, params.position.character, found.qualifier, found.name);
  308. if (!resolved) return null;
  309. const targetUri = resolved.filePath === filePath ? document.uri : pathToFileURL(resolved.filePath).toString();
  310. return {
  311. uri: targetUri,
  312. range: {
  313. start: { line: resolved.sym.line, character: resolved.sym.ch },
  314. end: { line: resolved.sym.endLine, character: resolved.sym.endCh }
  315. }
  316. } as Location;
  317. });
  318. /** Resolve the declaration for a rename/references request, or null. */
  319. function resolveRequest(
  320. document: TextDocument, line: number, character: number,
  321. ): { symbols: PasSymbol[]; uses: ReturnType<typeof parseUses>; filePath: string; docDir: string; qualifier: string | null; name: string } | null {
  322. const text = document.getText();
  323. let symbols: PasSymbol[];
  324. try {
  325. symbols = parsePascal(text);
  326. } catch {
  327. return null;
  328. }
  329. const offset = document.offsetAt({ line, character });
  330. const found = nameAtPosition(mask(text), offset);
  331. if (!found) return null;
  332. const filePath = uriToFilePath(document.uri);
  333. return { symbols, uses: parseUses(text), filePath, docDir: path.dirname(filePath), qualifier: found.qualifier, name: found.name };
  334. }
  335. /** Reference occurrences across the open documents plus the declaration file. */
  336. function collectReferences(
  337. documents: TextDocument[],
  338. symbols: PasSymbol[], uses: ReturnType<typeof parseUses>,
  339. filePath: string, docDir: string, line: number, character: number,
  340. qualifier: string | null, name: string, includeDeclaration: boolean,
  341. ): { uri: string; occurrences: PasLocatedOccurrence[] }[] {
  342. const decl = resolveAt(symbols, uses, filePath, docDir, line, character, qualifier, name);
  343. if (!decl) return [];
  344. const seen = new Map<string, TextDocument>();
  345. for (const doc of documents) {
  346. if (!seen.has(doc.uri)) seen.set(doc.uri, doc);
  347. }
  348. // The declaration may live in a used unit that is not open.
  349. let extra: { uri: string; text: string } | null = null;
  350. if (!seen.has(pathToFileURL(decl.filePath).toString())) {
  351. try {
  352. extra = { uri: pathToFileURL(decl.filePath).toString(), text: readFileSync(decl.filePath, 'utf8') };
  353. } catch {
  354. extra = null;
  355. }
  356. }
  357. const out: { uri: string; occurrences: PasLocatedOccurrence[] }[] = [];
  358. for (const doc of seen.values()) {
  359. const fp = uriToFilePath(doc.uri);
  360. const occurrences = findReferencesInText(doc.getText(), fp, path.dirname(fp), decl, includeDeclaration);
  361. if (occurrences.length > 0) out.push({ uri: doc.uri, occurrences });
  362. }
  363. if (extra) {
  364. const occurrences = findReferencesInText(extra.text, decl.filePath, path.dirname(decl.filePath), decl, includeDeclaration);
  365. if (occurrences.length > 0) out.push({ uri: extra.uri, occurrences });
  366. }
  367. return out;
  368. }
  369. connection.onReferences(params => {
  370. const document = documents.get(params.textDocument.uri);
  371. if (!document) return [];
  372. const req = resolveRequest(document, params.position.line, params.position.character);
  373. if (!req) return [];
  374. const locations: Location[] = [];
  375. for (const { uri, occurrences } of collectReferences(
  376. documents.all(), req.symbols, req.uses, req.filePath, req.docDir,
  377. params.position.line, params.position.character,
  378. req.qualifier, req.name, params.context.includeDeclaration,
  379. )) {
  380. for (const occ of occurrences) {
  381. locations.push({
  382. uri,
  383. range: {
  384. start: { line: occ.line, character: occ.ch },
  385. end: { line: occ.endLine, character: occ.endCh }
  386. }
  387. });
  388. }
  389. }
  390. return locations;
  391. });
  392. connection.onPrepareRename(params => {
  393. const document = documents.get(params.textDocument.uri);
  394. if (!document) return null;
  395. const req = resolveRequest(document, params.position.line, params.position.character);
  396. if (!req) return null;
  397. const decl = resolveAt(req.symbols, req.uses, req.filePath, req.docDir,
  398. params.position.line, params.position.character, req.qualifier, req.name);
  399. if (!decl) return null;
  400. const token = tokenAtPosition(document.getText(), params.position.line, params.position.character);
  401. if (!token) return null;
  402. return {
  403. start: { line: token.line, character: token.ch },
  404. end: { line: token.endLine, character: token.endCh }
  405. };
  406. });
  407. connection.onRenameRequest(params => {
  408. const document = documents.get(params.textDocument.uri);
  409. if (!document) return null;
  410. const req = resolveRequest(document, params.position.line, params.position.character);
  411. if (!req) return null;
  412. const changes: { [uri: string]: { range: Location['range']; newText: string }[] } = {};
  413. for (const { uri, occurrences } of collectReferences(
  414. documents.all(), req.symbols, req.uses, req.filePath, req.docDir,
  415. params.position.line, params.position.character,
  416. req.qualifier, req.name, true,
  417. )) {
  418. changes[uri] = occurrences.map(occ => ({
  419. range: {
  420. start: { line: occ.line, character: occ.ch },
  421. end: { line: occ.endLine, character: occ.endCh }
  422. },
  423. newText: params.newName
  424. }));
  425. }
  426. if (Object.keys(changes).length === 0) return null;
  427. return { changes };
  428. });
  429. connection.onDocumentFormatting(params => {
  430. const document = documents.get(params.textDocument.uri);
  431. if (!document) return null;
  432. const filePath = uriToFilePath(document.uri);
  433. let formatted: string;
  434. try {
  435. formatted = formatDocument(
  436. document.getText(), formatOptions, filePath, path.dirname(filePath),
  437. );
  438. } catch (error) {
  439. connection.console.error(`formatting failed: ${error instanceof Error ? error.message : String(error)}`);
  440. return null;
  441. }
  442. if (formatted === document.getText()) return [];
  443. const lines = document.getText().split('\n');
  444. const last = lines.length - 1;
  445. const edit: TextEdit = {
  446. range: {
  447. start: { line: 0, character: 0 },
  448. end: { line: last, character: lines[last]?.length ?? 0 },
  449. },
  450. newText: formatted,
  451. };
  452. return [edit];
  453. });
  454. connection.onDocumentRangeFormatting(params => {
  455. const document = documents.get(params.textDocument.uri);
  456. if (!document) return null;
  457. const filePath = uriToFilePath(document.uri);
  458. try {
  459. const edits = formatRangeEdits(
  460. document.getText(), params.range.start.line, params.range.end.line,
  461. formatOptions, filePath, path.dirname(filePath),
  462. );
  463. return edits.map((e): TextEdit => ({
  464. range: {
  465. start: { line: e.startLine, character: e.startCh },
  466. end: { line: e.endLine, character: e.endCh },
  467. },
  468. newText: e.newText,
  469. }));
  470. } catch (error) {
  471. connection.console.error(`range formatting failed: ${error instanceof Error ? error.message : String(error)}`);
  472. return null;
  473. }
  474. });
  475. connection.languages.semanticTokens.on(params => {
  476. const document = documents.get(params.textDocument.uri);
  477. if (!document) return { data: [] };
  478. const filePath = uriToFilePath(document.uri);
  479. try {
  480. return {
  481. data: computeSemanticTokens(document.getText(), filePath, path.dirname(filePath)),
  482. };
  483. } catch (error) {
  484. connection.console.error(`semantic tokens failed: ${error instanceof Error ? error.message : String(error)}`);
  485. return { data: [] };
  486. }
  487. });
  488. documents.listen(connection);
  489. connection.listen();