From 69831484ef81d9aab842e7875aadcb7fcc589f23 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Tue, 18 Aug 2026 12:13:08 +0200 Subject: [PATCH] feat(zig): resolve path deps through the dep's build.zig and src/root.zig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare-name resolver only knew `src/.zig` and `src/main.zig`. `zig init` has written `src/root.zig` for libraries since 0.12, so the default library layout never resolved. Now: the root the dep's own build.zig declares (`b.addModule("", .{ .root_source_file = b.path("…") })`, name-matched module first), then src/root.zig, src/.zig, src/main.zig. `normalizeZigDepPath` is shared by the loader and the resolver. --- .../core/ingestion/import-resolvers/zig.ts | 48 ++++------ .../src/core/ingestion/language-config.ts | 93 +++++++++++++++++- .../test/unit/zig-import-resolver.test.ts | 95 ++++++++++++++++++- 3 files changed, 203 insertions(+), 33 deletions(-) diff --git a/gitnexus/src/core/ingestion/import-resolvers/zig.ts b/gitnexus/src/core/ingestion/import-resolvers/zig.ts index 3b419bec1..5da67b8b7 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/zig.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/zig.ts @@ -14,11 +14,12 @@ * 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 conventional - * `/src/.zig` or `/src/main.zig` layout. + * `.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 type { ZigBuildZonConfig } from '../language-config.js'; +import { normalizeZigDepPath, type ZigBuildZonConfig } from '../language-config.js'; const ZIG_STDLIB_NAMES = new Set(['std', 'builtin', 'root']); @@ -76,14 +77,22 @@ export function resolveZigImportInternal( if (buildZon) { const depPath = buildZon.pathDeps.get(importPath); if (depPath) { - const normalized = normalizeDepPath(depPath); + const normalized = normalizeZigDepPath(depPath); if (normalized !== null) { - // Conventional Zig layout: /src/.zig (matches the - // package's primary module name) or /src/main.zig. A dep at - // `.path = "."` (the repo itself) normalizes to '' and must not grow a - // leading slash — `allFiles` keys are repo-relative. + // What the dep's own build.zig declares comes first (its + // `addModule` root_source_file — see `parseZigBuildModuleRoots`), then + // the conventional layouts: `src/root.zig` (the `zig init` library + // root since 0.12), `src/.zig` (older name-matched convention), + // `src/main.zig` (executables / older inits). A dep at `.path = "."` + // (the repo itself) normalizes to '' and must not grow a leading slash + // — `allFiles` keys are repo-relative. const prefix = normalized === '' ? '' : `${normalized}/`; - const candidates = [`${prefix}src/${importPath}.zig`, `${prefix}src/main.zig`]; + const candidates = [ + ...(buildZon.moduleRoots?.get(importPath) ?? []), + `${prefix}src/root.zig`, + `${prefix}src/${importPath}.zig`, + `${prefix}src/main.zig`, + ]; for (const c of candidates) { if (allFiles.has(c)) return c; } @@ -95,24 +104,3 @@ export function resolveZigImportInternal( // unconventional layout). return null; } - -/** - * Normalize a `.path` value from build.zig.zon into a repo-relative form. - * Returns null for paths that escape the repo root (start with `..`) or - * are absolute — those point to files we don't index in `allFilePaths`. - * `.` / `./` normalize to the empty string (the repo root itself). - */ -function normalizeDepPath(depPath: string): string | null { - if (depPath.startsWith('/')) return null; - const parts: string[] = []; - for (const part of depPath.split('/')) { - if (part === '' || part === '.') continue; - if (part === '..') { - if (parts.length === 0) return null; - parts.pop(); - } else { - parts.push(part); - } - } - return parts.join('/'); -} diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 1a5257ec7..ee5f33954 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -93,12 +93,22 @@ export interface ZigBuildZonConfig { * Map of dependency name -> the raw `.path = "..."` value, exactly as * written in build.zig.zon (relative to the repo root, and possibly * escaping it: `../local_dep`). Consumers normalize — see - * `normalizeDepPath` in `import-resolvers/zig.ts`, which rejects absolute + * `normalizeZigDepPath` below, which rejects absolute * and repo-escaping values. `.url`-based deps cannot be resolved to a * repo-local file (they unpack into a build cache outside the repo) and so * are not included here. */ pathDeps: Map; + /** + * Per path-dep: repo-relative root source files the dep's own `build.zig` + * declares (`b.addModule("name", .{ .root_source_file = b.path("src/x.zig") + * })`), keyed by dep name, in file order. Entries whose module name matches + * the dep name come first — that is the module a consumer's + * `@import("")` maps to under the ecosystem convention that the zon key + * and the module name agree. Absent (or empty) when the dep has no readable + * `build.zig`; the resolver then falls back to the conventional layouts. + */ + moduleRoots?: Map; } // ============================================================================ @@ -511,13 +521,92 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise { + let config: ZigBuildZonConfig | null; try { const zonPath = path.join(repoRoot, 'build.zig.zon'); const raw = await fs.readFile(zonPath, 'utf-8'); - return parseZigBuildZon(raw); + config = parseZigBuildZon(raw); } catch { return 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 + // repo-relative. Best effort — an unreadable build.zig just leaves the + // conventional-layout fallback in place. + const moduleRoots = new Map(); + for (const [depName, depPath] of config.pathDeps) { + const rel = normalizeZigDepPath(depPath); + if (rel === null) continue; + let buildZig: string; + try { + buildZig = await fs.readFile(path.join(repoRoot, rel, 'build.zig'), 'utf-8'); + } catch { + continue; + } + const roots = parseZigBuildModuleRoots(buildZig, depName).map((r) => + rel === '' ? r : `${rel}/${r}`, + ); + if (roots.length > 0) moduleRoots.set(depName, roots); + } + return moduleRoots.size > 0 ? { ...config, moduleRoots } : config; +} + +/** + * Normalize a `.path` value from build.zig.zon into a repo-relative form. + * Returns null for paths that escape the repo root (start with `..`) or + * are absolute — those point to files we don't index. `.` / `./` normalize + * to the empty string (the repo root itself). Shared with the import + * resolver so both sides agree on which deps are in-repo. + */ +export function normalizeZigDepPath(depPath: string): string | null { + if (depPath.startsWith('/')) return null; + const parts: string[] = []; + for (const part of depPath.replace(/\\/g, '/').split('/')) { + if (part === '' || part === '.') continue; + if (part === '..') { + if (parts.length === 0) return null; + parts.pop(); + } else { + parts.push(part); + } + } + return parts.join('/'); +} + +/** + * The `root_source_file` paths a `build.zig` declares, dep-relative, with the + * module whose `addModule("", …)` name equals `preferredName` first. + * + * Reads two shapes, which between them cover `zig init` output and the + * common hand-written build scripts: + * - `b.addModule("name", .{ .root_source_file = b.path("src/root.zig") })` + * - any other `.root_source_file = b.path("…")` (exe/lib/test artifacts), + * kept as unnamed fallbacks in file order. + * A `.zig` under `b.path` is required — `.{ .cwd_relative = … }` and + * `LazyPath` values computed at build time are not resolvable statically and + * are skipped. Duplicates collapse to the first occurrence. + */ +export function parseZigBuildModuleRoots(buildZig: string, preferredName: string): string[] { + const named: string[] = []; + const unnamed: string[] = []; + const seen = new Set(); + const add = (into: string[], p: string): void => { + const norm = normalizeZigDepPath(p); + if (norm === null || norm === '' || !norm.endsWith('.zig') || seen.has(norm)) return; + seen.add(norm); + into.push(norm); + }; + const namedRe = + /addModule\(\s*"([^"\n]+)"\s*,\s*\.\{[^}]*?\.root_source_file\s*=\s*b\.path\(\s*"([^"\n]+)"\s*\)/g; + let m: RegExpExecArray | null; + while ((m = namedRe.exec(buildZig)) !== null) { + if (m[1] === preferredName) add(named, m[2]!); + } + const anyRe = /\.root_source_file\s*=\s*b\.path\(\s*"([^"\n]+)"\s*\)/g; + while ((m = anyRe.exec(buildZig)) !== null) add(unnamed, m[1]!); + return [...named, ...unnamed]; } /** diff --git a/gitnexus/test/unit/zig-import-resolver.test.ts b/gitnexus/test/unit/zig-import-resolver.test.ts index db732d432..bdb33dd7d 100644 --- a/gitnexus/test/unit/zig-import-resolver.test.ts +++ b/gitnexus/test/unit/zig-import-resolver.test.ts @@ -3,8 +3,19 @@ * imports and bare-name imports resolved through build.zig.zon. */ import { describe, it, expect } from 'vitest'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { resolveZigImportInternal } from '../../src/core/ingestion/import-resolvers/zig.js'; -import { parseZigBuildZon } from '../../src/core/ingestion/language-config.js'; +import { + loadZigBuildZon, + parseZigBuildModuleRoots, + parseZigBuildZon, +} from '../../src/core/ingestion/language-config.js'; + +const FIXTURES = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../fixtures/lang-resolution', +); describe('resolveZigImportInternal', () => { it('returns null for stdlib / builtin / root', () => { @@ -68,6 +79,35 @@ describe('resolveZigImportInternal', () => { ); }); + it('resolves a bare name to `/src/root.zig` — the `zig init` library root since 0.12', () => { + // 0.12+ `zig init` writes src/root.zig for libraries (0.14 writes both + // root.zig and main.zig). Knowing only src/.zig and src/main.zig + // left every such dep unresolved. + const files = new Set(['src/main.zig', 'libs/geo/src/root.zig']); + const zon = { pathDeps: new Map([['geo', 'libs/geo']]) }; + expect(resolveZigImportInternal('src/main.zig', 'geo', files, zon)).toBe( + 'libs/geo/src/root.zig', + ); + }); + + it('prefers the root the dep’s own build.zig declares over every conventional layout', () => { + // A dep can call its module root anything (`lib/geo.zig`); when its + // build.zig says so, that beats src/root.zig even if both exist. + const files = new Set([ + 'src/main.zig', + 'libs/geo/lib/geo.zig', + 'libs/geo/src/root.zig', + 'libs/geo/src/main.zig', + ]); + const zon = { + pathDeps: new Map([['geo', 'libs/geo']]), + moduleRoots: new Map([['geo', ['libs/geo/lib/geo.zig']]]), + }; + expect(resolveZigImportInternal('src/main.zig', 'geo', files, zon)).toBe( + 'libs/geo/lib/geo.zig', + ); + }); + it('falls back to `/src/main.zig` when no `.zig` exists', () => { const files = new Set(['src/main.zig', 'vendor/ziggit/src/main.zig']); const buildZon = { pathDeps: new Map([['ziggit', 'vendor/ziggit']]) }; @@ -246,3 +286,56 @@ describe('parseZigBuildZon', () => { expect(parseZigBuildZon(raw)).toBeNull(); }); }); + +describe('parseZigBuildModuleRoots', () => { + it('reads `addModule("", .{ .root_source_file = b.path("…") })`, preferring the named module', () => { + const buildZig = ` +const std = @import("std"); +pub fn build(b: *std.Build) void { + const lib = b.addStaticLibrary(.{ .name = "geo", .root_source_file = b.path("src/lib_entry.zig") }); + _ = b.addModule("helpers", .{ .root_source_file = b.path("src/helpers.zig") }); + _ = b.addModule("geo", .{ + .root_source_file = b.path("src/root.zig"), + .target = b.standardTargetOptions(.{}), + }); + b.installArtifact(lib); +} +`; + // The module named like the dep comes first; the others stay as ordered + // fallbacks (an importer's `@import("geo")` maps to the "geo" module). + expect(parseZigBuildModuleRoots(buildZig, 'geo')).toEqual([ + 'src/root.zig', + 'src/lib_entry.zig', + 'src/helpers.zig', + ]); + }); + + it('skips roots that are not a static `b.path("….zig")` and normalizes `./`', () => { + const buildZig = ` +_ = b.addModule("x", .{ .root_source_file = .{ .cwd_relative = "/abs/x.zig" } }); +_ = b.addModule("y", .{ .root_source_file = b.path("./src/y.zig") }); +_ = b.addModule("z", .{ .root_source_file = generated.getPath() }); +_ = b.addModule("w", .{ .root_source_file = b.path("../outside.zig") }); +`; + expect(parseZigBuildModuleRoots(buildZig, 'y')).toEqual(['src/y.zig']); + }); + + it('returns [] for a build.zig that declares no module root', () => { + expect(parseZigBuildModuleRoots('pub fn build(b: *std.Build) void { _ = b; }', 'x')).toEqual( + [], + ); + }); +}); + +describe('loadZigBuildZon (zig-idioms fixture)', () => { + 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')); + 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") }) + expect(config!.moduleRoots?.get('geo')).toEqual(['libs/geo/src/root.zig']); + // oldlib has no build.zig → no entry; the resolver falls back to + // src/root.zig → src/oldlib.zig → src/main.zig. + expect(config!.moduleRoots?.has('oldlib')).toBe(false); + }); +});