feat(languages/zig): resolve build.zig.zon package imports

Bare-name @import("pkg") calls in Zig refer to dependencies declared in
build.zig.zon. Previously these returned null and fell through to the
generic suffix matcher, which could only resolve them by accident.

This adds:
- A `loadZigBuildZon` config loader that parses build.zig.zon at the
  repo root and extracts `.path = "..."` dependency mappings into a
  per-run ZigBuildZonConfig.
- A regex-based parser (`parseZigBuildZon`) over the .zon source — a
  small anonymous-struct literal — rather than pulling in tree-sitter
  for one file. Limitations are documented inline; the parser handles
  the two common shapes (`.path` and `.url + .hash`) and bails to null
  on anything weirder.
- Resolver wiring in `resolveZigImportInternal` that, given a parsed
  ZigBuildZonConfig, tries the conventional Zig package layout
  (`<dep_root>/src/<name>.zig`, then `<dep_root>/src/main.zig`).

`.url`-based deps unpack into a build cache outside the repo
(.zig-cache/p/<hash>/) and so can't be resolved against repo-local
files; those bare names continue to return null cleanly. `.path` deps
that escape the repo root (`..`) are also rejected to avoid pointing
at files outside `allFilePaths`.

Tests:
- Unit coverage for `resolveZigImportInternal` (10 cases — relative
  paths, parent traversal, bare-name dep resolution with both
  `<name>.zig` and `main.zig` layouts, escape-the-repo rejection).
- Unit coverage for `parseZigBuildZon` (3 cases — mixed `.path`/`.url`
  block, missing deps block, deps with no `.path` entries).
- The pre-existing `csharpConfigs: []` test contexts gain a
  `zigBuildZon: null` field to match the widened `ImportConfigs`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garrett Griffin-Morales 2026-04-26 18:37:40 -04:00
parent ededebf012
commit 91f680ec8e
8 changed files with 275 additions and 9 deletions

View file

@ -9,7 +9,12 @@ import { createStandardStrategy } from '../standard.js';
import { resolveZigImportInternal } from '../zig.js';
export const zigModuleStrategy: ImportResolverStrategy = (rawImportPath, filePath, ctx) => {
const resolved = resolveZigImportInternal(filePath, rawImportPath, ctx.allFilePaths);
const resolved = resolveZigImportInternal(
filePath,
rawImportPath,
ctx.allFilePaths,
ctx.configs.zigBuildZon,
);
return resolved ? { kind: 'files', files: [resolved] } : null;
};

View file

@ -10,7 +10,7 @@ import type {
CSharpProjectConfig,
ComposerConfig,
} from '../language-config.js';
import type { SwiftPackageConfig } from '../language-config.js';
import type { SwiftPackageConfig, ZigBuildZonConfig } from '../language-config.js';
import type { SuffixIndex } from './utils.js';
import type { SupportedLanguages } from 'gitnexus-shared';
@ -32,6 +32,7 @@ export interface ImportConfigs {
composerConfig: ComposerConfig | null;
swiftPackageConfig: SwiftPackageConfig | null;
csharpConfigs: CSharpProjectConfig[];
zigBuildZon: ZigBuildZonConfig | null;
}
/** Pre-built lookup structures for import resolution. Build once, reuse across chunks. */

View file

@ -10,22 +10,29 @@
* paths with a `.zig` extension as
* filesystem-relative to the importer)
* const bar = @import("bar"); package dep declared in build.zig.zon
* (TODO: resolve via build.zig.zon)
*
* Only the relative-path cases are resolved here. Stdlib / builtin / root
* names and unrecognised package names return null so the standard fallback
* can attempt suffix matching.
* 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.
*/
import type { ZigBuildZonConfig } from '../language-config.js';
const ZIG_STDLIB_NAMES = new Set(['std', 'builtin', 'root']);
/** Resolve a Zig @import argument to a file path in the repository.
* Returns null when the import is a stdlib / builtin / root reference,
* a build.zig.zon package dep, or genuinely unresolvable. */
* an unresolvable build.zig.zon package dep, or genuinely unresolvable.
*
* `buildZon` (optional) supplies the parsed `.dependencies` map from
* build.zig.zon. */
export function resolveZigImportInternal(
currentFile: string,
importPath: string,
allFiles: Set<string>,
buildZon?: ZigBuildZonConfig | null,
): string | null {
// Stdlib / compiler builtin / root — not resolvable from source files alone.
if (ZIG_STDLIB_NAMES.has(importPath)) return null;
@ -53,7 +60,46 @@ export function resolveZigImportInternal(
}
// Bare name without extension or slashes (e.g. @import("bar")).
// TODO: resolve via build.zig.zon package map. For now, return null and
// let the standard suffix matcher try its luck.
// Try to resolve via build.zig.zon `.path` deps.
if (buildZon) {
const depPath = buildZon.pathDeps.get(importPath);
if (depPath) {
const normalized = normalizeDepPath(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.
const candidates = [
`${normalized}/src/${importPath}.zig`,
`${normalized}/src/main.zig`,
];
for (const c of candidates) {
if (allFiles.has(c)) return c;
}
}
}
}
// Bare name with no resolution (no build.zig.zon, .url-based dep, or
// unconventional layout). Fall through to the standard suffix matcher.
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`.
*/
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

@ -45,6 +45,16 @@ export interface SwiftPackageConfig {
targets: Map<string, string>;
}
/** Zig package config parsed from build.zig.zon */
export interface ZigBuildZonConfig {
/**
* Map of dependency name -> repo-relative path for `.path = "..."` entries.
* `.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>;
}
// ============================================================================
// LANGUAGE-SPECIFIC CONFIG LOADERS
// ============================================================================
@ -224,6 +234,88 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
return null;
}
/**
* Parse build.zig.zon to extract `.path = "..."` dependency mappings.
*
* `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
* regex-based extractor that handles the common shapes:
*
* .dependencies = .{
* .ziggit_pkg = .{
* .url = "https://...",
* .hash = "1220...",
* },
* .local_dep = .{
* .path = "../local_dep",
* },
* },
*
* Limitations (intentional bail to null on anything weirder):
* - Only the top-level `.dependencies = .{ ... }` block is parsed; nested
* or aliased blocks are ignored.
* - Each dep entry is matched by a single shape: `.<name> = .{ ... }`
* where `<name>` is a bare identifier (no `@"…"` quoted form).
* - Only `.path = "..."` is captured. `.url` deps are left unresolved
* because their unpacked location lives outside the repo
* (.zig-cache/p/<hash>/ or ~/.cache/zig/p/<hash>/) and is therefore
* not in our `allFilePaths` set.
* - Comments (`//`) inside the dep block are not stripped; if a `.path`
* is commented out it will still match. Acceptable for an indexer.
*/
export async function loadZigBuildZon(repoRoot: string): Promise<ZigBuildZonConfig | null> {
try {
const zonPath = path.join(repoRoot, 'build.zig.zon');
const raw = await fs.readFile(zonPath, 'utf-8');
return parseZigBuildZon(raw);
} catch {
return null;
}
}
/** Pure parser split out for testability. Returns null when no path-deps found. */
export function parseZigBuildZon(raw: string): ZigBuildZonConfig | null {
// Locate the `.dependencies = .{ ... }` block. Use brace counting because
// dep entries are nested anonymous structs and a naive `}` match would stop early.
const depsHeader = raw.match(/\.dependencies\s*=\s*\.\{/);
if (!depsHeader) return null;
const start = depsHeader.index! + depsHeader[0].length;
let depth = 1;
let end = -1;
for (let i = start; i < raw.length; i++) {
const ch = raw[i];
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
if (end < 0) return null;
const block = raw.slice(start, end);
const pathDeps = new Map<string, string>();
// Match each `.<name> = .{ ... }` entry; capture the entry body.
const entryRe = /\.([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\.\{([\s\S]*?)\}\s*,?/g;
let m: RegExpExecArray | null;
while ((m = entryRe.exec(block)) !== null) {
const depName = m[1];
const body = m[2];
const pathMatch = body.match(/\.path\s*=\s*"([^"\n]+)"/);
if (pathMatch) {
pathDeps.set(depName, pathMatch[1]);
}
}
if (pathDeps.size === 0) return null;
if (isDev) {
console.log(`📦 Loaded ${pathDeps.size} Zig path-dep(s) from build.zig.zon`);
}
return { pathDeps };
}
// ============================================================================
// BUNDLED CONFIG LOADER
// ============================================================================
@ -236,5 +328,6 @@ export async function loadImportConfigs(repoRoot: string): Promise<ImportConfigs
composerConfig: await loadComposerConfig(repoRoot),
swiftPackageConfig: await loadSwiftPackageConfig(repoRoot),
csharpConfigs: await loadCSharpProjectConfig(repoRoot),
zigBuildZon: await loadZigBuildZon(repoRoot),
};
}

View file

@ -37,6 +37,7 @@ function makeCtx(files: string[]): ResolveCtx {
composerConfig: null,
swiftPackageConfig: null,
csharpConfigs: [],
zigBuildZon: null,
},
};
}

View file

@ -77,6 +77,7 @@ function makeCtx(files: string[], overrides: Partial<ResolveCtx['configs']> = {}
composerConfig: null,
swiftPackageConfig: null,
csharpConfigs: [],
zigBuildZon: null,
...overrides,
},
};

View file

@ -34,6 +34,7 @@ const emptyCtx: ResolveCtx = {
composerConfig: null,
swiftPackageConfig: null,
csharpConfigs: [],
zigBuildZon: null,
},
};

