fix(zig): resolve @import of the repo's own build.zig modules (F3)

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("<name>", …root_source_file = b.path("<p>.zig")…)`, and
  `createModule`/`addModule` bindings named via `addImport("<name>", 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.
This commit is contained in:
Navid EMAD 2026-08-18 14:06:12 +02:00
parent 30e9a3a1d3
commit 2a0cfe6726
No known key found for this signature in database
12 changed files with 409 additions and 24 deletions

View file

@ -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.
*/

View file

@ -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 `<dep_root>/src/root.zig`,
* `<dep_root>/src/<name>.zig`, `<dep_root>/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
* `<dep_root>/src/root.zig`, `<dep_root>/src/<name>.zig`,
* `<dep_root>/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;
}

View file

@ -87,7 +87,7 @@ export interface SwiftPackageConfig {
targets: Map<string, string>;
}
/** 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<string, readonly string[]>;
/**
* 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<string, string>;
}
// ============================================================================
@ -490,7 +501,11 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
}
/**
* Parse build.zig.zon to extract `.path = "..."` dependency mappings.
* Load the Zig build configuration a repo's `build.zig.zon` + root `build.zig`
* declare: `.path` deps (and the roots their own build.zig names) from the
* zon, and the repo's own named modules from the root build.zig. Either file
* may be missing — a repo with a `build.zig` but no `build.zig.zon` still
* resolves `@import("<own module>")`. 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<SwiftPac
* string literals — a commented-out `.path` or a `}` inside a comment
* or string cannot declare a dep or truncate the block.
*/
export async function loadZigBuildZon(repoRoot: string): Promise<ZigBuildZonConfig | null> {
let config: ZigBuildZonConfig | null;
export async function loadZigBuildConfig(repoRoot: string): Promise<ZigBuildZonConfig | null> {
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("<own module>")` is how single-package repos refer
// to their root file from every other file.
let rootModules: Map<string, string> | 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<ZigBuildZonConf
);
if (roots.length > 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("<name>", .{ .root_source_file = b.path("<p>.zig"), … })`
* names the module directly;
* - `const m = b.createModule(.{ .root_source_file = b.path("<p>.zig"), … })`
* (or `const m = b.addModule(…)`) bound to an identifier and later named
* by `x.addImport("<name>", m)` or `.imports = &.{ .{ .name = "<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("<name>", 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<string, string> {
const text = stripZonComments(buildZig);
const mask = zonStringMask(text);
const modules = new Map<string, string>();
// identifier → repo-relative root, for `const m = b.createModule(…)` /
// `const m = b.addModule(…)` bindings later named via addImport.
const bindings = new Map<string, string>();
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<ImportConfigs
swiftPackageConfig: await loadSwiftPackageConfig(repoRoot),
csharpConfigs: csharpScan.configs,
csharpNamespaces: csharpScanToEvidence(csharpScan),
zigBuildZon: await loadZigBuildZon(repoRoot),
zigBuildZon: await loadZigBuildConfig(repoRoot),
};
}

View file

@ -14,7 +14,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { loadZigBuildZon, type ZigBuildZonConfig } from '../../language-config.js';
import { loadZigBuildConfig, type ZigBuildZonConfig } from '../../language-config.js';
import { resolveZigImportInternal } from '../../import-resolvers/zig.js';
import { zigProvider } from '../zig.js';
import { expandZigWildcardNames, zigArityCompatibility, zigMergeBindings } from './index.js';
@ -24,7 +24,7 @@ export const zigScopeResolver: ScopeResolver = {
languageProvider: zigProvider,
importEdgeReason: 'zig-scope: import',
loadResolutionConfig: (repoPath: string) => loadZigBuildZon(repoPath),
loadResolutionConfig: (repoPath: string) => loadZigBuildConfig(repoPath),
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
resolveZigImportInternal(

View file

@ -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);
}

View file

@ -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 {}

View file

@ -8,6 +8,11 @@ const Stack = @import("counter.zig").Stack;
// oldlib has no build.zig and relies on the src/<name>.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;

View file

@ -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);
}

View file

@ -0,0 +1 @@
pub fn start() void {}

View file

@ -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();
}

View file

@ -277,6 +277,22 @@ describe.skipIf(!zigAvailable)('Zig idioms (zig-idioms fixture)', () => {
expect(calls).toContain('main → legacy');
});
it('resolves `@import("<own module>")` 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');
});
},
);

View file

@ -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<string>(['src/main.zig', 'src/lightpanda.zig', 'src/browser/page.zig']);
const config = {
pathDeps: new Map<string, string>(),
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<string, string>(),
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<string>(['src/main.zig', 'src/std.zig']);
const config = {
pathDeps: new Map<string, string>(),
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<string>(['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<string>(['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") })