fix(rust): resolve chained associated-function calls Foo::new().bar() (#750) (#757)

A Rust call through a chained associated function — `Foo::new().bar()`,
`Foo::with(cfg).build()` — dropped the receiver to a bare method name, which
then attached to a same-named method on an unrelated type (a wrong edge) or
didn't resolve. Ports the #645/#608 mechanism for Rust's `::` receivers:

- Part 1: capture Rust return types; `-> Self` yields the `self` marker (resolved
  to the impl's own type, like PHP), references/generics are unwrapped/reduced.
- Part 2: encode an associated-function chain (`Foo::new().bar`), gated to a
  scoped_identifier receiver so instance chains (`x.foo().bar()`) keep bare-name.
- Part 3: resolve via matchScopedCallChain (PHP's `::` resolver, generalized),
  validated by resolveMethodOnType. Wire Rust into the conformance second pass
  (matchScopedCallChain variant) so a chained method provided by a trait the type
  implements (`impl Trait for Type` → existing implements edges) resolves too.

Validated: synthetic decoy + args + Self + trait-default-conformance + absent
safety tests; full suite green (lone failure is the known-flaky #662 daemon test,
passes in isolation). Real-repo A/B vs main: clap (329 .rs) a net precision win —
**+937 added (96% correct builder methods), 622 wrong->right retargets**
(`Command::new().arg()` was mis-resolving to `ArgGroup::arg`, now `Command::arg`),
+162 net unique edges; the pure-drops are largely wrong bare-name edges the fix
correctly stops emitting. tokio-rs/bytes 0/0 (no regression). Known limit: the
single-hop mechanism re-encodes only the first hop of a chain (deeper hops keep
bare-name) — clap's unusually deep builder chains are partly covered.
EXTRACTION_VERSION 10 -> 11.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-09 02:41:59 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 7c7f0dd56f
commit 5805f01957
7 changed files with 163 additions and 28 deletions
+1 -1
View File
@@ -21,4 +21,4 @@
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
* in the product is load-bearing").
*/
export const EXTRACTION_VERSION = 10;
export const EXTRACTION_VERSION = 11;
+31
View File
@@ -2,6 +2,36 @@ import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
/**
* A Rust function's declared return type, normalized to the bare type a chained
* `Foo::new().bar()` could be called on (the #645/#608 mechanism). Reads the
* `return_type` field: `-> Self` yields the marker `self` (resolved to the impl's
* own type at resolution time, like PHP's `self`/`static`); a concrete `-> Foo` /
* `-> FooBuilder` its name; a reference (`&Foo`) is unwrapped; generics are reduced
* to the base type (`Vec<Foo>` → `Vec`); primitives / unit / tuple yield undefined.
* Stdlib types that aren't in the graph simply fail the later existence check.
*/
function extractRustReturnType(node: SyntaxNode, source: string): string | undefined {
let rt = getChildByField(node, 'return_type');
if (!rt) return undefined;
if (rt.type === 'reference_type') {
rt =
rt.namedChildren.find(
(c: SyntaxNode) =>
c.type === 'type_identifier' ||
c.type === 'scoped_type_identifier' ||
c.type === 'generic_type',
) ?? rt;
}
if (!rt || rt.type === 'primitive_type' || rt.type === 'unit_type' || rt.type === 'tuple_type') {
return undefined;
}
const text = getNodeText(rt, source).trim().replace(/<[^>]*>/g, '');
const last = text.split('::').pop()?.trim();
if (!last || !/^[A-Za-z_]\w*$/.test(last)) return undefined;
return last === 'Self' ? 'self' : last;
}
export const rustExtractor: LanguageExtractor = {
// `function_signature_item` is a trait method DECLARATION (`fn render(&self);`,
// no body). Extracting it makes a trait's method set first-class, which
@@ -23,6 +53,7 @@ export const rustExtractor: LanguageExtractor = {
bodyField: 'body',
paramsField: 'parameters',
returnField: 'return_type',
getReturnType: extractRustReturnType,
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
const returnType = getChildByField(node, 'return_type');
+16 -9
View File
@@ -2528,18 +2528,19 @@ export class TreeSitterExtractor {
(this.language === 'cpp' ||
this.language === 'c' ||
this.language === 'kotlin' ||
this.language === 'swift') &&
this.language === 'swift' ||
this.language === 'rust') &&
receiver &&
receiver.type === 'call_expression'
) {
// Receiver that is itself a call — `Foo::instance().bar()`,
// `openSession()->run()`, `mgr.view().render()` (C/C++), or
// `Foo.getInstance().bar()` (Kotlin) / `Foo.make().draw()` (Swift).
// Keep the inner call so resolution can infer bar()'s class from what
// the inner call RETURNS (#645/#608). Encode as `<innerCallee>().<method>`;
// the `().` marker never appears in an ordinary ref, so the resolver
// can detect and split it. Other languages keep the bare-name
// behavior (dropping the receiver) below.
// `openSession()->run()`, `mgr.view().render()` (C/C++),
// `Foo.getInstance().bar()` (Kotlin) / `Foo.make().draw()` (Swift), or
// `Foo::new().bar()` (Rust). Keep the inner call so resolution can
// infer bar()'s class from what the inner call RETURNS (#645/#608).
// Encode as `<innerCallee>().<method>`; the `().` marker never appears
// in an ordinary ref, so the resolver can detect and split it. Other
// languages keep the bare-name behavior (dropping the receiver) below.
let innerCallee: string;
let reencode: boolean;
if (this.language === 'kotlin' || this.language === 'swift') {
@@ -2561,7 +2562,13 @@ export class TreeSitterExtractor {
innerCallee = innerFn
? getNodeText(innerFn, this.source).replace(/->/g, '.').replace(/\s+/g, '')
: '';
reencode = !!innerCallee;
// Rust: only re-encode an associated-function chain
// (`Foo::new().bar()`), whose inner callee is a path/`scoped_identifier`.
// An instance chain (`x.foo().bar()`, inner callee a field_expression)
// keeps bare-name — the `::` resolver can't recover a variable's type,
// so re-encoding would only drop the edge. C/C++ re-encode any inner.
reencode =
this.language === 'rust' ? innerFn?.type === 'scoped_identifier' : !!innerCallee;
}
calleeName = reencode ? `${innerCallee}().${methodName}` : methodName;
} else {