Просмотр исходного кода

Definition search paths for go-to-definition and friends

resolveName/loadDefModule used to look for .def files only next to the
open file, so go-to-definition (and hover, completion, references)
silently failed for every import living elsewhere. New defpaths module:
document directory, then definitionDirectories/sourceDirectories/
libraryDirectories from the nearest modula2.json, then gm2's own
library sources (m2iso first, matching the default -fiso build; a
-fpim/-flog/-fmin compiler flag promotes its dialect). ownDefUnit keeps
sibling-only semantics. Best-effort throughout, results cached.

Widening the search exposed and fixed, all probed against gm2 16.0.1:

- definition-level EXPORT lists are captured (foreign FOR "C"
  re-exports, gm2's SYSTEM.def list);
- DEFINITION MODULE FOR "C" name parses (name after the tag);
- lone __BUILTIN__ procedure attributes parse;
- qualified formal types keep their dot (ARRAY OF SYSTEM.LOC);
- open ARRAY OF LOC/BYTE/WORD is a raw-memory formal taking any
  actual; ADDRESS takes NIL and mixes with pointers both ways;
- field lookup falls back to the stamped declaration identity.

Validation: 178-case builtin differential still zero mismatches;
21 real files (m2compiler-V3, m2-sunrise, hello) at zero errors;
live LSP definition jumps across directories and into gm2 libs.
Eric Streit 1 неделя назад
Родитель
Сommit
6607572ad6

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

@@ -80,6 +80,33 @@ issues on all six. Getting there exposed and fixed:
   procedure variables (calls and `P := Q` assignment), the pervasive
   `LENGTH`/`ORDL`, arithmetic that preserves `CARDINAL` (`c + 1`).
 
+## Definition search paths
+
+Go-to-definition (and hover, completion, references) used to look for
+`.def` files only next to the open file, silently failing for every
+other import. Modules are now searched in order: the importing file's
+directory, the project's `definitionDirectories`/`sourceDirectories`/
+`libraryDirectories` from the nearest `modula2.json`, then gm2's own
+library sources (`m2iso` first, matching the default `-fiso` build; a
+`-fpim`/`-flog`/`-fmin` compiler flag promotes its dialect). Widening
+the search exposed and fixed, all probed against gm2:
+
+- definition-level `EXPORT` lists are captured, so re-exported foreign
+  functions (`EXPORT UNQUALIFIED` in `FOR "C"` libraries) and gm2's own
+  `SYSTEM.def` list count as exported;
+- `DEFINITION MODULE FOR "C" name` (name after the language tag) parses;
+- lone `__BUILTIN__` procedure attributes (`PROCEDURE __BUILTIN__
+  sin ...`) parse;
+- qualified formal types keep their dot (`ARRAY OF SYSTEM.LOC`, which
+  the parameter parser used to record as `SYSTEM LOC`);
+- an open `ARRAY OF LOC`/`BYTE`/`WORD` is a raw-memory formal taking
+  any actual (`RawIO.Read` into a `CHAR`);
+- `ADDRESS` takes `NIL` and mixes with pointers both ways, including
+  `= NIL` comparisons;
+- field lookup falls back to the stamped declaration identity, reaching
+  types that are not imported under their own name (`tm` behind
+  `ptrToTM = POINTER TO tm`).
+
 ## Record-field types
 
 Record declarations capture each field's type, so `r.f`, `r.a.b`,

+ 2 - 2
extensions/modula2-language/modula2-schema.json

@@ -48,12 +48,12 @@
     "definitionDirectories": {
       "type": "array",
       "items": { "type": "string" },
-      "description": "Reserved: definition-module folders for future project indexing."
+      "description": "Definition-module folders searched by language services (go-to-definition, hover, completion) after the importing file's own directory."
     },
     "libraryDirectories": {
       "type": "array",
       "items": { "type": "string" },
-      "description": "Reserved: library folders for future project indexing."
+      "description": "Third-party library folders searched by language services after the source folders."
     }
   },
   "additionalProperties": false

+ 9 - 3
extensions/modula2-language/src/m2/analyse.ts

@@ -19,7 +19,7 @@
 
 import { lex, Token } from './lexer';
 import { M2Symbol, M2Unit, parseUnitText, rangeContains } from './parser';
