From 606fb07612bb1f0ed5d88427a4c55907c50dd098 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Tue, 18 Aug 2026 14:10:33 +0200 Subject: [PATCH] fix(zig): import every @import in expression position; resolve @import("x").f() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../core/ingestion/languages/zig/captures.ts | 66 ++++++++++++++ .../core/ingestion/languages/zig/interpret.ts | 9 +- .../src/core/ingestion/languages/zig/query.ts | 18 ++++ .../src/core/ingestion/tree-sitter-queries.ts | 41 ++++----- .../lang-resolution/zig-idioms/src/dump.zig | 4 + .../lang-resolution/zig-idioms/src/main.zig | 10 ++ .../zig-idioms/src/webapi/AbortController.zig | 8 ++ .../zig-idioms/src/webapi/AbortSignal.zig | 6 ++ .../test/integration/resolvers/zig.test.ts | 20 ++++ gitnexus/test/unit/zig-extractors.test.ts | 91 +++++++++++++++++++ 10 files changed, 247 insertions(+), 26 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/dump.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/AbortController.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/AbortSignal.zig diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts index 543f24786..bf248e5a7 100644 --- a/gitnexus/src/core/ingestion/languages/zig/captures.ts +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -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(); const aliasDeclIds = new Set(); + // 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(); + const importedSourceTexts = 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'); 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). diff --git a/gitnexus/src/core/ingestion/languages/zig/interpret.ts b/gitnexus/src/core/ingestion/languages/zig/interpret.ts index a173d538f..07901b4e4 100644 --- a/gitnexus/src/core/ingestion/languages/zig/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/zig/interpret.ts @@ -23,7 +23,14 @@ export function zigModuleNameOf(targetRaw: string): string { * `_ = @import("x.zig");` (and any keyword-less ` = @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; diff --git a/gitnexus/src/core/ingestion/languages/zig/query.ts b/gitnexus/src/core/ingestion/languages/zig/query.ts index 4a3386bd6..bde1329b5 100644 --- a/gitnexus/src/core/ingestion/languages/zig/query.ts +++ b/gitnexus/src/core/ingestion/languages/zig/query.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index b5eb3b250..caabde39f 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -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(...) diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/dump.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/dump.zig new file mode 100644 index 000000000..15188dc7a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/dump.zig @@ -0,0 +1,4 @@ +// Only reached through an inline receiver: `@import("dump.zig").root(…)`. +pub fn root(depth: u32) void { + _ = depth; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig index e0f46eac1..1cb77e2ee 100644 --- a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig @@ -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; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/AbortController.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/AbortController.zig new file mode 100644 index 000000000..e3526345d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/AbortController.zig @@ -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"; + }; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/AbortSignal.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/AbortSignal.zig new file mode 100644 index 000000000..d96e776ad --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/AbortSignal.zig @@ -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"; + }; +}; diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index 6d2d32007..1a20a0907 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -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/.zig and src/main.zig, so every such dep was unresolved. diff --git a/gitnexus/test/unit/zig-extractors.test.ts b/gitnexus/test/unit/zig-extractors.test.ts index 8c72d4283..f10e5267e 100644 --- a/gitnexus/test/unit/zig-extractors.test.ts +++ b/gitnexus/test/unit/zig-extractors.test.ts @@ -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