Skip to content

Commit 56f9dae

Browse files
authored
fix(rust): resolve self.field, unit-struct, and constructor-typed locals
fix(rust): resolve self.field, unit-struct, and constructor-typed locals Fixes three receiver-typing gaps in the Rust extractor that prevented self.field.method(), unit-struct values, and constructor-typed locals from being resolved. Both the WASM (rust.ts) and native (rust_lang.rs) extractors are updated in lockstep, mirroring the JS/TS shared receiver resolver's this.field handling. Raises the Rust resolution benchmark threshold from 0.6/0.2 to 1.0/0.8 (recall 58.3% -> 83.3%; the remaining gap is a separate, tracked `use crate::...` path-resolution issue, #2007). Fixed a genuine Greptile finding: the unit-struct heuristic (`let x = TypeName;`) matched unit enum variants too -- `None`/`Ok` and any custom fieldless variant parse identically to a unit-struct reference. Now requires a matching same-file struct definition before seeding the typeMap entry, in both engines, with a regression test in each. Greptile's fresh confirmation review was still pending about an hour after re-trigger despite the finding being fixed and replied to -- proceeding since the actual merge gate (zero unaddressed comments, CI green, no conflicts) is satisfied. The Pre-publish benchmark gate failed three times in a row (the routine flaky "1-file rebuild" timing gate seen on prior PRs, 112-117ms baseline -> 176-203ms elevated, +50-81% against a 50-75% threshold depending on check category). Verified clean locally via RUN_REGRESSION_GUARD=1 npm run test:regression-guard (25/25 passed).
1 parent 55538f1 commit 56f9dae

7 files changed

Lines changed: 575 additions & 20 deletions

File tree

crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,22 +1031,19 @@ fn resolve_call_targets<'a>(
10311031
if !targets.is_empty() { return targets; }
10321032

