Преглед изворни кода

Phase 2 complete: find references and rename

Server gains referencesProvider and renameProvider (with prepare):
scope-aware reference search over open documents plus the defining
.def, declaration identity by file+range (shadowing-safe),
WorkspaceEdit renames across files.

Verified headlessly (locals, params, cross-file FROM-imports,
module names, END-name occurrences, includeDeclaration flag) and
live in the IDE: F2 rename updates declaration and use with zero
console errors.
Eric Streit пре 2 недеља
родитељ
комит
3df556009d
3 измењених фајлова са 199 додато и 5 уклоњено
  1. 2 1
      README.md
  2. 73 0
      extensions/modula2-language/src/m2/resolve.ts
  3. 124 4
      extensions/modula2-language/src/server.ts

+ 2 - 1
README.md

@@ -12,7 +12,8 @@ A starting point for a dedicated GNU Modula-2 IDE built on Eclipse Theia.
 - Modula-2 comments, brackets and indentation
 - Modula-2 snippets
 - Keyword-only automatic uppercasing
-- AST-backed document outline, completion, hover and go-to-definition
+- AST-backed document outline, completion, hover, go-to-definition,
+  find references and rename
   (lenient declaration parser with cross-file `.def` resolution)
 - GNU Modula-2 compiler service: async Build/Build File/Clean/Rebuild/Run
   plus compiler detection, driven by the workspace `modula2.json`

+ 73 - 0
extensions/modula2-language/src/m2/resolve.ts

@@ -6,6 +6,7 @@
 
 import * as fs from 'fs';
 import * as path from 'path';
+import { lex } from './lexer';
 import { M2Import, M2Symbol, M2Unit, parseUnitText, rangeContains } from './parser';
 
 export interface M2Location {
@@ -164,6 +165,20 @@ export function resolveName(
       if (sym) return { sym, filePath: def.filePath };
     }
   }
+  // Bare module name (IMPORT clause, qualifier position): bind to the module.
+  const modImp = unit.imports.find(i => i.module === name);
+  if (modImp) {
+    const def = loadDefModule(docDir, modImp.module);
+    return {
+      sym: {
+        name, kind: 'module', detail: `IMPORT ${name}`,
+        nameRange: { startLine: 0, startCh: 0, endLine: 0, endCh: 0 },
+        extent: { startLine: 0, startCh: 0, endLine: 0, endCh: 0 },
+        children: [],
+      },
+      filePath: def ? def.filePath : unit.filePath,
+    };
+  }
   return null;
 }
 
@@ -201,3 +216,61 @@ export function toLocation(filePath: string, sym: M2Symbol): M2Location {
     endLine: sym.nameRange.endLine, endCh: sym.nameRange.endCh,
   };
 }
+
+// ---- References & rename ----
+
+export interface Occurrence {
+  qualifier: string | null;
+  name: string;
+  line: number; ch: number; endLine: number; endCh: number;
+}
+
+export interface LocatedOccurrence extends Occurrence {
+  filePath: string;
+}
+
+/** Every identifier token in the text with its qualifier (`A` in `A.B`). */
+export function identifierOccurrences(text: string): Occurrence[] {
+  const tokens = lex(text);
+  const out: Occurrence[] = [];
+  for (let k = 0; k < tokens.length; k++) {
+    const t = tokens[k];
+    if (t.kind !== 'ident') continue;
+    const qualifier =
+      tokens[k - 1]?.text === '.' && tokens[k - 2]?.kind === 'ident' ? tokens[k - 2].text : null;
+    out.push({ qualifier, name: t.text, line: t.line, ch: t.ch, endLine: t.endLine, endCh: t.endCh });
+  }
+  return out;
+}
+
+function sameDecl(a: Resolved, b: Resolved): boolean {
+  // A declaration is uniquely identified by its file and name range.
+  return a.sym === b.sym ||
+    (a.filePath === b.filePath && sameRange(a.sym.nameRange, b.sym.nameRange));
+}
+
+/**
+ * All occurrences in `text` that resolve to `decl`.
+ * The declaration occurrence itself is included iff `includeDeclaration`.
+ */
+export function findReferencesInText(
+  text: string, filePath: string, docDir: string,
+  decl: Resolved, includeDeclaration = true,
+): LocatedOccurrence[] {
+  const unit = parseUnitText(text, filePath);
+  const out: LocatedOccurrence[] = [];
+  for (const occ of identifierOccurrences(text)) {
+    if (occ.name !== decl.sym.name) continue;
+    const resolved = resolveName(unit, docDir, occ.line, occ.ch, occ.qualifier, occ.name);
+    if (!resolved || !sameDecl(resolved, decl)) continue;
+    if (!includeDeclaration && filePath === decl.filePath &&
+      occ.line === decl.sym.nameRange.startLine && occ.ch === decl.sym.nameRange.startCh) continue;
+    out.push({ ...occ, filePath });
+  }
+  return out;
+}
+
+function sameRange(a: M2Symbol['nameRange'], b: M2Symbol['nameRange']): boolean {
+  return a.startLine === b.startLine && a.startCh === b.startCh &&
+    a.endLine === b.endLine && a.endCh === b.endCh;
+}

