diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts index 46e2f8440..2c42ba674 100644 --- a/gitnexus/src/core/ingestion/languages/zig/captures.ts +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/zig/interpret.ts b/gitnexus/src/core/ingestion/languages/zig/interpret.ts index 099ade67b..3f6bbe0e4 100644 --- a/gitnexus/src/core/ingestion/languages/zig/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/zig/interpret.ts @@ -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'; } diff --git a/gitnexus/src/core/ingestion/languages/zig/query.ts b/gitnexus/src/core/ingestion/languages/zig/query.ts index 13f823d4c..7822eb87b 100644 --- a/gitnexus/src/core/ingestion/languages/zig/query.ts +++ b/gitnexus/src/core/ingestion/languages/zig/query.ts @@ -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 diff --git a/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/aliases.zig b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/aliases.zig new file mode 100644 index 000000000..007132c1e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/aliases.zig @@ -0,0 +1,85 @@ +// Type aliases (review finding F7). `const X = ;` 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; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/generic.zig b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/generic.zig new file mode 100644 index 000000000..ed796f19e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/generic.zig @@ -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 {} + }; +} diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index a6c78d45e..16af32370 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -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 = ;` 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([]); + }); +}); diff --git a/gitnexus/test/unit/zig-extractors.test.ts b/gitnexus/test/unit/zig-extractors.test.ts index a4ad240df..e8f958ecd 100644 --- a/gitnexus/test/unit/zig-extractors.test.ts +++ b/gitnexus/test/unit/zig-extractors.test.ts @@ -847,6 +847,110 @@ pub fn run() void { { boundName: 'e', rawTypeName: 'Stack', source: 'constructor-inferred' }, ]); }); + + // F7 — `const X = ;` 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(