fix(zig): pub const X = @import(…) at file scope republishes X (reexportsName)

Lightpanda's `lightpanda.zig` is one long list of `pub const Arena =
@import("Arena.zig");`, and most files name their types through it (`const
lp = @import("lightpanda"); const Arena = lp.Arena;`, `arena: *lp.Arena`).
The scope side treated those bindings as plain imports of the hub file, so
the hub never published the names it re-exports and a third file's `const
Arena = lp.Arena;` (promoted to a named import of `Arena` from the hub) found
nothing.

`emitZigScopeCaptures` now marks named/alias import groups whose declaration
is a file-level `pub const` — the `@import(...).X` form, the alias promotion
`pub const Bar = ns.Bar`, and the file-struct type twin of `pub const Arena =
@import("Arena.zig")` — and `interpretZigImport` sets the shared contract's
`reexportsName: true` on them (the Python `__init__.py` shape, consumed by
`buildReexportClosures`). Private and fn-local bindings stay unflagged.

Not covered here: a receiver ANNOTATED with the dotted hub path (`arena:
*lp.Arena`) — Case 3 of the receiver-bound pass looks the member up with
`findExportedDef`, which only sees locally declared names; following
re-exports there is a shared change left for a follow-up.
This commit is contained in:
Navid EMAD 2026-08-18 14:42:07 +02:00
parent 81a6b62890
commit 85df4c1d3b
No known key found for this signature in database
6 changed files with 96 additions and 2 deletions

View file

@ -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 = <container> {…}` 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<string, Capture> {
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),
});
}
}

View file

@ -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 {

View file

@ -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");

View file

@ -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();
}

View file

@ -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

View file

@ -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");