-import { flattenUnit, loadDefModule, resolveName, topLevel } from './resolve';
+import { flattenUnit, defExportList, loadDefModule, resolveName, topLevel } from './resolve';
 import { checkBodies } from './checker';
 import { M2Type, parseTypeText } from './types';
 
@@ -139,15 +139,21 @@ function unknownIdentifiers(tokens: Token[], unit: M2Unit, docDir: string): M2Is
   return issues;
 }
 
-/** FROM-imported names missing from a resolvable `.def` (silent when unavailable). */
+/** FROM-imported names missing from a resolvable `.def` (silent when unavailable).
+ *
+ *  A name also counts as exported when the `.def` lists it in `EXPORT`
+ *  (re-exported C functions have no declaration) or when it is a
+ *  pervasive builtin imported through `SYSTEM` (gm2 accepts those even
+ *  when its own `SYSTEM.def` pseudo-declaration will not parse). */
 function fromMembership(unit: M2Unit, docDir: string): M2Issue[] {
   const issues: M2Issue[] = [];
   for (const imp of unit.imports) {
     if (imp.all || imp.names.length === 0) continue;
     const def = loadDefModule(docDir, imp.module);
     if (!def) continue;
+    const listed = defExportList(def);
     for (const n of imp.names) {
-      if (!topLevel(def, n)) {
+      if (!topLevel(def, n) && !listed.includes(n)) {
         issues.push({
           line: imp.range.startLine, ch: imp.range.startCh,
           endLine: imp.range.endLine, endCh: imp.range.endCh,

+ 13 - 1
extensions/modula2-language/src/m2/checker.ts

@@ -717,7 +717,8 @@ class Walker {
     for (let k = 0; k < Math.min(args.length, params.length); k++) {
       const p = params[k];
       const pt = this.paramType(p);
-      if (pt.kind !== 'unknown' && args[k].kind !== 'unknown' && !isArrayArgCompatible(pt, args[k])) {
+      if (pt.kind !== 'unknown' && args[k].kind !== 'unknown' &&
+          !isArrayArgCompatible(pt, args[k]) && !isRawMemoryFormal(p, pt)) {
         this.issue(tok, `Argument ${k + 1} of "${info.sym.name}": cannot assign ${displayType(args[k])} to ${displayType(pt)}`);
       }
     }
@@ -1185,6 +1186,17 @@ function nameOf(info: HeadInfo, tok: Token): string {
   return tok.text;
 }
 
+/** gm2's raw-memory formals (`ARRAY OF SYSTEM.LOC/BYTE/WORD`, whose
+ *  element stays unknown because gm2 keeps those types internal): accept
+ *  any actual. Matched on the declaration text, since resolution erases
+ *  the element name. */
+function isRawMemoryFormal(p: M2Symbol, pt: M2Type): boolean {
+  if (pt.kind !== 'array' || pt.size !== null) return false;
+  const el = pt.element.kind === 'subrange' ? pt.element.base : pt.element;
+  if (el.kind !== 'unknown') return false;
+  return /\bARRAY\s+OF\s+(SYSTEM\s*\.?\s*)?(LOC|BYTE|WORD)\b/.test(p.detail);
+}
+
 /** Base type through any nesting of subranges. */
 function deepBase(t: M2Type): M2Type {
   let b = t;

+ 185 - 0
extensions/modula2-language/src/m2/defpaths.ts

@@ -0,0 +1,185 @@
+/** Search paths for definition modules (`.def`).
+ *
+ *  `loadDefModule` used to look only in the open file's directory, so
+ *  go-to-definition (and hover, completion, references) silently failed
+ *  for every import whose `.def` lived anywhere else — other project
+ *  folders or gm2's own libraries (`InOut`, `TextIO`, ...). The search
+ *  order is now: the document's directory, the project's
+ *  `definitionDirectories`/`sourceDirectories`/`libraryDirectories`
+ *  from the nearest `modula2.json`, then gm2's library sources (`m2iso` first, matching
+ *  the default `-fiso` build; a `-fpim`/`-flog`/`-fmin` flag in the
+ *  project's compiler options promotes its dialect instead).
+ *
+ *  Best-effort throughout: missing files, unparsable configs and gm2
+ *  probe failures shrink the list to what is known, never throw.
+ */
+
+import { execFileSync } from 'child_process';
+import * as fs from 'fs';
+import * as path from 'path';
+
+const PROJECT_FILE = 'modula2.json';
+const MAX_UP = 8;
+
+/** Dialect library folders in default search order (ISO first). */
+const DIALECTS = ['m2iso', 'm2pim', 'm2log', 'm2min', 'm2cor'];
+
+interface ProjectEntry {
+  file: string;
+  mtimeMs: number;
+  dirs: string[];
+  dialectFirst: string | null;
+}
+
+const projectCache = new Map<string, ProjectEntry>();
+
+let gm2Cache: { at: number; key: string; dirs: string[] } | null = null;
+const GM2_TTL_MS = 5 * 60 * 1000;
+
+/** Extra `.def` search directories for a document (project folders). */
+export function projectSearchDirs(docDir: string): string[] {
+  const found = findProjectFile(docDir);
+  if (!found) return [];
+  try {
+    const mtimeMs = fs.statSync(found).mtimeMs;
+    const hit = projectCache.get(docDir);
+    if (hit && hit.file === found && hit.mtimeMs === mtimeMs) return hit.dirs;
+    const { dirs, dialectFirst } = readProjectDirs(found);
+    projectCache.set(docDir, { file: found, mtimeMs, dirs, dialectFirst });
+    return dirs;
+  } catch {
+    return [];
+  }
+}
+
+/** Preferred gm2 dialect folder for a document (`m2iso` default). */
+export function preferredDialect(docDir: string): string | null {
+  const found = findProjectFile(docDir);
+  if (!found) return null;
+  try {
+    const mtimeMs = fs.statSync(found).mtimeMs;
+    const hit = projectCache.get(docDir);
+    if (hit && hit.file === found && hit.mtimeMs === mtimeMs) return hit.dialectFirst;
+    const { dirs, dialectFirst } = readProjectDirs(found);
+    projectCache.set(docDir, { file: found, mtimeMs, dirs, dialectFirst });
+    return dialectFirst;
+  } catch {
+    return null;
+  }
+}
+
+/** gm2 library source folders (`.../m2/m2iso`, ...), cached for 5 minutes. */
+export function gm2LibraryDirs(docDir: string): string[] {
+  const now = Date.now();
+  const key = `${docDir}`;
+  if (gm2Cache && now - gm2Cache.at < GM2_TTL_MS && gm2Cache.key === key) return gm2Cache.dirs;
+  const dirs = probeGm2Libs(docDir);
+  gm2Cache = { at: now, key, dirs };
+  return dirs;
+}
+
+/** Nearest `modula2.json` walking up from `startDir`, or null. */
+function findProjectFile(startDir: string): string | null {
+  let dir = path.resolve(startDir);
+  for (let k = 0; k < MAX_UP; k++) {
+    const candidate = path.join(dir, PROJECT_FILE);
+    try {
+      if (fs.statSync(candidate).isFile()) return candidate;
+    } catch {
+      // absent: keep climbing
+    }
+    const parent = path.dirname(dir);
+    if (parent === dir) return null;
+    dir = parent;
+  }
+  return null;
+}
+
+function readProjectDirs(file: string): { dirs: string[]; dialectFirst: string | null } {
+  const root = path.dirname(file);
+  let json: {
+    sourceDirectories?: unknown; definitionDirectories?: unknown;
+    libraryDirectories?: unknown;
+    compiler?: { path?: unknown; options?: unknown };
+  };
+  try {
+    json = JSON.parse(fs.readFileSync(file, 'utf8'));
+  } catch {
+    return { dirs: [], dialectFirst: null };
+  }
+  const dirs: string[] = [];
+  for (const key of ['definitionDirectories', 'sourceDirectories', 'libraryDirectories'] as const) {
+    const raw = json[key];
+    if (!Array.isArray(raw)) continue;
+    for (const entry of raw) {
+      if (typeof entry !== 'string' || !entry) continue;
+      const abs = path.resolve(root, entry);
+      try {
+        if (!fs.statSync(abs).isDirectory()) continue;
+      } catch {
+        continue;
+      }
+      if (!dirs.includes(abs)) dirs.push(abs);
+    }
+  }
+  return { dirs, dialectFirst: dialectFromOptions(json.compiler?.options) };
+}
+
+/** `m2pim` for `-fpim*`, `m2log` for `-flog`, ...; null keeps the default. */
+function dialectFromOptions(options: unknown): string | null {
+  if (!Array.isArray(options)) return null;
+  for (const opt of options) {
+    if (typeof opt !== 'string') continue;
+    const m = /^-f(pim\d*|iso|log|min)\b/.exec(opt.trim());
+    if (m) return 'm2' + m[1].replace(/\d+$/, '');
+  }
+  return null;
+}
+
+function compilerBinary(docDir: string): string {
+  const found = findProjectFile(docDir);
+  if (found) {
+    try {
+      const json = JSON.parse(fs.readFileSync(found, 'utf8')) as {
+        compiler?: { path?: unknown };
+      };
+      const p = json.compiler?.path;
+      if (typeof p === 'string' && p) return p;
+    } catch {
+      // fall through to PATH lookup
+    }
+  }
+  return 'gm2';
+}
+
+function probeGm2Libs(docDir: string): string[] {
+  let base: string;
+  try {
+    const out = execFileSync(compilerBinary(docDir), ['-print-search-dirs'], {
+      timeout: 15000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
+    });
+    const line = out.split('\n').map(l => l.trim()).find(l => /^install(és)?:/.test(l));
+    if (!line) return [];
+    const install = line.slice(line.indexOf(':') + 1).trim().replace(/\/+$/, '');
+    if (!install) return [];
+    base = path.join(install, 'm2');
+  } catch {
+    return [];
+  }
+  const order = orderedDialects(preferredDialect(docDir));
+  const out: string[] = [];
+  for (const d of order) {
+    const abs = path.join(base, d);
+    try {
+      if (fs.statSync(abs).isDirectory()) out.push(abs);
+    } catch {
+      // dialect not installed: skip
+    }
+  }
+  return out;
+}
+
+function orderedDialects(first: string | null): string[] {
+  if (!first || !DIALECTS.includes(first)) return [...DIALECTS];
+  return [first, ...DIALECTS.filter(d => d !== first)];
+}

+ 31 - 11
extensions/modula2-language/src/m2/parser.ts

@@ -125,10 +125,15 @@ class Parser {
       const nm = this.expectIdent();
       if (nm) { moduleName = nm.text; startTok = nm; }
     }
-    // FOR "..." in definition modules, priority [..], then ';'
+    // FOR "..." in definition modules (`MODULE M FOR "C"` or gm2's
+    // `MODULE FOR "C" M`), priority [..], then ';'
     if (this.atKw('FOR')) {
       this.next();
       if (this.peek().kind === 'string') this.next();
+      if (!moduleName) {
+        const nm = this.expectIdent();
+        if (nm) { moduleName = nm.text; startTok = nm; }
+      }
     }
     if (this.atSym('[')) this.skipBalanced('[', ']');
     this.eatSym(';');
@@ -145,7 +150,15 @@ class Parser {
       this.parseBlock(modSym, unit, true);
       this.eatSym('.');
     } else {
-      if (this.atKw('EXPORT')) this.skipToSym(';');
+      // A definition module's EXPORT list (QUALIFIED or UNQUALIFIED) names
+      // its importable interface — including foreign `FOR "C"` modules
+      // whose members re-export C functions with no Modula-2 declaration.
+      if (this.atKw('EXPORT')) {
+        this.next();
+        if (this.atKw('QUALIFIED') || this.atKw('UNQUALIFIED')) this.next();
+        modSym.exports = this.parseIdentList();
+        this.eatSym(';');
+      }
       this.parseDeclarations(modSym, unit, true);
     }
     // Module extent runs to end of file.
@@ -437,15 +450,16 @@ class Parser {
   private parseProcedure(unit: M2Unit, withBody = true): M2Symbol | null {
     const kw = this.peek();
     this.next(); // PROCEDURE
-    // GNU builtin attributes: __ATTRIBUTE__ __BUILTIN__ (( ident )) | __INLINE__
-    if (this.peek().text === '__ATTRIBUTE__') {
-      this.next();
-      if (this.peek().text === '__BUILTIN__') {
+    // GNU attributes before the name: __ATTRIBUTE__ ... | __BUILTIN__ ...
+    // | __INLINE__ (gm2's own .defs write `PROCEDURE __BUILTIN__ sin ...`).
+    for (;;) {
+      const a = this.peek();
+      if (a && (a.text === '__ATTRIBUTE__' || a.text === '__BUILTIN__' || a.text === '__INLINE__')) {
         this.next();
         if (this.atSym('(')) this.skipBalanced('(', ')');
+        continue;
       }
-    } else if (this.peek().text === '__INLINE__') {
-      this.next();
+      break;
     }
     const nm = this.expectIdent();
     if (!nm) return null;
@@ -553,14 +567,20 @@ class Parser {
     }
     let dtype = '';
     if (this.eatSym(':')) {
-      // FormalType: { ARRAY OF } Qualident
+      // FormalType: { ARRAY OF } Qualident (the qualifier dot is
+      // significant: `ARRAY OF SYSTEM.LOC` is not `SYSTEM LOC`).
+      let prefix = '';
+      while (this.atKw('ARRAY')) {
+        prefix += 'ARRAY OF ';
+        this.next();
+        this.eatKw('OF');
+      }
       const parts: string[] = [];
-      while (this.atKw('ARRAY')) { parts.push('ARRAY'); this.next(); this.eatKw('OF'); parts.push('OF'); }
       while (this.peek().kind === 'ident') {
         parts.push(this.next().text);
         if (!this.eatSym('.')) break;
       }
-      dtype = parts.join(' ');
+      dtype = (prefix + parts.join('.')).trim();
     }
     this.skipAlignment();
     for (const n of names) {

+ 38 - 4
extensions/modula2-language/src/m2/resolve.ts

@@ -1,13 +1,15 @@
 /** Name resolution over parsed Modula-2 units (Phase 2).
  *
  *  Provides scope-aware lookup for hover/definition/completion, including
- *  cross-file resolution of imported definition modules (`X.def` next to
- *  the importing file). Best-effort: unresolved names yield null/empty. */
+ *  cross-file resolution of imported definition modules (sibling `.def`,
+ *  project folders, gm2 libraries — see `defpaths`). Best-effort:
+ *  unresolved names yield null/empty. */
 
 import * as fs from 'fs';
 import * as path from 'path';
 import { lex } from './lexer';
 import { M2Import, M2Symbol, M2Unit, parseUnitText, rangeContains } from './parser';
+import { gm2LibraryDirs, projectSearchDirs } from './defpaths';
 import { findRecordField } from './types';
 
 export interface M2Location {
@@ -119,7 +121,8 @@ interface CacheEntry { mtimeMs: number; unit: M2Unit; }
 
 const defCache = new Map<string, CacheEntry>();
 
-export function loadDefModule(dir: string, moduleName: string): M2Unit | null {
+/** Load `dir/Module.def` only — the sibling semantics `ownDefUnit` needs. */
+export function loadSiblingDef(dir: string, moduleName: string): M2Unit | null {
   const filePath = path.join(dir, `${moduleName}.def`);
   try {
     const st = fs.statSync(filePath);
@@ -134,6 +137,22 @@ export function loadDefModule(dir: string, moduleName: string): M2Unit | null {
   }
 }
 
+/** Load a definition module by search order: the importing file's
+ *  directory, the project's `definitionDirectories`/`sourceDirectories`,
+ *  then gm2's own library sources. First hit wins. */
+export function loadDefModule(dir: string, moduleName: string): M2Unit | null {
+  const seen = new Set<string>();
+  const dirs = [dir, ...projectSearchDirs(dir), ...gm2LibraryDirs(dir)];
+  for (const d of dirs) {
+    const key = path.resolve(d);
+    if (seen.has(key)) continue;
+    seen.add(key);
+    const unit = loadSiblingDef(d, moduleName);
+    if (unit) return unit;
+  }
+  return null;
+}
+
 export function topLevel(unit: M2Unit, name: string): M2Symbol | null {
   for (const s of unit.symbols) {
     for (const c of s.children) {
@@ -144,6 +163,21 @@ export function topLevel(unit: M2Unit, name: string): M2Symbol | null {
   return null;
 }
 
+/** Names a definition module lists in `EXPORT` (qualified or not).
+ *
+ *  Covers re-exported foreign functions (`EXPORT UNQUALIFIED` in
+ *  `DEFINITION MODULE FOR "C"` libraries) and gm2's own pseudo-
+ *  declarations (`PROCEDURE TSIZE (<type>; ...)`) that have no
+ *  parseable declaration to return. Membership only: there may be no
+ *  symbol to jump to. */
+export function defExportList(def: M2Unit): string[] {
+  const out: string[] = [];
+  for (const s of def.symbols) {
+    if (s.kind === 'module' && s.exports) out.push(...s.exports);
+  }
+  return out;
+}
+
 /** Find a name among scope children, including enumeration literals
  *  (constants declared inside a TYPE's `(A, B, ...)` list). */
 function findInScope(children: M2Symbol[], name: string): M2Symbol | null {
@@ -166,7 +200,7 @@ function findInScope(children: M2Symbol[], name: string): M2Symbol | null {
  *  `.def` exists. Never returns the unit itself. */
 export function ownDefUnit(unit: M2Unit): M2Unit | null {
   if (unit.definition || !unit.moduleName) return null;
-  const def = loadDefModule(path.dirname(unit.filePath), unit.moduleName);
+  const def = loadSiblingDef(path.dirname(unit.filePath), unit.moduleName);
   if (!def || def.filePath === unit.filePath) return null;
   return def;
 }

+ 70 - 9
extensions/modula2-language/src/m2/types.ts

@@ -407,6 +407,11 @@ export function isAssignable(to: M2Type, from: M2Type): boolean {
     return normKey(t) === normKey(f);
   }
   if (t.kind === 'pointer' && f.kind === 'nil') return true;
+  // gm2 lets ADDRESS take NIL and mix with pointers in both directions
+  // (`q := NIL`, `p := q`, `q := p` all compile with -fiso).
+  if (t.kind === 'whole' && t.id === 'ADDRESS' && f.kind === 'nil') return true;
+  if (t.kind === 'pointer' && f.kind === 'whole' && f.id === 'ADDRESS') return true;
+  if (t.kind === 'whole' && t.id === 'ADDRESS' && f.kind === 'pointer') return true;
   if (t.kind === 'pointer' && f.kind === 'pointer') return typeKey(t.target) === typeKey(f.target);
   if (t.kind === 'proc' && f.kind === 'nil') return true;
   if (t.kind === 'proc' && f.kind === 'proc') return true;
@@ -424,11 +429,24 @@ export function isAssignable(to: M2Type, from: M2Type): boolean {
   return false;
 }
 
-/** Open-array formal accepts a fixed array with compatible elements. */
+/** Open-array formal accepts a fixed array with compatible elements.
+ *
+ *  Calibrated against gm2 (`-fiso`): an open `ARRAY OF LOC`/`BYTE`/`WORD`
+ *  is a raw-memory formal and additionally takes any actual (e.g.
+ *  `RawIO.Read` into a `CHAR` variable); other element types still
+ *  require an array actual. The element may be unresolved — gm2's own
+ *  `SYSTEM.def` keeps those types internal, so `SYSTEM.LOC` often stays
+ *  a qualified name — hence the name-based fallback. */
+const OCTET_ELEMENTS = new Set(['LOC', 'BYTE', 'WORD']);
 export function isArrayArgCompatible(param: M2Type, arg: M2Type): boolean {
   if (param.kind === 'array' && param.size === null && arg.kind === 'array') {
     return isAssignable(param.element, arg.element);
   }
+  if (param.kind === 'array' && param.size === null && arg.kind !== 'unknown') {
+    const el = param.element.kind === 'subrange' ? param.element.base : param.element;
+    if (el.kind === 'whole' && OCTET_ELEMENTS.has(el.id)) return true;
+    if (el.kind === 'named' && OCTET_ELEMENTS.has(el.name)) return true;
+  }
   return isAssignable(param, arg);
 }
 
@@ -465,8 +483,11 @@ export function isComparable(a: M2Type, b: M2Type, ordering: boolean): boolean {
     if (ordering) return false;
     return typeKey(ta.target) === typeKey(tb.target);
   }
-  if (ta.kind === 'pointer' && tb.kind === 'nil') return !ordering;
-  if (tb.kind === 'pointer' && ta.kind === 'nil') return !ordering;
+  // ADDRESS compares like a pointer (probed: `q = NIL` compiles).
+  const addrLike = (t: M2Type): boolean =>
+    t.kind === 'pointer' || (t.kind === 'whole' && t.id === 'ADDRESS');
+  if (addrLike(ta) && tb.kind === 'nil') return !ordering;
+  if (addrLike(tb) && ta.kind === 'nil') return !ordering;
   // Arrays and records are never comparable (confirmed against gm2).
   return false;
 }
@@ -665,14 +686,17 @@ export function findFieldInType(
   }
   activeFieldLookups.add(key);
   try {
-    const dot = t.name.lastIndexOf('.');
-    const qualifier = dot >= 0 ? t.name.slice(0, dot) : null;
-    const typeName = dot >= 0 ? t.name.slice(dot + 1) : t.name;
-    const r = resolveName(hostUnit, hostDir, line, ch, qualifier, typeName);
-    if (!r || r.sym.kind !== 'type') return null;
+    // Preferred: the declaration identity stamped at resolution time.
+    // It reaches types that are not visible under their own name — e.g.
+    // `tm` behind an imported `ptrToTM = POINTER TO tm` (foreign `libc`).
+    const viaDecl = t.decl ? findTypeByDecl(hostUnit, t.decl) : null;
+    const r = viaDecl ?? resolveByTypeName(hostUnit, hostDir, line, ch, t.name);
+    if (!r) return null;
     const declUnit = r.filePath === hostUnit.filePath ? hostUnit : unitOfFile(r.filePath);
     if (!declUnit) return null;
-    const f = r.sym.children.find(c => c.name === fieldName);
+    const typeSym = viaDecl ? viaDecl.sym : r.sym.kind === 'type' ? r.sym : null;
+    if (!typeSym) return null;
+    const f = typeSym.children.find(c => c.name === fieldName);
     if (!f) return null;
     return {
       sym: f, unit: declUnit,
@@ -684,6 +708,43 @@ export function findFieldInType(
   }
 }
 
+/** Locate a type symbol through a stamped declaration identity
+ *  (`filePath:line:ch` as built by `attachName`). */
+function findTypeByDecl(
+  hostUnit: M2Unit, decl: string,
+): { sym: M2Symbol; filePath: string } | null {
+  const m = /^(.*):(\d+):(\d+)$/.exec(decl);
+  if (!m) return null;
+  const [, fp, ls, cs] = m;
+  const line = Number(ls);
+  const ch = Number(cs);
+  const unit = fp === hostUnit.filePath ? hostUnit : unitOfFile(fp);
+  if (!unit) return null;
+  const sym = findSymbolAt(unit.symbols, line, ch);
+  if (!sym || sym.kind !== 'type') return null;
+  return { sym, filePath: fp };
+}
+
+function findSymbolAt(syms: M2Symbol[], line: number, ch: number): M2Symbol | null {
+  for (const s of syms) {
+    if (s.nameRange.startLine === line && s.nameRange.startCh === ch) return s;
+    const inner = findSymbolAt(s.children, line, ch);
+    if (inner) return inner;
+  }
+  return null;
+}
+
+function resolveByTypeName(
+  hostUnit: M2Unit, hostDir: string, line: number, ch: number, name: string,
+): { sym: M2Symbol; filePath: string } | null {
+  const dot = name.lastIndexOf('.');
+  const qualifier = dot >= 0 ? name.slice(0, dot) : null;
+  const typeName = dot >= 0 ? name.slice(dot + 1) : name;
+  const r = resolveName(hostUnit, hostDir, line, ch, qualifier, typeName);
+  if (!r || r.sym.kind !== 'type') return null;
+  return r;
+}
+
 /** Resolve an imported qualified name (`M.X`) to its declaration for typing. */
 export function resolveQualified(
   unit: M2Unit, docDir: string, module: string, name: string,