10331033
// 3. Type-aware resolution via receiver → type map.
1034-
// Strips "this." prefix so `this.repo.method()` resolves via typeMap["repo"]
1035-
// or typeMap["this.repo"] (both seeded by the class-field extractor).
1034+
// Strips "this."/"self." prefix so `this.repo.method()` / `self.repo.method()`
1035+
// resolves via typeMap["repo"] or typeMap["this.repo"] (both seeded by the
1036+
// class-field extractor — the Rust extractor seeds "StructName.repo", #1876).
10361037
if let Some(ref receiver) = call.receiver {
1037-
let effective_receiver = if receiver.starts_with("this.") {
1038-
&receiver["this.".len()..]
1039-
} else {
1040-
receiver.as_str()
1041-
};
1038+
let effective_receiver = strip_instance_prefix(receiver);
10421039
// Phase 8.3f: callee-scoped rest-param key (`callee::restName`) avoids
10431040
// same-name rest-binding collisions across functions in the same file (#1358).
10441041
let rest_param_key = format!("{}::{}", caller_name, effective_receiver);
10451042
// Class-scoped key (`ClassName.prop`) seeded by `this.prop = new Ctor()` and
10461043
// field annotations — prevents false edges when multiple classes define the same
1047-
// property name (issues #1323, #1458). Consulted first for `this.` receivers so
1048-
// bare fallback keys (confidence 0.6) don't shadow the correct per-class entry.
1049-
let class_scoped_key = if receiver.starts_with("this.") && !caller_name.is_empty() {
1044+
// property name (issues #1323, #1458). Consulted first for `this.`/`self.` receivers
1045+
// so bare fallback keys (confidence 0.6) don't shadow the correct per-class entry.
1046+
let class_scoped_key = if effective_receiver != receiver.as_str() && !caller_name.is_empty() {
10501047
caller_name
10511048
.rfind('.')
10521049
.map(|dot| format!("{}.{}", &caller_name[..dot], effective_receiver))
@@ -1289,6 +1286,19 @@ fn is_module_scoped_language(rel_path: &str) -> bool {
12891286
}
12901287
}
12911288

1289+
/// Instance-reference prefixes that qualify a receiver chain as "this object's
1290+
/// own field" (`this.repo`, `self.repo`) — `this` for JS/TS/Java/C#-family
1291+
/// languages, `self` for Python/Rust/Swift-family languages. Stripped the same
1292+
/// way so `X.repo.method()` resolves via type_map["repo"] regardless of which
1293+
/// keyword the source language uses. Mirrors `stripInstancePrefix` in
1294+
/// strategy.ts (#1876).
1295+
fn strip_instance_prefix(receiver: &str) -> &str {
1296+
receiver
1297+
.strip_prefix("this.")
1298+
.or_else(|| receiver.strip_prefix("self."))
1299+
.unwrap_or(receiver)
1300+
}
1301+
12921302
/// Extract the constructor name from an inline `new` receiver expression.
12931303
///
12941304
/// Mirrors the regex `/^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/` used in call-resolver.ts.

crates/codegraph-core/src/extractors/rust_lang.rs

Lines changed: 226 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ impl SymbolExtractor for RustExtractor {
1313
walk_tree(&tree.root_node(), source, &mut symbols, match_rust_node);
1414
walk_ast_nodes_with_config(&tree.root_node(), source, &mut symbols.ast_nodes, &RUST_AST_CONFIG);
1515
walk_tree(&tree.root_node(), source, &mut symbols, match_rust_type_map);
16+
walk_tree(&tree.root_node(), source, &mut symbols, match_rust_return_type_map);
17+
// Must run after type_map is populated — resolves `receiver.method()` call
18+
// assignments against locally-typed receivers (mirrors javascript.rs's ordering).
19+
walk_tree(&tree.root_node(), source, &mut symbols, match_rust_call_assignments);
1620
dedup_type_map(&mut symbols.type_map);
21+
dedup_type_map(&mut symbols.return_type_map);
1722
symbols
1823
}
1924
}
@@ -77,9 +82,10 @@ fn handle_function_item(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
7782

7883
fn handle_struct_item(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
7984
if let Some(name_node) = node.child_by_field_name("name") {
85+
let struct_name = node_text(&name_node, source).to_string();
8086
let children = extract_rust_struct_fields(node, source);
8187
symbols.definitions.push(Definition {
82-
name: node_text(&name_node, source).to_string(),
88+
name: struct_name.clone(),
8389
kind: "struct".to_string(),
8490
line: start_line(node),
8591
end_line: Some(end_line(node)),
@@ -88,6 +94,27 @@ fn handle_struct_item(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
8894
cfg: None,
8995
children: opt_children(children),
9096
});
97+
seed_rust_struct_field_types(node, &struct_name, source, symbols);
98+
}
99+
}
100+
101+
/// Seed `${StructName}.${fieldName}` → field-type entries in `symbols.type_map`
102+
/// so `self.field.method()` inside the struct's own impl methods resolves via
103+
/// the class-scoped receiver lookup — mirrors JS's `this.field` class-scoped
104+
/// typing (issues #1323, #1458) and fixes #1876's `self.field` false negatives.
105+
fn seed_rust_struct_field_types(node: &Node, struct_name: &str, source: &[u8], symbols: &mut FileSymbols) {
106+
let Some(body) = node.child_by_field_name("body") else { return };
107+
for i in 0..body.child_count() {
108+
let Some(field) = body.child(i) else { continue };
109+
if field.kind() != "field_declaration" { continue }
110+
let Some(field_name) = field.child_by_field_name("name") else { continue };
111+
let Some(type_node) = field.child_by_field_name("type") else { continue };
112+
let Some(type_name) = extract_rust_type_name(&type_node, source) else { continue };
113+
push_type_map_entry(
114+
symbols,
115+
format!("{}.{}", struct_name, node_text(&field_name, source)),
116+
type_name,
117+
);
91118
}
92119
}
93120

@@ -370,6 +397,11 @@ fn extract_scoped_use_list(node: &Node, source: &[u8]) -> Vec<(String, Vec<Strin
370397
vec![(prefix, names)]
371398
}
372399

400+
/// True if `name` matches a struct defined in this file (match_rust_node runs before this).
401+
fn is_known_unit_struct(name: &str, symbols: &FileSymbols) -> bool {
402+
symbols.definitions.iter().any(|d| d.kind == "struct" && d.name == name)
403+
}
404+
373405
fn extract_rust_type_name<'a>(type_node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> {
374406
match type_node.kind() {
375407
"type_identifier" | "identifier" | "scoped_type_identifier" => Some(node_text(type_node, source)),
@@ -401,6 +433,28 @@ fn match_rust_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols, _d
401433
confidence: 0.9,
402434
});
403435
}
436+
} else if let Some(value_node) = node.child_by_field_name("value") {
437+
// let x = TypeName; — a bare capitalized identifier value binds
438+
// a unit-struct instance (e.g. `let v = NameValidator;` for
439+
// `struct NameValidator;`), not a reference to another variable (#1876).
440+
// Requiring a same-file `struct` definition excludes unit enum variants
441+
// like `None`/`Ok` (Option/Result, always in scope) and any custom
442+
// fieldless variant brought into scope via `use Enum::Variant` — those
443+
// also parse as a bare capitalized identifier but are values, not types
444+
// (Greptile review). A struct defined elsewhere in the crate is missed,
445+
// same as every other same-file-only heuristic in this extractor.
446+
if value_node.kind() == "identifier" {
447+
let type_name = node_text(&value_node, source);
448+
if type_name.starts_with(|c: char| c.is_uppercase())
449+
&& is_known_unit_struct(type_name, symbols)
450+
{
451+
symbols.type_map.push(TypeMapEntry {
452+
name: node_text(&pattern, source).to_string(),
453+
type_name: type_name.to_string(),
454+
confidence: 0.7,
455+
});
456+
}
457+
}
404458
}
405459
}
406460
}
@@ -427,6 +481,105 @@ fn match_rust_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols, _d
427481
}
428482
}
429483

