Sfoglia il codice sorgente

WITH hover support for Modula-2 editors

WITH-aware name resolution (resolveNameWith) for hover,
go-to-definition, references and rename: bare field names, and
qualified heads like inner in inner.y, resolve to the guard record's
declarations innermost-first, with nested guards resolving through
outer blocks. Everything else falls back to resolveName, unchanged.
Reference search matches field declarations by position and no longer
lets a shadowing declaration leak in as another symbol's use; shared
identifier lexing removes a redundant pass.

Validation: headless resolution matrix (shadowing, nesting,
qualified heads, unknown, guard and out-of-block positions); live LSP
hover/definition/references exact; GUI screenshot with squiggle on
unknown field; 21-file corpora at zero errors.
Eric Streit 6 giorni fa
parent
commit
b5774f1eae

+ 8 - 7
PLAN.md

@@ -1,19 +1,20 @@
-# Plan — next steps (from v0.25.0)
+# Plan — next steps (from v0.26.0)
 
-Status: IDE complete through v0.25.0, distributables fresh at v0.24.0
+Status: IDE complete through v0.26.0, distributables fresh at v0.24.0
 (AppImage + deb in gitignored `apps/electron/dist/`). Tree clean except
 this file; all tags local.
 
 ## Done since the last plan
 
-- v0.25.0 builtin-type colors (semantic `type` tokens for predeclared
-  identifiers; user shadowing still wins, GUI-verified teal).
+- v0.26.0 WITH hover support (hover, go-to-definition, references and
+  rename for bare field names in `WITH` bodies, innermost guard first;
+  reference search matches field declarations by position without
+  cross-symbol leakage; GUI-verified with squiggle on unknown field).
 
 ## Next, in priority order
 
-1. **WITH hover support** (recommended) — hover and go-to-definition
-   for bare field names inside `WITH` bodies (checker side done in
-   v0.23.0).
+1. **WITH completion** — field-name completion inside `WITH` bodies
+   (hover/definition/references done in v0.26.0).
 
 ## Also pending (undecided)
 

+ 8 - 0
extensions/modula2-language/DIAGNOSTICS.md

@@ -164,6 +164,14 @@ passes with no mismatches, including shadowing, nesting, two-level
 access (`inner.y`), field guards (`WITH o.inner DO`), and fields named
 like builtins (`ORD`).
 
+Hover, go-to-definition, references and rename work through the same
+scopes (`resolveNameWith`): bare field names — and qualified heads like
+`inner` in `inner.y` — resolve to the guard record's declarations,
+innermost guard first, with nested guards resolving through outer
+blocks. Unknown names and unknowable guards stay silent, and reference
+search matches field declarations by position (they never resolve by
+name) without letting shadowing declarations leak across symbols.
+
 ## Circular dependencies (no use-before-declaration)
 gm2 is multi-pass, so declaration order never matters — there is
 deliberately no use-before-declaration check. What is checked instead is

+ 234 - 7
extensions/modula2-language/src/m2/resolve.ts

@@ -7,10 +7,13 @@
 
 import * as fs from 'fs';
 import * as path from 'path';
-import { lex } from './lexer';
+import { lex, Token } from './lexer';
 import { M2Import, M2Symbol, M2Unit, parseUnitText, rangeContains } from './parser';
 import { gm2LibraryDirs, projectSearchDirs } from './defpaths';
