From 2a0cfe672650f339c1f8e6032e9782a320df25ed Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Tue, 18 Aug 2026 14:06:12 +0200 Subject: [PATCH] fix(zig): resolve @import of the repo's own build.zig modules (F3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare-name imports were resolved through build.zig.zon path deps only, so the module a repo's ROOT build.zig declares for itself — `b.addModule("lightpanda", .{ .root_source_file = b.path("src/lightpanda.zig") })`, imported by name from 378/567 Lightpanda files — never produced an IMPORTS edge, and nothing reached through `lp.X` resolved. A repo with a build.zig but no build.zig.zon got no resolution config at all. - language-config: `parseZigRootModules` (static scan of the root build.zig: `addModule("", …root_source_file = b.path("

.zig")…)`, and `createModule`/`addModule` bindings named via `addImport("", m)` or `.imports = &.{ .{ .name, .module = m } }`; generated / `.url` / computed modules are skipped) → `ZigBuildZonConfig.rootModules`. - `loadZigBuildZon` → `loadZigBuildConfig`: reads the zon AND the root build.zig; null only when neither contributes. - resolver: root modules are consulted before path deps; std/builtin/root still never resolve. - fixtures: zig-idioms gains a Lightpanda-shaped root module (+ decoy `addOptions().createModule()`); new zig-rootmodule (build.zig, no zon). Corpus (Lightpanda): IMPORTS 3014→3389 (378 edges to src/lightpanda.zig, was 0), CALLS 13885→13989, ns.f() 79.3%→83.3%, param.m() type=ns-qualified 43→46/417. --- .../ingestion/import-resolvers/configs/zig.ts | 2 +- .../core/ingestion/import-resolvers/zig.ts | 28 ++- .../src/core/ingestion/language-config.ts | 135 +++++++++++++-- .../ingestion/languages/zig/scope-resolver.ts | 4 +- .../lang-resolution/zig-idioms/build.zig | 17 ++ .../lang-resolution/zig-idioms/src/idioms.zig | 11 ++ .../lang-resolution/zig-idioms/src/main.zig | 10 ++ .../lang-resolution/zig-rootmodule/build.zig | 17 ++ .../zig-rootmodule/src/core.zig | 1 + .../zig-rootmodule/src/main.zig | 7 + .../test/integration/resolvers/zig.test.ts | 42 +++++ .../test/unit/zig-import-resolver.test.ts | 159 +++++++++++++++++- 12 files changed, 409 insertions(+), 24 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/idioms.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-rootmodule/build.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-rootmodule/src/core.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-rootmodule/src/main.zig diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/zig.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/zig.ts index 5c9a9aa11..a191df9b6 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/configs/zig.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/zig.ts @@ -4,7 +4,7 @@ * Local-file imports (`@import("./foo.zig")`, `@import("foo.zig")`) resolve * relative to the importer. Bare names (`@import("bar")`) resolve through * build.zig.zon `.path` deps when a parsed ZigBuildZonConfig is available - * (see language-config.ts `loadZigBuildZon`). Everything unresolvable — + * (see language-config.ts `loadZigBuildConfig`). Everything unresolvable — * `std`, `builtin`, `root`, `.url`-based deps — returns an empty result so * it doesn't produce ghost import edges. */ diff --git a/gitnexus/src/core/ingestion/import-resolvers/zig.ts b/gitnexus/src/core/ingestion/import-resolvers/zig.ts index 5da67b8b7..8a36571ca 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/zig.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/zig.ts @@ -9,14 +9,18 @@ * const foo = @import("foo.zig"); → also relative (Zig treats unprefixed * paths with a `.zig` extension as * filesystem-relative to the importer) + * const lp = @import("lightpanda"); → the repo's OWN module, declared by + * its root build.zig (`b.addModule`) * const bar = @import("bar"); → package dep declared in build.zig.zon * - * Bare-name (build.zig.zon) resolution is handled when a parsed - * ZigBuildZonConfig is supplied (see `loadZigBuildZon`). `.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 `/src/root.zig`, - * `/src/.zig`, `/src/main.zig` layouts. + * 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 + * 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 + * `/src/root.zig`, `/src/.zig`, + * `/src/main.zig` layouts. */ import { normalizeZigDepPath, type ZigBuildZonConfig } from '../language-config.js'; @@ -73,8 +77,16 @@ export function resolveZigImportInternal( } // Bare name without extension or slashes (e.g. @import("bar")). - // Try to resolve via build.zig.zon `.path` deps. if (buildZon) { + // 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 + // `@import("lightpanda")` means, whatever the zon says. `std` / `builtin` + // / `root` were rejected above and can never be reached from here. + const rootModule = buildZon.rootModules?.get(importPath); + if (rootModule !== undefined && allFiles.has(rootModule)) return rootModule; + + // Then build.zig.zon `.path` deps. const depPath = buildZon.pathDeps.get(importPath); if (depPath) { const normalized = normalizeZigDepPath(depPath); @@ -100,7 +112,7 @@ export function resolveZigImportInternal( } } - // Bare name with no resolution (no build.zig.zon, .url-based dep, or + // Bare name with no resolution (no build.zig / build.zig.zon, .url-based dep, generated module, or // unconventional layout). return null; } diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 5bfcff5f9..5eadd2ec1 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -87,7 +87,7 @@ export interface SwiftPackageConfig { targets: Map; } -/** Zig package config parsed from build.zig.zon */ +/** Zig package config parsed from build.zig.zon and the root build.zig */ export interface ZigBuildZonConfig { /** * Map of dependency name -> the raw `.path = "..."` value, exactly as @@ -109,6 +109,17 @@ export interface ZigBuildZonConfig { * `build.zig`; the resolver then falls back to the conventional layouts. */ moduleRoots?: Map; + /** + * Modules the repo's OWN root `build.zig` declares under an importable + * name, module name → repo-relative root source file + * (`b.addModule("lp", .{ .root_source_file = b.path("src/lp.zig") })`, or a + * `createModule` binding later named through `addImport("lp", binding)`). + * These are what an in-repo `@import("lp")` means — the most common shape in + * single-package repos, where every file imports the package's own root + * module by name. Independent of `build.zig.zon`: a repo with a `build.zig` + * and no zon still resolves them. See `parseZigRootModules`. + */ + rootModules?: Map; } // ============================================================================ @@ -490,7 +501,11 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise")`. Null only when neither contributes. * * `build.zig.zon` is Zig source (an anonymous-struct literal), not JSON. * Rather than pull in a tree-sitter parse for one file, we use a small @@ -520,16 +535,30 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise { - let config: ZigBuildZonConfig | null; +export async function loadZigBuildConfig(repoRoot: string): Promise { + let config: ZigBuildZonConfig | null = null; try { - const zonPath = path.join(repoRoot, 'build.zig.zon'); - const raw = await fs.readFile(zonPath, 'utf-8'); + const raw = await fs.readFile(path.join(repoRoot, 'build.zig.zon'), 'utf-8'); config = parseZigBuildZon(raw); } catch { - return null; + // No zon (or unreadable): the root build.zig may still declare modules. + } + + // The repo's own importable modules, from its root build.zig. Independent + // of the zon: `@import("")` is how single-package repos refer + // to their root file from every other file. + let rootModules: Map | undefined; + try { + const rootBuildZig = await fs.readFile(path.join(repoRoot, 'build.zig'), 'utf-8'); + const parsed = parseZigRootModules(rootBuildZig); + if (parsed.size > 0) rootModules = parsed; + } catch { + // No root build.zig — nothing to declare. + } + + if (config === null) { + return rootModules ? { pathDeps: new Map(), rootModules } : null; } - if (config === null) return null; // A path dep's importable root is whatever ITS build.zig declares, not a // fixed layout: read `root_source_file` per `addModule` and remember it @@ -550,7 +579,11 @@ export async function loadZigBuildZon(repoRoot: string): Promise 0) moduleRoots.set(depName, roots); } - return moduleRoots.size > 0 ? { ...config, moduleRoots } : config; + return { + ...config, + ...(moduleRoots.size > 0 ? { moduleRoots } : {}), + ...(rootModules ? { rootModules } : {}), + }; } /** @@ -611,6 +644,88 @@ export function parseZigBuildModuleRoots(buildZig: string, preferredName: string return [...named, ...unnamed]; } +/** + * The importable modules a repo's ROOT `build.zig` declares, module name → + * repo-relative root source file. Static scan (no execution) of two shapes: + * + * - `b.addModule("", .{ .root_source_file = b.path("

.zig"), … })` + * names the module directly; + * - `const m = b.createModule(.{ .root_source_file = b.path("

.zig"), … })` + * (or `const m = b.addModule(…)`) bound to an identifier and later named + * by `x.addImport("", m)` or `.imports = &.{ .{ .name = "", + * .module = m } }`. + * + * Deliberately NOT resolved — they are not in-repo source files: modules whose + * root is not a static `b.path("….zig")` (generated `opts.createModule()` from + * `addOptions`, `translate_c.createModule()`, `.cwd_relative` / computed + * LazyPaths), `addImport("", dep.module("…"))` (a `.url` / path dep, + * handled through the zon), and aliases whose module operand is anything but a + * bare identifier bound above (`config.lp_module`). Comments are stripped and + * string literals skipped; the first declaration of a name wins. + */ +export function parseZigRootModules(buildZig: string): Map { + const text = stripZonComments(buildZig); + const mask = zonStringMask(text); + const modules = new Map(); + // identifier → repo-relative root, for `const m = b.createModule(…)` / + // `const m = b.addModule(…)` bindings later named via addImport. + const bindings = new Map(); + 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 callRe = /\b(addModule|createModule)\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 rootMatch = rootRe.exec(args); + const root = rootMatch ? normalizeZigDepPath(rootMatch[1]!) : null; + if (root === null || root === '' || !root.endsWith('.zig')) continue; + if (m[1] === 'addModule') { + const nameMatch = /^\s*"([^"\n]+)"\s*,/.exec(args); + if (nameMatch && !modules.has(nameMatch[1]!)) modules.set(nameMatch[1]!, root); + } + const binding = bindingRe.exec(text.slice(0, m.index)); + if (binding && !bindings.has(binding[1]!)) bindings.set(binding[1]!, root); + } + if (bindings.size === 0) return modules; + const aliasRes = [ + /\.addImport\(\s*"([^"\n]+)"\s*,\s*([A-Za-z_]\w*)\s*\)/g, + /\.name\s*=\s*"([^"\n]+)"\s*,\s*\.module\s*=\s*([A-Za-z_]\w*)\s*[,}]/g, + ]; + for (const re of aliasRes) { + while ((m = re.exec(text)) !== null) { + if (mask[m.index] !== 0) continue; + const root = bindings.get(m[2]!); + if (root !== undefined && !modules.has(m[1]!)) modules.set(m[1]!, root); + } + } + return modules; +} + +/** + * Index of the `)` matching the `(` that precedes `start`, skipping parens + * inside `"…"` literals. -1 when unbalanced. Call on comment-stripped text. + */ +function findZigParenEnd(text: string, start: number): number { + let depth = 1; + let inString = false; + for (let i = start; 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 === '(') depth++; + else if (ch === ')' && --depth === 0) return i; + } + return -1; +} + /** * Blank out `//` line comments (and `\\` multiline-string-literal lines) in * ZON source, string-aware: a `//` inside a `"…"` literal (`.url = @@ -869,6 +984,6 @@ export async function loadImportConfigs(repoRoot: string): Promise loadZigBuildZon(repoPath), + loadResolutionConfig: (repoPath: string) => loadZigBuildConfig(repoPath), resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => resolveZigImportInternal( diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/build.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/build.zig index e8cd1de4a..40a6a4500 100644 --- a/gitnexus/test/fixtures/lang-resolution/zig-idioms/build.zig +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/build.zig @@ -1,7 +1,24 @@ const std = @import("std"); pub fn build(b: *std.Build) void { + // Build-time options module: generated at build time, no source file in + // the repo — `@import("build_config")` must stay unresolved. + var opts = b.addOptions(); + opts.addOption([]const u8, "version", "0.1.0"); + + // The package's OWN root module, imported by name from every other file + // (Lightpanda shape: named via addModule, then re-imported into itself + // to allow the circular `@import("idioms")`). + const idioms_module = b.addModule("idioms", .{ + .root_source_file = b.path("src/idioms.zig"), + .link_libc = true, + }); + idioms_module.addImport("idioms", idioms_module); // allow circular "idioms" import + idioms_module.addImport("build_config", opts.createModule()); + const geo = b.dependency("geo", .{}); const exe = b.addExecutable(.{ .name = "idioms", .root_source_file = b.path("src/main.zig") }); exe.root_module.addImport("geo", geo.module("geo")); + exe.root_module.addImport("idioms", idioms_module); + exe.root_module.addImport("build_config", opts.createModule()); b.installArtifact(exe); } diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/idioms.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/idioms.zig new file mode 100644 index 000000000..9aee2a6aa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/idioms.zig @@ -0,0 +1,11 @@ +// The package's own root module — what `@import("idioms")` means in-repo, +// per `b.addModule("idioms", …)` in build.zig. +pub const Arena = struct { + used: usize = 0, + + pub fn reset(self: *Arena) void { + self.used = 0; + } +}; + +pub fn boot() void {} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig index 52c58bb43..e0f46eac1 100644 --- a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig @@ -8,6 +8,11 @@ const Stack = @import("counter.zig").Stack; // oldlib has no build.zig and relies on the src/.zig convention. const geo = @import("geo"); const oldlib = @import("oldlib"); +// The package's OWN root module, named by build.zig's addModule("idioms", …) +// (Lightpanda: `const lp = @import("lightpanda");` in 378 of 567 files). +const idioms = @import("idioms"); +// Generated at build time (addOptions().createModule()) — no in-repo file. +const build_config = @import("build_config"); // Removed from the language in 0.15, still everywhere in 0.11–0.14 code. pub usingnamespace @import("mixin.zig"); @@ -35,6 +40,11 @@ pub fn main() void { var p = geo.Point{}; p.shift(1); oldlib.legacy(); + // own root module + idioms.boot(); + var arena = idioms.Arena{}; + arena.reset(); + _ = build_config.version; // statement assignments share the variable_declaration node type with // declarations in tree-sitter-zig 1.1.2 — none of these is a binding. counter.global_count = 5; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-rootmodule/build.zig b/gitnexus/test/fixtures/lang-resolution/zig-rootmodule/build.zig new file mode 100644 index 000000000..3f3b39014 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-rootmodule/build.zig @@ -0,0 +1,17 @@ +const std = @import("std"); + +// No build.zig.zon at all: the only bare-name module this repo can import is +// the one its own build.zig declares — here through a createModule binding +// that addImport later names "core". +pub fn build(b: *std.Build) void { + const core_mod = b.createModule(.{ + .root_source_file = b.path("src/core.zig"), + .target = b.standardTargetOptions(.{}), + }); + const exe = b.addExecutable(.{ + .name = "app", + .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig") }), + }); + exe.root_module.addImport("core", core_mod); + b.installArtifact(exe); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-rootmodule/src/core.zig b/gitnexus/test/fixtures/lang-resolution/zig-rootmodule/src/core.zig new file mode 100644 index 000000000..cb30d7964 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-rootmodule/src/core.zig @@ -0,0 +1 @@ +pub fn start() void {} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-rootmodule/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-rootmodule/src/main.zig new file mode 100644 index 000000000..a5700f5ae --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-rootmodule/src/main.zig @@ -0,0 +1,7 @@ +// Bare-name import of the repo's own module, declared only in build.zig +// (no build.zig.zon anywhere). +const core = @import("core"); + +pub fn main() void { + core.start(); +} diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index f90062705..6d2d32007 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -277,6 +277,22 @@ describe.skipIf(!zigAvailable)('Zig idioms (zig-idioms fixture)', () => { expect(calls).toContain('main → legacy'); }); + it('resolves `@import("")` through the ROOT build.zig’s addModule (Lightpanda: `@import("lightpanda")`)', () => { + // Bare names were resolved through build.zig.zon path deps only, so the + // package's own root module — `b.addModule("idioms", .{ .root_source_file + // = b.path("src/idioms.zig") })`, re-imported into itself via addImport — + // had no IMPORTS edge and nothing reached through it resolved. + expect(imports).toContain('main.zig → src/idioms.zig'); + expect(calls).toContain('main → boot'); + // …and a type reached through the module namespace dispatches. + expect(calls).toContain('main → reset'); + }); + + it('does not fabricate an edge for a generated module (`addOptions().createModule()`)', () => { + // `build_config` exists only at build time; there is no file to import. + expect(imports.some((e) => e.startsWith('main.zig → ') && /build_config/.test(e))).toBe(false); + }); + it('a re-assignment (`a = Counter.init();`) is not a declaration and does not shadow the typed binding', () => { // Guarded on the scope side by the literal `"const"` / `"var"` in the // query and on the structure side by `isZigKeywordDeclaration`. @@ -288,3 +304,29 @@ describe.skipIf(!zigAvailable)('Zig idioms (zig-idioms fixture)', () => { ).toBeGreaterThanOrEqual(2); }); }); + +/** + * `zig-rootmodule`: a repo with a root `build.zig` and NO `build.zig.zon`. + * Its only bare-name import is the module its own build.zig declares through + * a `createModule` binding that `addImport("core", core_mod)` names. + */ +describe.skipIf(!zigAvailable)( + 'Zig own root module without build.zig.zon (zig-rootmodule fixture)', + () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-rootmodule'), () => {}); + }, 60000); + + it('resolves `@import("core")` to src/core.zig and the `core.start()` call through it', () => { + // The resolution config was null without a build.zig.zon, so the repo's + // own module never resolved: no IMPORTS edge, no call through `core.`. + const imports = getRelationships(result, 'IMPORTS').map( + (e) => `${path.basename(e.sourceFilePath)} → ${e.targetFilePath}`, + ); + expect(imports).toContain('main.zig → src/core.zig'); + expect(edgeSet(getRelationships(result, 'CALLS'))).toContain('main → start'); + }); + }, +); diff --git a/gitnexus/test/unit/zig-import-resolver.test.ts b/gitnexus/test/unit/zig-import-resolver.test.ts index 3c4dd4213..dd0c20fab 100644 --- a/gitnexus/test/unit/zig-import-resolver.test.ts +++ b/gitnexus/test/unit/zig-import-resolver.test.ts @@ -7,7 +7,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveZigImportInternal } from '../../src/core/ingestion/import-resolvers/zig.js'; import { - loadZigBuildZon, + loadZigBuildConfig, + parseZigRootModules, parseZigBuildModuleRoots, parseZigBuildZon, } from '../../src/core/ingestion/language-config.js'; @@ -159,6 +160,59 @@ describe('resolveZigImportInternal', () => { expect(resolveZigImportInternal('src/main.zig', 'ziggit', files, buildZon)).toBeNull(); }); + it('resolves the repo’s OWN module (root build.zig `addModule`) with no build.zig.zon at all', () => { + // Lightpanda: `const lp = @import("lightpanda");` in 378 of 567 files, + // declared only by the root build.zig — bare names were resolved through + // zon path deps alone, so the package's own root module had ZERO IMPORTS + // edges and every `lp.X` downstream stayed unresolved. + const files = new Set(['src/main.zig', 'src/lightpanda.zig', 'src/browser/page.zig']); + const config = { + pathDeps: new Map(), + rootModules: new Map([['lightpanda', 'src/lightpanda.zig']]), + }; + expect(resolveZigImportInternal('src/browser/page.zig', 'lightpanda', files, config)).toBe( + 'src/lightpanda.zig', + ); + // From the root file itself (the circular self-import build.zig allows). + expect(resolveZigImportInternal('src/lightpanda.zig', 'lightpanda', files, config)).toBe( + 'src/lightpanda.zig', + ); + // A generated module (`addOptions().createModule()`) is not declared and + // must not resolve to anything. + expect(resolveZigImportInternal('src/main.zig', 'build_config', files, config)).toBeNull(); + // A declared root that is not among the indexed files resolves nothing. + const stale = { + pathDeps: new Map(), + rootModules: new Map([['gone', 'src/gone.zig']]), + }; + expect(resolveZigImportInternal('src/main.zig', 'gone', files, stale)).toBeNull(); + }); + + it('never resolves std / builtin / root through a same-named root module', () => { + const files = new Set(['src/main.zig', 'src/std.zig']); + const config = { + pathDeps: new Map(), + rootModules: new Map([['std', 'src/std.zig']]), + }; + expect(resolveZigImportInternal('src/main.zig', 'std', files, config)).toBeNull(); + }); + + it('consults root modules BEFORE build.zig.zon path deps', () => { + // A `.path = "."` self-dep and an `addModule` for the same name must agree + // on the addModule root — the build.zig declaration is the source of truth + // for what the name means, the zon layout heuristics are a fallback. + const files = new Set(['src/main.zig', 'src/root.zig', 'lib/pkg.zig']); + const config = { + pathDeps: new Map([['pkg', '.']]), + rootModules: new Map([['pkg', 'lib/pkg.zig']]), + }; + expect(resolveZigImportInternal('src/main.zig', 'pkg', files, config)).toBe('lib/pkg.zig'); + // Without the root module, the zon fallback still applies. + expect( + resolveZigImportInternal('src/main.zig', 'pkg', files, { pathDeps: config.pathDeps }), + ).toBe('src/root.zig'); + }); + it('returns null for an unknown bare name not in build.zig.zon', () => { const files = new Set(['src/main.zig']); const buildZon = { pathDeps: new Map([['ziggit', 'vendor/ziggit']]) }; @@ -363,9 +417,108 @@ _ = b.addModule("w", .{ .root_source_file = b.path("../outside.zig") }); }); }); -describe('loadZigBuildZon (zig-idioms fixture)', () => { +describe('parseZigRootModules', () => { + it('maps the root build.zig’s named modules to their root files (Lightpanda-shaped build.zig)', () => { + // Excerpt of Lightpanda's build.zig: `addModule` names the package's own + // module, `addImport` re-aliases it (circular self-import), the options + // module is generated (`addOptions().createModule()`) and `v8` comes from + // a `.url` dep (`dep.module("v8")`) — neither of those is an in-repo file. + const buildZig = ` +const std = @import("std"); +const Build = std.Build; + +pub fn build(b: *Build) !void { + var opts = b.addOptions(); + opts.addOption([]const u8, "version", version_string); + + const lightpanda_module = b.addModule("lightpanda", .{ + .root_source_file = b.path("src/lightpanda.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + lightpanda_module.addImport("lightpanda", lightpanda_module); // allow circular "lightpanda" import + lightpanda_module.addImport("build_config", opts.createModule()); + + // A createModule binding named only through addImport. + const testing_mod = b.createModule(.{ + .root_source_file = b.path("./src/testing.zig"), + .imports = &.{ + .{ .name = "lightpanda", .module = lightpanda_module }, + }, + }); + lightpanda_module.addImport("testing", testing_mod); + // \`.imports\` naming form, bare identifier. + _ = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .imports = &.{ .{ .name = "lp_alias", .module = lightpanda_module } }, + }); + // Struct-field operand — not statically recoverable, skipped. + _ = b.createModule(.{ + .root_source_file = b.path("src/other.zig"), + .imports = &.{ .{ .name = "lightpanda_cfg", .module = config.lightpanda_module } }, + }); + // The addModule("v8", …) below lives in a string and a comment: neither counts. + const note = "b.addModule(\\"decoy\\", .{ .root_source_file = b.path(\\"src/decoy.zig\\") })"; + // _ = b.addModule("commented", .{ .root_source_file = b.path("src/commented.zig") }); + _ = note; +} + +fn linkV8(b: *Build, mod: *Build.Module) void { + const dep = b.dependency("v8", .{}); + mod.addImport("v8", dep.module("v8")); + const translate_c = b.addTranslateC(.{ .root_source_file = b.path("include/curl.h") }); + mod.addImport("curl", translate_c.createModule()); + mod.addImport("computed", b.createModule(.{ .root_source_file = generated.getPath() })); +} +`; + expect(parseZigRootModules(buildZig)).toEqual( + new Map([ + ['lightpanda', 'src/lightpanda.zig'], + ['testing', 'src/testing.zig'], + ['lp_alias', 'src/lightpanda.zig'], + ]), + ); + }); + + it('keeps the first declaration of a name and ignores non-.zig / escaping roots', () => { + const buildZig = ` +_ = b.addModule("x", .{ .root_source_file = b.path("src/x.zig") }); +_ = b.addModule("x", .{ .root_source_file = b.path("src/other.zig") }); +_ = b.addModule("h", .{ .root_source_file = b.path("include/h.h") }); +_ = b.addModule("out", .{ .root_source_file = b.path("../outside.zig") }); +_ = b.addModule("abs", .{ .root_source_file = .{ .cwd_relative = "/abs/x.zig" } }); +`; + expect(parseZigRootModules(buildZig)).toEqual(new Map([['x', 'src/x.zig']])); + }); + + it('returns an empty map for a build.zig that names no module', () => { + expect(parseZigRootModules('pub fn build(b: *std.Build) void { _ = b; }').size).toBe(0); + }); +}); + +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 + // unresolved, including the repo's own module. + const config = await loadZigBuildConfig(path.join(FIXTURES, 'zig-rootmodule')); + expect(config).not.toBeNull(); + expect(config!.pathDeps.size).toBe(0); + expect(config!.rootModules).toEqual(new Map([['core', 'src/core.zig']])); + }); +}); + +describe('loadZigBuildConfig (zig-idioms fixture)', () => { + it('reads the root build.zig’s own named modules alongside the zon path deps', async () => { + const config = await loadZigBuildConfig(path.join(FIXTURES, 'zig-idioms')); + // addModule("idioms", …src/idioms.zig) + the self-addImport; the + // build_config (addOptions) module and geo (dependency module) are not + // in-repo files. + expect(config!.rootModules).toEqual(new Map([['idioms', 'src/idioms.zig']])); + }); + it('reads each path dep’s build.zig for its module roots and leaves deps without one to the layout fallback', async () => { - const config = await loadZigBuildZon(path.join(FIXTURES, 'zig-idioms')); + const config = await loadZigBuildConfig(path.join(FIXTURES, 'zig-idioms')); expect(config).not.toBeNull(); expect([...config!.pathDeps.keys()].sort()).toEqual(['geo', 'oldlib']); // geo/build.zig: addModule("geo", .{ .root_source_file = b.path("src/root.zig") })