From 91f680ec8ef9bfbe52e2ca11bd9a4806da6e1e31 Mon Sep 17 00:00:00 2001 From: Garrett Griffin-Morales Date: Sun, 26 Apr 2026 18:37:40 -0400 Subject: [PATCH] feat(languages/zig): resolve build.zig.zon package imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (`/src/.zig`, then `/src/main.zig`). `.url`-based deps unpack into a build cache outside the repo (.zig-cache/p//) 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 `.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) --- .../ingestion/import-resolvers/configs/zig.ts | 7 +- .../core/ingestion/import-resolvers/types.ts | 3 +- .../core/ingestion/import-resolvers/zig.ts | 60 +++++++-- .../src/core/ingestion/language-config.ts | 93 ++++++++++++++ .../test/unit/dart-import-resolver.test.ts | 1 + .../test/unit/import-resolver-factory.test.ts | 1 + .../import-target-adapter.test.ts | 1 + .../test/unit/zig-import-resolver.test.ts | 118 ++++++++++++++++++ 8 files changed, 275 insertions(+), 9 deletions(-) create mode 100644 gitnexus/test/unit/zig-import-resolver.test.ts diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/zig.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/zig.ts index 448f1d625..986feefbb 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/configs/zig.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/zig.ts @@ -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; }; diff --git a/gitnexus/src/core/ingestion/import-resolvers/types.ts b/gitnexus/src/core/ingestion/import-resolvers/types.ts index 66e23a79b..c9c5c3851 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/types.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/types.ts @@ -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. */ diff --git a/gitnexus/src/core/ingestion/import-resolvers/zig.ts b/gitnexus/src/core/ingestion/import-resolvers/zig.ts index 29819ff66..1028f0fa3 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/zig.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/zig.ts @@ -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 + * `/src/.zig` or `/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, + 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: /src/.zig (matches the + // package's primary module name) or /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('/'); +} diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 682d7b190..4ed78c1c6 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -45,6 +45,16 @@ export interface SwiftPackageConfig { targets: Map; } +/** 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; +} + // ============================================================================ // LANGUAGE-SPECIFIC CONFIG LOADERS // ============================================================================ @@ -224,6 +234,88 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise = .{ ... }` + * where `` 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// or ~/.cache/zig/p//) 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 { + 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(); + // Match each `. = .{ ... }` 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 = {} composerConfig: null, swiftPackageConfig: null, csharpConfigs: [], + zigBuildZon: null, ...overrides, }, }; diff --git a/gitnexus/test/unit/scope-resolution/import-target-adapter.test.ts b/gitnexus/test/unit/scope-resolution/import-target-adapter.test.ts index 8459fc4ba..c8db64be8 100644 --- a/gitnexus/test/unit/scope-resolution/import-target-adapter.test.ts +++ b/gitnexus/test/unit/scope-resolution/import-target-adapter.test.ts @@ -34,6 +34,7 @@ const emptyCtx: ResolveCtx = { composerConfig: null, swiftPackageConfig: null, csharpConfigs: [], + zigBuildZon: null, }, }; diff --git a/gitnexus/test/unit/zig-import-resolver.test.ts b/gitnexus/test/unit/zig-import-resolver.test.ts new file mode 100644 index 000000000..66af4199b --- /dev/null +++ b/gitnexus/test/unit/zig-import-resolver.test.ts @@ -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(['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(['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(['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(['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(['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 (`/src/.zig`)', () => { + const files = new Set(['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 `/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']]) }; + 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(['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(['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(['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(); + }); +});