fix(zig): address gitnexus-check review findings

One commit per the bot's list so each item is easy to check off:

- parser-loader: Zig row gains `userSkippable: true`, so
  `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` (=1 or a list naming `zig`) disables it
  at analyze time like swift/dart/kotlin — as `optional-grammars.ts` already
  documented. Covered in parser-loader-skip-optional.test.ts.
- export-detection: `zigExportChecker` stops at the first declaration it
  reaches. A non-`pub` fn inside `pub const T = struct {…}` was reported
  exported because the walk continued up to the wrapper.
- import-resolvers/zig: a `.path = "."` dep normalizes to '' and no longer
  grows a leading slash (`/src/main.zig` could never match).
- language-config: build.zig.zon parsing strips `//` comments string-aware
  (a `//` inside `.url = "https://…"` survives) and matches braces while
  skipping string literals, so a commented-out `.path` cannot declare a dep
  and a `}` in a comment/string cannot truncate the block.
- method-extractors/configs/zig: the leading `self` receiver is excluded
  from `parameters` (Rust parity). Fixing that exposed a worse bug: the
  `parameters` node is a plain child of `function_declaration`, not a
  `parameters:` field, so `childForFieldName('parameters')` was always null
  and every Zig method had no parameters, no receiver and `isStatic: true`.
  One `zigParameterList` helper now feeds all three readers.
- variable-extractors/configs/zig: container (`struct`/`enum`/`union`) and
  `@import` bindings are skipped via the same predicate the scope captures
  use (`isZigContainerOrImportBinding`), instead of the comment merely
  claiming they were.
- tree-sitter-languages.test.ts: the "missing grammar" case now forces the
  absent-binding path through the loader's runtime opt-out on a fresh module
  instead of passing vacuously when the package is installed.

New: test/unit/zig-extractors.test.ts (exports, receiver/parameters,
variable guard); zig-import-resolver.test.ts gains the `.` dep, comment and
brace cases.

The `createFieldExtractor` heads-up needs no change: the added branch is
unreachable for every existing config (none has empty `bodyNodeTypes`).
This commit is contained in:
Navid EMAD 2026-08-18 03:35:06 +02:00
parent a69cb3a48a
commit bc39f1b2d0
No known key found for this signature in database
12 changed files with 378 additions and 49 deletions

View file

@ -251,6 +251,11 @@ const ZIG_DECL_TYPES = new Set(['function_declaration', 'variable_declaration'])
* keyword token (tree-sitter-zig models `pub` as an anonymous keyword child of
* function_declaration / variable_declaration). Container fields (struct/enum
* variants) are public if their enclosing variable_declaration is public.
*
* The walk stops at the FIRST declaration it reaches: a `fn` inside
* `pub const T = struct { … }` carries its own `pub` (or not), independent of
* the container's. Continuing up to the wrapper marked every private method of
* a public container as exported.
*/
export const zigExportChecker: ExportChecker = (node, _name) => {
let current: SyntaxNode | null = node;
@ -260,13 +265,6 @@ export const zigExportChecker: ExportChecker = (node, _name) => {
const child = current.child(i);
if (child?.type === 'pub') return true;
}
// For nested function_declaration (a method inside a struct), keep walking
// up to the outer variable_declaration — it's the binding that carries
// module-level visibility.
if (current.type === 'function_declaration') {
current = current.parent;
continue;
}
return false;
}
current = current.parent;

View file

@ -67,8 +67,11 @@ export function resolveZigImportInternal(
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`];
// 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.
const prefix = normalized === '' ? '' : `${normalized}/`;
const candidates = [`${prefix}src/${importPath}.zig`, `${prefix}src/main.zig`];
for (const c of candidates) {
if (allFiles.has(c)) return c;
}
@ -85,6 +88,7 @@ export function resolveZigImportInternal(
* 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;

View file

@ -501,8 +501,10 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
* 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.
* - `//` line comments are stripped before scanning (string-aware, so a
* `//` inside `.url = "https://…"` survives), and brace matching skips
* string literals — a commented-out `.path` or a `}` inside a comment
* or string cannot declare a dep or truncate the block.
*/
export async function loadZigBuildZon(repoRoot: string): Promise<ZigBuildZonConfig | null> {
try {
@ -514,36 +516,84 @@ export async function loadZigBuildZon(repoRoot: string): Promise<ZigBuildZonConf
}
}
/** 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++) {
/**
* Blank out `//` line comments (and `\\` multiline-string-literal lines) in
* ZON source, string-aware: a `//` inside a `"…"` literal (`.url =
* "https://…"`) is content, not a comment. Comment bytes are replaced with
* spaces so every surviving character keeps its offset.
*/
function stripZonComments(raw: string): string {
const out = raw.split('');
let inString = false;
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) {
end = i;
break;
}
if (inString) {
if (ch === '\\')
i++; // skip the escaped char
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') {
inString = true;
continue;
}
const isLineComment = ch === '/' && raw[i + 1] === '/';
const isMultilineLiteral =
ch === '\\' &&
raw[i + 1] === '\\' &&
/^[ \t]*$/.test(raw.slice(raw.lastIndexOf('\n', i) + 1, i));
if (isLineComment || isMultilineLiteral) {
while (i < raw.length && raw[i] !== '\n') out[i++] = ' ';
}
}
return out.join('');
}
/**
* Index of the `}` matching the `{` that precedes `start`, skipping braces
* inside `"…"` literals. -1 when unbalanced. Call on comment-stripped text.
*/
function findZonBlockEnd(text: string, start: number): number {
let depth = 1;
let inString = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (ch === '\\') i++;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '{') depth++;
else if (ch === '}' && --depth === 0) return i;
}
return -1;
}
/** Pure parser split out for testability. Returns null when no path-deps found. */
export function parseZigBuildZon(raw: string): ZigBuildZonConfig | null {
const text = stripZonComments(raw);
// Locate the `.dependencies = .{ ... }` block. Use brace counting because
// dep entries are nested anonymous structs and a naive `}` match would stop early.
const depsHeader = text.match(/\.dependencies\s*=\s*\.\{/);
if (!depsHeader) return null;
const start = depsHeader.index! + depsHeader[0].length;
const end = findZonBlockEnd(text, start);
if (end < 0) return null;
const block = raw.slice(start, end);
const block = text.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;
// Walk each `.<name> = .{ ... }` entry; the body ends at the matching brace
// (string-aware), not at the first `}` in the text.
const entryHeaderRe = /\.([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\.\{/g;
let m: RegExpExecArray | null;
while ((m = entryRe.exec(block)) !== null) {
while ((m = entryHeaderRe.exec(block)) !== null) {
const depName = m[1];
const body = m[2];
const bodyStart = m.index + m[0].length;
const bodyEnd = findZonBlockEnd(block, bodyStart);
if (bodyEnd < 0) break;
const body = block.slice(bodyStart, bodyEnd);
entryHeaderRe.lastIndex = bodyEnd + 1;
const pathMatch = body.match(/\.path\s*=\s*"([^"\n]+)"/);
if (pathMatch) {
pathDeps.set(depName, pathMatch[1]);

View file

@ -14,8 +14,10 @@ export const ZIG_CONTAINER_TYPES: ReadonlySet<string> = new Set([
/** Is this variable_declaration a container binding (`const T = struct {…}`)
* or an import binding (`const x = @import("…")`)? Those groups are emitted
* by their dedicated query rules; the plain @declaration.variable match for
* the same node must be dropped so the name binds exactly once. */
function isContainerOrImportBinding(declNode: SyntaxNode): boolean {
* the same node must be dropped so the name binds exactly once. Shared with
* the variable extractor config so the structure-phase Variable records and
* the scope-side bindings agree on what counts as a plain variable. */
export function isZigContainerOrImportBinding(declNode: SyntaxNode): boolean {
for (let i = 0; i < declNode.namedChildCount; i++) {
const child = declNode.namedChild(i);
if (child === null) continue;
@ -118,7 +120,7 @@ export function emitZigScopeCaptures(
// Drop the plain-variable group for container/import bindings — their
// dedicated rules already bind the name (as Struct/Enum/Union or import).
const variableAnchor = nodeMap['@declaration.variable'];
if (variableAnchor !== undefined && isContainerOrImportBinding(variableAnchor)) {
if (variableAnchor !== undefined && isZigContainerOrImportBinding(variableAnchor)) {
continue;
}

View file

@ -1,4 +1,9 @@
export { emitZigScopeCaptures, isZigContainerMethod, ZIG_CONTAINER_TYPES } from './captures.js';
export {
emitZigScopeCaptures,
isZigContainerMethod,
isZigContainerOrImportBinding,
ZIG_CONTAINER_TYPES,
} from './captures.js';
export { interpretZigImport, interpretZigTypeBinding, normalizeZigTypeName } from './interpret.js';
export {
zigArityCompatibility,

View file

@ -28,6 +28,18 @@ const extractZigName = (node: SyntaxNode): string | undefined => {
return nameNode?.text;
};
/**
* The `parameters` node of a function_declaration. tree-sitter-zig 1.1.2
* attaches it as a plain named child — NOT under a `parameters:` field (only
* `name`, `type` and `body` are fields) — so `childForFieldName('parameters')`
* is always null. Reading it that way silently produced empty parameter lists,
* no receiver, and `isStatic: true` for every method.
*/
const zigParameterList = (node: SyntaxNode): SyntaxNode | null =>
node.childForFieldName('parameters') ??
node.namedChildren.find((child): child is SyntaxNode => child?.type === 'parameters') ??
null;
const extractZigReturnType = (node: SyntaxNode): string | undefined => {
// tree-sitter-zig labels the return type as the `type` field on
// function_declaration (the same field name used for parameter types).
@ -35,15 +47,24 @@ const extractZigReturnType = (node: SyntaxNode): string | undefined => {
return typeNode?.text?.trim();
};
/**
* Regular parameters only. A leading `self` parameter is the receiver — it is
* reported through `extractReceiverType`, not the parameter list (same split
* as Rust's `self_parameter` skip in `configs/rust.ts`).
*/
const extractZigParameters = (node: SyntaxNode): ParameterInfo[] => {
const paramList = node.childForFieldName('parameters');
const paramList = zigParameterList(node);
if (!paramList) return [];
const params: ParameterInfo[] = [];
let seenParameter = false;
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param || param.type !== 'parameter') continue;
const nameNode = param.childForFieldName('name');
const typeNode = param.childForFieldName('type');
const isReceiver = !seenParameter && nameNode?.text === 'self';
seenParameter = true;
if (isReceiver) continue;
params.push({
name: nameNode?.text ?? '?',
type: typeNode?.text?.trim() ?? null,
@ -64,7 +85,7 @@ const hasPubKeyword = (node: SyntaxNode): boolean => {
};
const extractZigReceiverType = (node: SyntaxNode): string | undefined => {
const paramList = node.childForFieldName('parameters');
const paramList = zigParameterList(node);
if (!paramList) return undefined;
const first = paramList.namedChild(0);
if (!first || first.type !== 'parameter') return undefined;
@ -88,7 +109,7 @@ export const zigMethodConfig: MethodExtractionConfig = {
isStatic(node) {
// A Zig "method" is effectively static if its first parameter is not `self`.
const paramList = node.childForFieldName('parameters');
const paramList = zigParameterList(node);
if (!paramList) return true;
const first = paramList.namedChild(0);
if (!first || first.type !== 'parameter') return true;

View file

@ -1,6 +1,7 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { VariableExtractionConfig, VariableVisibility } from '../../variable-types.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { isZigContainerOrImportBinding } from '../../languages/zig/captures.js';
/**
* Zig variable extraction.
@ -11,9 +12,10 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js';
*
* Excludes variable_declarations whose value is a struct/enum/union or an
* `@import(...)` builtin — those are handled by the class extractor / import
* pipeline. The exclusion is by-effect rather than by-config: when one of
* those values is present, the surrounding pipeline labels the node as
* Struct/Enum/Import-edge and the variable record is redundant.
* pipeline, and a Variable record beside the Struct/Enum/Union node (or the
* import edge) would be a duplicate. `extractName` returns undefined for them,
* which is the generic extractor's skip signal (see `generic.ts`, and Python's
* broad `expression_statement` config for the same pattern).
*/
const hasPubKeyword = (node: SyntaxNode): boolean => {
@ -39,6 +41,7 @@ export const zigVariableConfig: VariableExtractionConfig = {
variableNodeTypes: [],
extractName(node) {
if (isZigContainerOrImportBinding(node)) return undefined;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'identifier') return child.text;

View file

@ -182,10 +182,13 @@ const SOURCES: Record<string, GrammarSource> = {
// (verified by load-time smoke test). The peer-dep mismatch is suppressed
// via the `overrides` block in package.json. Listed as `optional: true`
// because the package is in `optionalDependencies` — users on platforms
// without a prebuild may not have it.
// without a prebuild may not have it — and `userSkippable` because it is
// a genuinely-optional grammar: `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` must be
// able to disable it at analyze time like swift/dart/kotlin.
[SupportedLanguages.Zig]: {
load: () => _require('@tree-sitter-grammars/tree-sitter-zig'),
optional: true,
userSkippable: true,
unavailableNote:
'Zig parsing disabled: `@tree-sitter-grammars/tree-sitter-zig` is an ' +
'optionalDependency and is not installed (or its native binding failed ' +

View file

@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { describe, it, expect, beforeAll, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import {
@ -700,11 +700,25 @@ describe('Tree-sitter multi-language parsing', () => {
expect(defTypes).toContain('definition.enum');
});
it('gracefully handles missing tree-sitter-zig', async () => {
it('reports Zig unavailable and throws "Unsupported language" when the grammar is absent', async () => {
// Force the absent-binding path instead of hoping the package is missing:
// the loader's runtime opt-out (`GITNEXUS_SKIP_OPTIONAL_GRAMMARS`) makes a
// userSkippable grammar report exactly as an absent binding does. A fresh
// module copy is needed because the loader memoizes load results.
const ENV = 'GITNEXUS_SKIP_OPTIONAL_GRAMMARS';
const previous = process.env[ENV];
process.env[ENV] = 'zig';
try {
await loadLanguage(SupportedLanguages.Zig);
} catch (e: any) {
expect(e.message).toContain('Unsupported language');
vi.resetModules();
const fresh = await import('../../src/core/tree-sitter/parser-loader.js');
expect(fresh.isLanguageAvailable(SupportedLanguages.Zig)).toBe(false);
await expect(fresh.loadLanguage(SupportedLanguages.Zig)).rejects.toThrow(
/Unsupported language/,
);
} finally {
if (previous === undefined) delete process.env[ENV];
else process.env[ENV] = previous;
vi.resetModules();
}
});
});

View file

@ -39,6 +39,17 @@ describe('parser-loader GITNEXUS_SKIP_OPTIONAL_GRAMMARS runtime gate', () => {
expect(isLanguageAvailable(SupportedLanguages.Swift)).toBe(false);
expect(isLanguageAvailable(SupportedLanguages.Dart)).toBe(false);
expect(isLanguageAvailable(SupportedLanguages.Kotlin)).toBe(false);
// Zig is an npm optionalDependency rather than a vendored grammar, but it
// is just as genuinely optional: the documented opt-out must cover it too
// (`optional-grammars.ts` lists it among the skippable grammars).
expect(isLanguageAvailable(SupportedLanguages.Zig)).toBe(false);
});
it('a comma list can name zig on its own', async () => {
const { isLanguageAvailable, isGrammarRuntimeSkipped } = await freshLoader('zig');
expect(isLanguageAvailable(SupportedLanguages.Zig)).toBe(false);
expect(isGrammarRuntimeSkipped(SupportedLanguages.Zig)).toBe(true);
expect(isGrammarRuntimeSkipped(SupportedLanguages.Swift)).toBe(false);
});
it('skip=all/true/* also skip every optional grammar', async () => {

View file

@ -0,0 +1,153 @@
/**
* Zig structure-phase extractors: export detection, method parameters, and
* the variable extractor's container/import guard. Each case pins a finding
* from the PR review of the Zig provider — the assertion is the behavior that
* was wrong, not merely that extraction runs.
*
* `@tree-sitter-grammars/tree-sitter-zig` is an optionalDependency: the whole
* file skips cleanly when it is absent, mirroring the Dart/Kotlin suites.
*/
import { describe, it, expect } from 'vitest';
import { createRequire } from 'node:module';
import Parser from 'tree-sitter';
import { SupportedLanguages } from 'gitnexus-shared';
import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js';
import { zigExportChecker } from '../../src/core/ingestion/export-detection.js';
import { createMethodExtractor } from '../../src/core/ingestion/method-extractors/generic.js';
import { zigMethodConfig } from '../../src/core/ingestion/method-extractors/configs/zig.js';
import { createVariableExtractor } from '../../src/core/ingestion/variable-extractors/generic.js';
import { zigVariableConfig } from '../../src/core/ingestion/variable-extractors/configs/zig.js';
const _require = createRequire(import.meta.url);
let Zig: unknown = null;
try {
Zig = _require('@tree-sitter-grammars/tree-sitter-zig');
} catch {
// optional grammar absent on this platform — suite skips below
}
const describeZig = Zig ? describe : describe.skip;
const parser = new Parser();
const parse = (code: string) => {
parser.setLanguage(Zig as Parser.Language);
return parser.parse(code);
};
/** Depth-first search for the first node of `type` whose text starts with `prefix`. */
function find(root: SyntaxNode, type: string, prefix = ''): SyntaxNode {
const stack: SyntaxNode[] = [root];
while (stack.length > 0) {
const n = stack.pop()!;
if (n.type === type && n.text.startsWith(prefix)) return n;
for (let i = n.namedChildCount - 1; i >= 0; i--) {
const c = n.namedChild(i);
if (c) stack.push(c);
}
}
throw new Error(`no ${type} node starting with ${JSON.stringify(prefix)}`);
}
describeZig('zigExportChecker', () => {
const src = `
pub const Point = struct {
x: i32,
pub fn public(self: Point) i32 { return self.x; }
fn private(self: Point) i32 { return self.x; }
};
const Hidden = struct {
pub fn shown() void {}
};
`;
it('reads a method’s own `pub`, not the enclosing container’s', () => {
// Before the fix the walk continued from a non-`pub` fn up to
// `pub const Point`, so every private method of a public container was
// reported exported.
const root = parse(src).rootNode;
expect(zigExportChecker(find(root, 'function_declaration', 'pub fn public'), 'public')).toBe(
true,
);
expect(zigExportChecker(find(root, 'function_declaration', 'fn private'), 'private')).toBe(
false,
);
});
it('a `pub fn` inside a private container is still marked pub on its own terms', () => {
const root = parse(src).rootNode;
expect(zigExportChecker(find(root, 'function_declaration', 'pub fn shown'), 'shown')).toBe(
true,
);
});
it('container fields inherit the wrapper’s visibility', () => {
const root = parse(src).rootNode;
expect(zigExportChecker(find(root, 'container_field', 'x'), 'x')).toBe(true);
});
});
describeZig('Zig MethodExtractor — receiver vs parameters', () => {
const extractor = createMethodExtractor(zigMethodConfig);
const ctx = { filePath: 'test.zig', language: SupportedLanguages.Zig };
it('excludes the leading `self` receiver from `parameters` (Rust parity)', () => {
const root = parse(`
const Counter = struct {
n: u32,
pub fn add(self: *Counter, by: u32) void { self.n += by; }
pub fn make(n: u32) Counter { return .{ .n = n }; }
};
`).rootNode;
const result = extractor.extract(find(root, 'struct_declaration'), ctx);
expect(result).not.toBeNull();
const byName = new Map(result!.methods.map((m) => [m.name, m]));
const add = byName.get('add')!;
expect(add.receiverType).toBe('*Counter');
expect(add.parameters.map((p) => p.name)).toEqual(['by']);
expect(add.isStatic).toBe(false);
// No receiver: every parameter is regular, even when one is not first.
const make = byName.get('make')!;
expect(make.receiverType).toBeNull();
expect(make.parameters.map((p) => p.name)).toEqual(['n']);
expect(make.isStatic).toBe(true);
});
it('only a FIRST parameter named self is the receiver', () => {
const root = parse(`
const S = struct {
fn f(other: u32, self: u32) void { _ = other; _ = self; }
};
`).rootNode;
const result = extractor.extract(find(root, 'struct_declaration'), ctx);
expect(result!.methods[0].parameters.map((p) => p.name)).toEqual(['other', 'self']);
expect(result!.methods[0].receiverType).toBeNull();
});
});
describeZig('Zig VariableExtractor — container and import bindings are not variables', () => {
const extractor = createVariableExtractor(zigVariableConfig);
const ctx = { filePath: 'test.zig', language: SupportedLanguages.Zig };
it('skips `const T = struct/enum/union {…}` and `const x = @import(…)`', () => {
// These nodes are already emitted as Struct/Enum/Union nodes and IMPORTS
// edges; a Variable record beside them was a duplicate.
const root = parse(`
const std = @import("std");
pub const Point = struct { x: i32 };
const Color = enum { red, green };
const Tag = union(enum) { a: u8, b: u16 };
const limit: u32 = 10;
var count = @as(u32, 0);
`).rootNode;
const names: string[] = [];
for (let i = 0; i < root.namedChildCount; i++) {
const decl = root.namedChild(i)!;
expect(extractor.isVariableDeclaration(decl)).toBe(true); // node-type hint stays broad
const info = extractor.extract(decl, ctx);
if (info) names.push(info.name);
}
expect(names).toEqual(['limit', 'count']);
});
});

View file

@ -52,6 +52,23 @@ describe('resolveZigImportInternal', () => {
);
});
it('resolves a `.path = "."` dep against the repo root without a leading slash', () => {
// `normalizeDepPath('.')` is '' — the candidates used to become
// `/src/<name>.zig`, which can never match a repo-relative file key.
const files = new Set<string>(['src/main.zig', 'src/mylib.zig', 'examples/demo.zig']);
for (const dot of ['.', './']) {
const buildZon = { pathDeps: new Map([['mylib', dot]]) };
expect(resolveZigImportInternal('examples/demo.zig', 'mylib', files, buildZon)).toBe(
'src/mylib.zig',
);
}
// …and the `src/main.zig` fallback for a root dep too.
const buildZon = { pathDeps: new Map([['root_pkg', '.']]) };
expect(resolveZigImportInternal('examples/demo.zig', 'root_pkg', files, buildZon)).toBe(
'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']]) };
@ -100,6 +117,54 @@ describe('parseZigBuildZon', () => {
expect(cfg!.pathDeps.has('ziggit_pkg')).toBe(false);
});
it('ignores commented-out entries and `.path` lines', () => {
// A `// .path = "vendor/foo"` inside an entry used to be captured as a real
// dep because the regex ran over raw source. `//` inside a `.url` string
// must survive the strip — it is not a comment.
const raw = `
.{
.dependencies = .{
// .disabled = .{ .path = "vendor/disabled" },
.remote = .{
.url = "https://github.com/x/y/archive/abc.tar.gz",
// .path = "vendor/remote-override",
.hash = "1220abc",
},
.live = .{ .path = "vendor/live" }, // trailing note: .path = "nope"
},
}
`;
const cfg = parseZigBuildZon(raw);
expect(cfg).not.toBeNull();
expect([...cfg!.pathDeps.entries()]).toEqual([['live', 'vendor/live']]);
});
it('is not derailed by braces inside comments or string literals', () => {
// Every `{`/`}` used to count toward the block depth, so a `// }` comment or
// a `}` inside a string closed the `.dependencies` block early and dropped
// every dep after it.
const raw = `
.{
.dependencies = .{
.first = .{
.url = "https://example.com/weird}name{.tar.gz",
.hash = "1220x", // } stray brace in a comment
},
// } another one
.second = .{ .path = "vendor/second" },
.third = .{ .path = "vendor/third" },
},
.paths = .{ "" },
}
`;
const cfg = parseZigBuildZon(raw);
expect(cfg).not.toBeNull();
expect([...cfg!.pathDeps.entries()]).toEqual([
['second', 'vendor/second'],
['third', 'vendor/third'],
]);
});
it('returns null when no `.dependencies` block is present', () => {
const raw = `.{ .name = "x", .version = "0.0.0", .paths = .{""} }`;
expect(parseZigBuildZon(raw)).toBeNull();