From e5b1067209de44a3370cdf6c15b5842d712c8c22 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Wed, 2 Sep 2026 21:16:48 +0200 Subject: [PATCH] feat(zig): resolve hub re-exports, enum-variant receivers and type-named receivers (real-project audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of three real Zig projects indexed with this branch (tigerbeetle 246 files, mach 132, ghostty 788): method reachability was 63 % / 35 % / 55 %, and three shapes accounted for most of the misses. 1. Hub modules. Zig projects publish types through a file made only of re-exports (`pub const Terminal = @import("Terminal.zig");`, `pub const PRNG = @import("prng.zig");`, `pub const Thing = @import("thing.zig") .Thing;`). Such a file owns NO local binding, and `findExportedDef` reads local bindings only — so `terminal.Terminal.init()`, `t: stdx.Thing`, `var p = stdx.PRNG.from_seed()` and `h: stdx.BoundedArrayType(u8, 4)` all resolved to nothing. Measured before → after: CALLS into ghostty's `src/terminal/` from outside it 46 → 253 (150 `terminal.Terminal.` sites alone); into tigerbeetle's `stdx` hub from outside it 837 → 1500 (136 static calls, 289 annotations). Method reachability: tigerbeetle 2249 → 2272 of 3544, ghostty 2766 → 2865 of 5016, mach 1047 → 1051 of 2967 (mach's hub publishes generic instantiations, `pub const Quat = q.Quat(f32)`, a shape this commit does not cover). `findExportedDefIncludingImportedNames` reads the finalized channel (origin import / namespace / reexport, def already resolved to the declaring file), refusing a name bound to two distinct defs. Opt-in per provider (`namespaceExportsIncludeImportedNames`): a module's imports are not its exports in most languages; Zig opts in because a hub member a consumer can name is public by construction. Used by receiver-bound Case 1, Case 3, the compound resolver's namespace branch, and a new Case 2 route that resolves a namespace-qualified class receiver (`stdx.PRNG`) through the same lookup. 2. Enum variants as receivers. `Operation.create_accounts.event_max()` (147 sites in tigerbeetle): a variant has no written type, but it has one — the enum itself. `emitZigScopeCaptures` now emits a field type binding per enum variant, so the field walk that already handles `self.session.name()` types `Op.create` as `Op`. 3. Receivers named after their type. `self` is a convention, not a rule: tigerbeetle writes `replica: *Replica` (777 of 1127 methods), mach `pool: *@This()` (764 of 833). Reading only `self` as the receiver labelled all of them `isStatic: true`, counted the receiver in their arity (`Counter.incr#1`) and sourced the scope binding as a plain parameter. `zigReceiverParameter` is the single rule for both phases: the FIRST parameter when named `self`, or typed as the enclosing container (`@This()`, its binding name, a `const X = @This();` alias), pointers / const / optionals stripped. Fixtures `zig-hub` and `zig-receivers` pin each shape, including the refusals: a private hub import does not leak, a foreign-typed first parameter is not a receiver, a factory stays static. --- .../core/ingestion/languages/zig/captures.ts | 126 ++++++++++++++++- .../core/ingestion/languages/zig/interpret.ts | 12 +- .../ingestion/languages/zig/scope-resolver.ts | 8 ++ .../method-extractors/configs/zig.ts | 43 +++--- .../contract/scope-resolver.ts | 20 +++ .../passes/compound-receiver.ts | 10 +- .../passes/receiver-bound-calls.ts | 52 ++++++- .../scope-resolution/scope/walkers.ts | 54 +++++++ .../lang-resolution/zig-hub/src/main.zig | 56 ++++++++ .../lang-resolution/zig-hub/src/op.zig | 7 + .../zig-hub/src/stdx/bounded_array.zig | 9 ++ .../lang-resolution/zig-hub/src/stdx/prng.zig | 9 ++ .../lang-resolution/zig-hub/src/stdx/stdx.zig | 6 + .../zig-hub/src/stdx/thing.zig | 9 ++ .../lang-resolution/zig-hub/src/stdx/util.zig | 3 + .../zig-receivers/src/counter.zig | 32 +++++ .../zig-receivers/src/main.zig | 39 ++++++ .../zig-receivers/src/stdx/bounded_array.zig | 9 ++ .../zig-receivers/src/stdx/prng.zig | 9 ++ .../zig-receivers/src/stdx/stdx.zig | 2 + .../test/integration/resolvers/zig.test.ts | 132 ++++++++++++++++++ gitnexus/test/unit/zig-extractors.test.ts | 132 ++++++++++++++---- 22 files changed, 713 insertions(+), 66 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-hub/src/main.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-hub/src/op.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/bounded_array.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/prng.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/stdx.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/thing.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/util.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-receivers/src/counter.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-receivers/src/main.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/bounded_array.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/prng.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/stdx.zig diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts index 6fa48cfdd..002567fc3 100644 --- a/gitnexus/src/core/ingestion/languages/zig/captures.ts +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -199,6 +199,84 @@ export function zigContainerBindingName( return zigTypeConstructorOf(containerNode)?.childForFieldName('name')?.text; } +/** The nominal type spelled at a parameter with its sigils stripped: + * `*const Self` → `Self`, `?*T` → `T`, `Pool(Node)` → `Pool`; `@This()` is + * kept whole (a builtin, not a constructor name). */ +function zigParameterNominalType(typeText: string): string { + let t = typeText.trim(); + let previous: string; + do { + previous = t; + t = t.replace(/^(?:\*|\?|const\s+|\s+)/, ''); + } while (t !== previous); + if (!t.startsWith('@')) { + const paren = t.indexOf('('); + if (paren > 0) t = t.slice(0, paren).trim(); + } + return t; +} + +/** The `const X = @This();` alias names declared DIRECTLY in `container` (a + * container node, or the root of a file-struct). */ +function zigThisAliasNamesIn(container: SyntaxNode): Set { + const out = new Set(); + for (let i = 0; i < container.namedChildCount; i++) { + const decl = container.namedChild(i); + if (decl === null || decl.type !== 'variable_declaration') continue; + const named = decl.namedChildren.filter((c): c is SyntaxNode => c !== null); + const value = named[named.length - 1]; + if ( + named.length === 2 && + named[0]!.type === 'identifier' && + value?.type === 'builtin_function' && + value.namedChild(0)?.text === '@This' && + isZigKeywordDeclaration(decl) + ) { + out.add(named[0]!.text); + } + } + return out; +} + +/** The RECEIVER parameter of a container method, or null for a static fn. + * + * Zig has no receiver keyword: `self` is a convention, not a rule, and real + * code names the receiver after its type as often as not — tigerbeetle + * (`replica: *Replica`, 777 of 1127 methods), mach (`pool: *@This()`, 764 of + * 833). Treating only `self` as the receiver labelled those methods static, + * counted the receiver in their arity (`Counter.incr#1`) and left the + * scope-side binding sourced as a plain parameter. The rule, applied to the + * FIRST parameter only: + * - named `self`, whatever its type; or + * - typed as the enclosing container: `@This()`, the container's binding + * name (`Counter`, the generic constructor `Pool` for `Pool(Node)`, the + * file stem for a file-struct when `filePath` is known), or a + * `const X = @This();` alias declared in that container (`Self`, `PRNG` + * in `prng.zig`) — pointers, `const` and optionals stripped. + * `fn` must be a direct child of a container (or the file root); a fn nested + * in a body is never a method. Single source for the method extractor + * (parameters / receiver type / `isStatic`) and `emitZigScopeCaptures` + * (`@type-binding.receiver`), so the structure and scope phases agree. */ +export function zigReceiverParameter(fn: SyntaxNode, filePath?: string): SyntaxNode | null { + const paramList = fn.namedChildren.find( + (child): child is SyntaxNode => child?.type === 'parameters', + ); + const first = paramList?.namedChild(0); + if (first === null || first === undefined || first.type !== 'parameter') return null; + if (first.childForFieldName('name')?.text === 'self') return first; + const typeNode = first.childForFieldName('type'); + if (typeNode === null) return null; + const nominal = zigParameterNominalType(typeNode.text); + if (nominal.length === 0) return null; + if (nominal === '@This()') return first; + const container = fn.parent; + if (container === null || container === undefined) return null; + if (!ZIG_CONTAINER_TYPES.has(container.type) && container.type !== 'source_file') return null; + if (zigThisAliasNamesIn(container).has(nominal)) return first; + const binding = zigContainerBindingName(container, filePath); + return binding !== undefined && nominal === binding ? first : null; +} + /** The graph IDENTITY of a Zig container node — the name its class-like * node, its members' owner segment (`Method::.`) and the * scope-side def all carry. Single source for the class/field/method @@ -1229,12 +1307,13 @@ export function emitZigScopeCaptures( } } - // Zig's receiver convention is specifically the FIRST parameter named - // `self`. Tag first-position parameters so `interpretZigTypeBinding` can - // require the position and not just the name — a later `self` parameter - // (legal Zig) is an ordinary parameter, not a receiver. The synthetic - // capture sits on the name node (smaller than the `parameter` anchor), so - // it never displaces the anchor. + // The receiver is the FIRST parameter when it is named `self` or typed as + // the enclosing container — `zigReceiverParameter` is the single rule, + // shared with the method extractor so the two phases agree. Tag it so + // `interpretZigTypeBinding` sources the binding as `self`; a later `self` + // parameter (legal Zig) is an ordinary parameter, not a receiver. The + // synthetic captures sit on the name node (smaller than the `parameter` + // anchor), so they never displace the anchor. const paramAnchor = nodeMap['@type-binding.parameter']; const paramName = nodeMap['@type-binding.name']; if ( @@ -1247,6 +1326,18 @@ export function emitZigScopeCaptures( paramName, 'true', ); + const fnNode = paramAnchor.parent?.parent; + if ( + fnNode !== null && + fnNode !== undefined && + zigReceiverParameter(fnNode, _filePath)?.id === paramAnchor.id + ) { + grouped['@type-binding.receiver'] = syntheticCapture( + '@type-binding.receiver', + paramName, + 'true', + ); + } } // Mark containers returned by a generic type constructor so @@ -1341,6 +1432,29 @@ export function emitZigScopeCaptures( ? nodeToCapture('@type-binding.type', fieldType) : syntheticCapture('@type-binding.type', fieldType, rewritten), }); + } else if ( + fieldAnchor !== undefined && + fieldType === undefined && + fieldName !== undefined && + fieldName.text !== '_' && + fieldAnchor.parent?.type === 'enum_declaration' + ) { + // An enum VARIANT has no written type, but it has one: the enum itself. + // `Operation.create_accounts.event_max()` — a method called on a variant + // reached through the type — needs `Operation.create_accounts` typed as + // `Operation` for the compound resolver to walk the field like + // `self.session.name()`. tigerbeetle writes this shape 147 times + // (`Operation..event_max(…)`, `TestOperation.create.event_size(`). + const enumName = + zigContainerBindingName(fieldAnchor.parent, _filePath) ?? + zigContainerName(fieldAnchor.parent, _filePath); + if (enumName !== undefined) { + out.push({ + '@type-binding.field': nodeToCapture('@type-binding.field', fieldName), + '@type-binding.name': nodeToCapture('@type-binding.name', fieldName), + '@type-binding.type': syntheticCapture('@type-binding.type', fieldName, enumName), + }); + } } // `const Page = @import("Page.zig")` binds BOTH the module (namespace: diff --git a/gitnexus/src/core/ingestion/languages/zig/interpret.ts b/gitnexus/src/core/ingestion/languages/zig/interpret.ts index eac006720..ce69d6d4a 100644 --- a/gitnexus/src/core/ingestion/languages/zig/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/zig/interpret.ts @@ -119,11 +119,13 @@ export function interpretZigTypeBinding(captures: CaptureMatch): ParsedTypeBindi let source: TypeRef['source'] = 'annotation'; if (captures['@type-binding.parameter'] !== undefined) { - // Zig has no implicit receiver keyword; the convention is a FIRST - // parameter named `self`. `emitZigScopeCaptures` tags first-position - // parameters; the name alone is not enough — `fn f(a: u32, self: T)` is - // legal and `self` there is an ordinary parameter, not a receiver. - const isReceiver = name === 'self' && captures['@type-binding.first-parameter'] !== undefined; + // Zig has no implicit receiver keyword. `emitZigScopeCaptures` tags the + // receiver parameter (`@type-binding.receiver`, see `zigReceiverParameter`): + // the FIRST parameter when it is named `self` OR typed as the enclosing + // container (`counter: *Counter`, `pool: *@This()`, `prng: *PRNG` with + // `const PRNG = @This();`). Position matters — `fn f(a: u32, self: T)` is + // legal and `self` there is an ordinary parameter. + const isReceiver = captures['@type-binding.receiver'] !== undefined; source = isReceiver ? 'self' : 'parameter-annotation'; } else if (captures['@type-binding.constructor'] !== undefined) { source = 'constructor-inferred'; diff --git a/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts index e2a94ed0a..93f140720 100644 --- a/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts @@ -30,6 +30,14 @@ export const zigScopeResolver: ScopeResolver = { // itself — `main → SpawnRequest` looked like a call to a function // (PR #1432 review). Emits `local-call (constructor)` and friends. markConstructionSites: true, + // Hub modules are how Zig projects publish their types: `pub const Terminal + // = @import("Terminal.zig");` / `pub const PRNG = @import("prng.zig");` in a + // file that declares nothing itself. Consumers then write + // `terminal.Terminal.init()`, `stdx.PRNG.from_seed()`, `t: stdx.Thing`. + // Measured on real projects before → after this flag: CALLS into ghostty's + // `src/terminal/` from outside it 46 → 253; into tigerbeetle's `stdx` hub + // from outside it 837 → 1500 (136 `stdx.Type.fn(` sites, 289 annotations). + namespaceExportsIncludeImportedNames: true, loadResolutionConfig: (repoPath: string) => loadZigBuildConfig(repoPath), diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts index b8960b4fc..2665f6a0d 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts @@ -2,7 +2,11 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { MethodExtractionConfig, ParameterInfo } from '../../method-types.js'; import type { SyntaxNode } from '../../utils/ast-helpers.js'; import { hasZigPubKeyword } from '../../export-detection.js'; -import { ZIG_CONTAINER_TYPES, zigContainerName } from '../../languages/zig/captures.js'; +import { + ZIG_CONTAINER_TYPES, + zigContainerName, + zigReceiverParameter, +} from '../../languages/zig/captures.js'; /** * Zig method extraction. @@ -13,8 +17,10 @@ import { ZIG_CONTAINER_TYPES, zigContainerName } from '../../languages/zig/captu * {…}; }`) — `zigContainerName` decides. Methods inside a container appear as * plain `function_declaration` children of the container node. * - * The first parameter is the receiver iff it is named `self` (convention) — - * unlike Rust, Zig has no dedicated `self_parameter` node type. + * The first parameter is the receiver when it is named `self` OR typed as the + * enclosing container (`replica: *Replica`, `pool: *@This()`) — see + * `zigReceiverParameter`; unlike Rust, Zig has no dedicated `self_parameter` + * node type and `self` is a convention, not a rule. */ const extractZigOwnerName = (node: SyntaxNode, filePath?: string): string | undefined => @@ -44,7 +50,7 @@ const extractZigReturnType = (node: SyntaxNode): string | undefined => { }; /** - * Regular parameters only. A leading `self` parameter is the receiver — it is + * Regular parameters only. The receiver parameter (`zigReceiverParameter`) is * reported through `extractReceiverType`, not the parameter list (same split * as Rust's `self_parameter` skip in `configs/rust.ts`). */ @@ -52,15 +58,13 @@ const extractZigParameters = (node: SyntaxNode): ParameterInfo[] => { const paramList = zigParameterList(node); if (!paramList) return []; const params: ParameterInfo[] = []; - let seenParameter = false; + const receiver = zigReceiverParameter(node); for (let i = 0; i < paramList.namedChildCount; i++) { const param = paramList.namedChild(i); if (!param || param.type !== 'parameter') continue; + if (receiver !== null && param.id === receiver.id) continue; const nameNode = param.childForFieldName('name'); const typeNode = param.childForFieldName('type'); - const isReceiver = !seenParameter && nameNode?.text === 'self'; - seenParameter = true; - if (isReceiver) continue; params.push({ name: nameNode?.text ?? '?', type: typeNode?.text?.trim() ?? null, @@ -72,16 +76,8 @@ const extractZigParameters = (node: SyntaxNode): ParameterInfo[] => { return params; }; -const extractZigReceiverType = (node: SyntaxNode): string | undefined => { - const paramList = zigParameterList(node); - if (!paramList) return undefined; - const first = paramList.namedChild(0); - if (!first || first.type !== 'parameter') return undefined; - const nameNode = first.childForFieldName('name'); - if (nameNode?.text !== 'self') return undefined; - const typeNode = first.childForFieldName('type'); - return typeNode?.text?.trim(); -}; +const extractZigReceiverType = (node: SyntaxNode): string | undefined => + zigReceiverParameter(node)?.childForFieldName('type')?.text?.trim(); /** * Names a `test_declaration` during the enclosing-function walk (parse-worker @@ -124,13 +120,10 @@ export const zigMethodConfig: MethodExtractionConfig = { extractReceiverType: extractZigReceiverType, isStatic(node) { - // A Zig "method" is effectively static if its first parameter is not `self`. - const paramList = zigParameterList(node); - if (!paramList) return true; - const first = paramList.namedChild(0); - if (!first || first.type !== 'parameter') return true; - const nameNode = first.childForFieldName('name'); - return nameNode?.text !== 'self'; + // A Zig "method" is static when it has no receiver parameter — `self` OR + // a first parameter typed as the enclosing container (`replica: + // *Replica`, `pool: *@This()`); see `zigReceiverParameter`. + return zigReceiverParameter(node) === null; }, isAbstract() { diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 1832d4526..4af1ca4ea 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -902,6 +902,26 @@ export interface ScopeResolver { */ readonly markConstructionSites?: boolean; + /** + * When true, a namespace's exported member may also be a name the target + * module IMPORTED and publishes as its own — the hub-module shape, a file + * made only of re-exports (`pub const Terminal = @import("Terminal.zig");`, + * `pub const Thing = @import("thing.zig").Thing;`). Such a file owns no + * local binding, so the default local-only export lookup + * (`findExportedDef`) finds nothing for `terminal.Terminal.init()`, + * `t: stdx.Thing`, `var p = stdx.PRNG.from_seed()` or `var a: + * stdx.BoundedArrayType(u8, 4)`, and the receiver-bound namespace paths + * (Case 1, Case 3, the compound resolver's namespace branch) fall through. + * With the flag those paths use `findExportedDefIncludingImportedNames`, + * which reads the finalized channel where the published names live. + * + * Off by default: in most languages a module's imports are not its exports + * (TypeScript `import { X }` publishes nothing), and the finalized edge does + * not record whether the import was written `pub`. Zig opts in — a hub + * member a consumer can name through the hub is public by construction. + */ + readonly namespaceExportsIncludeImportedNames?: boolean; + /** * How this language spells a construction expression, so the compound * receiver resolver can type an INLINE constructor receiver — the diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 66453dc3e..e96e31903 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -38,6 +38,7 @@ import { findEnclosingClassDef, findExportedDef, findExportedDefByName, + findExportedDefIncludingImportedNames, findReceiverTypeBinding, isClassLike, isNamespaceNameShadowed, @@ -128,6 +129,9 @@ interface ResolveCompoundReceiverOptions { readonly constructionSyntax?: ScopeResolver['constructionSyntax']; /** Verified namespace handles visible in the current file. */ readonly namespaceTargets?: ReadonlyMap; + /** A namespace member may be a name the target module imported and + * publishes (hub modules). See `ScopeResolver.namespaceExportsIncludeImportedNames`. */ + readonly namespaceExportsIncludeImportedNames?: boolean; /** Compact receiver chain for THIS site (`ReferenceSite.receiverChain`), when * the language's capture emitter produced one. Present ⇒ the structural fold * is tried before the text cascade; absent ⇒ behaviour is exactly as before. @@ -241,7 +245,11 @@ function resolveConstructionExpressionClass( if (namespaceFiles.length > 0) { if (isNamespaceNameShadowed(namespaceName, inScope, scopes)) return undefined; const namespaceMatches = namespaceFiles - .map((targetFile) => findExportedDef(targetFile, exportedName, index)) + .map((targetFile) => + options.namespaceExportsIncludeImportedNames === true + ? findExportedDefIncludingImportedNames(targetFile, exportedName, index, scopes) + : findExportedDef(targetFile, exportedName, index), + ) .filter((def): def is SymbolDefinition => def !== undefined && isClassLike(def.type)); return namespaceMatches.length === 1 ? namespaceMatches[0] : undefined; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 6c4ef4ea4..37f2cda3f 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -59,7 +59,7 @@ * resolved to a wrong target. */ -import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { SemanticModel } from '../../model/semantic-model.js'; @@ -73,6 +73,7 @@ import { findEnclosingClassDef, isReceiverOwnedButUnbound, findExportedDef, + findExportedDefIncludingImportedNames, findOwnedMember, findReceiverTypeBinding, findValueBindingInScope, @@ -137,6 +138,7 @@ type ReceiverBoundProviderSubset = Pick< | 'isStaticOnly' | 'normalizeTypeArgument' | 'markConstructionSites' + | 'namespaceExportsIncludeImportedNames' >; /** A bare, undecorated identifier and nothing else — see {@link isBareTypeName}. */ @@ -331,9 +333,42 @@ export function emitReceiverBoundCalls( const fieldFallback = provider.fieldFallbackOnMethodLookup ?? true; const collapse = provider.collapseMemberCallsByCallerTarget === true; const hoistTypeBindingsToModule = provider.hoistTypeBindingsToModule === true; + // Namespace-member lookup for Case 1 / Case 3: local exports only, unless + // the provider publishes imported names too (hub modules — see + // `ScopeResolver.namespaceExportsIncludeImportedNames`). + const lookupNamespaceMember = (targetFile: string, name: string): SymbolDefinition | undefined => + provider.namespaceExportsIncludeImportedNames === true + ? findExportedDefIncludingImportedNames(targetFile, name, index, scopes) + : findExportedDef(targetFile, name, index); + // `ns.Type` as a receiver, where `ns` is a verified namespace of the current + // file and `Type` a class-like member of it. Unique across the namespace's + // target files or nothing — two same-named classes behind one handle would + // mint a confident wrong edge. + const resolveNamespaceQualifiedClass = ( + receiverName: string, + inScope: ScopeId, + namespaceTargets: ReadonlyMap, + ): SymbolDefinition | undefined => { + const dot = receiverName.lastIndexOf('.'); + if (dot <= 0 || dot === receiverName.length - 1) return undefined; + const head = receiverName.slice(0, dot); + const tail = receiverName.slice(dot + 1); + if (tail.includes('(') || tail.includes('[')) return undefined; + const files = namespaceTargets.get(head); + if (files === undefined || isNamespaceNameShadowed(head, inScope, scopes)) return undefined; + let picked: SymbolDefinition | undefined; + for (const file of files) { + const def = lookupNamespaceMember(file, tail); + if (def === undefined || !isClassLike(def.type)) continue; + if (picked !== undefined && picked.nodeId !== def.nodeId) return undefined; + picked = def; + } + return picked; + }; const compoundOpts = { fieldFallback, elementTypeOf: provider.elementTypeOf, + namespaceExportsIncludeImportedNames: provider.namespaceExportsIncludeImportedNames === true, hoistTypeBindingsToModule, stripReceiverCastExpressions: provider.stripReceiverCastExpressions === true, constructionSyntax: provider.constructionSyntax, @@ -1275,7 +1310,7 @@ export function emitReceiverBoundCalls( if (targetFiles !== undefined && provider.resolveQualifiedReceiverMember === undefined) { let found = false; for (const targetFile of targetFiles) { - const memberDef = findExportedDef(targetFile, memberName, index); + const memberDef = lookupNamespaceMember(targetFile, memberName); if (memberDef !== undefined) { if ( suppressDeletedCallTarget( @@ -1379,7 +1414,16 @@ export function emitReceiverBoundCalls( } // ── Case 2: class-name receiver ────────────────────────────── - const classDef = findClassBindingInScope(site.inScope, receiverName, scopes); + // A namespace-qualified class (`stdx.PRNG.from_seed()`, `terminal + // .Terminal.init()`) binds nothing in the caller's scope chain; when the + // head names a verified namespace, the tail is looked up as that + // module's member — through the same lookup Case 1 / Case 3 use, so a + // hub module (a file made only of re-exports) answers when the provider + // opted in. Only a bare tail is walked here; `ns.Type.field.m()` is the + // compound resolver's shape. + const classDef = + findClassBindingInScope(site.inScope, receiverName, scopes) ?? + resolveNamespaceQualifiedClass(receiverName, site.inScope, namespaceTargets); if (classDef !== undefined) { const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)]; let memberDef: SymbolDefinition | undefined; @@ -1469,7 +1513,7 @@ export function emitReceiverBoundCalls( if (targetFiles3 !== undefined && className.length > 0) { let found3 = false; for (const targetFile3 of targetFiles3) { - const classDef3 = findExportedDef(targetFile3, className, index); + const classDef3 = lookupNamespaceMember(targetFile3, className); if (classDef3 !== undefined) { const picked = site.kind === 'call' diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index c4fc9b193..62e996737 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -2113,3 +2113,57 @@ export function findExportedDef( } return undefined; } + +/** + * `findExportedDef`, then — when the target file declares no such local — a + * name the target file IMPORTED and publishes as its own (a hub module). + * + * A Zig hub is a file made only of re-exports: `pub const Terminal = + * @import("Terminal.zig");`, `pub const Thing = @import("thing.zig").Thing;`. + * Its module scope owns NO local binding, so `findExportedDef` answers nothing + * for `terminal.Terminal.init()` or `t: stdx.Thing`, and the finalized channel + * (`lookupBindingsAt`) is the only place the published names exist — origin + * `import` / `namespace` / `reexport`, def already resolved to the declaring + * file. Measured on ghostty (788 Zig files) before and after this helper: + * CALLS into `src/terminal/` from outside that directory went from 46 to 253; + * on tigerbeetle, CALLS into its `stdx` hub from outside went from 837 to 1500. + * + * Opt-in per provider (`ScopeResolver.namespaceExportsIncludeImportedNames`): + * in most languages a module's imports are NOT its exports (a TypeScript + * `import { X }` publishes nothing), and the finalized edge cannot say whether + * the import was written `pub`. Zig opts in because a hub member a consumer + * can name through the hub IS public — a private import cannot be reached + * through the hub in code that compiles. + * + * Class-like defs win over anything else bound under the name (a re-exported + * type over a same-named value), and a name the finalized channel binds to + * several distinct defs is refused — never guess a namespace member. + */ +export function findExportedDefIncludingImportedNames( + targetFile: string, + memberName: string, + index: WorkspaceResolutionIndex, + scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + const local = findExportedDef(targetFile, memberName, index); + if (local !== undefined) return local; + const moduleScope = index.moduleScopeByFile.get(targetFile); + if (moduleScope === undefined) return undefined; + let picked: SymbolDefinition | undefined; + for (const ref of lookupBindingsAt(moduleScope.id, memberName, scopes)) { + if (ref.origin !== 'import' && ref.origin !== 'namespace' && ref.origin !== 'reexport') + continue; + if (picked === undefined) { + picked = ref.def; + continue; + } + if (picked.nodeId === ref.def.nodeId) continue; + if (isClassLike(ref.def.type) && !isClassLike(picked.type)) { + picked = ref.def; + continue; + } + if (isClassLike(picked.type) && !isClassLike(ref.def.type)) continue; + return undefined; // two distinct defs under one published name → refuse + } + return picked; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-hub/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-hub/src/main.zig new file mode 100644 index 000000000..aa6a596cc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-hub/src/main.zig @@ -0,0 +1,56 @@ +const stdx = @import("stdx/stdx.zig"); +const opmod = @import("op.zig"); +const Op = opmod.Op; + +fn c1_hub_named_static() void { + _ = stdx.Thing.make(); +} +fn c2_hub_module_static() void { + _ = stdx.PRNG.from_seed(1); +} +fn c3_hub_named_annotation() u32 { + var t: stdx.Thing = .{}; + return t.m(); +} +fn c4_hub_module_annotation() u64 { + var p: stdx.PRNG = .{}; + return p.next(); +} +fn c5_alias_then_static() u64 { + const PRNG = stdx.PRNG; + var p = PRNG.from_seed(2); + return p.next(); +} +fn c6_hub_call_return_typing() u64 { + var p = stdx.PRNG.from_seed(3); + return p.next(); +} +fn c7_enum_variant_receiver() u32 { + return Op.create.event_max(); +} +fn c8_enum_param_receiver(op: Op) u32 { + return op.event_max(); +} +fn c9_enum_qualified_variant_receiver() u32 { + return opmod.Op.lookup.event_max(); +} +fn c10_hub_generic_annotation() usize { + var headers: stdx.BoundedArrayType(u8, 4) = .{}; + return headers.count(); +} +fn c11_hub_reexported_fn() u32 { + return stdx.helper(); +} +pub fn main() void { + _ = c10_hub_generic_annotation(); + _ = c11_hub_reexported_fn(); + c1_hub_named_static(); + c2_hub_module_static(); + _ = c3_hub_named_annotation(); + _ = c4_hub_module_annotation(); + _ = c5_alias_then_static(); + _ = c6_hub_call_return_typing(); + _ = c7_enum_variant_receiver(); + _ = c8_enum_param_receiver(.create); + _ = c9_enum_qualified_variant_receiver(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-hub/src/op.zig b/gitnexus/test/fixtures/lang-resolution/zig-hub/src/op.zig new file mode 100644 index 000000000..7b18a27d1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-hub/src/op.zig @@ -0,0 +1,7 @@ +pub const Op = enum(u8) { + create = 1, + lookup = 2, + pub fn event_max(self: Op) u32 { + return @intFromEnum(self); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/bounded_array.zig b/gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/bounded_array.zig new file mode 100644 index 000000000..b359dd8a3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/bounded_array.zig @@ -0,0 +1,9 @@ +pub fn BoundedArrayType(comptime T: type, comptime capacity: usize) type { + return struct { + buffer: [capacity]T = undefined, + len: usize = 0, + pub fn count(array: *const @This()) usize { + return array.len; + } + }; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/prng.zig b/gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/prng.zig new file mode 100644 index 000000000..6132d6e91 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/prng.zig @@ -0,0 +1,9 @@ +const PRNG = @This(); +state: u64 = 0, +pub fn from_seed(seed: u64) PRNG { + return .{ .state = seed }; +} +pub fn next(self: *PRNG) u64 { + self.state += 1; + return self.state; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/stdx.zig b/gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/stdx.zig new file mode 100644 index 000000000..f76bba06c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/stdx.zig @@ -0,0 +1,6 @@ +pub const PRNG = @import("prng.zig"); +pub const Thing = @import("thing.zig").Thing; +pub const BoundedArrayType = @import("bounded_array.zig").BoundedArrayType; +pub const helper = @import("util.zig").helper; +// Private import: NOT reachable through the hub in compiling Zig. +const secret = @import("util.zig"); diff --git a/gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/thing.zig b/gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/thing.zig new file mode 100644 index 000000000..5dc07139b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/thing.zig @@ -0,0 +1,9 @@ +pub const Thing = struct { + v: u32 = 0, + pub fn make() Thing { + return .{}; + } + pub fn m(self: *Thing) u32 { + return self.v; + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/util.zig b/gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/util.zig new file mode 100644 index 000000000..7eb64fa4e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/util.zig @@ -0,0 +1,3 @@ +pub fn helper() u32 { + return 1; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/counter.zig b/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/counter.zig new file mode 100644 index 000000000..92afc1323 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/counter.zig @@ -0,0 +1,32 @@ +pub const Counter = struct { + n: u32 = 0, + // receiver named after the type, TigerBeetle style + pub fn incr(counter: *Counter) void { + counter.n += 1; + } + pub fn incr_self(self: *Counter) void { + self.n += 1; + } + pub fn by_value(counter: Counter) u32 { + return counter.n; + } +}; + +pub fn Pool(comptime Node: type) type { + return struct { + free: ?*Node = null, + // mach style: `pool: *@This()` + pub fn release(pool: *@This(), node: *Node) void { + _ = pool; + _ = node; + } + }; +} + +pub const Op = enum(u8) { + create = 1, + lookup = 2, + pub fn event_max(op: Op) u32 { + return @intFromEnum(op); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/main.zig new file mode 100644 index 000000000..63c71133a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/main.zig @@ -0,0 +1,39 @@ +const stdx = @import("stdx/stdx.zig"); +const counter = @import("counter.zig"); +const Counter = counter.Counter; + +fn use_named_receiver() void { + var c = Counter{}; + c.incr(); + c.incr_self(); + _ = c.by_value(); +} + +fn use_this_receiver() void { + const P = counter.Pool(u32); + var p = P{}; + var node: u32 = 0; + p.release(&node); +} + +fn use_enum_variant_receiver() u32 { + return counter.Op.create.event_max(); +} + +fn use_hub_static_call() u64 { + var prng = stdx.PRNG.from_seed(42); + return prng.next(); +} + +fn use_hub_generic_annotation() usize { + var headers: stdx.BoundedArrayType(u8, 4) = .{}; + return headers.count(); +} + +pub fn main() void { + use_named_receiver(); + use_this_receiver(); + _ = use_enum_variant_receiver(); + _ = use_hub_static_call(); + _ = use_hub_generic_annotation(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/bounded_array.zig b/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/bounded_array.zig new file mode 100644 index 000000000..b359dd8a3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/bounded_array.zig @@ -0,0 +1,9 @@ +pub fn BoundedArrayType(comptime T: type, comptime capacity: usize) type { + return struct { + buffer: [capacity]T = undefined, + len: usize = 0, + pub fn count(array: *const @This()) usize { + return array.len; + } + }; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/prng.zig b/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/prng.zig new file mode 100644 index 000000000..41d46319b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/prng.zig @@ -0,0 +1,9 @@ +const PRNG = @This(); +state: u64 = 0, +pub fn from_seed(seed: u64) PRNG { + return .{ .state = seed }; +} +pub fn next(prng: *PRNG) u64 { + prng.state += 1; + return prng.state; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/stdx.zig b/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/stdx.zig new file mode 100644 index 000000000..c2ecd7b49 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/stdx.zig @@ -0,0 +1,2 @@ +pub const PRNG = @import("prng.zig"); +pub const BoundedArrayType = @import("bounded_array.zig").BoundedArrayType; diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index 8314e753b..a09c64ace 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -844,3 +844,135 @@ describe.skipIf(!zigAvailable)( }); }, ); + +describe.skipIf(!zigAvailable)('Zig hub modules (`pub const X = @import(…)` re-exports)', () => { + // Audit of three real projects (tigerbeetle, mach, ghostty, 2026-09-02): + // a hub file made only of re-exports owns NO local binding, so the + // local-only export lookup answered nothing for `terminal.Terminal.init()`, + // `t: stdx.Thing`, `var p = stdx.PRNG.from_seed()`. Measured before → after: + // CALLS into ghostty's `src/terminal/` from outside it 46 → 253, into + // tigerbeetle's `stdx` hub from outside it 837 → 1500. The published names + // live in the finalized channel; `namespaceExportsIncludeImportedNames` lets + // the namespace paths read it. + let result: PipelineResult; + let calls: string[]; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-hub'), () => {}); + calls = getRelationships(result, 'CALLS') + .filter((e) => e.sourceFilePath.endsWith('main.zig')) + .map((e) => `${e.source} → ${e.target} @ ${e.targetFilePath}`); + }, 60000); + + it('resolves a static call through a hub-republished NAMED type (`stdx.Thing.make()`)', () => { + expect(calls).toContain('c1_hub_named_static → make @ src/stdx/thing.zig'); + }); + + it('resolves a static call through a hub-republished MODULE (`stdx.PRNG.from_seed()`, `PRNG = @import("prng.zig")`)', () => { + // ghostty's `terminal.Terminal.init(…)` shape (150 sites). + expect(calls).toContain('c2_hub_module_static → from_seed @ src/stdx/prng.zig'); + }); + + it('types a receiver ANNOTATED with the hub path (`t: stdx.Thing`, `p: stdx.PRNG`)', () => { + // tigerbeetle: 289 `: stdx.Type` annotations. + expect(calls).toContain('c3_hub_named_annotation → m @ src/stdx/thing.zig'); + expect(calls).toContain('c4_hub_module_annotation → next @ src/stdx/prng.zig'); + }); + + it('types a receiver from a constructor call through the hub (`var p = stdx.PRNG.from_seed()`)', () => { + expect(calls).toContain('c6_hub_call_return_typing → next @ src/stdx/prng.zig'); + expect(calls).toContain('c6_hub_call_return_typing → from_seed @ src/stdx/prng.zig'); + }); + + it('types a generic instantiation annotated through the hub (`h: stdx.BoundedArrayType(u8, 4)`)', () => { + expect(calls).toContain('c10_hub_generic_annotation → count @ src/stdx/bounded_array.zig'); + }); + + it('resolves a hub-republished free function (`stdx.helper()`)', () => { + expect(calls).toContain('c11_hub_reexported_fn → helper @ src/stdx/util.zig'); + }); + + it('still resolves the alias-then-use shape (`const PRNG = stdx.PRNG; PRNG.from_seed()`)', () => { + expect(calls).toContain('c5_alias_then_static → from_seed @ src/stdx/prng.zig'); + expect(calls).toContain('c5_alias_then_static → next @ src/stdx/prng.zig'); + }); + + it('never resolves a name through the hub that the hub does not publish', () => { + // `stdx.secret` (a private `const secret = @import(…)`) is not referenced + // by the fixture because it would not compile; the guard here is that no + // call from main.zig lands in util.zig except through the published + // `helper` — nothing leaks via the private import. + const intoUtil = calls.filter((c) => c.endsWith('@ src/stdx/util.zig')); + expect(intoUtil).toEqual(['c11_hub_reexported_fn → helper @ src/stdx/util.zig']); + }); +}); + +describe.skipIf(!zigAvailable)( + 'Zig enum variants as typed receivers (`Op.create.event_max()`)', + () => { + let result: PipelineResult; + let calls: string[]; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-hub'), () => {}); + calls = getRelationships(result, 'CALLS') + .filter((e) => e.sourceFilePath.endsWith('main.zig')) + .map((e) => `${e.source} → ${e.target}`); + }, 60000); + + it('dispatches a method called on a variant reached through the enum type', () => { + // tigerbeetle writes `Operation..event_max(…)` 147 times. The + // variant has no written type; its type is the enum, so the field walk + // that already handles `self.session.name()` types `Op.create` as `Op`. + expect(calls).toContain('c7_enum_variant_receiver → event_max'); + }); + + it('dispatches a method on an enum-typed parameter (`op: Op`)', () => { + expect(calls).toContain('c8_enum_param_receiver → event_max'); + }); + }, +); + +describe.skipIf(!zigAvailable)( + 'Zig receivers named after their type (`counter: *Counter`, `pool: *@This()`)', + () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-receivers'), () => {}); + }, 60000); + + it('labels container-typed first parameters as receivers: instance methods, receiver out of the arity', () => { + // `self` is a convention. tigerbeetle names the receiver after its type + // (777 of 1127 methods), mach writes `pool: *@This()` (764 of 833); both + // used to be `isStatic: true` with the receiver counted in `#`. + const methods = new Map(); + result.graph.forEachNode((n) => { + if (n.label === 'Method') methods.set(n.id, { isStatic: n.properties.isStatic === true }); + }); + expect(methods.get('Method:src/counter.zig:Counter.incr#0')?.isStatic).toBe(false); + expect(methods.get('Method:src/counter.zig:Counter.incr_self#0')?.isStatic).toBe(false); + expect(methods.get('Method:src/counter.zig:Counter.by_value#0')?.isStatic).toBe(false); + expect(methods.get('Method:src/counter.zig:Pool.release#1')?.isStatic).toBe(false); + expect(methods.get('Method:src/counter.zig:Op.event_max#0')?.isStatic).toBe(false); + expect(methods.get('Method:src/stdx/prng.zig:prng.next#0')?.isStatic).toBe(false); + // A factory keeps its static label and full arity. + expect(methods.get('Method:src/stdx/prng.zig:prng.from_seed#1')?.isStatic).toBe(true); + // Nothing is left under the old receiver-counted ids. + expect(methods.has('Method:src/counter.zig:Counter.incr#1')).toBe(false); + expect(methods.has('Method:src/counter.zig:Pool.release#2')).toBe(false); + }); + + it('dispatches calls onto those methods exactly as onto `self` methods', () => { + const calls = edgeSet(getRelationships(result, 'CALLS')); + expect(calls).toEqual( + expect.arrayContaining([ + 'use_named_receiver → incr', + 'use_named_receiver → incr_self', + 'use_named_receiver → by_value', + 'use_this_receiver → release', + ]), + ); + }); + }, +); diff --git a/gitnexus/test/unit/zig-extractors.test.ts b/gitnexus/test/unit/zig-extractors.test.ts index 40bbd7474..e32b387f2 100644 --- a/gitnexus/test/unit/zig-extractors.test.ts +++ b/gitnexus/test/unit/zig-extractors.test.ts @@ -194,6 +194,61 @@ const S = struct { expect(result!.methods[0].parameters.map((p) => p.name)).toEqual(['other', 'self']); expect(result!.methods[0].receiverType).toBeNull(); }); + + it('a FIRST parameter typed as the enclosing container is the receiver, whatever its name', () => { + // `self` is a convention, not a rule: tigerbeetle names the receiver after + // the type (777 of 1127 methods), mach writes `pool: *@This()` (764 of + // 833). Reading only `self` as the receiver labelled all of them static + // and counted the receiver in their arity. + const root = parse(` +const Counter = struct { + n: u32, + const Self = @This(); + pub fn incr(counter: *Counter) void { counter.n += 1; } + pub fn peek(counter: *const Counter) u32 { return counter.n; } + pub fn by_value(counter: Counter) u32 { return counter.n; } + pub fn via_this(c: *@This(), by: u32) void { c.n += by; } + pub fn via_alias(c: *Self) void { c.n = 0; } + pub fn make(n: u32) Counter { return .{ .n = n }; } + pub fn other(o: *Other) void { _ = o; } +}; +const Other = struct { x: u32 }; +pub fn Pool(comptime Node: type) type { + return struct { + pub fn release(pool: *@This(), node: *Node) void { _ = pool; _ = node; } + pub fn acquire(pool: *Pool(Node)) ?*Node { _ = pool; return null; } + }; +} +`).rootNode; + const counter = extractor.extract(find(root, 'struct_declaration'), ctx)!; + const byName = new Map(counter.methods.map((m) => [m.name, m])); + for (const [name, receiver] of [ + ['incr', '*Counter'], + ['peek', '*const Counter'], + ['by_value', 'Counter'], + ['via_this', '*@This()'], + ['via_alias', '*Self'], + ] as const) { + expect(byName.get(name)!.receiverType, name).toBe(receiver); + expect(byName.get(name)!.isStatic, name).toBe(false); + } + expect(byName.get('via_this')!.parameters.map((p) => p.name)).toEqual(['by']); + // A factory (no container-typed first parameter) and a fn whose first + // parameter is ANOTHER type stay static — the type, not the position, is + // what makes a receiver. + expect(byName.get('make')!.isStatic).toBe(true); + expect(byName.get('other')!.isStatic).toBe(true); + expect(byName.get('other')!.parameters.map((p) => p.name)).toEqual(['o']); + + const poolDecl = find(root, 'struct_declaration', 'struct {\n pub fn release'); + const pool = extractor.extract(poolDecl, ctx)!; + const poolByName = new Map(pool.methods.map((m) => [m.name, m])); + expect(poolByName.get('release')!.receiverType).toBe('*@This()'); + expect(poolByName.get('release')!.parameters.map((p) => p.name)).toEqual(['node']); + // `Pool(Node)` names the generic constructor's container. + expect(poolByName.get('acquire')!.receiverType).toBe('*Pool(Node)'); + expect(poolByName.get('acquire')!.isStatic).toBe(false); + }); }); describeZig('Zig VariableExtractor — container and import bindings are not variables', () => { @@ -288,31 +343,53 @@ describeZig('Zig scope captures — `@import` in TYPE position is not an import }); }); -describeZig('Zig scope captures — receiver is the FIRST parameter named self', () => { - function parameterBindings(src: string) { - return emitZigScopeCaptures(src, 'test.zig') - .filter((m) => m['@type-binding.parameter'] !== undefined) - .map((m) => interpretZigTypeBinding(m)) - .filter((b): b is NonNullable => b !== null) - .map((b) => `${b.boundName}:${b.source}`); - } +describeZig( + 'Zig scope captures — receiver is the FIRST parameter, named self or typed as the container', + () => { + function parameterBindings(src: string) { + return emitZigScopeCaptures(src, 'test.zig') + .filter((m) => m['@type-binding.parameter'] !== undefined) + .map((m) => interpretZigTypeBinding(m)) + .filter((b): b is NonNullable => b !== null) + .map((b) => `${b.boundName}:${b.source}`); + } - it('marks a leading self as the receiver and later parameters as annotations', () => { - expect(parameterBindings('const S = struct { fn m(self: *S, other: u32) void {} };')).toEqual([ - 'self:self', - 'other:parameter-annotation', - ]); - }); + it('marks a leading self as the receiver and later parameters as annotations', () => { + expect(parameterBindings('const S = struct { fn m(self: *S, other: u32) void {} };')).toEqual( + ['self:self', 'other:parameter-annotation'], + ); + }); - it('a later parameter named self is an ordinary parameter, not a receiver', () => { - // Legal Zig; `zigReceiverBinding` picks the `self`-sourced binding, so - // sourcing this one as `self` would turn a static fn into an instance method. - expect(parameterBindings('const S = struct { fn f(other: u32, self: S) void {} };')).toEqual([ - 'other:parameter-annotation', - 'self:parameter-annotation', - ]); - }); -}); + it('a leading parameter typed as the enclosing container is the receiver, whatever its name', () => { + // Same rule as the method extractor (`zigReceiverParameter`): the two + // phases must agree on what a method is. + expect( + parameterBindings('const S = struct { fn m(state: *S, other: u32) void {} };'), + ).toEqual(['state:self', 'other:parameter-annotation']); + expect(parameterBindings('const S = struct { fn m(s: *@This()) void {} };')).toEqual([ + 's:self', + ]); + expect( + parameterBindings( + 'const S = struct { const Self = @This(); fn m(s: *const Self) void {} };', + ), + ).toEqual(['s:self']); + // A first parameter of ANOTHER type is a plain parameter. + expect( + parameterBindings('const S = struct { fn m(o: *Other) void {} }; const Other = struct {};'), + ).toEqual(['o:parameter-annotation']); + }); + + it('a later parameter named self is an ordinary parameter, not a receiver', () => { + // Legal Zig; `zigReceiverBinding` picks the `self`-sourced binding, so + // sourcing this one as `self` would turn a static fn into an instance method. + expect(parameterBindings('const S = struct { fn f(other: u32, self: S) void {} };')).toEqual([ + 'other:parameter-annotation', + 'self:parameter-annotation', + ]); + }); + }, +); describeZig('Zig callable-flow captures — member calls, receiver formals, decl literals', () => { const src = ` @@ -1250,7 +1327,7 @@ 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', () => { + it('emits one @type-binding.field per typed field and per enum variant — the nominal type, sigils stripped, aliases rewritten', () => { const fields = emitZigScopeCaptures(SRC, 'src/Page.zig') .filter((m) => m['@type-binding.field'] !== undefined) .map((m) => { @@ -1278,7 +1355,12 @@ pub const Payload = union(enum) { x: u32, y: Counter }; ['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 + // enum variants carry no written type, but they HAVE one — the enum + // itself — so `Kind.a.method()` types `Kind.a` as `Kind` (tigerbeetle's + // `Operation.create_accounts.event_max()`, 147 sites) + ['a', 'Kind', 'Kind', 'annotation'], + ['b', 'Kind', 'Kind', 'annotation'], + // union variants carry a type ['x', 'u32', 'u32', 'annotation'], ['y', 'Counter', 'Counter', 'annotation'], ]);