fix(zig): import every @import in expression position; resolve @import("x").f()

Both query sets only saw `@import` as the value of a const/var or under
`usingnamespace`, so an @import in any other position produced no file
edge: Lightpanda's `pub const Interfaces = .{ @import("a.zig"), … }`
registration table (288 modules), call arguments
(`CounterEnum("size", @import("ArenaPool.zig").BucketSize)`), comparison
operands (`JsApi == @import("x.zig").JsApi`) and member-call receivers
(`try @import("dump.zig").root(...)`) — 417 of 3,401 in-repo import pairs
had no IMPORTS edge, and the 80 inline-receiver calls resolved 0 times.

Scope query: a catch-all `@import.inline` rule matches every `@import`
builtin; `emitZigScopeCaptures` drops the ones a binding rule (or the
keyword-less side-effect rule) already claimed (by string-node id) so a
bound import is never doubled, emits the rest as side-effect imports once
per distinct source per file, and binds a member-call receiver as a
namespace import whose local name is the builtin's own text — the
`@reference.receiver` text on that call is identical, so the shared
namespace-receiver lookup (Case 1) resolves the member in the imported
module.

ZIG_QUERIES: the three variable_declaration/usingnamespace-anchored
`@import` rules collapse into the same single builtin rule (the structure
phase only skips import matches; one match per builtin keeps
tree-sitter-languages' exact-capture assertion intact).

Lightpanda corpus (zig-corpus-check, before → after): IMPORTS 3014 → 3426,
in-repo pairs missing 417 → 5 (4 under a default-ignored `cache/` dir, 1 a
commented-out import the census regex counts), `@import(..).f()` 0/80 →
72/80 (the 8 left are `@import("root")` and non-import builtins the census
mislabels), CALLS 13885 → 13959; every other line unchanged.
This commit is contained in:
Navid EMAD 2026-08-18 14:10:33 +02:00
parent 2a0cfe6726
commit 606fb07612
No known key found for this signature in database
10 changed files with 247 additions and 26 deletions

View file

@ -31,6 +31,19 @@ export function zigImportRootOf(value: SyntaxNode | null): SyntaxNode | null {
return isZigImportBuiltin(cur) ? cur : null;
}
/** Is this `@import(…)` builtin the receiver of a member call —
* `@import("dump.zig").root(...)` — i.e. the `object` of a `field_expression`
* that is the `function` of a `call_expression`? A deeper chain
* (`@import("x.zig").Foo.init()`) is not: its receiver is `….Foo`, and the
* builtin is only the module the chain starts from. */
export function isZigInlineImportReceiver(importNode: SyntaxNode): boolean {
const field = importNode.parent;
if (field?.type !== 'field_expression') return false;
if (field.childForFieldName('object')?.id !== importNode.id) return false;
const call = field.parent;
return call?.type === 'call_expression' && call.childForFieldName('function')?.id === field.id;
}
/** Is this variable_declaration a container binding (`const T = struct {…}`)
* or an import binding (`const x = @import("…")`, `const X = @import("…").X`)?
* Those groups are emitted by their dedicated query rules; the plain
@ -197,11 +210,27 @@ export function emitZigScopeCaptures(
// would outrank the import binding it stands for).
const importSources = new Map<string, SyntaxNode>();
const aliasDeclIds = new Set<number>();
// The `@import(…)` string nodes a BINDING rule (or the keyword-less
// side-effect rule) matched, by node id, plus their texts. The catch-all
// `@import.inline` rule matches those same builtins again; the id set
// keeps a bound import from being doubled, the text set keeps a second
// spelling of an already-imported file from adding a redundant edge.
const claimedImportSourceIds = new Set<number>();
const importedSourceTexts = new Set<string>();
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');
const importStmt = byName.get('import.statement');
if (
importSource !== undefined &&
(importStmt !== undefined ||
byName.get('import.side-effect') !== undefined ||
byName.get('import.wildcard') !== undefined)
) {
claimedImportSourceIds.add(importSource.id);
importedSourceTexts.add(importSource.text);
}
if (
importName !== undefined &&
importSource !== undefined &&
@ -250,6 +279,43 @@ export function emitZigScopeCaptures(
continue;
}
// `@import("…")` in expression position — a tuple element, a call
// argument, a comparison operand, a member-call receiver, a chain deeper
// than the binding rules follow. Skip the builtins a binding rule (or the
// keyword-less side-effect rule) already owns; the rest are file
// dependencies without a name. The receiver of a member call
// (`try @import("dump.zig").root(...)`) is more: it is a namespace used
// in place. Bind it as a namespace import whose local name IS the
// builtin's own text — `@reference.receiver` on that call carries the
// same text, so the shared Case-1 namespace-receiver lookup resolves
// `root` in dump.zig exactly as it would for `const dump =
// @import("dump.zig"); dump.root(...)`. Anything else is a side-effect
// import (file edge only), emitted once per distinct source per file.
const inlineImport = nodeMap['@import.inline'];
if (inlineImport !== undefined) {
const source = nodeMap['@import.source']!;
if (claimedImportSourceIds.has(source.id)) continue;
const sourceCapture = grouped['@import.source']!;
if (isZigInlineImportReceiver(inlineImport)) {
const key = `receiver:${source.text}`;
if (importedSourceTexts.has(key)) continue;
importedSourceTexts.add(key);
out.push({
'@import.statement': nodeToCapture('@import.statement', inlineImport),
'@import.name': nodeToCapture('@import.name', inlineImport),
'@import.source': sourceCapture,
});
continue;
}
if (importedSourceTexts.has(source.text)) continue;
importedSourceTexts.add(source.text);
out.push({
'@import.side-effect': nodeToCapture('@import.side-effect', inlineImport),
'@import.source': sourceCapture,
});
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).

View file

@ -23,7 +23,14 @@ export function zigModuleNameOf(targetRaw: string): string {
* `_ = @import("x.zig");` (and any keyword-less `<ident> = @import(…)`, a
* statement rather than a declaration in this grammar) references the file
* without binding a name — the `refAllDecls` / test-aggregation idiom. That
* is a `side-effect` import: file edge, no binding (TS `import './x'`).
* is a `side-effect` import: file edge, no binding (TS `import './x'`). So is
* an `@import` in any expression position (a tuple element, a call argument,
* a comparison operand — `emitZigScopeCaptures`'s `@import.inline` rule).
*
* The receiver of a member call, `@import("dump.zig").root(...)`, arrives as
* a namespace import whose `@import.name` is the builtin's own text: the
* shared namespace-receiver lookup keys on the receiver text, and that is
* how the call resolves into `dump.zig` without a `const` handle.
*/
export function interpretZigImport(captures: CaptureMatch): ParsedImport | null {
const source = captures['@import.source']?.text;

View file

@ -197,6 +197,24 @@ const ZIG_SCOPE_QUERY = `
(arguments (string) @import.source))
(#eq? @_builtin "@import")) @import.wildcard
;; Imports — \`@import("...")\` in ANY other position: a tuple element
;; (\`pub const Interfaces = .{ @import("a.zig"), @import("b.zig") }\`, the
;; JS-API registration table), a call argument (\`event.is(@import("x.zig"))\`),
;; a comparison operand (\`T == @import("x.zig").T\`), the receiver of a
;; member call (\`try @import("dump.zig").root(...)\`), a 3-deep member chain…
;; Every one of them is a file dependency; only the const/var/usingnamespace
;; shapes above bind a name. This rule matches EVERY \`@import\` builtin, the
;; bound shapes included — \`emitZigScopeCaptures\` drops the matches whose
;; string node a binding rule (or the keyword-less side-effect rule) already
;; claimed, so a bound import is never doubled, and emits the rest as
;; side-effect imports (file edge, no binding) — except the member-call
;; receiver, which becomes a namespace import keyed by its own source text so
;; the call resolves into the imported module (see the emitter).
((builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
(#eq? @_builtin "@import")) @import.inline
;; Type bindings — parameter annotations (incl. self: *T receivers)
(parameter
name: (identifier) @type-binding.name

View file

@ -2540,31 +2540,22 @@ export const ZIG_QUERIES = `
(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).
(variable_declaration
(builtin_function
(builtin_identifier) @builtin
(arguments
(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))
; @import("path") — capture the string argument as @import.source, in
; EVERY position: the value of a const/var (\`const std = @import("std")\`),
; a member chain (\`const X = @import("x.zig").X\`), \`pub usingnamespace
; @import("path")\`, a tuple element (\`pub const Interfaces = .{
; @import("a.zig"), @import("b.zig") }\`), a call argument, a comparison
; operand, the receiver of a member call (\`try @import("dump.zig").root(...)\`).
; Zig has no import statement — the builtin IS the import, wherever it sits,
; and every occurrence is a file dependency. The #eq? predicate keeps the
; other builtins (@sizeOf, @TypeOf, @as, …) out. One rule, one match per
; builtin: the structure phase only skips import matches (IMPORTS edges come
; from the scope phase — \`emitZigScopeCaptures\`, whose \`@import.inline\`
; rule is this rule's twin, decides which occurrences bind a name).
((builtin_function
(builtin_identifier) @builtin
(arguments
(string) @import.source))
(#eq? @builtin "@import")) @import
; Free calls: foo(...)

View file

@ -0,0 +1,4 @@
// Only reached through an inline receiver: `@import("dump.zig").root(…)`.
pub fn root(depth: u32) void {
_ = depth;
}

View file

@ -15,6 +15,13 @@ const idioms = @import("idioms");
const build_config = @import("build_config");
// Removed from the language in 0.15, still everywhere in 0.11–0.14 code.
pub usingnamespace @import("mixin.zig");
// @import in EXPRESSION position — the JS-API registration table idiom
// (Lightpanda's bridge.zig lists ~290 modules this way). No name is bound;
// each element is still a file dependency.
pub const Interfaces = .{
@import("webapi/AbortController.zig"),
@import("webapi/AbortSignal.zig"),
};
pub fn main() void {
// call-return inference: the receiver of the constructor call names the type
@ -45,6 +52,9 @@ pub fn main() void {
var arena = idioms.Arena{};
arena.reset();
_ = build_config.version;
// inline import as a member-call receiver: no `const dump = @import(...)`
// handle, the module is used in place.
@import("dump.zig").root(2);
// statement assignments share the variable_declaration node type with
// declarations in tree-sitter-zig 1.1.2 — none of these is a binding.
counter.global_count = 5;

View file

@ -0,0 +1,8 @@
// A JS-API module: only ever referenced from the registration table in
// main.zig (`Interfaces = .{ @import("webapi/AbortController.zig"), … }`),
// never bound to a `const`.
pub const JsApi = struct {
pub const bridge = struct {
pub const name = "AbortController";
};
};

View file

@ -0,0 +1,6 @@
// Second entry of the registration table in main.zig.
pub const JsApi = struct {
pub const bridge = struct {
pub const name = "AbortSignal";
};
};

View file

@ -264,6 +264,26 @@ describe.skipIf(!zigAvailable)('Zig idioms (zig-idioms fixture)', () => {
expect(calls).toContain('main → push');
});
it('imports every file behind an `@import` in EXPRESSION position (the `Interfaces = .{ @import(…), … }` table)', () => {
// Both query sets only matched `@import` as the value of a const/var or
// under `usingnamespace`, so a registration table of inline imports
// (Lightpanda's bridge.zig: ~290 modules) produced NO file edges — the
// modules looked unreferenced. Neither element binds a name; each is
// still a dependency of main.zig.
expect(imports).toContain('main.zig → src/webapi/AbortController.zig');
expect(imports).toContain('main.zig → src/webapi/AbortSignal.zig');
// and it mints no Const for the tuple elements — only for the table
expect(getNodesByLabel(result, 'Const')).toContain('Interfaces');
});
it('resolves a member call whose receiver is an inline import (`@import("dump.zig").root(…)`)', () => {
// The receiver text is the builtin itself, not a `const` handle; the
// inline import is bound as a namespace import under that very text so
// the shared namespace-receiver lookup lands in dump.zig.
expect(imports).toContain('main.zig → src/dump.zig');
expect(calls).toContain('main → root');
});
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/<name>.zig and src/main.zig, so every such dep was unresolved.

View file

@ -522,6 +522,97 @@ test {
expect(vars).toEqual(['notAnImport']);
});
it('emits a side-effect import for every @import in EXPRESSION position, once per source, never doubling a bound one', () => {
// Both query sets only saw `@import` as the value of a const/var (or
// under `usingnamespace`). Lightpanda's `Interfaces = .{ @import(…), … }`
// table (288 modules), `CounterEnum("size", @import("ArenaPool.zig").BucketSize)`
// (call argument), `event.is(@import("x.zig"))` and
// `JsApi == @import("x.zig").JsApi` (comparison) all produced NO file
// edge: 487 of 3,471 in-repo import pairs missing. Each is a dependency
// without a name — a side-effect import — and the same file spelled
// twice, or spelled inline AND bound to a const, gets one edge, not two.
const src = `
const std = @import("std");
const c = @import("counter.zig");
pub const Interfaces = .{ @import("a.zig"), @import("b.zig"), @import("a.zig") };
const size = CounterEnum("size", @import("ArenaPool.zig").BucketSize);
pub fn f() void {
if (event.is(@import("event/MouseEvent.zig"))) {}
if (JsApi == @import("cdata/Text.zig").JsApi) {}
_ = @import("counter.zig").Extra;
_ = std.mem;
}
`;
const imports = emitZigScopeCaptures(src, 'x.zig')
.filter((m) => m['@import.source'] !== undefined)
.map((m) => interpretZigImport(m));
expect(imports).toEqual([
{ kind: 'namespace', localName: 'std', importedName: 'std', targetRaw: 'std' },
{ kind: 'namespace', localName: 'c', importedName: 'counter', targetRaw: 'counter.zig' },
{ kind: 'side-effect', targetRaw: 'a.zig' },
{ kind: 'side-effect', targetRaw: 'b.zig' },
{ kind: 'side-effect', targetRaw: 'ArenaPool.zig' },
{ kind: 'side-effect', targetRaw: 'event/MouseEvent.zig' },
{ kind: 'side-effect', targetRaw: 'cdata/Text.zig' },
// `@import("counter.zig").Extra` in a discard: counter.zig is already
// bound above (`const c = …`) — no second edge, and no phantom binding.
]);
// The tuple binds `Interfaces` as an ordinary Const (its value is a
// struct literal, not an import); the discard binds nothing.
const vars = emitZigScopeCaptures(src, 'x.zig')
.filter((m) => m['@declaration.variable'] !== undefined)
.map((m) => m['@declaration.name']?.text);
expect(vars).toEqual(['Interfaces', 'size']);
});
it('binds an inline import used as a member-call receiver under its own text, so `@import("dump.zig").root()` resolves', () => {
// `try @import("dump.zig").root(…)` (80 sites in Lightpanda, 0 resolved):
// the receiver is the builtin, not a const handle. Emitting a namespace
// import whose local name IS the receiver text lets the shared
// namespace-receiver lookup (`namespaceTargets.get(receiverText)`) find
// dump.zig. One binding per distinct source; a deeper chain
// (`@import("x.zig").Foo.init()`) is not a module receiver and stays a
// plain side-effect import.
const src = `
pub fn f() !void {
try @import("dump.zig").root(1);
@import("dump.zig").other();
_ = @import("../id.zig").uuidv4(&buf);
_ = @import("x.zig").Foo.init();
}
`;
const groups = emitZigScopeCaptures(src, 'x.zig');
const imports = groups
.filter((m) => m['@import.source'] !== undefined)
.map((m) => interpretZigImport(m));
expect(imports).toEqual([
{
kind: 'namespace',
localName: '@import("dump.zig")',
importedName: 'dump',
targetRaw: 'dump.zig',
},
{
kind: 'namespace',
localName: '@import("../id.zig")',
importedName: 'id',
targetRaw: '../id.zig',
},
{ kind: 'side-effect', targetRaw: 'x.zig' },
]);
// The receiver capture on the call carries exactly the binding's text —
// that identity is what makes the lookup succeed.
const receivers = groups
.filter((m) => m['@reference.call.member'] !== undefined)
.map((m) => m['@reference.receiver']?.text);
expect(receivers).toEqual([
'@import("dump.zig")',
'@import("dump.zig")',
'@import("../id.zig")',
'@import("x.zig").Foo',
]);
});
it('a function-scoped @import is not deferred: Zig imports are compile-time (importsExecuteWhereWritten: false)', () => {
// C `#include` and Rust `use` answer the same. Without the flag the scope
// extractor marks a body-level `@import` `runsOnlyWhenCalled`, hiding a