diff --git a/gitnexus/src/core/ingestion/export-detection.ts b/gitnexus/src/core/ingestion/export-detection.ts index 3ff99b5ca..82e0f8090 100644 --- a/gitnexus/src/core/ingestion/export-detection.ts +++ b/gitnexus/src/core/ingestion/export-detection.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/import-resolvers/zig.ts b/gitnexus/src/core/ingestion/import-resolvers/zig.ts index 9c1200be3..e9c19efa5 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/zig.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/zig.ts @@ -67,8 +67,11 @@ export function resolveZigImportInternal( 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`]; + // package's primary module name) or /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; diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 18edda8e6..2f53722fb 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -501,8 +501,10 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise/ 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. + * - `//` 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 { try { @@ -514,36 +516,84 @@ export async function loadZigBuildZon(repoRoot: string): Promise(); - // Match each `. = .{ ... }` entry; capture the entry body. - const entryRe = /\.([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\.\{([\s\S]*?)\}\s*,?/g; + // Walk each `. = .{ ... }` 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]); diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts index 35aa878e9..e19b5e54b 100644 --- a/gitnexus/src/core/ingestion/languages/zig/captures.ts +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -14,8 +14,10 @@ export const ZIG_CONTAINER_TYPES: ReadonlySet = 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; } diff --git a/gitnexus/src/core/ingestion/languages/zig/index.ts b/gitnexus/src/core/ingestion/languages/zig/index.ts index 97f23702b..ad8348d6c 100644 --- a/gitnexus/src/core/ingestion/languages/zig/index.ts +++ b/gitnexus/src/core/ingestion/languages/zig/index.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts index 8844c9e1a..25b43dcb1 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/zig.ts index aa13c0674..057286fce 100644 --- a/gitnexus/src/core/ingestion/variable-extractors/configs/zig.ts +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/zig.ts @@ -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; diff --git a/gitnexus/src/core/tree-sitter/parser-loader.ts b/gitnexus/src/core/tree-sitter/parser-loader.ts index d45aca7d4..1e53c6fb4 100644 --- a/gitnexus/src/core/tree-sitter/parser-loader.ts +++ b/gitnexus/src/core/tree-sitter/parser-loader.ts @@ -182,10 +182,13 @@ const SOURCES: Record = { // (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 ' + diff --git a/gitnexus/test/integration/tree-sitter-languages.test.ts b/gitnexus/test/integration/tree-sitter-languages.test.ts index 5aa651ddd..0c124847a 100644 --- a/gitnexus/test/integration/tree-sitter-languages.test.ts +++ b/gitnexus/test/integration/tree-sitter-languages.test.ts @@ -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(); } }); }); diff --git a/gitnexus/test/unit/parser-loader-skip-optional.test.ts b/gitnexus/test/unit/parser-loader-skip-optional.test.ts index ae8cff396..7a898f957 100644 --- a/gitnexus/test/unit/parser-loader-skip-optional.test.ts +++ b/gitnexus/test/unit/parser-loader-skip-optional.test.ts @@ -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 () => { diff --git a/gitnexus/test/unit/zig-extractors.test.ts b/gitnexus/test/unit/zig-extractors.test.ts new file mode 100644 index 000000000..d17a0b561 --- /dev/null +++ b/gitnexus/test/unit/zig-extractors.test.ts @@ -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']); + }); +}); diff --git a/gitnexus/test/unit/zig-import-resolver.test.ts b/gitnexus/test/unit/zig-import-resolver.test.ts index 66af4199b..4bbde9a9e 100644 --- a/gitnexus/test/unit/zig-import-resolver.test.ts +++ b/gitnexus/test/unit/zig-import-resolver.test.ts @@ -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/.zig`, which can never match a repo-relative file key. + const files = new Set(['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(['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();