mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
feat(zig): resolve hub re-exports, enum-variant receivers and type-named receivers (real-project audit)
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.
This commit is contained in:
parent
e2b13233ce
commit
e5b1067209
22 changed files with 713 additions and 66 deletions
|
|
@ -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<string> {
|
||||
const out = new Set<string>();
|
||||
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:<file>:<name>.<fn>`) 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.<variant>.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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, readonly string[]>;
|
||||
/** 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, readonly string[]>,
|
||||
): 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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
56
gitnexus/test/fixtures/lang-resolution/zig-hub/src/main.zig
vendored
Normal file
56
gitnexus/test/fixtures/lang-resolution/zig-hub/src/main.zig
vendored
Normal file
|
|
@ -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();
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/zig-hub/src/op.zig
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/zig-hub/src/op.zig
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub const Op = enum(u8) {
|
||||
create = 1,
|
||||
lookup = 2,
|
||||
pub fn event_max(self: Op) u32 {
|
||||
return @intFromEnum(self);
|
||||
}
|
||||
};
|
||||
9
gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/bounded_array.zig
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/bounded_array.zig
vendored
Normal file
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/prng.zig
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/prng.zig
vendored
Normal file
|
|
@ -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;
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/stdx.zig
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/stdx.zig
vendored
Normal file
|
|
@ -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");
|
||||
9
gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/thing.zig
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/thing.zig
vendored
Normal file
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
3
gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/util.zig
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/util.zig
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub fn helper() u32 {
|
||||
return 1;
|
||||
}
|
||||
32
gitnexus/test/fixtures/lang-resolution/zig-receivers/src/counter.zig
vendored
Normal file
32
gitnexus/test/fixtures/lang-resolution/zig-receivers/src/counter.zig
vendored
Normal file
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
39
gitnexus/test/fixtures/lang-resolution/zig-receivers/src/main.zig
vendored
Normal file
39
gitnexus/test/fixtures/lang-resolution/zig-receivers/src/main.zig
vendored
Normal file
|
|
@ -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();
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/bounded_array.zig
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/bounded_array.zig
vendored
Normal file
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/prng.zig
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/prng.zig
vendored
Normal file
|
|
@ -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;
|
||||
}
|
||||
2
gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/stdx.zig
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/stdx.zig
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub const PRNG = @import("prng.zig");
|
||||
pub const BoundedArrayType = @import("bounded_array.zig").BoundedArrayType;
|
||||
|
|
@ -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.<variant>.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 `#<arity>`.
|
||||
const methods = new Map<string, { isStatic: boolean }>();
|
||||
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',
|
||||
]),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<typeof b> => 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<typeof b> => 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'],
|
||||
]);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue