diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/class-extractors/configs/zig.ts index dfa690117..6be4e411e 100644 --- a/gitnexus/src/core/ingestion/class-extractors/configs/zig.ts +++ b/gitnexus/src/core/ingestion/class-extractors/configs/zig.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/export-detection.ts b/gitnexus/src/core/ingestion/export-detection.ts index ab6900900..143358c6f 100644 --- a/gitnexus/src/core/ingestion/export-detection.ts +++ b/gitnexus/src/core/ingestion/export-detection.ts @@ -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; +} diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/field-extractors/configs/zig.ts index 10eafe078..b961c8318 100644 --- a/gitnexus/src/core/ingestion/field-extractors/configs/zig.ts +++ b/gitnexus/src/core/ingestion/field-extractors/configs/zig.ts @@ -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 / diff --git a/gitnexus/src/core/ingestion/languages/zig.ts b/gitnexus/src/core/ingestion/languages/zig.ts index 5c75322e8..f1b7d144c 100644 --- a/gitnexus/src/core/ingestion/languages/zig.ts +++ b/gitnexus/src/core/ingestion/languages/zig.ts @@ -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/.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'; diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts index 71aeb8ce1..d110c1472 100644 --- a/gitnexus/src/core/ingestion/languages/zig/captures.ts +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -15,25 +15,97 @@ export const ZIG_CONTAINER_TYPES: ReadonlySet = 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(); + const aliasDeclIds = new Set(); + 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 = {}; const nodeMap: Record = {}; @@ -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; diff --git a/gitnexus/src/core/ingestion/languages/zig/index.ts b/gitnexus/src/core/ingestion/languages/zig/index.ts index ad8348d6c..6face936c 100644 --- a/gitnexus/src/core/ingestion/languages/zig/index.ts +++ b/gitnexus/src/core/ingestion/languages/zig/index.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/languages/zig/interpret.ts b/gitnexus/src/core/ingestion/languages/zig/interpret.ts index 1b1bb3a6e..5efe199cf 100644 --- a/gitnexus/src/core/ingestion/languages/zig/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/zig/interpret.ts @@ -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 }; diff --git a/gitnexus/src/core/ingestion/languages/zig/query.ts b/gitnexus/src/core/ingestion/languages/zig/query.ts index 29193f363..000c9cbdf 100644 --- a/gitnexus/src/core/ingestion/languages/zig/query.ts +++ b/gitnexus/src/core/ingestion/languages/zig/query.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts index 780a4fafa..90098e5af 100644 --- a/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/languages/zig/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/zig/simple-hooks.ts index 199bc9c11..998b9d5ea 100644 --- a/gitnexus/src/core/ingestion/languages/zig/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/zig/simple-hooks.ts @@ -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(); + 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 diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts index 77c7dd554..4b20667c3 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts @@ -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) { diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 681f69b53..b5eb3b250 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -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 +; \` 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 diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 0ec5398fc..5332bb62f 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -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 ` 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 diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/zig.ts index 06f355c3c..c6e266c37 100644 --- a/gitnexus/src/core/ingestion/variable-extractors/configs/zig.ts +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/zig.ts @@ -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) { diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index bccc3eb21..f90062705 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -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/.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/.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); + }); +}); diff --git a/gitnexus/test/unit/zig-extractors.test.ts b/gitnexus/test/unit/zig-extractors.test.ts index dfc02df17..062241fc0 100644 --- a/gitnexus/test/unit/zig-extractors.test.ts +++ b/gitnexus/test/unit/zig-extractors.test.ts @@ -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' }, + ]); + }); +});