fix(zig): give function-local and anonymous containers an identity (F8)

`const R = struct {…}` declared inside a fn (Lightpanda's reflection.zig
has ~20, one per builder) all collapsed onto one `Struct:<file>:R` with one
`R.get`; anonymous containers (`std.sort.pdq(…, struct { fn lessThan … }
.lessThan)`, `const byte_size = struct { fn it … }.it;`, `?struct { min,
max }` field types) had no identity at all, so their fns were OWNERLESS
Methods (`Method:<file>:lessThan#3`) that collided across a file.

`zigContainerName` now yields the graph IDENTITY on both phases:
  - function-local named: `<enclosing callable>$<name>` — `Reflect.string$R`
    (Java local-class `$` chain; `populateClassOwnedMembers` leaves it whole);
  - anonymous: `<host>$<ordinal>` — `build$1`, `Outer$1`, `Page$1`
    (javac's `Outer$1` numbering per host, in source order);
  - a `test` host is keyed `test@L<line>` (its string does not survive the
    class extractor's qualified-name normalization).
`zigContainerBindingName` keeps the spelling code writes (`R`) for scope
bindings and `@This()` alias rewrites (`@declaration.binding-name`).

Structure phase: bare `(struct|enum|union|opaque_declaration)` rules mint the
local/anonymous nodes via the class extractor; `shouldSkipDefinitionCapture`
keeps exactly one rule per container (`zigContainerAnchor`); a new
grammar-neutral `resolveContainerTypeOwner` provider hook lets the shared
owner walk name a container from context, so `Method:<file>:Reflect.string$R
.get#0` and its HAS_METHOD source agree by construction. Scope phase: the
wrapper group splits name/binding-name for locals and anonymous containers
get synthesized `@declaration.<kind>` defs (`is-synthetic`).

Lightpanda: ownerless Methods 14 → 0, fns without a node 55 → 0, ownerless
Properties 276 → 5, HAS_METHOD 8003 → 8074, HAS_PROPERTY 7275 → 7403,
CALLS 15838 → 15857, Struct 1905 → 2199; no resolution bucket dropped.
This commit is contained in:
Navid EMAD 2026-08-18 15:00:22 +02:00
parent 9f958557d6
commit 4ec68c5f82
No known key found for this signature in database
12 changed files with 833 additions and 23 deletions

View file

@ -4,6 +4,7 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js';
import {
isZigFileStruct,
ZIG_CONTAINER_TYPES,
zigContainerLabel,
zigContainerName,
} from '../../languages/zig/captures.js';
@ -12,11 +13,16 @@ import {
*
* const Point = struct { ... };
* pub fn List(comptime T: type) type { return struct { ... }; }
* fn build() void { const R = struct { ... }; sort(struct { fn lt … }.lt); }
*
* The binding name is the first identifier child of the parent
* variable_declaration, or the generic type constructor's name —
* `zigContainerName` is the single source shared with the field/method
* extractors so owner ids and node ids agree by construction.
* The identity is the binding name (first identifier of the parent
* variable_declaration), the generic type constructor's name, or — for a
* FUNCTION-LOCAL or ANONYMOUS container — a synthesized `host$Name` /
* `host$N` (F8). `zigContainerName` is the single source shared with the
* field/method extractors and the owner walk, so owner ids and node ids
* agree by construction. Which of the (up to three) ZIG_QUERIES rules that
* match one container gets to mint it is decided by the provider's
* `shouldSkipDefinitionCapture` (`isZigRedundantContainerCapture`).
*/
const extractZigContainerName = (node: SyntaxNode, filePath?: string): string | undefined =>
zigContainerName(node, filePath);
@ -25,13 +31,7 @@ const extractZigContainerType = (node: SyntaxNode): ClassLikeNodeLabel | undefin
// The file itself, when it declares top-level fields (file-struct); a
// namespace-only file is not a type — `extract` then yields no symbol.
if (node.type === 'source_file') return isZigFileStruct(node) ? 'Struct' : undefined;
if (node.type === 'struct_declaration') return 'Struct';
if (node.type === 'enum_declaration') return 'Enum';
if (node.type === 'union_declaration') return 'Union';
// `opaque {}` is a fieldless container that may own methods — Struct is the
// closest class-like label (rationale in ZIG_QUERIES).
if (node.type === 'opaque_declaration') return 'Struct';
return undefined;
return zigContainerLabel(node);
};
export const zigClassConfig: ClassExtractionConfig = {

View file

@ -174,6 +174,26 @@ interface LanguageProviderConfig {
filePath: string,
) => { readonly name: string; readonly label: NodeLabel } | null;
/**
* The type a CONTAINER node declares, when the language names it from its
* context rather than from a name child of the node — a binding wrapper,
* an enclosing callable, an ordinal among anonymous siblings (Zig:
* `const T = struct {…}` is `T`; a function-local `const R = struct {…}`
* inside `fn string` is `string$R`; `struct { fn lessThan … }.lessThan`
* passed to a sort is `<fn>$1`).
*
* Consulted by the enclosing-owner walk for every `CLASS_CONTAINER_TYPES`
* node it meets (after `resolveEnclosingOwner` remapping), BEFORE the
* generic name-child derivation; return `null` to fall back to it. The name
* must be the one the class-like node is minted under
* (`<label>:<file>:<name>`), so a member's owner id and the node id agree
* by construction.
* Default: undefined (containers are named by the generic derivation). */
readonly resolveContainerTypeOwner?: (
container: SyntaxNode,
filePath: string,
) => { readonly name: string; readonly label: NodeLabel } | null;
// ── Enclosing function resolution ───────────────────────────────
/** Resolve the enclosing function name + label from an AST ancestor node
* that is NOT a standard FUNCTION_NODE_TYPE. For languages where the

View file

@ -53,11 +53,15 @@ import {
interpretZigTypeBinding,
isZigContainerMethod,
isZigFileStruct,
isZigRedundantContainerCapture,
isZigTypeShadowingBinding,
zigArityCompatibility,
zigContainerLabel,
zigContainerName,
zigFileStructName,
zigBindingScopeFor,
zigReceiverBinding,
ZIG_CONTAINER_TYPES,
} from './zig/index.js';
export const zigProvider = defineLanguage({
@ -79,16 +83,40 @@ export const zigProvider = defineLanguage({
variableExtractor: createVariableExtractor(zigVariableConfig),
// A `const`/`var` whose value is a container or an `@import` is the
// Struct/Enum/Union node or the import binding, not a Const beside it.
// Up to three ZIG_QUERIES rules match one container (wrapper, type
// constructor, bare container — F8); `zigContainerAnchor` names the one
// that mints it and the others are dropped here.
shouldSkipDefinitionCapture: (captureMap, defaultLabel) => {
if (defaultLabel !== 'Const' && defaultLabel !== 'Variable') return false;
const decl = captureMap['definition.const'] ?? captureMap['definition.variable'];
return decl !== undefined && isZigTypeShadowingBinding(decl);
if (defaultLabel === 'Const' || defaultLabel === 'Variable') {
const decl = captureMap['definition.const'] ?? captureMap['definition.variable'];
return decl !== undefined && isZigTypeShadowingBinding(decl);
}
if (defaultLabel === 'Struct' || defaultLabel === 'Enum' || defaultLabel === 'Union') {
const decl =
captureMap['definition.struct'] ??
captureMap['definition.enum'] ??
captureMap['definition.union'];
return decl !== undefined && isZigRedundantContainerCapture(decl, captureMap['name']);
}
return false;
},
// A file whose top level declares fields IS a struct named after the file
// (`Page.zig` → `Page`): its top-level fns/fields are members of that
// Struct. Files without fields are namespaces and own nothing.
resolveFileTypeOwner: (root, filePath) =>
isZigFileStruct(root) ? { name: zigFileStructName(filePath), label: 'Struct' } : null,
// Every container's identity comes from `zigContainerName` — the binding
// name for `const T = struct {…}` at file/container level, the fn name for
// a generic type constructor, and (F8) `string$R` / `build$1` for
// function-local and anonymous containers, which no name child spells. The
// class extractor names the node from the same function, so a member's
// owner id (`Method:<file>:string$R.get`) and the node id agree.
resolveContainerTypeOwner: (container, filePath) => {
if (!ZIG_CONTAINER_TYPES.has(container.type)) return null;
const name = zigContainerName(container, filePath);
const label = zigContainerLabel(container);
return name !== undefined && label !== undefined ? { name, label } : null;
},
labelOverride: (functionNode, defaultLabel) => {
if (defaultLabel !== 'Function') return defaultLabel;
if (isZigContainerMethod(functionNode)) return 'Method';

View file

@ -143,16 +143,24 @@ export function isZigPublishingImport(declNode: SyntaxNode): boolean {
return declNode.parent?.type === 'source_file' && hasZigPubKeyword(declNode);
}
/** The binding name of a Zig container node, or undefined for a truly
* anonymous one. Two shapes carry a name:
/** The BINDING name of a Zig container node — the spelling code uses to
* refer to it — or undefined for a truly anonymous one. Three shapes carry
* one:
* - a file-struct: the file stem (`Page.zig` → `Page`);
* - `const Point = struct {…}` — the first identifier of the wrapping
* `variable_declaration`;
* - `pub fn List(comptime T: type) type { return struct {…}; }` — the
* generic type constructor. Zig has no other spelling for a generic
* type, and every reader calls the returned container `List`, so the
* enclosing function's name IS the type name (`ArrayList(u8)`).
* Single source for the class/field/method extractor configs. */
export function zigContainerName(containerNode: SyntaxNode, filePath?: string): string | undefined {
* This is what scope-side NAME BINDINGS use (`R.get()`, `self: *R`, a
* `const Self = @This();` rewrite). The graph IDENTITY of a container is
* `zigContainerName`, which equals the binding name except for the
* function-local and anonymous shapes (F8). */
export function zigContainerBindingName(
containerNode: SyntaxNode,
filePath?: string,
): string | undefined {
if (containerNode.type === 'source_file') {
return filePath !== undefined && isZigFileStruct(containerNode)
? zigFileStructName(filePath)
@ -170,6 +178,210 @@ export function zigContainerName(containerNode: SyntaxNode, filePath?: string):
return zigTypeConstructorOf(containerNode)?.childForFieldName('name')?.text;
}
/** 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
* extractor configs, the provider's `resolveContainerTypeOwner` hook (the
* structure phase's owner walk) and `emitZigScopeCaptures`, so the two
* phases agree by construction.
*
* - file-struct / `const T = struct {…}` at file or container level /
* generic type constructor: the binding name (`zigContainerBindingName`).
* - FUNCTION-LOCAL named container (F8) — `const R = struct {…}` inside a
* `fn` or `test` body: `<enclosing callable>$<name>`, e.g. `string$R`,
* `Reflect.string$R`. Zig code declares such helper containers per
* builder function (Lightpanda's `reflection.zig` has ~20 `const R =
* struct { fn get… fn set… }`), and by binding name alone they all
* collapsed onto ONE `Struct:<file>:R` with one `R.get`. Java's local
* classes are keyed the same way (`Outer$1Local`); the `$` is what tells
* the shared `populateClassOwnedMembers` the name is already complete.
* Two same-named locals in sibling blocks of ONE callable still share
* an identity (Zig code does not do that; javac's ordinal would).
* - ANONYMOUS container (F8) — `struct { fn lessThan(…) … }.lessThan`
* passed to `std.sort.pdq`, `const cmp = struct {…}.lt;`, a field or
* parameter typed `struct { min: u32 }`: `<host>$<ordinal>` where the
* host is the enclosing callable (`build$1`), else the enclosing
* container (`Outer$1`), else the file stem (`util$1`), and the ordinal
* counts the anonymous containers of that host in source order —
* javac's `Outer$1` rule (`synthesizeJavaTypeIdentity`). Without an
* identity their fns were OWNERLESS Methods (`Method:<file>:lessThan`),
* and same-named ones in one file collided on a single node.
* Deterministic in the tree alone, so any two walks over one source agree. */
export function zigContainerName(containerNode: SyntaxNode, filePath?: string): string | undefined {
const binding = zigContainerBindingName(containerNode, filePath);
if (containerNode.type === 'source_file') return binding;
if (!ZIG_CONTAINER_TYPES.has(containerNode.type)) return undefined;
const host = zigIdentityHost(containerNode);
if (binding !== undefined) {
if (containerNode.parent?.type !== 'variable_declaration' || host === null) return binding;
if (!isZigCallableNode(host)) return binding; // file / container level: unchanged
return `${zigCallableQualifiedName(host, filePath)}$${binding}`;
}
const ordinal = zigAnonymousContainerOrdinal(containerNode);
const prefix = host === null ? undefined : zigAnonymousHostPrefix(host, filePath);
return `${prefix ?? ''}$${ordinal}`;
}
/** The label the graph gives a container node (`opaque {}` is a fieldless
* container that may own methods — Struct, see ZIG_QUERIES). */
export function zigContainerLabel(
containerNode: SyntaxNode,
): 'Struct' | 'Enum' | 'Union' | undefined {
switch (containerNode.type) {
case 'struct_declaration':
case 'opaque_declaration':
return 'Struct';
case 'enum_declaration':
return 'Enum';
case 'union_declaration':
return 'Union';
default:
return undefined;
}
}
/** Which ZIG_QUERIES rule mints a container's graph node (F8):
* - 'wrapper': `const T = struct {…}` at file or container level — the
* `variable_declaration … @definition.struct` rule (name from `@name`);
* - 'constructor': the container a generic type constructor returns —
* the `fn … type { return struct {…}; }` rule;
* - 'container': everything the bare `(struct_declaration)
* @definition.struct` rule owns — FUNCTION-LOCAL named containers (their
* identity `string$R` is not a capture, so the class extractor names
* them via `zigContainerName`) and ANONYMOUS containers.
* The provider's `shouldSkipClassCapture` drops the other rules' matches for
* the same node so each container is minted exactly once. */
export function zigContainerAnchor(
containerNode: SyntaxNode,
): 'wrapper' | 'constructor' | 'container' | 'file' {
if (containerNode.type === 'source_file') return 'file';
if (zigTypeConstructorOf(containerNode) !== null) return 'constructor';
if (
containerNode.parent?.type === 'variable_declaration' &&
zigContainerBindingName(containerNode) !== undefined
) {
const host = zigIdentityHost(containerNode);
return host !== null && isZigCallableNode(host) ? 'container' : 'wrapper';
}
return 'container';
}
/** Is this `@definition.<container>` match of ZIG_QUERIES REDUNDANT — i.e.
* minted by another rule for the same container (F8)? `definitionNode` is
* the match anchor (the wrapper `variable_declaration`, or the container
* node for the type-constructor and bare-container rules), `nameNode` its
* `@name` capture (the type-constructor rule captures the fn's name, the
* bare rule captures none). Wired through the provider's
* `shouldSkipDefinitionCapture`, which the definition phase consults for
* EVERY label — `shouldSkipClassCapture` would not see `Union`. */
export function isZigRedundantContainerCapture(
definitionNode: SyntaxNode,
nameNode: SyntaxNode | undefined,
): boolean {
let container: SyntaxNode | undefined;
if (ZIG_CONTAINER_TYPES.has(definitionNode.type)) {
container = definitionNode;
} else if (definitionNode.type === 'variable_declaration') {
for (let i = 0; i < definitionNode.namedChildCount; i++) {
const child = definitionNode.namedChild(i);
if (child !== null && ZIG_CONTAINER_TYPES.has(child.type)) {
container = child;
break;
}
}
}
if (container === undefined) return false;
const anchor = zigContainerAnchor(container);
if (definitionNode.type === 'variable_declaration') return anchor !== 'wrapper';
return nameNode !== undefined ? anchor !== 'constructor' : anchor !== 'container';
}
/** A callable body in Zig: a `fn` or a `test` block. */
function isZigCallableNode(node: SyntaxNode): boolean {
return node.type === 'function_declaration' || node.type === 'test_declaration';
}
/** The nearest ancestor that hosts identities — a callable, a container or
* the file — starting from `node`'s parent. */
function zigIdentityHost(node: SyntaxNode): SyntaxNode | null {
let cur = node.parent;
while (cur !== null && cur !== undefined) {
if (isZigCallableNode(cur) || ZIG_CONTAINER_TYPES.has(cur.type) || cur.type === 'source_file') {
return cur;
}
cur = cur.parent;
}
return null;
}
/** A callable's qualified name as the structure phase spells it: its owner
* container's identity, a dot, its own name (`Reflect.string`, `Page.init`,
* `helper` in a namespace file). A `test` block is keyed by its LINE
* (`test@L<line>`, 1-based; a test is a top-level declaration, so the line
* identifies it), not by its string: the class extractor's
* `buildQualifiedName` normalizes a graph node's `qualifiedName`
* (whitespace stripped, `::` → `.`), so an identity carrying `"Function:
* requested termination …"` would key the node under one spelling and the
* scope def under another, and `State{}` in that test would resolve to
* nothing. No `:` either (`row:col` would do), so an id keeps a single
* `label:file:name` colon structure for anything that splits on it. */
export function zigCallableQualifiedName(fnNode: SyntaxNode, filePath?: string): string {
const own =
fnNode.type === 'test_declaration'
? `test@L${fnNode.startPosition.row + 1}`
: (fnNode.childForFieldName('name')?.text ?? `fn@L${fnNode.startPosition.row + 1}`);
const host = zigIdentityHost(fnNode);
if (host === null) return own;
const hostName = ZIG_CONTAINER_TYPES.has(host.type)
? zigContainerName(host, filePath)
: host.type === 'source_file'
? zigContainerBindingName(host, filePath) // file-struct stem; nothing for a namespace file
: zigCallableQualifiedName(host, filePath);
return hostName === undefined ? own : `${hostName}.${own}`;
}
/** The `<host>` part of an anonymous container's identity. */
function zigAnonymousHostPrefix(host: SyntaxNode, filePath?: string): string | undefined {
if (isZigCallableNode(host)) return zigCallableQualifiedName(host, filePath);
if (ZIG_CONTAINER_TYPES.has(host.type)) return zigContainerName(host, filePath);
// File level: the file stem — a Zig file is itself a struct, so this is
// javac's `Outer$1` with the file as `Outer` (`Page$1`, `util$1`).
return filePath === undefined ? undefined : zigFileStructName(filePath);
}
/** 1-based ordinal of an anonymous container among the anonymous containers
* sharing its identity host, in source order. Computed once per tree and
* memoized on the tree object (a node without `.tree` recomputes from its
* root); position-derived, so two parses of one source agree. */
const zigAnonymousOrdinalMemo = new WeakMap<object, Map<number, number>>();
function zigAnonymousContainerOrdinal(containerNode: SyntaxNode): number {
const tree = (containerNode as { tree?: object }).tree;
let table = tree === undefined ? undefined : zigAnonymousOrdinalMemo.get(tree);
if (table === undefined) {
let root: SyntaxNode = containerNode;
while (root.parent !== null && root.parent !== undefined) root = root.parent;
const built = new Map<number, number>();
const perHost = new Map<number, number>();
const visit = (node: SyntaxNode): void => {
if (ZIG_CONTAINER_TYPES.has(node.type) && zigContainerBindingName(node) === undefined) {
const host = zigIdentityHost(node);
const key = host === null ? -1 : host.id;
const next = (perHost.get(key) ?? 0) + 1;
perHost.set(key, next);
built.set(node.id, next);
}
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null) visit(child);
}
};
visit(root);
if (tree !== undefined) zigAnonymousOrdinalMemo.set(tree, built);
table = built;
}
return table.get(containerNode.id) ?? 0;
}
/** For a container that is the direct `return` value of a function whose
* return type is `type` — `fn List(comptime T: type) type { return struct
* {…}; }` — the function_declaration; null for any other placement. Only the
@ -242,10 +454,15 @@ function republishMarker(stmt: SyntaxNode): Record<string, Capture> {
* returns a map from the DECLARING container node id to `[aliasName,
* containerName]`. The file-level alias maps to the file-struct name (only
* when the file is a file-struct — a namespace-only file's `const js =
* @This();` stays a Const and is not a type to rewrite to). */
* @This();` stays a Const and is not a type to rewrite to). The container
* side is the name the SCOPE BINDS: the binding name (`R` for a function-
* local `const R = struct {…}`, whose identity `string$R` is not a binding),
* or the synthetic identity of an anonymous container, which is bound under
* that very name (F8). */
function collectZigThisAliases(
root: SyntaxNode,
fileStructName: string | undefined,
filePath: string,
): Map<number, { readonly alias: string; readonly container: string }> {
const out = new Map<number, { readonly alias: string; readonly container: string }>();
const visit = (node: SyntaxNode): void => {
@ -265,7 +482,7 @@ function collectZigThisAliases(
out.set(owner.id, { alias: named[0]!.text, container: fileStructName });
}
} else if (owner !== null && ZIG_CONTAINER_TYPES.has(owner.type)) {
const containerName = zigContainerName(owner);
const containerName = zigContainerBindingName(owner) ?? zigContainerName(owner, filePath);
if (containerName !== undefined) {
out.set(owner.id, { alias: named[0]!.text, container: containerName });
}
@ -711,7 +928,7 @@ export function emitZigScopeCaptures(
}
// `@This()` aliases: alias name ↦ the container it names (file stem for the
// file-struct, binding name for `const Self = @This();` inside a container).
const thisAliases = collectZigThisAliases(root, fileStructName);
const thisAliases = collectZigThisAliases(root, fileStructName, _filePath);
// F6: fn-local names per function node, for `zigCallReturnTypeOf`.
const fnLocalNames = new Map<number, Set<string>>();
@ -971,6 +1188,23 @@ export function emitZigScopeCaptures(
'true',
);
}
// F8 — FUNCTION-LOCAL named container (`const R = struct {…}` inside a
// fn body): the def's qualified name is its identity (`string$R`,
// matching the graph node the structure phase mints via
// `zigContainerName`), while the scope still binds the spelling code
// uses (`R.get()`, `self: *R`) — the Java local-class split
// (`@declaration.binding-name`, java/captures.ts).
if (
containerAnchor !== undefined &&
nameNode !== undefined &&
zigContainerAnchor(containerAnchor) === 'container'
) {
const identity = zigContainerName(containerAnchor, _filePath);
if (identity !== undefined && identity !== nameNode.text) {
grouped['@declaration.binding-name'] = grouped['@declaration.name']!;
grouped['@declaration.name'] = syntheticCapture('@declaration.name', nameNode, identity);
}
}
}
// Relabel container-nested fns Function → Method (provider labelOverride
@ -1097,7 +1331,49 @@ export function emitZigScopeCaptures(
}
}
// F8 — ANONYMOUS containers (`std.sort.pdq(…, struct { fn lessThan … }
// .lessThan)`, `const cmp = struct { fn lt … }.lt;`, `clamp: ?struct { min:
// u32 }`): no query rule binds them, so their Class scope owned no def and
// their fns were ownerless, colliding Methods. Synthesize the def the
// structure phase mints (`zigContainerName` → `build$1`), anchored on the
// container node so it shares the `@scope.class` range: the def joins that
// scope's ownedDefs (`populateClassOwnedMembers` then qualifies its members
// `build$1.lessThan` and stamps their ownerId) and the name auto-hoists to
// the enclosing scope, where a `const Self = @This();` rewrite can find it.
out.push(...synthesizeZigAnonymousContainerDeclarations(root, _filePath));
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, ZIG_CALLABLE_CAPTURE_OPTIONS));
return out;
}
/** One `@declaration.<kind>` group per anonymous container in the tree —
* see the call site in `emitZigScopeCaptures` (F8). Named after
* `synthesizeJavaAnonymousClassDeclarations`, which does the same for
* `new Runnable() {…}` bodies. */
function synthesizeZigAnonymousContainerDeclarations(
root: SyntaxNode,
filePath: string,
): CaptureMatch[] {
const out: CaptureMatch[] = [];
const visit = (node: SyntaxNode): void => {
if (ZIG_CONTAINER_TYPES.has(node.type) && zigContainerBindingName(node) === undefined) {
const identity = zigContainerName(node, filePath);
const label = zigContainerLabel(node);
if (identity !== undefined && label !== undefined) {
const tag = `@declaration.${label.toLowerCase()}`;
out.push({
[tag]: nodeToCapture(tag, node),
'@declaration.name': syntheticCapture('@declaration.name', node, identity),
'@declaration.is-synthetic': syntheticCapture('@declaration.is-synthetic', node, 'true'),
});
}
}
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null) visit(child);
}
};
visit(root);
return out;
}

