diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs index e2580fc20..1dad687f1 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs @@ -1031,22 +1031,19 @@ fn resolve_call_targets<'a>( if !targets.is_empty() { return targets; } // 3. Type-aware resolution via receiver → type map. - // Strips "this." prefix so `this.repo.method()` resolves via typeMap["repo"] - // or typeMap["this.repo"] (both seeded by the class-field extractor). + // Strips "this."/"self." prefix so `this.repo.method()` / `self.repo.method()` + // resolves via typeMap["repo"] or typeMap["this.repo"] (both seeded by the + // class-field extractor — the Rust extractor seeds "StructName.repo", #1876). if let Some(ref receiver) = call.receiver { - let effective_receiver = if receiver.starts_with("this.") { - &receiver["this.".len()..] - } else { - receiver.as_str() - }; + let effective_receiver = strip_instance_prefix(receiver); // Phase 8.3f: callee-scoped rest-param key (`callee::restName`) avoids // same-name rest-binding collisions across functions in the same file (#1358). let rest_param_key = format!("{}::{}", caller_name, effective_receiver); // Class-scoped key (`ClassName.prop`) seeded by `this.prop = new Ctor()` and // field annotations — prevents false edges when multiple classes define the same - // property name (issues #1323, #1458). Consulted first for `this.` receivers so - // bare fallback keys (confidence 0.6) don't shadow the correct per-class entry. - let class_scoped_key = if receiver.starts_with("this.") && !caller_name.is_empty() { + // property name (issues #1323, #1458). Consulted first for `this.`/`self.` receivers + // so bare fallback keys (confidence 0.6) don't shadow the correct per-class entry. + let class_scoped_key = if effective_receiver != receiver.as_str() && !caller_name.is_empty() { caller_name .rfind('.') .map(|dot| format!("{}.{}", &caller_name[..dot], effective_receiver)) @@ -1289,6 +1286,19 @@ fn is_module_scoped_language(rel_path: &str) -> bool { } } +/// Instance-reference prefixes that qualify a receiver chain as "this object's +/// own field" (`this.repo`, `self.repo`) — `this` for JS/TS/Java/C#-family +/// languages, `self` for Python/Rust/Swift-family languages. Stripped the same +/// way so `X.repo.method()` resolves via type_map["repo"] regardless of which +/// keyword the source language uses. Mirrors `stripInstancePrefix` in +/// strategy.ts (#1876). +fn strip_instance_prefix(receiver: &str) -> &str { + receiver + .strip_prefix("this.") + .or_else(|| receiver.strip_prefix("self.")) + .unwrap_or(receiver) +} + /// Extract the constructor name from an inline `new` receiver expression. /// /// Mirrors the regex `/^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/` used in call-resolver.ts. diff --git a/crates/codegraph-core/src/extractors/rust_lang.rs b/crates/codegraph-core/src/extractors/rust_lang.rs index 8fc645eb4..9dbd5644a 100644 --- a/crates/codegraph-core/src/extractors/rust_lang.rs +++ b/crates/codegraph-core/src/extractors/rust_lang.rs @@ -13,7 +13,12 @@ impl SymbolExtractor for RustExtractor { walk_tree(&tree.root_node(), source, &mut symbols, match_rust_node); walk_ast_nodes_with_config(&tree.root_node(), source, &mut symbols.ast_nodes, &RUST_AST_CONFIG); walk_tree(&tree.root_node(), source, &mut symbols, match_rust_type_map); + walk_tree(&tree.root_node(), source, &mut symbols, match_rust_return_type_map); + // Must run after type_map is populated — resolves `receiver.method()` call + // assignments against locally-typed receivers (mirrors javascript.rs's ordering). + walk_tree(&tree.root_node(), source, &mut symbols, match_rust_call_assignments); dedup_type_map(&mut symbols.type_map); + dedup_type_map(&mut symbols.return_type_map); symbols } } @@ -77,9 +82,10 @@ fn handle_function_item(node: &Node, source: &[u8], symbols: &mut FileSymbols) { fn handle_struct_item(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if let Some(name_node) = node.child_by_field_name("name") { + let struct_name = node_text(&name_node, source).to_string(); let children = extract_rust_struct_fields(node, source); symbols.definitions.push(Definition { - name: node_text(&name_node, source).to_string(), + name: struct_name.clone(), kind: "struct".to_string(), line: start_line(node), end_line: Some(end_line(node)), @@ -88,6 +94,27 @@ fn handle_struct_item(node: &Node, source: &[u8], symbols: &mut FileSymbols) { cfg: None, children: opt_children(children), }); + seed_rust_struct_field_types(node, &struct_name, source, symbols); + } +} + +/// Seed `${StructName}.${fieldName}` → field-type entries in `symbols.type_map` +/// so `self.field.method()` inside the struct's own impl methods resolves via +/// the class-scoped receiver lookup — mirrors JS's `this.field` class-scoped +/// typing (issues #1323, #1458) and fixes #1876's `self.field` false negatives. +fn seed_rust_struct_field_types(node: &Node, struct_name: &str, source: &[u8], symbols: &mut FileSymbols) { + let Some(body) = node.child_by_field_name("body") else { return }; + for i in 0..body.child_count() { + let Some(field) = body.child(i) else { continue }; + if field.kind() != "field_declaration" { continue } + let Some(field_name) = field.child_by_field_name("name") else { continue }; + let Some(type_node) = field.child_by_field_name("type") else { continue }; + let Some(type_name) = extract_rust_type_name(&type_node, source) else { continue }; + push_type_map_entry( + symbols, + format!("{}.{}", struct_name, node_text(&field_name, source)), + type_name, + ); } } @@ -370,6 +397,11 @@ fn extract_scoped_use_list(node: &Node, source: &[u8]) -> Vec<(String, Vec bool { + symbols.definitions.iter().any(|d| d.kind == "struct" && d.name == name) +} + fn extract_rust_type_name<'a>(type_node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> { match type_node.kind() { "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 confidence: 0.9, }); } + } else if let Some(value_node) = node.child_by_field_name("value") { + // let x = TypeName; — a bare capitalized identifier value binds + // a unit-struct instance (e.g. `let v = NameValidator;` for + // `struct NameValidator;`), not a reference to another variable (#1876). + // Requiring a same-file `struct` definition excludes unit enum variants + // like `None`/`Ok` (Option/Result, always in scope) and any custom + // fieldless variant brought into scope via `use Enum::Variant` — those + // also parse as a bare capitalized identifier but are values, not types + // (Greptile review). A struct defined elsewhere in the crate is missed, + // same as every other same-file-only heuristic in this extractor. + if value_node.kind() == "identifier" { + let type_name = node_text(&value_node, source); + if type_name.starts_with(|c: char| c.is_uppercase()) + && is_known_unit_struct(type_name, symbols) + { + symbols.type_map.push(TypeMapEntry { + name: node_text(&pattern, source).to_string(), + type_name: type_name.to_string(), + confidence: 0.7, + }); + } + } } } } @@ -427,6 +481,105 @@ fn match_rust_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols, _d } } +// ── Return-type map extraction (Phase 8.2 parity, #1876) ──────────────────── + +/// Populate `symbols.return_type_map` with declared `-> ReturnType` return +/// types for free functions and impl methods, resolving `Self` to the +/// enclosing impl's type name. Mirrors `extractRustReturnTypeMap` in +/// `src/extractors/rust.ts`. Consumed by `propagate_return_types_across_files` +/// (Phase 8.2) — the same generic cross-file mechanism the JS/TS extractor +/// feeds — so a local var typed from a cross-file call's return value +/// (`let service = build_service();`) resolves without any Rust-specific +/// propagation logic. +fn match_rust_return_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: usize) { + if node.kind() != "function_item" { return } + // Skip default-impl functions inside traits, matching handle_function_item — + // their return type is not tied to a concrete implementing type. + if node.parent().and_then(|p| p.parent()).map_or(false, |gp| gp.kind() == "trait_item") { + return; + } + let Some(name_node) = node.child_by_field_name("name") else { return }; + let Some(return_type_node) = node.child_by_field_name("return_type") else { return }; + let Some(raw_type) = extract_rust_type_name(&return_type_node, source) else { return }; + let impl_type = find_current_impl(node, source); + // `-> Self` inside an impl block returns the concrete implementing type. + let type_name = if raw_type == "Self" { + impl_type.as_deref().unwrap_or(raw_type) + } else { + raw_type + }; + let full_name = match &impl_type { + Some(t) => format!("{}.{}", t, node_text(&name_node, source)), + None => node_text(&name_node, source).to_string(), + }; + let existing_confidence = symbols.return_type_map.iter() + .find(|e| e.name == full_name) + .map(|e| e.confidence); + if existing_confidence.map_or(true, |c| c < 1.0) { + symbols.return_type_map.push(TypeMapEntry { + name: full_name, + type_name: type_name.to_string(), + confidence: 1.0, + }); + } +} + +// ── Call-assignment extraction (Phase 8.2 parity, #1876) ───────────────────── + +/// Record `let x = callee(...);` bindings into `symbols.call_assignments` so +/// `propagate_return_types_across_files` can type `x` from `callee`'s +/// declared return type. Mirrors `extractRustCallAssignments` in +/// `src/extractors/rust.ts` — see that function's doc comment for the three +/// call shapes handled (bare function call, `Type::assoc_fn()`, and method +/// call on a locally-typed receiver). +fn match_rust_call_assignments(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: usize) { + if node.kind() != "let_declaration" { return } + let Some(pattern) = node.child_by_field_name("pattern") else { return }; + if pattern.kind() != "identifier" { return } + let Some(value) = node.child_by_field_name("value") else { return }; + if value.kind() != "call_expression" { return } + let Some(fn_node) = value.child_by_field_name("function") else { return }; + let var_name = node_text(&pattern, source).to_string(); + + match fn_node.kind() { + "identifier" => { + symbols.call_assignments.push(NativeCallAssignment { + var_name, + callee_name: node_text(&fn_node, source).to_string(), + receiver_type_name: None, + }); + } + "scoped_identifier" => { + let name = fn_node.child_by_field_name("name"); + let path = fn_node.child_by_field_name("path"); + if let (Some(name), Some(path)) = (name, path) { + symbols.call_assignments.push(NativeCallAssignment { + var_name, + callee_name: node_text(&name, source).to_string(), + receiver_type_name: Some(node_text(&path, source).to_string()), + }); + } + } + "field_expression" => { + let field = fn_node.child_by_field_name("field"); + let receiver = fn_node.child_by_field_name("value"); + if let (Some(field), Some(receiver)) = (field, receiver) { + if receiver.kind() == "identifier" { + let receiver_type = symbols.type_map.iter() + .find(|e| e.name == node_text(&receiver, source)) + .map(|e| e.type_name.clone()); + symbols.call_assignments.push(NativeCallAssignment { + var_name, + callee_name: node_text(&field, source).to_string(), + receiver_type_name: receiver_type, + }); + } + } + } + _ => {} + } +} + #[cfg(test)] mod tests { use super::*; @@ -492,4 +645,76 @@ mod tests { assert_eq!(children.len(), 1); assert_eq!(children[0].name, "x"); } + + // ── #1876: receiver-typed locals + self.field type map ────────────────── + + #[test] + fn seeds_struct_field_type_map() { + let s = parse_rust("struct UserService { repo: UserRepository }"); + let entry = s.type_map.iter().find(|e| e.name == "UserService.repo").unwrap(); + assert_eq!(entry.type_name, "UserRepository"); + } + + #[test] + fn seeds_unit_struct_value_type_map() { + let s = parse_rust("struct NameValidator;\nfn f() { let v = NameValidator; }"); + let entry = s.type_map.iter().find(|e| e.name == "v").unwrap(); + assert_eq!(entry.type_name, "NameValidator"); + } + + #[test] + fn does_not_type_unit_enum_variant_as_unit_struct() { + // `None` (Option::None) parses identically to a unit-struct reference — a bare + // capitalized identifier — but is an enum variant, not a struct. Without a same-file + // `struct` definition for the name, it must not be typed (#1876 review). + let s = parse_rust("fn f() { let x = None; }"); + assert!(s.type_map.iter().all(|e| e.name != "x")); + } + + #[test] + fn does_not_type_lowercase_bare_identifier_binding() { + let s = parse_rust("fn f() { let a = 1; let b = a; }"); + assert!(s.type_map.iter().all(|e| e.name != "b")); + } + + #[test] + fn stores_return_type_for_free_function() { + let s = parse_rust("fn build_service() -> UserService { todo!() }"); + let entry = s.return_type_map.iter().find(|e| e.name == "build_service").unwrap(); + assert_eq!(entry.type_name, "UserService"); + assert_eq!(entry.confidence, 1.0); + } + + #[test] + fn resolves_self_return_type_to_impl_type() { + let s = parse_rust("struct UserRepository;\nimpl UserRepository {\n fn new() -> Self { UserRepository }\n}"); + let entry = s.return_type_map.iter().find(|e| e.name == "UserRepository.new").unwrap(); + assert_eq!(entry.type_name, "UserRepository"); + } + + #[test] + fn records_call_assignment_for_bare_function_call() { + let s = parse_rust("fn f() { let service = build_service(); }"); + let ca = s.call_assignments.iter().find(|c| c.var_name == "service").unwrap(); + assert_eq!(ca.callee_name, "build_service"); + assert_eq!(ca.receiver_type_name, None); + } + + #[test] + fn records_call_assignment_for_associated_function_call() { + let s = parse_rust("fn f() { let repo = UserRepository::new(); }"); + let ca = s.call_assignments.iter().find(|c| c.var_name == "repo").unwrap(); + assert_eq!(ca.callee_name, "new"); + assert_eq!(ca.receiver_type_name.as_deref(), Some("UserRepository")); + } + + #[test] + fn records_call_assignment_for_method_call_on_typed_receiver() { + let s = parse_rust( + "fn f() {\n let repo: UserRepository = make();\n let user = repo.find_by_id(1);\n}", + ); + let ca = s.call_assignments.iter().find(|c| c.var_name == "user").unwrap(); + assert_eq!(ca.callee_name, "find_by_id"); + assert_eq!(ca.receiver_type_name.as_deref(), Some("UserRepository")); + } } diff --git a/src/domain/graph/resolver/strategy.ts b/src/domain/graph/resolver/strategy.ts index f4b99b536..4a50b1ecf 100644 --- a/src/domain/graph/resolver/strategy.ts +++ b/src/domain/graph/resolver/strategy.ts @@ -77,11 +77,28 @@ export function unwrapTypeEntry(entry: unknown): string | null { // ── resolveByReceiver ───────────────────────────────────────────────────────── +/** + * Instance-reference prefixes that qualify a receiver chain as "this object's + * own field" (`this.repo`, `self.repo`) — `this` for JS/TS/Java/C#-family + * languages, `self` for Python/Rust/Swift-family languages. Stripped the same + * way so `X.repo.method()` resolves via typeMap["repo"] regardless of which + * keyword the source language uses (#1876). + */ +const INSTANCE_RECEIVER_PREFIXES = ['this.', 'self.'] as const; + +/** Strip a leading `this.`/`self.` prefix from a receiver chain, if present. */ +function stripInstancePrefix(receiver: string): string { + for (const prefix of INSTANCE_RECEIVER_PREFIXES) { + if (receiver.startsWith(prefix)) return receiver.slice(prefix.length); + } + return receiver; +} + /** * Steps 1-3 of the resolveByReceiver cascade: resolve the type name for a * concrete-object receiver. * - * 1. typeMap class-scoped lookup (`ClassName.prop` key) for `this.prop` receivers. + * 1. typeMap class-scoped lookup (`ClassName.prop` key) for `this.prop`/`self.prop` receivers. * 2. typeMap bare key, full-receiver key, callee-scoped rest-param key. * 3. Inline `new Ctor()` heuristic for un-normalised receiver text. */ @@ -91,13 +108,13 @@ function resolveReceiverTypeName( effectiveReceiver: string, callerName?: string | null, ): string | null { - // For this.prop receivers, prefer the class-scoped key (ClassName.prop) seeded by + // For this.prop/self.prop receivers, prefer the class-scoped key (ClassName.prop) seeded by // handlePropWriteTypeMap / handleFieldDefTypeMap — prevents false edges when multiple // classes define the same property name (issues #1323, #1458). // Class-scoped lookup runs first so bare fallback keys (confidence 0.6) don't shadow // the correct per-class entry when callerName is available. let typeEntry: unknown; - if (receiver.startsWith('this.') && callerName) { + if (effectiveReceiver !== receiver && callerName) { const dotIdx = callerName.lastIndexOf('.'); if (dotIdx > -1) { const callerClass = callerName.slice(0, dotIdx); @@ -228,11 +245,10 @@ export function resolveByReceiver( callerName?: string | null, importedOriginalNames?: ReadonlyMap, ): ReadonlyArray<{ id: number; file: string }> { - // Strip "this." so `this.repo.method()` resolves via typeMap["repo"] - // (or the "this.repo" key seeded directly by the TSC property-declaration enricher). - const effectiveReceiver = call.receiver.startsWith('this.') - ? call.receiver.slice('this.'.length) - : call.receiver; + // Strip "this."/"self." so `this.repo.method()` / `self.repo.method()` resolves via + // typeMap["repo"] (or the "this.repo" key seeded directly by the TSC property-declaration + // enricher, or the "StructName.repo" struct-field key seeded by the Rust extractor, #1876). + const effectiveReceiver = stripInstancePrefix(call.receiver); const typeName = resolveReceiverTypeName(typeMap, call.receiver, effectiveReceiver, callerName); diff --git a/src/extractors/rust.ts b/src/extractors/rust.ts index 50472f012..5f3dfc0fa 100644 --- a/src/extractors/rust.ts +++ b/src/extractors/rust.ts @@ -25,10 +25,16 @@ export function extractRustSymbols(tree: TreeSitterTree, _filePath: string): Ext classes: [], exports: [], typeMap: new Map(), + returnTypeMap: new Map(), + callAssignments: [], }; walkRustNode(tree.rootNode, ctx); extractRustTypeMap(tree.rootNode, ctx); + extractRustReturnTypeMap(tree.rootNode, ctx); + // Must run after typeMap is populated — resolves `receiver.method()` call + // assignments against locally-typed receivers (mirrors javascript.ts's ordering). + extractRustCallAssignments(tree.rootNode, ctx); return ctx; } @@ -102,6 +108,32 @@ function handleRustStructItem(node: TreeSitterNode, ctx: ExtractorOutput): void children: fields.length > 0 ? fields : undefined, visibility: rustVisibility(node), }); + seedRustStructFieldTypes(node, nameNode.text, ctx); +} + +/** + * Seed `${StructName}.${fieldName}` → field-type entries in ctx.typeMap so + * `self.field.method()` inside the struct's own impl methods resolves via the + * class-scoped receiver lookup — mirrors JS's `this.field` class-scoped typing + * (issues #1323, #1458) and fixes #1876's `self.field` false negatives. + */ +function seedRustStructFieldTypes( + structNode: TreeSitterNode, + structName: string, + ctx: ExtractorOutput, +): void { + if (!ctx.typeMap) return; + const body = structNode.childForFieldName('body'); + if (!body) return; + for (let i = 0; i < body.childCount; i++) { + const field = body.child(i); + if (field?.type !== 'field_declaration') continue; + const fieldName = field.childForFieldName('name'); + const typeNode = field.childForFieldName('type'); + if (!fieldName || !typeNode) continue; + const typeName = extractRustTypeName(typeNode); + if (typeName) setTypeMapEntry(ctx.typeMap, `${structName}.${fieldName.text}`, typeName, 0.9); + } } function handleRustEnumItem(node: TreeSitterNode, ctx: ExtractorOutput): void { @@ -263,6 +295,11 @@ function extractRustTypeMap(node: TreeSitterNode, ctx: ExtractorOutput): void { extractRustTypeMapDepth(node, ctx, 0); } +/** True if `name` matches a struct defined in this file (walkRustNode runs before this). */ +function isKnownUnitStruct(name: string, ctx: ExtractorOutput): boolean { + return ctx.definitions.some((d) => d.kind === 'struct' && d.name === name); +} + function extractRustTypeMapDepth(node: TreeSitterNode, ctx: ExtractorOutput, depth: number): void { if (depth >= MAX_WALK_DEPTH) return; @@ -273,6 +310,25 @@ function extractRustTypeMapDepth(node: TreeSitterNode, ctx: ExtractorOutput, dep if (pattern && pattern.type === 'identifier' && typeNode) { const typeName = extractRustTypeName(typeNode); if (typeName && ctx.typeMap) setTypeMapEntry(ctx.typeMap, pattern.text, typeName, 0.9); + } else if (pattern && pattern.type === 'identifier' && !typeNode) { + // let x = TypeName; — a bare capitalized identifier value binds a + // unit-struct instance (e.g. `let v = NameValidator;` for `struct + // NameValidator;`), not a reference to another variable (#1876). + // Requiring a same-file `struct` definition excludes unit enum variants + // like `None`/`Ok` (Option/Result, always in scope) and any custom + // fieldless variant brought into scope via `use Enum::Variant` — those + // also parse as a bare capitalized identifier but are values, not types + // (Greptile review). A struct defined elsewhere in the crate is missed, + // same as every other same-file-only heuristic in this extractor. + const valueNode = node.childForFieldName('value'); + if ( + valueNode?.type === 'identifier' && + /^[A-Z]/.test(valueNode.text) && + ctx.typeMap && + isKnownUnitStruct(valueNode.text, ctx) + ) { + setTypeMapEntry(ctx.typeMap, pattern.text, valueNode.text, 0.7); + } } } @@ -317,6 +373,123 @@ function extractRustTypeName(typeNode: TreeSitterNode): string | null { return null; } +// ── Return-type map extraction (Phase 8.2 parity, #1876) ──────────────────── + +/** + * Populate ctx.returnTypeMap with declared `-> ReturnType` return types for + * free functions and impl methods, resolving `Self` to the enclosing impl's + * type name. Consumed by build-edges.ts's `propagateReturnTypesAcrossFiles` + * (Phase 8.2) — the same generic cross-file mechanism the JS/TS extractor + * feeds — so a local var typed from a cross-file call's return value + * (`let service = build_service();`) resolves without any Rust-specific + * propagation logic. + */ +function extractRustReturnTypeMap(node: TreeSitterNode, ctx: ExtractorOutput): void { + extractRustReturnTypeMapDepth(node, ctx, 0); +} + +function extractRustReturnTypeMapDepth( + node: TreeSitterNode, + ctx: ExtractorOutput, + depth: number, +): void { + if (depth >= MAX_WALK_DEPTH) return; + // Skip default-impl functions inside traits, matching handleRustFuncItem — + // their return type is not tied to a concrete implementing type. + if (node.type === 'function_item' && node.parent?.parent?.type !== 'trait_item') { + storeRustReturnType(node, ctx); + } + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) extractRustReturnTypeMapDepth(child, ctx, depth + 1); + } +} + +/** Extract the return type of a function/method node and store it in ctx.returnTypeMap. */ +function storeRustReturnType(node: TreeSitterNode, ctx: ExtractorOutput): void { + if (!ctx.returnTypeMap) return; + const nameNode = node.childForFieldName('name'); + const returnTypeNode = node.childForFieldName('return_type'); + if (!nameNode || !returnTypeNode) return; + const rawType = extractRustTypeName(returnTypeNode); + if (!rawType) return; + const implType = findCurrentImpl(node); + // `-> Self` inside an impl block returns the concrete implementing type. + const typeName = rawType === 'Self' && implType ? implType : rawType; + const fullName = implType ? `${implType}.${nameNode.text}` : nameNode.text; + const existing = ctx.returnTypeMap.get(fullName); + if (!existing || existing.confidence < 1.0) { + ctx.returnTypeMap.set(fullName, { type: typeName, confidence: 1.0 }); + } +} + +// ── Call-assignment extraction (Phase 8.2 parity, #1876) ───────────────────── + +/** + * Record `let x = callee(...);` bindings into ctx.callAssignments so + * build-edges.ts's cross-file return-type propagation can type `x` from + * `callee`'s declared return type. Handles three call shapes: + * - bare function call (`build_service()`) — calleeName only, resolved + * against the file's imports by the generic propagation pass. + * - associated-function call (`Type::assoc_fn()`) — the type is already + * spelled out in the call syntax, so receiverTypeName is the literal + * path text (no typeMap lookup needed). + * - method call on a locally-typed receiver (`x.method()`) — receiverTypeName + * is resolved from ctx.typeMap at extraction time, mirroring the + * JS/TS extractor's member_expression case. + */ +function extractRustCallAssignments(node: TreeSitterNode, ctx: ExtractorOutput): void { + extractRustCallAssignmentsDepth(node, ctx, 0); +} + +function extractRustCallAssignmentsDepth( + node: TreeSitterNode, + ctx: ExtractorOutput, + depth: number, +): void { + if (depth >= MAX_WALK_DEPTH) return; + if (node.type === 'let_declaration') { + recordRustCallAssignment(node, ctx); + } + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) extractRustCallAssignmentsDepth(child, ctx, depth + 1); + } +} + +function recordRustCallAssignment(node: TreeSitterNode, ctx: ExtractorOutput): void { + if (!ctx.callAssignments) return; + const pattern = node.childForFieldName('pattern'); + const value = node.childForFieldName('value'); + if (pattern?.type !== 'identifier' || value?.type !== 'call_expression') return; + const fn = value.childForFieldName('function'); + if (!fn) return; + const varName = pattern.text; + + if (fn.type === 'identifier') { + ctx.callAssignments.push({ varName, calleeName: fn.text }); + return; + } + if (fn.type === 'scoped_identifier') { + const name = fn.childForFieldName('name'); + const path = fn.childForFieldName('path'); + if (name && path) { + ctx.callAssignments.push({ varName, calleeName: name.text, receiverTypeName: path.text }); + } + return; + } + if (fn.type === 'field_expression') { + const field = fn.childForFieldName('field'); + const receiver = fn.childForFieldName('value'); + if (field && receiver?.type === 'identifier') { + const receiverEntry = ctx.typeMap?.get(receiver.text); + const receiverTypeName = + typeof receiverEntry === 'string' ? receiverEntry : receiverEntry?.type; + ctx.callAssignments.push({ varName, calleeName: field.text, receiverTypeName }); + } + } +} + /** Collect names from a scoped_use_list's list node. */ function collectScopedNames(listNode: TreeSitterNode): string[] { const names: string[] = []; diff --git a/tests/benchmarks/resolution/resolution-benchmark.test.ts b/tests/benchmarks/resolution/resolution-benchmark.test.ts index c7100b843..f5231f8d1 100644 --- a/tests/benchmarks/resolution/resolution-benchmark.test.ts +++ b/tests/benchmarks/resolution/resolution-benchmark.test.ts @@ -179,7 +179,15 @@ const THRESHOLDS: Record = { csharp: { precision: 1.0, recall: 0.9 }, kotlin: { precision: 0.6, recall: 0.2 }, // Lower bars — resolution still maturing - rust: { precision: 0.6, recall: 0.2 }, + // rust 1.0/0.8 (#1876): struct-field typeMap seeding + self./this.-prefix parity in the + // shared receiver resolver fix `self.field.method()` dispatch; unit-struct-value and + // `Type::assoc_fn()` typeMap heuristics plus Phase 8.2 return-type-map/callAssignments + // propagation (mirroring the JS/TS extractor) fix trait-dispatch and constructor-typed + // locals. Recall lifted from 58.3% (14/24) to 83.3% (20/24); precision stays 100%. + // Remaining 4 false negatives (`main -> UserService.*`/`User.display_name`, typed from a + // cross-file plain function call's return value) are blocked on a separate root cause — + // Rust `use crate::…` paths never resolve to real files (#2007) — not on receiver typing. + rust: { precision: 1.0, recall: 0.8 }, cpp: { precision: 0.6, recall: 0.2 }, swift: { precision: 0.5, recall: 0.15 }, // TODO(#872): raise haskell thresholds once call resolution lands diff --git a/tests/parsers/rust.test.ts b/tests/parsers/rust.test.ts index 0828fee43..46272e4a5 100644 --- a/tests/parsers/rust.test.ts +++ b/tests/parsers/rust.test.ts @@ -104,4 +104,78 @@ impl Display for Foo {}`); expect(macros.length).toBeGreaterThanOrEqual(1); expect(macros).toContainEqual(expect.objectContaining({ name: 'println!' })); }); + + // ── #1876: receiver-typed locals + self.field type map ──────────────────── + + it('seeds struct field type map for self.field resolution', () => { + const symbols = parseRust(`struct UserService { repo: UserRepository }`); + expect(symbols.typeMap?.get('UserService.repo')).toEqual( + expect.objectContaining({ type: 'UserRepository' }), + ); + }); + + it('types a unit-struct value assignment (let v = TypeName;)', () => { + const symbols = parseRust(`struct NameValidator;\nfn f() { let v = NameValidator; }`); + expect(symbols.typeMap?.get('v')).toEqual(expect.objectContaining({ type: 'NameValidator' })); + }); + + it('does not type a unit enum variant as a unit struct (Greptile review)', () => { + // `None` (Option::None) parses identically to a unit-struct reference — a bare + // capitalized identifier — but is an enum variant, not a struct. Without a + // same-file `struct` definition for the name, it must not be typed. + const symbols = parseRust(`fn f() { let x = None; }`); + expect(symbols.typeMap?.has('x')).toBe(false); + }); + + it('does not type a lowercase bare identifier assignment', () => { + const symbols = parseRust(`fn f() { let a = 1; let b = a; }`); + expect(symbols.typeMap?.has('b')).toBe(false); + }); + + it('stores the declared return type for a free function', () => { + const symbols = parseRust(`fn build_service() -> UserService { todo!() }`); + expect(symbols.returnTypeMap?.get('build_service')).toEqual( + expect.objectContaining({ type: 'UserService', confidence: 1.0 }), + ); + }); + + it('resolves -> Self to the enclosing impl type', () => { + const symbols = parseRust( + `struct UserRepository;\nimpl UserRepository {\n fn new() -> Self { UserRepository }\n}`, + ); + expect(symbols.returnTypeMap?.get('UserRepository.new')).toEqual( + expect.objectContaining({ type: 'UserRepository' }), + ); + }); + + it('records a call assignment for a bare function call', () => { + const symbols = parseRust(`fn f() { let service = build_service(); }`); + expect(symbols.callAssignments).toContainEqual( + expect.objectContaining({ varName: 'service', calleeName: 'build_service' }), + ); + }); + + it('records a call assignment for an associated-function call', () => { + const symbols = parseRust(`fn f() { let repo = UserRepository::new(); }`); + expect(symbols.callAssignments).toContainEqual( + expect.objectContaining({ + varName: 'repo', + calleeName: 'new', + receiverTypeName: 'UserRepository', + }), + ); + }); + + it('records a call assignment for a method call on a locally-typed receiver', () => { + const symbols = parseRust( + `fn f() {\n let repo: UserRepository = make();\n let user = repo.find_by_id(1);\n}`, + ); + expect(symbols.callAssignments).toContainEqual( + expect.objectContaining({ + varName: 'user', + calleeName: 'find_by_id', + receiverTypeName: 'UserRepository', + }), + ); + }); }); diff --git a/tests/unit/call-resolver.test.ts b/tests/unit/call-resolver.test.ts index 49eae3b23..6e298a5bc 100644 --- a/tests/unit/call-resolver.test.ts +++ b/tests/unit/call-resolver.test.ts @@ -538,3 +538,52 @@ describe('resolveDefinePropertyAccessorTarget — kind filter parity (#1766)', ( expect(result).toEqual([qualifiedMethod]); }); }); + +describe('resolveByMethodOrGlobal — self.field receiver parity with this.field (#1876)', () => { + const method = { id: 9, file: 'service.rs', kind: 'method' }; + + it('resolves self.repo.find_by_id() via the class-scoped struct-field type map', () => { + // Mirrors the Rust extractor's `${StructName}.${fieldName}` typeMap seeding + // for `struct UserService { repo: UserRepository }`. + const lookup = makeLookup({ 'UserRepository.find_by_id': [method] }); + const typeMap = new Map([['UserService.repo', { type: 'UserRepository', confidence: 0.9 }]]); + const result = resolveByMethodOrGlobal( + lookup, + { name: 'find_by_id', receiver: 'self.repo' }, + 'service.rs', + typeMap, + 'UserService.get_user', + ); + expect(result).toEqual([method]); + }); + + it('does not resolve self.repo.find_by_id() when only an unrelated class owns the field name', () => { + // The class-scoped key must be consulted — an unscoped 'repo' key belonging + // to a different struct must not leak across classes (mirrors #1323/#1458 + // for `this.field`). + const lookup = makeLookup({ 'UserRepository.find_by_id': [method] }); + const typeMap = new Map([['OtherService.repo', { type: 'UserRepository', confidence: 0.9 }]]); + const result = resolveByMethodOrGlobal( + lookup, + { name: 'find_by_id', receiver: 'self.repo' }, + 'service.rs', + typeMap, + 'UserService.get_user', + ); + expect(result).toEqual([]); + }); + + it('still resolves this.field.method() unaffected by the self. addition', () => { + const tsMethod = { id: 10, file: 'service.ts', kind: 'method' }; + const lookup = makeLookup({ 'Repository.findById': [tsMethod] }); + const typeMap = new Map([['Service.repo', { type: 'Repository', confidence: 0.9 }]]); + const result = resolveByMethodOrGlobal( + lookup, + { name: 'findById', receiver: 'this.repo' }, + 'service.ts', + typeMap, + 'Service.getUser', + ); + expect(result).toEqual([tsMethod]); + }); +});