feat(zig): export fn visibility, opaque containers, named test blocks, member ownership

Ports the parts of upstream PR #305 (closed, unmerged) that our Zig
provider lacked, plus two gaps found while porting.

- `export fn` / `export var` (C-ABI linkage, never `pub`) are exported;
  the pub/export predicate is now shared by the export checker and the
  method/variable extractors' visibility (`hasZigVisibilityKeyword`).
- `const H = opaque { … }` is a Struct-labelled container (it may own
  methods, never fields) in both the structure queries and the scope
  query; ZIG_CONTAINER_TYPES is the single source for the extractor
  configs.
- `test "name" { … }` blocks are Function nodes named by the string
  node WITH quotes, so `test "add"` beside `fn add` cannot merge onto
  Function:<file>:add; `test_declaration` joins FUNCTION_NODE_TYPES and
  the Zig method config names it in the enclosing-function walk, so
  calls inside a test attribute to the test. Anonymous `test {}` and
  decl-tests `test add {}` are scopes without a node (an empty-name hook
  result stops the walk instead of falling through to the identifier of
  the function under test).
- Empty container bodies (`struct {}`, `opaque {}`) no longer mint a
  nameless Property: tree-sitter-zig 1.1.2 recovers them as a
  container_field with a MISSING identifier; #not-eq? guards in both
  queries and the field extractor drop it.
- Owner walk (`findEnclosingClassInfo`): an anonymous container bound
  by the enclosing `variable_declaration` takes the binding identifier,
  same shape as the Go `type_spec` branch. Before this NO Zig member
  had an owner — zero HAS_METHOD / HAS_PROPERTY edges for Zig.

Not ported from #305, deliberately: `builtInNames` (a bare-name call-site
drop filter; `alloc`/`free`/`append`/`print` are the most common user
method names in Zig and `std.*` receivers are already external via the
import binding), `usingnamespace` (removed in Zig 0.15), `@cImport`,
`build.zig` ignore, and the web-app changes.
This commit is contained in:
Navid EMAD 2026-08-18 11:18:50 +02:00
parent 889da858d1
commit d02f09c53a
No known key found for this signature in database
15 changed files with 420 additions and 48 deletions

View file

@ -1,9 +1,10 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { ClassExtractionConfig, ClassLikeNodeLabel } from '../../class-types.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { ZIG_CONTAINER_TYPES } from '../../languages/zig/captures.js';
/**
* Zig containers (struct/enum/union) are anonymous in the grammar:
* Zig containers (struct/enum/union/opaque) are anonymous in the grammar:
*
* const Point = struct { ... };
*
@ -24,12 +25,15 @@ const extractZigContainerType = (node: SyntaxNode): ClassLikeNodeLabel | undefin
if (node.type === 'struct_declaration') return 'Struct';
if (node.type === 'enum_declaration') return 'Enum';
if (node.type === 'union_declaration') return 'Union';
// `opaque {}` is a fieldless container that may own methods — Struct is the
// closest class-like label (rationale in ZIG_QUERIES).
if (node.type === 'opaque_declaration') return 'Struct';
return undefined;
};
export const zigClassConfig: ClassExtractionConfig = {
language: SupportedLanguages.Zig,
typeDeclarationNodes: ['struct_declaration', 'enum_declaration', 'union_declaration'],
typeDeclarationNodes: [...ZIG_CONTAINER_TYPES],
extractName: extractZigContainerName,
extractType: extractZigContainerType,
};

View file

@ -243,14 +243,17 @@ export const rubyExportChecker: ExportChecker = (_node, _name) => true;
/** Dart: public if no leading underscore (convention, same as Python). */
export const dartExportChecker: ExportChecker = (_node, name) => !name.startsWith('_');
/** Zig declaration node types whose `pub` keyword child marks the symbol public. */
/** Zig declaration node types whose `pub` / `export` keyword child marks the symbol public. */
const ZIG_DECL_TYPES = new Set(['function_declaration', 'variable_declaration']);
/**
* Zig: walk to the enclosing decl, scan its direct children for an unnamed `pub`
* 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.
* or `export` keyword token (tree-sitter-zig models both as anonymous keyword
* children of function_declaration / variable_declaration). `pub` is Zig-module
* visibility; `export` is C-ABI linkage (`export fn add(...)`) — the strongest
* form of "exported", used for FFI entry points, and it never carries `pub`.
* 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
@ -260,14 +263,23 @@ const ZIG_DECL_TYPES = new Set(['function_declaration', 'variable_declaration'])
export const zigExportChecker: ExportChecker = (node, _name) => {
let current: SyntaxNode | null = node;
while (current) {
if (ZIG_DECL_TYPES.has(current.type)) {
for (let i = 0; i < current.childCount; i++) {
const child = current.child(i);
if (child?.type === 'pub') return true;
}
return false;
}
if (ZIG_DECL_TYPES.has(current.type)) return hasZigVisibilityKeyword(current);
current = current.parent;
}
return false;
};
/**
* Does this Zig declaration carry a `pub` or `export` keyword child? Shared by
* the export checker and the method/variable extractors' `extractVisibility`,
* so the three cannot disagree on what "public" means (a `pub`-only copy in
* one of them left `export fn` private in the graph while `isExported` said
* otherwise).
*/
export function hasZigVisibilityKeyword(declNode: SyntaxNode): boolean {
for (let i = 0; i < declNode.childCount; i++) {
const child = declNode.child(i);
if (child?.type === 'pub' || child?.type === 'export') return true;
}
return false;
}

View file

@ -1,9 +1,10 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { FieldExtractionConfig } from '../generic.js';
import { ZIG_CONTAINER_TYPES } from '../../languages/zig/captures.js';
/**
* Zig containers (struct/enum/union) are anonymous in tree-sitter-zig; the
* Zig containers (struct/enum/union/opaque) are anonymous in tree-sitter-zig; the
* binding name is the first identifier child of the parent variable_declaration.
*/
const extractZigOwnerName = (node: SyntaxNode): string | undefined => {
@ -24,7 +25,7 @@ const extractZigOwnerName = (node: SyntaxNode): string | undefined => {
*/
export const zigFieldConfig: FieldExtractionConfig = {
language: SupportedLanguages.Zig,
typeDeclarationNodes: ['struct_declaration', 'enum_declaration', 'union_declaration'],
typeDeclarationNodes: [...ZIG_CONTAINER_TYPES],
fieldNodeTypes: ['container_field'],
bodyNodeTypes: [],
defaultVisibility: 'public',
@ -32,7 +33,11 @@ export const zigFieldConfig: FieldExtractionConfig = {
extractName(node) {
const name = node.childForFieldName('name');
return name?.text;
// An empty container body (`struct {}`, `opaque {}`) is recovered by
// tree-sitter-zig 1.1.2 as one container_field with a zero-width MISSING
// identifier. Not a field — declining here keeps it out of the field map.
if (name === null || name.text.length === 0) return undefined;
return name.text;
},
extractType(node) {

View file

@ -6,7 +6,7 @@
* and no heritage hooks are provided (Zig queries never produce
* `@heritage.*` captures).
* - exportChecker: walks to the enclosing variable_declaration /
* function_declaration and looks for a `pub` keyword child.
* function_declaration and looks for a `pub` or `export` keyword child.
* - importResolver: only resolves local `@import("./foo.zig")` paths;
* `@import("std")` and external packages are deliberately external.
* - namedBindingExtractor: omitted — `const Foo = @import("x").Foo` is a

View file

@ -5,10 +5,14 @@ import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
/** Zig container node types: `struct`, `enum`, `union` and the fieldless
* `opaque` all bind through `const T = <container> {…}` and may own methods.
* Single source for the class/field/method extractor configs. */
export const ZIG_CONTAINER_TYPES: ReadonlySet<string> = new Set([
'struct_declaration',
'enum_declaration',
'union_declaration',
'opaque_declaration',
]);
/** Is this variable_declaration a container binding (`const T = struct {…}`)
@ -30,14 +34,21 @@ export function isZigContainerOrImportBinding(declNode: SyntaxNode): boolean {
return false;
}
/** A `fn` nested in a struct/enum/union container is a method. Single
/** A `fn` nested in a struct/enum/union/opaque container is a method. Single
* predicate shared between the provider's `labelOverride` (worker
* structure phase) and the scope-capture relabel below, so the graph
* node label and the scope-side def label cannot drift apart. The loose
* parameter shape matches what `labelOverride` receives. */
* parameter shape matches what `labelOverride` receives.
*
* Only a `function_declaration` can be a method: a named `test "…" {}`
* inside a container is also a Function definition, but the method
* extractor does not know test blocks (no parameters, no `self`), so
* relabelling it would mint a Method id the definition phase never
* builds. Tests stay Functions wherever they sit. */
export function isZigContainerMethod(
captureNode: { readonly parent?: SyntaxNode | null } | null | undefined,
captureNode: { readonly type?: string; readonly parent?: SyntaxNode | null } | null | undefined,
): boolean {
if (captureNode?.type !== undefined && captureNode.type !== 'function_declaration') return false;
let ancestor = captureNode?.parent;
while (ancestor) {
if (ZIG_CONTAINER_TYPES.has(ancestor.type)) return true;

View file

@ -13,7 +13,7 @@ const _require = createRequire(import.meta.url);
* `parser-loader.isLanguageAvailable` before any scope extraction runs.
*
* Zig specifics encoded here:
* - Containers (struct/enum/union) are anonymous nodes bound by the
* - Containers (struct/enum/union/opaque) are anonymous nodes bound by the
* enclosing `variable_declaration`; declarations capture the binding
* identifier from the wrapper.
* - `@import` is a builtin call, not import-statement syntax; the
@ -28,7 +28,9 @@ const ZIG_SCOPE_QUERY = `
(struct_declaration) @scope.class
(enum_declaration) @scope.class
(union_declaration) @scope.class
(opaque_declaration) @scope.class
(function_declaration) @scope.function
(test_declaration) @scope.function
(block) @scope.block
;; Declarations — functions (relabeled @declaration.method inside containers
@ -36,6 +38,12 @@ const ZIG_SCOPE_QUERY = `
(function_declaration
name: (identifier) @declaration.name) @declaration.function
;; Declarations — named tests. Same naming rule as ZIG_QUERIES: the string
;; node WITH quotes, so the def joins the graph node and never collides with
;; a same-named fn. Anonymous / decl-form tests are scopes without a def.
(test_declaration
(string) @declaration.name) @declaration.function
;; Declarations — containers. The binding name lives on the wrapper
;; variable_declaration, but the ANCHOR is the container node itself so its
;; range equals the @scope.class range: the extractor then attaches the def
@ -51,10 +59,18 @@ const ZIG_SCOPE_QUERY = `
(variable_declaration
(identifier) @declaration.name
(union_declaration) @declaration.union)
;; opaque {} is a fieldless container that may own methods — Struct, as in
;; ZIG_QUERIES (see the rationale there).
(variable_declaration
(identifier) @declaration.name
(opaque_declaration) @declaration.struct)
;; Declarations — container fields (struct fields, enum/union variants)
(container_field
;; Declarations — container fields (struct fields, enum/union variants).
;; The #not-eq? guard drops the MISSING placeholder identifier tree-sitter-zig
;; recovers for an empty container body (see ZIG_QUERIES).
((container_field
name: (identifier) @declaration.name) @declaration.field
(#not-eq? @declaration.name ""))
;; Declarations — const/var bindings (import/container groups filtered in TS).
;; The \`.\` anchor pins the FIRST named child: without it the pattern also

View file

@ -1,11 +1,13 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { MethodExtractionConfig, ParameterInfo } from '../../method-types.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { hasZigVisibilityKeyword } from '../../export-detection.js';
import { ZIG_CONTAINER_TYPES } from '../../languages/zig/captures.js';
/**
* Zig method extraction.
*
* tree-sitter-zig containers (struct/enum/union) are anonymous; the binding
* tree-sitter-zig containers (struct/enum/union/opaque) are anonymous; the binding
* name lives on the parent variable_declaration. Methods inside a container
* appear as plain `function_declaration` children of the container node.
*
@ -75,14 +77,6 @@ const extractZigParameters = (node: SyntaxNode): ParameterInfo[] => {
return params;
};
const hasPubKeyword = (node: SyntaxNode): boolean => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'pub') return true;
}
return false;
};
const extractZigReceiverType = (node: SyntaxNode): string | undefined => {
const paramList = zigParameterList(node);
if (!paramList) return undefined;
@ -94,16 +88,39 @@ const extractZigReceiverType = (node: SyntaxNode): string | undefined => {
return typeNode?.text?.trim();
};
/**
* Names a `test_declaration` during the enclosing-function walk (parse-worker
* `findEnclosingFunctionId`) with the SAME spelling ZIG_QUERIES captures as
* `@name` — the string node with its quotes — so calls inside `test "x" {}`
* attribute to the test's own Function node.
*
* Anonymous `test {}` and decl-tests `test add {}` are not graph nodes. They
* return `''`, not `null`: `null` falls through to `genericFuncName`, whose
* first-identifier scan would name `test add {}` "add" — the REAL `fn add`'s
* id — and hang the test body's calls on it. The empty name ends the walk at
* this node and lets the caller fall back to the File.
*/
const extractZigFunctionName = (
node: SyntaxNode,
): { funcName: string | null; label: 'Function' } | null => {
if (node.type !== 'test_declaration') return null;
const nameString = node.namedChildren.find(
(child): child is SyntaxNode => child?.type === 'string',
);
return { funcName: nameString?.text ?? '', label: 'Function' };
};
export const zigMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Zig,
typeDeclarationNodes: ['struct_declaration', 'enum_declaration', 'union_declaration'],
typeDeclarationNodes: [...ZIG_CONTAINER_TYPES],
methodNodeTypes: ['function_declaration'],
bodyNodeTypes: [],
extractOwnerName: extractZigOwnerName,
extractName: extractZigName,
extractFunctionName: extractZigFunctionName,
extractReturnType: extractZigReturnType,
extractParameters: extractZigParameters,
extractVisibility: (node) => (hasPubKeyword(node) ? 'public' : 'private'),
extractVisibility: (node) => (hasZigVisibilityKeyword(node) ? 'public' : 'private'),
extractReceiverType: extractZigReceiverType,
isStatic(node) {

View file

@ -2472,9 +2472,31 @@ export const ZIG_QUERIES = `
(identifier) @name
(union_declaration)) @definition.union
; Opaque: const Handle = opaque { ... } — the FFI handle type. It is a
; container (it may declare methods, never fields), so it is labelled Struct:
; the owner of a HAS_METHOD edge must be class-like, and there is no closer
; label. It is NOT a TypeAlias — an opaque type is a distinct nominal type,
; deliberately incompatible with whatever it wraps.
(variable_declaration
(identifier) @name
(opaque_declaration)) @definition.struct
; Container fields (struct fields, enum variants, union variants).
(container_field
; #not-eq? guard: tree-sitter-zig 1.1.2 recovers an EMPTY container body
; (\`struct {}\`, \`opaque {}\`) as a container_field whose identifier is a
; zero-width MISSING placeholder — a parser artefact, not a field, and
; without the guard it minted a Property with an empty name.
((container_field
name: (identifier) @name) @definition.property
(#not-eq? @name ""))
; Named tests: test "description" { ... }. The name is the string node WITH
; its quotes, so \`test "add"\` next to \`fn add\` (the idiomatic layout) does
; not collide on Function:<file>:add. Anonymous \`test {}\` and decl-tests
; \`test add {}\` have no name of their own and are not graph nodes; their
; bodies' calls attribute to the File.
(test_declaration
(string) @name) @definition.function
; @import("path") — capture the string argument as @import.source.
; The #eq? predicate restricts the match to the @import builtin (other

View file

@ -321,6 +321,9 @@ export const FUNCTION_NODE_TYPES = new Set([
// Dart
'function_signature',
'method_signature',
// Zig: `test "…" { }` bodies are callable scopes — calls inside attribute
// to the test, not the file. Named via methodExtractor.extractFunctionName.
'test_declaration',
]);
/**
@ -372,6 +375,7 @@ export const CLASS_CONTAINER_TYPES = new Set([
'interface_type',
// Zig
'union_declaration',
'opaque_declaration',
]);
/**
@ -440,10 +444,12 @@ export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
companion_object: 'Class',
struct_type: 'Struct',
interface_type: 'Interface',
// Zig: tagged and untagged unions are class-like containers.
// `struct_declaration` and `enum_declaration` are already present
// (Dart / generic).
// Zig: tagged and untagged unions are class-like containers, and so is
// the fieldless `opaque {}` (may own methods; labelled Struct, see
// ZIG_QUERIES). `struct_declaration` and `enum_declaration` are already
// present (Dart / generic).
union_declaration: 'Union',
opaque_declaration: 'Struct',
};
/**
@ -1051,7 +1057,19 @@ export const findEnclosingClassInfo = (
c.type === 'identifier' ||
c.type === 'name' ||
c.type === 'constant',
);
) ??
// An ANONYMOUS container bound by the enclosing declaration —
// `const Point = struct { … }` (tree-sitter-zig: struct/enum/union/
// opaque nodes carry no name; the binding identifier is the first
// named child of the parent `variable_declaration`). Same shape as the
// Go `type_spec` branch above: the name lives one level up. Without it
// the walk climbed past every Zig container and no member ever got a
// HAS_METHOD / HAS_PROPERTY owner. The definition phase names the
// container node from the same binding (`@name` on the wrapper), so
// the owner id and the node id agree by construction.
(current.parent?.type === 'variable_declaration'
? current.parent.namedChildren?.find((c: SyntaxNode) => c.type === 'identifier')
: undefined);
if (nameNode) {
let label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class';
// Kotlin: class_declaration with an anonymous "interface" keyword child

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 { hasZigVisibilityKeyword } from '../../export-detection.js';
import { isZigContainerOrImportBinding } from '../../languages/zig/captures.js';
/**
@ -18,14 +19,6 @@ import { isZigContainerOrImportBinding } from '../../languages/zig/captures.js';
* broad `expression_statement` config for the same pattern).
*/
const hasPubKeyword = (node: SyntaxNode): boolean => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'pub') return true;
}
return false;
};
const isVarKeyword = (node: SyntaxNode): boolean => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
@ -62,7 +55,7 @@ export const zigVariableConfig: VariableExtractionConfig = {
},
extractVisibility(node): VariableVisibility {
return hasPubKeyword(node) ? 'public' : 'private';
return hasZigVisibilityKeyword(node) ? 'public' : 'private';
},
isConst(node) {

View file

@ -0,0 +1,36 @@
const std = @import("std");
const main_mod = @import("./main.zig");
// C-ABI export: no `pub`, still the most externally visible symbol in the file.
export fn c_add(a: i32, b: i32) i32 {
return a + b;
}
// Fieldless container: FFI handle type. May own methods, never fields.
pub const Handle = opaque {
pub fn close(self: *Handle) void {
_ = self;
}
};
// Empty container body — tree-sitter-zig recovers it with a MISSING
// placeholder field; it must not become a nameless Property.
pub const Empty = struct {};
pub fn release(h: *Handle) void {
h.close();
}
test "c_add adds" {
_ = c_add(1, 2);
}
// The idiomatic layout: a test named after the function it exercises.
test "release" {
var h: *Handle = undefined;
release(h);
}
test {
_ = c_add(3, 4);
}

View file

@ -28,3 +28,22 @@ pub fn main() void {
_ = p.distance(Point{ .x = 3, .y = 4 });
_ = add(1, 2);
}
// C-ABI export (no `pub`), an opaque FFI handle, and test blocks.
export fn c_add(a: i32, b: i32) i32 {
return a + b;
}
pub const Handle = opaque {
pub fn close(self: *Handle) void {
_ = self;
}
};
test "add works" {
_ = add(1, 2);
}
test {
_ = c_add(1, 2);
}

View file

@ -7,6 +7,7 @@ import {
edgeSet,
FIXTURES,
getNodesByLabel,
getNodesByLabelFull,
getRelationships,
runPipelineFromRepo,
type PipelineResult,
@ -104,3 +105,71 @@ describe.skipIf(!zigAvailable)('Zig scope captures — variable bindings', () =>
expect(variableNames).toEqual(['h']);
});
});
describe.skipIf(!zigAvailable)('Zig export, opaque and test declarations (ffi.zig)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-basic'), () => {});
}, 60000);
it('marks `export fn` (C-ABI, no `pub`) as exported', () => {
// `export` is the strongest visibility Zig has — FFI entry points are
// declared this way and never carry `pub`. A `pub`-only checker left
// every C-ABI symbol private in the graph.
const cAdd = getNodesByLabelFull(result, 'Function').find((n) => n.name === 'c_add');
expect(cAdd).toBeDefined();
expect(cAdd!.properties.isExported).toBe(true);
});
it('models `opaque {}` as a Struct that owns its methods', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('Handle');
expect(getNodesByLabel(result, 'Method')).toContain('close');
expect(edgeSet(getRelationships(result, 'HAS_METHOD'))).toContain('Handle → close');
});
it('owns container members through the binding name (HAS_METHOD / HAS_PROPERTY)', () => {
// tree-sitter-zig containers are anonymous; the owner walk used to climb
// past them and NO Zig member ever got an owner edge, so `context(Pioneer)`
// listed no methods and the struct's fields dangled off the File.
expect(edgeSet(getRelationships(result, 'HAS_METHOD'))).toEqual(
expect.arrayContaining(['Pioneer → tick', 'Pioneer → reset', 'Tag → isEnergy']),
);
expect(edgeSet(getRelationships(result, 'HAS_PROPERTY'))).toEqual(
expect.arrayContaining(['Pioneer → energy', 'State → idle', 'Tag → energy']),
);
});
it('dispatches a method call on an opaque receiver (release → close)', () => {
expect(edgeSet(getRelationships(result, 'CALLS'))).toContain('release → close');
});
it('never mints a nameless Property for an empty container body', () => {
// tree-sitter-zig 1.1.2 recovers `struct {}` / `opaque {}` as one
// container_field with a zero-width MISSING identifier.
expect(getNodesByLabel(result, 'Struct')).toContain('Empty');
expect(getNodesByLabel(result, 'Property')).not.toContain('');
});
it('captures named tests as Functions, quoted, so `test "release"` and `fn release` stay distinct nodes', () => {
const fns = getNodesByLabel(result, 'Function');
expect(fns).toContain('"c_add adds"');
expect(fns).toContain('"release"');
// Both must exist as separate nodes — an unquoted test name would have
// merged onto Function:<file>:release and fabricated a self-call.
expect(fns.filter((n) => n === 'release')).toHaveLength(1);
expect(fns.filter((n) => n === '"release"')).toHaveLength(1);
});
it('attributes calls inside a named test to the test node, not the file', () => {
const calls = edgeSet(getRelationships(result, 'CALLS'));
expect(calls).toContain('"c_add adds" → c_add');
expect(calls).toContain('"release" → release');
expect(calls).not.toContain('release → release');
});
it('does not create a graph node for an anonymous `test {}`', () => {
const fns = getNodesByLabel(result, 'Function');
expect(fns.some((n) => n.startsWith('test@') || n === 'test')).toBe(false);
});
});

View file

@ -713,6 +713,17 @@ describe('Tree-sitter multi-language parsing', () => {
expect(defTypes).toContain('definition.struct');
expect(defTypes).toContain('definition.enum');
// `opaque {}` is a Struct-labelled container; a named `test "…"` block
// is a Function whose @name is the string node WITH quotes (so it never
// collides with a same-named fn); an anonymous `test {}` is not a def.
const named = defs.map((d) => `${d.type}:${d.name}`);
expect(named).toContain('definition.struct:Handle');
expect(named).toContain('definition.function:"add works"');
expect(named.filter((n) => n.startsWith('definition.function:')).length).toBe(
['add', 'private_helper', 'init', 'distance', 'main', 'c_add', 'close', '"add works"']
.length,
);
// `const std = @import("std");` must yield an import.source capture —
// without this assertion a query change that drops Zig import matching
// would pass this test unchanged.

View file

@ -219,3 +219,142 @@ describeZig('Zig scope captures — receiver is the FIRST parameter named self',
]);
});
});
describeZig('Zig `export` (C-ABI) visibility', () => {
const src = `
export fn c_add(a: i32, b: i32) i32 { return a + b; }
fn hidden() void {}
export const table: [4]u8 = .{ 0, 0, 0, 0 };
`;
it('zigExportChecker treats `export fn` as exported without `pub`', () => {
// `export` is C-ABI linkage — the FFI entry point form — and never
// carries `pub`. A `pub`-only check reported every C-ABI symbol private.
const root = parse(src).rootNode;
expect(zigExportChecker(find(root, 'function_declaration', 'export fn c_add'), 'c_add')).toBe(
true,
);
expect(zigExportChecker(find(root, 'function_declaration', 'fn hidden'), 'hidden')).toBe(false);
expect(zigExportChecker(find(root, 'variable_declaration', 'export const'), 'table')).toBe(
true,
);
});
it('the method and variable extractors report `export` as public (one shared predicate)', () => {
// The three visibility readers share `hasZigVisibilityKeyword`; a private
// copy in one of them made `isExported` and `visibility` disagree.
const root = parse(`
const C = struct {
export fn cb(self: *C) void { _ = self; }
};
export var counter: u32 = 0;
`).rootNode;
const methods = createMethodExtractor(zigMethodConfig).extract(
find(root, 'struct_declaration'),
{ filePath: 'test.zig', language: SupportedLanguages.Zig },
);
expect(methods!.methods.find((m) => m.name === 'cb')!.visibility).toBe('public');
const variable = createVariableExtractor(zigVariableConfig).extract(
find(root, 'variable_declaration', 'export var'),
{ filePath: 'test.zig', language: SupportedLanguages.Zig },
);
expect(variable!.visibility).toBe('public');
});
});
describeZig('Zig test declarations', () => {
const extractor = createMethodExtractor(zigMethodConfig);
const src = `
fn add(a: i32, b: i32) i32 { return a + b; }
test "add works" { _ = add(1, 2); }
test { _ = add(3, 4); }
test add { _ = add(5, 6); }
`;
it('names a `test "…"` block by its string node, quotes included, in the enclosing-function walk', () => {
// Must be byte-equal to the `@name` capture in ZIG_QUERIES (the string
// node) so calls inside attribute to the test's own node — and the quotes
// are what keep `test "add"` and `fn add` from sharing Function:<file>:add.
const root = parse(src).rootNode;
const named = extractor.extractFunctionName!(
find(root, 'test_declaration', 'test "add works"'),
);
expect(named).toEqual({ funcName: '"add works"', label: 'Function' });
});
it('returns an EMPTY name — never null — for anonymous and decl-form tests', () => {
// `null` would fall through to `genericFuncName`, whose first-identifier
// scan names `test add {}` "add": the REAL `fn add`'s id, so the test
// body's calls would hang on the function under test. `''` stops the walk
// here and lets the caller fall back to the File.
const root = parse(src).rootNode;
expect(extractor.extractFunctionName!(find(root, 'test_declaration', 'test {'))).toEqual({
funcName: '',
label: 'Function',
});
expect(extractor.extractFunctionName!(find(root, 'test_declaration', 'test add'))).toEqual({
funcName: '',
label: 'Function',
});
});
it('declines (null) for anything that is not a test_declaration', () => {
const root = parse(src).rootNode;
expect(extractor.extractFunctionName!(find(root, 'function_declaration'))).toBeNull();
});
it('scope captures: a named test is a Function scope with a matching def; anonymous tests are scopes only', () => {
const matches = emitZigScopeCaptures(src, 'test.zig');
const fnScopes = matches.filter((m) => m['@scope.function'] !== undefined);
// fn add + 3 test blocks
expect(fnScopes).toHaveLength(4);
const fnDefs = matches
.filter((m) => m['@declaration.function'] !== undefined)
.map((m) => m['@declaration.name']!.text);
expect(fnDefs).toEqual(['add', '"add works"']);
});
it('a test inside a container stays a Function — the method extractor cannot describe it', () => {
const src2 = `
const S = struct {
fn m(self: S) void { _ = self; }
test "S works" { _ = S{}; }
};
`;
const labels = emitZigScopeCaptures(src2, 'test.zig')
.filter(
(m) => m['@declaration.function'] !== undefined || m['@declaration.method'] !== undefined,
)
.map((m) => (m['@declaration.method'] !== undefined ? 'method' : 'function'));
expect(labels).toEqual(['method', 'function']);
});
});
describeZig('Zig opaque and empty containers', () => {
it('captures `const H = opaque { … }` as a Struct-labelled class scope owning its methods', () => {
const src = `
pub const H = opaque {
pub fn close(self: *H) void { _ = self; }
};
`;
const matches = emitZigScopeCaptures(src, 'test.zig');
const struct = matches.find((m) => m['@declaration.struct'] !== undefined);
expect(struct?.['@declaration.name']?.text).toBe('H');
expect(matches.some((m) => m['@declaration.method'] !== undefined)).toBe(true);
// No stray plain-variable binding for the container wrapper.
expect(
matches.some(
(m) => m['@declaration.variable'] !== undefined && m['@declaration.name']?.text === 'H',
),
).toBe(false);
});
it('does not emit a nameless field for an empty container body', () => {
// tree-sitter-zig 1.1.2 recovers `struct {}` / `opaque {}` as a
// container_field whose identifier is a zero-width MISSING node.
const fields = emitZigScopeCaptures('const E = struct {};\nconst O = opaque {};\n', 'test.zig')
.filter((m) => m['@declaration.field'] !== undefined)
.map((m) => m['@declaration.name']!.text);
expect(fields).toEqual([]);
});
});