View file

@ -5,7 +5,12 @@ export {
isZigFileStruct,
isZigFileThisAlias,
isZigKeywordDeclaration,
isZigRedundantContainerCapture,
isZigTypeShadowingBinding,
zigCallableQualifiedName,
zigContainerAnchor,
zigContainerBindingName,
zigContainerLabel,
zigContainerName,
zigFileStructName,
zigImportRootOf,

View file

@ -2518,6 +2518,21 @@ export const ZIG_QUERIES = `
(enum_declaration) @definition.enum))))
(#eq? @_ret "type"))
; Function-local and anonymous containers (F8): \`fn string() { const R =
; struct { fn get … }; }\` (Lightpanda's reflection.zig declares one \`R\` per
; builder fn), \`std.sort.pdq(T, items, {}, struct { fn lessThan … }.lessThan)\`,
; \`const cmp = struct { fn lt … }.lt;\`, a field typed \`?struct { min: u32 }\`.
; No name child spells their identity, so these rules match EVERY container
; and the class extractor names the node from \`zigContainerName\` (\`string$R\`,
; \`build$1\`) — the same function the owner walk uses for their fns, which
; were ownerless, colliding Methods before. The bound shapes above match too;
; the provider's \`shouldSkipDefinitionCapture\` keeps exactly one rule per
; container (\`zigContainerAnchor\`).
(struct_declaration) @definition.struct
(enum_declaration) @definition.enum
(union_declaration) @definition.union
(opaque_declaration) @definition.struct
; Container fields (struct fields, enum variants, union variants) — all are
; \`container_field\` in the grammar and all become Property (C labels its
; enumerators Const; Rust captures no variants; Zig's own vocabulary is

View file

@ -906,6 +906,18 @@ export const findEnclosingClassInfo = (
root: SyntaxNode,
filePath: string,
) => { readonly name: string; readonly label: NodeLabel } | null,
/**
* Optional: the type a CONTAINER node declares
* (`LanguageProvider.resolveContainerTypeOwner`). Consulted for every
* `CLASS_CONTAINER_TYPES` node the walk meets, before the generic name-child
* derivation, for languages whose containers are named from context (a
* binding wrapper, an enclosing callable, an anonymous ordinal). Null falls
* through to the generic derivation.
*/
resolveContainerTypeOwner?: (
container: SyntaxNode,
filePath: string,
) => { readonly name: string; readonly label: NodeLabel } | null,
): EnclosingClassInfo | null => {
let current = node.parent;
let iterations = 0;
@ -1003,6 +1015,19 @@ export const findEnclosingClassInfo = (
}
}
// A container the PROVIDER names from context (binding wrapper,
// enclosing callable, anonymous ordinal — Zig). The name is what the
// class-like node is minted under, so owner id == node id.
if (resolveContainerTypeOwner !== undefined) {
const containerOwner = resolveContainerTypeOwner(current, filePath);
if (containerOwner !== null) {
return {
classId: generateId(containerOwner.label, `${filePath}:${containerOwner.name}`),
className: containerOwner.name,
};
}
}
// Rust impl_item: for `impl Trait for Struct {}`, pick the type after `for`
// NOTE: This impl_item ownership logic is mirrored in
// method-extractors/configs/rust.ts (extractOwnerName, metadata only).

View file

@ -942,6 +942,7 @@ const callableOwnQualifiedName = (
provider.resolveEnclosingOwner,
undefined,
provider.resolveFileTypeOwner,
provider.resolveContainerTypeOwner,
)
: null;
const owner = prefix ?? classInfo?.className;
@ -991,6 +992,7 @@ const findEnclosingFunctionId = (
provider.resolveEnclosingOwner,
undefined,
provider.resolveFileTypeOwner,
provider.resolveContainerTypeOwner,
);
const encLang = getLanguageFromFilename(filePath);
const standaloneMethodInfo =
@ -1079,6 +1081,7 @@ const findEnclosingFunctionId = (
provider.resolveEnclosingOwner,
undefined,
provider.resolveFileTypeOwner,
provider.resolveContainerTypeOwner,
);
// Same nesting rule as the generic branch above (#2699). Anchored on
// `sigNode`-equivalent (`current.previousSibling ?? current`) so Dart,
@ -1144,6 +1147,7 @@ const cachedFindEnclosingClassInfo = (
resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null,
getQualifiedOwnerName?: (node: SyntaxNode, simpleName: string) => string | null,
resolveFileTypeOwner?: LanguageProvider['resolveFileTypeOwner'],
resolveContainerTypeOwner?: LanguageProvider['resolveContainerTypeOwner'],
): EnclosingClassInfo | null => {
const cached = classIdCache.get(node);
if (cached !== undefined) return cached;
@ -1154,6 +1158,7 @@ const cachedFindEnclosingClassInfo = (
resolveEnclosingOwner,
getQualifiedOwnerName,
resolveFileTypeOwner,
resolveContainerTypeOwner,
);
classIdCache.set(node, result);
return result;
@ -2000,6 +2005,7 @@ const processFileGroup = (
provider.resolveEnclosingOwner,
propGetQualifiedOwnerName,
provider.resolveFileTypeOwner,
provider.resolveContainerTypeOwner,
);
const propEnclosingClassId =
propEnclosingInfo?.qualifiedClassId ?? propEnclosingInfo?.classId ?? null;
@ -2407,6 +2413,7 @@ const processFileGroup = (
provider.resolveEnclosingOwner,
getQualifiedOwnerName,
provider.resolveFileTypeOwner,
provider.resolveContainerTypeOwner,
)
: null;
const enclosingClassId =

View file

@ -0,0 +1,50 @@
// A file-struct hosting ANONYMOUS containers (F8):
// - `std.sort.pdq(T, items, {}, struct { fn lessThan … }.lessThan)` — the
// comparator idiom (Lightpanda's ImportMap.zig has three in one file, all
// named `lessThan`: one ownerless `Method:…:lessThan` node for all three);
// - `const byteSize = struct { fn it … }.it;` — build.zig's shape;
// - a field typed `?struct { min: u32, max: u32 }`.
// Each gets an identity (`Sorter.sortBoth$1`, `Sorter.sortBoth$2`,
// `Sorter$1`, `Sorter$2`) so its fns are Methods WITH an owner and never
// collide.
const std = @import("std");
const Sorter = @This();
items: []u32,
bounds: ?struct { min: u32, max: u32 } = null,
const byteSize = struct {
fn it(n: u32) bool {
return before(n, n);
}
}.it;
pub fn sortBoth(self: *Sorter) void {
std.sort.pdq(u32, self.items, {}, struct {
fn lessThan(_: void, a: u32, b: u32) bool {
return before(a, b);
}
}.lessThan);
std.sort.pdq(u32, self.items, {}, struct {
fn lessThan(_: void, a: u32, b: u32) bool {
return before(b, a);
}
}.lessThan);
}
fn before(a: u32, b: u32) bool {
return a < b;
}
// A container local to a TEST block (Function.zig / HttpClient.zig tests
// declare a `const State = struct {…}` per test): host is the quoted test name.
test "Sorter: local state" {
const State = struct {
n: u32 = 0,
fn kill(self: *@This()) void {
self.n = if (before(self.n, 1)) 1 else 0;
}
};
var state = State{};
state.kill();
}

View file

@ -0,0 +1,62 @@
// Lightpanda's `webapi/element/reflection.zig` shape (F8): a generic type
// constructor whose builder fns each declare a FUNCTION-LOCAL helper container
// named `R`. By binding name alone every `R` collapsed onto ONE `Struct:…:R`
// with one `R.get` / one `R.set`; each must be its own type
// (`Reflect.string$R`, `Reflect.url$R`) owning its own methods.
const std = @import("std");
pub const Accessor = struct {
get: *const anyopaque,
set: *const anyopaque,
};
pub fn Reflect(comptime T: type) type {
return struct {
pub fn string(comptime attr: []const u8) Accessor {
const R = struct {
fn get(self: *const T) []const u8 {
return readAttr(self, attr);
}
fn set(self: *T, value: []const u8) void {
writeAttr(self, attr, value);
}
};
return Accessor{ .get = R.get, .set = R.set };
}
pub fn url(comptime attr: []const u8) Accessor {
const R = struct {
// A `@This()` alias inside a function-local container names
// THAT container (`R`), not the enclosing type constructor:
// `self: *const Self` below must dispatch `self.get()` to
// `Reflect.url$R.get`.
const Self = @This();
fn get(self: *const T) []const u8 {
return normalize(readAttr(self, attr));
}
fn set(self: *T, value: []const u8) void {
writeAttr(self, attr, value);
}
fn check(self: *const Self) []const u8 {
return self.get();
}
};
return Accessor{ .get = R.get, .set = R.set };
}
};
}
fn readAttr(self: anytype, attr: []const u8) []const u8 {
_ = self;
return attr;
}
fn writeAttr(self: anytype, attr: []const u8, value: []const u8) void {
_ = self;
_ = attr;
_ = value;
}
fn normalize(s: []const u8) []const u8 {
return s;
}

View file

@ -608,3 +608,134 @@ describe.skipIf(!zigAvailable)('Zig type aliases (zig-filestruct fixture, aliase
expect(calls).toContain('viaDeref → bump');
});
});
describe.skipIf(!zigAvailable)('Zig function-local and anonymous containers (F8)', () => {
// reflect.zig mirrors Lightpanda's reflection.zig: a generic type
// constructor whose builder fns each declare `const R = struct { fn get…
// fn set… }`. Sorter.zig hosts the anonymous shapes: two `std.sort.pdq(…,
// struct { fn lessThan … }.lessThan)` comparators in one fn (ImportMap.zig
// has three), `const byteSize = struct { fn it … }.it;` (build.zig), a
// field typed `?struct { min, max }`, and a test-local `const State`.
// Before this modelling every `R` was one `Struct:…:R` with one `R.get`,
// and every comparator's `lessThan` was an OWNERLESS `Method:<file>:lessThan`
// — the corpus gate counted 14 ownerless Methods and 55 fns without a node.
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-filestruct'), () => {});
}, 60000);
const idsIn = (label: string, file: string): string[] => {
const ids: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === label && String(n.properties.filePath).endsWith(file)) ids.push(n.id);
});
return ids.sort();
};
it('keys a function-local `const R = struct` by its enclosing callable, so two builders own two `R.get`s', () => {
expect(idsIn('Struct', 'reflect.zig')).toEqual([
'Struct:src/reflect.zig:Accessor',
'Struct:src/reflect.zig:Reflect',
'Struct:src/reflect.zig:Reflect.string$R',
'Struct:src/reflect.zig:Reflect.url$R',
]);
// Both `get`s exist, under distinct owner-qualified ids…
expect(result.graph.getNode('Method:src/reflect.zig:Reflect.string$R.get#0')).toBeDefined();
expect(result.graph.getNode('Method:src/reflect.zig:Reflect.url$R.get#0')).toBeDefined();
// …and nothing is left under the bare binding name.
expect(result.graph.getNode('Struct:src/reflect.zig:R')).toBeUndefined();
expect(result.graph.getNode('Method:src/reflect.zig:R.get#0')).toBeUndefined();
const hasMethod = getRelationships(result, 'HAS_METHOD').map(
(e) => `${e.rel.sourceId} → ${e.rel.targetId}`,
);
expect(hasMethod).toContain(
'Struct:src/reflect.zig:Reflect.string$R → Method:src/reflect.zig:Reflect.string$R.get#0',
);
expect(hasMethod).toContain(
'Struct:src/reflect.zig:Reflect.url$R → Method:src/reflect.zig:Reflect.url$R.get#0',
);
});
it('gives anonymous containers a host + ordinal identity, so no Method is ownerless and same-named fns never collide', () => {
expect(idsIn('Struct', 'Sorter.zig')).toEqual([
'Struct:src/Sorter.zig:Sorter',
'Struct:src/Sorter.zig:Sorter$1', // `bounds: ?struct { min, max }`
'Struct:src/Sorter.zig:Sorter$2', // `const byteSize = struct { fn it }.it`
'Struct:src/Sorter.zig:Sorter.sortBoth$1', // first comparator
'Struct:src/Sorter.zig:Sorter.sortBoth$2', // second comparator
'Struct:src/Sorter.zig:Sorter.test@L41$State', // test-local `const State`
]);
// Two `lessThan`s in one file → two nodes, each owned by its own Struct.
expect(
result.graph.getNode('Method:src/Sorter.zig:Sorter.sortBoth$1.lessThan#3'),
).toBeDefined();
expect(
result.graph.getNode('Method:src/Sorter.zig:Sorter.sortBoth$2.lessThan#3'),
).toBeDefined();
expect(result.graph.getNode('Method:src/Sorter.zig:Sorter$2.it#1')).toBeDefined();
const hasMethod = getRelationships(result, 'HAS_METHOD').map(
(e) => `${e.rel.sourceId} → ${e.rel.targetId}`,
);
expect(hasMethod).toContain(
'Struct:src/Sorter.zig:Sorter.sortBoth$1 → Method:src/Sorter.zig:Sorter.sortBoth$1.lessThan#3',
);
expect(hasMethod).toContain(
'Struct:src/Sorter.zig:Sorter.sortBoth$2 → Method:src/Sorter.zig:Sorter.sortBoth$2.lessThan#3',
);
expect(hasMethod).toContain(
'Struct:src/Sorter.zig:Sorter$2 → Method:src/Sorter.zig:Sorter$2.it#1',
);
// The anonymous field type owns its fields (they were `Sorter.min` before).
const hasProp = getRelationships(result, 'HAS_PROPERTY').map(
(e) => `${e.rel.sourceId} → ${e.rel.targetId}`,
);
expect(hasProp).toContain(
'Struct:src/Sorter.zig:Sorter$1 → Property:src/Sorter.zig:Sorter$1.min',
);
// No ownerless Method anywhere in the fixture: every Method id is `<owner>.<name>#N`.
const ownerless: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Method' && /^Method:[^:]+:[^.]+#\d+$/.test(n.id)) ownerless.push(n.id);
});
expect(ownerless).toEqual([]);
});
it('attributes calls inside such containers to the right node, on both ends', () => {
const calls = getRelationships(result, 'CALLS').map(
(e) => `${e.rel.sourceId} → ${e.rel.targetId}`,
);
// From inside each `R.get` — the caller is THAT builder's `R.get`.
expect(calls).toContain(
'Method:src/reflect.zig:Reflect.string$R.get#0 → Function:src/reflect.zig:readAttr',
);
expect(calls).toContain(
'Method:src/reflect.zig:Reflect.url$R.get#0 → Function:src/reflect.zig:normalize',
);
// A `const Self = @This();` inside a local container still names THAT
// container: `check(self: *const Self)` dispatches `self.get()` to `url$R.get`.
expect(calls).toContain(
'Method:src/reflect.zig:Reflect.url$R.check#0 → Method:src/reflect.zig:Reflect.url$R.get#0',
);
// From inside each anonymous comparator (and the test-local State).
expect(calls).toContain(
'Method:src/Sorter.zig:Sorter.sortBoth$1.lessThan#3 → Method:src/Sorter.zig:Sorter.before#2',
);
expect(calls).toContain(
'Method:src/Sorter.zig:Sorter.sortBoth$2.lessThan#3 → Method:src/Sorter.zig:Sorter.before#2',
);
expect(calls).toContain(
'Method:src/Sorter.zig:Sorter.test@L41$State.kill#0 → Method:src/Sorter.zig:Sorter.before#2',
);
// INTO a test-local container: `State{}` and `state.kill()` from the test
// resolve to the qualified Struct / Method (the qualified key must survive
// the class extractor's whitespace normalization — a test-string host would not).
expect(calls).toContain(
'Function:src/Sorter.zig:Sorter."Sorter: local state" → Struct:src/Sorter.zig:Sorter.test@L41$State',
);
expect(calls).toContain(
'Function:src/Sorter.zig:Sorter."Sorter: local state" → Method:src/Sorter.zig:Sorter.test@L41$State.kill#0',
);
});
});