-import { findRecordField } from './types';
+import {
+  findFieldInType, findRecordField, resolveDeclaredType, resolveType, unitOfFile,
+} from './types';
+import type { M2Type } from './types';
 
 export interface M2Location {
   filePath: string;
@@ -293,6 +296,211 @@ export function resolveName(
   return null;
 }
 
+// ---- WITH scopes (bare field names in WITH bodies) ----
+
+const WITH_OPENERS = new Set([
+  'BEGIN', 'IF', 'CASE', 'LOOP', 'WHILE', 'FOR', 'WITH', 'RECORD', 'MODULE',
+]);
+
+interface WithBlock {
+  guardFrom: number;
+  guardTo: number;
+  bodyFromIdx: number;
+  endIdx: number;
+}
+
+/** WITH blocks from tokens (twin of analyse.withRegionsOf, keeping spans).
+ *
+ *  Guards are short designators, so the `DO` search stays bounded like
+ *  the analyser's; bodies nest with the usual opener counting. Anything
+ *  unparseable yields no block and stays silent downstream. */
+function withBlocksOf(tokens: Token[]): WithBlock[] {
+  const cached = withBlockCache.get(tokens);
+  if (cached) return cached;
+  const out: WithBlock[] = [];
+  for (let k = 0; k < tokens.length; k++) {
+    const t = tokens[k];
+    if (t.kind !== 'keyword' || t.text !== 'WITH') continue;
+    let doIdx = -1;
+    for (let j = k + 1; j < Math.min(k + 13, tokens.length); j++) {
+      const u = tokens[j];
+      if (u.kind === 'keyword' && u.text === 'DO') { doIdx = j; break; }
+      if ((u.kind === 'symbol' && u.text === ';') ||
+          (u.kind === 'keyword' && (u.text === 'BEGIN' || u.text === 'END'))) break;
+    }
+    if (doIdx < 0 || doIdx + 1 >= tokens.length) continue;
+    let depth = 1;
+    for (let m = doIdx + 1; m < tokens.length; m++) {
+      const u = tokens[m];
+      if (u.kind === 'keyword' && WITH_OPENERS.has(u.text)) depth++;
+      else if (u.kind === 'keyword' && u.text === 'END') {
+        depth--;
+        if (depth === 0) {
+          out.push({ guardFrom: k + 1, guardTo: doIdx - 1, bodyFromIdx: doIdx + 1, endIdx: m });
+          break;
+        }
+      }
+    }
+  }
+  withBlockCache.set(tokens, out);
+  return out;
+}
+
+const withBlockCache = new WeakMap<Token[], WithBlock[]>();
+
+function posInBody(b: WithBlock, tokens: Token[], line: number, ch: number): boolean {
+  const s = tokens[b.bodyFromIdx];
+  const e = tokens[b.endIdx];
+  if (!s || !e) return false;
+  const afterStart = line > s.line || (line === s.line && ch >= s.ch);
+  const beforeEnd = line < e.line || (line === e.line && ch < e.ch);
+  return afterStart && beforeEnd;
+}
+
+/** WITH-aware name resolution for hover/definition/references/rename.
+ *
+ *  Bare field names inside WITH bodies resolve to the guard record's
+ *  field declarations (innermost guard first, mirroring the checker's
+ *  scope stack); everything else falls back to `resolveName`, which is
+ *  left untouched. Qualified heads consult the WITH stack too, so
+ *  `inner.y` hovers `y`. Never throws. */
+export function resolveNameWith(
+  unit: M2Unit, docDir: string, tokens: Token[],
+  line: number, ch: number, qualifier: string | null, name: string,
+): Resolved | null {
+  if (qualifier) {
+    const head = resolveHeadWith(unit, docDir, tokens, line, ch, qualifier, 0);
+    if (head && (head.sym.kind === 'variable' || head.sym.kind === 'parameter' ||
+        head.sym.kind === 'constant' || head.sym.kind === 'field')) {
+      const host = head.filePath === unit.filePath ? unit : unitOfFile(head.filePath) ?? unit;
+      const dir = head.filePath === unit.filePath ? docDir : path.dirname(head.filePath);
+      const f = findRecordField(host, dir, head.sym, name, line, ch);
+      if (f) return { sym: f.sym, filePath: f.unit.filePath };
+    }
+    return resolveName(unit, docDir, line, ch, qualifier, name);
+  }
+  return resolveHeadWith(unit, docDir, tokens, line, ch, name, 0)
+    ?? resolveName(unit, docDir, line, ch, null, name);
+}
+
+/** A bare name through enclosing WITH guards (innermost first). */
+function resolveHeadWith(
+  unit: M2Unit, docDir: string, tokens: Token[],
+  line: number, ch: number, name: string, depth: number,
+): Resolved | null {
+  if (depth > 8) return null;
+  const blocks = withBlocksOf(tokens)
+    .filter(b => posInBody(b, tokens, line, ch))
+    .sort((a, b) => (a.endIdx - a.bodyFromIdx) - (b.endIdx - b.bodyFromIdx));
+  for (const b of blocks) {
+    const gt = guardRecord(unit, docDir, tokens, b, depth);
+    if (!gt) continue;
+    const f = findFieldInType(unit, docDir, gt, name, line, ch);
+    if (f) return { sym: f.sym, filePath: f.unit.filePath };
+  }
+  return null;
+}
+
+/** Record type denoted by a WITH guard, or null when unknowable.
+ *
+ *  Guards are restricted designators (`rec`, `p^`, `a[i]`, `r.f`);
+ *  gm2 rejects call results there, so anything else bails silently.
+ *  Nested guards resolve through outer blocks via `resolveHeadWith`. */
+function guardRecord(
+  unit: M2Unit, docDir: string, tokens: Token[], b: WithBlock, depth: number,
+): M2Type | null {
+  const ctx = { unit, docDir };
+  let k = b.guardFrom;
+  while (k <= b.guardTo && tokens[k].kind !== 'ident') k++;
+  if (k > b.guardTo) return null;
+  const ht = tokens[k];
+  const r = resolveHeadWith(unit, docDir, tokens, ht.line, ht.ch, ht.text, depth + 1)
+    ?? resolveName(unit, docDir, ht.line, ht.ch, null, ht.text);
+  if (!r) return null;
+  let t = declaredTypeOf(ctx, r);
+  if (!t) return null;
+  k++;
+  while (k <= b.guardTo) {
+    const tok = tokens[k];
+    if (tok.text === '^') {
+      t = derefType(ctx, t, tok.line, tok.ch);
+      if (!t) return null;
+      k++;
+    } else if (tok.text === '.') {
+      const nm = tokens[k + 1];
+      if (!nm || nm.kind !== 'ident') return null;
+      t = fieldTypeOf(ctx, t, nm.text, nm.line, nm.ch);
+      if (!t) return null;
+      k += 2;
+    } else if (tok.text === '[') {
+      const close = skipBalanced(tokens, k, '[', ']');
+      if (close < 0 || close > b.guardTo) return null;
+      t = elementTypeOf(ctx, t, tok.line, tok.ch);
+      if (!t) return null;
+      k = close + 1;
+    } else {
+      k++;
+    }
+  }
+  const base = t.kind === 'subrange' ? t.base : t;
+  return base.kind === 'record' ? t : null;
+}
+
+function declaredTypeOf(
+  ctx: { unit: M2Unit; docDir: string }, r: Resolved,
+): M2Type | null {
+  const k = r.sym.kind;
+  if (k !== 'variable' && k !== 'parameter' && k !== 'constant' && k !== 'field') return null;
+  const host = r.filePath === ctx.unit.filePath ? ctx.unit : unitOfFile(r.filePath);
+  if (!host) return null;
+  const dir = r.filePath === ctx.unit.filePath ? ctx.docDir : path.dirname(r.filePath);
+  const t = resolveDeclaredType(
+    r.sym, host, dir, r.sym.nameRange.startLine, r.sym.nameRange.startCh);
+  return t.kind === 'unknown' ? null : t;
+}
+
+function derefType(
+  ctx: { unit: M2Unit; docDir: string }, t: M2Type, line: number, ch: number,
+): M2Type | null {
+  const r = resolveType(t, ctx.unit, ctx.docDir, line, ch);
+  if (r.kind !== 'pointer') return null;
+  const tg = resolveType(r.target, ctx.unit, ctx.docDir, line, ch);
+  return tg.kind === 'unknown' ? null : tg;
+}
+
+function elementTypeOf(
+  ctx: { unit: M2Unit; docDir: string }, t: M2Type, line: number, ch: number,
+): M2Type | null {
+  const r = resolveType(t, ctx.unit, ctx.docDir, line, ch);
+  if (r.kind !== 'array') return null;
+  const el = resolveType(r.element, ctx.unit, ctx.docDir, line, ch);
+  return el.kind === 'unknown' ? null : el;
+}
+
+function fieldTypeOf(
+  ctx: { unit: M2Unit; docDir: string }, t: M2Type,
+  name: string, line: number, ch: number,
+): M2Type | null {
+  const r = resolveType(t, ctx.unit, ctx.docDir, line, ch);
+  if (r.kind !== 'record') return null;
+  const f = findFieldInType(ctx.unit, ctx.docDir, r, name, line, ch);
+  if (!f) return null;
+  const ft = resolveDeclaredType(f.sym, f.unit, f.dir, line, ch);
+  return ft.kind === 'unknown' ? null : ft;
+}
+
+function skipBalanced(tokens: Token[], from: number, open: string, close: string): number {
+  let depth = 0;
+  for (let k = from; k < tokens.length; k++) {
+    if (tokens[k].text === open) depth++;
+    else if (tokens[k].text === close) {
+      depth--;
+      if (depth === 0) return k;
+    }
+  }
+  return -1;
+}
+
 /** Exported symbols of a module (for `Module.` completion). */
 export function moduleExports(docDir: string, moduleName: string): M2Symbol[] {
   const def = loadDefModule(docDir, moduleName);
@@ -342,7 +550,11 @@ export interface LocatedOccurrence extends Occurrence {
 
 /** Every identifier token in the text with its qualifier (`A` in `A.B`). */
 export function identifierOccurrences(text: string): Occurrence[] {
-  const tokens = lex(text);
+  return identifierOccurrencesIn(lex(text));
+}
+
+/** Every identifier token in a pre-lexed stream with its qualifier (`A` in `A.B`). */
+export function identifierOccurrencesIn(tokens: Token[]): Occurrence[] {
   const out: Occurrence[] = [];
   for (let k = 0; k < tokens.length; k++) {
     const t = tokens[k];
@@ -369,13 +581,28 @@ export function findReferencesInText(
   decl: Resolved, includeDeclaration = true,
 ): LocatedOccurrence[] {
   const unit = parseUnitText(text, filePath);
+  const tokens = lex(text);
+  // Declaration sites belong to their own symbol: a shadowing
+  // declaration (e.g. a field) must not count as a use of the symbol
+  // it shadows, which scope resolution would otherwise return.
+  const declared = new Set<string>();
+  for (const { sym } of flattenUnit(unit)) {
+    if (sym.name) declared.add(`${sym.nameRange.startLine}:${sym.nameRange.startCh}`);
+  }
+  const declKey = `${decl.sym.nameRange.startLine}:${decl.sym.nameRange.startCh}`;
   const out: LocatedOccurrence[] = [];
-  for (const occ of identifierOccurrences(text)) {
+  for (const occ of identifierOccurrencesIn(tokens)) {
     if (occ.name !== decl.sym.name) continue;
-    const resolved = resolveName(unit, docDir, occ.line, occ.ch, occ.qualifier, occ.name);
+    const key = `${occ.line}:${occ.ch}`;
+    // The declaration occurrence itself (field declarations never
+    // resolve by name, so match it by position).
+    if (filePath === decl.filePath && key === declKey) {
+      if (includeDeclaration) out.push({ ...occ, filePath });
+      continue;
+    }
+    if (declared.has(key)) continue;
+    const resolved = resolveNameWith(unit, docDir, tokens, 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;

+ 5 - 3
extensions/modula2-language/src/m2/semantic.ts

@@ -8,7 +8,8 @@
  */
 
 import { M2Symbol, M2Unit, parseUnitText } from './parser';
-import { flattenUnit, identifierOccurrences, resolveName } from './resolve';
+import { flattenUnit, identifierOccurrencesIn, resolveNameWith } from './resolve';
+import { lex } from './lexer';
 import { TYPE_BUILTINS } from './checker';
 
 export const SEMANTIC_TOKEN_TYPES = [
@@ -38,6 +39,7 @@ interface RawToken {
 /** LSP delta-encoded token data for a document. Never throws on broken input. */
 export function computeSemanticTokens(text: string, filePath: string, docDir: string): number[] {
   const unit = parseUnitText(text, filePath);
+  const tokens = lex(text);
   const out: RawToken[] = [];
   const declared = new Set<string>();
   for (const { sym, chain } of flattenUnit(unit)) {
@@ -55,9 +57,9 @@ export function computeSemanticTokens(text: string, filePath: string, docDir: st
       modifiers: mapped.modifiers,
     });
   }
-  for (const occ of identifierOccurrences(text)) {
+  for (const occ of identifierOccurrencesIn(tokens)) {
     if (declared.has(`${occ.line}:${occ.ch}`)) continue;
-    const resolved = resolveName(unit, docDir, occ.line, occ.ch, occ.qualifier, occ.name);
+    const resolved = resolveNameWith(unit, docDir, tokens, occ.line, occ.ch, occ.qualifier, occ.name);
     if (resolved) {
       const kind = useTokenKind(resolved.sym);
       if (kind === null) continue;

+ 11 - 11
extensions/modula2-language/src/server.ts

@@ -16,7 +16,7 @@ import { analyseUnit } from './m2/analyse';
 import { computeSemanticTokens, semanticTokensLegend } from './m2/semantic';
 import { DEFAULT_FORMAT_OPTIONS, FormatOptions, formatDocument, formatRangeEdits, normalizeFormatOptions } from './m2/format';
 import {
-  moduleExports, recordFieldsOf, resolveName, toLocation, visibleSymbols,
+  moduleExports, recordFieldsOf, resolveName, resolveNameWith, toLocation, visibleSymbols,
   findReferencesInText, LocatedOccurrence,
 } from './m2/resolve';
 
@@ -308,7 +308,7 @@ connection.onHover(params => {
   const found = nameAt(tokens, offset);
   if (!found) return null;
   const docDir = path.dirname(uriToFilePath(document.uri));
-  const resolved = resolveName(unit, docDir, params.position.line, params.position.character, found.qualifier, found.name);
+  const resolved = resolveNameWith(unit, docDir, tokens, params.position.line, params.position.character, found.qualifier, found.name);
   if (!resolved) return null;
   const hover: Hover = {
     contents: {
@@ -332,7 +332,7 @@ connection.onDefinition(params => {
   const found = nameAt(tokens, offset);
   if (!found) return null;
   const filePath = uriToFilePath(document.uri);
-  const resolved = resolveName(unit, path.dirname(filePath), params.position.line, params.position.character, found.qualifier, found.name);
+  const resolved = resolveNameWith(unit, path.dirname(filePath), tokens, params.position.line, params.position.character, found.qualifier, found.name);
   if (!resolved) return null;
   const loc = toLocation(resolved.filePath, resolved.sym);
   const targetUri = resolved.filePath === filePath ? document.uri : pathToFileURL(resolved.filePath).toString();
@@ -349,22 +349,22 @@ connection.onDefinition(params => {
 /** 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 {
+): { unit: M2Unit; tokens: Token[]; 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 };
+  return { unit, tokens, 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,
+  unit: M2Unit, tokens: Token[], 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);
+  const decl = resolveNameWith(unit, path.dirname(filePath), tokens, line, character, qualifier, name);
   if (!decl) return [];
   const seen = new Map<string, TextDocument>();
   for (const doc of documents) {
@@ -405,7 +405,7 @@ connection.onReferences(params => {
   if (!req) return [];
   const locations: Location[] = [];
   for (const { uri, occurrences } of collectReferences(
-    documents.all(), document.uri, req.unit, req.filePath,
+    documents.all(), document.uri, req.unit, req.tokens, req.filePath,
     params.position.line, params.position.character,
     req.qualifier, req.name, params.context.includeDeclaration,
   )) {
@@ -427,8 +427,8 @@ connection.onPrepareRename(params => {
   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),
+  const decl = resolveNameWith(
+    req.unit, path.dirname(req.filePath), req.tokens,
     params.position.line, params.position.character, req.qualifier, req.name,
   );
   if (!decl) return null;
@@ -449,7 +449,7 @@ connection.onRenameRequest(params => {
   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,
+    documents.all(), document.uri, req.unit, req.tokens, req.filePath,
     params.position.line, params.position.character,
     req.qualifier, req.name, true,
   )) {