View file

@ -0,0 +1,118 @@
/**
* Unit tests for the Zig import resolver, covering both relative-path
* imports and bare-name imports resolved through build.zig.zon.
*/
import { describe, it, expect } from 'vitest';
import { resolveZigImportInternal } from '../../src/core/ingestion/import-resolvers/zig.js';
import { parseZigBuildZon } from '../../src/core/ingestion/language-config.js';
describe('resolveZigImportInternal', () => {
it('returns null for stdlib / builtin / root', () => {
const files = new Set<string>(['src/main.zig']);
expect(resolveZigImportInternal('src/main.zig', 'std', files)).toBeNull();
expect(resolveZigImportInternal('src/main.zig', 'builtin', files)).toBeNull();
expect(resolveZigImportInternal('src/main.zig', 'root', files)).toBeNull();
});
it('resolves "./foo.zig" relative to the importer', () => {
const files = new Set<string>(['src/main.zig', 'src/foo.zig']);
expect(resolveZigImportInternal('src/main.zig', './foo.zig', files)).toBe('src/foo.zig');
});
it('resolves "foo.zig" without a "./" prefix as filesystem-relative', () => {
const files = new Set<string>(['src/main.zig', 'src/foo.zig']);
expect(resolveZigImportInternal('src/main.zig', 'foo.zig', files)).toBe('src/foo.zig');
});
it('resolves "../sibling/file.zig" with parent traversal', () => {
const files = new Set<string>(['src/a/main.zig', 'src/b/util.zig']);
expect(resolveZigImportInternal('src/a/main.zig', '../b/util.zig', files)).toBe(
'src/b/util.zig',
);
});
it('returns null for a bare name when no build.zig.zon is supplied', () => {
const files = new Set<string>(['src/main.zig', 'vendor/ziggit/src/ziggit.zig']);
expect(resolveZigImportInternal('src/main.zig', 'ziggit', files)).toBeNull();
});
it('resolves a bare name via a `.path` build.zig.zon dep (`<root>/src/<name>.zig`)', () => {
const files = new Set<string>(['src/main.zig', 'vendor/ziggit/src/ziggit.zig']);
const buildZon = { pathDeps: new Map([['ziggit', 'vendor/ziggit']]) };
expect(resolveZigImportInternal('src/main.zig', 'ziggit', files, buildZon)).toBe(
'vendor/ziggit/src/ziggit.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']]) };
expect(resolveZigImportInternal('src/main.zig', 'ziggit', files, buildZon)).toBe(
'vendor/ziggit/src/main.zig',
);
});
it('returns null for `.path` deps that escape the repo root (`..`)', () => {
const files = new Set<string>(['src/main.zig']);
const buildZon = { pathDeps: new Map([['ziggit', '../ziggit']]) };
expect(resolveZigImportInternal('src/main.zig', 'ziggit', files, buildZon)).toBeNull();
});
it('returns null when the conventional layout file is missing', () => {
const files = new Set<string>(['src/main.zig', 'vendor/ziggit/lib/something.zig']);
const buildZon = { pathDeps: new Map([['ziggit', 'vendor/ziggit']]) };
expect(resolveZigImportInternal('src/main.zig', 'ziggit', files, buildZon)).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']]) };
expect(resolveZigImportInternal('src/main.zig', 'mystery_pkg', files, buildZon)).toBeNull();
});
});
describe('parseZigBuildZon', () => {
it('extracts `.path = "..."` deps and skips `.url`-based deps', () => {
const raw = `
.{
.name = "myproject",
.version = "0.1.0",
.dependencies = .{
.ziggit_pkg = .{
.url = "https://github.com/.../archive/abc.tar.gz",
.hash = "1220abc",
},
.local_dep = .{
.path = "../local_dep",
},
.vendor_dep = .{
.path = "vendor/foo",
},
},
.paths = .{ "" },
}
`;
const cfg = parseZigBuildZon(raw);
expect(cfg).not.toBeNull();
expect(cfg!.pathDeps.get('local_dep')).toBe('../local_dep');
expect(cfg!.pathDeps.get('vendor_dep')).toBe('vendor/foo');
// .url-based deps are intentionally absent
expect(cfg!.pathDeps.has('ziggit_pkg')).toBe(false);
});
it('returns null when no `.dependencies` block is present', () => {
const raw = `.{ .name = "x", .version = "0.0.0", .paths = .{""} }`;
expect(parseZigBuildZon(raw)).toBeNull();
});
it('returns null when the deps block has no `.path` entries', () => {
const raw = `
.{
.dependencies = .{
.only_url = .{ .url = "https://x", .hash = "1220y" },
},
}
`;
expect(parseZigBuildZon(raw)).toBeNull();
});
});