diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts index df756bd59..46e2f8440 100644 --- a/gitnexus/src/core/ingestion/languages/zig/captures.ts +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -4,6 +4,7 @@ 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'; +import { hasZigPubKeyword } from '../../export-detection.js'; /** Zig container node types: `struct`, `enum`, `union` and the fieldless * `opaque` all bind through `const T = {…}` and may own methods. @@ -131,6 +132,17 @@ export function isZigFileThisAlias(declNode: SyntaxNode): boolean { ); } +/** Does this module-level import binding REPUBLISH the name? `pub const Arena + * = @import("Arena.zig");` at file scope makes `Arena` part of this file's + * public surface: a third file reaches it as `lp.Arena` (Lightpanda's + * `lightpanda.zig` is one long list of these). Same fact as a Python + * `__init__.py` `from .impl import X` — the shared contract's + * `reexportsName` flag. Only `pub` at container level publishes; a `const` + * inside a fn body binds locally. */ +export function isZigPublishingImport(declNode: SyntaxNode): boolean { + return declNode.parent?.type === 'source_file' && hasZigPubKeyword(declNode); +} + /** 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 @@ -204,6 +216,14 @@ export function isZigContainerMethod( return false; } +/** `{ '@import.reexports': 'true' }` when `stmt` is a publishing import + * (see `isZigPublishingImport`), else nothing — spread into an import group. */ +function republishMarker(stmt: SyntaxNode): Record { + return isZigPublishingImport(stmt) + ? { '@import.reexports': syntheticCapture('@import.reexports', stmt, 'true') } + : {}; +} + /** Every `const X = @This();` in the tree, keyed by the alias node id's owner: * returns a map from the DECLARING container node id to `[aliasName, * containerName]`. The file-level alias maps to the file-struct name (only @@ -499,6 +519,7 @@ export function emitZigScopeCaptures( '@import.name': nodeToCapture('@import.name', nodeMap['@alias.name']!), '@import.imported': nodeToCapture('@import.imported', nodeMap['@alias.member']!), '@import.source': nodeToCapture('@import.source', source), + ...republishMarker(aliasStmt), }); continue; } @@ -592,6 +613,14 @@ export function emitZigScopeCaptures( grouped['@declaration.method'] = { ...fnCapture, name: '@declaration.method' }; } + // `pub const X = @import("x.zig").X;` at file scope republishes `X`. + if ( + nodeMap['@import.statement'] !== undefined && + nodeMap['@import.imported'] !== undefined && + isZigPublishingImport(nodeMap['@import.statement']) + ) { + Object.assign(grouped, republishMarker(nodeMap['@import.statement'])); + } out.push(grouped); // F5 — field types. `session: *Session,` declares the TYPE of the member @@ -658,6 +687,9 @@ export function emitZigScopeCaptures( nodeMap['@import.name'], zigFileStructName(source), ), + // `pub const Arena = @import("Arena.zig");` republishes the TYPE too: + // `lp.Arena` in a third file is the Struct `Arena.zig` declares. + ...republishMarker(importStmt), }); } } diff --git a/gitnexus/src/core/ingestion/languages/zig/interpret.ts b/gitnexus/src/core/ingestion/languages/zig/interpret.ts index 8985b17b1..099ade67b 100644 --- a/gitnexus/src/core/ingestion/languages/zig/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/zig/interpret.ts @@ -57,9 +57,21 @@ export function interpretZigImport(captures: CaptureMatch): ParsedImport | null // `import { Foo as Bar }`. const imported = captures['@import.imported']?.text; if (imported !== undefined) { + // `pub const X = …` at file scope republishes the name (Python + // `__init__.py` shape): a third file reads it as `thisModule.X`. The + // marker is set by `emitZigScopeCaptures` where the syntax node is + // still available (`isZigPublishingImport`). + const republish = captures['@import.reexports'] !== undefined ? { reexportsName: true } : {}; return imported === name - ? { kind: 'named', localName: name, importedName: imported, targetRaw } - : { kind: 'alias', localName: name, importedName: imported, alias: name, targetRaw }; + ? { kind: 'named', localName: name, importedName: imported, targetRaw, ...republish } + : { + kind: 'alias', + localName: name, + importedName: imported, + alias: name, + targetRaw, + ...republish, + }; } return { diff --git a/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/lp.zig b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/lp.zig new file mode 100644 index 000000000..a89747b17 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/lp.zig @@ -0,0 +1,5 @@ +// A re-export hub (Lightpanda `lightpanda.zig`): `pub const X = @import(...)` +// at file scope republishes X, so a third file reaches the TYPE as `lp.X`. +pub const Page = @import("Page.zig"); +pub const Session = @import("Session.zig"); +const private_util = @import("util.zig"); diff --git a/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/main.zig index 35333eab9..629ebb35e 100644 --- a/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/main.zig +++ b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/main.zig @@ -24,3 +24,10 @@ pub fn main() void { fn useParam(page: *Page) void { _ = page.getArena(); } + +const lp = @import("lp.zig"); +// The type behind a re-exported name, taken through the hub. +const PageViaHub = lp.Page; +fn viaHubAlias(page: *PageViaHub) void { + _ = page.getArena(); +} diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index 8a942a469..a6c78d45e 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -449,6 +449,14 @@ describe.skipIf(!zigAvailable)('Zig file-structs (zig-filestruct fixture)', () = expect(calls).toContain('bump → getArena'); }); + it('republishes a `pub const X = @import("X.zig")` so a third file reaches the type through the hub', () => { + // Lightpanda's `lightpanda.zig` is one long list of `pub const X = + // @import("...")`; `const lp = @import("lightpanda"); const Arena = + // lp.Arena;` is how most files name their types. The re-export must + // publish the TYPE (the file-struct), not just the module. + expect(edgeSet(getRelationships(result, 'CALLS'))).toContain('viaHubAlias → getArena'); + }); + it('keeps the file-struct type reachable through the namespace import binding', () => { // `const Page = @import("Page.zig")` binds both the module (`Page.init`) // and the type it declares. Two Struct defs named `Page` in different diff --git a/gitnexus/test/unit/zig-extractors.test.ts b/gitnexus/test/unit/zig-extractors.test.ts index efec863cd..a4ad240df 100644 --- a/gitnexus/test/unit/zig-extractors.test.ts +++ b/gitnexus/test/unit/zig-extractors.test.ts @@ -947,6 +947,36 @@ pub const Inner = struct { ]); }); + it('flags file-level `pub` import bindings as republishing their name (reexportsName)', () => { + // `pub const Arena = @import("Arena.zig");` / `pub const X = @import("x.zig").X;` + // at file scope publish the name from THIS module (Python `__init__.py` + // shape); a private or fn-local binding does not. + const src = ` +pub const Arena = @import("Arena.zig"); +pub const Foo = @import("foo.zig").Foo; +const hidden = @import("hidden.zig"); +const ns = @import("ns.zig"); +pub const Bar = ns.Bar; +fn f() void { + const Local = @import("local.zig").Local; + _ = Local; +} +`; + const imports = emitZigScopeCaptures(src, 'lp.zig') + .filter((m) => m['@import.source'] !== undefined) + .map((m) => interpretZigImport(m)) + .filter((i) => i !== null && (i.kind === 'named' || i.kind === 'alias')) + .map((i) => [i!.localName, (i as { reexportsName?: boolean }).reexportsName === true]); + expect(imports).toEqual([ + ['Arena', true], // the file-struct TYPE twin of a pub namespace import + ['Foo', true], + ['hidden', false], // no `pub` + ['ns', false], + ['Bar', true], // pub alias of a namespace member + ['Local', false], // fn-local: binds locally, publishes nothing + ]); + }); + it('gives a namespace import of a .zig file a TYPE twin (named import of the file stem)', () => { const src = ` const Page = @import("Page.zig");