fix(zig): type locals through try/catch/orelse, return types and payload captures

F6 of the gitnexus-check review. Three gaps in the value flow that types a
local receiver, all measured on Lightpanda:

1. `@type-binding.call-return` needed the `call_expression` as the DIRECT
   value child, so `const p = try Page.init(…)` (2,551 sites), `… catch
   return` (410) and `… orelse return` typed nothing. The rule is now one
   keyword-gated declaration match; `emitZigScopeCaptures` unwraps `try`,
   `catch`, `orelse` and parentheses (`zigUnwrapValue`) and decides what the
   value types (`zigCallReturnTypeOf`): a module-level receiver still names
   the type (`Counter.init()` → Counter, Rust `Foo::new()`); a free call
   binds the callee name (`makeThing`); a member call on a fn-LOCAL receiver
   (parameter / local / payload — Zig forbids shadowing, so "declared in the
   fn" is exact) binds the compound `node.asElement()` the shared resolver
   walks to the method's return type — instead of typing `el` as `Node`. A
   TitleCase callee (`List(u8)`) is a type constructor and binds nothing.

2. No `@type-binding.return` existed. `fn make() !*Thing` now binds
   `make ↦ Thing` in the enclosing scope (Module for free fns, the container's
   Class scope for methods, where the compound resolver reads it). Builtins,
   `type`, `@TypeOf(…)` and comptime type parameters (`?*T`) bind nothing;
   `@This()` / `Self` returns name the container. `normalizeZigTypeName` now
   strips the error union BEFORE the payload's sigils, so
   `Allocator.Error!*Page` → `Page` (it used to leave `*Page`).

3. Payload captures had no binding at all. `populateZigRangeBindings`
   (registered as `populateRangeBindings`) types `for (items) |it| / |*it|`,
   `for (items, 0..) |it, i|`, `if (opt) |v|`, `if (call()) |v|`,
   `while (it.next()) |x|` from the SUBJECT's written type minus one layer
   (`[]T` element, `?T` payload) — declining when the layer is not visible
   (`ArrayList(T)`) — and the same projection for `const t = items[i]` /
   `opt.?` / `ptr.*`. `catch |err|` and `switch` prongs are skipped.

Corpus (Lightpanda, gate before → after): CALLS 15838 → 17853;
`ident.m() bound=local-try/catch/orelse` 11/1298 → 584/1298;
`local-call` 159/1282 → 398/1282; `payload` 82/1085 → 233/1085;
`other-recv:call_expression` 5/991 → 457/991; `local-other` 271 → 364;
`self.m()` 100 %, `free f()` 97.5 %, `ns.f()` 83.4 % unchanged; nothing down.

Not covered: `const t = ns.f()` (a namespace fn's return type across files —
Case 3 has no path from a namespace head to a callable's return binding),
and expression receivers (`items[i].run()`, `o.?.run()`).
This commit is contained in:
Navid EMAD 2026-08-18 14:54:23 +02:00
parent a46bce7681
commit bb51ca342c
No known key found for this signature in database
11 changed files with 1090 additions and 16 deletions

View file

@ -288,8 +288,10 @@ function collectZigThisAliases(
function rewriteZigThisAlias(
typeNode: SyntaxNode,
aliases: ReadonlyMap<number, { readonly alias: string; readonly container: string }>,
// The text to rewrite: the node's own, unless an earlier step (F6's value
// inference) already replaced the capture text.
text: string = typeNode.text,
): string | undefined {
const text = typeNode.text;
const nominal = /[A-Za-z_@][\w.]*(?:\([^)]*\))?\s*$/.exec(text)?.[0]?.trim();
if (nominal === undefined || nominal.length === 0) return undefined;
const bare = nominal.replace(/\(.*\)$/, '');
@ -370,6 +372,252 @@ const ZIG_CALLABLE_CAPTURE_OPTIONS = {
},
} as const;
// ─── F6: value-inferred and return-type bindings ─────────────────────────────
/** Strip the value wrappers Zig puts around a call — `try f()`, `f() catch
* …`, `f() orelse …`, `(f())` — down to the wrapped expression. Rust's
* `.await` unwrapping, one level richer. */
export function zigUnwrapValue(value: SyntaxNode): SyntaxNode {
let cur = value;
for (;;) {
if (cur.type === 'try_expression' || cur.type === 'parenthesized_expression') {
const inner = cur.namedChildren.find(
(c): c is SyntaxNode => c !== null && c.type !== 'comment',
);
if (inner === undefined) return cur;
cur = inner;
} else if (cur.type === 'catch_expression') {
// `<operand> catch [|err|] <handler>` — the operand is the first child.
const inner = cur.namedChild(0);
if (inner === null) return cur;
cur = inner;
} else if (cur.type === 'binary_expression') {
if (cur.childForFieldName('operator')?.type !== 'orelse') return cur;
const left = cur.childForFieldName('left');
if (left === null) return cur;
cur = left;
} else {
return cur;
}
}
}
/** Zig style: types are TitleCase, functions camelCase, namespaces
* snake_case. A TitleCase callee (`List(u8)`, `std.ArrayList(T)`) is a type
* constructor — its call yields a TYPE, not a value, and is a type alias
* (F7), not a call-return binding. */
function isZigTitleCase(name: string): boolean {
return /^[A-Z]/.test(name);
}
/** The leftmost identifier of a `field_expression` chain (`self.parser` →
* `self`), the identifier itself, or null when the chain roots in anything
* else (a call, an index, a builtin). */
function zigChainHead(node: SyntaxNode): SyntaxNode | null {
let cur: SyntaxNode | null = node;
while (cur !== null && cur.type === 'field_expression') cur = cur.childForFieldName('object');
return cur !== null && cur.type === 'identifier' ? cur : null;
}
/** Names bound INSIDE `fn` (parameters, `const`/`var` locals, payload
* captures), lazily computed once per function node. Zig forbids shadowing,
* so a name declared anywhere in the function is a value local everywhere in
* it, and a name NOT declared in it is a module-level (or imported) name.
* That is the whole distinction `zigCallReturnTypeOf` needs: a call on a
* local receiver (`node.asElement()`) yields the METHOD's return type, a
* call on a module-level receiver (`Counter.init()`, `mod.Counter.init()`)
* names the receiver's type. */
function zigFunctionLocalNames(fn: SyntaxNode, cache: Map<number, Set<string>>): Set<string> {
const cached = cache.get(fn.id);
if (cached !== undefined) return cached;
const names = new Set<string>();
const visit = (node: SyntaxNode): void => {
if (node.type === 'parameter') {
const name = node.childForFieldName('name');
if (name !== null) names.add(name.text);
} else if (node.type === 'variable_declaration' && isZigKeywordDeclaration(node)) {
const first = node.namedChild(0);
if (first?.type === 'identifier') names.add(first.text);
} else if (node.type === 'payload') {
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type === 'identifier') names.add(c.text);
}
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c !== null) visit(c);
}
};
visit(fn);
cache.set(fn.id, names);
return names;
}
/** The nearest enclosing `function_declaration` / `test_declaration`, or
* null at module (container) level. */
function zigEnclosingFunction(node: SyntaxNode): SyntaxNode | null {
let cur = node.parent;
while (cur !== null && cur !== undefined) {
if (cur.type === 'function_declaration' || cur.type === 'test_declaration') return cur;
cur = cur.parent;
}
return null;
}
/** For `const t = <value>;`, the type source the value names, or undefined
* when the value types nothing on its own:
* - `Counter.init()` / `mod.Counter.init()` / `List(u8).init()` (receiver
* is a module-level name) → `{ type: 'Counter' }` — the receiver names
* the type (Rust `Foo::new()`); a value receiver simply finds no
* container later and declines.
* - `node.asElement()` / `self.parser.next()` (receiver head is a fn-local
* param / local / payload) → `{ type: 'node.asElement()', memberCall:
* true }` — the compound shape the shared resolver walks through the
* receiver's class scope to the method's `@type-binding.return`.
* - `makeThing()` (free call) → `{ type: 'makeThing' }` — chains to the
* fn's return binding.
* - a `struct_initializer` under a wrapper (`try Thing{…}`) → its type,
* `structLiteral: true` (the direct shape has its own query rules).
* - `.init()` decl literals, TitleCase callees (type constructors —
* aliases, F7), identifiers, field chains, literals → undefined.
* Wrappers (`try`, `catch`, `orelse`, parens) are unwrapped first. */
export function zigCallReturnTypeOf(
value: SyntaxNode,
localNamesCache: Map<number, Set<string>>,
):
| { readonly type: string; readonly memberCall?: true; readonly structLiteral?: true }
| undefined {
const inner = zigUnwrapValue(value);
if (inner.type === 'struct_initializer') {
if (inner.id === value.id) return undefined; // direct: the constructor rules own it
const typeNode = inner.namedChild(0);
if (
typeNode === null ||
!(
typeNode.type === 'identifier' ||
typeNode.type === 'field_expression' ||
typeNode.type === 'call_expression'
)
) {
return undefined;
}
return { type: typeNode.text, structLiteral: true };
}
if (inner.type !== 'call_expression') return undefined;
const callee = inner.childForFieldName('function');
if (callee === null) return undefined;
if (callee.type === 'identifier') {
return isZigTitleCase(callee.text) ? undefined : { type: callee.text };
}
if (callee.type !== 'field_expression') return undefined;
const object = callee.childForFieldName('object');
const member = callee.childForFieldName('member');
if (object === null || member === null) return undefined; // `.init()` decl literal
if (isZigTitleCase(member.text)) return undefined;
const head = zigChainHead(object);
if (head !== null) {
const fn = zigEnclosingFunction(value);
if (fn !== null && zigFunctionLocalNames(fn, localNamesCache).has(head.text)) {
return { type: `${object.text}.${member.text}()`, memberCall: true };
}
}
return { type: object.text };
}
/** Return-type node → the nominal type node under its sigils (`!*Thing` →
* `Thing`), following error-union `ok`, pointer / nullable / slice / array
* wrappers. Undefined when it bottoms out in nothing nominal. */
function zigNominalTypeNode(typeNode: SyntaxNode): SyntaxNode | undefined {
let cur: SyntaxNode | null | undefined = typeNode;
for (let guard = 0; cur !== null && cur !== undefined && guard < 16; guard++) {
switch (cur.type) {
case 'error_union_type':
cur = cur.childForFieldName('ok');
continue;
case 'pointer_type':
case 'nullable_type':
case 'slice_type':
case 'array_type':
// The element type is the LAST named child (arrays carry the length
// first, pointers/slices may carry a sentinel or `const`).
cur = cur.namedChildren
.filter((c): c is SyntaxNode => c !== null && c.type !== 'comment')
.pop();
continue;
default:
return cur;
}
}
return undefined;
}
/** Should `fn name() <type>` bind `name ↦ <type>`? Only when the return
* type is nominal: a container name, a qualified name, a generic
* instantiation, or `@This()`. Builtins (`void`, `u8`, `bool`, `anyerror`),
* `type` (a generic type constructor — its container binding is the type),
* and `@TypeOf(…)` bind nothing: `List ↦ type` would hijack the
* `List(u8){}` constructor chain, and `f ↦ void` is noise. */
export function zigReturnTypeIsNominal(typeNode: SyntaxNode): boolean {
const nominal = zigNominalTypeNode(typeNode);
if (nominal === undefined) return false;
switch (nominal.type) {
case 'identifier':
// `fn is(self: *Node, comptime T: type) ?*T` — `T` is the CALLER's
// choice, not a type; binding `is ↦ T` would type every `node.is(X)`
// as whatever class happens to be named `T`.
return !zigIsComptimeTypeParameter(typeNode, nominal.text);
case 'field_expression':
case 'call_expression':
return true;
case 'builtin_function':
return nominal.namedChild(0)?.text === '@This';
default:
return false;
}
}
/** Is `name` a `comptime <name>: type` parameter of the function whose
* return type `returnTypeNode` is, or of any function enclosing it (the
* `T` of a generic type constructor is visible to every method of the
* returned container: `fn get(self) T`)? */
function zigIsComptimeTypeParameter(returnTypeNode: SyntaxNode, name: string): boolean {
let fn: SyntaxNode | null | undefined = returnTypeNode.parent;
while (fn !== null && fn !== undefined) {
if (fn.type === 'function_declaration') {
const params = fn.namedChildren.find((c) => c?.type === 'parameters');
for (let i = 0; params !== undefined && params !== null && i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p?.type !== 'parameter') continue;
if (
p.childForFieldName('name')?.text === name &&
p.childForFieldName('type')?.text === 'type'
) {
return true;
}
}
}
fn = fn.parent;
}
return false;
}
/** The container a `@This()` in `typeNode`'s position names: the innermost
* enclosing container's binding name, or the file-struct's name at file
* level. Undefined outside any named container. */
function zigThisTargetFor(
typeNode: SyntaxNode,
fileStructName: string | undefined,
): string | undefined {
let cur = typeNode.parent;
while (cur !== null && cur !== undefined) {
if (ZIG_CONTAINER_TYPES.has(cur.type)) return zigContainerName(cur);
if (cur.type === 'source_file') return fileStructName;
cur = cur.parent;
}
return undefined;
}
export function emitZigScopeCaptures(
sourceText: string,
_filePath: string,
@ -455,6 +703,8 @@ 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);
// F6: fn-local names per function node, for `zigCallReturnTypeOf`.
const fnLocalNames = new Map<number, Set<string>>();
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
@ -606,6 +856,57 @@ export function emitZigScopeCaptures(
const aliasBinding = nodeMap['@type-binding.alias'];
if (aliasBinding !== undefined && aliasDeclIds.has(aliasBinding.id)) continue;
// F6 — value-inferred bindings: `const t = [try] <call>` (see
// `zigCallReturnTypeOf`). The query pins every keyword declaration; here
// the value decides whether it types anything, and how. The value capture
// is scaffolding and never leaves this function.
const valueNode = nodeMap['@type-binding.value'];
if (valueNode !== undefined && nodeMap['@type-binding.call-return'] !== undefined) {
delete grouped['@type-binding.value'];
// `const x: T;` (extern) — the trailing child is the type, not a value.
if (valueNode.id === nodeMap['@type-binding.call-return'].childForFieldName('type')?.id) {
continue;
}
const inferred = zigCallReturnTypeOf(valueNode, fnLocalNames);
if (inferred === undefined) continue;
grouped['@type-binding.type'] = syntheticCapture(
'@type-binding.type',
valueNode,
inferred.type,
);
nodeMap['@type-binding.type'] = valueNode;
if (inferred.structLiteral === true) {
const anchor = grouped['@type-binding.call-return']!;
delete grouped['@type-binding.call-return'];
grouped['@type-binding.constructor'] = { ...anchor, name: '@type-binding.constructor' };
} else if (inferred.memberCall === true) {
grouped['@type-binding.member-call-return'] = syntheticCapture(
'@type-binding.member-call-return',
nodeMap['@type-binding.name']!,
'true',
);
}
}
// F6 — return-type bindings: `fn make() !*Thing` binds `make ↦ Thing`.
// Only nominal returns bind (see `zigReturnTypeIsNominal`); a bare
// `@This()` return names the enclosing container by name (the alias
// rewrite below handles the `Self` spelling of the same thing).
const returnAnchor = nodeMap['@type-binding.return'];
if (returnAnchor !== undefined) {
const retType = nodeMap['@type-binding.type'];
if (retType === undefined || !zigReturnTypeIsNominal(retType)) continue;
if (retType.text.includes('@This()')) {
const target = zigThisTargetFor(retType, fileStructName);
if (target === undefined) continue;
grouped['@type-binding.type'] = syntheticCapture(
'@type-binding.type',
retType,
retType.text.replace('@This()', target),
);
}
}
// `@This()` aliases in type position — `self: *Self` in a container that
// declares `const Self = @This();`, or `self: *SigHandler` in
// `Sighandler.zig` (`const SigHandler = @This();`) — name the enclosing
@ -614,7 +915,11 @@ export function emitZigScopeCaptures(
// rust/captures.ts). Only the nominal part is rewritten; sigils stay.
const typeNode = nodeMap['@type-binding.type'];
if (typeNode !== undefined && thisAliases.size > 0) {
const rewritten = rewriteZigThisAlias(typeNode, thisAliases);
const rewritten = rewriteZigThisAlias(
typeNode,
thisAliases,
grouped['@type-binding.type']?.text,
);
if (rewritten !== undefined) {
grouped['@type-binding.type'] = syntheticCapture('@type-binding.type', typeNode, rewritten);
}

View file

@ -9,9 +9,18 @@ export {
zigContainerName,
zigFileStructName,
zigImportRootOf,
zigCallReturnTypeOf,
zigReturnTypeIsNominal,
zigTypeConstructorOf,
zigUnwrapValue,
ZIG_CONTAINER_TYPES,
} from './captures.js';
export {
populateZigRangeBindings,
zigElementSpelling,
zigOptionalPayloadSpelling,
zigPointeeSpelling,
} from './range-binding.js';
export { interpretZigImport, interpretZigTypeBinding, normalizeZigTypeName } from './interpret.js';
export {
expandZigWildcardNames,

View file

@ -90,14 +90,16 @@ export function interpretZigImport(captures: CaptureMatch): ParsedImport | null
*/
export function normalizeZigTypeName(text: string): string {
let t = text.trim();
// Error union first: the payload of `E!*T` / `!?T` carries its own sigils
// (F6: `Allocator.Error!*Page` is the shape of every fallible constructor).
const bang = t.lastIndexOf('!');
if (bang !== -1) t = t.slice(bang + 1).trim();
let previous: string;
do {
previous = t;
t = t.replace(/^(\*|\?|\[\*?c?\]|\[[^\]]*\])\s*/, '');
t = t.replace(/^const\s+/, '');
} while (t !== previous);
const bang = t.lastIndexOf('!');
if (bang !== -1) t = t.slice(bang + 1).trim();
// Generic instantiation `List(u8)` / `std.ArrayList(u8)` → the type
// constructor `List` / `std.ArrayList`: Zig spells a generic type as a
// call, and the container def is registered under the function's name.
@ -129,7 +131,22 @@ export function interpretZigTypeBinding(captures: CaptureMatch): ParsedTypeBindi
// `var c = Counter.init();` — the call's receiver names the type when it
// is a container (`Counter`, `mod.Counter`, `List(u8)`); a value receiver
// (`std.mem`, `self.items`) simply finds no container and declines.
// `const t = makeThing();` — the callee name, chained to its return
// binding by the shared resolver.
source = 'constructor-inferred';
// `const el = node.asElement();` on a fn-LOCAL receiver (F6): the type is
// the METHOD's return type, spelled as the compound `node.asElement()` the
// shared resolver walks (receiver binding → class scope → the method's
// `@type-binding.return`). Kept verbatim: `normalizeZigTypeName` would
// read the `()` as a generic instantiation and strip it.
if (captures['@type-binding.member-call-return'] !== undefined) {
return { boundName: name, rawTypeName: type.trim(), source };
}
} else if (captures['@type-binding.return'] !== undefined) {
// `fn make() !*Thing` — `make ↦ Thing`, in the enclosing scope, so a
// call site chains through it. Error unions / pointers / optionals are
// stripped by `normalizeZigTypeName` (`Allocator.Error!*Page` → `Page`).
source = 'return-annotation';
} else if (captures['@type-binding.annotation'] !== undefined) {
// `var x: T = undefined;` / `const x: T = .init(…);` — the declared type.
source = 'annotation';

View file

@ -258,22 +258,32 @@ const ZIG_SCOPE_QUERY = `
. (identifier) @type-binding.name
type: (_) @type-binding.type) @type-binding.annotation
;; Type bindings — call-return inference: var c = Counter.init(); (Rust's
;; \`let x = Foo::new()\` twin). The receiver of the call is the type when it
;; names a container (\`Counter\`, \`mod.Counter\`, \`List(u8)\`); when it is a
;; value (\`std.mem\`, \`self.items\`) the lookup finds no container and
;; declines, exactly as Rust's does. Keyword-gated: \`_ = e.top();\` is an
;; assignment (same node type), not a binding of \`_\`.
;; Type bindings — value inference (F6): const t = <value>; where <value> is a
;; call (\`Counter.init()\`, \`makeThing()\`, \`node.asElement()\`), possibly
;; wrapped in \`try\` / \`catch …\` / \`… orelse …\` / parentheses — the shape of
;; nearly every Zig constructor call (\`const p = try Page.init(…)\`). The
;; query only pins the declaration; \`emitZigScopeCaptures\` unwraps the
;; wrappers and rewrites \`@type-binding.type\` to the type source (see
;; \`zigCallReturnTypeOf\`). Keyword-gated: \`_ = e.top();\` is an assignment
;; (same node type), not a binding of \`_\`. Rust's twin is
;; \`let x = Foo::new()\` / \`let x = foo().await\`.
(variable_declaration
"const" . (identifier) @type-binding.name
(call_expression
function: (field_expression
object: (_) @type-binding.type)) .) @type-binding.call-return
(_) @type-binding.value .) @type-binding.call-return
(variable_declaration
"var" . (identifier) @type-binding.name
(call_expression
function: (field_expression
object: (_) @type-binding.type)) .) @type-binding.call-return
(_) @type-binding.value .) @type-binding.call-return
;; Type bindings — return-type annotation (F6): \`fn make() !*Thing\` binds
;; \`make ↦ Thing\` in the enclosing scope (Module for free fns, the container's
;; Class scope for methods — that is where the compound resolver reads a
;; method's return type for \`node.asElement()\`), so \`const t = makeThing()\`
;; chains to \`Thing\`. Rust: \`(function_item … return_type:) @type-binding.return\`.
;; \`emitZigScopeCaptures\` drops builtin / \`type\` returns (a \`List ↦ type\`
;; binding would hijack the \`List(u8){}\` constructor chain).
(function_declaration
name: (identifier) @type-binding.name
type: (_) @type-binding.type) @type-binding.return
;; Type bindings — aliases (F5 field-access aliases + F7 type aliases):
;; \`const page = self.page;\` (F5: the RHS path is kept verbatim as the

View file

@ -0,0 +1,370 @@
/**
* Zig payload captures (F6): `for (items) |it|`, `if (opt) |v|`,
* `while (it.next()) |x|`. The captured name has no annotation anywhere — its
* type is the SUBJECT's type minus one layer (slice/array element, optional
* payload) — so the tree-sitter query cannot bind it; this post-finalize hook
* reads the subject's binding from the finished scope model and injects the
* payload's. Rust/Go do the same (`rust/range-binding.ts`,
* `go/range-binding.ts`).
*
* Deliberately narrow and honest: a payload binds only when the subject's
* WRITTEN type visibly has the layer the construct removes — `[]T` / `[N]T` /
* `[*]T` / `*[N]T` for `for`, `?T` (after any `E!`) for `if` / `while`. A
* subject typed `std.ArrayList(T)`, `anytype`, or anything unresolved binds
* nothing rather than guessing. `catch |err|` (an error, never a container
* receiver) and `switch` prongs are skipped. The same one-layer projection
* types `const t = items[i];` / `opt.?` / `ptr.*` (`bindProjectedLocal`).
*
* Runs after `propagateImportedReturnTypes`, so return bindings hoisted from
* other files are visible when a subject is a call.
*/
import type { ParsedFile, Scope, ScopeId, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { getZigParser } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe, ParseTimeoutError } from '../../../tree-sitter/safe-parse.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { logger } from '../../../logger.js';
import {
findClassBindingInScope,
findReceiverTypeBinding,
isClassLike,
} from '../../scope-resolution/scope/walkers.js';
import { isZigKeywordDeclaration, zigUnwrapValue } from './captures.js';
import { normalizeZigTypeName } from './interpret.js';
type ZigTree = ReturnType<ReturnType<typeof getZigParser>['parse']>;
const PAYLOAD_HOSTS: ReadonlySet<string> = new Set([
'for_statement',
'for_expression',
'if_statement',
'if_expression',
'while_statement',
'while_expression',
]);
export function populateZigRangeBindings(
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
ctx: {
readonly fileContents: ReadonlyMap<string, string>;
readonly treeCache?: { get(filePath: string): unknown };
},
): void {
const parser = getZigParser();
// Class def nodeId → its Class scope (whose typeBindings hold the members'
// field types and the methods' return types). Same derivation as
// `buildWorkspaceResolutionIndex`, which this hook does not receive.
const classScopeByDefId = new Map<string, Scope>();
for (const parsed of parsedFiles) {
for (const scope of parsed.scopes) {
if (scope.kind !== 'Class') continue;
const cd = scope.ownedDefs.find((d) => isClassLike(d.type));
if (cd !== undefined) classScopeByDefId.set(cd.nodeId, scope);
}
}
for (const parsed of parsedFiles) {
const sourceText = ctx.fileContents.get(parsed.filePath);
if (sourceText === undefined) continue;
let tree = ctx.treeCache?.get(parsed.filePath) as ZigTree | undefined;
if (tree === undefined) {
try {
tree = parseSourceSafe(parser, sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
} catch (err) {
if (err instanceof ParseTimeoutError) {
logger.warn(
{ file: parsed.filePath },
'zig range-binding: parse timed out, skipping file',
);
continue;
}
throw err;
}
}
const scopes = parsed.scopes;
if (scopes.length === 0) continue;
const resolver = new ZigSubjectTypeResolver(scopes, indexes, classScopeByDefId);
// Pre-order: an outer payload is bound before an inner construct reads it
// (`for (pages) |p| { if (p.frame) |f| … }`).
const visit = (node: SyntaxNode): void => {
if (PAYLOAD_HOSTS.has(node.type)) bindPayloads(node, resolver);
else if (node.type === 'variable_declaration') bindProjectedLocal(node, resolver);
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c !== null) visit(c);
}
};
visit(tree.rootNode);
}
}
/** Bind the payload identifiers of one `for` / `if` / `while` node. */
function bindPayloads(node: SyntaxNode, resolver: ZigSubjectTypeResolver): void {
const named = node.namedChildren.filter(
(c): c is SyntaxNode => c !== null && c.type !== 'comment',
);
// The payload right after the subject(s); a trailing `else |err|` payload
// belongs to the else_clause and is not a child here.
const payload = named.find((c) => c.type === 'payload');
if (payload === undefined) return;
const isFor = node.type === 'for_statement' || node.type === 'for_expression';
// A `for` lists its subjects BEFORE the payload; the body follows it.
const subjects: SyntaxNode[] = isFor
? named.slice(0, named.indexOf(payload)).filter((c) => c.type !== 'block_label')
: [node.childForFieldName('condition')].filter((c): c is SyntaxNode => c !== null);
if (subjects.length === 0) return;
// For a `for` the payload names pair positionally with the subjects
// (`for (items, 0..) |it, i|`); `if` / `while` capture one value.
const captured = payload.namedChildren.filter(
(c): c is SyntaxNode => c !== null && c.type === 'identifier',
);
const host = payloadHostScope(node, resolver);
if (host === undefined) return;
for (let i = 0; i < captured.length && i < subjects.length; i++) {
const name = captured[i]!.text;
if (name === '_') continue;
const subject = subjects[i]!;
if (subject.type === 'range_expression') continue; // `0..` — an index
const spelling = resolver.spellingOf(subject);
if (spelling === undefined) continue;
const element = isFor ? zigElementSpelling(spelling) : zigOptionalPayloadSpelling(spelling);
if (element === undefined) continue;
const rawName = normalizeZigTypeName(element);
if (rawName.length === 0 || rawName.startsWith('@')) continue;
const existing = host.typeBindings.get(name);
// Zig forbids shadowing, so an existing binding of this name in the host
// can only be a sibling construct's payload (bare-expression bodies share
// the enclosing scope). Two different types for one name: decline both
// rather than let the last one type the first one's body.
if (existing !== undefined) {
if (existing.rawName !== rawName) {
(host.typeBindings as Map<string, TypeRef>).delete(name);
}
continue;
}
const ref: TypeRef =
element === rawName
? { rawName, declaredAtScope: host.id, source: 'annotation' }
: { rawName, declaredSpelling: element, declaredAtScope: host.id, source: 'annotation' };
(host.typeBindings as Map<string, TypeRef>).set(name, ref);
}
}
/** `const t = items[i];` / `const t = opt.?;` / `const t = ptr.*;` — a local
* bound to ONE LAYER under a typed subject: the element of a slice/array
* (not a `[a..b]` re-slice), the payload of an optional, the pointee of a
* pointer. Same honesty rule as the payloads: the subject's WRITTEN type
* must show the layer. Skipped when the name is already typed (an
* annotation, or a capture-time inference). */
function bindProjectedLocal(decl: SyntaxNode, resolver: ZigSubjectTypeResolver): void {
if (!isZigKeywordDeclaration(decl)) return;
const named = decl.namedChildren.filter(
(c): c is SyntaxNode => c !== null && c.type !== 'comment',
);
if (named.length < 2 || named[0]!.type !== 'identifier') return;
const value = named[named.length - 1]!;
if (value.id === decl.childForFieldName('type')?.id) return; // `const x: T;`
const inner = zigUnwrapValue(value);
let subject: SyntaxNode | null;
let project: (spelling: string) => string | undefined;
switch (inner.type) {
case 'index_expression':
if (inner.childForFieldName('index')?.type === 'range_expression') return; // `a[0..n]`
subject = inner.childForFieldName('object');
project = zigElementSpelling;
break;
case 'null_coercion_expression':
subject = inner.namedChild(0);
project = zigOptionalPayloadSpelling;
break;
case 'dereference_expression':
subject = inner.namedChild(0);
project = zigPointeeSpelling;
break;
default:
return;
}
if (subject === null) return;
const name = named[0]!.text;
const host = resolver.scopeAt(decl.startPosition.row + 1, decl.startPosition.column, false);
if (host === undefined || host.typeBindings.has(name)) return;
const spelling = resolver.spellingOf(subject);
if (spelling === undefined) return;
const projected = project(spelling);
if (projected === undefined) return;
const rawName = normalizeZigTypeName(projected);
if (rawName.length === 0 || rawName.startsWith('@')) return;
const ref: TypeRef =
projected === rawName
? { rawName, declaredAtScope: host.id, source: 'assignment-inferred' }
: {
rawName,
declaredSpelling: projected,
declaredAtScope: host.id,
source: 'assignment-inferred',
};
(host.typeBindings as Map<string, TypeRef>).set(name, ref);
}
/** `*T` / `*const T` / `E!*T` → `T`; undefined when not visibly a pointer. */
export function zigPointeeSpelling(spelling: string): string | undefined {
let t = spelling.trim();
const bang = t.lastIndexOf('!');
if (bang !== -1) t = t.slice(bang + 1).trim();
const m = /^\*(const\s+)?/.exec(t);
if (m === null) return undefined;
const rest = t.slice(m[0].length).trim();
return rest.length > 0 ? rest : undefined;
}
function body(node: SyntaxNode): SyntaxNode | null {
return node.childForFieldName('body');
}
/** The scope the payload name lives in: the body block's own scope when the
* body is a block (`|t| { … }`), otherwise the innermost scope enclosing the
* construct (a bare-expression body: `for (items) |t| t.run();`). */
function payloadHostScope(node: SyntaxNode, resolver: ZigSubjectTypeResolver): Scope | undefined {
// for/if/while_statement carry `body:`; in the *_expression forms the
// body is the named child right after the payload.
let bodyNode = body(node);
if (bodyNode === null) {
const named = node.namedChildren.filter((c): c is SyntaxNode => c !== null);
const at = named.findIndex((c) => c.type === 'payload');
bodyNode = at === -1 ? null : (named[at + 1] ?? null);
}
let block: SyntaxNode | null = bodyNode;
if (block?.type === 'block_expression')
block = block.namedChildren.find((c) => c?.type === 'block') ?? null;
if (block?.type === 'labeled_statement')
block = block.namedChildren.find((c) => c?.type === 'block') ?? null;
if (block?.type === 'block') {
const exact = resolver.scopeAt(block.startPosition.row + 1, block.startPosition.column, true);
if (exact !== undefined) return exact;
}
return resolver.scopeAt(node.startPosition.row + 1, node.startPosition.column, false);
}
/** `[]T` / `[]const T` / `[N]T` / `[*]T` / `[*:0]T` / `*[N]T` / `*const []T`
* → `T` (with `T`'s own sigils kept: `[]*Thing` → `*Thing`). Undefined when
* the written type has no slice/array layer to remove. */
export function zigElementSpelling(spelling: string): string | undefined {
let t = spelling.trim();
const bang = t.lastIndexOf('!');
if (bang !== -1) t = t.slice(bang + 1).trim();
// A pointer TO an array/slice iterates the pointee.
t = t.replace(/^\*(const\s+)?(?=\[)/, '');
const m = /^\[[^\]]*\]\s*(const\s+)?/.exec(t);
if (m === null) return undefined;
const rest = t.slice(m[0].length).trim();
return rest.length > 0 ? rest : undefined;
}
/** `?T` / `?*T` / `E!?T` → `T` (sigils of `T` kept). Undefined when the
* written type is not visibly optional. */
export function zigOptionalPayloadSpelling(spelling: string): string | undefined {
let t = spelling.trim();
const bang = t.lastIndexOf('!');
if (bang !== -1) t = t.slice(bang + 1).trim();
if (!t.startsWith('?')) return undefined;
const rest = t.slice(1).trim();
return rest.length > 0 ? rest : undefined;
}
/** Resolves a subject expression to the WRITTEN type spelling of its binding
* (`declaredSpelling` when the capture layer reduced it, else `rawName`),
* through the finished scope model of one file. */
class ZigSubjectTypeResolver {
constructor(
private readonly scopes: readonly Scope[],
private readonly indexes: ScopeResolutionIndexes,
private readonly classScopeByDefId: ReadonlyMap<string, Scope>,
) {}
/** Innermost scope containing (line, col); with `exact`, only a scope
* STARTING there (a block's own scope). */
scopeAt(line: number, col: number, exact: boolean): Scope | undefined {
let best: Scope | undefined;
for (const s of this.scopes) {
const r = s.range;
if (exact) {
if (r.startLine === line && r.startCol === col && s.kind === 'Block') return s;
continue;
}
const startsBefore = r.startLine < line || (r.startLine === line && r.startCol <= col);
const endsAfter = r.endLine > line || (r.endLine === line && r.endCol >= col);
if (!startsBefore || !endsAfter) continue;
if (best === undefined || contains(best.range, r)) best = s;
}
return best;
}
spellingOf(subject: SyntaxNode): string | undefined {
const ref = this.typeRefOf(subject);
return ref === undefined ? undefined : (ref.declaredSpelling ?? ref.rawName);
}
private typeRefOf(subjectRaw: SyntaxNode): TypeRef | undefined {
const subject = zigUnwrapValue(subjectRaw);
const scope = this.scopeAt(subject.startPosition.row + 1, subject.startPosition.column, false);
if (scope === undefined) return undefined;
switch (subject.type) {
case 'identifier':
return findReceiverTypeBinding(scope.id, subject.text, this.indexes);
case 'field_expression': {
// `self.items` / `page.session` — the member's binding on the
// object's class scope (field type, or a method's return type).
const object = subject.childForFieldName('object');
const member = subject.childForFieldName('member');
if (object === null || member === null) return undefined;
return this.memberRefOf(object, member.text, scope.id);
}
case 'call_expression': {
const callee = subject.childForFieldName('function');
if (callee === null) return undefined;
if (callee.type === 'identifier') {
// A free call: the fn's return binding, in the scope chain.
return findReceiverTypeBinding(scope.id, callee.text, this.indexes);
}
if (callee.type !== 'field_expression') return undefined;
const object = callee.childForFieldName('object');
const member = callee.childForFieldName('member');
if (object === null || member === null) return undefined;
return this.memberRefOf(object, member.text, scope.id);
}
default:
return undefined;
}
}
/** The binding of `member` on the class the `object` expression is typed
* by — recursively through field chains (`self.a.b`). */
private memberRefOf(object: SyntaxNode, member: string, scopeId: ScopeId): TypeRef | undefined {
const objectRef = this.typeRefOf(object);
if (objectRef === undefined) return undefined;
// A compound rawName (`node.asElement()`, F6's member-call shape) needs
// the shared compound resolver; not walked here.
if (objectRef.rawName.includes('(')) return undefined;
const classDef = findClassBindingInScope(scopeId, objectRef.rawName, this.indexes);
if (classDef === undefined) return undefined;
const classScope = this.classScopeByDefId.get(classDef.nodeId);
return classScope?.typeBindings.get(member);
}
}
function contains(outer: Scope['range'], inner: Scope['range']): boolean {
const startsBefore =
outer.startLine < inner.startLine ||
(outer.startLine === inner.startLine && outer.startCol <= inner.startCol);
const endsAfter =
outer.endLine > inner.endLine ||
(outer.endLine === inner.endLine && outer.endCol >= inner.endCol);
return startsBefore && endsAfter;
}

View file

@ -18,6 +18,7 @@ import { loadZigBuildConfig, type ZigBuildZonConfig } from '../../language-confi
import { resolveZigImportInternal } from '../../import-resolvers/zig.js';
import { zigProvider } from '../zig.js';
import { expandZigWildcardNames, zigArityCompatibility, zigMergeBindings } from './index.js';
import { populateZigRangeBindings } from './range-binding.js';
export const zigScopeResolver: ScopeResolver = {
language: SupportedLanguages.Zig,
@ -46,6 +47,10 @@ export const zigScopeResolver: ScopeResolver = {
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
// Payload captures — `for (items) |it|`, `if (opt) |v|`, `while (it.next())
// |x|` — typed from the subject's binding after finalize (F6).
populateRangeBindings: populateZigRangeBindings,
// Zig has no `super`.
isSuperReceiver: () => false,

View file

@ -21,6 +21,7 @@ pub fn bump(self: *Page) void {
self.count += 1;
_ = self.getArena();
}
<<<<<<< HEAD
// Field-typed receivers (F5): `session: *Session` types the member, so a
// call through it dispatches into Session.zig — directly (`self.session.name()`)
@ -33,3 +34,24 @@ pub fn sessionLabel(self: *Page) []const u8 {
const s = self.session;
return s.name();
}
||||||| parent of fb83c9d9 (fix(zig): type locals through try/catch/orelse, return types and payload captures)
=======
// F6 — value flow: a fallible constructor, member calls whose RETURN types
// type the local, optionals for `if` / `while` payloads.
pub fn make(session: *Session) !*Page {
_ = session;
return error.Nope;
}
pub fn getSession(self: *Page) *Session {
return self.session;
}
pub fn maybeSession(self: *Page) ?*Session {
return self.session;
}
>>>>>>> fb83c9d9 (fix(zig): type locals through try/catch/orelse, return types and payload captures)

View file

@ -11,3 +11,9 @@ pub fn findFrame(self: *Session, page: *Page) u32 {
// Method call on a parameter typed by ANOTHER file-struct.
return page.getArena();
}
// F6 — an iterator-shaped method: `while (s.next()) |p| p.bump()`.
pub fn next(self: *Session) ?*Page {
_ = self;
return null;
}

View file

@ -0,0 +1,109 @@
// F6 — the value-flow shapes that bind a local WITHOUT an annotation. Each fn
// is one shape; the CALLS edge out of it (`… → bump`, `… → name`) only exists
// when the local was typed. Lightpanda: `const page = try Page.init(…)`
// (2,551 `try T.f()` sites), `for (self.frames) |*f|`, `if (self.doc) |doc|`,
// `while (it.next()) |node|`, `const el = node.asElement()`.
const Page = @import("Page.zig");
const Session = @import("Session.zig");
// `try` / `catch` / `orelse` / parens around a constructor call — the
// receiver names the type exactly as in the bare `Page.init(…)` shape.
fn viaTry(s: *Session) !void {
const p = try Page.make(s);
p.bump();
}
fn viaCatch(s: *Session) void {
const p = Page.make(s) catch return;
p.bump();
}
fn viaOrelse(p: *Page) void {
const s = p.maybeSession() orelse return;
_ = s.name();
}
fn viaParens(s: *Session) !void {
const p = (try Page.make(s));
p.bump();
}
fn viaTryLiteral(s: *Session) !void {
const p = try Page{ .session = s };
p.bump();
}
// A free call: the callee's RETURN type types the local.
fn viaFreeCall() void {
const p = makeLocal();
p.bump();
}
fn makeLocal() Page {
return undefined;
}
// A member call on a fn-local receiver: the METHOD's return type, not the
// receiver's — `s` is a Session, not a Page.
fn viaMemberReturn(p: *Page) void {
const s = p.getSession();
_ = s.name();
}
// The same on `self`, inside a container (`const el = self.asElement()`).
const Runner = struct {
page: *Page,
pub fn current(self: *Runner) *Page {
return self.page;
}
pub fn go(self: *Runner) void {
const p = self.current();
p.bump();
}
};
// Payload captures.
fn forSlice(pages: []Page) void {
for (pages) |*p| p.bump();
for (pages) |p| {
_ = p.getArena();
}
}
fn forIndexed(pages: []Page) void {
for (pages, 0..) |p, i| {
_ = i;
p.bump();
}
}
fn ifOptional(o: ?*Page) void {
if (o) |p| p.bump();
}
fn ifCallOptional(p: *Page) void {
if (p.maybeSession()) |s| {
_ = s.name();
}
}
fn whileNext(s: *Session) void {
while (s.next()) |p| p.bump();
}
// One-layer projections bound to a local.
fn viaIndex(pages: []Page) void {
const p = pages[0];
p.bump();
}
fn viaUnwrap(o: ?Page) void {
const p = o.?;
p.bump();
}
fn viaDeref(ptr: *Page) void {
const p = ptr.*;
p.bump();
}
// Guards: nothing typed here, no edge may appear.
fn viaTypeConstructor() void {
// A TitleCase callee is a type constructor (an alias, F7), not a value.
const L = List(u8);
_ = L;
}
fn List(comptime T: type) type {
return struct {
x: T,
};
}

View file

@ -552,4 +552,59 @@ describe.skipIf(!zigAvailable)('Zig type aliases (zig-filestruct fixture, aliase
// resolves `main`'s discards to anything.
expect(calls.filter((c) => c.startsWith('main → '))).toEqual([]);
});
// F6 — locals bound WITHOUT an annotation (src/flow.zig). Before this, the
// call-return rule needed the `call_expression` as the DIRECT value child,
// so `const p = try Page.make(s)` (the shape of 2,551 Lightpanda sites),
// `… catch return`, `… orelse return` typed nothing; no fn had a
// return-type binding, so `const t = makeThing()` / `const el =
// node.asElement()` typed nothing; and payload captures (`for … |*p|`,
// `if (o) |p|`, `while (it.next()) |p|`) had no binding at all. Every
// edge below is a method call on such a local.
it('types a local through try / catch / orelse / parens around a constructor call', () => {
const calls = edgeSet(getRelationships(result, 'CALLS'));
expect(calls).toContain('viaTry → bump');
expect(calls).toContain('viaCatch → bump');
expect(calls).toContain('viaOrelse → name');
expect(calls).toContain('viaParens → bump');
// `try Page{ .session = s }` — a wrapped struct literal
expect(calls).toContain('viaTryLiteral → bump');
});
it('types a local from the callee’s RETURN type: free call, member call on a local, member call on self', () => {
const calls = edgeSet(getRelationships(result, 'CALLS'));
// `const p = makeLocal();` — `fn makeLocal() Page`
expect(calls).toContain('viaFreeCall → bump');
// `const s = p.getSession();` — `p: *Page`, `fn getSession(self: *Page) *Session`.
// The RECEIVER is a Page: had the old "receiver names the type" rule
// applied to a value receiver, `s` would be a Page and `s.name()` would
// find no method (or the wrong one).
expect(calls).toContain('viaMemberReturn → name');
// `const p = self.current();` inside `Runner`
expect(calls).toContain('go → bump');
// A TitleCase callee (`const L = List(u8)`) is a type constructor: the free
// call itself resolves, and NOTHING else — `List ↦ type` must not bind.
expect(calls.filter((c) => c.startsWith('viaTypeConstructor →'))).toEqual([
'viaTypeConstructor → List',
]);
});
it('types payload captures from the subject: for |*p| / |p|, for (…, 0..) |p, i|, if (o) |p|, if (call) |s|, while (it.next()) |p|', () => {
const calls = edgeSet(getRelationships(result, 'CALLS'));
expect(calls).toContain('forSlice → bump');
expect(calls).toContain('forSlice → getArena');
expect(calls).toContain('forIndexed → bump');
expect(calls).toContain('ifOptional → bump');
// `if (p.maybeSession()) |s|` — the payload of the METHOD's `?*Session`
expect(calls).toContain('ifCallOptional → name');
// `while (s.next()) |p|` — `fn next(self: *Session) ?*Page`
expect(calls).toContain('whileNext → bump');
});
it('types one-layer projections bound to a local: items[i], opt.?, ptr.*', () => {
const calls = edgeSet(getRelationships(result, 'CALLS'));
expect(calls).toContain('viaIndex → bump');
expect(calls).toContain('viaUnwrap → bump');
expect(calls).toContain('viaDeref → bump');
});
});

View file

@ -31,6 +31,11 @@ import {
interpretZigTypeBinding,
normalizeZigTypeName,
} from '../../src/core/ingestion/languages/zig/interpret.js';
import {
zigElementSpelling,
zigOptionalPayloadSpelling,
zigPointeeSpelling,
} from '../../src/core/ingestion/languages/zig/range-binding.js';
import { createFieldExtractor } from '../../src/core/ingestion/field-extractors/generic.js';
import { zigFieldConfig } from '../../src/core/ingestion/field-extractors/configs/zig.js';
import { zigProvider } from '../../src/core/ingestion/languages/zig.js';
@ -1242,3 +1247,164 @@ pub fn run(self: *Holder) void {
});
},
);
describeZig('Zig value-inferred and return-type bindings (F6)', () => {
const bindingsOf = (src: string, file = 'x.zig') =>
emitZigScopeCaptures(src, file)
.filter(
(m) => m['@type-binding.name'] !== undefined && m['@type-binding.parameter'] === undefined,
)
.map((m) => ({
...interpretZigTypeBinding(m),
kind: Object.keys(m).find(
(k) =>
k !== '@type-binding.name' &&
k !== '@type-binding.type' &&
k !== '@type-binding.member-call-return',
),
}));
const cr = (boundName: string, rawTypeName: string) => ({
boundName,
rawTypeName,
source: 'constructor-inferred',
kind: '@type-binding.call-return',
});
const ret = (boundName: string, rawTypeName: string) => ({
boundName,
rawTypeName,
source: 'return-annotation',
kind: '@type-binding.return',
});
it('unwraps try / catch / orelse / parens around a constructor call — the receiver still names the type', () => {
// Before: the call had to be the DIRECT value child, so every one of
// these (2,551 `try T.f()` sites in Lightpanda) typed nothing.
const src = `
fn f() !void {
const a = try Thing.make();
const b = Thing.make() catch return;
const c = Thing.maybe() orelse return;
const d = (Thing.make());
const e = try Thing.make() catch |err| return err;
var g = try mod.Thing.make();
}
`;
expect(bindingsOf(src)).toEqual([
cr('a', 'Thing'),
cr('b', 'Thing'),
cr('c', 'Thing'),
cr('d', 'Thing'),
cr('e', 'Thing'),
cr('g', 'mod.Thing'),
]);
});
it('a wrapped struct literal (`try Thing{…}`) is a constructor binding', () => {
expect(bindingsOf('fn f() !void { const t = try Thing{ .a = 1 }; }')).toEqual([
{
boundName: 't',
rawTypeName: 'Thing',
source: 'constructor-inferred',
kind: '@type-binding.constructor',
},
]);
});
it('a free call binds the callee name (chained to its return binding); a member call on a fn-LOCAL receiver binds the compound `recv.method()`', () => {
const src = `
fn f(node: *Node) void {
const t = makeThing();
const el = try node.asElement();
const p = self.parser.next();
for (items) |it| { const n = it.next(); }
}
const R = struct { pub fn go(self: *R) void { const q = self.current(); } };
`;
expect(bindingsOf(src)).toEqual([
cr('t', 'makeThing'),
// `node` is a parameter: the type is asElement's RETURN, walked by the
// shared compound resolver — NOT `Node` (the old rule typed el as its
// receiver, a confidently wrong owner for `el.m()`).
cr('el', 'node.asElement()'),
// `self` is NOT a local of `f` (no such parameter): module-level
// receiver semantics — the receiver text is the type (declines later).
cr('p', 'self.parser'),
// a payload capture is a local too
cr('n', 'it.next()'),
cr('q', 'self.current()'),
]);
});
it('emits nothing for a TitleCase callee (type constructor), a decl literal, an identifier or a field chain', () => {
const src = `
fn f() void {
const B = util.List(u8);
const L = List(u8);
const X = Foo.Bar();
const d = .init();
const y = other;
const z = a.b;
const n = 42;
}
`;
expect(bindingsOf(src)).toEqual([]);
});
it('binds a fn name to its NOMINAL return type; builtins, `type`, @TypeOf and comptime type parameters bind nothing', () => {
const src = `
pub const Iter = struct {
const Self = @This();
pub fn next(self: *Iter) ?Thing { _ = self; return null; }
pub fn me(self: *Iter) *Self { return self; }
pub fn mine() @This() { return .{}; }
pub fn count(self: *Iter) usize { _ = self; return 0; }
pub fn is(self: *Iter, comptime T: type) ?*T { _ = self; return null; }
pub fn Wrap(comptime T: type) type { return struct { pub fn get(self: *@This()) T { _ = self; } }; }
};
pub fn makeErr() std.mem.Allocator.Error!*Thing { return .{}; }
pub fn make0() void {}
pub fn make1() []const u8 {}
pub fn make2() @TypeOf(x) {}
pub fn make3() !List(u8) {}
`;
expect(bindingsOf(src)).toEqual([
ret('next', 'Thing'),
// `*Self` and `@This()` both name the enclosing container
ret('me', 'Iter'),
ret('mine', 'Iter'),
ret('makeErr', 'Thing'),
ret('make3', 'List'),
]);
});
it('a file-struct’s `@This()` return names the file stem', () => {
const src = `
count: u32 = 0,
pub fn init() @This() { return .{}; }
`;
expect(bindingsOf(src, 'src/Page.zig')).toEqual([ret('init', 'Page')]);
});
it('normalizes an error-union payload’s own sigils (`Allocator.Error!*Page` → `Page`)', () => {
expect(normalizeZigTypeName('Allocator.Error!*Page')).toBe('Page');
expect(normalizeZigTypeName('!?*const Page')).toBe('Page');
expect(normalizeZigTypeName('anyerror![]Thing')).toBe('Thing');
});
it('projects one layer off a written type for payloads and projections', () => {
expect(zigElementSpelling('[]Thing')).toBe('Thing');
expect(zigElementSpelling('[]const *Thing')).toBe('*Thing');
expect(zigElementSpelling('[4]Thing')).toBe('Thing');
expect(zigElementSpelling('[*:0]const u8')).toBe('u8');
expect(zigElementSpelling('*const []Thing')).toBe('Thing');
expect(zigElementSpelling('![]Thing')).toBe('Thing');
// not visibly iterable — declines instead of guessing
expect(zigElementSpelling('std.ArrayList(Thing)')).toBeUndefined();
expect(zigElementSpelling('Thing')).toBeUndefined();
expect(zigOptionalPayloadSpelling('?*Thing')).toBe('*Thing');
expect(zigOptionalPayloadSpelling('!?Thing')).toBe('Thing');
expect(zigOptionalPayloadSpelling('*Thing')).toBeUndefined();
expect(zigPointeeSpelling('*const Thing')).toBe('Thing');
expect(zigPointeeSpelling('Thing')).toBeUndefined();
});
});