feat(zig): resolve path deps through the dep's build.zig and src/root.zig

The bare-name resolver only knew `src/<name>.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("<name>", .{ .root_source_file =
b.path("…") })`, name-matched module first), then src/root.zig,
src/<name>.zig, src/main.zig. `normalizeZigDepPath` is shared by the
loader and the resolver.
This commit is contained in:
Navid EMAD 2026-08-18 12:13:08 +02:00
parent 307f7054de
commit 69831484ef
No known key found for this signature in database
3 changed files with 203 additions and 33 deletions

View file

@ -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
* `<dep_root>/src/<name>.zig` or `<dep_root>/src/main.zig` layout.
* `.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 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: <pkg_root>/src/<name>.zig (matches the
// package's primary module name) or <pkg_root>/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/<name>.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('/');
}

View file

@ -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<string, string>;
/**
* 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("<dep>")` 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<string, readonly string[]>;
}
// ============================================================================
@ -511,13 +521,92 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
* or string cannot declare a dep or truncate the block.
*/
export async function loadZigBuildZon(repoRoot: string): Promise<ZigBuildZonConfig | null> {
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<string, readonly string[]>();
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>", …)` 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<string>();
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];
}
/**

View file

@ -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 `<root>/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/<name>.zig and src/main.zig
// left every such dep unresolved.
const files = new Set<string>(['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<string>([
'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 `<root>/src/main.zig` when no `<name>.zig` exists', () => {
const files = new Set<string>(['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("<name>", .{ .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);
});
});