diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts index 2c42ba674..fbc595460 100644 --- a/gitnexus/src/core/ingestion/languages/zig/captures.ts +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -288,8 +288,10 @@ function collectZigThisAliases( function rewriteZigThisAlias( typeNode: SyntaxNode, aliases: ReadonlyMap, + // 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') { + // ` catch [|err|] ` — 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>): Set { + const cached = cache.get(fn.id); + if (cached !== undefined) return cached; + const names = new Set(); + 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 = ;`, 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>, +): + | { 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() ` bind `name ↦ `? 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 : 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>(); for (const m of rawMatches) { const grouped: Record = {}; @@ -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] ` (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); } diff --git a/gitnexus/src/core/ingestion/languages/zig/index.ts b/gitnexus/src/core/ingestion/languages/zig/index.ts index 969351a6b..a57fda445 100644 --- a/gitnexus/src/core/ingestion/languages/zig/index.ts +++ b/gitnexus/src/core/ingestion/languages/zig/index.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/languages/zig/interpret.ts b/gitnexus/src/core/ingestion/languages/zig/interpret.ts index 3f6bbe0e4..eac006720 100644 --- a/gitnexus/src/core/ingestion/languages/zig/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/zig/interpret.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/languages/zig/query.ts b/gitnexus/src/core/ingestion/languages/zig/query.ts index 9701d671d..de8c46dbc 100644 --- a/gitnexus/src/core/ingestion/languages/zig/query.ts +++ b/gitnexus/src/core/ingestion/languages/zig/query.ts @@ -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 = ; where 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 diff --git a/gitnexus/src/core/ingestion/languages/zig/range-binding.ts b/gitnexus/src/core/ingestion/languages/zig/range-binding.ts new file mode 100644 index 000000000..87150ba1f --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/zig/range-binding.ts @@ -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['parse']>; + +const PAYLOAD_HOSTS: ReadonlySet = 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; + 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(); + 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).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).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).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, + ) {} + + /** 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; +} diff --git a/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts index babfb85c8..e7a303fb9 100644 --- a/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts @@ -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, diff --git a/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Page.zig b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Page.zig index de8aa7b90..546ab667c 100644 --- a/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Page.zig +++ b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Page.zig @@ -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) \ No newline at end of file diff --git a/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Session.zig b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Session.zig index 66131d310..2394c93aa 100644 --- a/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Session.zig +++ b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Session.zig @@ -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; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/flow.zig b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/flow.zig new file mode 100644 index 000000000..7d22d4f76 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/flow.zig @@ -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, + }; +} diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index 16af32370..1d7532611 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -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'); + }); }); diff --git a/gitnexus/test/unit/zig-extractors.test.ts b/gitnexus/test/unit/zig-extractors.test.ts index d3b74e936..83f982ed5 100644 --- a/gitnexus/test/unit/zig-extractors.test.ts +++ b/gitnexus/test/unit/zig-extractors.test.ts @@ -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(); + }); +});