GitNexus/gitnexus/test/unit/zig-extractors.test.ts
Navid EMAD bc39f1b2d0
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`).
2026-08-24 19:56:13 +02:00

153 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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']);
});
});