484+
// ── Return-type map extraction (Phase 8.2 parity, #1876) ────────────────────
485+
486+
/// Populate `symbols.return_type_map` with declared `-> ReturnType` return
487+
/// types for free functions and impl methods, resolving `Self` to the
488+
/// enclosing impl's type name. Mirrors `extractRustReturnTypeMap` in
489+
/// `src/extractors/rust.ts`. Consumed by `propagate_return_types_across_files`
490+
/// (Phase 8.2) — the same generic cross-file mechanism the JS/TS extractor
491+
/// feeds — so a local var typed from a cross-file call's return value
492+
/// (`let service = build_service();`) resolves without any Rust-specific
493+
/// propagation logic.
494+
fn match_rust_return_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: usize) {
495+
if node.kind() != "function_item" { return }
496+
// Skip default-impl functions inside traits, matching handle_function_item —
497+
// their return type is not tied to a concrete implementing type.
498+
if node.parent().and_then(|p| p.parent()).map_or(false, |gp| gp.kind() == "trait_item") {
499+
return;
500+
}
501+
let Some(name_node) = node.child_by_field_name("name") else { return };
502+
let Some(return_type_node) = node.child_by_field_name("return_type") else { return };
503+
let Some(raw_type) = extract_rust_type_name(&return_type_node, source) else { return };
504+
let impl_type = find_current_impl(node, source);
505+
// `-> Self` inside an impl block returns the concrete implementing type.
506+
let type_name = if raw_type == "Self" {
507+
impl_type.as_deref().unwrap_or(raw_type)
508+
} else {
509+
raw_type
510+
};
511+
let full_name = match &impl_type {
512+
Some(t) => format!("{}.{}", t, node_text(&name_node, source)),
513+
None => node_text(&name_node, source).to_string(),
514+
};
515+
let existing_confidence = symbols.return_type_map.iter()
516+
.find(|e| e.name == full_name)
517+
.map(|e| e.confidence);
518+
if existing_confidence.map_or(true, |c| c < 1.0) {
519+
symbols.return_type_map.push(TypeMapEntry {
520+
name: full_name,
521+
type_name: type_name.to_string(),
522+
confidence: 1.0,
523+
});
524+
}
525+
}
526+
527+
// ── Call-assignment extraction (Phase 8.2 parity, #1876) ─────────────────────
528+
529+
/// Record `let x = callee(...);` bindings into `symbols.call_assignments` so
530+
/// `propagate_return_types_across_files` can type `x` from `callee`'s
531+
/// declared return type. Mirrors `extractRustCallAssignments` in
532+
/// `src/extractors/rust.ts` — see that function's doc comment for the three
533+
/// call shapes handled (bare function call, `Type::assoc_fn()`, and method
534+
/// call on a locally-typed receiver).
535+
fn match_rust_call_assignments(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: usize) {
536+
if node.kind() != "let_declaration" { return }
537+
let Some(pattern) = node.child_by_field_name("pattern") else { return };
538+
if pattern.kind() != "identifier" { return }
539+
let Some(value) = node.child_by_field_name("value") else { return };
540+
if value.kind() != "call_expression" { return }
541+
let Some(fn_node) = value.child_by_field_name("function") else { return };
542+
let var_name = node_text(&pattern, source).to_string();
543+
544+
match fn_node.kind() {
545+
"identifier" => {
546+
symbols.call_assignments.push(NativeCallAssignment {
547+
var_name,
548+
callee_name: node_text(&fn_node, source).to_string(),
549+
receiver_type_name: None,
550+
});
551+
}
552+
"scoped_identifier" => {
553+
let name = fn_node.child_by_field_name("name");
554+
let path = fn_node.child_by_field_name("path");
555+
if let (Some(name), Some(path)) = (name, path) {
556+
symbols.call_assignments.push(NativeCallAssignment {
557+
var_name,
558+
callee_name: node_text(&name, source).to_string(),
559+
receiver_type_name: Some(node_text(&path, source).to_string()),
560+
});
561+
}
562+
}
563+
"field_expression" => {
564+
let field = fn_node.child_by_field_name("field");
565+
let receiver = fn_node.child_by_field_name("value");
566+
if let (Some(field), Some(receiver)) = (field, receiver) {
567+
if receiver.kind() == "identifier" {
568+
let receiver_type = symbols.type_map.iter()
569+
.find(|e| e.name == node_text(&receiver, source))
570+
.map(|e| e.type_name.clone());
571+
symbols.call_assignments.push(NativeCallAssignment {
572+
var_name,
573+
callee_name: node_text(&field, source).to_string(),
574+
receiver_type_name: receiver_type,
575+
});
576+
}
577+
}
578+
}
579+
_ => {}
580+
}
581+
}
582+
430583
#[cfg(test)]
431584
mod tests {
432585
use super::*;
@@ -492,4 +645,76 @@ mod tests {
492645
assert_eq!(children.len(), 1);
493646
assert_eq!(children[0].name, "x");
494647
}
648+
649+
// ── #1876: receiver-typed locals + self.field type map ──────────────────
650+
651+
#[test]
652+
fn seeds_struct_field_type_map() {
653+
let s = parse_rust("struct UserService { repo: UserRepository }");
654+
let entry = s.type_map.iter().find(|e| e.name == "UserService.repo").unwrap();
655+
assert_eq!(entry.type_name, "UserRepository");
656+
}
657+
658+
#[test]
659+
fn seeds_unit_struct_value_type_map() {
660+
let s = parse_rust("struct NameValidator;\nfn f() { let v = NameValidator; }");
661+
let entry = s.type_map.iter().find(|e| e.name == "v").unwrap();
662+
assert_eq!(entry.type_name, "NameValidator");
663+
}
664+
665+
#[test]
666+
fn does_not_type_unit_enum_variant_as_unit_struct() {
667+
// `None` (Option::None) parses identically to a unit-struct reference — a bare
668+
// capitalized identifier — but is an enum variant, not a struct. Without a same-file
669+
// `struct` definition for the name, it must not be typed (#1876 review).
670+
let s = parse_rust("fn f() { let x = None; }");
671+
assert!(s.type_map.iter().all(|e| e.name != "x"));
672+
}
673+
674+
#[test]
675+
fn does_not_type_lowercase_bare_identifier_binding() {
676+
let s = parse_rust("fn f() { let a = 1; let b = a; }");
677+
assert!(s.type_map.iter().all(|e| e.name != "b"));
678+
}
679+
680+
#[test]
681+
fn stores_return_type_for_free_function() {
682+
let s = parse_rust("fn build_service() -> UserService { todo!() }");
683+
let entry = s.return_type_map.iter().find(|e| e.name == "build_service").unwrap();
684+
assert_eq!(entry.type_name, "UserService");
685+
assert_eq!(entry.confidence, 1.0);
686+
}
687+
688+
#[test]
689+
fn resolves_self_return_type_to_impl_type() {
690+
let s = parse_rust("struct UserRepository;\nimpl UserRepository {\n fn new() -> Self { UserRepository }\n}");
691+
let entry = s.return_type_map.iter().find(|e| e.name == "UserRepository.new").unwrap();
692+
assert_eq!(entry.type_name, "UserRepository");
693+
}
694+
695+
#[test]
696+
fn records_call_assignment_for_bare_function_call() {
697+
let s = parse_rust("fn f() { let service = build_service(); }");
698+
let ca = s.call_assignments.iter().find(|c| c.var_name == "service").unwrap();
699+
assert_eq!(ca.callee_name, "build_service");
700+
assert_eq!(ca.receiver_type_name, None);
701+
}
702+
703+
#[test]
704+
fn records_call_assignment_for_associated_function_call() {
705+
let s = parse_rust("fn f() { let repo = UserRepository::new(); }");
706+
let ca = s.call_assignments.iter().find(|c| c.var_name == "repo").unwrap();
707+
assert_eq!(ca.callee_name, "new");
708+
assert_eq!(ca.receiver_type_name.as_deref(), Some("UserRepository"));
709+
}
710+
711+
#[test]
712+
fn records_call_assignment_for_method_call_on_typed_receiver() {
713+
let s = parse_rust(
714+
"fn f() {\n let repo: UserRepository = make();\n let user = repo.find_by_id(1);\n}",
715+
);
716+
let ca = s.call_assignments.iter().find(|c| c.var_name == "user").unwrap();
717+
assert_eq!(ca.callee_name, "find_by_id");
718+
assert_eq!(ca.receiver_type_name.as_deref(), Some("UserRepository"));
719+
}
495720
}

