From d02f09c53adb376cd3c68bb8f40085ef65cce185 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Tue, 18 Aug 2026 11:18:50 +0200 Subject: [PATCH] feat(zig): export fn visibility, opaque containers, named test blocks, member ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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::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. --- .../ingestion/class-extractors/configs/zig.ts | 8 +- .../src/core/ingestion/export-detection.ts | 34 +++-- .../ingestion/field-extractors/configs/zig.ts | 11 +- gitnexus/src/core/ingestion/languages/zig.ts | 2 +- .../core/ingestion/languages/zig/captures.ts | 17 ++- .../src/core/ingestion/languages/zig/query.ts | 22 ++- .../method-extractors/configs/zig.ts | 39 +++-- .../src/core/ingestion/tree-sitter-queries.ts | 24 ++- .../src/core/ingestion/utils/ast-helpers.ts | 26 +++- .../variable-extractors/configs/zig.ts | 11 +- .../lang-resolution/zig-basic/src/ffi.zig | 36 +++++ gitnexus/test/fixtures/sample-code/simple.zig | 19 +++ .../test/integration/resolvers/zig.test.ts | 69 +++++++++ .../integration/tree-sitter-languages.test.ts | 11 ++ gitnexus/test/unit/zig-extractors.test.ts | 139 ++++++++++++++++++ 15 files changed, 420 insertions(+), 48 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-basic/src/ffi.zig diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/class-extractors/configs/zig.ts index db0c13efe..dfa690117 100644 --- a/gitnexus/src/core/ingestion/class-extractors/configs/zig.ts +++ b/gitnexus/src/core/ingestion/class-extractors/configs/zig.ts @@ -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, }; diff --git a/gitnexus/src/core/ingestion/export-detection.ts b/gitnexus/src/core/ingestion/export-detection.ts index 82e0f8090..ab6900900 100644 --- a/gitnexus/src/core/ingestion/export-detection.ts +++ b/gitnexus/src/core/ingestion/export-detection.ts @@ -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; +} diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/field-extractors/configs/zig.ts index a5ac342b6..10eafe078 100644 --- a/gitnexus/src/core/ingestion/field-extractors/configs/zig.ts +++ b/gitnexus/src/core/ingestion/field-extractors/configs/zig.ts @@ -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) { diff --git a/gitnexus/src/core/ingestion/languages/zig.ts b/gitnexus/src/core/ingestion/languages/zig.ts index a15ca61e7..5c75322e8 100644 --- a/gitnexus/src/core/ingestion/languages/zig.ts +++ b/gitnexus/src/core/ingestion/languages/zig.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts index e0ca03d24..71aeb8ce1 100644 --- a/gitnexus/src/core/ingestion/languages/zig/captures.ts +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -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 = {…}` and may own methods. + * Single source for the class/field/method extractor configs. */ export const ZIG_CONTAINER_TYPES: ReadonlySet = 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; diff --git a/gitnexus/src/core/ingestion/languages/zig/query.ts b/gitnexus/src/core/ingestion/languages/zig/query.ts index cf0ee3ca5..29193f363 100644 --- a/gitnexus/src/core/ingestion/languages/zig/query.ts +++ b/gitnexus/src/core/ingestion/languages/zig/query.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts index 5044c68cd..77c7dd554 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts @@ -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) { diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index c06e0b764..681f69b53 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -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::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 diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 95a6a8814..0ec5398fc 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -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 = { 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 diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/zig.ts index caf00ed3b..06f355c3c 100644 --- a/gitnexus/src/core/ingestion/variable-extractors/configs/zig.ts +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/zig.ts @@ -1,6 +1,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { VariableExtractionConfig, VariableVisibility } from '../../variable-types.js'; import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { 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) { diff --git a/gitnexus/test/fixtures/lang-resolution/zig-basic/src/ffi.zig b/gitnexus/test/fixtures/lang-resolution/zig-basic/src/ffi.zig new file mode 100644 index 000000000..2339cb517 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-basic/src/ffi.zig @@ -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); +} diff --git a/gitnexus/test/fixtures/sample-code/simple.zig b/gitnexus/test/fixtures/sample-code/simple.zig index 9a3a2685b..401883780 100644 --- a/gitnexus/test/fixtures/sample-code/simple.zig +++ b/gitnexus/test/fixtures/sample-code/simple.zig @@ -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); +} diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index 0a499381d..bccc3eb21 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -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::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); + }); +}); diff --git a/gitnexus/test/integration/tree-sitter-languages.test.ts b/gitnexus/test/integration/tree-sitter-languages.test.ts index 3c4165c2d..5f1902a48 100644 --- a/gitnexus/test/integration/tree-sitter-languages.test.ts +++ b/gitnexus/test/integration/tree-sitter-languages.test.ts @@ -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. diff --git a/gitnexus/test/unit/zig-extractors.test.ts b/gitnexus/test/unit/zig-extractors.test.ts index afa2a0272..dfc02df17 100644 --- a/gitnexus/test/unit/zig-extractors.test.ts +++ b/gitnexus/test/unit/zig-extractors.test.ts @@ -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::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([]); + }); +});