mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-14 23:22:54 +00:00
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:
parent
ededebf012
commit
91f680ec8e
8 changed files with 275 additions and 9 deletions
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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('/');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ function makeCtx(files: string[]): ResolveCtx {
|
|||
composerConfig: null,
|
||||
swiftPackageConfig: null,
|
||||
csharpConfigs: [],
|
||||
zigBuildZon: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ function makeCtx(files: string[], overrides: Partial<ResolveCtx['configs']> = {}
|
|||
composerConfig: null,
|
||||
swiftPackageConfig: null,
|
||||
csharpConfigs: [],
|
||||
zigBuildZon: null,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const emptyCtx: ResolveCtx = {
|
|||
composerConfig: null,
|
||||
swiftPackageConfig: null,
|
||||
csharpConfigs: [],
|
||||
zigBuildZon: null,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
118
gitnexus/test/unit/zig-import-resolver.test.ts
Normal file
118
gitnexus/test/unit/zig-import-resolver.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue