fix(zig): bind container field types so self.field.m() resolves (F5)

A container's field types were never bound on its Class scope: the scope
query's `container_field` rule captured only the name, and
`emitZigScopeCaptures` synthesized no `@type-binding.field` group. The
compound resolver reads member types from that scope
(`typeOfMemberOnClass` → `classScope.typeBindings.get(field)`), so
`self.session.name()`, `self.counter.incr()` — Lightpanda's dominant
cross-object call shape — resolved 9 of 2803 times (0.3 %).

- query.ts: capture `type: (_)? @declaration.field-type` on
  `container_field` (enum variants have none).
- captures.ts: per typed field, push a `@type-binding.field` group (name =
  field, type = the type text, `@This()` aliases rewritten to the container
  name like parameter types); anonymous inline containers are skipped. The
  binding lands on the container's Class scope — the file's Class scope for
  a file-struct — since `zigBindingScopeFor` hoists only declaration names.
- query.ts/captures.ts: `const page = self.page;` / `var s = self.session;`
  one-level field aliases become `@type-binding.alias` bindings whose "type"
  is the RHS path; the resolver's member-alias branch re-resolves it as a
  receiver chain. Import aliases (`const Counter = counter.Counter;`) are
  dropped — they are named imports.
- interpret.ts: `@type-binding.field` → 'annotation',
  `@type-binding.alias` → 'assignment-inferred' (an annotation on the same
  binding wins).

Corpus (Lightpanda, zig-corpus-check): self.field.m() 11/2803 (0.4 %) →
1433/2803 (51.1 %); ident.m() bound=local-field-access 29/1902 (1.5 %) →
307/1902 (16.1 %); chain.m() 146/5022 (2.9 %) → 642/5022 (12.8 %);
CALLS 15838 → 18066. self.m() / free f() / ns.f() unchanged.

