fix(zig): type receivers through const X = <type expr>; aliases (F7)

`const LocalAlias = Local;`, `const T2 = Thing;` (alias of an alias/import)
and `const B = util.List(u8);` (an INSTANTIATED generic type constructor)
were plain `@declaration.variable` bindings, so `LocalAlias.mk()`,
`var l = LocalAlias.mk(); l.go()`, `T2.make()`, `B.init()`, `B{}` and
`var x: B` all typed nothing (review repro r3-flow b1..b9; Lightpanda:
`pub const Proto = HtmlElement;` x68, `const Allocator = std.mem.Allocator`
x104, `pub const KeyIterator = GenericIterator(...)`, fn-local
`const R = ...(...)`).

Model: a `@type-binding.alias` binding of the alias NAME to the value's type
text — Rust's `let x = y` / JS's `const B = Foo`, source
'assignment-inferred' — NOT a TypeAlias def. Reasons: (1) the shared
machinery already chains typeBindings (`followChainedRef` in the extractor,
`followChainPostFinalize` after propagation), so `var l = LocalAlias.mk()`
and `var x: B` reach the target through the alias with no new shared code;
(2) nothing shared follows a `TypeAlias` def to its target — `isShapeLike`
only makes the alias itself a member owner (TS object-type aliases) — so a
relabel would have needed language-named shared code; (3) graph node ids
are UNCHANGED: every alias stays `Const:<file>:X`. `normalizeZigTypeName`
already drops the comptime arguments, so `util.List(u8)` binds `util.List`
and resolves through the namespace import (Case 3). The identifier /
member shapes also take `var` (`var cur = orig; cur.go()` — the cursor
idiom, same binding as Rust's `let x = y`).

Heuristic, stated as such: a CALL value is kept only when the callee's last
identifier is TitleCase (Zig's naming convention for types), because the
grammar cannot tell `util.List(u8)` from `util.makeThing()` and the latter
belongs to the call-return rules; the call-return group is dropped for the
same TitleCase shape so the two never race on match order. Import bindings
(`const Stack = @import("x.zig").Stack`), promoted namespace-member aliases,
enum/decl literals (`.foo`) and the `type:` annotation of
`var b: T = undefined;` are excluded.

Not done: the two-hop `pub const bridge = js.Bridge(T); bridge.accessor()`
chain. `js.Bridge` is a Function that RETURNS `bridge.Builder(T)` (a call,
not a container), so the alias binds `js.Bridge`, Case 3 finds a Function
with no members in js.zig, and Case 3b is skipped for a namespace head.
Following that hop needs a namespace-member return-type route in shared
code (or a Zig `resolveQualifiedReceiverMember` hook that re-implements
member lookup without the model); left for a follow-up.

Corpus (Lightpanda, `harness/zig-corpus-check.mjs`): CALLS 15838 -> 16051
(+213, 0 removed), `ident.m() bound=local-alias` 6/63 -> 36/63,
`local-call` 159 -> 176, `module/unknown` 110 -> 116, `local-other`
271 -> 273; `self.m()`, `free f()`, `ns.f()` unchanged or up.
This commit is contained in:
Navid EMAD 2026-08-18 14:53:09 +02:00
parent 85df4c1d3b
commit 614157c6c2
No known key found for this signature in database
7 changed files with 398 additions and 4 deletions

View file

@ -191,6 +191,20 @@ export function zigTypeConstructorOf(containerNode: SyntaxNode): SyntaxNode | nu
return fn.childForFieldName('type')?.text === 'type' ? fn : null;
}
/** F7 — is `call` an instantiation of a generic type constructor —
* `List(u8)`, `util.List(u8)`, `js.Bridge(T)`, `std.AutoHashMapUnmanaged(K, V)`?
* Zig spells a generic type as a CALL, and the grammar cannot separate it
* from a value call; the one signal is the naming convention (types and
* type constructors are TitleCase, functions camelCase), so the callee's
* last identifier decides. Heuristic by nature — say so wherever it is used. */
export function isZigTypeConstructorCall(call: SyntaxNode): boolean {
if (call.type !== 'call_expression') return false;
let callee = call.childForFieldName('function');
if (callee?.type === 'field_expression') callee = callee.childForFieldName('member');
if (callee?.type !== 'identifier') return false;
return /^[A-Z]/.test(callee.text);
}
/** 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
@ -524,6 +538,47 @@ export function emitZigScopeCaptures(
continue;
}
// F7 — type aliases (`@type-binding.alias`, see the query): keep the group
// only for a value that is a TYPE expression. An identifier / member chain
// is kept as written (a value alias such as `const log = lp.log` binds a
// type name that resolves to nothing, exactly like Rust's `let x = y`); a
// call is kept only when its callee's last identifier is TitleCase —
// Zig's naming convention for types, hence for type constructors
// (`util.List(u8)`, `js.Bridge(T)`, `GenericIterator(K, V)`). That is a
// heuristic and the only one here: the grammar cannot tell a type
// constructor call from a value call, and a value call (`const t =
// util.makeThing()`) is typed by the call-return rules — a second binding
// for the same name would race on match order. A promoted namespace-
// member alias (`const Counter = counter.Counter;`) is skipped: it is a
// named import already, and the import binding carries the type.
const aliasAnchor = nodeMap['@type-binding.alias'];
if (aliasAnchor !== undefined) {
// An @import binding (`const Stack = @import("counter.zig").Stack;`) is
// an import, and a promoted member alias a named import: both already
// carry the type through the import binding.
if (aliasDeclIds.has(aliasAnchor.id) || isZigContainerOrImportBinding(aliasAnchor)) continue;
const value = nodeMap['@type-binding.type'];
if (value === undefined) continue;
// `var b: Counter = undefined;` — `undefined` / `null` are anonymous
// nodes, so the last NAMED child the query anchored on is the `type:`
// annotation, not a value. The annotation rule owns that binding.
if (value.id === aliasAnchor.childForFieldName('type')?.id) continue;
if (value.type === 'call_expression' && !isZigTypeConstructorCall(value)) continue;
// `.foo` / `.init` (enum / decl literal) parses as a field_expression
// without an object — a value, never a type name.
if (value.type === 'field_expression' && value.childForFieldName('object') === null) continue;
}
// …and the converse: a call-return binding for `const B = util.List(u8)`
// would type `B` as `util` (the call's receiver) and, at equal strength,
// race the alias binding above on match order. A type-constructor
// instantiation is the alias rule's; drop the call-return group for it.
const callReturnAnchor = nodeMap['@type-binding.call-return'];
if (callReturnAnchor !== undefined) {
const named = callReturnAnchor.namedChildren.filter((c): c is SyntaxNode => c !== null);
const value = named[named.length - 1];
if (value?.type === 'call_expression' && isZigTypeConstructorCall(value)) 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

View file

@ -141,10 +141,15 @@ export function interpretZigTypeBinding(captures: CaptureMatch): ParsedTypeBindi
// a field has exactly one type source.
source = 'annotation';
} else if (captures['@type-binding.alias'] !== undefined) {
// `const page = self.page;` — the RHS member path IS the "type"; the
// compound resolver's member-alias branch re-resolves `self.page` as a
// receiver chain (F5). Weakest source: an annotation on the same
// binding (`const p: *Page = self.page;`) must win.
// Two alias shapes share the group: `const page = self.page;` — the RHS
// member path IS the "type"; the compound resolver's member-alias branch
// re-resolves `self.page` as a receiver chain (F5) — and
// `const LocalAlias = Local;` / `const B = util.List(u8);` — the alias
// name is bound to the value's type text (`util.List` after the comptime
// arguments are dropped) and chained to the target by the shared
// `followChainedRef` (F7). Rust's `let x = y` source: it must rank
// BELOW an annotation on the same name (`const x: T = y;` is typed by
// `T`), and the default here is 'annotation'.
source = 'assignment-inferred';
}

View file

@ -297,6 +297,44 @@ const ZIG_SCOPE_QUERY = `
object: (identifier)
member: (identifier)) @type-binding.type .) @type-binding.alias
;; Type bindings — F7, aliases of a type: \`const LocalAlias = Local;\`,
;; \`const Proto = HtmlElement;\`, \`const T2 = Thing;\` (alias of an alias /
;; import), \`const B = util.List(u8);\` (an INSTANTIATED generic type
;; constructor). Zig has no \`type X = Y\` syntax — a type alias is a const
;; whose value is a type expression, and it stays a Const in the graph. What
;; must change is the scope side: bind the alias NAME to the value's type
;; text (Rust's \`let x = y\` / JS's \`const B = Foo\` \`@type-binding.alias\`,
;; source 'assignment-inferred'), so \`LocalAlias.mk()\` types through Case 4,
;; \`B.init()\` / \`x: B\` / \`B{}\` through Case 3 once \`normalizeZigTypeName\`
;; drops the comptime arguments (\`util.List(u8)\` → \`util.List\`), and every
;; binding that names the alias (\`var l = LocalAlias.mk()\`, \`var x: B\`) is
;; chained to the target by the shared \`followChainedRef\` /
;; \`followChainPostFinalize\`. The identifier / member shapes take \`var\` too:
;; \`var node = orig_node;\` is the same value alias as Rust's \`let x = y\`
;; and chains to the type of \`orig_node\` (a Zig type is comptime and never
;; \`var\`, so the type-alias reading only ever applies to \`const\`). The
;; call shape is \`const\`-only and kept only when the callee's last
;; identifier is TitleCase — see \`emitZigScopeCaptures\` (a value call
;; \`const t = util.makeThing()\` belongs to the call-return rules above and
;; must not receive a competing binding). A promoted namespace-member alias
;; (\`const Counter = counter.Counter;\` → named import) is skipped there too:
;; the import binding already carries the type.
(variable_declaration
"const" . (identifier) @type-binding.name
(identifier) @type-binding.type .) @type-binding.alias
(variable_declaration
"var" . (identifier) @type-binding.name
(identifier) @type-binding.type .) @type-binding.alias
(variable_declaration
"const" . (identifier) @type-binding.name
(field_expression) @type-binding.type .) @type-binding.alias
(variable_declaration
"var" . (identifier) @type-binding.name
(field_expression) @type-binding.type .) @type-binding.alias
(variable_declaration
"const" . (identifier) @type-binding.name
(call_expression) @type-binding.type .) @type-binding.alias
;; References — free calls: foo(...)
(call_expression
function: (identifier) @reference.name) @reference.call.free

View file

@ -0,0 +1,85 @@
// Type aliases (review finding F7). `const X = <type expr>;` is Zig's only
// alias syntax; every one of these is a Const in the graph, and every receiver
// below is typed THROUGH the alias. Lightpanda: `pub const Proto = HtmlElement;`,
// `const Allocator = std.mem.Allocator;`, `pub const bridge = js.Bridge(T);`.
const generic = @import("generic.zig");
const Page = @import("Page.zig");
const Thing = generic.Thing;
const Local = struct {
pub fn mk() Local {
return .{};
}
pub fn go(self: *Local) void {
_ = self;
}
};
// b1/b2 — alias of a same-file struct
const LocalAlias = Local;
// b3/b4 — alias of an alias (the promoted namespace-member import `Thing`)
const T2 = Thing;
// b5..b7 — alias of an INSTANTIATED generic type constructor
const B = generic.List(u8);
// alias of a namespace import that is also a file-struct type
const P = Page;
// a value const and a value call: NOT type aliases
const max = 5;
const helperResult = generic.Thing.make();
fn b1() void {
_ = LocalAlias.mk();
}
fn b2() void {
var l = LocalAlias.mk();
l.go();
}
fn b3() void {
_ = T2.make();
}
fn b4() void {
var t = T2.make();
t.run();
}
fn b5() void {
_ = B.init();
}
fn b6() void {
var b = B{};
b.push(1);
}
fn b7() void {
var x: B = .{};
x.push(2);
}
// b8 — the same alias inside a fn body (Lightpanda `const R = …;` locals)
fn b8() void {
const R = generic.List(u16);
var r = R.init();
r.push(3);
}
fn b10() void {
var q: P = undefined;
_ = q.getArena();
}
// b11 — a VALUE alias (`var cur = orig;`, the cursor idiom) is Rust's
// `let x = y`: the same binding, chained to the parameter's type.
fn b11(orig: *Local) void {
var cur = orig;
cur.go();
}
// b9 — Lightpanda's `JsApi` shape: a container-level alias of a forwarding
// type constructor, used by every member declaration of the container.
pub const JsApi = struct {
pub const bridge = generic.Bridge(Local);
pub const Meta = struct {
pub const prototype_chain = bridge.prototypeChain();
};
pub const value = bridge.accessor("value");
};
pub fn main() void {
_ = max;
_ = helperResult;
}

View file

@ -0,0 +1,38 @@
// A NAMESPACE file exporting a generic type constructor (`List(u8)`) and the
// Lightpanda `js.Bridge(T)` shape: a type constructor that FORWARDS to
// another one (`return Builder(T);`) instead of returning a container.
pub const Thing = struct {
pub fn make() Thing {
return .{};
}
pub fn run(self: *Thing) void {
_ = self;
}
};
pub fn List(comptime T: type) type {
return struct {
items: []T = &.{},
pub fn init() @This() {
return .{};
}
pub fn push(self: *@This(), v: T) void {
_ = self;
_ = v;
}
};
}
pub fn Bridge(comptime T: type) type {
return Builder(T);
}
pub fn Builder(comptime T: type) type {
return struct {
pub fn accessor(comptime name: []const u8) void {
_ = name;
_ = T;
}
pub fn prototypeChain() void {}
};
}

View file

@ -484,3 +484,72 @@ describe.skipIf(!zigAvailable)('Zig file-structs (zig-filestruct fixture)', () =
expect(targets.every((e) => e.targetFilePath.endsWith('Session.zig'))).toBe(true);
});
});
/**
* F7 — type aliases (`src/aliases.zig` + `src/generic.zig` in zig-filestruct).
* `const X = <type expr>;` is a Const in the graph; on the scope side the
* alias name must be bound to the value's type so receivers written through
* it dispatch. Before the fix every case below resolved nothing.
*/
describe.skipIf(!zigAvailable)('Zig type aliases (zig-filestruct fixture, aliases.zig)', () => {
let result: PipelineResult;
let calls: string[];
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-filestruct'), () => {});
calls = edgeSet(
getRelationships(result, 'CALLS').filter((e) => e.sourceFilePath.endsWith('aliases.zig')),
);
}, 60000);
it('keeps every alias a Const (graph ids unchanged) — the type lives on the scope side', () => {
const consts = getNodesByLabelFull(result, 'Const').filter((n) =>
n.properties.filePath.endsWith('aliases.zig'),
);
expect(consts.map((n) => n.name)).toEqual(
expect.arrayContaining(['LocalAlias', 'T2', 'B', 'P', 'max', 'bridge']),
);
expect(getNodesByLabel(result, 'TypeAlias')).toEqual([]);
});
it('dispatches through an alias of a same-file struct (`const LocalAlias = Local;`)', () => {
// b1: class-name receiver typed by the alias binding (Case 4)
expect(calls).toContain('b1 → mk');
// b2: `var l = LocalAlias.mk()` chains l → LocalAlias → Local
expect(calls).toContain('b2 → mk');
expect(calls).toContain('b2 → go');
});
it('dispatches through an alias of an alias / import (`const T2 = Thing;`)', () => {
expect(calls).toContain('b3 → make');
expect(calls).toContain('b4 → make');
expect(calls).toContain('b4 → run');
});
it('dispatches through an alias of an INSTANTIATED generic type constructor (`const B = generic.List(u8);`)', () => {
// `B.init()` — the alias binds `generic.List` (comptime args dropped), Case 3
expect(calls).toContain('b5 → init');
// `var b = B{}` (constructor-inferred → B → generic.List)
expect(calls).toContain('b6 → push');
// `var x: B = .{}` (annotation → B → generic.List)
expect(calls).toContain('b7 → push');
// the same alias declared INSIDE a fn body (`const R = generic.List(u16);`)
expect(calls).toContain('b8 → init');
expect(calls).toContain('b8 → push');
});
it('dispatches through an alias of a namespace import that is a file-struct (`const P = Page;`)', () => {
expect(calls).toContain('b10 → getArena');
});
it('a VALUE alias (`var cur = orig; cur.go()`) chains to the value’s type, as Rust’s `let x = y`', () => {
expect(calls).toContain('b11 → go');
});
it('a value const / value call is not a type alias and gains no edge', () => {
// `const helperResult = generic.Thing.make();` is a call, not an alias;
// `const max = 5;` is a literal. Neither may bind a phantom type that
// resolves `main`'s discards to anything.
expect(calls.filter((c) => c.startsWith('main → '))).toEqual([]);
});
});