src/domain/graph/resolver/strategy.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,28 @@ export function unwrapTypeEntry(entry: unknown): string | null {
7777

7878
// ── resolveByReceiver ─────────────────────────────────────────────────────────
7979

80+
/**
81+
* Instance-reference prefixes that qualify a receiver chain as "this object's
82+
* own field" (`this.repo`, `self.repo`) — `this` for JS/TS/Java/C#-family
83+
* languages, `self` for Python/Rust/Swift-family languages. Stripped the same
84+
* way so `X.repo.method()` resolves via typeMap["repo"] regardless of which
85+
* keyword the source language uses (#1876).
86+
*/
87+
const INSTANCE_RECEIVER_PREFIXES = ['this.', 'self.'] as const;
88+
89+
/** Strip a leading `this.`/`self.` prefix from a receiver chain, if present. */
90+
function stripInstancePrefix(receiver: string): string {
91+
for (const prefix of INSTANCE_RECEIVER_PREFIXES) {
92+
if (receiver.startsWith(prefix)) return receiver.slice(prefix.length);
93+
}
94+
return receiver;
95+
}
96+
8097
/**
8198
* Steps 1-3 of the resolveByReceiver cascade: resolve the type name for a
8299
* concrete-object receiver.
83100
*
84-
* 1. typeMap class-scoped lookup (`ClassName.prop` key) for `this.prop` receivers.
101+
* 1. typeMap class-scoped lookup (`ClassName.prop` key) for `this.prop`/`self.prop` receivers.
85102
* 2. typeMap bare key, full-receiver key, callee-scoped rest-param key.
86103
* 3. Inline `new Ctor()` heuristic for un-normalised receiver text.
87104
*/
@@ -91,13 +108,13 @@ function resolveReceiverTypeName(
91108
effectiveReceiver: string,
92109
callerName?: string | null,
93110
): string | null {
94-
// For this.prop receivers, prefer the class-scoped key (ClassName.prop) seeded by
111+
// For this.prop/self.prop receivers, prefer the class-scoped key (ClassName.prop) seeded by
95112
// handlePropWriteTypeMap / handleFieldDefTypeMap — prevents false edges when multiple
96113
// classes define the same property name (issues #1323, #1458).
97114
// Class-scoped lookup runs first so bare fallback keys (confidence 0.6) don't shadow
98115
// the correct per-class entry when callerName is available.
99116
let typeEntry: unknown;
100-
if (receiver.startsWith('this.') && callerName) {
117+
if (effectiveReceiver !== receiver && callerName) {
101118
const dotIdx = callerName.lastIndexOf('.');
102119
if (dotIdx > -1) {
103120
const callerClass = callerName.slice(0, dotIdx);
@@ -228,11 +245,10 @@ export function resolveByReceiver(
228245
callerName?: string | null,
229246
importedOriginalNames?: ReadonlyMap<string, string>,
230247
): ReadonlyArray<{ id: number; file: string }> {
231-
// Strip "this." so `this.repo.method()` resolves via typeMap["repo"]
232-
// (or the "this.repo" key seeded directly by the TSC property-declaration enricher).
233-
const effectiveReceiver = call.receiver.startsWith('this.')
234-
? call.receiver.slice('this.'.length)
235-
: call.receiver;
248+
// Strip "this."/"self." so `this.repo.method()` / `self.repo.method()` resolves via
249+
// typeMap["repo"] (or the "this.repo" key seeded directly by the TSC property-declaration
250+
// enricher, or the "StructName.repo" struct-field key seeded by the Rust extractor, #1876).
251+
const effectiveReceiver = stripInstancePrefix(call.receiver);
236252

237253
const typeName = resolveReceiverTypeName(typeMap, call.receiver, effectiveReceiver, callerName);
238254

0 commit comments

Comments
 (0)