Tests: unit (zig-extractors) — one @type-binding.field per typed field with
sigils stripped and aliases rewritten; the binding hosted on the container's
Class scope (file-struct: the file's Class scope, not Module) with the
written spelling as declaredSpelling; field aliases bound to the RHS path
and never for import aliases. Integration (zig-idioms `holder.zig`,
zig-filestruct `Page.zig`): `viaField → incr` ×2 into counter.zig,
`viaAlias → get/twice`, `sessionName → name` / `sessionLabel → name` into
Session.zig. All fail without the change. The optional-payload capture
`if (self.opt) |c| c.incr()` is not asserted (F6).
This commit is contained in:
Navid EMAD 2026-08-18 14:41:47 +02:00
parent b527266ce8
commit 81a6b62890
No known key found for this signature in database
8 changed files with 308 additions and 5 deletions

View file

@ -24,7 +24,9 @@
* to a namespace import, member forms to named/alias imports and
* `usingnamespace` to a wildcard; receiver types come from `self`
* parameters, `T{…}` / `mod.T{…}` / `List(u8){…}` literals, `T.init()`
* call returns and `x: T` annotations (incl. decl literals). The
* call returns, `x: T` annotations (incl. decl literals), container
* FIELD types on the container's class scope (`self.session.name()`)
* and one-level field aliases (`const s = self.session; s.name()`). The
* emit-side wiring lives in `zig/scope-resolver.ts` (SCOPE_RESOLVERS).
*/

View file

@ -522,6 +522,14 @@ export function emitZigScopeCaptures(
continue;
}
// F5 — `const page = self.page;` value aliases (`@type-binding.alias`)
// share the query shape with `const Counter = counter.Counter;`, which is
// a NAMED IMPORT of `Counter` (promoted above) — a type binding beside it
// would make `Counter` look like an instance of the class it names. Drop
// the alias binding for those declarations.
const aliasBinding = nodeMap['@type-binding.alias'];
if (aliasBinding !== undefined && aliasDeclIds.has(aliasBinding.id)) continue;
// `@This()` aliases in type position — `self: *Self` in a container that
// declares `const Self = @This();`, or `self: *SigHandler` in
// `Sighandler.zig` (`const SigHandler = @This();`) — name the enclosing
@ -586,6 +594,45 @@ export function emitZigScopeCaptures(
out.push(grouped);
// F5 — field types. `session: *Session,` declares the TYPE of the member
// `session`; the compound resolver reads member types from the owning
// container's Class scope (`typeOfMemberOnClass` →
// `classScope.typeBindings.get('session')`), so without a binding there
// `self.session.name()` / `self.counter.incr()` never resolve (Lightpanda:
// 9 of 2803 such calls, 0.3 %). Synthesize a `@type-binding.field` group
// per typed field (Go does the same in go/captures.ts; Rust/C++ capture it
// in the query). Enum variants carry no type and get no binding. The
// group's anchor is the type node — inside the container, never equal to
// its range — so the extractor hosts it in the container's Class scope
// (the file's Class scope for a file-struct); `zigBindingScopeFor` hoists
// only declaration NAMES, never `@type-binding.*`. `?*Self` /
// `?*Page` (a `@This()` alias) is rewritten to the container name exactly
// like a parameter type; `normalizeZigTypeName` reduces `?*Session`,
// `[]const Counter` to the nominal name and the extractor keeps the
// written spelling as `declaredSpelling`.
const fieldAnchor = nodeMap['@declaration.field'];
const fieldType = nodeMap['@declaration.field-type'];
const fieldName = nodeMap['@declaration.name'];
if (
fieldAnchor !== undefined &&
fieldType !== undefined &&
fieldName !== undefined &&
// An inline anonymous container (`opts: struct { a: u32 },`) names no
// type; its own Class scope already owns its members.
!ZIG_CONTAINER_TYPES.has(fieldType.type)
) {
const rewritten =
thisAliases.size > 0 ? rewriteZigThisAlias(fieldType, thisAliases) : undefined;
out.push({
'@type-binding.field': nodeToCapture('@type-binding.field', fieldType),
'@type-binding.name': nodeToCapture('@type-binding.name', fieldName),
'@type-binding.type':
rewritten === undefined
? nodeToCapture('@type-binding.type', fieldType)
: syntheticCapture('@type-binding.type', fieldType, rewritten),
});
}
// `const Page = @import("Page.zig")` binds BOTH the module (namespace:
// `Page.init()`) and, when the target is a file-struct, the type it
// declares (`page: *Page`, `var p: Page`). The type is exported from the

View file

@ -121,6 +121,19 @@ export function interpretZigTypeBinding(captures: CaptureMatch): ParsedTypeBindi
} else if (captures['@type-binding.annotation'] !== undefined) {
// `var x: T = undefined;` / `const x: T = .init(…);` — the declared type.
source = 'annotation';
} else if (captures['@type-binding.field'] !== undefined) {
// `session: *Session,` — a container field's declared type, hosted in
// the container's Class scope so `self.session.name()` walks it
// (synthesized by `emitZigScopeCaptures`, F5). A declaration, hence
// 'annotation': it must outrank nothing and be outranked by nothing —
// 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.
source = 'assignment-inferred';
}
return { boundName: name, rawTypeName: normalizeZigTypeName(type), source };

View file

@ -94,9 +94,14 @@ const ZIG_SCOPE_QUERY = `
;; Declarations — container fields (struct fields, enum/union variants).
;; The #not-eq? guard drops the MISSING placeholder identifier tree-sitter-zig
;; recovers for an empty container body (see ZIG_QUERIES).
;; recovers for an empty container body (see ZIG_QUERIES). The optional
;; \`type:\` (absent on enum variants) is captured as @declaration.field-type:
;; \`emitZigScopeCaptures\` turns it into a @type-binding.field on the
;; container's Class scope so \`self.session.name()\` can walk the field's
;; type (Rust/Go parity — see the F5 block in captures.ts).
((container_field
name: (identifier) @declaration.name) @declaration.field
name: (identifier) @declaration.name
type: (_)? @declaration.field-type) @declaration.field
(#not-eq? @declaration.name ""))
;; Declarations — const/var bindings (import/container groups filtered in TS).
@ -270,6 +275,28 @@ const ZIG_SCOPE_QUERY = `
function: (field_expression
object: (_) @type-binding.type)) .) @type-binding.call-return
;; Type bindings — field-access alias: \`const page = self.page;\` /
;; \`var s = self.session;\` (F5 companion). The RHS path is kept verbatim as
;; the "type" (\`self.page\`): the compound resolver's member-alias branch
;; re-resolves it as a receiver chain (head \`self\` → class → field type),
;; so \`page.getArena()\` dispatches like \`self.page.getArena()\` does. One
;; level only — \`std.mem.Allocator\` chains and the namespace aliases
;; \`const Counter = counter.Counter;\` (promoted to named imports) are not
;; value aliases; \`emitZigScopeCaptures\` drops the import-alias matches.
;; The trailing \`.\` anchor rejects a declaration whose value has any
;; further child (\`const x = a.b + 1\` is a binary_expression, not matched
;; anyway; the anchor is the belt to that brace).
(variable_declaration
"const" . (identifier) @type-binding.name
(field_expression
object: (identifier)
member: (identifier)) @type-binding.type .) @type-binding.alias
(variable_declaration
"var" . (identifier) @type-binding.name
(field_expression
object: (identifier)
member: (identifier)) @type-binding.type .) @type-binding.alias
;; References — free calls: foo(...)
(call_expression
function: (identifier) @reference.name) @reference.call.free

View file

@ -21,3 +21,15 @@ pub fn bump(self: *Page) void {
self.count += 1;
_ = self.getArena();
}
// Field-typed receivers (F5): `session: *Session` types the member, so a
// call through it dispatches into Session.zig — directly (`self.session.name()`)
// or through a local alias of the field (`const s = self.session; s.name()`).
pub fn sessionName(self: *Page) []const u8 {
return self.session.name();
}
pub fn sessionLabel(self: *Page) []const u8 {
const s = self.session;
return s.name();
}

View file

@ -0,0 +1,27 @@
// Field-typed receivers (F5). Lightpanda writes most cross-object calls as
// `self.<field>.<method>()` (`self.session.findFrame…`, `self.counter.incr()`),
// or aliases the field first (`const page = self.page; page.getArena()`).
// The receiver's type is the FIELD's declared type — plain, pointer, optional
// pointer, slice — and lives on the owning container, not in any function.
const Counter = @import("counter.zig").Counter;
pub const Holder = struct {
counter: Counter,
ptr: *Counter,
opt: ?*Counter,
many: []Counter,
pub fn viaField(self: *Holder) void {
self.counter.incr();
self.ptr.incr();
// payload capture of an optional field
if (self.opt) |c| c.incr();
}
pub fn viaAlias(self: *Holder) u32 {
const c = self.counter;
var p = self.ptr;
p.twice();
return c.get();
}
};

View file

@ -323,6 +323,31 @@ describe.skipIf(!zigAvailable)('Zig idioms (zig-idioms fixture)', () => {
.length,
).toBeGreaterThanOrEqual(2);
});
it('types a receiver by the FIELD it is read from (`self.counter.incr()`, `self.ptr.incr()`) — F5', () => {
// A container's field types were never bound on its Class scope, so the
// compound resolver (`typeOfMemberOnClass`) found nothing for `counter`
// and `self.<field>.<method>()` — Lightpanda's dominant cross-object call
// shape — resolved 9 of 2803 times (0.3 %). Plain, pointer and optional-
// pointer field types all reduce to the nominal `Counter`.
const viaField = getRelationships(result, 'CALLS').filter(
(e) => e.source === 'viaField' && e.target === 'incr',
);
// `self.counter.incr()` and `self.ptr.incr()` — two sites, one callee, and
// the callee lives in counter.zig, not in holder.zig.
expect(viaField.length).toBeGreaterThanOrEqual(2);
expect(viaField.every((e) => e.targetFilePath.endsWith('counter.zig'))).toBe(true);
// (`if (self.opt) |c| c.incr()` — the payload capture — is F6 territory
// and is deliberately not asserted here.)
});
it('types a local ALIAS of a field (`const c = self.counter; c.get()`, `var p = self.ptr; p.twice()`) — F5', () => {
// `const c = self.counter;` binds nothing on the scope side without the
// alias rule; the alias keeps the RHS path (`self.counter`) as its type
// and the compound resolver re-resolves it as a receiver chain.
expect(calls).toContain('viaAlias → get');
expect(calls).toContain('viaAlias → twice');
});
});
/**
@ -435,4 +460,19 @@ describe.skipIf(!zigAvailable)('Zig file-structs (zig-filestruct fixture)', () =
const nameCall = calls.filter((e) => e.target === 'name');
expect(nameCall.every((e) => e.targetFilePath.endsWith('Session.zig'))).toBe(true);
});
it('dispatches through a file-struct FIELD typed by an imported file-struct (`self.session.name()`) — F5', () => {
// `session: *Session` sits at the top level of Page.zig, whose Class
// scope spans the file: the field's type binding must land there (not be
// hoisted to the Module scope like the member NAMES are), and `Session`
// must resolve through the import binding's type twin. Before F5 the
// scope had no typeBindings at all and neither call resolved.
const calls = edgeSet(getRelationships(result, 'CALLS'));
expect(calls).toContain('sessionName → name');
// `const s = self.session; s.name()` — alias of the field
expect(calls).toContain('sessionLabel → name');
const targets = getRelationships(result, 'CALLS').filter((e) => e.target === 'name');
expect(targets.length).toBeGreaterThanOrEqual(2);
expect(targets.every((e) => e.targetFilePath.endsWith('Session.zig'))).toBe(true);
});
});

View file

@ -307,9 +307,12 @@ pub fn main() void {
it('formals skip the leading `self` receiver so the member-call actual at 0 joins `cb`', () => {
const runFormals = flow()
.filter(
(m) => m['@callable-flow.formal'] !== undefined && m['@callable-flow.owner']!.text === 'run',
(m) =>
m['@callable-flow.formal'] !== undefined && m['@callable-flow.owner']!.text === 'run',
)
.map((m) => `${m['@callable-flow.binding']!.text}@${m['@callable-flow.parameter-index']!.text}`);
.map(
(m) => `${m['@callable-flow.binding']!.text}@${m['@callable-flow.parameter-index']!.text}`,
);
expect(runFormals).toEqual(['cb@0']);
});
@ -972,3 +975,135 @@ const lp = @import("lightpanda");
});
},
);
describeZig(
'Zig field types (F5: `self.field.m()` resolves through the field’s declared type)',
() => {
// Lightpanda's dominant cross-object call shape is `self.<field>.<method>()`
// (2803 sites) — and it resolved 9 times, because a container's field types
// were never bound on its Class scope: `typeOfMemberOnClass` reads
// `classScope.typeBindings.get(field)` and found nothing.
const SRC = `
const Page = @This();
const Session = @import("Session.zig");
session: *Session,
parent: ?*Page,
count: u32 = 0,
pub const Holder = struct {
const Self = @This();
counter: Counter,
ptr: *Counter,
opt: ?*Counter,
list: []const Counter,
gen: std.ArrayList(u8),
next: ?*Self,
inline_: struct { a: u32 },
pub fn viaField(self: *Holder) void { self.counter.incr(); }
};
pub const Kind = enum { a, b };
pub const Payload = union(enum) { x: u32, y: Counter };
`;
it('emits one @type-binding.field per TYPED field — the nominal type, sigils stripped, aliases rewritten', () => {
const fields = emitZigScopeCaptures(SRC, 'src/Page.zig')
.filter((m) => m['@type-binding.field'] !== undefined)
.map((m) => {
const parsed = interpretZigTypeBinding(m)!;
return [
parsed.boundName,
m['@type-binding.type']!.text,
parsed.rawTypeName,
parsed.source,
];
});
expect(fields).toEqual([
// file-struct fields; `?*Page` is the file's own @This() alias, kept as
// the file stem (Page.zig → Page) exactly like a parameter type
['session', '*Session', 'Session', 'annotation'],
['parent', '?*Page', 'Page', 'annotation'],
['count', 'u32', 'u32', 'annotation'],
// nested container: plain / pointer / optional pointer / slice /
// generic instantiation / nested `Self` alias
['counter', 'Counter', 'Counter', 'annotation'],
['ptr', '*Counter', 'Counter', 'annotation'],
['opt', '?*Counter', 'Counter', 'annotation'],
['list', '[]const Counter', 'Counter', 'annotation'],
['gen', 'std.ArrayList(u8)', 'std.ArrayList', 'annotation'],
['next', '?*Holder', 'Holder', 'annotation'],
// the anonymous inline struct's OWN field, not `inline_` itself
['a', 'u32', 'u32', 'annotation'],
// union variants carry a type; enum variants (`a`, `b`) do not
['x', 'u32', 'u32', 'annotation'],
['y', 'Counter', 'Counter', 'annotation'],
]);
});
it('hosts the binding on the CONTAINER’s Class scope — the file’s Class scope for a file-struct — never hoisted to Module', () => {
// `zigBindingScopeFor` hoists member NAMES of a file-struct to the Module
// scope so `Page.init()` keeps working; the compound resolver reads member
// TYPES from the Class scope, so those must stay put.
const parsed = extractScopes(
emitZigScopeCaptures(SRC, 'src/Page.zig'),
'src/Page.zig',
zigProvider,
);
const byKind = (kind: string) => parsed.scopes.filter((s) => s.kind === kind);
const moduleScope = byKind('Module')[0]!;
expect(moduleScope.typeBindings.size).toBe(0);
const fileClass = byKind('Class').find(
(s) =>
s.range.startLine === moduleScope.range.startLine &&
s.range.endLine === moduleScope.range.endLine,
)!;
expect(fileClass).toBeDefined();
expect(fileClass.typeBindings.get('session')?.rawName).toBe('Session');
// The written spelling survives beside the reduced name (`TypeRef.declaredSpelling`).
expect(fileClass.typeBindings.get('session')?.declaredSpelling).toBe('*Session');
expect(fileClass.typeBindings.get('parent')?.rawName).toBe('Page');
const holder = byKind('Class').find((s) => s.typeBindings.has('counter'))!;
expect(holder).toBeDefined();
expect(holder.id).not.toBe(fileClass.id);
expect(holder.typeBindings.get('opt')).toMatchObject({
rawName: 'Counter',
declaredSpelling: '?*Counter',
});
expect(holder.typeBindings.get('next')?.rawName).toBe('Holder');
// The receiver `self` stays on the function scope, not on the class.
expect(holder.typeBindings.has('self')).toBe(false);
expect(byKind('Function')[0]!.typeBindings.get('self')?.rawName).toBe('Holder');
});
it('binds a local alias of a field to the RHS path (`const page = self.page;`), never an import alias', () => {
const src = `
const counter = @import("counter.zig");
const Counter = counter.Counter;
const Allocator = std.mem.Allocator;
pub fn run(self: *Holder) void {
const page = self.page;
var s = self.session;
const typed: *Page = self.page;
_ = page; _ = s; _ = typed;
}
`;
const aliases = emitZigScopeCaptures(src, 'x.zig')
.filter((m) => m['@type-binding.alias'] !== undefined)
.map((m) => interpretZigTypeBinding(m));
expect(aliases).toEqual([
{ boundName: 'page', rawTypeName: 'self.page', source: 'assignment-inferred' },
{ boundName: 's', rawTypeName: 'self.session', source: 'assignment-inferred' },
{ boundName: 'typed', rawTypeName: 'self.page', source: 'assignment-inferred' },
]);
// `const Counter = counter.Counter;` is a NAMED IMPORT (counter is an
// @import binding), and `std.mem.Allocator` is a two-level chain — neither
// is a value alias.
expect(aliases.map((a) => a!.boundName)).not.toContain('Counter');
expect(aliases.map((a) => a!.boundName)).not.toContain('Allocator');
// The annotation outranks the alias for the same name.
const parsed = extractScopes(emitZigScopeCaptures(src, 'x.zig'), 'x.zig', zigProvider);
const fn = parsed.scopes.find((s) => s.kind === 'Function')!;
const block = parsed.scopes.find((s) => s.kind === 'Block')!;
const typed = block.typeBindings.get('typed') ?? fn.typeBindings.get('typed');
expect(typed).toMatchObject({ rawName: 'Page', source: 'annotation' });
});
},
);