mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
fix(zig): close the adversarial review's ten findings (8.2–8.12)
PR #1432 review 5095267917 on 34c53473 retained eight P1 and two P2
findings; each is reproduced on the new `zig-chains` / `zig-buildmodules`
fixtures with the decoy that made the old answer wrong, and pinned by a
test named after its number.
- 8.2 per-build-module import tables (`parseZigBuildModules`): a source
resolves a bare name through its own module's `addImport` table (root
file, else deepest root directory), fails closed when same-directory
modules disagree, and follows `addImport("api", dep.module("core"))`
through the dep's `addModule`; repo-wide names and zon deps remain the
fallback.
- 8.3 module-level value receivers (`zigHostValueNames`) prepend the
implicit `self` like fn-locals, so `global_runner.run(cb)` joins `cb@1`.
- 8.4 deep member aliases (`@import("lib.zig").B.work`, `lib.B.work`)
keep the written owner: the module is bound as a namespace and the
alias's use sites are rewritten to `receiver . member`; only one-level
aliases are promoted to named imports.
- 8.5 container-hosted containers get owner-qualified identities
(`A.Item`, `B.Item`, `Outer.Inner`), minted by the bare-container rule,
while the scope keeps the lexical binding.
- 8.6 result-location `.init(…)` / `.{…}` under an annotation, a return
type or a field type emit the call / construction site with the
expected type as receiver.
- 8.7 Zig arm in bench/import-target (five dispatchers, config-free
fingerprint) + baselines row; `--check` passes.
- 8.9 fn-local `@import` bindings and their uses are keyed per callable
(`m$f_sib_a`), so sibling fns no longer share one namespace bucket.
- 8.10 `ScopeResolver.resolveNamespaceChains` (opt-in, Zig only): Case 1
/ Case 2 / Case 3 and the compound resolver walk a qualified receiver
segment by segment — republished modules, nested types, enum variants
through the module — refusing ambiguous hops. Off, every lookup keeps
its one-hop split; the 70 resolver suites are unchanged.
- 8.11 `@import("a.zig").Thing{}` binds the module as a namespace in type
position; `List(u8){}` / `lists.List(u8){}` get constructor sites.
- 8.12 a fieldless file whose top-level fn takes the file's own type
(`self: *@This()`, `self: *Self`) is a file-struct; two over-matching
ZIG_QUERIES rules are filtered by `shouldSkipDefinitionCapture`.
Also asserts the committed `opmod.Op.lookup.event_max()` call in zig-hub.
This commit is contained in:
parent
dd2977aefe
commit
215f70e329
42 changed files with 2492 additions and 115 deletions
File diff suppressed because one or more lines are too long
|
|
@ -4,7 +4,9 @@
|
|||
* `--check` inventory arm at the foot of this file fails when the two disagree
|
||||
* — over ONE shared corpus so the arms are directly comparable. One arm per
|
||||
* registered language, plus a second `csharp` arm carrying csproj configs
|
||||
* (#2902), so there is one more arm than there are languages.
|
||||
* (#2902), so there is one more arm than there are languages. The newest row
|
||||
* is `zig` (PR #1432), added the day its resolver registered — the inventory
|
||||
* arm below is what noticed it missing, which is the arm doing its job.
|
||||
*
|
||||
* NO LANGUAGE IS OMITTED, and that is the point of the list rather than an
|
||||
* accident of it. Nine of these arms (go, csharp, csharp_csproj, dart, ruby,
|
||||
|
|
@ -109,6 +111,19 @@
|
|||
* depth-then-lexicographic tie-break, so the collide arm (a `mod{n}` header
|
||||
* in every service's `include/`) is where it grows: 2.54 / 2.64 against
|
||||
* 1.06 on file count.
|
||||
* - zig: `resolveZigImportInternal` is rust's shape — an `@import("…zig")`
|
||||
* path is walked component by component from the importer's directory and
|
||||
* probed with two `allFiles.has(...)` calls (as written, then `+ '.zig'`),
|
||||
* and a bare name is a Map lookup in the build config or a miss. No index
|
||||
* is built, so the cost is O(path SEGMENTS) and flat in the file count,
|
||||
* and — as for rust — its collide arm is a deep tree whose spellings carry
|
||||
* ~4x the components rather than a shared-leaf layout that cannot fail.
|
||||
* The arm passes NO build config (`buildZon` null): the bare-name legs
|
||||
* (`b.addModule` roots, zon `.path` deps) read `build.zig` / `build.zig.zon`
|
||||
* through `loadZigBuildConfig` and are gated by
|
||||
* `test/unit/zig-import-resolver.test.ts`, so this fingerprint pins the
|
||||
* path-walking resolver alone and does not move when that config parsing
|
||||
* changes.
|
||||
*
|
||||
* Two properties of the corpus are load-bearing and must not be "simplified":
|
||||
*
|
||||
|
|
@ -221,7 +236,9 @@
|
|||
* corpus is a deep module tree whose targets carry ~2x the `::` segments,
|
||||
* which is the axis that CAN grow; the ratio across file counts staying at
|
||||
* 1.06 on it is the assertion, and `collide_ms_ceiling` bounds the absolute
|
||||
* cost of the long-path probe.
|
||||
* cost of the long-path probe. zig's collide arm is built the same way and
|
||||
* for the same reason: a deep tree whose `../../…/l4/mod{n}/file.zig`
|
||||
* spellings walk ~4x the components of the unique arm's `../mod{n}/…`.
|
||||
*
|
||||
* This is a scope-of-claim limit, not a regression: on the MISS path with a
|
||||
* shared leaf name the bucket grows with the file count BY CONSTRUCTION, and
|
||||
|
|
@ -308,7 +325,9 @@
|
|||
*
|
||||
* Only rust's exclusion survived unchanged: 16 B at 8000 files and 16 B at
|
||||
* 32 000, identical in all five runs, because it probes candidate paths with
|
||||
* `allFilePaths.has(...)` and builds nothing.
|
||||
* `allFilePaths.has(...)` and builds nothing. zig joined that tier on the same
|
||||
* reading for the same reason (`resolveZigImportInternal` holds no per-pass
|
||||
* structure at all), and takes rust's absolute 1 MiB bound.
|
||||
*
|
||||
* So the nine are still not BUDGETED — their ceilings, floors and ratio arms
|
||||
* are not this change to write — but they are all measured and all bounded. See
|
||||
|
|
@ -463,6 +482,7 @@ import { javaScopeResolver } from '../../src/core/ingestion/languages/java/scope
|
|||
import { cobolScopeResolver } from '../../src/core/ingestion/languages/cobol/scope-resolver.ts';
|
||||
import { resolveSwiftImportTarget } from '../../src/core/ingestion/languages/swift/import-target.ts';
|
||||
import { resolveRustImportTarget } from '../../src/core/ingestion/languages/rust/import-target.ts';
|
||||
import { resolveZigImportInternal } from '../../src/core/ingestion/import-resolvers/zig.ts';
|
||||
import { resolvePythonImportTarget } from '../../src/core/ingestion/languages/python/import-target.ts';
|
||||
import { makeJsResolveImportTarget } from '../../src/core/ingestion/languages/javascript/import-target.ts';
|
||||
import { makeVueResolveImportTarget } from '../../src/core/ingestion/languages/vue/import-target.ts';
|
||||
|
|
@ -736,6 +756,7 @@ const EXTENSION = {
|
|||
vue: '.vue',
|
||||
c: '.c',
|
||||
cpp: '.cpp',
|
||||
zig: '.zig',
|
||||
};
|
||||
/** C and C++ resolve `#include` against HEADERS, which reach the resolver
|
||||
* through `resolutionConfig` rather than through `allFilePaths` — see
|
||||
|
|
@ -859,6 +880,13 @@ function uniqueDir(lang, d, i) {
|
|||
// `resolutionConfig` load-bearing. Odd `i` is the header.
|
||||
if (lang === 'c' || lang === 'cpp') return i % 2 === 1 ? `include/comp${d}` : `src/comp${d}`;
|
||||
if (lang === 'ruby') return `lib/mod${d}`;
|
||||
// One flat `src/mod{d}/` per index and NO nested slice, on purpose: a Zig
|
||||
// import is spelled RELATIVE TO THE IMPORTER, and `uniqueTarget` does not
|
||||
// know which file issues it, so every importer has to sit at one depth for
|
||||
// `../mod{n}/file{j}.zig` to mean the same file from all of them. The miss
|
||||
// share the other unique arms take from a nested directory comes from the
|
||||
// target instead (see `uniqueTarget`).
|
||||
if (lang === 'zig') return `src/mod${d}`;
|
||||
throw unwiredLanguage('uniqueDir', lang);
|
||||
}
|
||||
|
||||
|
|
@ -947,6 +975,12 @@ function collideDir(lang, d, i) {
|
|||
if (lang === 'vue') return `src/pkg${d}/components`;
|
||||
if (lang === 'c' || lang === 'cpp') return i % 2 === 1 ? `svc${d}/include` : `svc${d}/src`;
|
||||
if (lang === 'ruby') return `svc${d}/lib/models`;
|
||||
// Rust's reasoning, verbatim: the resolver walks path components and probes
|
||||
// `.has()`, never searches, so file count is not an axis its cost has and a
|
||||
// shared-leaf layout would be an arm that cannot fail. A deep tree is the
|
||||
// axis that CAN grow — `collideTarget` spells its imports up through the
|
||||
// tree and back down, ~4x the components of the unique arm.
|
||||
if (lang === 'zig') return `src/l0/l1/l2/l3/l4/mod${d}`;
|
||||
throw unwiredLanguage('collideDir', lang);
|
||||
}
|
||||
|
||||
|
|
@ -1373,6 +1407,29 @@ function uniqueTarget(lang, { local, r, d, j, dirs }) {
|
|||
? ['json', 'set', 'net/http', 'digest'][(r >>> 4) % 4]
|
||||
: `gem${(r >>> 4) % 97}/missing/thing`;
|
||||
}
|
||||
if (lang === 'zig') {
|
||||
// `@import("../mod{n}/file{j}.zig")`, importer-relative — every file sits
|
||||
// in `src/mod{d}/`, so one `..` reaches `src/` from all of them (see
|
||||
// `uniqueDir`). The target is file `j`'s OWN directory, `j % dirs`, so a
|
||||
// hit is a real file; the `d % 7` slice names an `inner/` that exists
|
||||
// nowhere and misses, which is where the resolved count comes from, as in
|
||||
// the rust arm. One local spelling in three drops the extension, which is
|
||||
// the second `.has()` probe (`candidate + '.zig'`) — the leg an
|
||||
// extension-only corpus would never reach. The misses are the three
|
||||
// kinds a Zig file has: the compiler's own modules (`std`, `builtin`,
|
||||
// `root`), which the resolver rejects by name before any walk; a bare
|
||||
// package name with no build config to map it, which falls through every
|
||||
// leg to null; and a relative path to a vendored file that is not in the
|
||||
// corpus, which walks to the end and misses on both probes.
|
||||
if (local) {
|
||||
if (d % 7 === 0) return `../mod${d}/inner/file${j}.zig`;
|
||||
return (r >>> 3) % 3 === 0 ? `../mod${j % dirs}/file${j}` : `../mod${j % dirs}/file${j}.zig`;
|
||||
}
|
||||
const miss = (r >>> 3) % 3;
|
||||
if (miss === 0) return ['std', 'builtin', 'root'][(r >>> 4) % 3];
|
||||
if (miss === 1) return `ghost${(r >>> 4) % 97}`;
|
||||
return `../vendor${(r >>> 4) % 97}/missing.zig`;
|
||||
}
|
||||
throw unwiredLanguage('uniqueTarget', lang);
|
||||
}
|
||||
|
||||
|
|
@ -1583,6 +1640,27 @@ function collideTarget(lang, { local, r, d, j, dirs }) {
|
|||
? ['json', 'set', 'net/http', 'digest'][(r >>> 4) % 4]
|
||||
: `gem${(r >>> 4) % 97}/missing/thing`;
|
||||
}
|
||||
if (lang === 'zig') {
|
||||
// The same three families in the same proportions as the unique arm, so
|
||||
// the resolved count is identical by construction (asserted), spelled up
|
||||
// six levels to `src/` and back down through `l0/…/l4` — thirteen
|
||||
// components against the unique arm's three, in the hits and in the path
|
||||
// misses alike, because component count is the only axis this resolver's
|
||||
// cost has. The `d % 7` slice and the extension-less third mirror the
|
||||
// unique arm's; the by-name misses are unchanged, since no walk is what
|
||||
// they measure.
|
||||
const up = '../../../../../../l0/l1/l2/l3/l4';
|
||||
if (local) {
|
||||
if (d % 7 === 0) return `${up}/mod${d}/inner/file${j}.zig`;
|
||||
return (r >>> 3) % 3 === 0
|
||||
? `${up}/mod${j % dirs}/file${j}`
|
||||
: `${up}/mod${j % dirs}/file${j}.zig`;
|
||||
}
|
||||
const miss = (r >>> 3) % 3;
|
||||
if (miss === 0) return ['std', 'builtin', 'root'][(r >>> 4) % 3];
|
||||
if (miss === 1) return `ghost${(r >>> 4) % 97}`;
|
||||
return `${up}/vendor${(r >>> 4) % 97}/missing.zig`;
|
||||
}
|
||||
throw unwiredLanguage('collideTarget', lang);
|
||||
}
|
||||
|
||||
|
|
@ -1784,6 +1862,10 @@ function resolveOne(lang, from, target, pass) {
|
|||
);
|
||||
}
|
||||
if (lang === 'rust') return resolveRustImportTarget(target, from, allFilePaths, undefined);
|
||||
// Quotes already stripped — `configs/zig.ts` strips them before this call in
|
||||
// production too. `null` build config: this arm pins the path walk alone
|
||||
// (see the header); the config legs are gated by their own unit tests.
|
||||
if (lang === 'zig') return resolveZigImportInternal(from, target, allFilePaths, null);
|
||||
if (lang === 'python') {
|
||||
// `from <target> import X` — the spelling the orchestrator actually hands
|
||||
// the provider, and the ONLY one that reads `context.parsedFiles`: a
|
||||
|
|
@ -2062,7 +2144,7 @@ const HEAP_PROBE_TARGET = {
|
|||
// - `kotlin` misses after building its declared-package/module-binding index;
|
||||
// - `cobol` misses in both tier maps, `swift` in `byModule`, and `rust`
|
||||
// probes candidate paths and builds nothing — that last is the reading
|
||||
// the exclusion rests on;
|
||||
// the exclusion rests on, and `zig` shares it exactly;
|
||||
// - `typescript`, `vue` and `cpp` carry the same spelling shape as the
|
||||
// `javascript` and `c` arms they are excluded as duplicates OF, so the
|
||||
// bound compares like with like. `vue`'s is bare rather than `@/…`
|
||||
|
|
@ -2073,6 +2155,10 @@ const HEAP_PROBE_TARGET = {
|
|||
cobol: 'VENDOR0',
|
||||
swift: 'ExternalPkg0',
|
||||
rust: 'ghost0::Missing',
|
||||
// A relative path to a file the corpus does not hold: both `.has()` probes
|
||||
// miss after the full component walk, which is the longest leg the resolver
|
||||
// has (a by-name miss returns before any walk).
|
||||
zig: '../vendor0/missing.zig',
|
||||
typescript: 'vendor0/lib/missing',
|
||||
vue: 'vendor0/lib/Missing.vue',
|
||||
cpp: 'vendor0/missing.hpp',
|
||||
|
|
@ -2308,6 +2394,7 @@ const LANG_REGISTRY = {
|
|||
vue: SupportedLanguages.Vue,
|
||||
c: SupportedLanguages.C,
|
||||
cpp: SupportedLanguages.CPlusPlus,
|
||||
zig: SupportedLanguages.Zig,
|
||||
};
|
||||
const LANGS = Object.keys(LANG_REGISTRY);
|
||||
/**
|
||||
|
|
@ -2853,17 +2940,19 @@ for (const lang of HEAP_BUDGETED) {
|
|||
* `heap_bound_bytes` is the "exclusion still holds" bound. It does not claim
|
||||
* these indexes are small enough, which is what a ceiling claims about a
|
||||
* budgeted one; it claims each is still the SIZE the decision to leave it out
|
||||
* was taken on. `HEAP_BOUNDED` derives to THREE today — cobol, swift, rust.
|
||||
* was taken on. `HEAP_BOUNDED` derives to SEVEN today — cobol, swift, rust,
|
||||
* the ts family (#2953), and zig, which reads what rust reads (16 B) because
|
||||
* `resolveZigImportInternal` builds nothing and takes rust's absolute bound.
|
||||
* The prose below still counts nine because six were promoted to tier one
|
||||
* after it was written; read the counts as history, and `HEAP_BOUNDED` itself
|
||||
* as the answer. The re-entry condition the MEMORY section states — "if any of
|
||||
* the four ever diverges in what it ASKS, it earns an arm the same way" — is a
|
||||
* claim about growth, and this is the only thing in the file that can see it.
|
||||
*
|
||||
* NO FLOOR, and the reason is per language rather than uniform. rust reads 16 B
|
||||
* because it builds nothing, so any floor at all would be a floor on noise and
|
||||
* `1.5 x 0 B` is 0 — its bound is ABSOLUTE (1 MiB) for the same reason: a
|
||||
* multiplier on 16 B fails on the first byte of anything. The other eight are
|
||||
* NO FLOOR, and the reason is per language rather than uniform. rust (and zig)
|
||||
* reads 16 B because it builds nothing, so any floor at all would be a floor
|
||||
* on noise and `1.5 x 0 B` is 0 — its bound is ABSOLUTE (1 MiB) for the same
|
||||
* reason: a multiplier on 16 B fails on the first byte of anything. The other eight are
|
||||
* stable enough today to floor (0.24% peak-to-peak at worst over five runs).
|
||||
* The two this paragraph named as floor candidates, kotlin and dart, TOOK that
|
||||
* promotion: both now carry a ceiling and a recorded reading in tier one, which
|
||||
|
|
|
|||
|
|
@ -14,19 +14,81 @@
|
|||
* const bar = @import("bar"); → package dep declared in build.zig.zon
|
||||
*
|
||||
* Bare-name resolution is handled when a parsed ZigBuildZonConfig is
|
||||
* supplied (see `loadZigBuildConfig`). The root build.zig's own named modules
|
||||
* come first (`rootModules`, name → root file — a repo with no build.zig.zon
|
||||
* still resolves them). `.url`-based deps unpack into a build cache outside
|
||||
* supplied (see `loadZigBuildConfig`). The import table of the build module
|
||||
* the importer belongs to comes first (`buildModules` — per-module
|
||||
* `addImport` aliases, see `zigModulesContaining`), then the root
|
||||
* build.zig's named modules flattened repo-wide (`rootModules`, name → root
|
||||
* file — a repo with no build.zig.zon still resolves them). `.url`-based deps unpack into a build cache outside
|
||||
* the repo and so are returned as null; `.path`-based deps are resolved
|
||||
* through the root the dep's own build.zig declares, then the conventional
|
||||
* `<dep_root>/src/root.zig`, `<dep_root>/src/<name>.zig`,
|
||||
* `<dep_root>/src/main.zig` layouts.
|
||||
*/
|
||||
|
||||
import { normalizeZigDepPath, type ZigBuildZonConfig } from '../language-config.js';
|
||||
import {
|
||||
normalizeZigDepPath,
|
||||
type ZigBuildModule,
|
||||
type ZigBuildZonConfig,
|
||||
} from '../language-config.js';
|
||||
|
||||
const ZIG_STDLIB_NAMES = new Set(['std', 'builtin', 'root']);
|
||||
|
||||
/**
|
||||
* The build module(s) a source file belongs to: the module whose ROOT the
|
||||
* file is, else the module(s) whose root's directory is the deepest prefix
|
||||
* of the file's path. Membership is not declared anywhere static — a module
|
||||
* is its root plus whatever that root reaches through relative imports —
|
||||
* so the directory is the proxy, and several modules may share one
|
||||
* (`src/main.zig` executable beside `src/root.zig` library is the `zig init`
|
||||
* layout). A root file is unambiguous by construction: it is its own module.
|
||||
*/
|
||||
function zigModulesContaining(
|
||||
currentFile: string,
|
||||
modules: readonly ZigBuildModule[],
|
||||
): ZigBuildModule[] {
|
||||
const own = modules.filter((m) => m.root === currentFile);
|
||||
if (own.length > 0) return own;
|
||||
let best = -1;
|
||||
let out: ZigBuildModule[] = [];
|
||||
for (const mod of modules) {
|
||||
const slash = mod.root.lastIndexOf('/');
|
||||
const dir = slash === -1 ? '' : mod.root.slice(0, slash);
|
||||
if (dir !== '' && !currentFile.startsWith(`${dir}/`)) continue;
|
||||
if (dir.length > best) {
|
||||
best = dir.length;
|
||||
out = [mod];
|
||||
} else if (dir.length === best) {
|
||||
out.push(mod);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare `@import("<name>")` through the containing module(s)' own import
|
||||
* tables. `undefined` when no containing module binds the name (the caller
|
||||
* falls back to the repo-wide tables); `null` when the containing modules
|
||||
* DISAGREE — two same-directory modules that bind one alias to different
|
||||
* roots. That is fail-closed on purpose: picking either would mint a
|
||||
* confident wrong `IMPORTS` edge for half the files, which is the
|
||||
* first-wins defect this table exists to remove.
|
||||
*/
|
||||
function resolveThroughBuildModules(
|
||||
currentFile: string,
|
||||
importPath: string,
|
||||
allFiles: ReadonlySet<string>,
|
||||
modules: readonly ZigBuildModule[],
|
||||
): string | null | undefined {
|
||||
const targets = new Set<string>();
|
||||
for (const mod of zigModulesContaining(currentFile, modules)) {
|
||||
const target = mod.imports.get(importPath);
|
||||
if (target !== undefined && allFiles.has(target)) targets.add(target);
|
||||
}
|
||||
if (targets.size === 0) return undefined;
|
||||
if (targets.size > 1) return null;
|
||||
return targets.values().next().value ?? null;
|
||||
}
|
||||
|
||||
/** Resolve a Zig @import argument to a file path in the repository.
|
||||
* Returns null when the import is a stdlib / builtin / root reference,
|
||||
* an unresolvable build.zig.zon package dep, or genuinely unresolvable.
|
||||
|
|
@ -78,6 +140,22 @@ export function resolveZigImportInternal(
|
|||
|
||||
// Bare name without extension or slashes (e.g. @import("bar")).
|
||||
if (buildZon) {
|
||||
// First the import table of the build module the importer belongs to:
|
||||
// an alias is scoped to the module whose `addImport` declared it, so
|
||||
// this is the only table that can tell `app`'s `@import("config")` from
|
||||
// `tool`'s. A disagreement between same-directory modules is `null`
|
||||
// here and stops the chain — the repo-wide fallbacks below would only
|
||||
// reintroduce the first-wins answer.
|
||||
if (buildZon.buildModules !== undefined && buildZon.buildModules.length > 0) {
|
||||
const scoped = resolveThroughBuildModules(
|
||||
currentFile,
|
||||
importPath,
|
||||
allFiles,
|
||||
buildZon.buildModules,
|
||||
);
|
||||
if (scoped !== undefined) return scoped;
|
||||
}
|
||||
|
||||
// The repo's own modules, as its root build.zig names them
|
||||
// (`b.addModule("lightpanda", .{ .root_source_file = b.path("src/lightpanda.zig") })`),
|
||||
// take precedence: that declaration is exactly what an in-repo
|
||||
|
|
|
|||
|
|
@ -212,6 +212,34 @@ export interface ZigBuildZonConfig {
|
|||
* and no zon still resolves them. See `parseZigRootModules`.
|
||||
*/
|
||||
rootModules?: Map<string, string>;
|
||||
/**
|
||||
* Every build module the root `build.zig` declares, each with ITS OWN
|
||||
* import table — `addModule` / `createModule` roots and the root modules of
|
||||
* `addExecutable` / `addLibrary` / `addTest` artifacts, with the aliases
|
||||
* their `addImport("<alias>", …)` calls and `.imports = &.{ … }` fields
|
||||
* bind. `rootModules` flattens all of those into one first-wins map, which
|
||||
* is wrong as soon as two modules bind one alias to different roots (an
|
||||
* `app` and a `tool` executable that each `addImport("config", …)` their
|
||||
* own `config.zig`): the second module's files resolved to the first
|
||||
* module's target. The resolver walks a source file to its containing
|
||||
* module(s) and consults their tables first — see
|
||||
* `resolveZigImportInternal` / `parseZigBuildModules`.
|
||||
*/
|
||||
buildModules?: readonly ZigBuildModule[];
|
||||
}
|
||||
|
||||
/** One build module of the root `build.zig` — see `ZigBuildZonConfig.buildModules`. */
|
||||
export interface ZigBuildModule {
|
||||
/** The `addModule("<name>", …)` name; absent for `createModule` bindings
|
||||
* and artifact root modules, which are reachable only through aliases. */
|
||||
readonly name?: string;
|
||||
/** Repo-relative root source file (`b.path("src/x.zig")`). */
|
||||
readonly root: string;
|
||||
/** Alias → repo-relative root source file, as this module's own
|
||||
* `addImport` calls and `.imports` field declare it. Includes aliases to
|
||||
* a path dep's module (`addImport("api", dep.module("core"))`) when the
|
||||
* dep's build.zig declares that module. */
|
||||
readonly imports: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -631,8 +659,9 @@ export async function loadZigBuildConfig(repoRoot: string): Promise<ZigBuildZonC
|
|||
// of the zon: `@import("<own module>")` is how single-package repos refer
|
||||
// to their root file from every other file.
|
||||
let rootModules: Map<string, string> | undefined;
|
||||
let rootBuildZig: string | null = null;
|
||||
try {
|
||||
const rootBuildZig = await fs.readFile(path.join(repoRoot, 'build.zig'), 'utf-8');
|
||||
rootBuildZig = await fs.readFile(path.join(repoRoot, 'build.zig'), 'utf-8');
|
||||
const parsed = parseZigRootModules(rootBuildZig);
|
||||
if (parsed.size > 0) rootModules = parsed;
|
||||
} catch {
|
||||
|
|
@ -640,7 +669,15 @@ export async function loadZigBuildConfig(repoRoot: string): Promise<ZigBuildZonC
|
|||
}
|
||||
|
||||
if (config === null) {
|
||||
return rootModules ? { pathDeps: new Map(), rootModules } : null;
|
||||
if (rootBuildZig === null) return null;
|
||||
// No zon: no path deps, so `dep.module(…)` operands resolve to nothing.
|
||||
const buildModules = parseZigBuildModules(rootBuildZig);
|
||||
if (!rootModules && buildModules.length === 0) return null;
|
||||
return {
|
||||
pathDeps: new Map(),
|
||||
...(rootModules ? { rootModules } : {}),
|
||||
...(buildModules.length > 0 ? { buildModules } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// A path dep's importable root is whatever ITS build.zig declares, not a
|
||||
|
|
@ -648,6 +685,9 @@ export async function loadZigBuildConfig(repoRoot: string): Promise<ZigBuildZonC
|
|||
// repo-relative. Best effort — an unreadable build.zig just leaves the
|
||||
// conventional-layout fallback in place.
|
||||
const moduleRoots = new Map<string, readonly string[]>();
|
||||
// Per path dep: the modules its build.zig NAMES (`addModule("core", …)`),
|
||||
// repo-relative — what a root-build.zig `dep.module("core")` operand means.
|
||||
const depModules = new Map<string, ReadonlyMap<string, string>>();
|
||||
for (const [depName, depPath] of config.pathDeps) {
|
||||
const rel = normalizeZigDepPath(depPath);
|
||||
if (rel === null) continue;
|
||||
|
|
@ -657,15 +697,21 @@ export async function loadZigBuildConfig(repoRoot: string): Promise<ZigBuildZonC
|
|||
} catch {
|
||||
continue;
|
||||
}
|
||||
const roots = parseZigBuildModuleRoots(buildZig, depName).map((r) =>
|
||||
rel === '' ? r : `${rel}/${r}`,
|
||||
);
|
||||
const prefixed = (r: string): string => (rel === '' ? r : `${rel}/${r}`);
|
||||
const roots = parseZigBuildModuleRoots(buildZig, depName).map(prefixed);
|
||||
if (roots.length > 0) moduleRoots.set(depName, roots);
|
||||
const named = new Map<string, string>();
|
||||
for (const mod of parseZigBuildModules(buildZig)) {
|
||||
if (mod.name !== undefined && !named.has(mod.name)) named.set(mod.name, prefixed(mod.root));
|
||||
}
|
||||
if (named.size > 0) depModules.set(depName, named);
|
||||
}
|
||||
const buildModules = rootBuildZig === null ? [] : parseZigBuildModules(rootBuildZig, depModules);
|
||||
return {
|
||||
...config,
|
||||
...(moduleRoots.size > 0 ? { moduleRoots } : {}),
|
||||
...(rootModules ? { rootModules } : {}),
|
||||
...(buildModules.length > 0 ? { buildModules } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -805,6 +851,205 @@ export function parseZigRootModules(buildZig: string): Map<string, string> {
|
|||
return modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every build module the ROOT `build.zig` declares, each with its OWN import
|
||||
* table (`ZigBuildModule`). Static scan (no execution) of:
|
||||
*
|
||||
* - `b.addModule("<name>", .{ .root_source_file = b.path("<p>.zig"), … })`
|
||||
* and `const m = b.createModule(.{ .root_source_file = … })` — a module,
|
||||
* bound to the identifier a preceding `const m =` names;
|
||||
* - `b.addExecutable` / `addLibrary` / `addStaticLibrary` /
|
||||
* `addSharedLibrary` / `addTest` / `addObject(.{ .root_source_file =
|
||||
* b.path("<p>.zig"), … })` — an artifact whose ROOT MODULE is a module of
|
||||
* its own (reached as `exe.root_module.addImport(…)`), or `.root_module =
|
||||
* m` / `.root_module = b.createModule(…)` naming one declared inline;
|
||||
* - `<m>.addImport("<alias>", <operand>)`, `<exe>.root_module.addImport(…)`
|
||||
* and the `.imports = &.{ .{ .name = "<alias>", .module = <operand> } }`
|
||||
* field of a module's own arguments — an entry in THAT module's table.
|
||||
* The operand is a module binding (`m`) or a path dep's named module,
|
||||
* `dep.module("<name>")` with `const dep = b.dependency("<zon name>", …)`,
|
||||
* looked up in `depModules` (zon dep name → module name → repo-relative
|
||||
* root, from the dep's own build.zig).
|
||||
*
|
||||
* Why per module rather than one map (`parseZigRootModules`): an alias is
|
||||
* scoped to the module that declares it. Two executables that each
|
||||
* `addImport("config", …)` their own `config.zig` are the ordinary
|
||||
* multi-target layout, and a single first-wins map sent the second module's
|
||||
* `@import("config")` to the first module's file — a confident wrong
|
||||
* `IMPORTS` edge and every `config.*` call behind it. Deliberately NOT
|
||||
* resolved, as in `parseZigRootModules`: generated roots
|
||||
* (`addOptions().createModule()`, `translate_c.createModule()`, computed
|
||||
* LazyPaths), `.url` deps, and operands that are not a bare identifier or a
|
||||
* `dep.module("…")` on a `b.dependency` binding. Comments stripped, string
|
||||
* literals masked; the first binding of an identifier wins.
|
||||
*/
|
||||
export function parseZigBuildModules(
|
||||
buildZig: string,
|
||||
depModules?: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
): ZigBuildModule[] {
|
||||
const text = stripZonComments(buildZig);
|
||||
const mask = zonStringMask(text);
|
||||
const rootRe = /\.root_source_file\s*=\s*b\.path\(\s*"([^"\n]+)"\s*\)/;
|
||||
const bindingRe = /(?:const|var)\s+([A-Za-z_]\w*)\s*=\s*(?:[A-Za-z_]\w*\.)*$/;
|
||||
const staticRoot = (args: string): string | null => {
|
||||
const rootMatch = rootRe.exec(args);
|
||||
const root = rootMatch ? normalizeZigDepPath(rootMatch[1]!) : null;
|
||||
return root === null || root === '' || !root.endsWith('.zig') ? null : root;
|
||||
};
|
||||
|
||||
// Pass 1 — modules and the identifiers bound to them. `at` is the offset
|
||||
// of the call's name token, so an inline `.root_module = b.createModule(…)`
|
||||
// can be matched back to the module it minted.
|
||||
interface Draft {
|
||||
readonly name?: string;
|
||||
readonly root: string;
|
||||
readonly at: number;
|
||||
readonly argsStart: number;
|
||||
readonly argsEnd: number;
|
||||
readonly imports: Map<string, string>;
|
||||
}
|
||||
const drafts: Draft[] = [];
|
||||
const bindings = new Map<string, number>(); // identifier → drafts index
|
||||
const bind = (prefixEnd: number, idx: number): void => {
|
||||
const binding = bindingRe.exec(text.slice(0, prefixEnd));
|
||||
if (binding && !bindings.has(binding[1]!)) bindings.set(binding[1]!, idx);
|
||||
};
|
||||
// Artifact bindings whose `.root_module = <ident>` names a module declared
|
||||
// by another call; resolved once every binding is known.
|
||||
const pendingArtifactAliases: { readonly ident: string; readonly module: string }[] = [];
|
||||
const callRe =
|
||||
/\b(addModule|createModule|addExecutable|addLibrary|addStaticLibrary|addSharedLibrary|addTest|addObject)\s*\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = callRe.exec(text)) !== null) {
|
||||
if (mask[m.index] !== 0) continue;
|
||||
const argsStart = m.index + m[0].length;
|
||||
const argsEnd = findZigParenEnd(text, argsStart);
|
||||
if (argsEnd < 0) break;
|
||||
const args = text.slice(argsStart, argsEnd);
|
||||
const kind = m[1]!;
|
||||
if (kind === 'addModule' || kind === 'createModule') {
|
||||
const root = staticRoot(args);
|
||||
if (root === null) continue;
|
||||
const nameMatch = kind === 'addModule' ? /^\s*"([^"\n]+)"\s*,/.exec(args) : null;
|
||||
drafts.push({
|
||||
...(nameMatch ? { name: nameMatch[1]! } : {}),
|
||||
root,
|
||||
at: m.index,
|
||||
argsStart,
|
||||
argsEnd,
|
||||
imports: new Map(),
|
||||
});
|
||||
bind(m.index, drafts.length - 1);
|
||||
continue;
|
||||
}
|
||||
// An artifact. Its root module is either declared inline by
|
||||
// `.root_source_file`, or handed over through `.root_module = …`.
|
||||
const rootModule = /\.root_module\s*=\s*((?:[A-Za-z_]\w*\.)*)([A-Za-z_]\w*)\s*(\()?/.exec(args);
|
||||
if (rootModule) {
|
||||
if (rootModule[3] === '(' && rootModule[2] === 'createModule') {
|
||||
// Inline `.root_module = b.createModule(.{ … })`: the module is minted
|
||||
// by the createModule call inside these args (a later iteration of
|
||||
// this loop); remember the artifact's binding for it.
|
||||
const nameOffset = rootModule.index + rootModule[0].lastIndexOf('createModule');
|
||||
const binding = bindingRe.exec(text.slice(0, m.index));
|
||||
if (binding) {
|
||||
pendingArtifactAliases.push({
|
||||
ident: binding[1]!,
|
||||
module: `@${argsStart + nameOffset}`,
|
||||
});
|
||||
}
|
||||
} else if (rootModule[1] === '' && rootModule[3] === undefined) {
|
||||
const binding = bindingRe.exec(text.slice(0, m.index));
|
||||
if (binding) pendingArtifactAliases.push({ ident: binding[1]!, module: rootModule[2]! });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const root = staticRoot(args);
|
||||
if (root === null) continue;
|
||||
drafts.push({ root, at: m.index, argsStart, argsEnd, imports: new Map() });
|
||||
bind(m.index, drafts.length - 1);
|
||||
}
|
||||
for (const alias of pendingArtifactAliases) {
|
||||
if (bindings.has(alias.ident)) continue;
|
||||
const idx = alias.module.startsWith('@')
|
||||
? drafts.findIndex((d) => d.at === Number(alias.module.slice(1)))
|
||||
: (bindings.get(alias.module) ?? -1);
|
||||
if (idx >= 0) bindings.set(alias.ident, idx);
|
||||
}
|
||||
if (drafts.length === 0) return [];
|
||||
|
||||
// `const dep = b.dependency("<zon name>", …)` bindings, for `dep.module("…")`.
|
||||
const dependencyBindings = new Map<string, string>();
|
||||
const depRe =
|
||||
/(?:const|var)\s+([A-Za-z_]\w*)\s*=\s*(?:[A-Za-z_]\w*\.)*dependency\(\s*"([^"\n]+)"/g;
|
||||
while ((m = depRe.exec(text)) !== null) {
|
||||
if (mask[m.index] !== 0) continue;
|
||||
if (!dependencyBindings.has(m[1]!)) dependencyBindings.set(m[1]!, m[2]!);
|
||||
}
|
||||
// An import operand → the repo-relative root it names, or null.
|
||||
const operandRoot = (operand: string): string | null => {
|
||||
const bare = /^([A-Za-z_]\w*)$/.exec(operand);
|
||||
if (bare) {
|
||||
const idx = bindings.get(bare[1]!);
|
||||
return idx === undefined ? null : drafts[idx]!.root;
|
||||
}
|
||||
const viaDep = /^([A-Za-z_]\w*)\.module\(\s*"([^"\n]+)"\s*\)$/.exec(operand);
|
||||
if (viaDep) {
|
||||
const zonName = dependencyBindings.get(viaDep[1]!);
|
||||
return zonName === undefined ? null : (depModules?.get(zonName)?.get(viaDep[2]!) ?? null);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const addImport = (idx: number, alias: string, operand: string): void => {
|
||||
const root = operandRoot(operand.trim());
|
||||
const table = drafts[idx]!.imports;
|
||||
if (root !== null && !table.has(alias)) table.set(alias, root);
|
||||
};
|
||||
|
||||
// Pass 2a — `<m>.addImport("<alias>", <operand>)` / `<exe>.root_module.addImport(…)`.
|
||||
const addImportRe = /\b([A-Za-z_]\w*)(?:\.root_module)?\.addImport\s*\(/g;
|
||||
while ((m = addImportRe.exec(text)) !== null) {
|
||||
if (mask[m.index] !== 0) continue;
|
||||
const idx = bindings.get(m[1]!);
|
||||
if (idx === undefined) continue;
|
||||
const argsStart = m.index + m[0].length;
|
||||
const argsEnd = findZigParenEnd(text, argsStart);
|
||||
if (argsEnd < 0) break;
|
||||
const args = text.slice(argsStart, argsEnd);
|
||||
const aliasMatch = /^\s*"([^"\n]+)"\s*,/.exec(args);
|
||||
if (!aliasMatch) continue;
|
||||
addImport(idx, aliasMatch[1]!, args.slice(aliasMatch[0].length));
|
||||
}
|
||||
// Pass 2b — `.imports = &.{ .{ .name = "<alias>", .module = <operand> }, … }`
|
||||
// inside a module's own argument list. The operand runs to the next `,` or
|
||||
// `}` at paren depth 0 (`dep.module("core")` carries parentheses).
|
||||
const entryRe = /\.name\s*=\s*"([^"\n]+)"\s*,\s*\.module\s*=\s*/g;
|
||||
drafts.forEach((draft, idx) => {
|
||||
const args = text.slice(draft.argsStart, draft.argsEnd);
|
||||
let e: RegExpExecArray | null;
|
||||
while ((e = entryRe.exec(args)) !== null) {
|
||||
if (mask[draft.argsStart + e.index] !== 0) continue;
|
||||
let depth = 0;
|
||||
let end = e.index + e[0].length;
|
||||
for (; end < args.length; end++) {
|
||||
const ch = args[end];
|
||||
if (ch === '(') depth++;
|
||||
else if (ch === ')') {
|
||||
if (depth === 0) break;
|
||||
depth--;
|
||||
} else if (depth === 0 && (ch === ',' || ch === '}')) break;
|
||||
}
|
||||
addImport(idx, e[1]!, args.slice(e.index + e[0].length, end));
|
||||
}
|
||||
});
|
||||
|
||||
return drafts.map(({ name, root, imports }) => ({
|
||||
...(name !== undefined ? { name } : {}),
|
||||
root,
|
||||
imports,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the `)` matching the `(` that precedes `start`, skipping parens
|
||||
* inside `"…"` literals. -1 when unbalanced. Call on comment-stripped text.
|
||||
|
|
|
|||
|
|
@ -96,7 +96,11 @@ export const zigProvider = defineLanguage({
|
|||
captureMap['definition.struct'] ??
|
||||
captureMap['definition.enum'] ??
|
||||
captureMap['definition.union'];
|
||||
return decl !== undefined && isZigRedundantContainerCapture(decl, captureMap['name']);
|
||||
if (decl === undefined) return false;
|
||||
// The file-struct rules over-match (a `@This` first parameter, a
|
||||
// top-level `@This()` alias — see ZIG_QUERIES); the one predicate decides.
|
||||
if (decl.type === 'source_file') return !isZigFileStruct(decl);
|
||||
return isZigRedundantContainerCapture(decl, captureMap['name']);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { getTreeSitterBufferSize } from '../../constants.js';
|
|||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
||||
import { hasZigPubKeyword } from '../../export-detection.js';
|
||||
import { normalizeZigTypeName } from './interpret.js';
|
||||
|
||||
/** Zig container node types: `struct`, `enum`, `union` and the fieldless
|
||||
* `opaque` all bind through `const T = <container> {…}` and may own methods.
|
||||
|
|
@ -33,16 +34,25 @@ export function zigImportRootOf(value: SyntaxNode | null): SyntaxNode | null {
|
|||
}
|
||||
|
||||
/** Is this `@import(…)` builtin the receiver of a member call —
|
||||
* `@import("dump.zig").root(...)` — i.e. the `object` of a `field_expression`
|
||||
* that is the `function` of a `call_expression`? A deeper chain
|
||||
* (`@import("x.zig").Foo.init()`) is not: its receiver is `….Foo`, and the
|
||||
* builtin is only the module the chain starts from. */
|
||||
* `@import("dump.zig").root(...)` — or of a qualified construction —
|
||||
* `@import("a.zig").Thing{…}` — i.e. the `object` of a `field_expression`
|
||||
* that is the `function` of a `call_expression` or the type of a
|
||||
* `struct_initializer`? A deeper chain (`@import("x.zig").Foo.init()`) is
|
||||
* not: its receiver is `….Foo`, and the builtin is only the module the chain
|
||||
* starts from (the namespace chain walk resolves it from there). */
|
||||
export function isZigInlineImportReceiver(importNode: SyntaxNode): boolean {
|
||||
const field = importNode.parent;
|
||||
if (field?.type !== 'field_expression') return false;
|
||||
if (field.childForFieldName('object')?.id !== importNode.id) return false;
|
||||
const call = field.parent;
|
||||
return call?.type === 'call_expression' && call.childForFieldName('function')?.id === field.id;
|
||||
const use = field.parent;
|
||||
if (use === null || use === undefined) return false;
|
||||
if (use.type === 'call_expression') return use.childForFieldName('function')?.id === field.id;
|
||||
// `@import("a.zig").Thing{…}` — the module is the receiver of a qualified
|
||||
// CONSTRUCTION (the `@reference.call.constructor` rule with a receiver, see
|
||||
// the query), exactly as it is of a member call. Without the namespace
|
||||
// binding the site had a receiver nothing was bound to, so the aggregate
|
||||
// event had no target (PR #1432 review, 8.11).
|
||||
return use.type === 'struct_initializer' && use.namedChild(0)?.id === field.id;
|
||||
}
|
||||
|
||||
/** Is this variable_declaration a container binding (`const T = struct {…}`)
|
||||
|
|
@ -106,14 +116,26 @@ export function isZigKeywordDeclaration(declNode: SyntaxNode): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
/** Is `root` a FILE-STRUCT — a `.zig` file whose top level declares at least
|
||||
* one container field? In Zig every file is a struct; one with fields is an
|
||||
* instantiable type whose name is the file stem (`Page.zig` declares `Page`,
|
||||
* `@typeName(Page)` is `"Page"`), and its top-level `fn`s taking `self` are
|
||||
* its methods. A file WITHOUT fields is only a namespace and stays a Module:
|
||||
* its fns keep their `Function` ids. The MISSING-identifier placeholder
|
||||
* tree-sitter-zig recovers for an empty body is not a field (see
|
||||
* `zigFieldConfig.extractName`). */
|
||||
/** Is `root` a FILE-STRUCT — a `.zig` file that IS a type? In Zig every file
|
||||
* is a struct; the ones that matter as types are named after the file stem
|
||||
* (`Page.zig` declares `Page`, `@typeName(Page)` is `"Page"`) and their
|
||||
* top-level `fn`s taking a receiver are its methods. Two signals, either one
|
||||
* suffices:
|
||||
* - the top level declares at least one container field (an instantiable
|
||||
* type with state); the MISSING-identifier placeholder tree-sitter-zig
|
||||
* recovers for an empty body is not a field (see
|
||||
* `zigFieldConfig.extractName`);
|
||||
* - a top-level `fn` takes the file's OWN type as its first parameter —
|
||||
* `self: *@This()`, or `self: *Self` with `const Self = @This();` at
|
||||
* file level. A zero-sized file type (`Empty.zig`: no field, a `Self`
|
||||
* alias and `pub fn ping(self: *Self)`) is still constructed (`Empty{}`)
|
||||
* and dispatched on by importers; keyed on fields alone it lost its
|
||||
* `Struct`, its `HAS_METHOD`s and every `e.ping()` edge (PR #1432
|
||||
* review, 8.12).
|
||||
* A file with neither is a namespace and stays a Module: its fns keep their
|
||||
* `Function` ids, and a `const js = @This();` there stays a Const. Only the
|
||||
* receiver TYPE decides, never the parameter name: `fn f(self: Foo)` in a
|
||||
* utility file is a free function that happens to call its argument `self`. */
|
||||
export function isZigFileStruct(root: SyntaxNode | null | undefined): boolean {
|
||||
if (root?.type !== 'source_file') return false;
|
||||
for (let i = 0; i < root.namedChildCount; i++) {
|
||||
|
|
@ -122,6 +144,20 @@ export function isZigFileStruct(root: SyntaxNode | null | undefined): boolean {
|
|||
const name = child.childForFieldName('name');
|
||||
if (name !== null && name.text.length > 0) return true;
|
||||
}
|
||||
let aliases: Set<string> | undefined;
|
||||
for (let i = 0; i < root.namedChildCount; i++) {
|
||||
const fn = root.namedChild(i);
|
||||
if (fn?.type !== 'function_declaration') continue;
|
||||
const first = fn.namedChildren
|
||||
.find((c): c is SyntaxNode => c?.type === 'parameters')
|
||||
?.namedChild(0);
|
||||
const typeNode = first?.type === 'parameter' ? first.childForFieldName('type') : null;
|
||||
if (typeNode === null || typeNode === undefined) continue;
|
||||
const nominal = zigParameterNominalType(typeNode.text);
|
||||
if (nominal === '@This()') return true;
|
||||
aliases ??= zigThisAliasNamesIn(root);
|
||||
if (aliases.has(nominal)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -284,8 +320,18 @@ export function zigReceiverParameter(fn: SyntaxNode, filePath?: string): SyntaxN
|
|||
* structure phase's owner walk) and `emitZigScopeCaptures`, so the two
|
||||
* phases agree by construction.
|
||||
*
|
||||
* - file-struct / `const T = struct {…}` at file or container level /
|
||||
* generic type constructor: the binding name (`zigContainerBindingName`).
|
||||
* - file-struct / `const T = struct {…}` at file level / generic type
|
||||
* constructor: the binding name (`zigContainerBindingName`).
|
||||
* - CONTAINER-HOSTED named container — `pub const Item = struct {…}`
|
||||
* inside `const A = struct {…}`: owner-qualified, `A.Item` (recursively:
|
||||
* `A.B.Item`). By binding name alone `A.Item` and `B.Item` — two
|
||||
* distinct types, each with its own `run` — shared ONE `Struct:<file>:
|
||||
* Item` node and one `Item.run` (PR #1432 review, 8.5). The scope side
|
||||
* still binds the lexical spelling `Item` inside `A` (the
|
||||
* `@declaration.binding-name` split in `emitZigScopeCaptures`), and the
|
||||
* shared `populateClassOwnedMembers` leaves a dotted qualified name
|
||||
* alone, so the def and the graph node agree on `A.Item` and its members
|
||||
* qualify as `A.Item.run` — the same shape Java's `Outer.Inner` takes.
|
||||
* - FUNCTION-LOCAL named container (F8) — `const R = struct {…}` inside a
|
||||
* `fn` or `test` body: `<enclosing callable>$<name>`, e.g. `string$R`,
|
||||
* `Reflect.string$R`. Zig code declares such helper containers per
|
||||
|
|
@ -313,8 +359,12 @@ export function zigContainerName(containerNode: SyntaxNode, filePath?: string):
|
|||
const host = zigIdentityHost(containerNode);
|
||||
if (binding !== undefined) {
|
||||
if (containerNode.parent?.type !== 'variable_declaration' || host === null) return binding;
|
||||
if (!isZigCallableNode(host)) return binding; // file / container level: unchanged
|
||||
return `${zigCallableQualifiedName(host, filePath)}$${binding}`;
|
||||
if (isZigCallableNode(host)) return `${zigCallableQualifiedName(host, filePath)}$${binding}`;
|
||||
if (ZIG_CONTAINER_TYPES.has(host.type)) {
|
||||
const hostName = zigContainerName(host, filePath);
|
||||
return hostName === undefined ? binding : `${hostName}.${binding}`;
|
||||
}
|
||||
return binding; // file level: the binding name is the identity
|
||||
}
|
||||
const ordinal = zigAnonymousContainerOrdinal(containerNode);
|
||||
const prefix = host === null ? undefined : zigAnonymousHostPrefix(host, filePath);
|
||||
|
|
@ -340,14 +390,15 @@ export function zigContainerLabel(
|
|||
}
|
||||
|
||||
/** Which ZIG_QUERIES rule mints a container's graph node (F8):
|
||||
* - 'wrapper': `const T = struct {…}` at file or container level — the
|
||||
* - 'wrapper': `const T = struct {…}` at FILE level — the
|
||||
* `variable_declaration … @definition.struct` rule (name from `@name`);
|
||||
* - 'constructor': the container a generic type constructor returns —
|
||||
* the `fn … type { return struct {…}; }` rule;
|
||||
* - 'container': everything the bare `(struct_declaration)
|
||||
* @definition.struct` rule owns — FUNCTION-LOCAL named containers (their
|
||||
* identity `string$R` is not a capture, so the class extractor names
|
||||
* them via `zigContainerName`) and ANONYMOUS containers.
|
||||
* @definition.struct` rule owns — FUNCTION-LOCAL and CONTAINER-HOSTED
|
||||
* named containers (their identities `string$R` / `A.Item` are not
|
||||
* captures, so the class extractor names them via `zigContainerName`)
|
||||
* and ANONYMOUS containers.
|
||||
* The provider's `shouldSkipClassCapture` drops the other rules' matches for
|
||||
* the same node so each container is minted exactly once. */
|
||||
export function zigContainerAnchor(
|
||||
|
|
@ -360,7 +411,7 @@ export function zigContainerAnchor(
|
|||
zigContainerBindingName(containerNode) !== undefined
|
||||
) {
|
||||
const host = zigIdentityHost(containerNode);
|
||||
return host !== null && isZigCallableNode(host) ? 'container' : 'wrapper';
|
||||
return host === null || host.type === 'source_file' ? 'wrapper' : 'container';
|
||||
}
|
||||
return 'container';
|
||||
}
|
||||
|
|
@ -654,16 +705,22 @@ function rewriteZigThisAlias(
|
|||
* or type receiver (`Runner.init(cb)`, `std.sort.pdq(…)`, `List(u8).init`,
|
||||
* `@import("x.zig").f(cb)`) passes nothing implicitly and gets no prepend.
|
||||
* Value-vs-type is F6's rule: the chain head is a fn-local name (param /
|
||||
* local / payload) that is not TitleCase. Known residual gap: a
|
||||
* MODULE-level value receiver (`global_runner.run(cb)`) is not fn-local and
|
||||
* gets no prepend, so its callback misses the formal.
|
||||
* local / payload) that is not TitleCase, or a MODULE-level value — a
|
||||
* file- or container-level `var` / `const` whose declaration shape says
|
||||
* "value" (`zigHostValueNames`: annotated, a struct literal, a non-type
|
||||
* call, a literal), so `global_runner.run(target)` prepends `global_runner`
|
||||
* exactly like `r.run(target)` does and `target` joins formal `cb@1`
|
||||
* instead of `self@0` (PR #1432 review, 8.3).
|
||||
*
|
||||
* `builtin_function` (`@import`, `@sizeOf`, …) is deliberately not a call node:
|
||||
* builtins never take user callables as flow arguments.
|
||||
*/
|
||||
/** Per file: the callable-flow options close over the file's fn-local name
|
||||
* cache (shared with F6's `zigCallReturnTypeOf`). */
|
||||
function zigCallableCaptureOptions(fnLocalNames: Map<number, Set<string>>) {
|
||||
/** Per file: the callable-flow options close over the file's fn-local and
|
||||
* host-value name caches (shared with F6's `zigCallReturnTypeOf`). */
|
||||
function zigCallableCaptureOptions(
|
||||
fnLocalNames: Map<number, Set<string>>,
|
||||
hostValueNames: Map<number, Set<string>>,
|
||||
) {
|
||||
return {
|
||||
functionNodeTypes: new Set(['function_declaration']),
|
||||
callNodeTypes: new Set(['call_expression']),
|
||||
|
|
@ -698,7 +755,7 @@ function zigCallableCaptureOptions(fnLocalNames: Map<number, Set<string>>) {
|
|||
(child): child is SyntaxNode =>
|
||||
child !== null && child.id !== callee?.id && child.type !== 'comment',
|
||||
);
|
||||
const receiver = zigImplicitReceiver(call, fnLocalNames);
|
||||
const receiver = zigImplicitReceiver(call, fnLocalNames, hostValueNames);
|
||||
return receiver === undefined ? explicit : [receiver, ...explicit];
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -709,10 +766,12 @@ function zigCallableCaptureOptions(fnLocalNames: Map<number, Set<string>>) {
|
|||
* (`Runner.init(…)`, `std.mem.eql(…)`, `List(u8).init(…)`, `@import(…).f(…)`).
|
||||
* Same value-vs-type rule as `zigCallReturnTypeOf` (F6): the chain head is a
|
||||
* name declared in the enclosing fn and is not TitleCase (a fn-local
|
||||
* TitleCase name is a type alias, F7). */
|
||||
* TitleCase name is a type alias, F7), or a module-level value
|
||||
* (`zigIsHostValueName`). */
|
||||
function zigImplicitReceiver(
|
||||
call: SyntaxNode,
|
||||
fnLocalNames: Map<number, Set<string>>,
|
||||
hostValueNames: Map<number, Set<string>>,
|
||||
): SyntaxNode | undefined {
|
||||
const callee = call.childForFieldName('function');
|
||||
if (callee === null || callee.type !== 'field_expression') return undefined;
|
||||
|
|
@ -720,9 +779,73 @@ function zigImplicitReceiver(
|
|||
if (object === null) return undefined; // `.init(…)` decl literal
|
||||
const head = zigChainHead(object);
|
||||
if (head === null || isZigTitleCase(head.text)) return undefined;
|
||||
const fn = zigEnclosingFunction(call);
|
||||
if (fn === null || !zigFunctionLocalNames(fn, fnLocalNames).has(head.text)) return undefined;
|
||||
return object;
|
||||
return zigIsValueName(call, head.text, fnLocalNames, hostValueNames) ? object : undefined;
|
||||
}
|
||||
|
||||
/** Is `name`, read at `at`, a VALUE — a fn-local (param / local / payload) of
|
||||
* the enclosing fn, or a module-level value declared by the file or by a
|
||||
* container enclosing `at`? Zig forbids shadowing, so the first declaration
|
||||
* found walking outwards is the only one. Namespaces (`std`, an `@import`
|
||||
* handle, `const mem = std.mem`) and types are not values. */
|
||||
function zigIsValueName(
|
||||
at: SyntaxNode,
|
||||
name: string,
|
||||
fnLocalNames: Map<number, Set<string>>,
|
||||
hostValueNames: Map<number, Set<string>>,
|
||||
): boolean {
|
||||
const fn = zigEnclosingFunction(at);
|
||||
if (fn !== null && zigFunctionLocalNames(fn, fnLocalNames).has(name)) return true;
|
||||
let host = zigIdentityHost(at);
|
||||
while (host !== null) {
|
||||
if (!isZigCallableNode(host) && zigHostValueNames(host, hostValueNames).has(name)) return true;
|
||||
host = zigIdentityHost(host);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The names a HOST (the file root or a container node) declares DIRECTLY as
|
||||
* values — module-level state such as `var global_runner = Runner{};`,
|
||||
* `var pool: Pool = undefined;`, `const default_config = Config.load();`,
|
||||
* `const max = 16;`. Decided from the declaration's shape, the only evidence
|
||||
* available before finalization:
|
||||
* - annotated (`var x: T = …`, `const x: T;`) or initialized with a struct
|
||||
* literal, a literal, or a call that is not a generic type instantiation
|
||||
* (`isZigTypeConstructorCall`) → value;
|
||||
* - container / `@import` bindings, TitleCase names (types, F7), and
|
||||
* values that merely ALIAS another name (`const mem = std.mem;`,
|
||||
* `var cur = orig;`) → not a value here: an alias is whatever it aliases,
|
||||
* and a namespace alias prepended as a receiver would shift every
|
||||
* callback index the other way.
|
||||
* Lazily computed once per host node. */
|
||||
function zigHostValueNames(host: SyntaxNode, cache: Map<number, Set<string>>): Set<string> {
|
||||
const cached = cache.get(host.id);
|
||||
if (cached !== undefined) return cached;
|
||||
const names = new Set<string>();
|
||||
for (let i = 0; i < host.namedChildCount; i++) {
|
||||
const decl = host.namedChild(i);
|
||||
if (decl === null || decl.type !== 'variable_declaration' || !isZigKeywordDeclaration(decl)) {
|
||||
continue;
|
||||
}
|
||||
const named = decl.namedChildren.filter((c): c is SyntaxNode => c !== null);
|
||||
const name = named[0];
|
||||
if (name === undefined || name.type !== 'identifier' || isZigTitleCase(name.text)) continue;
|
||||
if (named.length < 2 || isZigContainerOrImportBinding(decl)) continue;
|
||||
const last = named[named.length - 1]!;
|
||||
if (last.id !== decl.childForFieldName('type')?.id) {
|
||||
const value = zigUnwrapValue(last);
|
||||
if (
|
||||
value.type === 'identifier' ||
|
||||
value.type === 'field_expression' ||
|
||||
value.type === 'builtin_function' ||
|
||||
(value.type === 'call_expression' && isZigTypeConstructorCall(value))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
names.add(name.text);
|
||||
}
|
||||
cache.set(host.id, names);
|
||||
return names;
|
||||
}
|
||||
|
||||
// ─── F6: value-inferred and return-type bindings ─────────────────────────────
|
||||
|
|
@ -838,6 +961,7 @@ function zigEnclosingFunction(node: SyntaxNode): SyntaxNode | null {
|
|||
export function zigCallReturnTypeOf(
|
||||
value: SyntaxNode,
|
||||
localNamesCache: Map<number, Set<string>>,
|
||||
hostValueNames: Map<number, Set<string>> = new Map(),
|
||||
):
|
||||
| { readonly type: string; readonly memberCall?: true; readonly structLiteral?: true }
|
||||
| undefined {
|
||||
|
|
@ -870,16 +994,15 @@ export function zigCallReturnTypeOf(
|
|||
if (isZigTitleCase(member.text)) return undefined;
|
||||
const head = zigChainHead(object);
|
||||
if (head !== null) {
|
||||
const fn = zigEnclosingFunction(value);
|
||||
// A fn-local name is a VALUE receiver — unless it is TitleCase: `const R
|
||||
// = generic.List(u8); var l = R.init();` binds a type alias inside the fn
|
||||
// (F7), and `R.init()` names the type `R` exactly like `Counter.init()`
|
||||
// does at module level. Zig's naming convention (types TitleCase, values
|
||||
// snake_case) is the same signal `isZigTypeConstructorCall` relies on.
|
||||
// A fn-local or module-level value name is a VALUE receiver — unless it
|
||||
// is TitleCase: `const R = generic.List(u8); var l = R.init();` binds a
|
||||
// type alias inside the fn (F7), and `R.init()` names the type `R`
|
||||
// exactly like `Counter.init()` does at module level. Zig's naming
|
||||
// convention (types TitleCase, values snake_case) is the same signal
|
||||
// `isZigTypeConstructorCall` relies on.
|
||||
if (
|
||||
fn !== null &&
|
||||
zigFunctionLocalNames(fn, localNamesCache).has(head.text) &&
|
||||
!isZigTitleCase(head.text)
|
||||
!isZigTitleCase(head.text) &&
|
||||
zigIsValueName(value, head.text, localNamesCache, hostValueNames)
|
||||
) {
|
||||
return { type: `${object.text}.${member.text}()`, memberCall: true };
|
||||
}
|
||||
|
|
@ -1049,10 +1172,62 @@ export function emitZigScopeCaptures(
|
|||
const byName = new Map(m.captures.map((c) => [c.name, c.node] as const));
|
||||
const stmt = byName.get('alias.statement');
|
||||
const ns = byName.get('alias.namespace');
|
||||
if (stmt !== undefined && ns !== undefined && importSources.has(ns.text)) {
|
||||
// Only a ONE-level member (`const Counter = counter.Counter;`) is the
|
||||
// named-import fact. A deeper chain (`const chosen = lib.B.work;`) names a
|
||||
// member OF a member: promoting it to a named import of `work` from
|
||||
// `lib.zig` discarded the written owner `B`, and `findExportedDef` then
|
||||
// answered with the first `work` in the file — `A.work` (PR #1432 review,
|
||||
// 8.4). Those stay Consts and are rewritten at their use sites instead
|
||||
// (`collectZigDeepAliases`).
|
||||
if (
|
||||
stmt !== undefined &&
|
||||
ns !== undefined &&
|
||||
importSources.has(ns.text) &&
|
||||
zigMemberChainOf(stmt)?.members.length === 1
|
||||
) {
|
||||
aliasDeclIds.add(stmt.id);
|
||||
}
|
||||
}
|
||||
// Function-local `@import` bindings, keyed per enclosing callable (PR #1432
|
||||
// review, 8.9). Finalization flattens every import of a file onto its
|
||||
// Module scope, so two sibling fns each binding `const m = @import(…)` to a
|
||||
// different file became ONE `m → [a.zig, b.zig]` namespace bucket and both
|
||||
// `m.Thing{}` sites took the first target. The binding and every use of the
|
||||
// name inside that fn are rewritten to `m$<fn>` — a spelling no Zig
|
||||
// identifier can take — so each fn's handle is its own bucket and resolves
|
||||
// through its own lexical import (`rewriteZigFunctionLocalImportNames`).
|
||||
const fnLocalImports = new Map<
|
||||
number,
|
||||
{ readonly fn: SyntaxNode; readonly names: Map<string, string> }
|
||||
>();
|
||||
for (const m of rawMatches) {
|
||||
const byName = new Map(m.captures.map((c) => [c.name, c.node] as const));
|
||||
const importName = byName.get('import.name');
|
||||
const importStmt = byName.get('import.statement');
|
||||
const importSource = byName.get('import.source');
|
||||
if (importName === undefined || importStmt === undefined || importSource === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!isZigKeywordDeclaration(importStmt) || isZigTypePositionImport(importStmt, importSource)) {
|
||||
continue;
|
||||
}
|
||||
const fn = zigEnclosingFunction(importStmt);
|
||||
if (fn === null) continue;
|
||||
let entry = fnLocalImports.get(fn.id);
|
||||
if (entry === undefined) {
|
||||
entry = { fn, names: new Map() };
|
||||
fnLocalImports.set(fn.id, entry);
|
||||
}
|
||||
if (!entry.names.has(importName.text)) {
|
||||
entry.names.set(importName.text, zigFunctionLocalImportKey(importName.text, fn, _filePath));
|
||||
}
|
||||
}
|
||||
// Deep member aliases — `const chosen = @import("lib.zig").B.work;`,
|
||||
// `const chosen2 = lib.B.work;`, `const Inner = nested.Outer.Inner;` (PR
|
||||
// #1432 review, 8.4): the owner path is kept and the alias's use sites are
|
||||
// rewritten to qualified references (`chosen()` → `lib.B` . `work`), which
|
||||
// the namespace chain walk resolves segment by segment.
|
||||
const deepAliases = collectZigDeepAliases(tree.rootNode, importSources, fnLocalImports);
|
||||
|
||||
// File-struct (top-level fields): the file IS a type named after the file.
|
||||
// Emit a Class scope over the whole file (nested under the Module scope —
|
||||
|
|
@ -1075,8 +1250,11 @@ export function emitZigScopeCaptures(
|
|||
// `@This()` aliases: alias name ↦ the container it names (file stem for the
|
||||
// file-struct, binding name for `const Self = @This();` inside a container).
|
||||
const thisAliases = collectZigThisAliases(root, fileStructName, _filePath);
|
||||
// F6: fn-local names per function node, for `zigCallReturnTypeOf`.
|
||||
// F6: fn-local names per function node, for `zigCallReturnTypeOf`; module-
|
||||
// level value names per host node (file / container), for the value-vs-
|
||||
// namespace receiver rule (`zigHostValueNames`).
|
||||
const fnLocalNames = new Map<number, Set<string>>();
|
||||
const hostValueNames = new Map<number, Set<string>>();
|
||||
|
||||
for (const m of rawMatches) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
|
|
@ -1123,7 +1301,7 @@ export function emitZigScopeCaptures(
|
|||
const source = nodeMap['@import.source']!;
|
||||
if (claimedImportSourceIds.has(source.id)) continue;
|
||||
const sourceCapture = grouped['@import.source']!;
|
||||
if (isZigInlineImportReceiver(inlineImport)) {
|
||||
if (isZigInlineImportReceiver(inlineImport) || deepAliases.inlineRoots.has(inlineImport.id)) {
|
||||
const key = `receiver:${source.text}`;
|
||||
if (importedSourceTexts.has(key)) continue;
|
||||
importedSourceTexts.add(key);
|
||||
|
|
@ -1143,6 +1321,35 @@ export function emitZigScopeCaptures(
|
|||
continue;
|
||||
}
|
||||
|
||||
// `const chosen = @import("lib.zig").B.work;` — the two-member import
|
||||
// rule matched a DEEP chain. Binding `chosen` as a named import of the
|
||||
// innermost member `work` lost the owner `B` (8.4); bind the module under
|
||||
// the builtin's own text instead — the same namespace binding a member-
|
||||
// call receiver gets — and let the rewritten use sites walk `B.work`.
|
||||
const deepImportStmt = nodeMap['@import.statement'];
|
||||
if (
|
||||
deepImportStmt !== undefined &&
|
||||
nodeMap['@import.imported'] !== undefined &&
|
||||
deepAliases.declIds.has(deepImportStmt.id)
|
||||
) {
|
||||
const importRoot = zigImportRootOf(
|
||||
deepImportStmt.namedChildren.filter((c): c is SyntaxNode => c !== null).pop() ?? null,
|
||||
);
|
||||
const source = nodeMap['@import.source'];
|
||||
if (importRoot !== null && source !== undefined) {
|
||||
const key = `receiver:${source.text}`;
|
||||
if (!importedSourceTexts.has(key)) {
|
||||
importedSourceTexts.add(key);
|
||||
out.push({
|
||||
'@import.statement': nodeToCapture('@import.statement', importRoot),
|
||||
'@import.name': nodeToCapture('@import.name', importRoot),
|
||||
'@import.source': grouped['@import.source']!,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Member aliases: promote to a named import when the object is one of
|
||||
// this file's @import bindings; otherwise the group is inert (the same
|
||||
// node is also matched by the plain-variable rule).
|
||||
|
|
@ -1178,7 +1385,15 @@ export function emitZigScopeCaptures(
|
|||
// An @import binding (`const Stack = @import("counter.zig").Stack;`) is
|
||||
// an import, and a promoted member alias a named import: both already
|
||||
// carry the type through the import binding.
|
||||
if (aliasDeclIds.has(aliasAnchor.id) || isZigContainerOrImportBinding(aliasAnchor)) continue;
|
||||
// A deep alias off an inline import (`const Inner =
|
||||
// @import("n.zig").Outer.Inner;`) keeps its type binding: the written
|
||||
// path is the type (8.4).
|
||||
if (
|
||||
aliasDeclIds.has(aliasAnchor.id) ||
|
||||
(isZigContainerOrImportBinding(aliasAnchor) && !deepAliases.declIds.has(aliasAnchor.id))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const value = nodeMap['@type-binding.type'];
|
||||
if (value === undefined) continue;
|
||||
// `var b: Counter = undefined;` — `undefined` / `null` are anonymous
|
||||
|
|
@ -1249,7 +1464,7 @@ export function emitZigScopeCaptures(
|
|||
if (valueNode.id === nodeMap['@type-binding.call-return'].childForFieldName('type')?.id) {
|
||||
continue;
|
||||
}
|
||||
const inferred = zigCallReturnTypeOf(valueNode, fnLocalNames);
|
||||
const inferred = zigCallReturnTypeOf(valueNode, fnLocalNames, hostValueNames);
|
||||
if (inferred === undefined) continue;
|
||||
grouped['@type-binding.type'] = syntheticCapture(
|
||||
'@type-binding.type',
|
||||
|
|
@ -1358,16 +1573,14 @@ export function emitZigScopeCaptures(
|
|||
);
|
||||
}
|
||||
// F8 — FUNCTION-LOCAL named container (`const R = struct {…}` inside a
|
||||
// fn body): the def's qualified name is its identity (`string$R`,
|
||||
// matching the graph node the structure phase mints via
|
||||
// `zigContainerName`), while the scope still binds the spelling code
|
||||
// uses (`R.get()`, `self: *R`) — the Java local-class split
|
||||
// (`@declaration.binding-name`, java/captures.ts).
|
||||
if (
|
||||
containerAnchor !== undefined &&
|
||||
nameNode !== undefined &&
|
||||
zigContainerAnchor(containerAnchor) === 'container'
|
||||
) {
|
||||
// fn body) and (8.5) CONTAINER-HOSTED named container (`pub const Item
|
||||
// = struct {…}` inside `A`): the def's qualified name is its identity
|
||||
// (`string$R`, `A.Item` — matching the graph node the structure phase
|
||||
// mints via `zigContainerName`), while the scope still binds the
|
||||
// spelling code uses (`R.get()`, `self: *R`, `Item{}`) — the Java
|
||||
// local / nested class split (`@declaration.binding-name`,
|
||||
// java/captures.ts).
|
||||
if (containerAnchor !== undefined && nameNode !== undefined) {
|
||||
const identity = zigContainerName(containerAnchor, _filePath);
|
||||
if (identity !== undefined && identity !== nameNode.text) {
|
||||
grouped['@declaration.binding-name'] = grouped['@declaration.name']!;
|
||||
|
|
@ -1534,13 +1747,400 @@ export function emitZigScopeCaptures(
|
|||
// the enclosing scope, where a `const Self = @This();` rewrite can find it.
|
||||
out.push(...synthesizeZigAnonymousContainerDeclarations(root, _filePath));
|
||||
|
||||
// Result-location sites — `const a: Counter = .init(1);`, `return .init(3);`,
|
||||
// `const b: Counter = .{ .n = 2 };` (8.6): the call / construction the query
|
||||
// cannot see because the type is written on the LEFT.
|
||||
out.push(...synthesizeZigResultLocationReferences(root, thisAliases, fileStructName));
|
||||
|
||||
out.push(
|
||||
...synthesizeCallableFlowCaptures(tree.rootNode, zigCallableCaptureOptions(fnLocalNames)),
|
||||
...synthesizeCallableFlowCaptures(
|
||||
tree.rootNode,
|
||||
zigCallableCaptureOptions(fnLocalNames, hostValueNames),
|
||||
),
|
||||
);
|
||||
|
||||
// Use-site rewrites, in this order: a deep alias's receiver may itself name
|
||||
// a fn-local import (`const m = @import(…); const w = m.B.work;`), and the
|
||||
// second pass rewrites that name inside the receiver text it just minted.
|
||||
rewriteZigDeepAliasReferences(out, deepAliases.aliases);
|
||||
rewriteZigFunctionLocalImportNames(out, fnLocalImports);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Use-site rewrites (8.4 / 8.9) and result-location sites (8.6) ────────────
|
||||
|
||||
/** The unique spelling a function-local import binding gets: `m$<callable>`
|
||||
* — `m$f_sib_a`, `m$Reflect$string`, `m$test$L12`. `$` cannot appear in a
|
||||
* Zig identifier, so the key collides with nothing the source declares;
|
||||
* every non-word character of the callable's qualified name becomes `$` so
|
||||
* the key stays a single receiver segment (a `.` would split it). */
|
||||
function zigFunctionLocalImportKey(name: string, fn: SyntaxNode, filePath: string): string {
|
||||
return `${name}$${zigCallableQualifiedName(fn, filePath).replace(/[^\w]/g, '$')}`;
|
||||
}
|
||||
|
||||
type ZigRange = Capture['range'];
|
||||
|
||||
/** The capture-side range of `node` (1-based lines, as `nodeToCapture`). */
|
||||
function zigNodeRange(node: SyntaxNode): ZigRange {
|
||||
return {
|
||||
startLine: node.startPosition.row + 1,
|
||||
startCol: node.startPosition.column,
|
||||
endLine: node.endPosition.row + 1,
|
||||
endCol: node.endPosition.column,
|
||||
};
|
||||
}
|
||||
|
||||
function zigRangeWithin(inner: ZigRange, outer: ZigRange): boolean {
|
||||
const startsAfter =
|
||||
inner.startLine > outer.startLine ||
|
||||
(inner.startLine === outer.startLine && inner.startCol >= outer.startCol);
|
||||
const endsBefore =
|
||||
inner.endLine < outer.endLine ||
|
||||
(inner.endLine === outer.endLine && inner.endCol <= outer.endCol);
|
||||
return startsAfter && endsBefore;
|
||||
}
|
||||
|
||||
/** Replace every bare identifier token `name` in `text` — outside string
|
||||
* literals, and not the member of a `.name` access or the tail of a
|
||||
* `@builtin` — with `replacement`. Zig forbids shadowing, so inside the
|
||||
* region a rewrite applies to, every such token is the same binding. */
|
||||
function zigReplaceIdentifier(text: string, name: string, replacement: string): string {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
let inString = false;
|
||||
while (i < text.length) {
|
||||
const ch = text[i]!;
|
||||
if (inString) {
|
||||
out += ch;
|
||||
if (ch === '\\') {
|
||||
out += text[i + 1] ?? '';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') inString = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
out += ch;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z_]/.test(ch)) {
|
||||
let j = i;
|
||||
while (j < text.length && /\w/.test(text[j]!)) j++;
|
||||
const word = text.slice(i, j);
|
||||
const prev = i > 0 ? text[i - 1] : '';
|
||||
out += word === name && prev !== '.' && prev !== '@' ? replacement : word;
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every capture whose text can spell a receiver, a type, a bound name or a
|
||||
* callable-flow cell — the ones a fn-local import name can appear in. */
|
||||
const ZIG_NAME_BEARING_TAGS: readonly string[] = [
|
||||
'@import.name',
|
||||
'@reference.receiver',
|
||||
'@reference.name',
|
||||
'@type-binding.type',
|
||||
'@callable-flow.target-name',
|
||||
'@callable-flow.target-qualified-name',
|
||||
'@callable-flow.receiver',
|
||||
'@callable-flow.source',
|
||||
'@callable-flow.destination',
|
||||
'@callable-flow.callee',
|
||||
'@callable-flow.direct-callee-name',
|
||||
];
|
||||
|
||||
/** 8.9 — rewrite a fn-local import's binding and its uses to its unique key
|
||||
* (`zigFunctionLocalImportKey`), within that fn's range only. */
|
||||
function rewriteZigFunctionLocalImportNames(
|
||||
out: CaptureMatch[],
|
||||
fnLocalImports: ReadonlyMap<
|
||||
number,
|
||||
{ readonly fn: SyntaxNode; readonly names: Map<string, string> }
|
||||
>,
|
||||
): void {
|
||||
if (fnLocalImports.size === 0) return;
|
||||
for (const { fn, names } of fnLocalImports.values()) {
|
||||
const fnRange = zigNodeRange(fn);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const group = out[i]!;
|
||||
let next: Record<string, Capture> | undefined;
|
||||
for (const tag of ZIG_NAME_BEARING_TAGS) {
|
||||
const cap = group[tag];
|
||||
if (cap === undefined || !zigRangeWithin(cap.range, fnRange)) continue;
|
||||
let text = cap.text;
|
||||
for (const [name, key] of names) text = zigReplaceIdentifier(text, name, key);
|
||||
if (text === cap.text) continue;
|
||||
next ??= { ...group };
|
||||
next[tag] = { ...cap, text };
|
||||
}
|
||||
if (next !== undefined) out[i] = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A `const X = <root>.<m1>.<m2>…;` alias (8.4): `X` stands for member `<mN>`
|
||||
* of the receiver `<root>.<m1>…<mN-1>`, inside `range` (null: the whole
|
||||
* file). */
|
||||
interface ZigDeepAlias {
|
||||
readonly name: string;
|
||||
readonly receiver: string;
|
||||
readonly member: string;
|
||||
readonly range: ZigRange | null;
|
||||
}
|
||||
|
||||
/** The member chain a declaration's value spells — `lib.B.work` →
|
||||
* `{ root: lib, members: [B, work] }`; undefined for anything but a
|
||||
* `field_expression` chain rooted in an identifier or an `@import(…)`. */
|
||||
function zigMemberChainOf(
|
||||
decl: SyntaxNode,
|
||||
): { readonly root: SyntaxNode; readonly members: readonly SyntaxNode[] } | undefined {
|
||||
const named = decl.namedChildren.filter((c): c is SyntaxNode => c !== null);
|
||||
if (named.length !== 2 || named[0]!.type !== 'identifier') return undefined;
|
||||
let cur: SyntaxNode | null = named[1]!;
|
||||
const members: SyntaxNode[] = [];
|
||||
while (cur !== null && cur.type === 'field_expression') {
|
||||
const member = cur.childForFieldName('member');
|
||||
const object = cur.childForFieldName('object');
|
||||
if (member === null || object === null) return undefined; // `.init` literal
|
||||
members.unshift(member);
|
||||
cur = object;
|
||||
}
|
||||
if (cur === null || members.length === 0) return undefined;
|
||||
if (cur.type !== 'identifier' && !isZigImportBuiltin(cur)) return undefined;
|
||||
return { root: cur, members };
|
||||
}
|
||||
|
||||
/** 8.4 — every deep member alias in the tree whose root is a module handle:
|
||||
* a file-level `@import` binding of this file, a fn-local one (its key is
|
||||
* applied by the later rewrite), or an inline `@import(…)`. Returns the
|
||||
* aliases, the declaring nodes (so their import / alias groups are handled
|
||||
* as deep aliases) and the inline-import roots (bound as namespaces). */
|
||||
function collectZigDeepAliases(
|
||||
root: SyntaxNode,
|
||||
importSources: ReadonlyMap<string, SyntaxNode>,
|
||||
fnLocalImports: ReadonlyMap<
|
||||
number,
|
||||
{ readonly fn: SyntaxNode; readonly names: Map<string, string> }
|
||||
>,
|
||||
): {
|
||||
readonly aliases: readonly ZigDeepAlias[];
|
||||
readonly declIds: ReadonlySet<number>;
|
||||
readonly inlineRoots: ReadonlySet<number>;
|
||||
} {
|
||||
const aliases: ZigDeepAlias[] = [];
|
||||
const declIds = new Set<number>();
|
||||
const inlineRoots = new Set<number>();
|
||||
const visit = (node: SyntaxNode): void => {
|
||||
if (node.type === 'variable_declaration' && isZigKeywordDeclaration(node)) {
|
||||
const chain = zigMemberChainOf(node);
|
||||
if (chain !== undefined && chain.members.length >= 2) {
|
||||
const fn = zigEnclosingFunction(node);
|
||||
const isHandle =
|
||||
isZigImportBuiltin(chain.root) ||
|
||||
importSources.has(chain.root.text) ||
|
||||
(fn !== null && fnLocalImports.get(fn.id)?.names.has(chain.root.text) === true);
|
||||
if (isHandle) {
|
||||
const host = zigIdentityHost(node);
|
||||
const receiver = [chain.root.text, ...chain.members.slice(0, -1).map((m) => m.text)].join(
|
||||
'.',
|
||||
);
|
||||
aliases.push({
|
||||
name: node.namedChild(0)!.text,
|
||||
receiver,
|
||||
member: chain.members[chain.members.length - 1]!.text,
|
||||
range: host === null || host.type === 'source_file' ? null : zigNodeRange(host),
|
||||
});
|
||||
declIds.add(node.id);
|
||||
if (isZigImportBuiltin(chain.root)) inlineRoots.add(chain.root.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child !== null) visit(child);
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return { aliases, declIds, inlineRoots };
|
||||
}
|
||||
|
||||
/** 8.4 — turn a free call / bare construction of a deep alias (`chosen()`,
|
||||
* `Inner{}`) into the qualified reference it stands for (receiver `lib.B`,
|
||||
* member `work`), so it resolves through the alias's written owner path and
|
||||
* never through a same-named member elsewhere in the module. */
|
||||
function rewriteZigDeepAliasReferences(
|
||||
out: CaptureMatch[],
|
||||
aliases: readonly ZigDeepAlias[],
|
||||
): void {
|
||||
if (aliases.length === 0) return;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const group = out[i]!;
|
||||
const free = group['@reference.call.free'];
|
||||
const ctor = group['@reference.call.constructor'];
|
||||
if ((free === undefined && ctor === undefined) || group['@reference.receiver'] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
const nameCap = group['@reference.name'];
|
||||
if (nameCap === undefined) continue;
|
||||
const alias = aliases.find(
|
||||
(a) =>
|
||||
a.name === nameCap.text && (a.range === null || zigRangeWithin(nameCap.range, a.range)),
|
||||
);
|
||||
if (alias === undefined) continue;
|
||||
const next: Record<string, Capture> = { ...group };
|
||||
if (free !== undefined) {
|
||||
delete next['@reference.call.free'];
|
||||
next['@reference.call.member'] = { ...free, name: '@reference.call.member' };
|
||||
}
|
||||
next['@reference.receiver'] = {
|
||||
name: '@reference.receiver',
|
||||
range: nameCap.range,
|
||||
text: alias.receiver,
|
||||
};
|
||||
next['@reference.name'] = { ...nameCap, text: alias.member };
|
||||
out[i] = next;
|
||||
}
|
||||
}
|
||||
|
||||
/** 8.6 — reference sites for RESULT-LOCATION expressions: a decl literal
|
||||
* `.init(…)` or an anonymous literal `.{…}` whose type comes from where the
|
||||
* value lands — a declared variable (`const a: Counter = .init(1);`), a
|
||||
* function's return (`fn make() Counter { return .init(3); }`), a container
|
||||
* field's default (`n: Counter = .init(0),`). The query cannot see these:
|
||||
* its member-call rule needs an object and its constructor rule a type
|
||||
* identifier, and the annotation only typed the VARIABLE — the `init` call
|
||||
* and the `Counter{…}` construction event were absent from the graph. Each
|
||||
* site is emitted with the expected type as its receiver (`Counter`,
|
||||
* `stdx.Thing`, `@This()` and `Self` rewritten to the container), so it
|
||||
* resolves exactly as `Counter.init(1)` / `Counter{…}` would — through the
|
||||
* class binding or the namespace chain, never by simple name workspace-wide.
|
||||
* Arguments (`f(.init(1))`) are out: the expected type is the callee's
|
||||
* parameter, which needs the resolved callee. */
|
||||
function synthesizeZigResultLocationReferences(
|
||||
root: SyntaxNode,
|
||||
thisAliases: ReadonlyMap<number, { readonly alias: string; readonly container: string }>,
|
||||
fileStructName: string | undefined,
|
||||
): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
const emit = (expected: SyntaxNode, value: SyntaxNode): void => {
|
||||
const inner = zigUnwrapValue(value);
|
||||
let isDeclLiteral = false;
|
||||
let member: SyntaxNode | null = null;
|
||||
if (inner.type === 'call_expression') {
|
||||
const callee = inner.childForFieldName('function');
|
||||
if (callee?.type !== 'field_expression' || callee.childForFieldName('object') !== null)
|
||||
return;
|
||||
member = callee.childForFieldName('member');
|
||||
if (member === null) return;
|
||||
isDeclLiteral = true;
|
||||
} else if (inner.type !== 'anonymous_struct_initializer') {
|
||||
return;
|
||||
}
|
||||
let text = expected.text;
|
||||
if (text.includes('@This()')) {
|
||||
const target = zigThisTargetFor(expected, fileStructName);
|
||||
if (target === undefined) return;
|
||||
text = text.replace('@This()', target);
|
||||
} else {
|
||||
text = rewriteZigThisAlias(expected, thisAliases) ?? text;
|
||||
}
|
||||
const nominal = normalizeZigTypeName(text);
|
||||
// A builtin / primitive (`u32`, `void`, `anyerror`) constructs nothing.
|
||||
if (!/^[A-Z@]/.test(nominal) && !nominal.includes('.')) return;
|
||||
if (isDeclLiteral) {
|
||||
out.push({
|
||||
'@reference.call.member': nodeToCapture('@reference.call.member', inner),
|
||||
'@reference.receiver': syntheticCapture('@reference.receiver', inner, nominal),
|
||||
'@reference.name': nodeToCapture('@reference.name', member!),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const dot = zigLastTopLevelDot(nominal);
|
||||
out.push(
|
||||
dot === -1
|
||||
? {
|
||||
'@reference.call.constructor': nodeToCapture('@reference.call.constructor', inner),
|
||||
'@reference.name': syntheticCapture('@reference.name', inner, nominal),
|
||||
}
|
||||
: {
|
||||
'@reference.call.constructor': nodeToCapture('@reference.call.constructor', inner),
|
||||
'@reference.receiver': syntheticCapture(
|
||||
'@reference.receiver',
|
||||
inner,
|
||||
nominal.slice(0, dot),
|
||||
),
|
||||
'@reference.name': syntheticCapture('@reference.name', inner, nominal.slice(dot + 1)),
|
||||
},
|
||||
);
|
||||
};
|
||||
const visit = (node: SyntaxNode): void => {
|
||||
if (node.type === 'variable_declaration' && isZigKeywordDeclaration(node)) {
|
||||
const typeNode = node.childForFieldName('type');
|
||||
const named = node.namedChildren.filter((c): c is SyntaxNode => c !== null);
|
||||
const last = named[named.length - 1];
|
||||
if (typeNode !== null && last !== undefined && last.id !== typeNode.id) emit(typeNode, last);
|
||||
} else if (node.type === 'return_expression') {
|
||||
const fn = zigEnclosingFunction(node);
|
||||
const ret = fn?.type === 'function_declaration' ? fn.childForFieldName('type') : null;
|
||||
const value = node.namedChild(0);
|
||||
if (ret !== null && ret !== undefined && value !== null && zigReturnTypeIsNominal(ret)) {
|
||||
emit(ret, value);
|
||||
}
|
||||
} else if (node.type === 'container_field') {
|
||||
const typeNode = node.childForFieldName('type');
|
||||
const nameNode = node.childForFieldName('name');
|
||||
const named = node.namedChildren.filter((c): c is SyntaxNode => c !== null);
|
||||
const last = named[named.length - 1];
|
||||
if (
|
||||
typeNode !== null &&
|
||||
last !== undefined &&
|
||||
last.id !== typeNode.id &&
|
||||
last.id !== nameNode?.id
|
||||
) {
|
||||
emit(typeNode, last);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child !== null) visit(child);
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Index of the last `.` at nesting depth 0 and outside string literals —
|
||||
* the one that separates `@import("a.zig").Thing` or `stdx.List(u8).Node`
|
||||
* into receiver and member. -1 when there is none. */
|
||||
function zigLastTopLevelDot(text: string): number {
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let last = -1;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]!;
|
||||
if (inString) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') inString = true;
|
||||
else if (ch === '(' || ch === '[') depth++;
|
||||
else if (ch === ')' || ch === ']') depth--;
|
||||
else if (ch === '.' && depth === 0) last = i;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/** One `@declaration.<kind>` group per anonymous container in the tree —
|
||||
* see the call site in `emitZigScopeCaptures` (F8). Named after
|
||||
* `synthesizeJavaAnonymousClassDeclarations`, which does the same for
|
||||
|
|
|
|||
|
|
@ -136,10 +136,13 @@ const ZIG_SCOPE_QUERY = `
|
|||
|
||||
;; Imports — const X = @import("...").X : a NAMED import of one member. The
|
||||
;; local name is whatever the user chose (\`const Alloc = @import("std").mem;\`
|
||||
;; is a rename), the imported name is the member. Deeper chains
|
||||
;; (\`@import("std").mem.Allocator\`) bind the innermost member — the file
|
||||
;; edge is what matters; a member-of-a-member resolves through the namespace
|
||||
;; later or not at all.
|
||||
;; is a rename), the imported name is the member. A deeper chain
|
||||
;; (\`@import("std").mem.Allocator\`, \`@import("lib.zig").B.work\`) is matched
|
||||
;; by the second rule below but is NOT bound as a named import of the
|
||||
;; innermost member: that discarded the written owner (\`B\`) and let a
|
||||
;; same-named \`A.work\` answer first. \`emitZigScopeCaptures\` binds the module
|
||||
;; under the builtin's text instead and rewrites the alias's use sites to the
|
||||
;; full path (\`collectZigDeepAliases\`, PR #1432 review 8.4).
|
||||
(variable_declaration
|
||||
"const" . (identifier) @import.name
|
||||
(field_expression
|
||||
|
|
@ -179,8 +182,9 @@ const ZIG_SCOPE_QUERY = `
|
|||
;; alias and \`emitZigScopeCaptures\` promotes the ones whose object is a
|
||||
;; known @import to a named import (same fact as \`const Counter =
|
||||
;; @import("counter.zig").Counter;\`); the rest stay ordinary variables.
|
||||
;; Deeper chains (\`std.mem.Allocator\`) bind the innermost member off the
|
||||
;; leftmost namespace.
|
||||
;; Only the ONE-level shape is promoted; a deeper chain (\`lib.B.work\`,
|
||||
;; \`std.mem.Allocator\`) is a deep alias — a Const whose use sites are
|
||||
;; rewritten to the written owner path (see the import rule above, 8.4).
|
||||
(variable_declaration
|
||||
"const" . (identifier) @alias.name
|
||||
(field_expression
|
||||
|
|
@ -371,6 +375,24 @@ const ZIG_SCOPE_QUERY = `
|
|||
(field_expression
|
||||
object: (_) @reference.receiver
|
||||
member: (identifier) @reference.name)) @reference.call.constructor
|
||||
|
||||
;; References — generic instantiation literals: List(u8){ ... } /
|
||||
;; lists.List(u8){ ... }. The type head is a call_expression — the
|
||||
;; instantiation of the type constructor — which neither constructor rule
|
||||
;; above matches, so the OUTER aggregate event had no site: only the inner
|
||||
;; \`List(u8)\` call (a free / member call reference on the call node) reached
|
||||
;; the graph (PR #1432 review, 8.11). Two sites on two anchors: the call
|
||||
;; (an invocation of \`List\`) and this initializer (a construction of the
|
||||
;; container \`List\` returns, marked \`(constructor)\`). The receiver form goes
|
||||
;; through the same namespace path as \`mod.T{}\`.
|
||||
(struct_initializer
|
||||
(call_expression
|
||||
function: (identifier) @reference.name)) @reference.call.constructor
|
||||
(struct_initializer
|
||||
(call_expression
|
||||
function: (field_expression
|
||||
object: (_) @reference.receiver
|
||||
member: (identifier) @reference.name))) @reference.call.constructor
|
||||
`;
|
||||
|
||||
let _parser: Parser | null = null;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@ export const zigScopeResolver: ScopeResolver = {
|
|||
// `src/terminal/` from outside it 46 → 253; into tigerbeetle's `stdx` hub
|
||||
// from outside it 837 → 1500 (136 `stdx.Type.fn(` sites, 289 annotations).
|
||||
namespaceExportsIncludeImportedNames: true,
|
||||
// A qualified receiver is a chain of `const` handles — hub modules
|
||||
// republishing modules (`hub.sub.Thing{}`), types nested in types
|
||||
// (`mod.Outer.Inner{}`), enum variants through the module
|
||||
// (`opmod.Op.lookup.event_max()`) — walked hop by hop from the verified
|
||||
// import; a one-hop split at the last dot resolved none of them.
|
||||
resolveNamespaceChains: true,
|
||||
|
||||
loadResolutionConfig: (repoPath: string) => loadZigBuildConfig(repoPath),
|
||||
|
||||
|
|
|
|||
|
|
@ -922,6 +922,24 @@ export interface ScopeResolver {
|
|||
*/
|
||||
readonly namespaceExportsIncludeImportedNames?: boolean;
|
||||
|
||||
/**
|
||||
* When true, a qualified receiver is walked SEGMENT BY SEGMENT from its
|
||||
* verified namespace root instead of being split once at the last dot:
|
||||
* `hub.sub.Thing{}` (a namespace republished by a hub — `pub const sub =
|
||||
* @import("sub.zig");`), `mod.Outer.Inner{}` (a type nested in a type),
|
||||
* `opmod.Op.lookup` (an enum variant reached through the module), and the
|
||||
* typed forms `x: mod.Outer.Inner`. Each hop is either a class-like member
|
||||
* of the current module(s) / the current class, or a namespace import
|
||||
* edge the current module's scope binds under that name; a hop that is
|
||||
* ambiguous — two files behind one handle disagree, or a name is both a
|
||||
* type and a republished module — resolves nothing rather than picking a
|
||||
* first match. Off, the receiver-bound paths (Case 1, Case 2's
|
||||
* namespace-qualified class, Case 3) keep their one-hop lookups exactly
|
||||
* as they are, so no existing edge moves; Zig opts in (PR #1432 review,
|
||||
* 8.10), whose module system is nothing but nested `const` handles.
|
||||
*/
|
||||
readonly resolveNamespaceChains?: boolean;
|
||||
|
||||
/**
|
||||
* How this language spells a construction expression, so the compound
|
||||
* receiver resolver can type an INLINE constructor receiver — the
|
||||
|
|
|
|||
|
|
@ -132,6 +132,17 @@ interface ResolveCompoundReceiverOptions {
|
|||
/** A namespace member may be a name the target module imported and
|
||||
* publishes (hub modules). See `ScopeResolver.namespaceExportsIncludeImportedNames`. */
|
||||
readonly namespaceExportsIncludeImportedNames?: boolean;
|
||||
/** Resolve a qualified CLASS name (`opmod.Op`, `hub.sub.Thing`,
|
||||
* `mod.Outer.Inner`) through the language's namespace chain walk
|
||||
* (`ScopeResolver.resolveNamespaceChains`). Seeds the dotted-chain walk
|
||||
* when its head is a namespace rather than a value: `opmod.Op.lookup` is
|
||||
* the enum `Op` reached through the module `opmod`, then its variant
|
||||
* `lookup` — a value of `Op` — and only then a method (PR #1432 review,
|
||||
* 8.10). Absent ⇒ the head must bind in scope, exactly as before. */
|
||||
readonly resolveQualifiedClass?: (
|
||||
qualifiedName: string,
|
||||
inScope: ScopeId,
|
||||
) => SymbolDefinition | undefined;
|
||||
/** Compact receiver chain for THIS site (`ReferenceSite.receiverChain`), when
|
||||
* the language's capture emitter produced one. Present ⇒ the structural fold
|
||||
* is tried before the text cascade; absent ⇒ behaviour is exactly as before.
|
||||
|
|
@ -1187,8 +1198,28 @@ export function resolveCompoundReceiverClass(
|
|||
options,
|
||||
);
|
||||
}
|
||||
// Namespace-qualified chain head — `opmod.Op.lookup` / `hub.sub.Thing.x`:
|
||||
// no binding and no class named `opmod`, but the LONGEST prefix the
|
||||
// language's chain walk accepts as a class seeds the walk (the class
|
||||
// itself, so a variant / static member hop is read off the class scope),
|
||||
// and the remaining segments are walked as members. Longest first: the
|
||||
// prefix is a class, not a value, and `a.B.C` must seed at `C`, not stop
|
||||
// at `B` and read `C` as a member of it.
|
||||
let firstHop = 1;
|
||||
if (currentClass === undefined && headType === undefined && options.resolveQualifiedClass) {
|
||||
for (let k = parts.length - 1; k >= 2; k--) {
|
||||
const prefix = parts.slice(0, k).join('.');
|
||||
if (prefix.includes('(')) continue;
|
||||
const seeded = options.resolveQualifiedClass(prefix, inScope);
|
||||
if (seeded === undefined) continue;
|
||||
currentClass = seeded;
|
||||
currentIsClassConstant = true;
|
||||
firstHop = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 1; i < parts.length && currentClass !== undefined; i++) {
|
||||
for (let i = firstHop; i < parts.length && currentClass !== undefined; i++) {
|
||||
const segment = parts[i];
|
||||
if (segment === undefined) break;
|
||||
const memberName = stripCallParens(segment);
|
||||
|
|
|
|||
|
|
@ -117,6 +117,53 @@ import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js';
|
|||
/** Subset of `ScopeResolver` consumed by this pass. Accepting the
|
||||
* subset rather than the full provider keeps tests and partial
|
||||
* refactors lighter — callers only need to populate what we read. */
|
||||
/** Split `text` at the dots that sit at nesting depth 0 and outside string
|
||||
* literals — `@import("a.zig").Outer.Inner` → three segments, not four;
|
||||
* `List(u8).Node` → two. The chain walk's segmenter. */
|
||||
function splitTopLevelDots(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let start = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]!;
|
||||
if (inString) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') inString = true;
|
||||
else if (ch === '(' || ch === '[' || ch === '<') depth++;
|
||||
else if (ch === ')' || ch === ']' || ch === '>') depth--;
|
||||
else if (ch === '.' && depth === 0) {
|
||||
out.push(text.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(text.slice(start));
|
||||
return out.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
/** Index of the last depth-0, outside-string dot of `text`, or -1. */
|
||||
function lastTopLevelDot(text: string): number {
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let last = -1;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]!;
|
||||
if (inString) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') inString = true;
|
||||
else if (ch === '(' || ch === '[' || ch === '<') depth++;
|
||||
else if (ch === ')' || ch === ']' || ch === '>') depth--;
|
||||
else if (ch === '.' && depth === 0) last = i;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
type ReceiverBoundProviderSubset = Pick<
|
||||
ScopeResolver,
|
||||
| 'isSuperReceiver'
|
||||
|
|
@ -139,6 +186,7 @@ type ReceiverBoundProviderSubset = Pick<
|
|||
| 'normalizeTypeArgument'
|
||||
| 'markConstructionSites'
|
||||
| 'namespaceExportsIncludeImportedNames'
|
||||
| 'resolveNamespaceChains'
|
||||
>;
|
||||
|
||||
/** A bare, undecorated identifier and nothing else — see {@link isBareTypeName}. */
|
||||
|
|
@ -340,31 +388,134 @@ export function emitReceiverBoundCalls(
|
|||
provider.namespaceExportsIncludeImportedNames === true
|
||||
? findExportedDefIncludingImportedNames(targetFile, name, index, scopes)
|
||||
: findExportedDef(targetFile, name, index);
|
||||
// `ns.Type` as a receiver, where `ns` is a verified namespace of the current
|
||||
// file and `Type` a class-like member of it. Unique across the namespace's
|
||||
// target files or nothing — two same-named classes behind one handle would
|
||||
// mint a confident wrong edge.
|
||||
const resolveNamespaceQualifiedClass = (
|
||||
receiverName: string,
|
||||
inScope: ScopeId,
|
||||
namespaceTargets: ReadonlyMap<string, readonly string[]>,
|
||||
// A class-like member `name` unique across `files`, or nothing — two
|
||||
// same-named classes behind one handle would mint a confident wrong edge.
|
||||
const uniqueClassAcross = (
|
||||
files: readonly string[],
|
||||
name: string,
|
||||
): SymbolDefinition | undefined => {
|
||||
const dot = receiverName.lastIndexOf('.');
|
||||
if (dot <= 0 || dot === receiverName.length - 1) return undefined;
|
||||
const head = receiverName.slice(0, dot);
|
||||
const tail = receiverName.slice(dot + 1);
|
||||
if (tail.includes('(') || tail.includes('[')) return undefined;
|
||||
const files = namespaceTargets.get(head);
|
||||
if (files === undefined || isNamespaceNameShadowed(head, inScope, scopes)) return undefined;
|
||||
let picked: SymbolDefinition | undefined;
|
||||
for (const file of files) {
|
||||
const def = lookupNamespaceMember(file, tail);
|
||||
const def = lookupNamespaceMember(file, name);
|
||||
if (def === undefined || !isClassLike(def.type)) continue;
|
||||
if (picked !== undefined && picked.nodeId !== def.nodeId) return undefined;
|
||||
picked = def;
|
||||
}
|
||||
return picked;
|
||||
};
|
||||
// A class-like def NESTED in `owner` (`A.Item` inside `A`): its qualified
|
||||
// name is the owner's plus the segment — the identity the structure phase
|
||||
// and `populateClassOwnedMembers` agree on — so the qualified-name index
|
||||
// answers directly; same file as the owner, unique or nothing. Only the
|
||||
// chain walk reads this: `findOwnedMember` knows methods and fields, and a
|
||||
// nested type is neither.
|
||||
const findNestedClass = (owner: SymbolDefinition, name: string): SymbolDefinition | undefined => {
|
||||
if (owner.qualifiedName === undefined || owner.qualifiedName.length === 0) return undefined;
|
||||
let picked: SymbolDefinition | undefined;
|
||||
for (const id of scopes.qualifiedNames.get(`${owner.qualifiedName}.${name}`)) {
|
||||
const def = scopes.defs.get(id);
|
||||
if (def === undefined || !isClassLike(def.type) || def.filePath !== owner.filePath) continue;
|
||||
if (picked !== undefined && picked.nodeId !== def.nodeId) return undefined;
|
||||
picked = def;
|
||||
}
|
||||
return picked;
|
||||
};
|
||||
// Namespace CHAIN walk (`ScopeResolver.resolveNamespaceChains`): resolve
|
||||
// every segment of a qualified prefix from its verified namespace root —
|
||||
// or, failing a namespace, from a class binding in scope (`Outer.Inner`).
|
||||
// The cursor is either "these module files" or "this class"; a hop from a
|
||||
// module is a class-like member of it (→ class) or a namespace-import edge
|
||||
// its module scope binds under the segment — a republished module,
|
||||
// `pub const sub = @import("sub.zig");` (→ files); a hop from a class is a
|
||||
// nested class-like. Anything ambiguous resolves nothing.
|
||||
const walkChains = provider.resolveNamespaceChains === true;
|
||||
const namespaceImportTargetsOf = (file: string, name: string): readonly string[] => {
|
||||
const moduleScope = index.moduleScopeByFile.get(file);
|
||||
if (moduleScope === undefined) return [];
|
||||
const out: string[] = [];
|
||||
for (const edge of scopes.imports.get(moduleScope.id) ?? []) {
|
||||
if (edge.kind !== 'namespace' || edge.localName !== name || edge.targetFile === null)
|
||||
continue;
|
||||
if (!out.includes(edge.targetFile)) out.push(edge.targetFile);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
type ChainCursor =
|
||||
| { readonly files: readonly string[] }
|
||||
| { readonly classDef: SymbolDefinition };
|
||||
const resolveNamespaceChain = (
|
||||
prefix: string,
|
||||
inScope: ScopeId,
|
||||
namespaceTargets: ReadonlyMap<string, readonly string[]>,
|
||||
): ChainCursor | undefined => {
|
||||
const segments = splitTopLevelDots(prefix);
|
||||
if (segments.length === 0) return undefined;
|
||||
let cursor: ChainCursor | undefined;
|
||||
let rest: readonly string[] = [];
|
||||
// The LONGEST namespace key wins: a provider may bind dotted handles
|
||||
// (`namespaceReceiverPaths`) and an inline `@import("x.zig")` handle
|
||||
// carries a dot of its own inside the quotes.
|
||||
for (let k = segments.length; k >= 1; k--) {
|
||||
const key = segments.slice(0, k).join('.');
|
||||
const files = namespaceTargets.get(key);
|
||||
if (files === undefined) continue;
|
||||
if (isNamespaceNameShadowed(key, inScope, scopes)) return undefined;
|
||||
cursor = { files };
|
||||
rest = segments.slice(k);
|
||||
break;
|
||||
}
|
||||
if (cursor === undefined) {
|
||||
const head = findClassBindingInScope(inScope, segments[0]!, scopes);
|
||||
if (head === undefined || !isClassLike(head.type)) return undefined;
|
||||
cursor = { classDef: head };
|
||||
rest = segments.slice(1);
|
||||
}
|
||||
for (const segment of rest) {
|
||||
if (segment.includes('(') || segment.includes('[')) return undefined;
|
||||
if ('files' in cursor) {
|
||||
const asClass = uniqueClassAcross(cursor.files, segment);
|
||||
const asModule: string[] = [];
|
||||
for (const file of cursor.files) {
|
||||
for (const target of namespaceImportTargetsOf(file, segment)) {
|
||||
if (!asModule.includes(target)) asModule.push(target);
|
||||
}
|
||||
}
|
||||
if (asClass !== undefined && asModule.length > 0) return undefined; // both — refuse
|
||||
if (asClass !== undefined) cursor = { classDef: asClass };
|
||||
else if (asModule.length > 0) cursor = { files: asModule };
|
||||
else return undefined;
|
||||
} else {
|
||||
const nested = findNestedClass(cursor.classDef, segment);
|
||||
if (nested === undefined) return undefined;
|
||||
cursor = { classDef: nested };
|
||||
}
|
||||
}
|
||||
return cursor;
|
||||
};
|
||||
// `ns.Type` as a receiver, where `ns` is a verified namespace of the current
|
||||
// file and `Type` a class-like member of it — or, with the chain walk, any
|
||||
// `a.b.c.Type` whose prefix resolves. Unique or nothing.
|
||||
const resolveNamespaceQualifiedClass = (
|
||||
receiverName: string,
|
||||
inScope: ScopeId,
|
||||
namespaceTargets: ReadonlyMap<string, readonly string[]>,
|
||||
): SymbolDefinition | undefined => {
|
||||
const dot = walkChains ? lastTopLevelDot(receiverName) : receiverName.lastIndexOf('.');
|
||||
if (dot <= 0 || dot === receiverName.length - 1) return undefined;
|
||||
const head = receiverName.slice(0, dot);
|
||||
const tail = receiverName.slice(dot + 1);
|
||||
if (tail.includes('(') || tail.includes('[')) return undefined;
|
||||
if (walkChains) {
|
||||
const cursor = resolveNamespaceChain(head, inScope, namespaceTargets);
|
||||
if (cursor === undefined) return undefined;
|
||||
return 'classDef' in cursor
|
||||
? findNestedClass(cursor.classDef, tail)
|
||||
: uniqueClassAcross(cursor.files, tail);
|
||||
}
|
||||
const files = namespaceTargets.get(head);
|
||||
if (files === undefined || isNamespaceNameShadowed(head, inScope, scopes)) return undefined;
|
||||
return uniqueClassAcross(files, tail);
|
||||
};
|
||||
const compoundOpts = {
|
||||
fieldFallback,
|
||||
elementTypeOf: provider.elementTypeOf,
|
||||
|
|
@ -815,7 +966,16 @@ export function emitReceiverBoundCalls(
|
|||
receiverPaths: provider.namespaceReceiverPaths,
|
||||
moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath),
|
||||
});
|
||||
const fileCompoundOpts = { ...compoundOpts, namespaceTargets };
|
||||
const fileCompoundOpts = {
|
||||
...compoundOpts,
|
||||
namespaceTargets,
|
||||
...(walkChains
|
||||
? {
|
||||
resolveQualifiedClass: (qualifiedName: string, inScope: ScopeId) =>
|
||||
resolveNamespaceQualifiedClass(qualifiedName, inScope, namespaceTargets),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
// Per-file resolved-callee-id capture context (#2227 U2). Built once per
|
||||
// file; `undefined` when the sink is absent (pdg off) so the `tryEmitEdge`
|
||||
// capture is a no-op and emission stays byte-identical (R4).
|
||||
|
|
@ -1302,11 +1462,18 @@ export function emitReceiverBoundCalls(
|
|||
// that is usually empty. Mirrors the order the compound-receiver
|
||||
// construction path already uses.
|
||||
const namespaceCandidates = namespaceTargets.get(receiverName);
|
||||
const targetFiles =
|
||||
let targetFiles: readonly string[] | undefined =
|
||||
namespaceCandidates !== undefined &&
|
||||
!isNamespaceNameShadowed(receiverName, site.inScope, scopes)
|
||||
? namespaceCandidates
|
||||
: undefined;
|
||||
// Chain walk: `hub.sub.helper()` / `hub.sub.Thing{}` — the receiver is
|
||||
// no handle of this file, but its segments reach a module (see
|
||||
// `resolveNamespaceChain`). A prefix that ends in a CLASS is Case 2's.
|
||||
if (targetFiles === undefined && walkChains && lastTopLevelDot(receiverName) > 0) {
|
||||
const cursor = resolveNamespaceChain(receiverName, site.inScope, namespaceTargets);
|
||||
if (cursor !== undefined && 'files' in cursor) targetFiles = cursor.files;
|
||||
}
|
||||
if (targetFiles !== undefined && provider.resolveQualifiedReceiverMember === undefined) {
|
||||
let found = false;
|
||||
for (const targetFile of targetFiles) {
|
||||
|
|
@ -1467,6 +1634,45 @@ export function emitReceiverBoundCalls(
|
|||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
// `A.Item{}` / `mod.Outer.Inner{}` — a construction whose member is a
|
||||
// type NESTED in the class the receiver names. Neither a method nor a
|
||||
// field, so the owner walk above cannot see it; the chain walk's
|
||||
// nested-class lookup can (`resolveNamespaceChains`).
|
||||
if (memberDef === undefined && walkChains && site.callForm === 'constructor') {
|
||||
const nested = findNestedClass(classDef, memberName);
|
||||
if (nested !== undefined) {
|
||||
if (
|
||||
suppressDeletedCallTarget(
|
||||
options.recordResolutionOutcome,
|
||||
parsed.filePath,
|
||||
site,
|
||||
nested,
|
||||
)
|
||||
) {
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
nested,
|
||||
constructionSiteReason(
|
||||
nested.filePath !== parsed.filePath ? 'import-resolved' : 'global',
|
||||
site,
|
||||
provider.markConstructionSites,
|
||||
),
|
||||
seen,
|
||||
0.85,
|
||||
collapse,
|
||||
calleeCapture,
|
||||
);
|
||||
if (ok) emitted++;
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (memberDef !== undefined) {
|
||||
if (
|
||||
suppressDeletedCallTarget(
|
||||
|
|
@ -1509,11 +1715,23 @@ export function emitReceiverBoundCalls(
|
|||
if (typeRef !== undefined && typeRef.rawName.includes('.')) {
|
||||
const [nsName, ...classNameParts] = typeRef.rawName.split('.');
|
||||
const className = classNameParts.join('.');
|
||||
const targetFiles3 = namespaceTargets.get(nsName);
|
||||
// With the chain walk the dotted type is resolved as a whole
|
||||
// (`x: mod.Outer.Inner`, `t: hub.sub.Thing`); the candidate list then
|
||||
// has one entry or none. Without it: the historical one-hop split.
|
||||
const chainDef3 = walkChains
|
||||
? resolveNamespaceQualifiedClass(typeRef.rawName, site.inScope, namespaceTargets)
|
||||
: undefined;
|
||||
const targetFiles3 = walkChains
|
||||
? chainDef3 === undefined
|
||||
? undefined
|
||||
: [chainDef3.filePath]
|
||||
: namespaceTargets.get(nsName);
|
||||
if (targetFiles3 !== undefined && className.length > 0) {
|
||||
let found3 = false;
|
||||
for (const targetFile3 of targetFiles3) {
|
||||
const classDef3 = lookupNamespaceMember(targetFile3, className);
|
||||
const classDef3 = walkChains
|
||||
? chainDef3
|
||||
: lookupNamespaceMember(targetFile3, className);
|
||||
if (classDef3 !== undefined) {
|
||||
const picked =
|
||||
site.kind === 'call'
|
||||
|
|
|
|||
|
|
@ -2481,6 +2481,21 @@ export const ZIG_QUERIES = `
|
|||
; never match and keep their Function ids.
|
||||
((source_file (container_field name: (identifier) @_field)) @definition.struct
|
||||
(#not-eq? @_field ""))
|
||||
; A FIELDLESS file-struct — \`Empty.zig\`: no field, but a top-level fn whose
|
||||
; first parameter is typed as the file's own type (\`self: *@This()\`, or
|
||||
; \`self: *Self\` beside \`const Self = @This();\`). Zero-sized types are still
|
||||
; constructed (\`Empty{}\`) and dispatched on, and keyed on fields alone the
|
||||
; file lost its Struct node and every \`e.ping()\` edge (PR #1432 review,
|
||||
; 8.12). The two rules over-match on purpose — any \`@This\` in a first
|
||||
; parameter, any top-level \`@This()\` alias — and the provider's
|
||||
; \`shouldSkipDefinitionCapture\` keeps only what \`isZigFileStruct\` (the
|
||||
; single predicate the owner walk and the scope side use) admits.
|
||||
((source_file (function_declaration (parameters . (parameter type: (_) @_recv))))
|
||||
@definition.struct
|
||||
(#match? @_recv "@This"))
|
||||
((source_file (variable_declaration (identifier) (builtin_function (builtin_identifier) @_this)))
|
||||
@definition.struct
|
||||
(#eq? @_this "@This"))
|
||||
|
||||
; Opaque: const Handle = opaque { ... } — the FFI handle type. It is a
|
||||
; container (it may declare methods, never fields), so it is labelled Struct:
|
||||
|
|
|
|||
34
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/build.zig
vendored
Normal file
34
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/build.zig
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
const std = @import("std");
|
||||
|
||||
// Two executables that each bind the alias "config" to THEIR OWN config.zig —
|
||||
// the ordinary multi-target layout. A single first-wins map of aliases sends
|
||||
// tool/main.zig's `@import("config")` to app/config.zig.
|
||||
pub fn build(b: *std.Build) void {
|
||||
const corelib = b.dependency("corelib", .{});
|
||||
|
||||
const app_config = b.createModule(.{ .root_source_file = b.path("src/app/config.zig") });
|
||||
const tool_config = b.createModule(.{ .root_source_file = b.path("src/tool/config.zig") });
|
||||
|
||||
const app = b.addExecutable(.{ .name = "app", .root_source_file = b.path("src/app/main.zig") });
|
||||
app.root_module.addImport("config", app_config);
|
||||
// A path dep's NAMED module (declared by libs/corelib/build.zig).
|
||||
app.root_module.addImport("api", corelib.module("core"));
|
||||
|
||||
const tool_mod = b.createModule(.{
|
||||
.root_source_file = b.path("src/tool/main.zig"),
|
||||
.imports = &.{ .{ .name = "config", .module = tool_config } },
|
||||
});
|
||||
const tool = b.addExecutable(.{ .name = "tool", .root_module = tool_mod });
|
||||
b.installArtifact(app);
|
||||
b.installArtifact(tool);
|
||||
|
||||
// Two modules rooted in ONE directory that disagree on "clash": a file of
|
||||
// that directory which is neither root cannot be attributed, and must
|
||||
// resolve nothing rather than the first declaration.
|
||||
const clash_a = b.createModule(.{ .root_source_file = b.path("src/shared/clash_a.zig") });
|
||||
const clash_b = b.createModule(.{ .root_source_file = b.path("src/shared/clash_b.zig") });
|
||||
const shared_a = b.addModule("shared_a", .{ .root_source_file = b.path("src/shared/a.zig") });
|
||||
shared_a.addImport("clash", clash_a);
|
||||
const shared_b = b.addModule("shared_b", .{ .root_source_file = b.path("src/shared/b.zig") });
|
||||
shared_b.addImport("clash", clash_b);
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/build.zig.zon
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/build.zig.zon
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.{
|
||||
.name = .buildmodules,
|
||||
.version = "0.1.0",
|
||||
.dependencies = .{
|
||||
.corelib = .{ .path = "libs/corelib" },
|
||||
},
|
||||
.paths = .{ "build.zig", "build.zig.zon", "src" },
|
||||
}
|
||||
4
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/libs/corelib/build.zig
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/libs/corelib/build.zig
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
const std = @import("std");
|
||||
pub fn build(b: *std.Build) void {
|
||||
_ = b.addModule("core", .{ .root_source_file = b.path("src/core.zig") });
|
||||
}
|
||||
1
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/libs/corelib/src/core.zig
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/libs/corelib/src/core.zig
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub fn ping() void {}
|
||||
1
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/app/config.zig
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/app/config.zig
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub fn load_app() void {}
|
||||
7
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/app/main.zig
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/app/main.zig
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const config = @import("config");
|
||||
const api = @import("api");
|
||||
|
||||
pub fn main() void {
|
||||
config.load_app();
|
||||
api.ping();
|
||||
}
|
||||
4
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/a.zig
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/a.zig
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
const clash = @import("clash");
|
||||
pub fn use_a() void {
|
||||
clash.hit_a();
|
||||
}
|
||||
4
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/b.zig
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/b.zig
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
const clash = @import("clash");
|
||||
pub fn use_b() void {
|
||||
clash.hit_b();
|
||||
}
|
||||
1
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/clash_a.zig
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/clash_a.zig
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub fn hit_a() void {}
|
||||
1
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/clash_b.zig
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/clash_b.zig
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub fn hit_b() void {}
|
||||
5
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/helper.zig
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/helper.zig
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Neither module root: "clash" is ambiguous here and must resolve nothing.
|
||||
const clash = @import("clash");
|
||||
pub fn use_helper() void {
|
||||
clash.hit_a();
|
||||
}
|
||||
1
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/tool/config.zig
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/tool/config.zig
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub fn load_tool() void {}
|
||||
5
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/tool/main.zig
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/tool/main.zig
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const config = @import("config");
|
||||
|
||||
pub fn run_tool() void {
|
||||
config.load_tool();
|
||||
}
|
||||
4
gitnexus/test/fixtures/lang-resolution/zig-chains/src/Empty.zig
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/zig-chains/src/Empty.zig
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
const Self = @This();
|
||||
pub fn ping(self: *Self) void {
|
||||
_ = self;
|
||||
}
|
||||
10
gitnexus/test/fixtures/lang-resolution/zig-chains/src/Host.zig
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/zig-chains/src/Host.zig
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
n: u32 = 0,
|
||||
pub const Inner = struct {
|
||||
pub fn m(self: Inner) void {
|
||||
_ = self;
|
||||
}
|
||||
};
|
||||
pub fn touch(self: *Host) void {
|
||||
_ = self;
|
||||
}
|
||||
const Host = @This();
|
||||
9
gitnexus/test/fixtures/lang-resolution/zig-chains/src/counter.zig
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/zig-chains/src/counter.zig
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
pub const Counter = struct {
|
||||
n: u32 = 0,
|
||||
pub fn init(n: u32) Counter {
|
||||
return .{ .n = n };
|
||||
}
|
||||
pub fn get(self: Counter) u32 {
|
||||
return self.n;
|
||||
}
|
||||
};
|
||||
1
gitnexus/test/fixtures/lang-resolution/zig-chains/src/hub.zig
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/zig-chains/src/hub.zig
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub const sub = @import("sub.zig");
|
||||
6
gitnexus/test/fixtures/lang-resolution/zig-chains/src/lib.zig
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/zig-chains/src/lib.zig
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub const A = struct {
|
||||
pub fn work() void {}
|
||||
};
|
||||
pub const B = struct {
|
||||
pub fn work() void {}
|
||||
};
|
||||
8
gitnexus/test/fixtures/lang-resolution/zig-chains/src/lists.zig
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/zig-chains/src/lists.zig
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
pub fn List(comptime T: type) type {
|
||||
return struct {
|
||||
items: []T = &.{},
|
||||
pub fn push(self: *@This()) void {
|
||||
_ = self;
|
||||
}
|
||||
};
|
||||
}
|
||||
112
gitnexus/test/fixtures/lang-resolution/zig-chains/src/main.zig
vendored
Normal file
112
gitnexus/test/fixtures/lang-resolution/zig-chains/src/main.zig
vendored
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
const counter = @import("counter.zig");
|
||||
const Counter = counter.Counter;
|
||||
const lib = @import("lib.zig");
|
||||
const hub = @import("hub.zig");
|
||||
const nested = @import("nested.zig");
|
||||
const lists = @import("lists.zig");
|
||||
const Empty = @import("Empty.zig");
|
||||
const runner = @import("runner.zig");
|
||||
const Runner = runner.Runner;
|
||||
const opmod = @import("op.zig");
|
||||
const List = lists.List;
|
||||
|
||||
var global_runner = Runner{};
|
||||
var global_runner2: Runner = undefined;
|
||||
|
||||
fn target_global() void {}
|
||||
fn target_global2() void {}
|
||||
fn target_local() void {}
|
||||
fn f_module_receiver() void {
|
||||
global_runner.run(target_global);
|
||||
global_runner2.run(target_global2);
|
||||
}
|
||||
fn f_local_receiver() void {
|
||||
var r = Runner{};
|
||||
r.run(target_local);
|
||||
}
|
||||
|
||||
const chosen = @import("lib.zig").B.work;
|
||||
const chosen2 = lib.B.work;
|
||||
fn f_deep_alias() void {
|
||||
chosen();
|
||||
chosen2();
|
||||
}
|
||||
|
||||
fn f_nested() void {
|
||||
var a = nested.A.Item{};
|
||||
a.run();
|
||||
var b = nested.B.Item{};
|
||||
b.run();
|
||||
}
|
||||
|
||||
fn f_result_location() u32 {
|
||||
const a: Counter = .init(1);
|
||||
const b: Counter = .{ .n = 2 };
|
||||
return a.get() + b.get();
|
||||
}
|
||||
fn f_return_decl_literal() Counter {
|
||||
return .init(3);
|
||||
}
|
||||
|
||||
fn f_sib_a() void {
|
||||
const m = @import("qa.zig");
|
||||
var t = m.Thing{};
|
||||
t.qa_only();
|
||||
m.hello();
|
||||
}
|
||||
fn f_sib_b() void {
|
||||
const m = @import("qb.zig");
|
||||
var t = m.Thing{};
|
||||
t.qb_only();
|
||||
m.hello();
|
||||
}
|
||||
|
||||
fn f_multihop() u32 {
|
||||
var s = hub.sub.Thing{};
|
||||
s.sub_m();
|
||||
var i = nested.Outer.Inner{};
|
||||
i.inner_m();
|
||||
_ = hub.sub.Thing.make();
|
||||
return opmod.Op.lookup.event_max();
|
||||
}
|
||||
|
||||
fn f_inline_generic() void {
|
||||
var t = @import("qa.zig").Thing{};
|
||||
t.qa_only();
|
||||
var l = lists.List(u8){};
|
||||
l.push();
|
||||
var l2 = List(u8){};
|
||||
l2.push();
|
||||
}
|
||||
|
||||
fn f_fieldless() void {
|
||||
var e = Empty{};
|
||||
e.ping();
|
||||
}
|
||||
|
||||
pub fn main() void {
|
||||
f_module_receiver();
|
||||
f_local_receiver();
|
||||
f_deep_alias();
|
||||
f_nested();
|
||||
_ = f_result_location();
|
||||
_ = f_return_decl_literal();
|
||||
f_sib_a();
|
||||
f_sib_b();
|
||||
_ = f_multihop();
|
||||
f_inline_generic();
|
||||
f_fieldless();
|
||||
}
|
||||
const Host = @import("Host.zig");
|
||||
fn f_filestruct_nested() void {
|
||||
var x = Host.Inner{};
|
||||
x.m();
|
||||
var h = Host{};
|
||||
h.touch();
|
||||
_ = hub.sub.Thing.make();
|
||||
var e2: Empty = .{};
|
||||
e2.ping();
|
||||
}
|
||||
fn f_calls_more() void {
|
||||
f_filestruct_nested();
|
||||
}
|
||||
21
gitnexus/test/fixtures/lang-resolution/zig-chains/src/nested.zig
vendored
Normal file
21
gitnexus/test/fixtures/lang-resolution/zig-chains/src/nested.zig
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
pub const A = struct {
|
||||
pub const Item = struct {
|
||||
pub fn run(self: Item) void {
|
||||
_ = self;
|
||||
}
|
||||
};
|
||||
};
|
||||
pub const B = struct {
|
||||
pub const Item = struct {
|
||||
pub fn run(self: Item) void {
|
||||
_ = self;
|
||||
}
|
||||
};
|
||||
};
|
||||
pub const Outer = struct {
|
||||
pub const Inner = struct {
|
||||
pub fn inner_m(self: Inner) void {
|
||||
_ = self;
|
||||
}
|
||||
};
|
||||
};
|
||||
7
gitnexus/test/fixtures/lang-resolution/zig-chains/src/op.zig
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/zig-chains/src/op.zig
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub const Op = enum(u8) {
|
||||
create = 1,
|
||||
lookup = 2,
|
||||
pub fn event_max(self: Op) u32 {
|
||||
return @intFromEnum(self);
|
||||
}
|
||||
};
|
||||
6
gitnexus/test/fixtures/lang-resolution/zig-chains/src/qa.zig
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/zig-chains/src/qa.zig
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub const Thing = struct {
|
||||
pub fn qa_only(self: Thing) void {
|
||||
_ = self;
|
||||
}
|
||||
};
|
||||
pub fn hello() void {}
|
||||
6
gitnexus/test/fixtures/lang-resolution/zig-chains/src/qb.zig
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/zig-chains/src/qb.zig
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub const Thing = struct {
|
||||
pub fn qb_only(self: Thing) void {
|
||||
_ = self;
|
||||
}
|
||||
};
|
||||
pub fn hello() void {}
|
||||
7
gitnexus/test/fixtures/lang-resolution/zig-chains/src/runner.zig
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/zig-chains/src/runner.zig
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub const Runner = struct {
|
||||
x: u32 = 0,
|
||||
pub fn run(self: *Runner, cb: *const fn () void) void {
|
||||
_ = self;
|
||||
cb();
|
||||
}
|
||||
};
|
||||
8
gitnexus/test/fixtures/lang-resolution/zig-chains/src/sub.zig
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/zig-chains/src/sub.zig
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
pub const Thing = struct {
|
||||
pub fn sub_m(self: Thing) void {
|
||||
_ = self;
|
||||
}
|
||||
pub fn make() Thing {
|
||||
return .{};
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Zig: per-build-module import tables (PR #1432 review, finding 8.2).
|
||||
*
|
||||
* `build.zig` binds bare-name aliases PER MODULE (`exe.root_module.addImport
|
||||
* ("config", …)`); flattening every alias into one repo-wide first-wins map
|
||||
* sent the second module's `@import("config")` to the first module's file.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
edgeSet,
|
||||
FIXTURES,
|
||||
getRelationships,
|
||||
runPipelineFromRepo,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
|
||||
import { describeGrammarPresence, optionalGrammarGate } from '../../helpers/optional-grammar.js';
|
||||
|
||||
const zig = optionalGrammarGate(SupportedLanguages.Zig);
|
||||
const zigAvailable = zig.available;
|
||||
|
||||
describeGrammarPresence(zig);
|
||||
|
||||
describe.skipIf(!zigAvailable)('Zig per-module build imports (zig-buildmodules fixture)', () => {
|
||||
let result: PipelineResult;
|
||||
let imports: string[];
|
||||
let calls: Set<string>;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-buildmodules'), () => {});
|
||||
imports = getRelationships(result, 'IMPORTS').map(
|
||||
(e) => `${e.sourceFilePath.replace(/\\/g, '/')} → ${e.targetFilePath.replace(/\\/g, '/')}`,
|
||||
);
|
||||
calls = edgeSet(getRelationships(result, 'CALLS'));
|
||||
}, 60000);
|
||||
|
||||
it('resolves each module’s `@import("config")` through ITS OWN import table, not the first-declared alias', () => {
|
||||
// Before: one `config → src/app/config.zig` entry for the whole repo, so
|
||||
// tool/main.zig imported app's config and `config.load_tool()` resolved
|
||||
// nothing (while `impact` on app's `load_app` gained a phantom caller).
|
||||
expect(imports).toContain('src/app/main.zig → src/app/config.zig');
|
||||
expect(imports).toContain('src/tool/main.zig → src/tool/config.zig');
|
||||
expect(imports).not.toContain('src/tool/main.zig → src/app/config.zig');
|
||||
expect(calls).toContain('main → load_app');
|
||||
expect(calls).toContain('run_tool → load_tool');
|
||||
});
|
||||
|
||||
it('resolves `addImport("api", dep.module("core"))` through the path dep’s own `addModule("core")`', () => {
|
||||
// `dep.module(…)` operands were deliberately unmodelled; the alias stayed
|
||||
// unresolved even though the dep's build.zig names the module statically.
|
||||
expect(imports).toContain('src/app/main.zig → libs/corelib/src/core.zig');
|
||||
expect(calls).toContain('main → ping');
|
||||
});
|
||||
|
||||
it('resolves a module ROOT through its own table even when it shares a directory with a disagreeing module', () => {
|
||||
expect(imports).toContain('src/shared/a.zig → src/shared/clash_a.zig');
|
||||
expect(imports).toContain('src/shared/b.zig → src/shared/clash_b.zig');
|
||||
expect(calls).toContain('use_a → hit_a');
|
||||
expect(calls).toContain('use_b → hit_b');
|
||||
});
|
||||
|
||||
it('fails closed for a non-root file whose same-directory modules disagree on the alias', () => {
|
||||
// helper.zig belongs to neither root; `shared_a` says clash_a.zig and
|
||||
// `shared_b` says clash_b.zig. First-wins would emit helper → clash_a —
|
||||
// a confident wrong edge — so the import resolves nothing instead.
|
||||
expect(imports.some((e) => e.startsWith('src/shared/helper.zig → '))).toBe(false);
|
||||
expect(calls).not.toContain('use_helper → hit_a');
|
||||
});
|
||||
});
|
||||
|
|
@ -930,6 +930,17 @@ describe.skipIf(!zigAvailable)(
|
|||
it('dispatches a method on an enum-typed parameter (`op: Op`)', () => {
|
||||
expect(calls).toContain('c8_enum_param_receiver → event_max');
|
||||
});
|
||||
|
||||
it('dispatches on a variant reached THROUGH THE MODULE (`opmod.Op.lookup.event_max()`)', () => {
|
||||
// The receiver `opmod.Op.lookup` is three hops: the module handle, the
|
||||
// enum inside it, the variant (a value of the enum). Split once at the
|
||||
// last dot, `opmod.Op` was looked up as a namespace key that does not
|
||||
// exist and the site resolved to nothing — the fixture line was
|
||||
// committed but never asserted (PR #1432 review, 8.10). The chain walk
|
||||
// seeds the compound resolver at the class `Op` and reads `lookup` as
|
||||
// its variant.
|
||||
expect(calls).toContain('c9_enum_qualified_variant_receiver → event_max');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -1000,3 +1011,199 @@ describe.skipIf(!zigAvailable)(
|
|||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(!zigAvailable)(
|
||||
'Zig qualified chains, deep aliases and result-location sites (PR #1432 review, 8.3–8.12)',
|
||||
() => {
|
||||
// One fixture per finding of the adversarial review, each with the decoy
|
||||
// that made the old answer WRONG rather than merely missing: two nested
|
||||
// `Item`s, `A.work` declared before `B.work`, two sibling fns binding `m`
|
||||
// to different files, a hub republishing a module, a fieldless file type.
|
||||
let result: PipelineResult;
|
||||
/** `caller → targetId reason`, callers in main.zig only. */
|
||||
let calls: string[];
|
||||
let nodeIds: Set<string>;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-chains'), () => {});
|
||||
calls = getRelationships(result, 'CALLS')
|
||||
.filter((e) => e.sourceFilePath.endsWith('main.zig'))
|
||||
.map((e) => `${e.source} → ${e.rel.targetId} ${e.rel.reason ?? ''}`);
|
||||
nodeIds = new Set<string>();
|
||||
result.graph.forEachNode((n) => nodeIds.add(n.id));
|
||||
}, 60000);
|
||||
|
||||
it('8.5 — keys a container nested in a container by its owner (`A.Item` ≠ `B.Item`)', () => {
|
||||
// `nested.zig` declares `A.Item` and `B.Item`, each with `run`. By
|
||||
// binding name alone both types and both methods collapsed onto ONE
|
||||
// `Struct:…:Item` / `Item.run` — ownership and call targets were
|
||||
// irrecoverably false. The identity is owner-qualified, like Java's
|
||||
// `Outer.Inner`; the scope side still binds the lexical `Item`.
|
||||
expect(nodeIds.has('Struct:src/nested.zig:A.Item')).toBe(true);
|
||||
expect(nodeIds.has('Struct:src/nested.zig:B.Item')).toBe(true);
|
||||
expect(nodeIds.has('Struct:src/nested.zig:Outer.Inner')).toBe(true);
|
||||
expect(nodeIds.has('Struct:src/nested.zig:Item')).toBe(false);
|
||||
expect(nodeIds.has('Method:src/nested.zig:A.Item.run#0')).toBe(true);
|
||||
expect(nodeIds.has('Method:src/nested.zig:B.Item.run#0')).toBe(true);
|
||||
expect(nodeIds.has('Method:src/nested.zig:Item.run#0')).toBe(false);
|
||||
const owners = getRelationships(result, 'HAS_METHOD')
|
||||
.filter((e) => e.targetFilePath.endsWith('nested.zig'))
|
||||
.map((e) => `${e.source} → ${e.target}`)
|
||||
.sort();
|
||||
expect(owners).toEqual(['A.Item → run', 'B.Item → run', 'Outer.Inner → inner_m']);
|
||||
// …and each construction / call lands on ITS type.
|
||||
expect(calls).toContain(
|
||||
'f_nested → Struct:src/nested.zig:A.Item import-resolved (constructor)',
|
||||
);
|
||||
expect(calls).toContain(
|
||||
'f_nested → Struct:src/nested.zig:B.Item import-resolved (constructor)',
|
||||
);
|
||||
expect(calls).toContain('f_nested → Method:src/nested.zig:A.Item.run#0 import-resolved');
|
||||
expect(calls).toContain('f_nested → Method:src/nested.zig:B.Item.run#0 import-resolved');
|
||||
});
|
||||
|
||||
it('8.3 — a MODULE-level value receiver passes itself as `self`, so the callback joins `cb`, not `self`', () => {
|
||||
// `global_runner.run(target_global)` against `run(self, cb)`: only a
|
||||
// fn-local head got the implicit receiver prepended, so `target_global`
|
||||
// sat at actual 0, joined `self@0`, and the `run → target_global` edge
|
||||
// was missing while the fn-local spelling `r.run(target_local)` had its
|
||||
// edge. Same for the annotated `var global_runner2: Runner = undefined`.
|
||||
const flow = getRelationships(result, 'CALLS')
|
||||
.filter((e) => e.rel.reason === 'callable-value-flow')
|
||||
.map((e) => `${e.source} → ${e.target}`);
|
||||
expect(flow).toEqual(
|
||||
expect.arrayContaining([
|
||||
'run → target_local',
|
||||
'run → target_global',
|
||||
'run → target_global2',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('8.4 — a deep alias keeps its written owner (`@import("lib.zig").B.work` is `B.work`, not the first `work`)', () => {
|
||||
// `lib.zig` declares `A.work` BEFORE `B.work`. The alias used to become a
|
||||
// named import of the tail `work`, and the first `work` in the module —
|
||||
// `A.work` — answered with exact confidence. Both the inline-import
|
||||
// spelling (`chosen`) and the handle spelling (`chosen2 = lib.B.work`)
|
||||
// must land on `B.work`; nothing may land on `A.work`.
|
||||
const deep = calls.filter((c) => c.startsWith('f_deep_alias → '));
|
||||
expect(deep).toContain('f_deep_alias → Method:src/lib.zig:B.work#0 import-resolved');
|
||||
expect(deep.some((c) => c.includes('A.work'))).toBe(false);
|
||||
});
|
||||
|
||||
it('8.6 — result-location `.init(…)` and `.{…}` emit the call and the construction the annotation implies', () => {
|
||||
// `const a: Counter = .init(1);` typed `a` but the `init` CALL was absent
|
||||
// from the graph; `const b: Counter = .{ .n = 2 };` had no construction
|
||||
// event. `return .init(3)` in a fn returning `Counter` likewise.
|
||||
expect(calls).toContain(
|
||||
'f_result_location → Method:src/counter.zig:Counter.init#1 import-resolved',
|
||||
);
|
||||
expect(calls).toContain(
|
||||
'f_result_location → Struct:src/counter.zig:Counter import-resolved (constructor)',
|
||||
);
|
||||
expect(calls).toContain(
|
||||
'f_return_decl_literal → Method:src/counter.zig:Counter.init#1 import-resolved',
|
||||
);
|
||||
// The variables themselves stay typed by the annotation.
|
||||
expect(calls).toContain(
|
||||
'f_result_location → Method:src/counter.zig:Counter.get#0 import-resolved',
|
||||
);
|
||||
});
|
||||
|
||||
it('8.9 — sibling fns binding the same local `m` to different files each resolve through their own import', () => {
|
||||
// `f_sib_a` binds `const m = @import("qa.zig")`, `f_sib_b` binds
|
||||
// `@import("qb.zig")`. Finalization flattens both onto the module scope:
|
||||
// `m → [qa.zig, qb.zig]`, and Case 1 took the first target for BOTH —
|
||||
// `f_sib_b → qa.zig`'s `Thing` and `hello` (wrong edges, not missing).
|
||||
const a = calls.filter((c) => c.startsWith('f_sib_a → '));
|
||||
const b = calls.filter((c) => c.startsWith('f_sib_b → '));
|
||||
expect(a).toEqual(
|
||||
expect.arrayContaining([
|
||||
'f_sib_a → Struct:src/qa.zig:Thing import-resolved (constructor)',
|
||||
'f_sib_a → Function:src/qa.zig:hello import-resolved',
|
||||
'f_sib_a → Method:src/qa.zig:Thing.qa_only#0 import-resolved',
|
||||
]),
|
||||
);
|
||||
expect(b).toEqual(
|
||||
expect.arrayContaining([
|
||||
'f_sib_b → Struct:src/qb.zig:Thing import-resolved (constructor)',
|
||||
'f_sib_b → Function:src/qb.zig:hello import-resolved',
|
||||
'f_sib_b → Method:src/qb.zig:Thing.qb_only#0 import-resolved',
|
||||
]),
|
||||
);
|
||||
expect(b.some((c) => c.includes('src/qa.zig'))).toBe(false);
|
||||
expect(a.some((c) => c.includes('src/qb.zig'))).toBe(false);
|
||||
});
|
||||
|
||||
it('8.10 — qualified chains are walked segment by segment: hub-republished module, nested type', () => {
|
||||
// `hub.sub.Thing{}` (`hub.zig`: `pub const sub = @import("sub.zig");`),
|
||||
// `nested.Outer.Inner{}` and `hub.sub.Thing.make()` all arrive with a
|
||||
// receiver whose prefix is not a namespace KEY of main.zig; the one-hop
|
||||
// split at the last dot asked for `hub.sub` / `nested.Outer` as exact
|
||||
// keys and resolved nothing.
|
||||
const hop = calls.filter((c) => c.startsWith('f_multihop → '));
|
||||
expect(hop).toEqual(
|
||||
expect.arrayContaining([
|
||||
'f_multihop → Struct:src/sub.zig:Thing import-resolved (constructor)',
|
||||
'f_multihop → Method:src/sub.zig:Thing.sub_m#0 import-resolved',
|
||||
'f_multihop → Method:src/sub.zig:Thing.make#0 import-resolved',
|
||||
'f_multihop → Struct:src/nested.zig:Outer.Inner import-resolved (constructor)',
|
||||
'f_multihop → Method:src/nested.zig:Outer.Inner.inner_m#0 import-resolved',
|
||||
'f_multihop → Method:src/op.zig:Op.event_max#0 import-resolved',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('8.11 — inline-import and generic-instantiation literals are construction events', () => {
|
||||
// `@import("qa.zig").Thing{}`: the module is the receiver of a
|
||||
// construction exactly as of a member call, but only the call shape was
|
||||
// bound as a namespace. `List(u8){}` / `lists.List(u8){}`: the type
|
||||
// head is a call_expression, which neither constructor rule matched, so
|
||||
// only the inner `List(u8)` invocation reached the graph and the outer
|
||||
// aggregate event had no site.
|
||||
const gen = calls.filter((c) => c.startsWith('f_inline_generic → '));
|
||||
expect(gen).toEqual(
|
||||
expect.arrayContaining([
|
||||
'f_inline_generic → Struct:src/qa.zig:Thing import-resolved (constructor)',
|
||||
'f_inline_generic → Method:src/qa.zig:Thing.qa_only#0 import-resolved',
|
||||
'f_inline_generic → Struct:src/lists.zig:List import-resolved (constructor)',
|
||||
'f_inline_generic → Method:src/lists.zig:List.push#0 import-resolved',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('8.12 — a FIELDLESS file type (`Empty.zig`: `const Self = @This(); fn ping(self: *Self)`) keeps its Struct', () => {
|
||||
// Keyed on top-level fields alone, `Empty.zig` was a namespace: no
|
||||
// `Struct`, `ping` a free `Function`, and `Empty{}` / `e.ping()` from an
|
||||
// importer resolved nothing. The receiver typed as the file's own type
|
||||
// is the second signal.
|
||||
expect(nodeIds.has('Struct:src/Empty.zig:Empty')).toBe(true);
|
||||
expect(nodeIds.has('Method:src/Empty.zig:Empty.ping#0')).toBe(true);
|
||||
expect(nodeIds.has('Function:src/Empty.zig:ping')).toBe(false);
|
||||
expect(nodeIds.has('Const:src/Empty.zig:Self')).toBe(false);
|
||||
expect(calls).toContain(
|
||||
'f_fieldless → Struct:src/Empty.zig:Empty import-resolved (constructor)',
|
||||
);
|
||||
expect(calls).toContain('f_fieldless → Method:src/Empty.zig:Empty.ping#0 import-resolved');
|
||||
// A namespace-only file (no field, no self-typed receiver) is still not a type.
|
||||
expect(nodeIds.has('Struct:src/qa.zig:qa')).toBe(false);
|
||||
expect(nodeIds.has('Struct:src/sub.zig:sub')).toBe(false);
|
||||
expect(nodeIds.has('Function:src/qa.zig:hello')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a type nested in a FILE-struct reachable through the file handle (`Host.Inner{}`)', () => {
|
||||
// A file-level container is already namespaced by its file, so its
|
||||
// identity stays the binding name; the chain `Host.Inner` walks the
|
||||
// file-struct's class to the nested type.
|
||||
expect(calls).toContain(
|
||||
'f_filestruct_nested → Struct:src/Host.zig:Inner import-resolved (constructor)',
|
||||
);
|
||||
expect(calls).toContain(
|
||||
'f_filestruct_nested → Method:src/Host.zig:Inner.m#0 import-resolved',
|
||||
);
|
||||
expect(calls).toContain(
|
||||
'f_filestruct_nested → Method:src/Host.zig:Host.touch#0 import-resolved',
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -833,11 +833,15 @@ test {
|
|||
targetRaw: 'counter.zig',
|
||||
},
|
||||
{ kind: 'named', localName: 'Same', importedName: 'Same', targetRaw: 'counter.zig' },
|
||||
// A DEEP chain (`@import("std").mem.Allocator`) is not a named import
|
||||
// of the innermost member: that lost the owner `mem` (review 8.4). The
|
||||
// module is bound under the builtin's own text — the namespace handle
|
||||
// the rewritten use sites of `Deep` (`@import("std").mem` . `Allocator`)
|
||||
// resolve through — and `Deep` itself stays a deep alias.
|
||||
{
|
||||
kind: 'alias',
|
||||
localName: 'Deep',
|
||||
importedName: 'Allocator',
|
||||
alias: 'Deep',
|
||||
kind: 'namespace',
|
||||
localName: '@import("std")',
|
||||
importedName: 'std',
|
||||
targetRaw: 'std',
|
||||
},
|
||||
{ kind: 'wildcard', targetRaw: 'mixin.zig' },
|
||||
|
|
@ -1200,6 +1204,29 @@ pub fn helper() u32 { return 1; }
|
|||
expect(isZigFileStruct(parse('').rootNode)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects a FIELDLESS file-struct by a top-level fn whose receiver is the file's own type (review 8.12)", () => {
|
||||
// `Empty.zig`: no field, but `ping` takes the file type. Constructed
|
||||
// (`Empty{}`) and dispatched on by importers, it lost its Struct when
|
||||
// fields were the only signal.
|
||||
expect(
|
||||
isZigFileStruct(
|
||||
parse('const Self = @This();\npub fn ping(self: *Self) void { _ = self; }\n').rootNode,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isZigFileStruct(parse('pub fn ping(self: *@This()) void { _ = self; }\n').rootNode),
|
||||
).toBe(true);
|
||||
// Only the receiver's TYPE decides — the parameter name `self` on some
|
||||
// other type is a free function in a utility file…
|
||||
expect(
|
||||
isZigFileStruct(
|
||||
parse('const Foo = struct {};\npub fn f(self: Foo) void { _ = self; }\n').rootNode,
|
||||
),
|
||||
).toBe(false);
|
||||
// …and a `@This()` alias with no receiver use is still a namespace.
|
||||
expect(isZigFileStruct(parse(NAMESPACE).rootNode)).toBe(false);
|
||||
});
|
||||
|
||||
it('names the type after the FILE STEM, on every platform spelling', () => {
|
||||
expect(zigFileStructName('src/browser/Page.zig')).toBe('Page');
|
||||
expect(zigFileStructName('src\\browser\\Sighandler.zig')).toBe('Sighandler');
|
||||
|
|
@ -1303,7 +1330,9 @@ fn f() void {
|
|||
['hidden', false], // no `pub`
|
||||
['ns', false],
|
||||
['Bar', true], // pub alias of a namespace member
|
||||
['Local', false], // fn-local: binds locally, publishes nothing
|
||||
// fn-local: binds locally under its per-callable key (`Local$f`, see
|
||||
// `zigFunctionLocalImportKey` — review 8.9), publishes nothing
|
||||
['Local$f', false],
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -1853,3 +1882,209 @@ pub fn string() void {
|
|||
expect(selfParam?.['@type-binding.type']?.text).toBe('*const R');
|
||||
});
|
||||
});
|
||||
|
||||
describeZig('Zig review findings 8.4 / 8.5 / 8.6 / 8.9 — capture-side contracts', () => {
|
||||
it('8.5 — a container nested in a container is identified `Owner.Name` and bound as `Name`', () => {
|
||||
const src = `
|
||||
pub const A = struct {
|
||||
pub const Item = struct {
|
||||
pub fn run(self: Item) void { _ = self; }
|
||||
};
|
||||
};
|
||||
pub const B = struct {
|
||||
pub const Item = struct {
|
||||
pub fn run(self: Item) void { _ = self; }
|
||||
};
|
||||
};
|
||||
`;
|
||||
const root = parse(src).rootNode;
|
||||
const containers: SyntaxNode[] = [];
|
||||
const visit = (n: SyntaxNode): void => {
|
||||
if (n.type === 'struct_declaration') containers.push(n);
|
||||
for (let i = 0; i < n.namedChildCount; i++) visit(n.namedChild(i)!);
|
||||
};
|
||||
visit(root);
|
||||
expect(containers.map((c) => zigContainerName(c, 'src/nested.zig'))).toEqual([
|
||||
'A',
|
||||
'A.Item',
|
||||
'B',
|
||||
'B.Item',
|
||||
]);
|
||||
// The lexical binding is unchanged: `Item{}` inside `A` still spells `Item`.
|
||||
expect(containers.map((c) => zigContainerBindingName(c))).toEqual(['A', 'Item', 'B', 'Item']);
|
||||
// Nested containers are minted by the bare-container rule (identity from
|
||||
// `zigContainerName`), not by the wrapper rule whose `@name` is `Item`.
|
||||
expect(containers.map((c) => zigContainerAnchor(c))).toEqual([
|
||||
'wrapper',
|
||||
'container',
|
||||
'wrapper',
|
||||
'container',
|
||||
]);
|
||||
const decls = emitZigScopeCaptures(src, 'src/nested.zig').filter(
|
||||
(m) => m['@declaration.struct'] !== undefined,
|
||||
);
|
||||
expect(
|
||||
decls.map((m) => [m['@declaration.name']?.text, m['@declaration.binding-name']?.text]),
|
||||
).toEqual([
|
||||
['A', undefined],
|
||||
['A.Item', 'Item'],
|
||||
['B', undefined],
|
||||
['B.Item', 'Item'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('8.9 — a fn-local `@import` binding and its uses are keyed per callable', () => {
|
||||
const src = `
|
||||
const std = @import("std");
|
||||
fn f_sib_a() void {
|
||||
const m = @import("qa.zig");
|
||||
var t = m.Thing{};
|
||||
t.go();
|
||||
m.hello();
|
||||
}
|
||||
fn f_sib_b() void {
|
||||
const m = @import("qb.zig");
|
||||
m.hello();
|
||||
}
|
||||
`;
|
||||
const matches = emitZigScopeCaptures(src, 'src/main.zig');
|
||||
const importNames = matches
|
||||
.filter((m) => m['@import.statement'] !== undefined && m['@import.imported'] === undefined)
|
||||
.map((m) => m['@import.name']!.text);
|
||||
// The module-level handle is untouched; each fn-local `m` gets its own key.
|
||||
expect(importNames).toEqual(['std', 'm$f_sib_a', 'm$f_sib_b']);
|
||||
const receivers = matches
|
||||
.filter((m) => m['@reference.receiver'] !== undefined)
|
||||
.map((m) => `${m['@reference.receiver']!.text}.${m['@reference.name']!.text}`);
|
||||
expect(receivers).toEqual(['m$f_sib_a.Thing', 't.go', 'm$f_sib_a.hello', 'm$f_sib_b.hello']);
|
||||
// The constructor type binding follows the same key.
|
||||
const tBinding = matches.find(
|
||||
(m) => m['@type-binding.constructor'] !== undefined && m['@type-binding.name']?.text === 't',
|
||||
);
|
||||
expect(tBinding?.['@type-binding.type']?.text).toBe('m$f_sib_a.Thing');
|
||||
// The string literal inside `@import("m.zig")` is never touched.
|
||||
const inString = emitZigScopeCaptures(
|
||||
'fn g() void {\n const m = @import("m.zig");\n m.run();\n}\n',
|
||||
'src/x.zig',
|
||||
);
|
||||
expect(inString.find((m) => m['@import.source'] !== undefined)?.['@import.source']?.text).toBe(
|
||||
'"m.zig"',
|
||||
);
|
||||
});
|
||||
|
||||
it('8.4 — a deep member alias rewrites its use sites to the written owner path', () => {
|
||||
const src = `
|
||||
const lib = @import("lib.zig");
|
||||
const chosen = @import("lib.zig").B.work;
|
||||
const chosen2 = lib.B.work;
|
||||
const Inner = lib.Outer.Inner;
|
||||
fn f() void {
|
||||
chosen();
|
||||
chosen2();
|
||||
var i = Inner{};
|
||||
_ = i;
|
||||
}
|
||||
`;
|
||||
const matches = emitZigScopeCaptures(src, 'src/main.zig');
|
||||
// The inline import is bound as a namespace under its own text — the
|
||||
// handle the rewritten sites resolve through — never as a named import
|
||||
// of the tail `work`.
|
||||
const imports = matches
|
||||
.filter((m) => m['@import.source'] !== undefined)
|
||||
.map((m) => interpretZigImport(m));
|
||||
expect(imports).toEqual([
|
||||
{ kind: 'namespace', localName: 'lib', importedName: 'lib', targetRaw: 'lib.zig' },
|
||||
{ kind: 'named', localName: 'lib', importedName: 'lib', targetRaw: 'lib.zig' },
|
||||
{
|
||||
kind: 'namespace',
|
||||
localName: '@import("lib.zig")',
|
||||
importedName: 'lib',
|
||||
targetRaw: 'lib.zig',
|
||||
},
|
||||
]);
|
||||
const sites = matches
|
||||
.filter((m) => m['@reference.receiver'] !== undefined)
|
||||
.map(
|
||||
(m) =>
|
||||
`${m['@reference.call.member'] !== undefined ? 'call' : 'ctor'} ${m['@reference.receiver']!.text} . ${m['@reference.name']!.text}`,
|
||||
);
|
||||
expect(sites).toEqual([
|
||||
'call @import("lib.zig").B . work',
|
||||
'call lib.B . work',
|
||||
'ctor lib.Outer . Inner',
|
||||
]);
|
||||
// No free-call site named `chosen` survives to be resolved by simple name.
|
||||
expect(
|
||||
matches.some(
|
||||
(m) => m['@reference.call.free'] !== undefined && m['@reference.name']?.text === 'chosen',
|
||||
),
|
||||
).toBe(false);
|
||||
// The two-level alias `lib.B.work` is NOT promoted to a named import; it
|
||||
// stays a Const with a type alias binding carrying the path.
|
||||
expect(
|
||||
matches.some(
|
||||
(m) =>
|
||||
m['@declaration.variable'] !== undefined && m['@declaration.name']?.text === 'chosen2',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('8.6 — result-location `.init(…)` / `.{…}` sites carry the expected type as their receiver', () => {
|
||||
const src = `
|
||||
const stdx = @import("stdx.zig");
|
||||
const Counter = @import("counter.zig").Counter;
|
||||
const a: Counter = .init(1);
|
||||
const b: Counter = .{ .n = 2 };
|
||||
var t: stdx.Thing = .{};
|
||||
const n: u32 = 5;
|
||||
fn mk() !Counter {
|
||||
return .init(2);
|
||||
}
|
||||
fn arg() void {
|
||||
use(.init(3));
|
||||
}
|
||||
const S = struct {
|
||||
const Self = @This();
|
||||
n: Counter = .init(0),
|
||||
pub fn make() Self {
|
||||
return .{};
|
||||
}
|
||||
};
|
||||
`;
|
||||
const sites = emitZigScopeCaptures(src, 'src/main.zig')
|
||||
.filter(
|
||||
(m) =>
|
||||
(m['@reference.call.member'] !== undefined ||
|
||||
m['@reference.call.constructor'] !== undefined) &&
|
||||
m['@reference.receiver'] !== undefined,
|
||||
)
|
||||
.map(
|
||||
(m) =>
|
||||
`${m['@reference.call.member'] !== undefined ? 'call' : 'ctor'} ${m['@reference.receiver']!.text} . ${m['@reference.name']!.text} @${m['@reference.call.member']?.range.startLine ?? m['@reference.call.constructor']?.range.startLine}`,
|
||||
);
|
||||
expect(sites).toEqual(
|
||||
expect.arrayContaining([
|
||||
'call Counter . init @4', // const a: Counter = .init(1)
|
||||
'call Counter . init @9', // return .init(2) in `fn mk() !Counter`
|
||||
'call Counter . init @16', // field default `n: Counter = .init(0)`
|
||||
'ctor stdx . Thing @6', // var t: stdx.Thing = .{}
|
||||
]),
|
||||
);
|
||||
// `.{}` under a bare annotation is a free construction of that type…
|
||||
const bare = emitZigScopeCaptures(src, 'src/main.zig').filter(
|
||||
(m) =>
|
||||
m['@reference.call.constructor'] !== undefined && m['@reference.receiver'] === undefined,
|
||||
);
|
||||
expect(
|
||||
bare.map(
|
||||
(m) =>
|
||||
`${m['@reference.name']!.text} @${m['@reference.call.constructor']!.range.startLine}`,
|
||||
),
|
||||
).toEqual(
|
||||
expect.arrayContaining(['Counter @5', 'S @18']), // `return .{}` in `fn make() Self` → the container
|
||||
);
|
||||
// …while a primitive annotation and an argument position emit nothing.
|
||||
expect(sites.some((s) => s.includes('u32'))).toBe(false);
|
||||
expect(sites.some((s) => s.endsWith('@12'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|||
import { resolveZigImportInternal } from '../../src/core/ingestion/import-resolvers/zig.js';
|
||||
import {
|
||||
loadZigBuildConfig,
|
||||
parseZigBuildModules,
|
||||
parseZigRootModules,
|
||||
parseZigBuildModuleRoots,
|
||||
parseZigBuildZon,
|
||||
|
|
@ -227,6 +228,98 @@ describe('resolveZigImportInternal', () => {
|
|||
).toBe('src/root.zig');
|
||||
});
|
||||
|
||||
it('resolves a bare name through the importer’s OWN build module table, not the first-declared alias', () => {
|
||||
// Two executables each `addImport("config", …)` their own config.zig. The
|
||||
// flat `rootModules` map keeps the first, so every tool/ file imported
|
||||
// app's config. The per-module table is consulted first, by membership.
|
||||
const files = new Set<string>([
|
||||
'src/app/main.zig',
|
||||
'src/app/config.zig',
|
||||
'src/app/util.zig',
|
||||
'src/tool/main.zig',
|
||||
'src/tool/config.zig',
|
||||
]);
|
||||
const config = {
|
||||
pathDeps: new Map<string, string>(),
|
||||
rootModules: new Map([['config', 'src/app/config.zig']]),
|
||||
buildModules: [
|
||||
{ root: 'src/app/main.zig', imports: new Map([['config', 'src/app/config.zig']]) },
|
||||
{ root: 'src/tool/main.zig', imports: new Map([['config', 'src/tool/config.zig']]) },
|
||||
],
|
||||
};
|
||||
expect(resolveZigImportInternal('src/tool/main.zig', 'config', files, config)).toBe(
|
||||
'src/tool/config.zig',
|
||||
);
|
||||
expect(resolveZigImportInternal('src/app/main.zig', 'config', files, config)).toBe(
|
||||
'src/app/config.zig',
|
||||
);
|
||||
// A non-root file is attributed to the module whose root shares its
|
||||
// directory (deepest prefix).
|
||||
expect(resolveZigImportInternal('src/app/util.zig', 'config', files, config)).toBe(
|
||||
'src/app/config.zig',
|
||||
);
|
||||
// A file outside every module directory falls back to the flat map.
|
||||
expect(resolveZigImportInternal('examples/demo.zig', 'config', files, config)).toBe(
|
||||
'src/app/config.zig',
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when same-directory modules disagree on an alias, instead of taking the first', () => {
|
||||
// `src/main.zig` (exe) and `src/root.zig` (lib) share a directory — the
|
||||
// `zig init` layout. A third file there cannot be attributed; resolving
|
||||
// through either would be a confident wrong edge, and the flat fallback
|
||||
// would only restore the first-wins answer, so the chain stops at null.
|
||||
const files = new Set<string>([
|
||||
'src/main.zig',
|
||||
'src/root.zig',
|
||||
'src/util.zig',
|
||||
'src/cfg_exe.zig',
|
||||
'src/cfg_lib.zig',
|
||||
]);
|
||||
const config = {
|
||||
pathDeps: new Map<string, string>(),
|
||||
rootModules: new Map([['cfg', 'src/cfg_exe.zig']]),
|
||||
buildModules: [
|
||||
{ root: 'src/main.zig', imports: new Map([['cfg', 'src/cfg_exe.zig']]) },
|
||||
{ root: 'src/root.zig', imports: new Map([['cfg', 'src/cfg_lib.zig']]) },
|
||||
],
|
||||
};
|
||||
expect(resolveZigImportInternal('src/util.zig', 'cfg', files, config)).toBeNull();
|
||||
// The roots themselves are unambiguous: each is its own module.
|
||||
expect(resolveZigImportInternal('src/main.zig', 'cfg', files, config)).toBe('src/cfg_exe.zig');
|
||||
expect(resolveZigImportInternal('src/root.zig', 'cfg', files, config)).toBe('src/cfg_lib.zig');
|
||||
// Agreement is not a conflict: two modules binding one alias to one root
|
||||
// resolve it for their shared directory.
|
||||
const agreeing = {
|
||||
...config,
|
||||
buildModules: [
|
||||
{ root: 'src/main.zig', imports: new Map([['cfg', 'src/cfg_lib.zig']]) },
|
||||
{ root: 'src/root.zig', imports: new Map([['cfg', 'src/cfg_lib.zig']]) },
|
||||
],
|
||||
};
|
||||
expect(resolveZigImportInternal('src/util.zig', 'cfg', files, agreeing)).toBe(
|
||||
'src/cfg_lib.zig',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls through to the flat root-module map when the containing module does not bind the name', () => {
|
||||
// A module table answers only for the aliases it declares; the package's
|
||||
// own `addModule` name stays importable from everywhere, as before.
|
||||
const files = new Set<string>(['src/main.zig', 'src/lib.zig', 'src/cfg.zig']);
|
||||
const config = {
|
||||
pathDeps: new Map<string, string>(),
|
||||
rootModules: new Map([['mylib', 'src/lib.zig']]),
|
||||
buildModules: [{ root: 'src/main.zig', imports: new Map([['cfg', 'src/cfg.zig']]) }],
|
||||
};
|
||||
expect(resolveZigImportInternal('src/main.zig', 'mylib', files, config)).toBe('src/lib.zig');
|
||||
// A table entry whose root is not indexed is not an answer either.
|
||||
const stale = {
|
||||
...config,
|
||||
buildModules: [{ root: 'src/main.zig', imports: new Map([['cfg', 'src/gone.zig']]) }],
|
||||
};
|
||||
expect(resolveZigImportInternal('src/main.zig', 'cfg', files, stale)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an unknown bare name not in build.zig.zon', () => {
|
||||
const files = new Set<string>(['src/main.zig']);
|
||||
const buildZon = { pathDeps: new Map([['ziggit', 'vendor/ziggit']]) };
|
||||
|
|
@ -529,6 +622,114 @@ _ = b.addModule("abs", .{ .root_source_file = .{ .cwd_relative = "/abs/x.zig" }
|
|||
});
|
||||
});
|
||||
|
||||
describe('parseZigBuildModules', () => {
|
||||
it('keeps each module’s addImport aliases in ITS OWN table (two modules, one alias, two roots)', () => {
|
||||
// The review trigger: `app` and `tool` each bind "config". One flat map
|
||||
// kept app's; the per-module tables keep both, each on its module.
|
||||
const buildZig = `
|
||||
pub fn build(b: *std.Build) void {
|
||||
const app_config = b.createModule(.{ .root_source_file = b.path("src/app/config.zig") });
|
||||
const tool_config = b.createModule(.{ .root_source_file = b.path("src/tool/config.zig") });
|
||||
const app = b.addExecutable(.{ .name = "app", .root_source_file = b.path("src/app/main.zig") });
|
||||
app.root_module.addImport("config", app_config);
|
||||
const tool_mod = b.createModule(.{
|
||||
.root_source_file = b.path("src/tool/main.zig"),
|
||||
.imports = &.{ .{ .name = "config", .module = tool_config } },
|
||||
});
|
||||
const tool = b.addExecutable(.{ .name = "tool", .root_module = tool_mod });
|
||||
tool.root_module.addImport("extra", app_config);
|
||||
}
|
||||
`;
|
||||
expect(parseZigBuildModules(buildZig)).toEqual([
|
||||
{ root: 'src/app/config.zig', imports: new Map() },
|
||||
{ root: 'src/tool/config.zig', imports: new Map() },
|
||||
{ root: 'src/app/main.zig', imports: new Map([['config', 'src/app/config.zig']]) },
|
||||
{
|
||||
root: 'src/tool/main.zig',
|
||||
imports: new Map([
|
||||
['config', 'src/tool/config.zig'],
|
||||
// `tool.root_module` IS `tool_mod`: the artifact alias lands on it.
|
||||
['extra', 'src/app/config.zig'],
|
||||
]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('names addModule modules, binds an inline `.root_module = b.createModule(…)` to its artifact, and keeps the first alias', () => {
|
||||
const buildZig = `
|
||||
pub fn build(b: *std.Build) void {
|
||||
const lib = b.addModule("mylib", .{ .root_source_file = b.path("./src/lib.zig") });
|
||||
lib.addImport("mylib", lib);
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "app",
|
||||
.root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig") }),
|
||||
});
|
||||
exe.root_module.addImport("mylib", lib);
|
||||
exe.root_module.addImport("mylib", exe.root_module); // second binding of a name: ignored
|
||||
exe.root_module.addImport("gen", opts.createModule()); // generated: no file
|
||||
unbound.addImport("x", lib); // receiver bound to nothing: ignored
|
||||
}
|
||||
`;
|
||||
expect(parseZigBuildModules(buildZig)).toEqual([
|
||||
{ name: 'mylib', root: 'src/lib.zig', imports: new Map([['mylib', 'src/lib.zig']]) },
|
||||
{ root: 'src/main.zig', imports: new Map([['mylib', 'src/lib.zig']]) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves `addImport("api", dep.module("core"))` through the dep’s declared modules, and nothing without them', () => {
|
||||
const buildZig = `
|
||||
pub fn build(b: *std.Build) void {
|
||||
const corelib = b.dependency("corelib", .{ .target = target });
|
||||
const exe = b.addExecutable(.{ .name = "app", .root_source_file = b.path("src/main.zig") });
|
||||
exe.root_module.addImport("api", corelib.module("core"));
|
||||
exe.root_module.addImport("other", corelib.module("missing"));
|
||||
exe.root_module.addImport("v8", v8.module("v8")); // not a b.dependency binding
|
||||
}
|
||||
`;
|
||||
const depModules = new Map([['corelib', new Map([['core', 'libs/corelib/src/core.zig']])]]);
|
||||
expect(parseZigBuildModules(buildZig, depModules)).toEqual([
|
||||
{ root: 'src/main.zig', imports: new Map([['api', 'libs/corelib/src/core.zig']]) },
|
||||
]);
|
||||
// The zon dep (or its build.zig) unknown: the alias is left unresolved
|
||||
// rather than guessed.
|
||||
expect(parseZigBuildModules(buildZig)).toEqual([{ root: 'src/main.zig', imports: new Map() }]);
|
||||
});
|
||||
|
||||
it('ignores calls, aliases and roots spelled in comments or strings', () => {
|
||||
const buildZig = `
|
||||
pub fn build(b: *std.Build) void {
|
||||
const m = b.createModule(.{ .root_source_file = b.path("src/m.zig") });
|
||||
// const decoy = b.createModule(.{ .root_source_file = b.path("src/decoy.zig") });
|
||||
// m.addImport("commented", m);
|
||||
const s = "m.addImport(\\"quoted\\", m)";
|
||||
_ = s;
|
||||
}
|
||||
`;
|
||||
expect(parseZigBuildModules(buildZig)).toEqual([{ root: 'src/m.zig', imports: new Map() }]);
|
||||
expect(parseZigBuildModules('pub fn build(b: *std.Build) void { _ = b; }')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadZigBuildConfig (zig-buildmodules fixture)', () => {
|
||||
it('carries per-module tables, with dep.module(…) aliases resolved through the dep’s build.zig', async () => {
|
||||
const config = await loadZigBuildConfig(path.join(FIXTURES, 'zig-buildmodules'));
|
||||
expect(config).not.toBeNull();
|
||||
const byRoot = new Map(config!.buildModules!.map((m) => [m.root, m.imports]));
|
||||
expect(byRoot.get('src/app/main.zig')).toEqual(
|
||||
new Map([
|
||||
['config', 'src/app/config.zig'],
|
||||
['api', 'libs/corelib/src/core.zig'],
|
||||
]),
|
||||
);
|
||||
expect(byRoot.get('src/tool/main.zig')).toEqual(new Map([['config', 'src/tool/config.zig']]));
|
||||
expect(byRoot.get('src/shared/a.zig')).toEqual(new Map([['clash', 'src/shared/clash_a.zig']]));
|
||||
expect(byRoot.get('src/shared/b.zig')).toEqual(new Map([['clash', 'src/shared/clash_b.zig']]));
|
||||
// The flat map is still there as the repo-wide fallback — and shows the
|
||||
// first-wins collapse the per-module tables exist to avoid.
|
||||
expect(config!.rootModules?.get('config')).toBe('src/app/config.zig');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadZigBuildConfig (zig-rootmodule fixture: build.zig, no build.zig.zon)', () => {
|
||||
it('still yields a config carrying the root build.zig’s modules', async () => {
|
||||
// Before: no build.zig.zon → null → every bare-name import in the repo
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue