diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts index 59507b6bb..35aa878e9 100644 --- a/gitnexus/src/core/ingestion/languages/zig/captures.ts +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -3,6 +3,7 @@ import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; import { getZigParser, getZigScopeQuery } from './query.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; export const ZIG_CONTAINER_TYPES: ReadonlySet = new Set([ 'struct_declaration', @@ -43,6 +44,51 @@ export function isZigContainerMethod( return false; } +/** + * Callable-value-flow vocabulary for tree-sitter-zig 1.1.2 (verified by AST + * dump). Two grammar quirks drive the callbacks: + * + * - `variable_declaration` is FIELDLESS apart from `type:` — `const f = target;` + * is `(variable_declaration (identifier) (identifier))`, and a bare + * re-assignment statement `f = target;` parses to the SAME node shape. The + * shared `left`/`name`/`value` fallback decomposes nothing, so + * `extractAssignment` pairs first-identifier → last-child positionally. + * `assignment_expression` (`self.f = target`) carries real `left`/`right` + * fields and is left to the shared path. + * - `call_expression` has NO argument-list wrapper: `invoke(second)` is + * `(call_expression function: (identifier) (identifier))`. Arguments are + * every named child other than the `function` field, hence + * `extractCallArguments`. + * + * `builtin_function` (`@import`, `@sizeOf`, …) is deliberately not a call node: + * builtins never take user callables as flow arguments. + */ +const ZIG_CALLABLE_CAPTURE_OPTIONS = { + functionNodeTypes: new Set(['function_declaration']), + callNodeTypes: new Set(['call_expression']), + parameterListNodeTypes: new Set(['parameters']), + parameterNodeTypes: new Set(['parameter']), + bindingNodeTypes: new Set(['variable_declaration']), + assignmentNodeTypes: new Set(['assignment_expression']), + identifierNodeTypes: new Set(['identifier']), + extractAssignment: (node: SyntaxNode) => { + if (node.type !== 'variable_declaration') return undefined; + const named = node.namedChildren.filter((child): child is SyntaxNode => child !== null); + if (named.length < 2 || named[0]!.type !== 'identifier') return undefined; + const source = named[named.length - 1]!; + // `const x: T;` (extern) — the trailing child is the type, not a value. + if (source.id === node.childForFieldName('type')?.id) return undefined; + return { destination: named[0]!, source }; + }, + extractCallArguments: (call: SyntaxNode) => { + const callee = call.childForFieldName('function'); + return call.namedChildren.filter( + (child): child is SyntaxNode => + child !== null && child.id !== callee?.id && child.type !== 'comment', + ); + }, +} as const; + export function emitZigScopeCaptures( sourceText: string, _filePath: string, @@ -88,5 +134,7 @@ export function emitZigScopeCaptures( out.push(grouped); } + out.push(...synthesizeCallableFlowCaptures(tree.rootNode, ZIG_CALLABLE_CAPTURE_OPTIONS)); + return out; } diff --git a/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts b/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts index 4ca872ec4..b7260a3d9 100644 --- a/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts +++ b/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts @@ -120,6 +120,15 @@ export interface CallableFlowCaptureOptions { | readonly { readonly destination: SyntaxNode; readonly source: SyntaxNode }[] | undefined; readonly extractFunctionParameters?: (node: SyntaxNode) => readonly SyntaxNode[] | undefined; + /** + * Provider-owned call-argument extraction, for grammars whose call node + * carries its arguments as DIRECT children with no argument-list wrapper + * (tree-sitter-zig's `call_expression`). Without a wrapper node the shared + * `arguments`/`parameterListNodeTypes` lookup finds nothing, so every + * `argument` fact is lost. Returning `undefined` falls back to the shared + * path (mirrors `extractFunctionParameters`). + */ + readonly extractCallArguments?: (call: SyntaxNode) => readonly SyntaxNode[] | undefined; readonly extractCallCallee?: (node: SyntaxNode) => SyntaxNode | undefined; readonly isCallNode?: (node: SyntaxNode) => boolean; /** @@ -891,6 +900,8 @@ function callArguments( call: SyntaxNode, options: CallableFlowCaptureOptions, ): readonly SyntaxNode[] { + const providerArguments = options.extractCallArguments?.(call); + if (providerArguments !== undefined) return providerArguments; const list = call.childForFieldName('arguments') ?? call.childForFieldName('argument') ?? diff --git a/gitnexus/test/integration/resolvers/callable-value-flow.test.ts b/gitnexus/test/integration/resolvers/callable-value-flow.test.ts index 6605c8e4d..820ece290 100644 --- a/gitnexus/test/integration/resolvers/callable-value-flow.test.ts +++ b/gitnexus/test/integration/resolvers/callable-value-flow.test.ts @@ -33,6 +33,7 @@ const CALLABLE_FLOW_PROVIDER_COVERAGE = { [SupportedLanguages.Dart]: 'matrix', [SupportedLanguages.Vue]: 'matrix', [SupportedLanguages.Cobol]: 'matrix', + [SupportedLanguages.Zig]: 'matrix', } as const satisfies Record; const PROVIDER_FLOW_CASES = [ @@ -249,6 +250,21 @@ PROGRAM-ID. TARGET. PROCEDURE DIVISION. GOBACK. END PROGRAM TARGET. +`, + }, + { + language: SupportedLanguages.Zig, + extension: 'zig', + caller: 'invoke', + target: 'target', + source: ` +fn target() void {} +fn invoke(callback: *const fn () void) void { callback(); } +pub fn main() void { + const first = target; + const second = first; + invoke(second); +} `, }, ] as const; diff --git a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts index 870a81bf4..fe6ecc2bd 100644 --- a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts +++ b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts @@ -362,6 +362,22 @@ const CASES: ReadonlyMap = new Map([ reachesDecoy: 'CUSTREC', }, ], + [ + SupportedLanguages.Zig, + { + // `@import("std")` is the standard library, and Zig's resolver answers + // null for the stdlib names outright — it never suffix-matches a bare + // name against the file set, so a repo file that happens to be called + // `std.zig` is not a candidate. The same file IS reachable through the + // filesystem-relative spelling, which is what the decoy arm proves. + files: ['src/std.zig', 'src/util.zig', 'src/main.zig'], + fromFile: 'src/main.zig', + resolutionConfig: undefined, + external: 'std', + decoy: 'src/std.zig', + reachesDecoy: 'std.zig', + }, + ], ]); /** diff --git a/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts b/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts index 8f1243566..2e0de5ae2 100644 --- a/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts +++ b/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts @@ -528,6 +528,25 @@ const FIXTURES: ReadonlyMap = new Map< minimumParsedFileReads: 0, }, ], + [ + SupportedLanguages.Zig, + { + files: ['src/util.zig', 'src/main.zig'], + fromFile: 'src/main.zig', + resolutionConfig: undefined, + // A relative `@import` that names no file: the resolver walks the path + // arithmetically from `fromFile` and probes the Set with two `has` calls + // (`` and `.zig`) — membership probes only, no + // traversal, the same shape as Rust. Bare names go through the + // build.zig.zon `.path` map (a Map lookup) and never touch the Set. + missTarget: (i) => `ghost${i}.zig`, + hitTarget: 'util.zig', + parsedImport: IGNORES_CONTEXT, + // See `minimumScans` on the interface: membership probes only. + minimumScans: 0, + minimumParsedFileReads: 0, + }, + ], ]); /**