fix(rust): a local holding a method call's value has the type the method returns (#2716)

A Rust local bound to a method call (`let server = b.port(80).build()`,
`let s = cfg.load()?`, `let conn = c.connect().await`, `if let Some(c) =
arg.get::<Completer>()`) had no type the typed receiver path could read,
so calls on it fell to the name strategies. Those strategies did not read
`?` on a returned `Result`, a `self` method's value or a turbofish, and
picked a same-named method of another type.

The value is now the return type of the method the call reaches on its
receiver's type, read from that method's signature. A receiver is a local,
`self`, or another such call. `.await` hands out an `async fn`'s return
type (read off the source: the extractor's async flag is never set for
Rust) or an `impl Future`'s `Output`, and an `async fn` not awaited is a
future, left unread. A method's own type parameter is the call's turbofish
argument. A parameter the call doesn't write stays opaque: `Standard<W>`
is a `Standard`, but a value that is `W` itself is unread rather than its
bound. A trait's provided method returning `Self` gives the receiver's
own type.

A type with two impls of one trait, `Encoder<Bytes>` and `Encoder<&[u8]>`
or `Service<IncomingStream>` and `Service<Request<B>>`, was ambiguous for
a typed call. The impl whose trait argument is the call's first
argument's type now wins.

A/B with fresh indexes against main on axum, serde, ripgrep, clap, dioxus,
tokio and gleam, every changed edge triaged: 536 calls linked that were
not, 69 removed (calls on std or dependency values a method returns, and
tokio values whose read and write methods come from macro-declared
extension traits), 50 moved to the declared type's method, 565 kept at a
higher confidence. gin, serilog, flask and express are byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-20 19:41:15 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 23b5ca1b43
commit f76021db22
3 changed files with 504 additions and 9 deletions
+165 -9
View File
@@ -9393,6 +9393,12 @@ interface RustWrittenType {
self?: string;
generics?: string;
wrappers?: string[];
/**
* Generic parameters the type names that a call's value fixes, which
* nothing here says: `W` in `build<W: WriteColor>(…) -> Standard<W>`. A
* value that is one of them is unread, whatever its bound.
*/
opaque?: string[];
}
/**
@@ -9770,6 +9776,7 @@ function rustReadType(written: RustWrittenType, ref: UnresolvedRef, context: Res
return { kind: 'named', name: self.type, path: self.root ? [self.root] : [], args: '', wrappers, byRef, written };
}
if (segments.length === 0) {
if (written.opaque?.includes(name)) return { kind: 'unread' };
const bounds = rustGenericBounds(name, written, context);
if (bounds !== undefined) {
const traits = bounds.filter((b) => !RUST_MARKER_TRAITS.has(rustPathHead(b)?.name ?? ''));
@@ -9825,9 +9832,13 @@ function rustValueType(value: string, at: number, scopes: RustScopes, ref: Unres
if (depth > 3) return null;
let v = value.trim();
let unwraps = 0;
let awaits = 0;
for (;;) {
const before = v;
v = v.replace(/\s*\.\s*await$/, '');
if (/\.\s*await$/.test(v)) {
v = v.replace(/\s*\.\s*await$/, '');
awaits++;
}
if (v.endsWith('?')) {
v = v.slice(0, -1).trimEnd();
unwraps++;
@@ -9877,13 +9888,14 @@ function rustValueType(value: string, at: number, scopes: RustScopes, ref: Unres
}
return rustFunctionReturn(segments.slice(0, -1), last, unwraps, at, ref, context);
}
// `x.clone()` is a value of `x`'s type.
if (call && call.args.trim() === '') {
const cloned = /^([a-z_]\w*)\s*\.\s*clone$/.exec(call.callee);
if (call) {
// `x.clone()` is a value of `x`'s type.
const cloned = call.args.trim() === '' ? /^([a-z_]\w*)\s*\.\s*clone$/.exec(call.callee) : null;
if (cloned) return unwrapped(rustLocalType(cloned[1]!, at, scopes, ref, context, depth));
return null;
// `x.m(…)`, `self.m(…)`, `x.m(…)?.n(…)`: what the method it reaches returns.
const method = /^([\s\S]+?)\s*\.\s*([a-z_]\w*)\s*(?:::\s*<([^()]*)>)?$/.exec(call.callee);
return method ? rustMethodReturn(method[1]!, method[2]!, method[3], unwraps, awaits, at, scopes, ref, context, depth) : null;
}
if (call) return null;
const literal = /^((?:[A-Za-z_]\w*\s*::\s*)*[A-Z]\w*)\s*(?:::\s*<[^{}]*>\s*)?\{/.exec(v);
if (literal) {
if (!rustGroupIsWhole(v.slice(v.indexOf('{')))) return null;
@@ -9978,6 +9990,137 @@ function rustFunctionReturn(modules: string[], fn: string, unwraps: number, at:
return unwraps > 0 ? rustUnwrapped(made, unwraps) : made;
}
/**
* What a method call's value is — `let b = builder.port(80)`, `let s =
* self.state()?`, `let n = conn.next_frame().await?` — read from the
* signature of the method the call reaches on its receiver's type
* (rustTypedMethodCall), where `Self` is that method's impl type. A receiver
* is a local, `self`, or another call's value. Null where any step can't be
* told: a receiver or a method from outside the project, a return type
* naming the impl's or the method's own generics, an `async fn` not awaited.
*/
function rustMethodReturn(
receiver: string, method: string, turbofish: string | undefined, unwraps: number, awaits: number, at: number,
scopes: RustScopes, ref: UnresolvedRef, context: ResolutionContext, depth: number,
): RustWrittenType | null {
let memo = RUST_METHOD_RETURNS.get(context);
if (!memo) RUST_METHOD_RETURNS.set(context, (memo = new Map()));
const key = `${ref.filePath}\0${at}\0${receiver}\0${method}\0${turbofish ?? ''}\0${unwraps}\0${awaits}\0${depth}`;
const hit = memo.get(key);
if (hit !== undefined) return hit;
const made = readRustMethodReturn(receiver, method, turbofish, unwraps, awaits, at, scopes, ref, context, depth);
if (memo.size >= 4096) memo.delete(memo.keys().next().value!);
memo.set(key, made);
return made;
}
const RUST_METHOD_RETURNS = new WeakMap<ResolutionContext, Map<string, RustWrittenType | null>>();
function readRustMethodReturn(
receiver: string, method: string, turbofish: string | undefined, unwraps: number, awaits: number, at: number,
scopes: RustScopes, ref: UnresolvedRef, context: ResolutionContext, depth: number,
): RustWrittenType | null {
const recv = receiver.trim();
let written: RustWrittenType | null;
if (recv === 'self') written = { text: 'Self', file: ref.filePath, at };
else if (/^[a-z_]\w*$/.test(recv)) written = rustLocalType(recv, at, scopes, ref, context, depth);
else if (/[)?]$/.test(recv)) written = rustValueType(recv, at, scopes, ref, context, depth + 1);
else return null;
if (!written) return null;
const read = rustReadType(written, ref, context);
const result = rustTypedMethodCall(read, method, ref, context);
if (!result || result === 'unread') return null;
const target = context.getNodeById?.(result.targetNodeId);
const text = target ? rustReturnText(target.signature) : null;
if (!target || !text) return null;
let made: RustWrittenType | null = { text, file: target.filePath, at: rustSignatureOffset(target, context) };
made = rustReturnInstance(made, target, turbofish, read, at, ref, context);
// An `async fn`'s value is a future of what it returns; `.await` hands that out.
const isAsync = rustIsAsyncFn(target, context);
if (awaits > 1 || (awaits === 0 && isAsync)) return null;
if (awaits === 1 && !isAsync) made = rustFutureOutput(made);
return made && unwraps > 0 ? rustUnwrapped(made, unwraps) : made;
}
/**
* A method's return type as the call makes it, where it names a generic or
* `Self`: the method's own type parameters are what the call's turbofish
* writes (clap's `arg.get::<ArgValueCompleter>()` on `fn get<T: ArgExt>(&self)
* -> Option<&T>`), read where the call is, when the rest of the type is std's;
* a trait's provided method's `Self` is the receiver's own type. A parameter
* the call doesn't write, or one of the impl's, stays opaque: the type is
* still the one its head names (`Standard<W>` is a `Standard`), but a value
* that is the parameter itself is unread, the bound alone not telling.
*/
function rustReturnInstance(
made: RustWrittenType, target: Node, turbofish: string | undefined, read: RustTypeRead, at: number,
ref: UnresolvedRef, context: ResolutionContext,
): RustWrittenType {
const names = [...made.text.matchAll(/(?<!['\w])([A-Za-z_]\w*)/g)].map((m) => m[1]!)
.filter((n) => !/^(?:mut|dyn|impl|const|for)$/.test(n));
const generics = names.filter((n) => n !== 'Self' && rustGenericBounds(n, made, context) !== undefined);
if (generics.length === 0) {
// `fn with_x(self) -> Self` in a trait: the value is the receiver's type, not the trait.
const bare = made.text.replace(/^(?:&\s*(?:'\w+\s+)?(?:mut\s+)?)+/, '').trim();
if (bare === 'Self' && read.kind === 'named' && rustTraitOf(target, context) !== null) {
const typeText = [...read.path, read.name].join('::') + (read.args ? `<${read.args}>` : '');
return { text: made.text.replace(/Self\s*$/, typeText), file: read.written.file, at: read.written.at };
}
return made;
}
const opaque: RustWrittenType = { ...made, opaque: [...new Set(generics)] };
if (turbofish === undefined) return opaque;
const scopes = rustScopesOf(made.file, context);
const own = scopes ? rustFnAt(scopes, made.at) : undefined;
const params = own ? splitRustTopLevel(own.generics, ',').map((p) => /^\s*(?:const\s+)?([A-Za-z_]\w*)/.exec(p)?.[1]).filter((p): p is string => p !== undefined) : [];
const args = splitRustTopLevel(turbofish, ',').map((a) => a.trim()).filter((a) => !a.startsWith("'"));
const bound = new Map<string, string>();
for (const g of new Set(generics)) {
const i = params.indexOf(g);
if (i < 0 || i >= args.length || args[i] === '_') return opaque;
bound.set(g, args[i]!);
}
// The type is then read where the call writes the turbofish: anything else it names must mean the same there.
if (names.some((n) => !bound.has(n) && (n === 'Self' || !(RUST_STD_TYPES.has(n) || RUST_PRELUDE.has(n) || RUST_PRIMITIVE_TYPES.has(n))))) return opaque;
const text = made.text.replace(/(?<!['\w])([A-Za-z_]\w*)/g, (n) => bound.get(n) ?? n);
return { text, file: ref.filePath, at };
}
/** Whether a Rust function is declared `async`, read off its source: the extractor's flag never says. */
function rustIsAsyncFn(fn: Node, context: ResolutionContext): boolean {
const scopes = rustScopesOf(fn.filePath, context);
if (!scopes) return false;
const start = rustNodeOffset(fn, context);
const head = scopes.code.slice(start, start + 300);
const name = head.search(new RegExp(`\\bfn\\s+(?:r#)?${fn.name.replace(/^r#/, '')}\\b`));
return name >= 0 && /\basync\b/.test(head.slice(0, name));
}
/**
* What `.await` hands out of a value of a Rust future type: the `Output` of
* `impl Future<Output = T>` or a `Pin<Box<dyn Future<Output = T>>>`, the `T`
* of futures' `BoxFuture<'_, T>`. Null for any other type.
*/
function rustFutureOutput(written: RustWrittenType): RustWrittenType | null {
let t = written.text.trim();
for (const wrapper of [/^(?:(?:std|core)\s*::\s*pin\s*::\s*)?Pin\s*</, /^(?:(?:std|alloc)\s*::\s*boxed\s*::\s*)?Box\s*</]) {
const open = wrapper.exec(t);
if (open && rustAngleEnd(t, open[0].length - 1) === t.length - 1) t = t.slice(open[0].length, -1).trim();
}
const boxed = /^(?:[A-Za-z_]\w*\s*::\s*)*(?:BoxFuture|LocalBoxFuture)\s*</.exec(t);
if (boxed) {
if (rustAngleEnd(t, boxed[0].length - 1) !== t.length - 1) return null;
const args = splitRustTopLevel(t.slice(boxed[0].length, -1), ',').filter((a) => !/^'/.test(a.trim()));
return args.length === 1 ? { ...written, text: args[0]!.trim() } : null;
}
const future = /^(?:impl|dyn)\s+(?:(?:std|core)\s*::\s*future\s*::\s*)?(?:Future|IntoFuture)\s*</.exec(t);
if (!future) return null;
const end = rustAngleEnd(t, future[0].length - 1);
if (end < 0 || !/^\s*(?:\+[\s\S]*)?$/.test(t.slice(end + 1))) return null;
const output = splitRustTopLevel(t.slice(future[0].length, end), ',').map((a) => /^\s*Output\s*=\s*([\s\S]+)$/.exec(a)?.[1]).find((o) => o !== undefined);
return output ? { ...written, text: output.trim() } : null;
}
/**
* The type a name a pattern binds is: in `Some(x)` / `Ok(x)` / `Err(e)` what
* the matched value's `Option` or `Result` holds; in a variant's or a
@@ -10500,9 +10643,21 @@ function rustTypedMethodCall(read: RustTypeRead, method: string, ref: Unresolved
if (traits.length > 1) traits = traits.filter((t) => isRustTraitCallable(t, ref, viaTraits.get(t)![0]!, context));
// Two traits in scope with the method: Rust asks which.
if (traits.length !== 1) return null;
const methods = rustSpecialized(viaTraits.get(traits[0]!)!, read.args, context);
// `impl From<A> for T` and `impl From<B> for T`: the argument decides.
if (new Set(methods.map((m) => rustTraitImplHeader(m, context)?.traitArgs.join(','))).size > 1) return null;
let methods = rustSpecialized(viaTraits.get(traits[0]!)!, read.args, context);
// `impl Encoder<Bytes> for C` and `impl Encoder<&[u8]> for C`: the call's
// argument decides, where its type is one impl's trait argument —
// tokio's `codec.encode(Bytes::from(…), &mut buf)`, axum's
// `router.call(request)` on a `request: Request<String>`.
const traitArgs = (m: Node): string => rustTraitImplHeader(m, context)?.traitArgs.join(',') ?? '';
if (new Set(methods.map(traitArgs)).size > 1) {
const fit = methods.filter((m) => {
const args = rustTraitImplHeader(m, context)?.traitArgs ?? [];
const head = args.find((a) => /^[A-Z]/.test(a)) ?? args[0];
return head !== undefined && rustFirstArgIs(head, ref, context);
});
if (fit.length === 0 || new Set(fit.map(traitArgs)).size > 1) return null;
methods = fit;
}
return rustPickMethod(methods, typeFiles, ref, 0.9);
}
// An impl of an alias of the type, for the instantiation it names: gleam's
@@ -16550,6 +16705,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
RUST_SCOPES.delete(context);
RUST_USE_PATHS.delete(context);
RUST_WALKED_ITEMS.delete(context);
RUST_METHOD_RETURNS.delete(context);
RUST_TRAIT_OF.delete(context);
RUST_TRAIT_IMPLS.delete(context);
RUST_TYPED_REFS.delete(context);