feat(zig): Const/Variable defs, member imports, receiver typing, generic type constructors

Coverage gaps found by indexing idiomatic Zig against the branch:

- Const / Variable nodes: ZIG_QUERIES had no @definition.const /
  @definition.variable, so `pub const VERSION`, error sets and type
  aliases were absent and zigVariableConfig never ran. Rules are gated on
  the literal `const` / `var` keyword — tree-sitter-zig 1.1.2 parses
  statement assignments (`x = 5;`, `x += 1;`, `_ = expr;`) as keyword-
  less `variable_declaration`s, and the scope query minted a phantom
  local per assignment and one `_` per discard. Container and @import
  bindings are skipped via `shouldSkipDefinitionCapture`.
- Imports: `const X = @import("x.zig").X` (named / alias), `const X =
  ns.X` where `ns` is an @import binding of the file (promoted to a
  named import), and `pub usingnamespace @import(...)` (wildcard, with
  `expandsWildcardTo`). All three lost the file-level IMPORTS edge.
- Receiver typing: `var x: T = undefined` / decl literals `const x: T =
  .init()` (annotation), `var c = T.init()` / `mod.T.init()` (call
  return), `List(u8){}` (instantiation literal); `normalizeZigTypeName`
  drops the comptime argument list.
- Generic type constructors `fn List(comptime T: type) type { return
  struct {…}; }`: the returned container is a Struct/Union/Enum named
  after the fn, owns its members (HAS_METHOD / HAS_PROPERTY), binds in
  the module scope beside the Function def, and is emitted ahead of it
  so a named import binds the type.
- `export` vs `pub`: `visibility` is now `pub`-only (Zig-module fact);
  `isExported` keeps `pub|export` (visible outside the unit, as C's
  external linkage). `export fn` without `pub` is not reachable from
  other Zig files.
- Extractor configs share `zigContainerName`; ast-helpers' owner walk
  learns the type-constructor shape.

Tests: zig-idioms fixture (10 resolver cases), extractor/interpret unit
cases for each rule.
This commit is contained in:
Navid EMAD 2026-08-18 12:13:13 +02:00
parent 69831484ef
commit 65c07c3d9a
No known key found for this signature in database
16 changed files with 958 additions and 89 deletions

View file

@ -1,25 +1,20 @@
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';
import { ZIG_CONTAINER_TYPES, zigContainerName } from '../../languages/zig/captures.js';
/**
* Zig containers (struct/enum/union/opaque) are anonymous in the grammar:
*
* const Point = struct { ... };
* pub fn List(comptime T: type) type { return struct { ... }; }
*
* The binding name is the first identifier child of the parent
* variable_declaration. Walk up one level to find it.
* variable_declaration, or the generic type constructor's name —
* `zigContainerName` is the single source shared with the field/method
* extractors so owner ids and node ids agree by construction.
*/
const extractZigContainerName = (node: SyntaxNode): string | undefined => {
const parent = node.parent;
if (!parent || parent.type !== 'variable_declaration') return undefined;
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child?.type === 'identifier') return child.text;
}
return undefined;
};
const extractZigContainerName = (node: SyntaxNode): string | undefined => zigContainerName(node);
const extractZigContainerType = (node: SyntaxNode): ClassLikeNodeLabel | undefined => {
if (node.type === 'struct_declaration') return 'Struct';

View file

@ -249,9 +249,16 @@ const ZIG_DECL_TYPES = new Set(['function_declaration', 'variable_declaration'])
/**
* Zig: walk to the enclosing decl, scan its direct children for an unnamed `pub`
* 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`.
* children of function_declaration / variable_declaration). Two different
* facts share this one flag, on purpose:
* - `pub` is Zig-module visibility — reachable from another `.zig` file
* through `@import`;
* - `export` is C-ABI linkage (`export fn add(...)`) — the symbol lands in
* the object file for FFI callers, and it never carries `pub`.
* `isExported` means "visible outside this compilation unit" graph-wide (C uses
* external linkage for the same flag), so both qualify. The `visibility`
* property is the Zig-only fact and is `pub`-only — see `hasZigPubKeyword`:
* an `export fn` without `pub` is public to C and PRIVATE to other Zig files.
* Container fields (struct/enum variants) are public if their enclosing
* variable_declaration is public.
*
@ -270,11 +277,9 @@ export const zigExportChecker: ExportChecker = (node, _name) => {
};
/**
* 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).
* Does this Zig declaration carry a `pub` or `export` keyword child? Feeds
* `isExported` (visible outside the compilation unit — to Zig importers OR to
* C callers).
*/
export function hasZigVisibilityKeyword(declNode: SyntaxNode): boolean {
for (let i = 0; i < declNode.childCount; i++) {
@ -283,3 +288,18 @@ export function hasZigVisibilityKeyword(declNode: SyntaxNode): boolean {
}
return false;
}
/**
* Does this Zig declaration carry a `pub` keyword child? Feeds `visibility`,
* the Zig-module fact: per the language reference, only `pub` declarations are
* accessible from another file via `@import`; `export` alone gives C linkage
* and leaves the declaration private to Zig code. So `export fn c_add` reports
* `isExported: true` (FFI surface) with `visibility: 'private'` (Zig surface)
* — two facts, two properties, deliberately not one.
*/
export function hasZigPubKeyword(declNode: SyntaxNode): boolean {
for (let i = 0; i < declNode.childCount; i++) {
if (declNode.child(i)?.type === 'pub') return true;
}
return false;
}

View file

@ -1,21 +1,15 @@
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';
import { ZIG_CONTAINER_TYPES, zigContainerName } from '../../languages/zig/captures.js';
/**
* 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.
* 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, or the enclosing generic type constructor's name —
* `zigContainerName` is the single source.
*/
const extractZigOwnerName = (node: SyntaxNode): string | undefined => {
const parent = node.parent;
if (!parent || parent.type !== 'variable_declaration') return undefined;
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child?.type === 'identifier') return child.text;
}
return undefined;
};
const extractZigOwnerName = (node: SyntaxNode): string | undefined => zigContainerName(node);
/**
* Container fields appear as direct children of struct_declaration /

View file

@ -7,17 +7,25 @@
* `@heritage.*` captures).
* - exportChecker: walks to the enclosing variable_declaration /
* 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
* const declaration, not import-statement syntax.
* - importResolver: resolves local `@import("./foo.zig")` paths and
* build.zig.zon `.path` deps (root the dep's build.zig declares, then
* src/root.zig, src/<name>.zig, src/main.zig); `@import("std")` and
* `.url` packages are deliberately external.
* - namedBindingExtractor: omitted — the scope side handles
* `const Foo = @import("x").Foo` and `const Foo = ns.Foo` (ns an
* @import binding) as NAMED imports instead (`zig/captures.ts`).
* - scope-resolution hooks (Ring 3): `emitScopeCaptures` walks the file via
* `zig/query.ts` (containers as Class scopes, container-nested fns
* relabeled @declaration.method, plain-variable groups filtered for
* container/import bindings); `interpretImport` maps
* `const x = @import("…")` to a namespace import; receiver dispatch
* rides the `self`-parameter convention. The emit-side wiring lives in
* `zig/scope-resolver.ts` (SCOPE_RESOLVERS registry).
* `zig/query.ts` (containers as Class scopes — including the container a
* generic type constructor `fn List(comptime T: type) type` returns,
* named after the fn; container-nested fns relabeled @declaration.method;
* plain-variable groups filtered for container/import bindings and for
* the keyword-less `variable_declaration`s tree-sitter-zig uses for
* statement assignments); `interpretImport` maps `const x = @import("…")`
* to a namespace import, member forms to named/alias imports and
* `usingnamespace` to a wildcard; receiver types come from `self`
* parameters, `T{…}` / `mod.T{…}` / `List(u8){…}` literals, `T.init()`
* call returns and `x: T` annotations (incl. decl literals). The
* emit-side wiring lives in `zig/scope-resolver.ts` (SCOPE_RESOLVERS).
*/
import { SupportedLanguages } from 'gitnexus-shared';
@ -42,6 +50,7 @@ import {
interpretZigImport,
interpretZigTypeBinding,
isZigContainerMethod,
isZigContainerOrImportBinding,
zigArityCompatibility,
zigBindingScopeFor,
zigReceiverBinding,
@ -64,6 +73,13 @@ export const zigProvider = defineLanguage({
fieldExtractor: createFieldExtractor(zigFieldConfig),
methodExtractor: createMethodExtractor(zigMethodConfig),
variableExtractor: createVariableExtractor(zigVariableConfig),
// A `const`/`var` whose value is a container or an `@import` is the
// Struct/Enum/Union node or the import binding, not a Const beside it.
shouldSkipDefinitionCapture: (captureMap, defaultLabel) => {
if (defaultLabel !== 'Const' && defaultLabel !== 'Variable') return false;
const decl = captureMap['definition.const'] ?? captureMap['definition.variable'];
return decl !== undefined && isZigContainerOrImportBinding(decl);
},
labelOverride: (functionNode, defaultLabel) => {
if (defaultLabel !== 'Function') return defaultLabel;
if (isZigContainerMethod(functionNode)) return 'Method';

View file

@ -15,25 +15,97 @@ export const ZIG_CONTAINER_TYPES: ReadonlySet<string> = new Set([
'opaque_declaration',
]);
/** Is `node` the `@import(…)` builtin call? */
function isZigImportBuiltin(node: SyntaxNode | null): boolean {
if (node?.type !== 'builtin_function') return false;
const builtin = node.namedChild(0);
return builtin?.type === 'builtin_identifier' && builtin.text === '@import';
}
/** The `@import(…)` call at the ROOT of a value expression: the value itself
* (`@import("x.zig")`) or the leftmost object of a member chain
* (`@import("x.zig").Foo`, `@import("std").mem.Allocator`). Null otherwise. */
export function zigImportRootOf(value: SyntaxNode | null): SyntaxNode | null {
let cur = value;
while (cur?.type === 'field_expression') cur = cur.childForFieldName('object');
return isZigImportBuiltin(cur) ? cur : null;
}
/** 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. 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. */
* or an import binding (`const x = @import("…")`, `const X = @import("…").X`)?
* Those groups are emitted by their dedicated query rules; the plain
* @declaration.variable / @definition.const match for the same node must be
* dropped so the name binds exactly once (and no Const node shadows the
* Struct / import). Shared by the scope walker, the variable extractor and
* the provider's `shouldSkipDefinitionCapture` so the structure-phase 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;
if (ZIG_CONTAINER_TYPES.has(child.type)) return true;
if (child.type === 'builtin_function') {
const builtin = child.namedChild(0);
if (builtin?.type === 'builtin_identifier' && builtin.text === '@import') return true;
}
if (zigImportRootOf(child) !== null) return true;
}
return false;
}
/** Does this `variable_declaration` carry a `const` / `var` keyword child?
* tree-sitter-zig 1.1.2 parses statement-position ASSIGNMENTS (`x = 5;`,
* `x += 1;`, `_ = expr;`) as `variable_declaration` too — the only thing
* that separates a real binding from a re-assignment is the keyword. Query
* rules match the keyword literally; this is the JS-side twin for the
* extractors and the phantom-local guard in `emitZigScopeCaptures`. */
export function isZigKeywordDeclaration(declNode: SyntaxNode): boolean {
for (let i = 0; i < declNode.childCount; i++) {
const t = declNode.child(i)?.type;
if (t === 'const' || t === 'var') return true;
}
return false;
}
/** The binding name of a Zig container node, or undefined for a truly
* anonymous one. Two shapes carry a name:
* - `const Point = struct {…}` — the first identifier of the wrapping
* `variable_declaration`;
* - `pub fn List(comptime T: type) type { return struct {…}; }` — the
* generic type constructor. Zig has no other spelling for a generic
* type, and every reader calls the returned container `List`, so the
* enclosing function's name IS the type name (`ArrayList(u8)`).
* Single source for the class/field/method extractor configs. */
export function zigContainerName(containerNode: SyntaxNode): string | undefined {
const parent = containerNode.parent;
if (parent === null || parent === undefined) return undefined;
if (parent.type === 'variable_declaration') {
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child?.type === 'identifier') return child.text;
}
return undefined;
}
return zigTypeConstructorOf(containerNode)?.childForFieldName('name')?.text;
}
/** For a container that is the direct `return` value of a function whose
* return type is `type` — `fn List(comptime T: type) type { return struct
* {…}; }` — the function_declaration; null for any other placement. Only the
* literal `return_expression → expression_statement → block → fn` chain
* counts: a container nested deeper (`return struct {…}.field`, a container
* inside an `if`) is not the type the function constructs. */
export function zigTypeConstructorOf(containerNode: SyntaxNode): SyntaxNode | null {
if (!ZIG_CONTAINER_TYPES.has(containerNode.type)) return null;
const ret = containerNode.parent;
if (ret?.type !== 'return_expression') return null;
const stmt = ret.parent;
if (stmt?.type !== 'expression_statement') return null;
const block = stmt.parent;
if (block?.type !== 'block') return null;
const fn = block.parent;
if (fn?.type !== 'function_declaration' || fn.childForFieldName('body')?.id !== block.id) {
return null;
}
return fn.childForFieldName('type')?.text === 'type' ? fn : null;
}
/** 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
@ -117,6 +189,36 @@ export function emitZigScopeCaptures(
const rawMatches = getZigScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
// Pre-pass: this file's `@import` bindings (`const counter =
// @import("counter.zig")` → counter ↦ the string node), so a member alias
// `const Counter = counter.Counter;` can be promoted to a named import of
// `Counter` from `counter.zig` below. Aliases are collected in the same
// pass so their plain-variable group is dropped (a local Const binding
// would outrank the import binding it stands for).
const importSources = new Map<string, SyntaxNode>();
const aliasDeclIds = new Set<number>();
for (const m of rawMatches) {
const byName = new Map(m.captures.map((c) => [c.name, c.node] as const));
const importName = byName.get('import.name');
const importSource = byName.get('import.source');
if (
importName !== undefined &&
importSource !== undefined &&
byName.get('import.imported') === undefined &&
byName.get('import.statement')?.parent?.type === 'source_file'
) {
importSources.set(importName.text, importSource);
}
}
for (const m of rawMatches) {
const byName = new Map(m.captures.map((c) => [c.name, c.node] as const));
const stmt = byName.get('alias.statement');
const ns = byName.get('alias.namespace');
if (stmt !== undefined && ns !== undefined && importSources.has(ns.text)) {
aliasDeclIds.add(stmt.id);
}
}
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
const nodeMap: Record<string, SyntaxNode> = {};
@ -128,10 +230,35 @@ export function emitZigScopeCaptures(
}
if (Object.keys(grouped).length === 0) continue;
// Member aliases: promote to a named import when the object is one of
// this file's @import bindings; otherwise the group is inert (the same
// node is also matched by the plain-variable rule).
const aliasStmt = nodeMap['@alias.statement'];
if (aliasStmt !== undefined) {
if (!aliasDeclIds.has(aliasStmt.id)) continue;
const source = importSources.get(nodeMap['@alias.namespace']!.text)!;
out.push({
'@import.statement': nodeToCapture('@import.statement', aliasStmt),
'@import.name': nodeToCapture('@import.name', nodeMap['@alias.name']!),
'@import.imported': nodeToCapture('@import.imported', nodeMap['@alias.member']!),
'@import.source': nodeToCapture('@import.source', source),
});
continue;
}
// Drop the plain-variable group for container/import bindings — their
// dedicated rules already bind the name (as Struct/Enum/Union or import).
// The query already requires a `const`/`var` keyword, so statement
// assignments (`x = 5;`, `_ = expr;` — same node type, no keyword) never
// mint phantom locals; `isZigKeywordDeclaration` is the belt to that
// brace should the rule ever be loosened.
const variableAnchor = nodeMap['@declaration.variable'];
if (variableAnchor !== undefined && isZigContainerOrImportBinding(variableAnchor)) {
if (
variableAnchor !== undefined &&
(isZigContainerOrImportBinding(variableAnchor) ||
aliasDeclIds.has(variableAnchor.id) ||
!isZigKeywordDeclaration(variableAnchor))
) {
continue;
}
@ -155,6 +282,25 @@ export function emitZigScopeCaptures(
);
}
// Mark containers returned by a generic type constructor so
// `zigBindingScopeFor` hoists their name to the module scope (beside the
// Function def of the same name) instead of the fn body it sits in.
for (const kind of ['@declaration.struct', '@declaration.union', '@declaration.enum']) {
const containerAnchor = nodeMap[kind];
const nameNode = nodeMap['@declaration.name'];
if (
containerAnchor !== undefined &&
nameNode !== undefined &&
zigTypeConstructorOf(containerAnchor) !== null
) {
grouped['@declaration.type-constructor'] = syntheticCapture(
'@declaration.type-constructor',
nameNode,
'true',
);
}
}
// Relabel container-nested fns Function → Method (provider labelOverride
// parity). The anchor capture name carries the kind, so rebuild it.
const fnAnchor = nodeMap['@declaration.function'];
@ -167,6 +313,39 @@ export function emitZigScopeCaptures(
out.push(grouped);
}
// A generic type constructor yields two module-scope defs of one name: the
// Function `Stack` and the Struct `Stack` it returns. Import materialization
// keeps the FIRST def of a name (finalize `indexExportsByName`), and query
// order puts the fn (outer node) first — so `const Stack =
// @import("x.zig").Stack;` bound the Function and `Stack(u8){}` typed
// nothing. Emit the container def ahead of its constructor: the type is what
// an importer instantiates, and the free call `Stack(u8)` still resolves
// (a Struct is a valid call target — the constructor-reference path).
for (let i = 0; i < out.length; i++) {
const group = out[i]!;
if (group['@declaration.type-constructor'] === undefined) continue;
const anchor =
group['@declaration.struct'] ?? group['@declaration.union'] ?? group['@declaration.enum'];
if (anchor === undefined) continue;
for (let j = 0; j < i; j++) {
const fn = out[j]!['@declaration.function'];
if (fn === undefined) continue;
if (
fn.range.startLine < anchor.range.startLine ||
(fn.range.startLine === anchor.range.startLine &&
fn.range.startCol <= anchor.range.startCol)
) {
if (
fn.range.endLine > anchor.range.endLine ||
(fn.range.endLine === anchor.range.endLine && fn.range.endCol >= anchor.range.endCol)
) {
out.splice(j, 0, ...out.splice(i, 1));
break;
}
}
}
}
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, ZIG_CALLABLE_CAPTURE_OPTIONS));
return out;

View file

@ -2,10 +2,15 @@ export {
emitZigScopeCaptures,
isZigContainerMethod,
isZigContainerOrImportBinding,
isZigKeywordDeclaration,
zigContainerName,
zigImportRootOf,
zigTypeConstructorOf,
ZIG_CONTAINER_TYPES,
} from './captures.js';
export { interpretZigImport, interpretZigTypeBinding, normalizeZigTypeName } from './interpret.js';
export {
expandZigWildcardNames,
zigArityCompatibility,
zigBindingScopeFor,
zigMergeBindings,

View file

@ -10,11 +10,32 @@ const stripQuotes = (s: string): string => s.replace(/^["']|["']$/g, '');
* site (renames are ordinary const aliases handled as variable bindings).
*/
export function interpretZigImport(captures: CaptureMatch): ParsedImport | null {
const name = captures['@import.name']?.text;
const source = captures['@import.source']?.text;
if (name === undefined || source === undefined) return null;
if (source === undefined) return null;
const targetRaw = stripQuotes(source);
if (targetRaw.length === 0) return null;
// `pub usingnamespace @import("x.zig");` — every pub decl of the target
// becomes a decl of this container. A wildcard, expanded by
// `expandZigWildcardNames` in the scope resolver.
if (captures['@import.wildcard'] !== undefined) {
return { kind: 'wildcard', targetRaw };
}
const name = captures['@import.name']?.text;
if (name === undefined) return null;
// `const Foo = @import("x.zig").Foo;` — one member, under a name of the
// importer's choosing (a rename when it differs: `const Alloc =
// @import("std").mem;`). Same fact as TS `import { Foo } from './x'` /
// `import { Foo as Bar }`.
const imported = captures['@import.imported']?.text;
if (imported !== undefined) {
return imported === name
? { kind: 'named', localName: name, importedName: imported, targetRaw }
: { kind: 'alias', localName: name, importedName: imported, alias: name, targetRaw };
}
return { kind: 'namespace', localName: name, importedName: name, targetRaw };
}
@ -34,6 +55,15 @@ export function normalizeZigTypeName(text: string): string {
} while (t !== previous);
const bang = t.lastIndexOf('!');
if (bang !== -1) t = t.slice(bang + 1).trim();
// Generic instantiation `List(u8)` / `std.ArrayList(u8)` → the type
// constructor `List` / `std.ArrayList`: Zig spells a generic type as a
// call, and the container def is registered under the function's name.
// Builtins (`@This()`, `@TypeOf(x)`) keep their parentheses — they are
// not constructor names and must not turn into `@This`.
if (!t.startsWith('@')) {
const paren = t.indexOf('(');
if (paren > 0 && t.endsWith(')')) t = t.slice(0, paren).trim();
}
return t;
}
@ -52,6 +82,14 @@ export function interpretZigTypeBinding(captures: CaptureMatch): ParsedTypeBindi
source = isReceiver ? 'self' : 'parameter-annotation';
} else if (captures['@type-binding.constructor'] !== undefined) {
source = 'constructor-inferred';
} else if (captures['@type-binding.call-return'] !== undefined) {
// `var c = Counter.init();` — the call's receiver names the type when it
// is a container (`Counter`, `mod.Counter`, `List(u8)`); a value receiver
// (`std.mem`, `self.items`) simply finds no container and declines.
source = 'constructor-inferred';
} else if (captures['@type-binding.annotation'] !== undefined) {
// `var x: T = undefined;` / `const x: T = .init(…);` — the declared type.
source = 'annotation';
}
return { boundName: name, rawTypeName: normalizeZigTypeName(type), source };

View file

@ -65,6 +65,33 @@ const ZIG_SCOPE_QUERY = `
(identifier) @declaration.name
(opaque_declaration) @declaration.struct)
;; Declarations — generic type constructors. \`fn List(comptime T: type) type
;; { return struct {…}; }\` is Zig's only spelling of a generic type; the
;; returned container is anonymous in the grammar but every reader (and every
;; caller: \`List(u8)\`) names it after the function. Anchor on the container
;; so the def sits in its own class scope; the name binding is hoisted to the
;; MODULE scope by \`zigBindingScopeFor\` (not the fn body, where the name
;; would be invisible to callers) and coexists with the Function def of the
;; same name — \`List\` really is both a callable and a type.
((function_declaration
name: (identifier) @declaration.name
type: (builtin_type) @_ret
body: (block (expression_statement (return_expression
(struct_declaration) @declaration.struct))))
(#eq? @_ret "type"))
((function_declaration
name: (identifier) @declaration.name
type: (builtin_type) @_ret
body: (block (expression_statement (return_expression
(union_declaration) @declaration.union))))
(#eq? @_ret "type"))
((function_declaration
name: (identifier) @declaration.name
type: (builtin_type) @_ret
body: (block (expression_statement (return_expression
(enum_declaration) @declaration.enum))))
(#eq? @_ret "type"))
;; 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).
@ -76,9 +103,15 @@ const ZIG_SCOPE_QUERY = `
;; The \`.\` anchor pins the FIRST named child: without it the pattern also
;; matched the initializer of \`const first = target;\`, minting a phantom
;; local named \`target\` that shadowed the real callee for every later
;; reference in the block.
;; reference in the block. The literal keyword is load-bearing too:
;; tree-sitter-zig 1.1.2 parses statement assignments (\`x = 5;\`, \`x += 1;\`,
;; \`_ = expr;\`) as \`variable_declaration\` WITHOUT a keyword child, and
;; without the keyword every assignment and every discard minted a phantom
;; local (one \`_\` per statement).
(variable_declaration
. (identifier) @declaration.name) @declaration.variable
"const" . (identifier) @declaration.name) @declaration.variable
(variable_declaration
"var" . (identifier) @declaration.name) @declaration.variable
;; Imports — const x = @import("...")
(variable_declaration
@ -88,6 +121,59 @@ const ZIG_SCOPE_QUERY = `
(arguments (string) @import.source))
(#eq? @_builtin "@import")) @import.statement
;; Imports — const X = @import("...").X : a NAMED import of one member. The
;; local name is whatever the user chose (\`const Alloc = @import("std").mem;\`
;; is a rename), the imported name is the member. Deeper chains
;; (\`@import("std").mem.Allocator\`) bind the innermost member — the file
;; edge is what matters; a member-of-a-member resolves through the namespace
;; later or not at all.
(variable_declaration
(identifier) @import.name
(field_expression
object: (builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
member: (identifier) @import.imported)
(#eq? @_builtin "@import")) @import.statement
(variable_declaration
(identifier) @import.name
(field_expression
object: (field_expression
object: (builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source)))
member: (identifier) @import.imported)
(#eq? @_builtin "@import")) @import.statement
;; Aliases of a namespace member — const Counter = counter.Counter; where
;; \`counter\` is an @import binding of THIS file. The query cannot know which
;; identifiers are import bindings, so it captures every one-level member
;; alias and \`emitZigScopeCaptures\` promotes the ones whose object is a
;; known @import to a named import (same fact as \`const Counter =
;; @import("counter.zig").Counter;\`); the rest stay ordinary variables.
;; Deeper chains (\`std.mem.Allocator\`) bind the innermost member off the
;; leftmost namespace.
(variable_declaration
"const" . (identifier) @alias.name
(field_expression
object: (identifier) @alias.namespace
member: (identifier) @alias.member) .) @alias.statement
(variable_declaration
"const" . (identifier) @alias.name
(field_expression
object: (field_expression
object: (identifier) @alias.namespace)
member: (identifier) @alias.member) .) @alias.statement
;; Imports — pub usingnamespace @import("..."); : every pub decl of the target
;; becomes a decl of this container (removed from the language in 0.15, still
;; everywhere in 0.11–0.14 code). Modelled as a wildcard import.
(using_namespace_declaration
(builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
(#eq? @_builtin "@import")) @import.wildcard
;; Type bindings — parameter annotations (incl. self: *T receivers)
(parameter
name: (identifier) @type-binding.name
@ -108,6 +194,41 @@ const ZIG_SCOPE_QUERY = `
(struct_initializer
(field_expression) @type-binding.type)) @type-binding.constructor
;; Type bindings — generic instantiation literal: const l = List(u8){ ... }.
;; The callee is the type constructor; \`normalizeZigTypeName\` drops the
;; comptime argument list so \`List(u8)\` looks up \`List\`.
(variable_declaration
(identifier) @type-binding.name
(struct_initializer
(call_expression) @type-binding.type)) @type-binding.constructor
;; Type bindings — declared type: var x: T = …; const x: T = .init(…);
;; The annotation is the ONLY type source for \`= undefined\` and for 0.14+
;; decl literals (\`.init\`, \`.empty\`), which are the idiomatic
;; constructors in current std. Ranked below constructor inference by the
;; shared resolver (source 'annotation'), so a literal on the right still
;; wins when both are present.
(variable_declaration
. (identifier) @type-binding.name
type: (_) @type-binding.type) @type-binding.annotation
;; Type bindings — call-return inference: var c = Counter.init(); (Rust's
;; \`let x = Foo::new()\` twin). The receiver of the call is the type when it
;; names a container (\`Counter\`, \`mod.Counter\`, \`List(u8)\`); when it is a
;; value (\`std.mem\`, \`self.items\`) the lookup finds no container and
;; declines, exactly as Rust's does. Keyword-gated: \`_ = e.top();\` is an
;; assignment (same node type), not a binding of \`_\`.
(variable_declaration
"const" . (identifier) @type-binding.name
(call_expression
function: (field_expression
object: (_) @type-binding.type)) .) @type-binding.call-return
(variable_declaration
"var" . (identifier) @type-binding.name
(call_expression
function: (field_expression
object: (_) @type-binding.type)) .) @type-binding.call-return
;; References — free calls: foo(...)
(call_expression
function: (identifier) @reference.name) @reference.call.free

View file

@ -17,7 +17,7 @@ import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolv
import { loadZigBuildZon, type ZigBuildZonConfig } from '../../language-config.js';
import { resolveZigImportInternal } from '../../import-resolvers/zig.js';
import { zigProvider } from '../zig.js';
import { zigArityCompatibility, zigMergeBindings } from './index.js';
import { expandZigWildcardNames, zigArityCompatibility, zigMergeBindings } from './index.js';
export const zigScopeResolver: ScopeResolver = {
language: SupportedLanguages.Zig,
@ -34,6 +34,10 @@ export const zigScopeResolver: ScopeResolver = {
(resolutionConfig as ZigBuildZonConfig | null | undefined) ?? null,
),
// `pub usingnamespace @import("x.zig");` — target decls become local decls.
expandsWildcardTo: (targetModuleScope, parsedFiles) =>
expandZigWildcardNames(targetModuleScope, parsedFiles),
mergeBindings: zigMergeBindings,
arityCompatibility: zigArityCompatibility,

View file

@ -2,6 +2,7 @@ import type {
BindingRef,
Callsite,
CaptureMatch,
ParsedFile,
Scope,
ScopeId,
ScopeTree,
@ -15,14 +16,50 @@ import type {
export function zigBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
_tree: ScopeTree,
tree: ScopeTree,
): ScopeId | null {
if (decl['@type-binding.parameter'] !== undefined) {
return innermost.id;
}
// A container returned by a generic type constructor (`fn List(comptime T:
// type) type { return struct {…}; }`) is anchored on the container node,
// whose innermost enclosing scope is the FUNCTION BODY. Its name is only
// useful to callers (`List(u8){}`, `List(u8).init()`), so bind it in the
// module scope beside the Function def of the same name — `List` really is
// both a callable and a type. `zigTypeConstructorOf` in the walker decides
// WHICH containers get this rule; the marker capture carries the verdict.
if (decl['@declaration.type-constructor'] !== undefined) {
let scope: Scope | undefined = innermost;
while (scope !== undefined && scope.kind !== 'Module') scope = tree.getParent(scope.id);
return scope?.id ?? null;
}
return null; // default auto-hoist for other bindings
}
/** `pub usingnamespace @import("x.zig");` — every declaration of the target
* module becomes a declaration of the importer. Enumerates the target's
* module-level names for the finalize wildcard expansion (Dart pattern).
* Zig visibility (`pub`) is not recorded on scope-side defs, so this
* over-approximates to every top-level name; the structure phase's
* `isExported` is the authoritative visibility. */
export function expandZigWildcardNames(
targetModuleScope: ScopeId,
parsedFiles: readonly ParsedFile[],
): readonly string[] {
const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
if (target === undefined) return [];
const seen = new Set<string>();
const names: string[] = [];
for (const def of target.localDefs) {
const qn = def.qualifiedName;
if (qn === undefined || qn.length === 0 || qn.includes('.')) continue; // top-level only
if (seen.has(qn)) continue;
seen.add(qn);
names.push(qn);
}
return names;
}
/** Zig's receiver convention is a FIRST parameter named `self`; the
* `self`-sourced typeBinding on the function scope carries its type.
* Position is enforced upstream, not here: `interpretZigTypeBinding` only

View file

@ -1,29 +1,23 @@
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';
import { hasZigPubKeyword } from '../../export-detection.js';
import { ZIG_CONTAINER_TYPES, zigContainerName } from '../../languages/zig/captures.js';
/**
* Zig method extraction.
*
* 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.
* tree-sitter-zig containers (struct/enum/union/opaque) are anonymous; the
* binding name lives on the parent variable_declaration, or on the enclosing
* generic type constructor (`fn List(comptime T: type) type { return struct
* {…}; }`) — `zigContainerName` decides. Methods inside a container appear as
* plain `function_declaration` children of the container node.
*
* The first parameter is the receiver iff it is named `self` (convention) —
* unlike Rust, Zig has no dedicated `self_parameter` node type.
*/
const extractZigOwnerName = (node: SyntaxNode): string | undefined => {
const parent = node.parent;
if (!parent || parent.type !== 'variable_declaration') return undefined;
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child?.type === 'identifier') return child.text;
}
return undefined;
};
const extractZigOwnerName = (node: SyntaxNode): string | undefined => zigContainerName(node);
const extractZigName = (node: SyntaxNode): string | undefined => {
const nameNode = node.childForFieldName('name');
@ -120,7 +114,9 @@ export const zigMethodConfig: MethodExtractionConfig = {
extractFunctionName: extractZigFunctionName,
extractReturnType: extractZigReturnType,
extractParameters: extractZigParameters,
extractVisibility: (node) => (hasZigVisibilityKeyword(node) ? 'public' : 'private'),
// `pub` only: `export fn` is C linkage, still private to other Zig files
// (isExported carries the FFI fact — see export-detection.ts).
extractVisibility: (node) => (hasZigPubKeyword(node) ? 'public' : 'private'),
extractReceiverType: extractZigReceiverType,
isStatic(node) {

View file

@ -2481,7 +2481,37 @@ export const ZIG_QUERIES = `
(identifier) @name
(opaque_declaration)) @definition.struct
; Container fields (struct fields, enum variants, union variants).
; Generic type constructors: \`pub fn List(comptime T: type) type { return
; struct { … }; }\` — Zig's only spelling of a generic type. The returned
; container is anonymous in the grammar; the definition anchor is the
; container node and its name is the enclosing function's (\`List\`), which
; is what every caller writes (\`List(u8)\`). Only the direct \`return
; <container>\` of a fn whose return type is \`type\` qualifies (see
; \`zigTypeConstructorOf\`). The Function node \`List\` coexists: \`List\` is
; both a callable and a type.
((function_declaration
name: (identifier) @name
type: (builtin_type) @_ret
body: (block (expression_statement (return_expression
(struct_declaration) @definition.struct))))
(#eq? @_ret "type"))
((function_declaration
name: (identifier) @name
type: (builtin_type) @_ret
body: (block (expression_statement (return_expression
(union_declaration) @definition.union))))
(#eq? @_ret "type"))
((function_declaration
name: (identifier) @name
type: (builtin_type) @_ret
body: (block (expression_statement (return_expression
(enum_declaration) @definition.enum))))
(#eq? @_ret "type"))
; Container fields (struct fields, enum variants, union variants) — all are
; \`container_field\` in the grammar and all become Property (C labels its
; enumerators Const; Rust captures no variants; Zig's own vocabulary is
; "field" for all three, so one label keeps the query honest).
; #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
@ -2498,6 +2528,18 @@ export const ZIG_QUERIES = `
(test_declaration
(string) @name) @definition.function
; const / var bindings that are neither a container nor an @import (those two
; are skipped by the provider's \`shouldSkipDefinitionCapture\` so the Struct /
; import binding is the only node for that name). The literal keyword is
; load-bearing: tree-sitter-zig 1.1.2 parses statement assignments (\`x = 5;\`,
; \`x += 1;\`, \`_ = expr;\`) as \`variable_declaration\` WITHOUT a keyword
; child, and a keyword-less rule would mint a Const per assignment and a
; Variable named \`_\` per discard.
(variable_declaration
"const" . (identifier) @name) @definition.const
(variable_declaration
"var" . (identifier) @name) @definition.variable
; @import("path") — capture the string argument as @import.source.
; The #eq? predicate restricts the match to the @import builtin (other
; builtins like @sizeOf, @TypeOf, @as are not import statements).
@ -2508,6 +2550,23 @@ export const ZIG_QUERIES = `
(string) @import.source))
(#eq? @builtin "@import")) @import
; const X = @import("path").X — the same file edge, one member deep.
(variable_declaration
(field_expression
object: (builtin_function
(builtin_identifier) @builtin
(arguments
(string) @import.source)))
(#eq? @builtin "@import")) @import
; pub usingnamespace @import("path");
(using_namespace_declaration
(builtin_function
(builtin_identifier) @builtin
(arguments
(string) @import.source))
(#eq? @builtin "@import")) @import
; Free calls: foo(...)
(call_expression
function: (identifier) @call.name) @call

View file

@ -798,6 +798,26 @@ const javaBinaryNameOfType = (node: SyntaxNode): string | undefined => {
return enclosingName === undefined ? undefined : `${enclosingName}$${simpleName}`;
};
/**
* For a container node that is the direct `return` value of a function whose
* declared return type is the literal `type` (`fn List(comptime T: type) type
* { return struct {…}; }`), the function's `name` node; undefined otherwise.
* Language-agnostic by shape — today only tree-sitter-zig produces it.
*/
function typeConstructorNameNode(container: SyntaxNode): SyntaxNode | undefined {
const ret = container.parent;
if (ret?.type !== 'return_expression') return undefined;
const stmt = ret.parent;
if (stmt?.type !== 'expression_statement') return undefined;
const block = stmt.parent;
if (block?.type !== 'block') return undefined;
const fn = block.parent;
if (fn?.type !== 'function_declaration') return undefined;
if (fn.childForFieldName?.('body')?.id !== block.id) return undefined;
if (fn.childForFieldName?.('type')?.text !== 'type') return undefined;
return fn.childForFieldName?.('name') ?? undefined;
}
/**
* Authoritative Java local/anonymous type identity.
*
@ -1069,7 +1089,15 @@ export const findEnclosingClassInfo = (
// 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);
: undefined) ??
// An anonymous container RETURNED by a type-constructor function —
// `pub fn List(comptime T: type) type { return struct { … }; }`
// (Zig's only spelling of a generic type). The container's name is
// the function's, which is what the definition phase uses too
// (`@name` on the fn identifier, anchor on the container), so the
// owner id and the node id agree by construction. Only the literal
// `return <container>` of a fn returning `type` qualifies.
typeConstructorNameNode(current);
if (nameNode) {
let label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class';
// Kotlin: class_declaration with an anonymous "interface" keyword child

View file

@ -1,14 +1,19 @@
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';
import { hasZigPubKeyword } from '../../export-detection.js';
import {
isZigContainerOrImportBinding,
isZigKeywordDeclaration,
} from '../../languages/zig/captures.js';
/**
* Zig variable extraction.
* Zig variable extraction — enriches the Const / Variable nodes minted by
* ZIG_QUERIES' `@definition.const` / `@definition.variable` rules.
*
* tree-sitter-zig uses a single `variable_declaration` node for both `const`
* and `var` bindings. The keyword is exposed as an unnamed token child
* and `var` bindings — AND for statement assignments (`x = 5;`), which carry
* no keyword. The keyword is exposed as an unnamed token child
* (`type === 'const'` or `type === 'var'`).
*
* Excludes variable_declarations whose value is a struct/enum/union or an
@ -35,6 +40,9 @@ export const zigVariableConfig: VariableExtractionConfig = {
extractName(node) {
if (isZigContainerOrImportBinding(node)) return undefined;
// Statement assignments share the node type and have no keyword — not a
// declaration (belt to the query's literal `"const"` / `"var"` brace).
if (!isZigKeywordDeclaration(node)) return undefined;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'identifier') return child.text;
@ -55,7 +63,8 @@ export const zigVariableConfig: VariableExtractionConfig = {
},
extractVisibility(node): VariableVisibility {
return hasZigVisibilityKeyword(node) ? 'public' : 'private';
// `pub` only — `export var` is C linkage, private to other Zig files.
return hasZigPubKeyword(node) ? 'public' : 'private';
},
isConst(node) {

View file

@ -173,3 +173,118 @@ describe.skipIf(!zigAvailable)('Zig export, opaque and test declarations (ffi.zi
expect(fns.some((n) => n.startsWith('test@') || n === 'test')).toBe(false);
});
});
/**
* `zig-idioms`: the shapes real Zig is written in that `zig-basic` does not
* exercise. Each case names the idiom and what breaks without the rule.
*/
describe.skipIf(!zigAvailable)('Zig idioms (zig-idioms fixture)', () => {
let result: PipelineResult;
let calls: string[];
let imports: string[];
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-idioms'), () => {});
calls = edgeSet(getRelationships(result, 'CALLS'));
imports = getRelationships(result, 'IMPORTS').map(
(e) => `${path.basename(e.sourceFilePath)} → ${e.targetFilePath}`,
);
}, 90000);
it('mints Const / Variable nodes for `pub const` / `pub var` (incl. error sets and type aliases)', () => {
// ZIG_QUERIES had no @definition.const / @definition.variable at all, so
// `zigVariableConfig` never ran and `pub const VERSION`, error sets and
// aliases like `const Allocator = std.mem.Allocator` were absent from
// the graph.
expect(getNodesByLabel(result, 'Const')).toEqual(
expect.arrayContaining(['VERSION', 'Err', 'Allocator']),
);
expect(getNodesByLabel(result, 'Variable')).toContain('global_count');
});
it('never mints a Const for a container or an @import binding, nor for a statement assignment', () => {
// `const Counter = struct {…}` is the Struct node; `const counter =
// @import(…)` is the import binding; `counter.global_count = 5;` and
// `_ = counter.VERSION;` are assignments that tree-sitter-zig 1.1.2 parses
// as keyword-less `variable_declaration`s.
const consts = getNodesByLabel(result, 'Const');
// (`const Counter = counter.Counter;` IS a Const node — an alias — but
// the struct itself is not duplicated as one: exactly one `Counter` Const,
// from main.zig.)
expect(getNodesByLabelFull(result, 'Const').filter((n) => n.name === 'Counter')).toHaveLength(
1,
);
expect(consts).not.toContain('counter');
expect(consts).not.toContain('std');
expect(consts).not.toContain('_');
expect(getNodesByLabel(result, 'Variable')).not.toContain('_');
});
it('types a receiver from a constructor CALL (`var a = Counter.init(); a.incr()`)', () => {
expect(calls).toContain('main → incr');
});
it('types a receiver from its ANNOTATION (`var b: Counter = undefined; b.twice()`, `const c: Counter = .init(); c.get()`)', () => {
// The declared type is the ONLY type source for `= undefined` and for
// 0.14+ decl literals (`.init`, `.empty`), which current std uses for
// every container constructor.
expect(calls).toContain('main → twice');
expect(calls).toContain('main → get');
});
it('follows an alias of a namespace member as a named import (`const Counter = counter.Counter;`)', () => {
// Every receiver above is typed through the alias — none resolves if the
// scope-side binding is a plain local shadowing the import (the graph
// still carries the alias as a Const node in main.zig, which is what it is).
expect(calls).toContain('main → get');
expect(calls).toContain('main → init');
});
it('owns a generic type constructor’s members and dispatches on its instantiations', () => {
// `pub fn Stack(comptime T: type) type { return struct {…}; }` — the
// returned container had no owner (methods hung off the File) and
// `Stack(u8){}` / `Stack(u8).init()` / `: Stack(u16)` typed nothing.
expect(getNodesByLabel(result, 'Struct')).toContain('Stack');
expect(getNodesByLabel(result, 'Function')).toContain('Stack');
expect(edgeSet(getRelationships(result, 'HAS_METHOD'))).toEqual(
expect.arrayContaining(['Stack → push', 'Stack → top', 'Stack → clear']),
);
expect(edgeSet(getRelationships(result, 'HAS_PROPERTY'))).toContain('Stack → items');
expect(calls).toEqual(expect.arrayContaining(['main → push', 'main → top', 'main → clear']));
});
it('imports the file behind `const X = @import("x.zig").X` and `usingnamespace @import(...)`', () => {
// Both forms lost the file-level IMPORTS edge: the rule needed
// `builtin_function` as a DIRECT child of the declaration.
expect(imports).toContain('main.zig → src/mixin.zig');
// counter.zig is imported twice from main.zig (namespace + member); the
// edge is deduped, so its presence proves at least one form resolved and
// `Stack` (member form only) dispatching proves the other.
expect(imports).toContain('main.zig → src/counter.zig');
expect(calls).toContain('main → push');
});
it('resolves a build.zig.zon path dep to the root its build.zig declares (src/root.zig)', () => {
// `zig init` ≥ 0.12 lays libraries out as src/root.zig; the resolver only
// knew src/<name>.zig and src/main.zig, so every such dep was unresolved.
expect(imports).toContain('main.zig → libs/geo/src/root.zig');
expect(calls).toContain('main → area');
expect(calls).toContain('main → shift');
});
it('still resolves the older src/<name>.zig convention when the dep has no build.zig', () => {
expect(imports).toContain('main.zig → libs/oldlib/src/oldlib.zig');
expect(calls).toContain('main → legacy');
});
it('a re-assignment (`a = Counter.init();`) is not a declaration and does not shadow the typed binding', () => {
// Guarded on the scope side by the literal `"const"` / `"var"` in the
// query and on the structure side by `isZigKeywordDeclaration`.
// main → incr resolves twice through the same binding (before and after
// the re-assignment); an untyped phantom `a` would drop the second.
expect(
getRelationships(result, 'CALLS').filter((e) => e.source === 'main' && e.target === 'incr')
.length,
).toBeGreaterThanOrEqual(2);
});
});

View file

@ -17,8 +17,21 @@ import { createMethodExtractor } from '../../src/core/ingestion/method-extractor
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';
import { emitZigScopeCaptures } from '../../src/core/ingestion/languages/zig/captures.js';
import { interpretZigTypeBinding } from '../../src/core/ingestion/languages/zig/interpret.js';
import {
emitZigScopeCaptures,
isZigContainerOrImportBinding,
isZigKeywordDeclaration,
zigContainerName,
} from '../../src/core/ingestion/languages/zig/captures.js';
import {
interpretZigImport,
interpretZigTypeBinding,
normalizeZigTypeName,
} from '../../src/core/ingestion/languages/zig/interpret.js';
import { createFieldExtractor } from '../../src/core/ingestion/field-extractors/generic.js';
import { zigFieldConfig } from '../../src/core/ingestion/field-extractors/configs/zig.js';
import { zigProvider } from '../../src/core/ingestion/languages/zig.js';
import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js';
const _require = createRequire(import.meta.url);
let Zig: unknown = null;
@ -240,25 +253,40 @@ export const table: [4]u8 = .{ 0, 0, 0, 0 };
);
});
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.
it('`export` is C linkage, not Zig visibility: isExported true, visibility private, `pub` public', () => {
// Language reference: only `pub` declarations are reachable from another
// file through `@import`; `export` puts the symbol in the object file for
// C callers and leaves it PRIVATE to Zig code. The graph keeps both facts
// in their own property: `isExported` (visible outside the compilation
// unit — the FFI surface, same reading as C external linkage) and
// `visibility` (the Zig-module fact). Reporting `export fn` as `public`
// let the resolver connect a cross-file call Zig would reject.
const root = parse(`
const C = struct {
export fn cb(self: *C) void { _ = self; }
pub fn open(self: *C) void { _ = self; }
fn hidden(self: *C) void { _ = self; }
};
export var counter: u32 = 0;
pub var visible: 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 },
const vis = (name: string) => methods!.methods.find((m) => m.name === name)!.visibility;
expect(vis('cb')).toBe('private');
expect(vis('open')).toBe('public');
expect(vis('hidden')).toBe('private');
expect(zigExportChecker(find(root, 'function_declaration', 'export fn cb'), 'cb')).toBe(true);
const variables = createVariableExtractor(zigVariableConfig);
const ctx = { filePath: 'test.zig', language: SupportedLanguages.Zig };
expect(
variables.extract(find(root, 'variable_declaration', 'export var'), ctx)!.visibility,
).toBe('private');
expect(variables.extract(find(root, 'variable_declaration', 'pub var'), ctx)!.visibility).toBe(
'public',
);
expect(variable!.visibility).toBe('public');
});
});
@ -358,3 +386,228 @@ pub const H = opaque {
expect(fields).toEqual([]);
});
});
describeZig('Zig declarations vs statement assignments (tree-sitter-zig 1.1.2 quirk)', () => {
const src = `
var count: u32 = 0;
fn inc() void {
count = 5;
count += 1;
_ = inc;
const local = 1;
var m: u32 = undefined;
_ = local; _ = m;
}
`;
it('`x = 5;`, `x += 1;` and `_ = expr;` are keyword-less variable_declarations, not bindings', () => {
// The grammar reuses `variable_declaration` for statement assignments; the
// `const` / `var` keyword child is the only thing that tells them apart.
// Without the gate every assignment minted a phantom local (one `_` per
// discard) and, on the structure side, a Const/Variable node per statement.
const root = parse(src).rootNode;
const decls: SyntaxNode[] = [];
const walk = (n: SyntaxNode): void => {
if (n.type === 'variable_declaration') decls.push(n);
n.children.forEach(walk);
};
walk(root);
const verdicts = decls.map((d) => [d.text.split('\n')[0]!.trim(), isZigKeywordDeclaration(d)]);
expect(verdicts).toEqual([
['var count: u32 = 0;', true],
['count = 5;', false],
['count += 1;', false],
['_ = inc;', false],
['const local = 1;', true],
['var m: u32 = undefined;', true],
['_ = local;', false],
['_ = m;', false],
]);
});
it('the variable extractor declines an assignment and the scope walker binds only real declarations', () => {
const root = parse(src).rootNode;
const extractor = createVariableExtractor(zigVariableConfig);
const ctx = { filePath: 'x.zig', language: SupportedLanguages.Zig };
expect(extractor.extract(find(root, 'variable_declaration', 'count = 5'), ctx)).toBeNull();
expect(extractor.extract(find(root, 'variable_declaration', 'const local'), ctx)?.name).toBe(
'local',
);
const bound = emitZigScopeCaptures(src, 'x.zig')
.filter((m) => m['@declaration.variable'] !== undefined)
.map((m) => m['@declaration.name']?.text);
expect(bound).toEqual(['count', 'local', 'm']);
});
});
describeZig('Zig import forms', () => {
it('a member alias off @import and a usingnamespace are import bindings, not variables', () => {
// `const Foo = @import("x.zig").Foo;` is the single-symbol import Zig is
// written with; treating it as a plain Const lost the file edge and left
// `Foo{}` untyped. `pub usingnamespace @import(...)` has no name at all.
const root = parse(`
const std = @import("std");
const Foo = @import("foo.zig").Foo;
const Alloc = @import("std").mem.Allocator;
const plain = 1;
`).rootNode;
expect(isZigContainerOrImportBinding(find(root, 'variable_declaration', 'const std'))).toBe(
true,
);
expect(isZigContainerOrImportBinding(find(root, 'variable_declaration', 'const Foo'))).toBe(
true,
);
expect(isZigContainerOrImportBinding(find(root, 'variable_declaration', 'const Alloc'))).toBe(
true,
);
expect(isZigContainerOrImportBinding(find(root, 'variable_declaration', 'const plain'))).toBe(
false,
);
});
it('interprets namespace, named, alias, wildcard and namespace-member forms', () => {
const src = `
const counter = @import("counter.zig");
const Counter = counter.Counter;
const Renamed = @import("counter.zig").Counter;
const Same = @import("counter.zig").Same;
const Deep = @import("std").mem.Allocator;
pub usingnamespace @import("mixin.zig");
const notAnImport = other.Thing;
`;
const imports = emitZigScopeCaptures(src, 'x.zig')
.filter((m) => m['@import.source'] !== undefined)
.map((m) => interpretZigImport(m));
expect(imports).toEqual([
{
kind: 'namespace',
localName: 'counter',
importedName: 'counter',
targetRaw: 'counter.zig',
},
// alias of a namespace member → promoted to a named import of that member
{ kind: 'named', localName: 'Counter', importedName: 'Counter', targetRaw: 'counter.zig' },
{
kind: 'alias',
localName: 'Renamed',
importedName: 'Counter',
alias: 'Renamed',
targetRaw: 'counter.zig',
},
{ kind: 'named', localName: 'Same', importedName: 'Same', targetRaw: 'counter.zig' },
{
kind: 'alias',
localName: 'Deep',
importedName: 'Allocator',
alias: 'Deep',
targetRaw: 'std',
},
{ kind: 'wildcard', targetRaw: 'mixin.zig' },
]);
// `other` is not an @import binding of this file → stays a variable.
const vars = emitZigScopeCaptures(src, 'x.zig')
.filter((m) => m['@declaration.variable'] !== undefined)
.map((m) => m['@declaration.name']?.text);
expect(vars).toEqual(['notAnImport']);
});
it('the provider skips the Const capture for container and @import bindings', () => {
const root = parse(`
const std = @import("std");
const Foo = @import("foo.zig").Foo;
const S = struct {};
const n = 1;
`).rootNode;
const skip = (prefix: string) =>
zigProvider.shouldSkipDefinitionCapture!(
{ 'definition.const': find(root, 'variable_declaration', prefix) },
'Const',
);
expect(skip('const std')).toBe(true);
expect(skip('const Foo')).toBe(true);
expect(skip('const S')).toBe(true);
expect(skip('const n')).toBe(false);
});
});
describeZig('Zig generic type constructors', () => {
const src = `
pub fn Stack(comptime T: type) type {
return struct {
items: []T = &.{},
pub fn push(self: *@This(), v: T) void { _ = self; _ = v; }
};
}
fn notAType(comptime T: type) u32 {
return struct { pub fn x() u32 { return 1; } }.x();
}
const Plain = struct { a: u8 };
`;
it('names the container returned by a fn returning `type` after that fn; other anonymous containers stay nameless', () => {
const root = parse(src).rootNode;
const structs: SyntaxNode[] = [];
const walk = (n: SyntaxNode): void => {
if (n.type === 'struct_declaration') structs.push(n);
n.children.forEach(walk);
};
walk(root);
expect(structs.map((n) => zigContainerName(n))).toEqual(['Stack', undefined, 'Plain']);
});
it('the method and field extractors own the generic container’s members under the fn name', () => {
const root = parse(src).rootNode;
const container = find(root, 'struct_declaration', 'struct {\n items');
const ctx = { filePath: 'x.zig', language: SupportedLanguages.Zig };
const methods = createMethodExtractor(zigMethodConfig).extract(container, ctx);
expect(methods?.ownerName).toBe('Stack');
expect(methods?.methods.map((m) => m.name)).toEqual(['push']);
const fields = createFieldExtractor(zigFieldConfig).extract(container, {
...ctx,
typeEnv: {
lookup: () => undefined,
constructorBindings: [],
fileScope: () => new Map(),
allScopes: () => new Map(),
constructorTypeMap: new Map(),
} as unknown as import('../../src/core/ingestion/type-env.js').TypeEnvironment,
symbolTable: createSemanticModel().symbols,
});
expect(fields?.ownerFqn).toBe('Stack');
expect(fields?.fields.map((f) => f.name)).toEqual(['items']);
});
it('normalizes generic instantiations to the constructor name and leaves builtins alone', () => {
expect(normalizeZigTypeName('List(u8)')).toBe('List');
expect(normalizeZigTypeName('*std.ArrayList(u8)')).toBe('std.ArrayList');
expect(normalizeZigTypeName('?*const Stack(u16)')).toBe('Stack');
expect(normalizeZigTypeName('@This()')).toBe('@This()');
expect(normalizeZigTypeName('*const @This()')).toBe('@This()');
expect(normalizeZigTypeName('[]const u8')).toBe('u8');
});
});
describeZig('Zig receiver typing sources', () => {
it('annotation and call-return bindings type receivers; a discard is never a binding', () => {
const src = `
pub fn run() void {
var a = Counter.init();
var b: Counter = undefined;
const c: Counter = .init();
var d = counter.Counter.init();
var e = Stack(u8).init();
_ = e.top();
}
`;
const bindings = emitZigScopeCaptures(src, 'x.zig')
.filter((m) => m['@type-binding.name'] !== undefined)
.map((m) => interpretZigTypeBinding(m));
expect(bindings).toEqual([
{ boundName: 'a', rawTypeName: 'Counter', source: 'constructor-inferred' },
{ boundName: 'b', rawTypeName: 'Counter', source: 'annotation' },
{ boundName: 'c', rawTypeName: 'Counter', source: 'annotation' },
{ boundName: 'd', rawTypeName: 'counter.Counter', source: 'constructor-inferred' },
{ boundName: 'e', rawTypeName: 'Stack', source: 'constructor-inferred' },
]);
});
});