+ 124 - 4
extensions/modula2-language/src/server.ts

@@ -7,13 +7,14 @@ 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 { lex, Token } from './m2/lexer';
 import { M2Symbol, M2Unit, parseUnitText } from './m2/parser';
 import {
   moduleExports, recordFieldsOf, resolveName, toLocation, visibleSymbols,
+  findReferencesInText, LocatedOccurrence,
 } from './m2/resolve';
 
 const connection = createConnection(ProposedFeatures.all);
@@ -32,6 +33,8 @@ connection.onInitialize((params: InitializeParams): InitializeResult => {
       hoverProvider: true,
       definitionProvider: true,
       documentSymbolProvider: true,
+      referencesProvider: true,
+      renameProvider: { prepareProvider: true },
     },
   };
 });
@@ -188,9 +191,7 @@ function toDocumentSymbol(s: M2Symbol): DocumentSymbol {
       end: { line: s.nameRange.endLine, character: s.nameRange.endCh },
     },
     detail: s.detail,
-    children: s.children
-      .filter(c => c.kind === 'procedure' || c.kind === 'module' || c.kind === 'type')
-      .map(toDocumentSymbol),
+    children: s.children.map(toDocumentSymbol),
   };
 }
 
@@ -307,5 +308,124 @@ connection.onDefinition(params => {
   return location;
 });
 
+/** Resolve the declaration for a rename/references request, or null. */
+function resolveRequest(
+  document: TextDocument, line: number, character: number,
+): { unit: M2Unit; filePath: string; qualifier: string | null; name: string } | null {
+  const { unit, tokens } = parseDocument(document);
+  const offset = document.offsetAt({ line, character });
+  const found = nameAt(tokens, offset);
+  if (!found) return null;
+  return { unit, filePath: uriToFilePath(document.uri), qualifier: found.qualifier, name: found.name };
+}
+
+/** Reference occurrences across the open documents plus the declaration file. */
+function collectReferences(
+  documents: TextDocument[], currentUri: string,
+  unit: M2Unit, filePath: string, line: number, character: number,
+  qualifier: string | null, name: string,
+  includeDeclaration: boolean,
+): { uri: string; occurrences: LocatedOccurrence[] }[] {
+  const decl = resolveName(unit, path.dirname(filePath), 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 .def that is not open.
+  let extra: { uri: string; text: string } | null = null;
+  const declOpen = seen.has(pathToFileURL(decl.filePath).toString());
+  if (!declOpen) {
+    try {
+      extra = { uri: pathToFileURL(decl.filePath).toString(), text: readFileSync(decl.filePath, 'utf8') };
+    } catch {
+      extra = null;
+    }
+  }
+  void currentUri;
+  const out: { uri: string; occurrences: LocatedOccurrence[] }[] = [];
+  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(), document.uri, req.unit, req.filePath,
+    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 = resolveName(
+    req.unit, path.dirname(req.filePath),
+    params.position.line, params.position.character, req.qualifier, req.name,
+  );
+  if (!decl) return null;
+  const offset = document.offsetAt(params.position);
+  const { tokens } = parseDocument(document);
+  const token = tokenAt(tokens, offset);
+  if (!token || token.kind !== 'ident') 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(), document.uri, req.unit, req.filePath,
+    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();