View file

@ -847,6 +847,110 @@ pub fn run() void {
{ boundName: 'e', rawTypeName: 'Stack', source: 'constructor-inferred' },
]);
});
// F7 — `const X = <type expr>;` was a plain `@declaration.variable` and
// nothing else, so `LocalAlias.mk()`, `T2.make()`, `B.init()`, `var x: B`
// and `B{}` all typed nothing (see the r3-flow b1..b9 repro). The alias
// name must carry a typeBinding to the value's type text.
it('binds a type alias (`const X = Local;` / `const B = util.List(u8);`) to its target type', () => {
const src = `
const util = @import("util.zig");
const Stack = @import("counter.zig").Stack;
const Thing = util.Thing;
const Local = struct {};
const LocalAlias = Local;
const T2 = Thing;
const B = util.List(u8);
pub const bridge = js.Bridge(Local);
const max = 5;
const log = std.log.scoped(.x);
const t = util.makeThing();
const val = lp.log;
fn body(orig: *Local) void {
const R = util.List(u16);
var r = R.init();
var cur = orig;
const lit = .foo;
var und: Local = undefined;
_ = r;
_ = cur;
_ = lit;
_ = und;
}
`;
const bindings = emitZigScopeCaptures(src, 'x.zig')
.filter((m) => m['@type-binding.name'] !== undefined)
.map((m) => interpretZigTypeBinding(m));
// Same-file struct, alias of an alias, instantiated generic type
// constructor (module level and inside a fn body), a TitleCase call
// through a namespace member.
expect(bindings).toEqual(
expect.arrayContaining([
{ boundName: 'LocalAlias', rawTypeName: 'Local', source: 'assignment-inferred' },
{ boundName: 'T2', rawTypeName: 'Thing', source: 'assignment-inferred' },
{ boundName: 'B', rawTypeName: 'util.List', source: 'assignment-inferred' },
{ boundName: 'bridge', rawTypeName: 'js.Bridge', source: 'assignment-inferred' },
{ boundName: 'R', rawTypeName: 'util.List', source: 'assignment-inferred' },
// the receiver of `R.init()` chains to `R`, then to `util.List`
{ boundName: 'r', rawTypeName: 'R', source: 'constructor-inferred' },
// a `var` VALUE alias (Rust `let x = y`) chains to `orig`'s type
{ boundName: 'cur', rawTypeName: 'orig', source: 'assignment-inferred' },
]),
);
// `.foo` (enum / decl literal) is a field_expression without an object:
// a value, never a type name.
expect(bindings.find((b) => b?.boundName === 'lit')).toBeUndefined();
// `var und: Local = undefined;` — `undefined` is an anonymous node, so the
// last named child is the annotation: the alias rule must not read it as
// a value (the annotation rule owns the binding, exactly once).
expect(bindings.filter((b) => b?.boundName === 'und')).toEqual([
{ boundName: 'und', rawTypeName: 'Local', source: 'annotation' },
]);
// A type-constructor instantiation gets exactly ONE binding: the
// call-return rule (`B` ↦ `util`, the call's receiver) must not compete.
expect(bindings.filter((b) => b?.boundName === 'B')).toHaveLength(1);
expect(bindings.filter((b) => b?.boundName === 'R')).toHaveLength(1);
// A promoted namespace-member alias is a named import, not an alias
// binding; a value const / a camelCase call are not type aliases.
expect(bindings.find((b) => b?.boundName === 'Thing')).toBeUndefined();
// …nor is an @import binding of one member (`@import("counter.zig").Stack`
// is a field_expression too): an alias binding there would shadow the
// import binding for `Stack(u8){}` and `Stack(u8).init()`.
expect(bindings.find((b) => b?.boundName === 'Stack')).toBeUndefined();
expect(bindings.find((b) => b?.boundName === 'max')).toBeUndefined();
// A value call (camelCase callee) is the call-return rule's alone: no
// alias binding to `std.log.scoped` / `util.makeThing`.
expect(bindings.filter((b) => b?.boundName === 'log').map((b) => b?.source)).not.toContain(
'assignment-inferred',
);
expect(bindings.filter((b) => b?.boundName === 't').map((b) => b?.source)).not.toContain(
'assignment-inferred',
);
// A value member alias binds like Rust's `let x = y` (resolves nothing).
expect(bindings).toContainEqual({
boundName: 'val',
rawTypeName: 'lp.log',
source: 'assignment-inferred',
});
});
it('an annotation on the same name outranks the alias binding (`const x: T = y;`)', () => {
// 'assignment-inferred' (strength 1) vs 'annotation' (3): the declared
// type wins in the extractor's per-scope merge. Verified end to end
// through `extract`, which is where the strengths are compared.
const src = `
const Foo = struct { pub fn f(self: *Foo) void { _ = self; } };
const Bar = struct {};
const y = Bar;
pub fn run() void {
const x: Foo = y;
x.f();
}
`;
const parsed = extractScopes(emitZigScopeCaptures(src, 'x.zig'), 'x.zig', zigProvider);
const xs = parsed.scopes.map((s) => s.typeBindings.get('x')).filter((b) => b !== undefined);
expect(xs).toEqual([expect.objectContaining({ rawName: 'Foo', source: 'annotation' })]);
});
});
describeZig(