diff --git a/gitnexus/src/core/ingestion/languages/zig.ts b/gitnexus/src/core/ingestion/languages/zig.ts index fdd4a3067..762580cde 100644 --- a/gitnexus/src/core/ingestion/languages/zig.ts +++ b/gitnexus/src/core/ingestion/languages/zig.ts @@ -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). */ diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts index 75f2506ab..df756bd59 100644 --- a/gitnexus/src/core/ingestion/languages/zig/captures.ts +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/zig/interpret.ts b/gitnexus/src/core/ingestion/languages/zig/interpret.ts index 07901b4e4..8985b17b1 100644 --- a/gitnexus/src/core/ingestion/languages/zig/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/zig/interpret.ts @@ -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 }; diff --git a/gitnexus/src/core/ingestion/languages/zig/query.ts b/gitnexus/src/core/ingestion/languages/zig/query.ts index bde1329b5..13f823d4c 100644 --- a/gitnexus/src/core/ingestion/languages/zig/query.ts +++ b/gitnexus/src/core/ingestion/languages/zig/query.ts @@ -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 diff --git a/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Page.zig b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Page.zig index 9e76218c8..de8aa7b90 100644 --- a/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Page.zig +++ b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Page.zig @@ -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(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/holder.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/holder.zig new file mode 100644 index 000000000..6daa59c6c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/holder.zig @@ -0,0 +1,27 @@ +// Field-typed receivers (F5). Lightpanda writes most cross-object calls as +// `self..()` (`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(); + } +}; diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index 70fcb521b..8a942a469 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -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..()` — 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); + }); }); diff --git a/gitnexus/test/unit/zig-extractors.test.ts b/gitnexus/test/unit/zig-extractors.test.ts index 40493c8ee..efec863cd 100644 --- a/gitnexus/test/unit/zig-extractors.test.ts +++ b/gitnexus/test/unit/zig-extractors.test.ts @@ -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..()` + // (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' }); + }); + }, +);