View file

@ -22,7 +22,11 @@ import {
isZigContainerOrImportBinding,
isZigFileStruct,
isZigKeywordDeclaration,
isZigRedundantContainerCapture,
isZigTypeShadowingBinding,
zigCallableQualifiedName,
zigContainerAnchor,
zigContainerBindingName,
zigContainerName,
zigFileStructName,
} from '../../src/core/ingestion/languages/zig/captures.js';
@ -41,6 +45,7 @@ import { zigFieldConfig } from '../../src/core/ingestion/field-extractors/config
import { zigProvider } from '../../src/core/ingestion/languages/zig.js';
import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js';
import { extract as extractScopes } from '../../src/core/ingestion/scope-extractor.js';
import { populateClassOwnedMembers } from '../../src/core/ingestion/scope-resolution/scope/walkers.js';
const _require = createRequire(import.meta.url);
let Zig: unknown = null;
@ -786,7 +791,7 @@ fn notAType(comptime T: type) u32 {
const Plain = struct { a: u8 };
`;
it('names the container returned by a fn returning `type` after that fn; other anonymous containers stay nameless', () => {
it('names the container returned by a fn returning `type` after that fn; an anonymous container is keyed by its host (F8)', () => {
const root = parse(src).rootNode;
const structs: SyntaxNode[] = [];
const walk = (n: SyntaxNode): void => {
@ -794,7 +799,10 @@ const Plain = struct { a: u8 };
n.children.forEach(walk);
};
walk(root);
expect(structs.map((n) => zigContainerName(n))).toEqual(['Stack', undefined, 'Plain']);
// `struct { … }.x()` inside `notAType` has no binding name; its identity
// is `<enclosing fn>$<ordinal>` so its fn `x` is a Method WITH an owner.
expect(structs.map((n) => zigContainerBindingName(n))).toEqual(['Stack', undefined, 'Plain']);
expect(structs.map((n) => zigContainerName(n))).toEqual(['Stack', 'notAType$1', 'Plain']);
});
it('the method and field extractors own the generic container’s members under the fn name', () => {
@ -1414,3 +1422,186 @@ pub fn init() @This() { return .{}; }
expect(zigPointeeSpelling('Thing')).toBeUndefined();
});
});
describeZig('Zig function-local and anonymous containers (F8)', () => {
// Lightpanda shapes: reflection.zig's per-builder `const R = struct {…}`,
// ImportMap.zig's `std.sort.pdq(…, struct { fn lessThan … }.lessThan)`
// (three in one file), build.zig's `const byte_size = struct { fn it … }.it;`,
// and a test-local `const State = struct {…}`.
const src = `
const Page = @This();
count: u32 = 0,
pub fn Reflect(comptime T: type) type {
return struct {
pub fn string() void {
const R = struct {
fn get(self: *const T) u8 { _ = self; return 1; }
};
_ = R.get;
}
pub fn url() void {
const R = struct {
fn get(self: *const T) u8 { _ = self; return 2; }
};
_ = R.get;
}
};
}
pub fn sortBoth(self: *Page) void {
_ = self;
sort(struct { fn lessThan(a: u32, b: u32) bool { return a < b; } }.lessThan);
sort(struct { fn lessThan(a: u32, b: u32) bool { return a > b; } }.lessThan);
}
const byte_size = struct { fn it(n: u32) u32 { return n; } }.it;
test "Page: local state" {
const State = struct { fn kill(self: *@This()) void { _ = self; } };
var s = State{};
s.kill();
}
`;
const containers = (): SyntaxNode[] => {
const out: SyntaxNode[] = [];
const walk = (n: SyntaxNode): void => {
if (n.type === 'struct_declaration') out.push(n);
n.children.forEach(walk);
};
walk(parse(src).rootNode);
return out;
};
const findFn = (n: SyntaxNode, name: string): SyntaxNode | undefined =>
n.type === 'function_declaration' && n.childForFieldName('name')?.text === name
? n
: n.children.map((c) => findFn(c, name)).find((x) => x !== undefined);
it('keys a function-local container by its enclosing callable and an anonymous one by host + ordinal', () => {
// Before F8 the two `R` collapsed onto one `Struct:<file>:R` (one `R.get`
// for ~20 builders in reflection.zig) and the anonymous comparators had
// no identity at all — their `lessThan`s were ownerless, colliding Methods.
expect(containers().map((n) => zigContainerName(n, 'src/Page.zig'))).toEqual([
'Reflect',
'Reflect.string$R',
'Reflect.url$R',
'Page.sortBoth$1',
'Page.sortBoth$2',
'Page$1',
// A test block is keyed by its line: its string is not stable under
// the class extractor's qualified-name normalization (whitespace).
'Page.test@L26$State',
]);
// The BINDING name is what code writes — the scope binds `R`, not `string$R`.
expect(containers().map((n) => zigContainerBindingName(n))).toEqual([
'Reflect',
'R',
'R',
undefined,
undefined,
undefined,
'State',
]);
// Which ZIG_QUERIES rule mints each: only file/container-level bindings
// stay on the `const T = struct` wrapper rule.
expect(containers().map((n) => zigContainerAnchor(n))).toEqual([
'constructor',
'container',
'container',
'container',
'container',
'container',
'container',
]);
const root = parse(src).rootNode;
expect(zigCallableQualifiedName(findFn(root, 'get')!, 'src/Page.zig')).toBe(
'Reflect.string$R.get',
);
expect(zigCallableQualifiedName(findFn(root, 'sortBoth')!, 'src/Page.zig')).toBe(
'Page.sortBoth',
);
// A namespace file (no fields) prefixes nothing for the file itself.
const nsRoot = parse('fn build() void { const R = struct {}; _ = R; }').rootNode;
expect(
zigContainerName(
findFn(nsRoot, 'build')!.descendantsOfType('struct_declaration')[0]!,
'build.zig',
),
).toBe('build$R');
});
it('the redundancy predicate keeps exactly one structure-phase rule per container', () => {
const [ctor, localR, , anon] = containers();
// Type constructor: its own rule (anchored on the container, fn `@name`)
// is kept; the bare rule's match (no name) is redundant.
const ctorFn = findFn(parse(src).rootNode, 'Reflect')!;
expect(isZigRedundantContainerCapture(ctor!, ctorFn.childForFieldName('name')!)).toBe(false);
expect(isZigRedundantContainerCapture(ctor!, undefined)).toBe(true);
// Function-local `const R = struct`: the wrapper rule is redundant, the
// bare rule owns it (it is the one that names it `string$R`).
const wrapper = localR!.parent!;
expect(isZigRedundantContainerCapture(wrapper, wrapper.namedChild(0)!)).toBe(true);
expect(isZigRedundantContainerCapture(localR!, undefined)).toBe(false);
// Anonymous: only the bare rule matches, and it is kept.
expect(isZigRedundantContainerCapture(anon!, undefined)).toBe(false);
// File-level `const Plain = struct {}` stays on the wrapper rule.
const plainWrapper = parse('const Plain = struct { a: u8 };').rootNode.namedChild(0)!;
const plain = plainWrapper.namedChild(1)!;
expect(isZigRedundantContainerCapture(plainWrapper, plainWrapper.namedChild(0)!)).toBe(false);
expect(isZigRedundantContainerCapture(plain, undefined)).toBe(true);
});
it('scope captures: a local container def is qualified but binds its own name; anonymous ones get distinct synthetic defs', () => {
const caps = emitZigScopeCaptures(src, 'src/Page.zig');
const structDefs = caps
.filter((m) => m['@declaration.struct'] !== undefined)
.map((m) => [
m['@declaration.name']?.text,
m['@declaration.binding-name']?.text,
m['@declaration.is-synthetic']?.text,
]);
expect(structDefs).toContainEqual(['Reflect.string$R', 'R', undefined]);
expect(structDefs).toContainEqual(['Reflect.url$R', 'R', undefined]);
expect(structDefs).toContainEqual(['Page.test@L26$State', 'State', undefined]);
// Two same-shaped `struct { fn lessThan }` comparators → two identities.
expect(structDefs).toContainEqual(['Page.sortBoth$1', undefined, 'true']);
expect(structDefs).toContainEqual(['Page.sortBoth$2', undefined, 'true']);
expect(structDefs).toContainEqual(['Page$1', undefined, 'true']);
// No def is left under the bare binding name.
expect(structDefs.map((d) => d[0])).not.toContain('R');
// Through the scope extractor: the members are owned by DISTINCT class
// defs and their qualified names carry the identity — the graph node id
// the structure phase mints (`Method:<file>:Reflect.string$R.get`).
const parsed = extractScopes(caps, 'src/Page.zig', zigProvider);
populateClassOwnedMembers(parsed);
const gets = parsed.localDefs.filter(
(d) => d.type === 'Method' && d.qualifiedName?.endsWith('.get'),
);
expect(gets.map((d) => d.qualifiedName).sort()).toEqual([
'Reflect.string$R.get',
'Reflect.url$R.get',
]);
expect(new Set(gets.map((d) => d.ownerId)).size).toBe(2);
const lts = parsed.localDefs.filter(
(d) => d.type === 'Method' && d.qualifiedName?.endsWith('.lessThan'),
);
expect(lts.map((d) => d.qualifiedName).sort()).toEqual([
'Page.sortBoth$1.lessThan',
'Page.sortBoth$2.lessThan',
]);
expect(lts.every((d) => d.ownerId !== undefined)).toBe(true);
});
it('a `@This()` alias inside a local container rewrites to the BINDING name (`R`), which is what the scope binds', () => {
const localAliasSrc = `
pub fn string() void {
const R = struct {
const Self = @This();
fn get(self: *const Self) u8 { _ = self; return 1; }
};
_ = R;
}
`;
const selfParam = emitZigScopeCaptures(localAliasSrc, 'src/r.zig').find(
(m) => m['@type-binding.parameter'] !== undefined && m['@type-binding.name']?.text === 'self',
);
expect(selfParam?.['@type-binding.type']?.text).toBe('*const R');
});
});