server.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. import {
  2. createConnection, ProposedFeatures, InitializeParams, InitializeResult,
  3. TextDocuments, TextDocumentSyncKind, Diagnostic, DiagnosticSeverity,
  4. DidChangeConfigurationParams, CompletionItem, CompletionItemKind,
  5. DocumentSymbol, SymbolKind, Hover, Location, TextEdit
  6. } from 'vscode-languageserver/node';
  7. import { TextDocument } from 'vscode-languageserver-textdocument';
  8. import * as path from 'path';
  9. import * as os from 'os';
  10. import { promises as fs, readFileSync } from 'fs';
  11. import { spawn } from 'child_process';
  12. import { pathToFileURL } from 'url';
  13. import { lex, Token } from './m2/lexer';
  14. import { M2Symbol, M2Unit, parseUnitText } from './m2/parser';
  15. import { analyseUnit } from './m2/analyse';
  16. import { computeSemanticTokens, semanticTokensLegend } from './m2/semantic';
  17. import { DEFAULT_FORMAT_OPTIONS, FormatOptions, formatDocument, normalizeFormatOptions } from './m2/format';
  18. import {
  19. moduleExports, recordFieldsOf, resolveName, toLocation, visibleSymbols,
  20. findReferencesInText, LocatedOccurrence,
  21. } from './m2/resolve';
  22. const connection = createConnection(ProposedFeatures.all);
  23. const documents = new TextDocuments(TextDocument);
  24. let validatorCommand = '';
  25. let validatorArguments: string[] = ['{file}'];
  26. let formatOptions: FormatOptions = { ...DEFAULT_FORMAT_OPTIONS };
  27. connection.onInitialize((params: InitializeParams): InitializeResult => {
  28. const options = (params.initializationOptions || {}) as {
  29. validatorCommand?: string; validatorArguments?: string[];
  30. format?: Partial<FormatOptions>;
  31. };
  32. validatorCommand = options.validatorCommand || '';
  33. validatorArguments = options.validatorArguments || ['{file}'];
  34. formatOptions = normalizeFormatOptions(options.format ?? {});
  35. return {
  36. capabilities: {
  37. textDocumentSync: TextDocumentSyncKind.Full,
  38. completionProvider: { triggerCharacters: ['.'] },
  39. hoverProvider: true,
  40. definitionProvider: true,
  41. documentSymbolProvider: true,
  42. referencesProvider: true,
  43. renameProvider: { prepareProvider: true },
  44. documentFormattingProvider: true,
  45. semanticTokensProvider: {
  46. legend: semanticTokensLegend(),
  47. full: true,
  48. },
  49. },
  50. };
  51. });
  52. connection.onDidChangeConfiguration((params: DidChangeConfigurationParams) => {
  53. const settings = (params.settings || {}) as {
  54. modula2?: {
  55. cocoR?: { validatorCommand?: string; validatorArguments?: string[] };
  56. format?: Partial<FormatOptions>;
  57. };
  58. };
  59. validatorCommand = settings.modula2?.cocoR?.validatorCommand || validatorCommand;
  60. validatorArguments = settings.modula2?.cocoR?.validatorArguments || validatorArguments;
  61. if (settings.modula2?.format) {
  62. formatOptions = normalizeFormatOptions(settings.modula2.format);
  63. }
  64. });
  65. documents.onDidOpen(e => validate(e.document));
  66. documents.onDidChangeContent(e => validate(e.document));
  67. documents.onDidClose(e => connection.sendDiagnostics({ uri: e.document.uri, diagnostics: [] }));
  68. async function validate(document: TextDocument): Promise<void> {
  69. const local = analyseDocument(document);
  70. if (!validatorCommand) {
  71. connection.sendDiagnostics({ uri: document.uri, diagnostics: local });
  72. return;
  73. }
  74. const filePath = uriToFilePath(document.uri);
  75. const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'modula2-lsp-'));
  76. const ext = path.extname(filePath) || '.mod';
  77. const tempFile = path.join(dir, path.basename(filePath, path.extname(filePath)) + ext);
  78. try {
  79. await fs.writeFile(tempFile, document.getText(), 'utf8');
  80. const args = validatorArguments.map(a => a.replace(/\{file\}/g, tempFile));
  81. const output = await execute(validatorCommand, args, path.dirname(filePath));
  82. connection.sendDiagnostics({ uri: document.uri, diagnostics: [...local, ...parseDiagnostics(output)] });
  83. } catch (error) {
  84. connection.sendDiagnostics({ uri: document.uri, diagnostics: [...local, {
  85. severity: DiagnosticSeverity.Warning,
  86. range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
  87. message: `Coco/R validator: ${error instanceof Error ? error.message : String(error)}`,
  88. source: 'Coco/R'
  89. }] });
  90. } finally {
  91. await fs.rm(dir, { recursive: true, force: true });
  92. }
  93. }
  94. function execute(command: string, args: string[], cwd: string): Promise<string> {
  95. return new Promise((resolve, reject) => {
  96. const child = spawn(command, args, { cwd, shell: false });
  97. let output = '';
  98. child.stdout.on('data', d => output += d.toString());
  99. child.stderr.on('data', d => output += d.toString());
  100. child.on('error', reject);
  101. child.on('close', () => resolve(output));
  102. });
  103. }
  104. function parseDiagnostics(output: string): Diagnostic[] {
  105. const result: Diagnostic[] = [];
  106. // CRQ/editor-oriented format: file (line,column) message
  107. const re = /^.*?\s*\((\d+),(\d+)\)\s+(.*)$/gm;
  108. let m: RegExpExecArray | null;
  109. while ((m = re.exec(output)) !== null) {
  110. const message = m[3].trim();
  111. if (!message || /^(Grammar tests|Undefined tests|Unreachable tests|Circular tests|Underivable tests|LL\(1\) tests)$/.test(message)) continue;
  112. const line = Math.max(0, Number(m[1]) - 1);
  113. const character = Math.max(0, Number(m[2]));
  114. result.push({ severity: DiagnosticSeverity.Error, range: {
  115. start: { line, character }, end: { line, character: character + 1 }
  116. }, message, source: 'Coco/R' });
  117. }
  118. return result;
  119. }
  120. function uriToFilePath(uri: string): string {
  121. if (uri.startsWith('file://')) return decodeURIComponent(new URL(uri).pathname);
  122. return uri;
  123. }
  124. /** Local semantic diagnostics (always on; merged with Coco/R output when configured). */
  125. function analyseDocument(document: TextDocument): Diagnostic[] {
  126. const text = document.getText();
  127. const filePath = uriToFilePath(document.uri);
  128. return analyseUnit(text, filePath, path.dirname(filePath)).map(issue => ({
  129. severity: issue.severity === 'error' ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning,
  130. range: {
  131. start: { line: issue.line, character: issue.ch },
  132. end: { line: issue.endLine, character: issue.endCh },
  133. },
  134. message: issue.message,
  135. source: 'modula2',
  136. }));
  137. }
  138. // ---- Phase 2: AST-backed language intelligence ----
  139. const SERVER_KEYWORDS = [
  140. 'AND', 'ARRAY', 'BEGIN', 'BY', 'CASE', 'CONST', 'DEFINITION', 'DIV',
  141. 'DO', 'ELSE', 'ELSIF', 'END', 'EXIT', 'EXPORT', 'FOR', 'FROM', 'IF',
  142. 'IMPLEMENTATION', 'IMPORT', 'IN', 'LOOP', 'MOD', 'MODULE', 'NOT', 'OF',
  143. 'OR', 'POINTER', 'PROCEDURE', 'QUALIFIED', 'RECORD', 'REPEAT', 'RETURN',
  144. 'SET', 'THEN', 'TO', 'TYPE', 'UNTIL', 'VAR', 'WHILE', 'WITH',
  145. ];
  146. function parseDocument(document: TextDocument): { unit: M2Unit; tokens: Token[] } {
  147. const text = document.getText();
  148. const filePath = uriToFilePath(document.uri);
  149. return { unit: parseUnitText(text, filePath), tokens: lex(text) };
  150. }
  151. function tokenAt(tokens: Token[], offset: number): Token | null {
  152. for (const t of tokens) {
  153. if (t.kind === 'eof') break;
  154. if (t.offset <= offset && offset <= t.endOffset) return t;
  155. }
  156. return null;
  157. }
  158. /** Extract `Qualifier.name` (or plain `name`) at a document offset. */
  159. function nameAt(tokens: Token[], offset: number): { qualifier: string | null; name: string } | null {
  160. for (let k = 0; k < tokens.length; k++) {
  161. const t = tokens[k];
  162. if (t.kind === 'eof') break;
  163. if (!(t.offset <= offset && offset <= t.endOffset)) continue;
  164. if (t.kind !== 'ident') return null;
  165. // Cursor on the qualifier part: A|.B
  166. if (tokens[k + 1]?.text === '.' && tokens[k + 2]?.kind === 'ident') {
  167. return { qualifier: null, name: t.text };
  168. }
  169. // Cursor on the qualified part: A.|B (or second ident after dot)
  170. if (t.text !== undefined && tokens[k - 1]?.text === '.' && tokens[k - 2]?.kind === 'ident') {
  171. return { qualifier: tokens[k - 2].text, name: t.text };
  172. }
  173. return { qualifier: null, name: t.text };
  174. }
  175. return null;
  176. }
  177. function lspKind(kind: M2Symbol['kind']): SymbolKind {
  178. switch (kind) {
  179. case 'module': return SymbolKind.Module;
  180. case 'procedure': return SymbolKind.Function;
  181. case 'variable': return SymbolKind.Variable;
  182. case 'parameter': return SymbolKind.Variable;
  183. case 'field': return SymbolKind.Field;
  184. case 'constant': return SymbolKind.Constant;
  185. case 'type': return SymbolKind.Class;
  186. }
  187. }
  188. function completionKind(kind: M2Symbol['kind']): CompletionItemKind {
  189. switch (kind) {
  190. case 'module': return CompletionItemKind.Module;
  191. case 'procedure': return CompletionItemKind.Function;
  192. case 'variable': return CompletionItemKind.Variable;
  193. case 'parameter': return CompletionItemKind.Variable;
  194. case 'field': return CompletionItemKind.Field;
  195. case 'constant': return CompletionItemKind.Constant;
  196. case 'type': return CompletionItemKind.Class;
  197. }
  198. }
  199. function toDocumentSymbol(s: M2Symbol): DocumentSymbol {
  200. return {
  201. name: s.name,
  202. kind: lspKind(s.kind),
  203. range: {
  204. start: { line: s.extent.startLine, character: s.extent.startCh },
  205. end: { line: s.extent.endLine, character: s.extent.endCh },
  206. },
  207. selectionRange: {
  208. start: { line: s.nameRange.startLine, character: s.nameRange.startCh },
  209. end: { line: s.nameRange.endLine, character: s.nameRange.endCh },
  210. },
  211. detail: s.detail,
  212. children: s.children.map(toDocumentSymbol),
  213. };
  214. }
  215. connection.onDocumentSymbol(params => {
  216. const document = documents.get(params.textDocument.uri);
  217. if (!document) return [];
  218. const { unit } = parseDocument(document);
  219. const out: DocumentSymbol[] = [];
  220. for (const s of unit.symbols) {
  221. for (const c of s.children) out.push(toDocumentSymbol(c));
  222. if (s.children.length === 0 && s.name) out.push(toDocumentSymbol(s));
  223. }
  224. return out;
  225. });
  226. connection.onCompletion(params => {
  227. const document = documents.get(params.textDocument.uri);
  228. if (!document) return [];
  229. const { unit } = parseDocument(document);
  230. const filePath = uriToFilePath(document.uri);
  231. const docDir = path.dirname(filePath);
  232. const offset = document.offsetAt(params.position);
  233. const prefix = document.getText().slice(0, offset);
  234. const dotMatch = /([A-Za-z_][A-Za-z0-9_]*)\.\s*[A-Za-z_0-9]*$/.exec(prefix);
  235. if (dotMatch) {
  236. const qualifier = dotMatch[1];
  237. // Imported module: offer its exports.
  238. const exps = moduleExports(docDir, qualifier);
  239. if (exps.length > 0) {
  240. return exps.map(s => ({
  241. label: s.name, kind: completionKind(s.kind), detail: s.detail,
  242. } as CompletionItem));
  243. }
  244. // Variable of record type: offer fields (inline or via named type).
  245. const local = unit.symbols.flatMap(s => s.children).find(c => c.name === qualifier);
  246. if (local) {
  247. if (local.children.length > 0) {
  248. return local.children.map(f => ({
  249. label: f.name, kind: CompletionItemKind.Field, detail: f.detail,
  250. } as CompletionItem));
  251. }
  252. const typeMatch = /:\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(local.detail);
  253. if (typeMatch) {
  254. const fields = recordFieldsOf(unit, docDir, typeMatch[1]);
  255. if (fields.length > 0) {
  256. return fields.map(f => ({
  257. label: f.name, kind: CompletionItemKind.Field, detail: f.detail,
  258. } as CompletionItem));
  259. }
  260. }
  261. }
  262. // Bare type name with record fields.
  263. const fields = recordFieldsOf(unit, docDir, qualifier);
  264. if (fields.length > 0) {
  265. return fields.map(f => ({
  266. label: f.name, kind: CompletionItemKind.Field, detail: f.detail,
  267. } as CompletionItem));
  268. }
  269. return [];
  270. }
  271. const items: CompletionItem[] = visibleSymbols(unit, params.position.line, params.position.character)
  272. .map(s => ({ label: s.name, kind: completionKind(s.kind), detail: s.detail } as CompletionItem));
  273. for (const kw of SERVER_KEYWORDS) {
  274. items.push({ label: kw, kind: CompletionItemKind.Keyword, detail: 'Modula-2 keyword' });
  275. }
  276. return items;
  277. });
  278. connection.onHover(params => {
  279. const document = documents.get(params.textDocument.uri);
  280. if (!document) return null;
  281. const { unit, tokens } = parseDocument(document);
  282. const offset = document.offsetAt(params.position);
  283. const found = nameAt(tokens, offset);
  284. if (!found) return null;
  285. const docDir = path.dirname(uriToFilePath(document.uri));
  286. const resolved = resolveName(unit, docDir, params.position.line, params.position.character, found.qualifier, found.name);
  287. if (!resolved) return null;
  288. const hover: Hover = {
  289. contents: {
  290. kind: 'markdown',
  291. value: [
  292. '```modula2',
  293. resolved.sym.detail || resolved.sym.name,
  294. '```',
  295. `*${resolved.sym.kind}* — \`${path.basename(resolved.filePath)}:${resolved.sym.nameRange.startLine + 1}\``,
  296. ].join('\n'),
  297. },
  298. };
  299. return hover;
  300. });
  301. connection.onDefinition(params => {
  302. const document = documents.get(params.textDocument.uri);
  303. if (!document) return null;
  304. const { unit, tokens } = parseDocument(document);
  305. const offset = document.offsetAt(params.position);
  306. const found = nameAt(tokens, offset);
  307. if (!found) return null;
  308. const filePath = uriToFilePath(document.uri);
  309. const resolved = resolveName(unit, path.dirname(filePath), params.position.line, params.position.character, found.qualifier, found.name);
  310. if (!resolved) return null;
  311. const loc = toLocation(resolved.filePath, resolved.sym);
  312. const targetUri = resolved.filePath === filePath ? document.uri : pathToFileURL(resolved.filePath).toString();
  313. const location: Location = {
  314. uri: targetUri,
  315. range: {
  316. start: { line: loc.startLine, character: loc.startCh },
  317. end: { line: loc.endLine, character: loc.endCh },
  318. },
  319. };
  320. return location;
  321. });
  322. /** Resolve the declaration for a rename/references request, or null. */
  323. function resolveRequest(
  324. document: TextDocument, line: number, character: number,
  325. ): { unit: M2Unit; filePath: string; qualifier: string | null; name: string } | null {
  326. const { unit, tokens } = parseDocument(document);
  327. const offset = document.offsetAt({ line, character });
  328. const found = nameAt(tokens, offset);
  329. if (!found) return null;
  330. return { unit, filePath: uriToFilePath(document.uri), qualifier: found.qualifier, name: found.name };
  331. }
  332. /** Reference occurrences across the open documents plus the declaration file. */
  333. function collectReferences(
  334. documents: TextDocument[], currentUri: string,
  335. unit: M2Unit, filePath: string, line: number, character: number,
  336. qualifier: string | null, name: string,
  337. includeDeclaration: boolean,
  338. ): { uri: string; occurrences: LocatedOccurrence[] }[] {
  339. const decl = resolveName(unit, path.dirname(filePath), line, character, qualifier, name);
  340. if (!decl) return [];
  341. const seen = new Map<string, TextDocument>();
  342. for (const doc of documents) {
  343. if (!seen.has(doc.uri)) seen.set(doc.uri, doc);
  344. }
  345. // The declaration may live in a .def that is not open.
  346. let extra: { uri: string; text: string } | null = null;
  347. const declOpen = seen.has(pathToFileURL(decl.filePath).toString());
  348. if (!declOpen) {
  349. try {
  350. extra = { uri: pathToFileURL(decl.filePath).toString(), text: readFileSync(decl.filePath, 'utf8') };
  351. } catch {
  352. extra = null;
  353. }
  354. }
  355. void currentUri;
  356. const out: { uri: string; occurrences: LocatedOccurrence[] }[] = [];
  357. for (const doc of seen.values()) {
  358. const fp = uriToFilePath(doc.uri);
  359. const occurrences = findReferencesInText(
  360. doc.getText(), fp, path.dirname(fp), decl, includeDeclaration,
  361. );
  362. if (occurrences.length > 0) out.push({ uri: doc.uri, occurrences });
  363. }
  364. if (extra) {
  365. const occurrences = findReferencesInText(
  366. extra.text, decl.filePath, path.dirname(decl.filePath), decl, includeDeclaration,
  367. );
  368. if (occurrences.length > 0) out.push({ uri: extra.uri, occurrences });
  369. }
  370. return out;
  371. }
  372. connection.onReferences(params => {
  373. const document = documents.get(params.textDocument.uri);
  374. if (!document) return [];
  375. const req = resolveRequest(document, params.position.line, params.position.character);
  376. if (!req) return [];
  377. const locations: Location[] = [];
  378. for (const { uri, occurrences } of collectReferences(
  379. documents.all(), document.uri, req.unit, req.filePath,
  380. params.position.line, params.position.character,
  381. req.qualifier, req.name, params.context.includeDeclaration,
  382. )) {
  383. for (const occ of occurrences) {
  384. locations.push({
  385. uri,
  386. range: {
  387. start: { line: occ.line, character: occ.ch },
  388. end: { line: occ.endLine, character: occ.endCh },
  389. },
  390. });
  391. }
  392. }
  393. return locations;
  394. });
  395. connection.onPrepareRename(params => {
  396. const document = documents.get(params.textDocument.uri);
  397. if (!document) return null;
  398. const req = resolveRequest(document, params.position.line, params.position.character);
  399. if (!req) return null;
  400. const decl = resolveName(
  401. req.unit, path.dirname(req.filePath),
  402. params.position.line, params.position.character, req.qualifier, req.name,
  403. );
  404. if (!decl) return null;
  405. const offset = document.offsetAt(params.position);
  406. const { tokens } = parseDocument(document);
  407. const token = tokenAt(tokens, offset);
  408. if (!token || token.kind !== 'ident') return null;
  409. return {
  410. start: { line: token.line, character: token.ch },
  411. end: { line: token.endLine, character: token.endCh },
  412. };
  413. });
  414. connection.onRenameRequest(params => {
  415. const document = documents.get(params.textDocument.uri);
  416. if (!document) return null;
  417. const req = resolveRequest(document, params.position.line, params.position.character);
  418. if (!req) return null;
  419. const changes: { [uri: string]: { range: Location['range']; newText: string }[] } = {};
  420. for (const { uri, occurrences } of collectReferences(
  421. documents.all(), document.uri, req.unit, req.filePath,
  422. params.position.line, params.position.character,
  423. req.qualifier, req.name, true,
  424. )) {
  425. changes[uri] = occurrences.map(occ => ({
  426. range: {
  427. start: { line: occ.line, character: occ.ch },
  428. end: { line: occ.endLine, character: occ.endCh },
  429. },
  430. newText: params.newName,
  431. }));
  432. }
  433. if (Object.keys(changes).length === 0) return null;
  434. return { changes };
  435. });
  436. connection.onDocumentFormatting(params => {
  437. const document = documents.get(params.textDocument.uri);
  438. if (!document) return null;
  439. const filePath = uriToFilePath(document.uri);
  440. let formatted: string;
  441. try {
  442. formatted = formatDocument(
  443. document.getText(), formatOptions, filePath, path.dirname(filePath),
  444. );
  445. } catch (error) {
  446. connection.console.error(`formatting failed: ${error instanceof Error ? error.message : String(error)}`);
  447. return null;
  448. }
  449. if (formatted === document.getText()) return [];
  450. const lines = document.getText().split('\n');
  451. const last = lines.length - 1;
  452. const edit: TextEdit = {
  453. range: {
  454. start: { line: 0, character: 0 },
  455. end: { line: last, character: lines[last]?.length ?? 0 },
  456. },
  457. newText: formatted,
  458. };
  459. return [edit];
  460. });
  461. connection.languages.semanticTokens.on(params => {
  462. const document = documents.get(params.textDocument.uri);
  463. if (!document) return { data: [] };
  464. const filePath = uriToFilePath(document.uri);
  465. try {
  466. return {
  467. data: computeSemanticTokens(document.getText(), filePath, path.dirname(filePath)),
  468. };
  469. } catch (error) {
  470. connection.console.error(`semantic tokens failed: ${error instanceof Error ? error.message : String(error)}`);
  471. return { data: [] };
  472. }
  473. });
  474. documents.listen(connection);
  475. connection.listen();