From d24b0bb2193d4abe4a972cf0cf05dfb19de00bf7 Mon Sep 17 00:00:00 2001 From: Juraj Oceliak Date: Mon, 5 Oct 2026 15:28:28 +0200 Subject: [PATCH 1/4] feat(resolve): root an inferred receiver's type name in the file it was written in (slice 1: field hops) An inferred receiver (`holder.model.text`) carried only the type's bare name; the member lookup then re-resolved that name against the CALLER's imports, so a type the caller never imports (and any same-named decoy elsewhere) broke it. ResolvedType now carries `declaring_uri` (the per-hop Url the inference layer already tracked and CstQuery::expr_type dropped; the deferred note in the qualified-resolution design spec). SymbolRole::Reference holds Option and resolve_identity uses Indexer::find_member_of_type, which resolves the type root in `declaring_uri` while extension scope stays the caller's (resolve_qualified_in_type_scope). A `jar:` origin keeps the caller's scope. Call hops still return the caller's file as origin (slice 2). Co-Authored-By: Claude Sonnet 5.5 --- src/features/implementation.rs | 3 +- src/features/references.rs | 4 +- src/features/references_verify.rs | 2 +- src/features/unresolved_symbol_diagnostics.rs | 8 +- src/indexer.rs | 2 +- src/indexer/infer/cst_symbol.rs | 23 ++- src/indexer/infer/cst_symbol_tests.rs | 137 +++++++++++++++++- src/indexer/infer/expr_type.rs | 12 ++ src/indexer/infer/mod.rs | 41 +++++- src/indexer/lookup.rs | 70 ++++++++- src/resolver/mod.rs | 1 + src/resolver/qualified.rs | 29 +++- src/resolver/resolve.rs | 27 +++- src/resolver/tests.rs | 4 + 14 files changed, 329 insertions(+), 34 deletions(-) diff --git a/src/features/implementation.rs b/src/features/implementation.rs index 519ec6b0..ae12ef33 100644 --- a/src/features/implementation.rs +++ b/src/features/implementation.rs @@ -49,7 +49,8 @@ pub(crate) async fn find_implementation_at( } = &sym.role { if let Some(response) = - find_method_implementations(&sym.name, receiver_type, indexer, uri).await + find_method_implementations(&sym.name, &receiver_type.type_path(), indexer, uri) + .await { return Some(response); } diff --git a/src/features/references.rs b/src/features/references.rs index a5755d62..657ac3de 100644 --- a/src/features/references.rs +++ b/src/features/references.rs @@ -194,7 +194,7 @@ pub(crate) async fn verified_references_for( SymbolRole::Reference { receiver_type: Some(receiver_type), .. - } => (Some(receiver_type.clone()), None), + } => (Some(receiver_type.type_path()), None), _ => (None, None), }, None => (None, None), @@ -227,7 +227,7 @@ pub(crate) async fn verified_references_for( .. } => crate::resolver::resolve_implicit_receiver_callee( index, - receiver_type, + &receiver_type.type_path(), &symbol.name, uri, *shape, diff --git a/src/features/references_verify.rs b/src/features/references_verify.rs index 4da2adcb..1ae40909 100644 --- a/src/features/references_verify.rs +++ b/src/features/references_verify.rs @@ -103,7 +103,7 @@ pub(crate) fn verify_candidates( continue; } } - let candidate_type = ReceiverType::from_raw(receiver_type.clone()).leaf; + let candidate_type = ReceiverType::from_raw(receiver_type.type_path()).leaf; // Only charge the agreement-walk unit when a walk will // actually run: `Exact` (same type, string equality) and // `Unresolvable` (candidate type not indexed) both return diff --git a/src/features/unresolved_symbol_diagnostics.rs b/src/features/unresolved_symbol_diagnostics.rs index 73e4a865..8ea7218d 100644 --- a/src/features/unresolved_symbol_diagnostics.rs +++ b/src/features/unresolved_symbol_diagnostics.rs @@ -16,8 +16,8 @@ use tree_sitter::Node; use crate::indexer::live_tree::LiveDoc; use crate::indexer::{ - classify_cursor, resolve_identity_with_io, Indexer, NavigationSource, SymbolAtCursor, - SymbolRole, + classify_cursor, resolve_identity_with_io, Indexer, NavigationSource, ResolvedType, + SymbolAtCursor, SymbolRole, }; use crate::queries::{KIND_IMPORT_HEADER, KIND_PACKAGE_HEADER, KIND_SIMPLE_IDENT, KIND_TYPE_IDENT}; @@ -119,7 +119,9 @@ pub(crate) fn collect_resolution_outcomes( continue; }; let receiver_type = match &symbol.role { - SymbolRole::Reference { receiver_type, .. } => receiver_type.clone(), + SymbolRole::Reference { receiver_type, .. } => { + receiver_type.as_ref().map(ResolvedType::type_path) + } SymbolRole::Declaration { .. } | SymbolRole::ImportSegment => continue, }; let name = symbol.name.clone(); diff --git a/src/indexer.rs b/src/indexer.rs index f523b59f..2c557112 100644 --- a/src/indexer.rs +++ b/src/indexer.rs @@ -60,7 +60,7 @@ pub(crate) use self::infer::{ }, type_subst::{build_type_arg_subst, find_last_dot_at_depth_zero}, }; -pub(crate) use self::infer::{CstQuery, Resolution}; +pub(crate) use self::infer::{CstQuery, Resolution, ResolvedType}; mod cache; pub(crate) use self::cache::workspace_cache_path; diff --git a/src/indexer/infer/cst_symbol.rs b/src/indexer/infer/cst_symbol.rs index c734d96a..0365b440 100644 --- a/src/indexer/infer/cst_symbol.rs +++ b/src/indexer/infer/cst_symbol.rs @@ -9,7 +9,9 @@ use tree_sitter::Node; -use crate::indexer::{CallShape, CstQuery, Indexer, NodeExt, Resolution, ShapeFiltered}; +use crate::indexer::{ + CallShape, CstQuery, Indexer, NodeExt, Resolution, ResolvedType, ShapeFiltered, +}; use crate::queries::{ KIND_BINDING_PATTERN_KIND, KIND_CALL_EXPR, KIND_CATCH_BLOCK, KIND_CLASS_DECL, KIND_CLASS_PARAM, KIND_COMPANION_OBJ, KIND_CONTROL_STRUCTURE_BODY, KIND_ENUM_ENTRY, KIND_FINALLY_BLOCK, @@ -20,7 +22,6 @@ use crate::queries::{ }; use crate::resolver::api::Definitions; use crate::semantic_tokens::is_named_argument_label; -use crate::str_ext::StrExt as _; use crate::types::CursorPos; use tower_lsp::lsp_types::{Location, Position, Url}; @@ -204,7 +205,7 @@ pub(crate) enum SymbolRole { /// wrong-arity candidate on the same receiver type (an explicit-receiver /// counterpart to `resolve_callee_definition`'s bare-call arity filter). Reference { - receiver_type: Option, + receiver_type: Option, is_call: bool, shape: Option, }, @@ -309,10 +310,12 @@ pub(crate) fn classify_symbol_at( return None; }; // The inferred type is as-written (`Box`, `User?`); - // the member lookup and the known-type gate both want the - // type's own name, not its type arguments or nullability. - let type_path = resolved.as_type_str().dotted_ident_prefix(); - names_a_known_type(indexer, &type_path, uri).then_some(type_path) + // the known-type gate wants the type's own name, not its type + // arguments or nullability; a qualified name is validated where + // it was written. + let type_path = resolved.type_path(); + names_a_known_type(indexer, &type_path, resolved.resolution_scope(uri)) + .then_some(resolved) }); return Some(SymbolAtCursor { name, @@ -405,7 +408,11 @@ pub(crate) fn resolve_identity_with_io( shape, .. } => { - let locations = find_definition_qualified(&symbol.name, Some(receiver_type), uri); + let locations = if index_only { + indexer.find_member_of_type_index_only(&symbol.name, receiver_type, uri) + } else { + indexer.find_member_of_type(&symbol.name, receiver_type, uri) + }; // A call's own shape rules out a same-named, wrong-arity member/ // extension on the same receiver type — e.g. `triggers.collect { // trigger -> }` (1 arg via trailing lambda) must not resolve to a diff --git a/src/indexer/infer/cst_symbol_tests.rs b/src/indexer/infer/cst_symbol_tests.rs index a3e5b3a9..325ce2f2 100644 --- a/src/indexer/infer/cst_symbol_tests.rs +++ b/src/indexer/infer/cst_symbol_tests.rs @@ -59,7 +59,7 @@ fn classifies_a_typed_member_reference() { receiver_type: Some(t), is_call: true, .. - } => assert_eq!(t, "User"), + } => assert_eq!(t.type_path(), "User"), other => panic!("expected typed call reference, got {other:?}"), } } @@ -681,7 +681,9 @@ fn receiver_type_at(src: &str, line: u32, member: &str) -> Option { ) .unwrap(); match symbol.role { - SymbolRole::Reference { receiver_type, .. } => receiver_type, + SymbolRole::Reference { receiver_type, .. } => { + receiver_type.as_ref().map(ResolvedType::type_path) + } other => panic!("expected a reference, got {other:?}"), } } @@ -748,3 +750,134 @@ fn qualified_annotation_naming_a_real_nested_type_keeps_its_receiver_type() { Some("Outer.Inner") ); } + +/// Index each `(path, source)` under one `Indexer`; returns the caller's Url +/// with a live tree stored for it. +fn indexed_workspace(files: &[(&str, &str)], caller_path: &str) -> (Url, Indexer) { + let indexer = Indexer::new(); + for (path, source) in files { + indexer.index_content(&uri(path), source); + } + let caller_uri = uri(caller_path); + let caller_source = files + .iter() + .find(|(path, _)| *path == caller_path) + .map(|(_, source)| *source) + .unwrap(); + indexer.store_live_tree(&caller_uri, caller_source); + (caller_uri, indexer) +} + +/// Resolve the one `member` on `line` of the caller to its definition files. +fn definition_paths_at( + files: &[(&str, &str)], + caller_path: &str, + line: u32, + member: &str, +) -> Vec { + let (caller_uri, indexer) = indexed_workspace(files, caller_path); + let caller_source = files + .iter() + .find(|(path, _)| *path == caller_path) + .map(|(_, source)| *source) + .unwrap(); + let column = caller_source + .lines() + .nth(line as usize) + .unwrap() + .find(member) + .unwrap(); + let symbol = classify_symbol_at( + &indexer, + &caller_uri, + CursorPos { + line: line as usize, + utf16_col: column, + }, + ) + .unwrap(); + match resolve_identity(&symbol, &indexer, &caller_uri) { + NavigationSource::CstResolved(definitions) => definitions + .0 + .iter() + .map(|location| location.uri.path().to_owned()) + .collect(), + NavigationSource::NameScan(_) => vec![], + } +} + +/// Slice 1 of the receiver-origin work: the receiver's type name (`ScreenFlowModel`) +/// was written in `Holder.kt`, which imports it. The CALLER never imports it, and a +/// same-named decoy with the same member lives in a third package — the member +/// lookup must be rooted in the file the type was written in, not the caller's. +#[test] +fn field_hop_member_resolves_in_the_file_the_receiver_type_was_written_in() { + let files = [ + ( + "/types/Types.kt", + "package types\nclass ScreenFlowModel(val text: T)\n", + ), + ( + "/impl/Holder.kt", + "package impl\nimport types.*\nclass Holder { val model: ScreenFlowModel = TODO() }\n", + ), + ( + "/app/Use.kt", + "package app\nimport impl.Holder\nfun f(holder: Holder) { holder.model.text }\n", + ), + ( + "/r/Decoy.kt", + "package r\nclass Nav { class ScreenFlowModel(val text: String) }\n", + ), + ]; + + let definitions = definition_paths_at(&files, "/app/Use.kt", 2, "text"); + + assert_eq!( + definitions, + vec!["/t/types/Types.kt".to_owned()], + "`text` must resolve to the real ScreenFlowModel, not the same-named decoy" + ); +} + +/// The origin the member lookup above relies on: a field hop's receiver type +/// is attributed to the file that declares the field, not to the caller. +#[test] +fn field_hop_receiver_type_carries_the_declaring_file_as_its_origin() { + let files = [ + ( + "/types/Types.kt", + "package types\nclass ScreenFlowModel(val text: T)\n", + ), + ( + "/impl/Holder.kt", + "package impl\nimport types.*\nclass Holder { val model: ScreenFlowModel = TODO() }\n", + ), + ( + "/app/Use.kt", + "package app\nimport impl.Holder\nfun f(holder: Holder) { holder.model.text }\n", + ), + ]; + let (caller_uri, indexer) = indexed_workspace(&files, "/app/Use.kt"); + let column = files[2].1.lines().nth(2).unwrap().find("text").unwrap(); + let symbol = classify_symbol_at( + &indexer, + &caller_uri, + CursorPos { + line: 2, + utf16_col: column, + }, + ) + .unwrap(); + + let SymbolRole::Reference { + receiver_type: Some(receiver), + .. + } = symbol.role + else { + panic!("expected a typed member reference, got {symbol:?}"); + }; + + assert_eq!(receiver.type_path(), "ScreenFlowModel"); + assert_eq!(receiver.declaring_uri(), &uri("/impl/Holder.kt")); +} diff --git a/src/indexer/infer/expr_type.rs b/src/indexer/infer/expr_type.rs index 3683b77c..5f0ce4e3 100644 --- a/src/indexer/infer/expr_type.rs +++ b/src/indexer/infer/expr_type.rs @@ -62,6 +62,18 @@ pub(crate) fn infer_expr_type( infer_expr_type_at_depth(node, bytes, deps, uri, 0).map(|(type_name, _)| type_name) } +/// [`infer_expr_type`] together with the file the answer was written in — the +/// anchor a lookup of the type's NAME must be resolved against. `uri` itself +/// when the type has no better origin (a literal, an unannotated local). +pub(crate) fn infer_expr_type_with_origin( + node: Node<'_>, + bytes: &[u8], + deps: &impl InferDeps, + uri: &Url, +) -> Option<(String, Url)> { + infer_expr_type_at_depth(node, bytes, deps, uri, 0) +} + /// Depth-guarded implementation of [`infer_expr_type`]. /// /// `infer_expr_type` and its helpers (`infer_navigation_expr_type`, diff --git a/src/indexer/infer/mod.rs b/src/indexer/infer/mod.rs index 91569f19..980d7809 100644 --- a/src/indexer/infer/mod.rs +++ b/src/indexer/infer/mod.rs @@ -105,20 +105,24 @@ impl Resolution { } /// A resolved expression type. Carries the inferred type *as-written* -/// (no lossy normalization); the RawTypeName/TypeName split is slice 5. +/// (no lossy normalization; the RawTypeName/TypeName split is slice 5) and the +/// file it was written in. +#[derive(Debug, Clone)] pub(crate) struct ResolvedType { type_name: String, nullable: bool, + declaring_uri: Url, } impl ResolvedType { - /// Construct from an inferred type string. + /// Construct from an inferred type string and the file that spelled it. /// Nullability is derived via `StrExt::is_nullable` (the canonical place). - pub(crate) fn from_inferred(raw: String) -> Self { + pub(crate) fn from_inferred(raw: String, declaring_uri: Url) -> Self { let nullable = raw.is_nullable(); ResolvedType { type_name: raw, nullable, + declaring_uri, } } @@ -127,6 +131,31 @@ impl ResolvedType { &self.type_name } + /// The type's own, possibly nested/qualified, name: no type arguments, no + /// `?`. What a member lookup and a known-type gate are keyed by. + pub(crate) fn type_path(&self) -> String { + self.type_name.dotted_ident_prefix() + } + + /// The file whose imports and package resolve [`Self::type_path`] — where + /// the type name was written, which is NOT the call site for an inferred + /// receiver (`loadScreenData(..)`'s return type is spelled in the callee's + /// file, where it is imported). + pub(crate) fn declaring_uri(&self) -> &Url { + &self.declaring_uri + } + + /// The file to resolve [`Self::type_path`] in: where the name was written, + /// or `caller` when that is a `jar:` file — a jar has no import list to + /// resolve a name against, so the caller's scope stays (pre-existing behaviour). + pub(crate) fn resolution_scope<'a>(&'a self, caller: &'a Url) -> &'a Url { + if self.declaring_uri().scheme() == "file" { + self.declaring_uri() + } else { + caller + } + } + #[allow(dead_code)] // read only by tests pinning `from_inferred`'s nullable computation pub(crate) fn is_nullable(&self) -> bool { self.nullable @@ -181,13 +210,15 @@ impl<'a, D: InferDeps> CstQuery<'a, D> { /// boolean operators, `if` expressions, and `this`. Returns /// `Resolution::Unresolved` for compound forms not yet handled. pub(crate) fn expr_type(&self) -> Resolution { - match crate::indexer::infer::expr_type::infer_expr_type( + match crate::indexer::infer::expr_type::infer_expr_type_with_origin( self.node, &self.doc.bytes, self.deps, self.uri, ) { - Some(raw) => Resolution::Resolved(ResolvedType::from_inferred(raw)), + Some((raw, declaring_uri)) => { + Resolution::Resolved(ResolvedType::from_inferred(raw, declaring_uri)) + } None => Resolution::Unresolved, } } diff --git a/src/indexer/lookup.rs b/src/indexer/lookup.rs index a0cb0cb1..12482703 100644 --- a/src/indexer/lookup.rs +++ b/src/indexer/lookup.rs @@ -14,7 +14,8 @@ use tower_lsp::lsp_types::*; use super::infer::lambda::SCOPE_FUNCTIONS; -use super::Indexer; +use super::{Indexer, ResolvedType}; +use crate::resolver::{resolve_symbol_in_type_scope, ResolveIo}; use crate::types::SymbolEntry; use crate::StrExt; @@ -69,6 +70,46 @@ impl Indexer { self.find_definition_qualified_with_io(name, qualifier, from_uri, true) } + /// `name` as a member of `receiver`'s type. The type's NAME is resolved in + /// the file it was written in ([`ResolvedType::declaring_uri`]), not the + /// caller's: an inferred receiver (`holder.model.text`) is spelled in the + /// callee's file, which imports it — the caller usually does not. + pub(crate) fn find_member_of_type( + &self, + name: &str, + receiver: &ResolvedType, + from_uri: &Url, + ) -> Vec { + self.find_member_of_type_with_io(name, receiver, from_uri, false) + } + + /// Like `find_member_of_type` but never spawns `rg`/`fd`. + pub(crate) fn find_member_of_type_index_only( + &self, + name: &str, + receiver: &ResolvedType, + from_uri: &Url, + ) -> Vec { + self.find_member_of_type_with_io(name, receiver, from_uri, true) + } + + fn find_member_of_type_with_io( + &self, + name: &str, + receiver: &ResolvedType, + from_uri: &Url, + index_only: bool, + ) -> Vec { + let type_scope = receiver.resolution_scope(from_uri); + self.find_qualified_in_type_scope( + name, + &receiver.type_path(), + type_scope, + from_uri, + index_only, + ) + } + fn find_definition_qualified_with_io( &self, name: &str, @@ -76,15 +117,34 @@ impl Indexer { from_uri: &Url, index_only: bool, ) -> Vec { - let locations = if index_only { - self.resolve_symbol_index_only(name, qualifier, from_uri) + match qualifier { + Some(qualifier) => { + self.find_qualified_in_type_scope(name, qualifier, from_uri, from_uri, index_only) + } + None if index_only => self.resolve_symbol_index_only(name, None, from_uri), + None => self.resolve_symbol(name, None, from_uri), + } + } + + fn find_qualified_in_type_scope( + &self, + name: &str, + qualifier: &str, + type_scope: &Url, + from_uri: &Url, + index_only: bool, + ) -> Vec { + let io = if index_only { + ResolveIo::IndexOnly } else { - self.resolve_symbol(name, qualifier, from_uri) + ResolveIo::Full }; + let locations = + resolve_symbol_in_type_scope(self, name, qualifier, type_scope, from_uri, io); if !locations.is_empty() { return locations; } - if qualifier.is_some() && SCOPE_FUNCTIONS.contains(&name) { + if SCOPE_FUNCTIONS.contains(&name) { return if index_only { self.resolve_symbol_index_only(name, None, from_uri) } else { diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index f263a30a..d22501ee 100644 --- a/src/resolver/mod.rs +++ b/src/resolver/mod.rs @@ -74,5 +74,6 @@ pub(crate) use infer_lines::{ pub(crate) use platform_types::resolve_kotlin_builtin_type_platform_equivalent; #[cfg(test)] pub(crate) use resolve::resolve_symbol; +pub(crate) use resolve::resolve_symbol_in_type_scope; #[cfg(test)] pub(crate) use resolve::resolve_symbol_index_only; diff --git a/src/resolver/qualified.rs b/src/resolver/qualified.rs index 292641b9..490198b1 100644 --- a/src/resolver/qualified.rs +++ b/src/resolver/qualified.rs @@ -71,6 +71,24 @@ pub(super) fn resolve_qualified( qualifier: &str, from_uri: &Url, io: ResolveIo, +) -> Vec { + resolve_qualified_in_type_scope(indexer, name, qualifier, from_uri, from_uri, io) +} + +/// [`resolve_qualified`] for a qualifier whose TYPE NAME was written in a file +/// other than the caller's (an inferred receiver: `holder.model.text`, where +/// `ScreenFlowModel` is spelled in `Holder.kt`'s imports, not the caller's). +/// +/// `type_scope` is the file whose imports/package resolve the qualifier's type +/// root; `from_uri` stays the CALLER, because which extensions are in scope is +/// a property of the call site, not of where the receiver's type was written. +pub(super) fn resolve_qualified_in_type_scope( + indexer: &Indexer, + name: &str, + qualifier: &str, + type_scope: &Url, + from_uri: &Url, + io: ResolveIo, ) -> Vec { let parsed = parse_qualifier(qualifier); @@ -100,7 +118,7 @@ pub(super) fn resolve_qualified( // companion-member access rather than protecting anything. let companion_applies = matches!(&parsed, QualifierRoot::TypePath { .. }); - for anchor in anchors_for(indexer, &parsed, from_uri, io) { + for anchor in anchors_for(indexer, &parsed, type_scope, from_uri, io) { if companion_applies { let companion_locations = companion_member_on(indexer, &anchor, name); if !companion_locations.is_empty() { @@ -183,6 +201,7 @@ pub(super) struct ReceiverAnchor { pub(super) fn anchors_for( indexer: &Indexer, root: &QualifierRoot<'_>, + type_scope: &Url, from_uri: &Url, io: ResolveIo, ) -> Vec { @@ -190,7 +209,7 @@ pub(super) fn anchors_for( // Dispatched by `resolve_qualified` before it ever gets here. QualifierRoot::This | QualifierRoot::Super => vec![], QualifierRoot::TypePath { root, nested } => { - type_path_anchors(indexer, root, nested, from_uri, io) + type_path_anchors(indexer, root, nested, type_scope, io) } QualifierRoot::ValuePath { root, rest } => value_path_anchor(indexer, root, rest, from_uri) .into_iter() @@ -205,7 +224,7 @@ fn type_path_anchors( indexer: &Indexer, root: &str, nested: &[&str], - from_uri: &Url, + type_scope: &Url, io: ResolveIo, ) -> Vec { // Honors the caller's IO policy — an IndexOnly caller (the @@ -213,9 +232,9 @@ fn type_path_anchors( // rg/fd resolving the qualifier root any more than it may for a bare // reference. let root_locations = if matches!(io, ResolveIo::IndexOnly) { - resolve_symbol_index_only(indexer, root, None, from_uri) + resolve_symbol_index_only(indexer, root, None, type_scope) } else { - resolve_symbol(indexer, root, None, from_uri) + resolve_symbol(indexer, root, None, type_scope) }; let anchors: Vec = root_locations diff --git a/src/resolver/resolve.rs b/src/resolver/resolve.rs index 94b9f331..73e35352 100644 --- a/src/resolver/resolve.rs +++ b/src/resolver/resolve.rs @@ -36,7 +36,9 @@ use super::package_scope::{ find_in_star_imports, resolve_same_package, resolve_star_imports, star_import_packages, }; use super::platform_types::resolve_kotlin_builtin_type_platform_equivalent; -use super::qualified::{resolve_from_class_hierarchy, resolve_qualified}; +use super::qualified::{ + resolve_from_class_hierarchy, resolve_qualified, resolve_qualified_in_type_scope, +}; use super::tie_break::ambiguity_safe_tail_with_denylist; /// Return `FileData` for `uri` — from the live index if indexed, otherwise parse from disk. @@ -144,6 +146,29 @@ pub(crate) fn resolve_symbol_index_only( resolve_symbol_with_io(indexer, name, qualifier, from_uri, ResolveIo::IndexOnly) } +/// [`resolve_symbol`] for `name` accessed through `qualifier`, where the +/// qualifier's TYPE NAME was written in `type_scope` rather than in the caller +/// (`from_uri`) — see [`super::qualified::resolve_qualified_in_type_scope`]. +/// Same fall-through contract as the qualified branch of `resolve_symbol_with_io`. +pub(crate) fn resolve_symbol_in_type_scope( + indexer: &Indexer, + name: &str, + qualifier: &str, + type_scope: &Url, + from_uri: &Url, + io: ResolveIo, +) -> Vec { + let in_scope = + resolve_qualified_in_type_scope(indexer, name, qualifier, type_scope, from_uri, io); + if !in_scope.is_empty() { + return in_scope; + } + if qualifier.starts_with_uppercase() { + return vec![]; + } + resolve_symbol_with_io(indexer, name, Some(qualifier), from_uri, io) +} + fn resolve_symbol_with_io( indexer: &Indexer, name: &str, diff --git a/src/resolver/tests.rs b/src/resolver/tests.rs index 72d2e481..192cb894 100644 --- a/src/resolver/tests.rs +++ b/src/resolver/tests.rs @@ -10492,6 +10492,7 @@ fn anchors_for_a_nested_type_path_anchors_on_the_leaf_not_the_root() { &idx, &parse_qualifier("Outer.Inner"), &caller_uri, + &caller_uri, ResolveIo::Full, ); assert_eq!( @@ -10553,6 +10554,7 @@ fn anchors_for_a_missing_nested_segment_yields_no_anchor_not_a_root_fallback() { &idx, &parse_qualifier("Outer.Missing"), &caller_uri, + &caller_uri, ResolveIo::Full, ); assert!( @@ -10583,6 +10585,7 @@ fn anchors_for_a_value_path_anchors_on_the_inferred_receiver_type() { &idx, &parse_qualifier("account"), &caller_uri, + &caller_uri, ResolveIo::Full, ); assert_eq!( @@ -10622,6 +10625,7 @@ fn anchors_for_an_unindexed_type_root_still_yields_a_declaration_less_anchor() { &idx, &parse_qualifier("Unindexed"), &caller_uri, + &caller_uri, ResolveIo::Full, ); assert_eq!( From ab9de47718f43942f785e1809dad3f4797782d14 Mon Sep 17 00:00:00 2001 From: Juraj Oceliak Date: Mon, 5 Oct 2026 15:35:18 +0200 Subject: [PATCH 2/4] feat(resolve): carry a type name's origin through call hops (slice 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A call's return type is spelled in the callee's file (loadScreenData's `ScreenFlowModel` is imported there, not by the caller), but call hops reported the caller's file as the origin, so the slice-1 member lookup still re-resolved the name against the caller. - resolver: DeclaredReturn / find_method_return_type_declared / find_method_return_type_via_supertypes_declared keep the declaring file next to the return type; Resolver::method_return_type_declared is the catalogue composite (the old method_return_type is now a test-only convenience). A supertype substitution is attributed to the subclass file that spelled `Base`. - InferDeps::find_method_return_type_with_origin_for_type (provided method, Indexer overrides; JAR/extension-registry answers report no file and keep the caller as origin). - chain.rs: StrategyOutcome carries the origin; finalize applies the head rule — when the declared head is a type parameter the receiver's type argument replaced, the name was spelled with the receiver, not the member. Scope functions pass the receiver's origin through. - resolve_field_type_on / resolve_method_return_type_on apply the same head rule per hop. Co-Authored-By: Claude Sonnet 5.5 --- src/indexer.rs | 40 ++++--- src/indexer/infer/chain.rs | 148 ++++++++++++++++++++------ src/indexer/infer/cst_symbol_tests.rs | 53 +++++++++ src/indexer/infer/deps.rs | 14 +++ src/indexer/infer/expr_type.rs | 8 +- src/resolver/api.rs | 32 ++++-- src/resolver/infer.rs | 92 ++++++++++++---- src/resolver/mod.rs | 4 +- 8 files changed, 309 insertions(+), 82 deletions(-) diff --git a/src/indexer.rs b/src/indexer.rs index 2c557112..96d12962 100644 --- a/src/indexer.rs +++ b/src/indexer.rs @@ -485,26 +485,38 @@ impl InferDeps for Indexer { } Vec::new() } - fn find_method_return_type_for_type( + fn find_method_return_type_with_origin_for_type( &self, class_name: &str, method_name: &str, uri: &Url, - ) -> Option { + ) -> Option<(String, Url)> { if let Some(type_name) = synthetic_enum_method(self, class_name, method_name) { - return Some(type_name); + return Some((type_name, uri.clone())); } - // The catalog composite covers member fns, extension fns, and supertype - // inheritance in one call (see `Resolver::method_return_type`); the - // previous explicit `find_extension_fn_return_type` step here was dead — - // `find_method_return_type` already probes extensions first. - // The calling file's uri is load-bearing for jar receivers: it drives - // import-reachability AND on-demand Tier-2 materialization of jar - // extension sets (a `None` here left `Modifier.fillMaxSize()`-style - // receivers unresolvable until some other feature happened to promote - // the same symbols — live-probe scenario C). - crate::resolver::Resolver::method_return_type(self, class_name, method_name, Some(uri)) - .map(crate::resolver::ReturnType::into_inner) + // Member fns, extension fns, and supertype inheritance in one pass — + // the catalogue composite. The calling file's uri is load-bearing for + // jar receivers: it drives import-reachability AND on-demand Tier-2 + // materialization of jar extension sets (a `None` here left + // `Modifier.fillMaxSize()`-style receivers unresolvable until some + // other feature happened to promote the same symbols). + let declared = crate::resolver::Resolver::method_return_type_declared( + self, + class_name, + method_name, + Some(uri), + )?; + let declared_in = declared.declared_in.unwrap_or_else(|| uri.clone()); + Some((declared.type_name, declared_in)) + } + fn find_method_return_type_for_type( + &self, + class_name: &str, + method_name: &str, + uri: &Url, + ) -> Option { + self.find_method_return_type_with_origin_for_type(class_name, method_name, uri) + .map(|(type_name, _)| type_name) } fn find_method_params_text( &self, diff --git a/src/indexer/infer/chain.rs b/src/indexer/infer/chain.rs index 0016749d..8a7b9ea3 100644 --- a/src/indexer/infer/chain.rs +++ b/src/indexer/infer/chain.rs @@ -471,6 +471,16 @@ fn receiver_class_name(current_type: &str) -> Option { } } +/// Whose file spelled the head of a member's type after receiver substitution. +enum SpelledIn { + /// The member's own declaring file (`Texts` in `val text: Texts` stays put + /// when it names a real class there). + MemberDeclaration, + /// The receiver's type arguments: the member's declared head was a type + /// parameter, so the name that replaced it was spelled with the receiver. + ReceiverTypeArguments, +} + /// A member's declared type with the receiver's own type arguments /// substituted in (`text: Texts` on `Box` → `ProductTexts`). /// A parameter still unbound after substitution falls back to the receiver's @@ -481,19 +491,26 @@ fn substitute_receiver_type_arguments( lookup_name: &str, current_type: &str, deps: &impl InferDeps, -) -> Option { +) -> Option<(String, SpelledIn)> { let class_path = lookup_name.dotted_ident_prefix(); - let subst = build_type_arg_subst(deps, class_path.last_segment(), current_type); - let applied = crate::indexer::apply_type_subst(declared_type, &subst); + let substitution = build_type_arg_subst(deps, class_path.last_segment(), current_type); + let head_path = declared_type.strip_nullable().dotted_ident_prefix(); + let applied = crate::indexer::apply_type_subst(declared_type, &substitution); if is_generic_param(applied.strip_nullable()) { - return first_type_arg_raw(current_type); + return first_type_arg_raw(current_type) + .map(|type_name| (type_name, SpelledIn::ReceiverTypeArguments)); } - Some(applied) + let spelled_in = if substitution.contains_key(&head_path) { + SpelledIn::ReceiverTypeArguments + } else { + SpelledIn::MemberDeclaration + }; + Some((applied, spelled_in)) } -/// A FIELD of `lookup_name`'s type, receiver type arguments substituted. The -/// `Url` is the field's declaring file — the reachability anchor for the next -/// hop. +/// A FIELD of `lookup_name`'s type, receiver type arguments substituted. `uri` +/// is the receiver's origin. The returned `Url` is the file that spelled the +/// answer's head — the reachability anchor for the next hop. pub(super) fn resolve_field_type_on( lookup_name: &str, current_type: &str, @@ -502,12 +519,17 @@ pub(super) fn resolve_field_type_on( uri: &Url, ) -> Option<(String, Url)> { let (field_type, declaring_uri) = deps.find_field_type(lookup_name, member, uri)?; - substitute_receiver_type_arguments(&field_type, lookup_name, current_type, deps) - .map(|substituted| (substituted, declaring_uri)) + let (substituted, spelled_in) = + substitute_receiver_type_arguments(&field_type, lookup_name, current_type, deps)?; + match spelled_in { + SpelledIn::MemberDeclaration => Some((substituted, declaring_uri)), + SpelledIn::ReceiverTypeArguments => Some((substituted, uri.clone())), + } } /// A METHOD of `lookup_name`'s type: its return type, receiver type arguments -/// substituted. +/// substituted. `uri` is the receiver's origin; the returned `Url` is the file +/// that spelled the answer's head. pub(super) fn resolve_method_return_type_on( lookup_name: &str, current_type: &str, @@ -515,9 +537,14 @@ pub(super) fn resolve_method_return_type_on( deps: &impl InferDeps, uri: &Url, ) -> Option<(String, Url)> { - let return_type = deps.find_method_return_type_for_type(lookup_name, member, uri)?; - substitute_receiver_type_arguments(&return_type, lookup_name, current_type, deps) - .map(|substituted| (substituted, uri.clone())) + let (return_type, declared_in) = + deps.find_method_return_type_with_origin_for_type(lookup_name, member, uri)?; + let (substituted, spelled_in) = + substitute_receiver_type_arguments(&return_type, lookup_name, current_type, deps)?; + match spelled_in { + SpelledIn::MemberDeclaration => Some((substituted, declared_in)), + SpelledIn::ReceiverTypeArguments => Some((substituted, uri.clone())), + } } /// Walk up from a node to find the enclosing class/object declaration name. @@ -620,26 +647,36 @@ struct CallCtx<'a, D: InferDeps> { enum StrategyOutcome { /// A signature's raw return type resolved against a concrete receiver — /// needs receiver-generic substitution, then call-site substitution. + /// `return_origin` is the file that declares the signature. ReceiverDerived { raw_return: String, effective_type: String, receiver_type: String, + return_origin: Url, }, /// A signature's raw return type resolved with no receiver in play — - /// needs call-site substitution only. - SignatureDerived(String), + /// needs call-site substitution only. `origin` is the file that declares + /// the signature. + SignatureDerived { raw_return: String, origin: Url }, /// Already the final, concrete answer (fixed stdlib type, a call-site /// type argument read directly, a class-literal argument, the bare - /// constructor name, or a scope-function's passthrough) — no - /// substitution applies. + /// constructor name) — no substitution applies, and the name was spelled + /// at the call site, so the caller's file is its origin. Final(String), + /// The receiver's own type flowing through unchanged (a scope function) — + /// its name was spelled wherever the receiver's was. + ReceiverPassthrough(String), } impl StrategyOutcome { - fn finalize(self, ctx: &CallCtx<'_, D>) -> String { + /// The call's type and the file its name was spelled in. + fn finalize(self, ctx: &CallCtx<'_, D>) -> (String, Url) { match self { - StrategyOutcome::Final(s) => s, - StrategyOutcome::SignatureDerived(raw_return) => { + StrategyOutcome::Final(type_name) => (type_name, ctx.uri.clone()), + StrategyOutcome::ReceiverPassthrough(type_name) => { + (type_name, callee_receiver_origin(ctx)) + } + StrategyOutcome::SignatureDerived { raw_return, origin } => { let has_explicit_receiver = ctx.callee.kind() == KIND_NAV_EXPR; let substitution = if has_explicit_receiver { callee_receiver_type(ctx) @@ -650,26 +687,58 @@ impl StrategyOutcome { } else { Default::default() }; + let origin = origin_after_substitution(&raw_return, &substitution, origin, ctx); let substituted = crate::indexer::apply_type_subst(&raw_return, &substitution); - apply_call_site_type_args(substituted, ctx) + (apply_call_site_type_args(substituted, ctx), origin) } StrategyOutcome::ReceiverDerived { raw_return, effective_type, receiver_type, + return_origin, } => { let mut substitution = build_type_arg_subst(ctx.deps, &effective_type, &receiver_type); for (param, concrete) in extension_receiver_type_param_subst(ctx, &receiver_type) { substitution.entry(param).or_insert(concrete); } + let origin = + origin_after_substitution(&raw_return, &substitution, return_origin, ctx); let substituted = crate::indexer::apply_type_subst(&raw_return, &substitution); - apply_call_site_type_args(substituted, ctx) + (apply_call_site_type_args(substituted, ctx), origin) } } } } +/// Which file spelled the head of a substituted return type: the declaring +/// file of the signature, unless the head (`T` in `T?`) was a type parameter +/// that `subst` bound — then the substituted name was spelled wherever the +/// receiver's type argument was, i.e. the receiver's origin. +fn origin_after_substitution( + raw_return: &str, + substitution: &std::collections::HashMap, + signature_origin: Url, + ctx: &CallCtx<'_, D>, +) -> Url { + let head_path = raw_return.strip_nullable().dotted_ident_prefix(); + if substitution.contains_key(&head_path) { + callee_receiver_origin(ctx) + } else { + signature_origin + } +} + +/// The file the call's explicit receiver expression's type was spelled in +/// (`ctx.uri` when there is no receiver or its origin is unknown). +fn callee_receiver_origin(ctx: &CallCtx<'_, D>) -> Url { + super::cst_symbol::navigation_receiver_node(ctx.callee) + .and_then(|receiver| { + super::expr_type::infer_expr_type_with_origin(receiver, ctx.bytes, ctx.deps, ctx.uri) + }) + .map_or_else(|| ctx.uri.clone(), |(_, origin)| origin) +} + /// Binds the called extension function's own type parameters from its /// concrete receiver: `build_type_arg_subst` only covers a type param NESTED /// inside the receiver's own type argument (`List`'s `T`). It misses the @@ -753,7 +822,7 @@ fn scope_function_identity(ctx: &CallCtx<'_, D>) -> StrategyVerdic return StrategyVerdict::NotApplicable; } let resolved_type = resolve_root_node_type(ctx.callee, ctx.bytes, ctx.deps, ctx.uri); - StrategyVerdict::Terminal(resolved_type.map(StrategyOutcome::Final)) + StrategyVerdict::Terminal(resolved_type.map(StrategyOutcome::ReceiverPassthrough)) } /// Lambda-result functions (e.g. Compose `remember { Foo() }`) return their @@ -788,16 +857,18 @@ fn receiver_based_method(ctx: &CallCtx<'_, D>) -> StrategyVerdict if effective_type.is_empty() { return StrategyVerdict::NotApplicable; } - let Some(raw_return) = - ctx.deps - .find_method_return_type_for_type(&effective_type, ctx.fn_name, ctx.uri) - else { + let Some((raw_return, return_origin)) = ctx.deps.find_method_return_type_with_origin_for_type( + &effective_type, + ctx.fn_name, + ctx.uri, + ) else { return StrategyVerdict::NotApplicable; }; StrategyVerdict::Terminal(Some(StrategyOutcome::ReceiverDerived { raw_return, effective_type, receiver_type, + return_origin, })) } @@ -809,7 +880,10 @@ fn reachable_return_type(ctx: &CallCtx<'_, D>) -> StrategyVerdict .deps .find_fun_return_type_reachable(ctx.fn_name, ctx.uri) { - Some(raw) => StrategyVerdict::Terminal(Some(StrategyOutcome::SignatureDerived(raw))), + Some(raw_return) => StrategyVerdict::Terminal(Some(StrategyOutcome::SignatureDerived { + raw_return, + origin: ctx.uri.clone(), + })), None => StrategyVerdict::NotApplicable, } } @@ -883,7 +957,10 @@ fn retrofit_class_literal(ctx: &CallCtx<'_, D>) -> StrategyVerdict /// this scan instead of before it). fn global_name_scan(ctx: &CallCtx<'_, D>) -> StrategyVerdict { match ctx.deps.find_fun_return_type(ctx.fn_name, ctx.uri) { - Some(raw) => StrategyVerdict::Terminal(Some(StrategyOutcome::SignatureDerived(raw))), + Some(raw_return) => StrategyVerdict::Terminal(Some(StrategyOutcome::SignatureDerived { + raw_return, + origin: ctx.uri.clone(), + })), None => StrategyVerdict::NotApplicable, } } @@ -953,6 +1030,17 @@ pub(super) fn resolve_call_expr_type( deps: &D, uri: &Url, ) -> Option { + resolve_call_expr_type_with_origin(node, bytes, deps, uri).map(|(type_name, _)| type_name) +} + +/// [`resolve_call_expr_type`] together with the file the answer's type name was +/// spelled in (see `ResolvedType::declaring_uri`). +pub(super) fn resolve_call_expr_type_with_origin( + node: tree_sitter::Node<'_>, + bytes: &[u8], + deps: &D, + uri: &Url, +) -> Option<(String, Url)> { let fn_name = node.call_fn_name(bytes)?; // Guaranteed `Some`: `call_fn_name` itself reads `node.child(0)` to // produce a name, so a resolved `fn_name` implies a callee exists. diff --git a/src/indexer/infer/cst_symbol_tests.rs b/src/indexer/infer/cst_symbol_tests.rs index 325ce2f2..e1d90153 100644 --- a/src/indexer/infer/cst_symbol_tests.rs +++ b/src/indexer/infer/cst_symbol_tests.rs @@ -881,3 +881,56 @@ fn field_hop_receiver_type_carries_the_declaring_file_as_its_origin() { assert_eq!(receiver.type_path(), "ScreenFlowModel"); assert_eq!(receiver.declaring_uri(), &uri("/impl/Holder.kt")); } + +/// The Moneta shape (`screenFlowInteractor.loadScreenData(..).text.scenes`): the +/// call's return type `ScreenFlowModel` is spelled in +/// `Impl.kt`, which imports both types; the caller imports neither. Decoys in +/// package `r` give BOTH names a same-named, member-bearing competitor. +fn call_hop_files() -> [(&'static str, &'static str); 5] { + [ + ( + "/types/Types.kt", + "package types\nclass ScreenFlowModel(val text: Texts)\n", + ), + ( + "/texts/Texts.kt", + "package texts\ninterface IProductScreenTexts { val scenes: String }\n", + ), + ( + "/impl/Impl.kt", + "package impl\nimport types.*\nimport texts.*\n\ + class ProcessInteractor {\n\ + suspend fun loadScreenData(refresh: Boolean): ScreenFlowModel = TODO()\n\ + }\n", + ), + ( + "/app/Use.kt", + "package app\nimport impl.ProcessInteractor\n\ + class Use(private val interactor: ProcessInteractor) {\n\ + suspend fun go() { interactor.loadScreenData(refresh = false).text.scenes }\n\ + }\n", + ), + ( + "/r/Decoy.kt", + "package r\nclass Nav { class ScreenFlowModel(val text: String) }\n\ + interface IProductScreenTexts { val scenes: Int }\n", + ), + ] +} + +#[test] +fn call_hop_member_resolves_in_the_file_the_return_type_was_written_in() { + let definitions = definition_paths_at(&call_hop_files(), "/app/Use.kt", 3, "text"); + + assert_eq!(definitions, vec!["/t/types/Types.kt".to_owned()]); +} + +/// Second hop: `IProductScreenTexts` was substituted into `ScreenFlowModel.text` +/// from the call's return type, so it was written in `Impl.kt` — NOT in `Types.kt` +/// (which declares `text`) and not in the caller. +#[test] +fn member_of_a_substituted_type_argument_resolves_in_the_receivers_origin() { + let definitions = definition_paths_at(&call_hop_files(), "/app/Use.kt", 3, "scenes"); + + assert_eq!(definitions, vec!["/t/texts/Texts.kt".to_owned()]); +} diff --git a/src/indexer/infer/deps.rs b/src/indexer/infer/deps.rs index a2951cf0..5c37a678 100644 --- a/src/indexer/infer/deps.rs +++ b/src/indexer/infer/deps.rs @@ -225,6 +225,20 @@ pub(crate) trait InferDeps { None } + /// [`Self::find_method_return_type_for_type`] together with the file the + /// return type was written in — the scope its type NAME must be resolved + /// against (see `ResolvedType::declaring_uri`). `uri` (the caller) when the + /// lookup does not report a file (a JAR or extension-registry answer). + fn find_method_return_type_with_origin_for_type( + &self, + class_name: &str, + method_name: &str, + uri: &Url, + ) -> Option<(String, Url)> { + self.find_method_return_type_for_type(class_name, method_name, uri) + .map(|type_name| (type_name, uri.clone())) + } + /// Return the raw parameter text for a method declared inside `class_name`, /// searching both members and extension functions on that receiver type. /// diff --git a/src/indexer/infer/expr_type.rs b/src/indexer/infer/expr_type.rs index 5f0ce4e3..ba405a7d 100644 --- a/src/indexer/infer/expr_type.rs +++ b/src/indexer/infer/expr_type.rs @@ -124,9 +124,7 @@ fn infer_expr_type_at_depth( infer_this_expr_type(node, bytes, deps, uri).map(|type_name| (type_name, uri.clone())) } k if k == KIND_NAV_EXPR => infer_navigation_expr_type(node, bytes, deps, uri, depth), - k if k == KIND_CALL_EXPR => { - infer_call_expr_type(node, bytes, deps, uri).map(|type_name| (type_name, uri.clone())) - } + k if k == KIND_CALL_EXPR => infer_call_expr_type(node, bytes, deps, uri), k if k == KIND_CHECK_EXPR || k == KIND_COMPARISON_EXPR || k == KIND_DISJUNCTION_EXPR @@ -175,8 +173,8 @@ fn infer_call_expr_type( bytes: &[u8], deps: &impl InferDeps, uri: &Url, -) -> Option { - super::chain::resolve_call_expr_type(node, bytes, deps, uri) +) -> Option<(String, Url)> { + super::chain::resolve_call_expr_type_with_origin(node, bytes, deps, uri) } /// Resolve the type of a `simple_identifier` or `type_identifier`. diff --git a/src/resolver/api.rs b/src/resolver/api.rs index 5488fec2..0bb599ec 100644 --- a/src/resolver/api.rs +++ b/src/resolver/api.rs @@ -29,8 +29,9 @@ use crate::indexer::Indexer; use super::infer::{ find_field_type_in_class, find_field_type_via_supertypes, find_fun_return_type_by_name, - find_fun_return_type_reachable, find_method_return_type, - find_method_return_type_via_supertypes, infer_field_chain_type, infer_receiver_type, + find_fun_return_type_reachable, find_method_return_type_declared, + find_method_return_type_via_supertypes_declared, infer_field_chain_type, infer_receiver_type, + DeclaredReturn, }; use super::ReceiverTypeAgreement; use super::{ReceiverKind, ReceiverType}; @@ -159,12 +160,25 @@ pub(crate) trait Resolver { /// /// Returns `None` when no matching method (own, extension, or inherited) with /// a resolvable return type — declared or inferred — is found. + fn method_return_type_declared( + &self, + type_name: &str, + method_name: &str, + from_uri: Option<&Url>, + ) -> Option; + + /// [`Self::method_return_type_declared`] without the file the type was + /// written in. + #[cfg(test)] fn method_return_type( &self, type_name: &str, method_name: &str, from_uri: Option<&Url>, - ) -> Option; + ) -> Option { + self.method_return_type_declared(type_name, method_name, from_uri) + .map(|declared| ReturnType(declared.type_name)) + } /// Resolve `field_name`'s type (declared, or inferred from its /// initializer for an unannotated `val`/`var`) on a receiver whose type's @@ -229,17 +243,15 @@ impl Resolver for Indexer { .map(ReturnType) } - fn method_return_type( + fn method_return_type_declared( &self, type_name: &str, method_name: &str, from_uri: Option<&Url>, - ) -> Option { - find_method_return_type(self, type_name, method_name, from_uri) - .or_else(|| { - find_method_return_type_via_supertypes(self, type_name, method_name, from_uri) - }) - .map(ReturnType) + ) -> Option { + find_method_return_type_declared(self, type_name, method_name, from_uri).or_else(|| { + find_method_return_type_via_supertypes_declared(self, type_name, method_name, from_uri) + }) } fn receiver_type_agreement( diff --git a/src/resolver/infer.rs b/src/resolver/infer.rs index 6f91479f..70bddcac 100644 --- a/src/resolver/infer.rs +++ b/src/resolver/infer.rs @@ -1736,17 +1736,41 @@ pub(crate) fn find_fun_return_type_reachable( fallback } +/// A declared (or body-inferred) return type together with the file it was +/// written in. `declared_in` is `None` when the answer came from an +/// extension-registry or JAR-backed lookup that does not report a file. +#[derive(Debug, Clone)] +pub(crate) struct DeclaredReturn { + pub(crate) type_name: String, + pub(crate) declared_in: Option, +} + +#[cfg(test)] pub(crate) fn find_method_return_type( indexer: &Indexer, type_name: &str, method_name: &str, from_uri: Option<&Url>, ) -> Option { + find_method_return_type_declared(indexer, type_name, method_name, from_uri) + .map(|declared| declared.type_name) +} + +/// [`find_method_return_type`], keeping the file the return type was written in. +pub(crate) fn find_method_return_type_declared( + indexer: &Indexer, + type_name: &str, + method_name: &str, + from_uri: Option<&Url>, +) -> Option { let type_base = type_name.last_segment(); // Extension functions take precedence over member functions. if let Some(ret) = find_extension_fn_return_type(indexer, type_base, method_name, from_uri) { - return Some(ret); + return Some(DeclaredReturn { + type_name: ret, + declared_in: None, + }); } // Then check member functions (container-based), scoped + capped via the helper. @@ -1766,18 +1790,20 @@ pub(crate) fn find_method_return_type( continue; } // Try detail first; fall back to source lines when detail is truncated. - if let Some(ret) = extract_return_type_from_detail(&symbol.detail) { - return Some(ret); - } - // detail may be truncated (120 char limit) — try the source lines. - let start_line = symbol.selection_start() as usize; - let full_sig = file_data.lines.collect_signature(start_line); - if let Some(ret) = extract_return_type_from_detail(&full_sig) { - return Some(ret); - } - // No declared type at all: the body's type, or `Unit`. - if let Some(ret) = infer_undeclared_return_type(indexer, &loc.uri, symbol) { - return Some(ret); + let declared_type = extract_return_type_from_detail(&symbol.detail) + .or_else(|| { + // detail may be truncated (120 char limit) — try the source lines. + let start_line = symbol.selection_start() as usize; + let full_sig = file_data.lines.collect_signature(start_line); + extract_return_type_from_detail(&full_sig) + }) + // No declared type at all: the body's type, or `Unit`. + .or_else(|| infer_undeclared_return_type(indexer, &loc.uri, symbol)); + if let Some(type_name) = declared_type { + return Some(DeclaredReturn { + type_name, + declared_in: Some(loc.uri.clone()), + }); } } None @@ -2124,12 +2150,25 @@ fn find_extension_fn_return_type_global( }) } +#[cfg(test)] pub(crate) fn find_method_return_type_via_supertypes( indexer: &Indexer, class_name: &str, method_name: &str, from_uri: Option<&Url>, ) -> Option { + find_method_return_type_via_supertypes_declared(indexer, class_name, method_name, from_uri) + .map(|declared| declared.type_name) +} + +/// [`find_method_return_type_via_supertypes`], keeping the file the return type +/// was written in. +pub(crate) fn find_method_return_type_via_supertypes_declared( + indexer: &Indexer, + class_name: &str, + method_name: &str, + from_uri: Option<&Url>, +) -> Option { // Strip generics AND any qualifying package prefix — `class_base` is // matched against bare symbol names below. let class_base = class_name.dotted_ident_prefix().last_segment().to_owned(); @@ -2159,21 +2198,23 @@ pub(crate) fn find_method_return_type_via_supertypes( /// `class Derived : Base`) are substituted into a hit found there via /// `substitute_direct_supertype_args`; a hit on a deeper ancestor is /// returned as-is — multi-level generic substitution isn't attempted, the -/// same scope the original single-level logic had. +/// same scope the original single-level logic had. A substituted answer is +/// attributed to `class_uri` (where `Base` was spelled), an unchanged one +/// to the ancestor's own file. fn find_method_return_type_via_class_hierarchy( indexer: &Indexer, class_base: &str, class_uri: &str, method_name: &str, from_uri: Option<&Url>, -) -> Option { +) -> Option { use crate::types::CallerContext; let caller = CallerContext { uri: from_uri.map(Url::as_str), cursor_line: None, }; - let hits: Vec<(String, String)> = walk_hierarchy( + let hits: Vec<(String, DeclaredReturn)> = walk_hierarchy( indexer, class_base, class_uri, @@ -2181,20 +2222,27 @@ fn find_method_return_type_via_class_hierarchy( 8, MAX_SYNC_JAR_PROMOTIONS_PER_HIERARCHY_WALK, |idx, super_name, _super_uri, _caller| { - find_method_return_type(idx, super_name, method_name, from_uri) - .map(|raw| (super_name.to_owned(), raw)) + find_method_return_type_declared(idx, super_name, method_name, from_uri) + .map(|declared| (super_name.to_owned(), declared)) .into_iter() .collect() }, ); - let (super_name, raw) = hits.into_iter().next()?; - Some(substitute_direct_supertype_args( + let (super_name, declared) = hits.into_iter().next()?; + let substituted = substitute_direct_supertype_args( indexer, class_uri, class_base, &super_name, - &raw, - )) + &declared.type_name, + ); + if substituted == declared.type_name { + return Some(declared); + } + Some(DeclaredReturn { + type_name: substituted, + declared_in: Url::parse(class_uri).ok(), + }) } /// If `super_name` is `class_base`'s *direct* supertype (declared with diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index d22501ee..9762465e 100644 --- a/src/resolver/mod.rs +++ b/src/resolver/mod.rs @@ -29,7 +29,9 @@ mod tie_break; // ─── re-exports ─────────────────────────────────────────────────────────────── -pub(crate) use api::{Resolver, ReturnType}; +pub(crate) use api::Resolver; +#[cfg(test)] +pub(crate) use api::ReturnType; pub(crate) use complete::symbols_from_uri_as_completions_pub; #[cfg(test)] pub(crate) use complete::{complete_symbol, complete_symbol_with_context, is_annotation_context}; From e718bff94e4c0028292284310f7afe35a5297ae5 Mon Sep 17 00:00:00 2001 From: Juraj Oceliak Date: Tue, 6 Oct 2026 10:15:48 +0200 Subject: [PATCH 3/4] chore(resolve): drop the now-unused find_method_return_type_via_supertypes wrapper and ReturnType re-export CI builds tests with RUSTFLAGS=-D warnings; the origin-carrying composite made both unused. Co-Authored-By: Claude Sonnet 5.5 --- src/resolver/infer.rs | 23 ++++++----------------- src/resolver/mod.rs | 2 -- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/src/resolver/infer.rs b/src/resolver/infer.rs index 70bddcac..dfc9cf12 100644 --- a/src/resolver/infer.rs +++ b/src/resolver/infer.rs @@ -835,13 +835,13 @@ fn infer_var_from_rhs_data( /// exact composite the CST engine's `resolve_call_expr_type` /// (`indexer/infer/chain.rs`) reaches for the same receiver-method shape -- /// rather than hand-chaining `find_method_return_type` and -/// `find_method_return_type_via_supertypes` here too. Shared by both STRING +/// `find_method_return_type_via_supertypes_declared` here too. Shared by both STRING /// call sites below (`infer_var_from_rhs_data`'s `method_match` branch and /// `infer_method_return_type`'s line-scan fallback) so a future change to /// that fallback policy can't silently diverge between the two STRING call /// sites, or between the STRING and CST engines -- which is exactly how the /// supertype-walk fallback itself went missing from this file before it was -/// added back (see `find_method_return_type_via_supertypes`'s callers). +/// added back (see `find_method_return_type_via_supertypes_declared`'s callers). fn resolve_method_return_type_substituted( indexer: &Indexer, recv_type_raw: &str, @@ -1234,7 +1234,7 @@ fn find_field_type_in_class_impl( /// workspace search and finally `lookup_definitions` (the only one of the /// three that promotes a not-yet-materialized JAR) when nothing reachable is /// found. Shared by [`find_field_type_via_supertypes`] and -/// [`find_method_return_type_via_supertypes`] so neither anchors its walk on +/// [`find_method_return_type_via_supertypes_declared`] so neither anchors its walk on /// an unrelated same-named class elsewhere in the workspace. fn reachable_class_candidates( indexer: &Indexer, @@ -1254,7 +1254,7 @@ fn reachable_class_candidates( } /// Resolve `field_name`'s declared type by walking `class_name`'s ancestors — -/// the field-typed sibling of [`find_method_return_type_via_supertypes`]. +/// the field-typed sibling of [`find_method_return_type_via_supertypes_declared`]. /// [`find_field_type_in_class`] only reads `class_name`'s own body, so a /// field declared only on a generic superclass (`viewModel.uiState` where /// `uiState` lives on `MviViewModel`, not the specific @@ -2150,19 +2150,8 @@ fn find_extension_fn_return_type_global( }) } -#[cfg(test)] -pub(crate) fn find_method_return_type_via_supertypes( - indexer: &Indexer, - class_name: &str, - method_name: &str, - from_uri: Option<&Url>, -) -> Option { - find_method_return_type_via_supertypes_declared(indexer, class_name, method_name, from_uri) - .map(|declared| declared.type_name) -} - -/// [`find_method_return_type_via_supertypes`], keeping the file the return type -/// was written in. +/// The supertype walk behind `Resolver::method_return_type_declared`: the return +/// type of `method_name` inherited by `class_name`, with the file it was written in. pub(crate) fn find_method_return_type_via_supertypes_declared( indexer: &Indexer, class_name: &str, diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index 9762465e..2d2c9564 100644 --- a/src/resolver/mod.rs +++ b/src/resolver/mod.rs @@ -30,8 +30,6 @@ mod tie_break; // ─── re-exports ─────────────────────────────────────────────────────────────── pub(crate) use api::Resolver; -#[cfg(test)] -pub(crate) use api::ReturnType; pub(crate) use complete::symbols_from_uri_as_completions_pub; #[cfg(test)] pub(crate) use complete::{complete_symbol, complete_symbol_with_context, is_annotation_context}; From 8aa5690611b27be27cd0198ab699a9ac0dd28d2e Mon Sep 17 00:00:00 2001 From: Juraj Oceliak Date: Tue, 6 Oct 2026 10:40:46 +0200 Subject: [PATCH 4/4] fix(resolve): package-qualified, call-site and extension origins (review on #331) - TypeScope (file + optional package) replaces a bare Url for where a qualifier's type name resolves; a package-qualified spelling (types.Payload) resolves its root through the fully-qualified index and constrains candidates to that package. extract_return_type_from_detail now accepts a package-qualified return type (it read a lowercase first segment as 'no declared type'). - an explicit call-site type argument that replaces the return head (create()) was spelled at the call site: origin = caller - a source extension function's return type reports the selected entry's declaring file (find_extension_fn_return_type_declared); JAR files keep the caller-scope fallback Co-Authored-By: Claude Sonnet 5.5 --- src/indexer/infer/chain.rs | 26 ++++++-- src/indexer/infer/cst_symbol_tests.rs | 86 +++++++++++++++++++++++++ src/indexer/lookup.rs | 29 +++++---- src/resolver/infer.rs | 50 ++++++++++---- src/resolver/infer_lines.rs | 6 +- src/resolver/infer_tests.rs | 18 ++++++ src/resolver/mod.rs | 2 +- src/resolver/qualified.rs | 93 ++++++++++++++++++++++++--- src/resolver/resolve.rs | 4 +- src/resolver/tests.rs | 8 +-- 10 files changed, 271 insertions(+), 51 deletions(-) diff --git a/src/indexer/infer/chain.rs b/src/indexer/infer/chain.rs index 8a7b9ea3..63d0bc32 100644 --- a/src/indexer/infer/chain.rs +++ b/src/indexer/infer/chain.rs @@ -689,7 +689,7 @@ impl StrategyOutcome { }; let origin = origin_after_substitution(&raw_return, &substitution, origin, ctx); let substituted = crate::indexer::apply_type_subst(&raw_return, &substitution); - (apply_call_site_type_args(substituted, ctx), origin) + apply_call_site_type_args(substituted, origin, ctx) } StrategyOutcome::ReceiverDerived { raw_return, @@ -705,7 +705,7 @@ impl StrategyOutcome { let origin = origin_after_substitution(&raw_return, &substitution, return_origin, ctx); let substituted = crate::indexer::apply_type_subst(&raw_return, &substitution); - (apply_call_site_type_args(substituted, ctx), origin) + apply_call_site_type_args(substituted, origin, ctx) } } } @@ -799,18 +799,30 @@ enum StrategyVerdict { /// caller's explicit ``, never derivable from the (possibly /// star-projected) receiver. The one place this runs, so every strategy /// that goes through `StrategyOutcome::finalize` gets it for free. -fn apply_call_site_type_args(ret: String, ctx: &CallCtx<'_, D>) -> String { +fn apply_call_site_type_args( + ret: String, + origin: Url, + ctx: &CallCtx<'_, D>, +) -> (String, Url) { let Some(call_type_args) = ctx.node.call_site_type_arg_strings(ctx.bytes) else { - return ret; + return (ret, origin); }; let Some(callable_info) = ctx.deps.find_fun_callable_info(ctx.fn_name, ctx.uri) else { - return ret; + return (ret, origin); }; if callable_info.type_params.is_empty() { - return ret; + return (ret, origin); } let fn_subst = build_fn_subst(&callable_info.type_params, &call_type_args); - apply_simple_subst(&ret, &fn_subst) + // An explicit type argument that replaces the return type's head was + // spelled at the call site (`create()`), not where the signature was. + let head_path = ret.strip_nullable().dotted_ident_prefix(); + let origin = if fn_subst.contains_key(&head_path) { + ctx.uri.clone() + } else { + origin + }; + (apply_simple_subst(&ret, &fn_subst), origin) } /// Scope functions (`let`/`also`/`run`/`apply`/`takeIf`/`takeUnless`): the diff --git a/src/indexer/infer/cst_symbol_tests.rs b/src/indexer/infer/cst_symbol_tests.rs index e1d90153..363ec16c 100644 --- a/src/indexer/infer/cst_symbol_tests.rs +++ b/src/indexer/infer/cst_symbol_tests.rs @@ -934,3 +934,89 @@ fn member_of_a_substituted_type_argument_resolves_in_the_receivers_origin() { assert_eq!(definitions, vec!["/t/texts/Texts.kt".to_owned()]); } + +/// Review finding on slice 2: a SOURCE extension function's return type is spelled in +/// the extension's own file (which imports `Payload`); the caller imports only +/// the extension itself. +#[test] +fn extension_call_member_resolves_in_the_file_the_extension_was_declared() { + let files = [ + ( + "/types/Types.kt", + "package types\nclass Payload(val text: String)\n", + ), + ("/impl/Holder.kt", "package impl\nclass Holder\n"), + ( + "/ext/Ext.kt", + "package ext\nimport impl.*\nimport types.*\nfun Holder.make(): Payload = TODO()\n", + ), + ( + "/app/Use.kt", + "package app\nimport impl.Holder\nimport ext.make\nfun f(holder: Holder) { holder.make().text }\n", + ), + ( + "/r/Decoy.kt", + "package r\nclass Payload(val text: Int)\n", + ), + ]; + + let definitions = definition_paths_at(&files, "/app/Use.kt", 3, "text"); + + assert_eq!(definitions, vec!["/t/types/Types.kt".to_owned()]); +} + +/// Review finding on slice 2: when an explicit call-site type argument replaces +/// the return type's head (`create()` for `fun create(): T`), `Foo` was +/// spelled at the CALL SITE, not in the factory's file. +#[test] +fn call_site_type_argument_is_resolved_in_the_callers_scope() { + let files = [ + ( + "/foos/Foo.kt", + "package foos\nclass Foo(val member: String)\n", + ), + ( + "/impl/Factory.kt", + "package impl\nclass Factory { fun create(): T = TODO() }\n", + ), + ( + "/app/Use.kt", + "package app\nimport impl.Factory\nimport foos.Foo\nfun f(factory: Factory) { factory.create().member }\n", + ), + ( + "/r/Decoy.kt", + "package r\nclass Foo(val member: Int)\n", + ), + ]; + + let definitions = definition_paths_at(&files, "/app/Use.kt", 3, "member"); + + assert_eq!(definitions, vec!["/t/foos/Foo.kt".to_owned()]); +} + +/// Review finding on slice 1: a package-qualified return type (`types.Payload`) was +/// misrouted — `parse_qualifier` took the lowercase `types` root for a value, so +/// the type scope was ignored. The package constrains WHICH `Payload`, and the +/// caller star-imports a same-named decoy. +#[test] +fn package_qualified_return_type_resolves_to_the_named_package() { + let files = [ + ( + "/types/Types.kt", + "package types\nclass Payload(val text: String)\n", + ), + ( + "/impl/Factory.kt", + "package impl\nfun makePayload(): types.Payload = TODO()\n", + ), + ( + "/app/Use.kt", + "package app\nimport impl.makePayload\nimport r.*\nfun go() { makePayload().text }\n", + ), + ("/r/Decoy.kt", "package r\nclass Payload(val text: Int)\n"), + ]; + + let definitions = definition_paths_at(&files, "/app/Use.kt", 3, "text"); + + assert_eq!(definitions, vec!["/t/types/Types.kt".to_owned()]); +} diff --git a/src/indexer/lookup.rs b/src/indexer/lookup.rs index 12482703..7733c501 100644 --- a/src/indexer/lookup.rs +++ b/src/indexer/lookup.rs @@ -15,7 +15,7 @@ use tower_lsp::lsp_types::*; use super::infer::lambda::SCOPE_FUNCTIONS; use super::{Indexer, ResolvedType}; -use crate::resolver::{resolve_symbol_in_type_scope, ResolveIo}; +use crate::resolver::{resolve_symbol_in_type_scope, split_package_prefix, ResolveIo, TypeScope}; use crate::types::SymbolEntry; use crate::StrExt; @@ -100,14 +100,13 @@ impl Indexer { from_uri: &Url, index_only: bool, ) -> Vec { - let type_scope = receiver.resolution_scope(from_uri); - self.find_qualified_in_type_scope( - name, - &receiver.type_path(), - type_scope, - from_uri, - index_only, - ) + let type_path = receiver.type_path(); + let (package, bare_path) = split_package_prefix(&type_path); + let type_scope = TypeScope { + file: receiver.resolution_scope(from_uri), + package, + }; + self.find_qualified_in_type_scope(name, bare_path, &type_scope, from_uri, index_only) } fn find_definition_qualified_with_io( @@ -118,9 +117,13 @@ impl Indexer { index_only: bool, ) -> Vec { match qualifier { - Some(qualifier) => { - self.find_qualified_in_type_scope(name, qualifier, from_uri, from_uri, index_only) - } + Some(qualifier) => self.find_qualified_in_type_scope( + name, + qualifier, + &TypeScope::in_file(from_uri), + from_uri, + index_only, + ), None if index_only => self.resolve_symbol_index_only(name, None, from_uri), None => self.resolve_symbol(name, None, from_uri), } @@ -130,7 +133,7 @@ impl Indexer { &self, name: &str, qualifier: &str, - type_scope: &Url, + type_scope: &TypeScope<'_>, from_uri: &Url, index_only: bool, ) -> Vec { diff --git a/src/resolver/infer.rs b/src/resolver/infer.rs index dfc9cf12..985506fb 100644 --- a/src/resolver/infer.rs +++ b/src/resolver/infer.rs @@ -1766,11 +1766,10 @@ pub(crate) fn find_method_return_type_declared( let type_base = type_name.last_segment(); // Extension functions take precedence over member functions. - if let Some(ret) = find_extension_fn_return_type(indexer, type_base, method_name, from_uri) { - return Some(DeclaredReturn { - type_name: ret, - declared_in: None, - }); + if let Some(declared) = + find_extension_fn_return_type_declared(indexer, type_base, method_name, from_uri) + { + return Some(declared); } // Then check member functions (container-based), scoped + capped via the helper. @@ -2007,12 +2006,26 @@ pub(crate) fn extension_declaration_matches( /// /// Example: `receiver_base = "Optional"`, `method_name = "getOrNull"` → /// finds `public fun Optional.getOrNull(): T?` and returns `"T?"`. +#[cfg(test)] pub(crate) fn find_extension_fn_return_type( indexer: &Indexer, receiver_base: &str, method_name: &str, from_uri: Option<&Url>, ) -> Option { + find_extension_fn_return_type_declared(indexer, receiver_base, method_name, from_uri) + .map(|declared| declared.type_name) +} + +/// [`find_extension_fn_return_type`], keeping the file of the extension +/// declaration the answer came from (the file its return type's name was +/// written in). +pub(crate) fn find_extension_fn_return_type_declared( + indexer: &Indexer, + receiver_base: &str, + method_name: &str, + from_uri: Option<&Url>, +) -> Option { if let Some(uri) = from_uri { return find_extension_fn_return_type_scoped(indexer, receiver_base, method_name, uri); } @@ -2024,7 +2037,7 @@ fn find_extension_fn_return_type_scoped( receiver_base: &str, method_name: &str, from_uri: &Url, -) -> Option { +) -> Option { // Promotion MUST happen before the `extension_by_receiver` read below — // `extension_by_receiver` is populated exclusively by Tier-2 // materialization (`build_jar_file_data`); Tier 1 @@ -2065,8 +2078,12 @@ fn find_extension_fn_return_type_scoped( continue; } // Try detail first; fall back to source lines when detail is truncated. + let declaring_file = Url::parse(&entry.file_uri).ok(); if let Some(ret) = extract_return_type_from_detail(&entry.detail) { - return Some(ret); + return Some(DeclaredReturn { + type_name: ret, + declared_in: declaring_file, + }); } // detail may be truncated (120 char limit) — try the source lines. // No promotion check needed here: reaching this `entry` at all means @@ -2103,7 +2120,10 @@ fn find_extension_fn_return_type_scoped( let start_line = declaring_symbol.selection_start() as usize; let full_sig = file_data.lines.collect_signature(start_line); if let Some(ret) = extract_return_type_from_detail(&full_sig) { - return Some(ret); + return Some(DeclaredReturn { + type_name: ret, + declared_in: declaring_file, + }); } } None @@ -2122,7 +2142,7 @@ fn find_extension_fn_return_type_global( indexer: &Indexer, receiver_base: &str, method_name: &str, -) -> Option { +) -> Option { // Global extension-fn lookup by bare method name: scoped + capped via the helper. indexer.find_in_workspace_defs(method_name, |loc| { let file_data = indexer.files.get(loc.uri.as_str())?; @@ -2137,13 +2157,15 @@ fn find_extension_fn_return_type_global( if symbol.extension_receiver() != receiver_base { continue; } - if let Some(ret) = extract_return_type_from_detail(&symbol.detail) { - return Some(ret); - } let start_line = symbol.selection_start() as usize; let full_sig = file_data.lines.collect_signature(start_line); - if let Some(ret) = extract_return_type_from_detail(&full_sig) { - return Some(ret); + let declared_type = extract_return_type_from_detail(&symbol.detail) + .or_else(|| extract_return_type_from_detail(&full_sig)); + if let Some(type_name) = declared_type { + return Some(DeclaredReturn { + type_name, + declared_in: Some(loc.uri.clone()), + }); } } None diff --git a/src/resolver/infer_lines.rs b/src/resolver/infer_lines.rs index 7c6e2586..f02ca632 100644 --- a/src/resolver/infer_lines.rs +++ b/src/resolver/infer_lines.rs @@ -513,7 +513,11 @@ pub(crate) fn extract_return_type_from_detail(detail: &str) -> Option { if after.starts_with(':') { let type_part = after.strip_prefix(':').map(str::trim_start).unwrap_or(""); let type_name = extract_type_with_generics(type_part); - if !type_name.is_empty() && type_name.starts_with_uppercase() { + // A type name starts uppercase; a package-qualified one + // (`types.Payload`) starts with its lowercase package, so judge the + // LAST dotted segment. + let type_path = type_name.dotted_ident_prefix(); + if !type_path.is_empty() && type_path.last_segment().starts_with_uppercase() { return Some(type_name); } } diff --git a/src/resolver/infer_tests.rs b/src/resolver/infer_tests.rs index 18efdd54..7ddb3a73 100644 --- a/src/resolver/infer_tests.rs +++ b/src/resolver/infer_tests.rs @@ -1989,3 +1989,21 @@ fn undeclared_return_of_a_block_bodied_or_bodyless_kotlin_method_is_unit() { assert_eq!(block_bodied.as_deref(), Some("Unit")); assert_eq!(bodyless.as_deref(), Some("Unit")); } + +/// A package-qualified return type (`types.Payload`) is a type: its LAST segment +/// is the type name, so a lowercase package prefix must not make the whole +/// annotation read as "no declared type". +#[test] +fn return_type_extraction_accepts_a_package_qualified_type() { + use super::super::infer_lines::extract_return_type_from_detail as extract; + + assert_eq!( + extract("fun make(): types.Payload = TODO()").as_deref(), + Some("types.Payload") + ); + assert_eq!( + extract("fun make(): kotlin.collections.List").as_deref(), + Some("kotlin.collections.List") + ); + assert_eq!(extract("fun make(): lowercase").as_deref(), None); +} diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index 2d2c9564..7e2bffb0 100644 --- a/src/resolver/mod.rs +++ b/src/resolver/mod.rs @@ -17,7 +17,7 @@ mod package; mod package_scope; mod platform_types; mod qualified; -pub(crate) use qualified::resolve_type_path_declarations; +pub(crate) use qualified::{resolve_type_path_declarations, split_package_prefix, TypeScope}; pub(crate) use resolve::ResolveIo; pub(crate) mod resolve; mod scope_check; diff --git a/src/resolver/qualified.rs b/src/resolver/qualified.rs index 490198b1..e7e5c7ff 100644 --- a/src/resolver/qualified.rs +++ b/src/resolver/qualified.rs @@ -65,6 +65,50 @@ pub(super) fn parse_qualifier(qualifier: &str) -> QualifierRoot<'_> { } } +/// Where a qualifier's TYPE NAME is resolved: the file whose imports/package +/// resolve it, optionally constrained to a declared package (`types` in the +/// package-qualified spelling `types.Payload`). +#[derive(Clone, Copy)] +pub(crate) struct TypeScope<'a> { + pub(crate) file: &'a Url, + pub(crate) package: Option<&'a str>, +} + +impl<'a> TypeScope<'a> { + pub(crate) fn in_file(file: &'a Url) -> Self { + TypeScope { + file, + package: None, + } + } +} + +/// Splits a type path that may start with package segments +/// (`com.example.Outer.Inner`) into `(package, type path)`. Package segments +/// are the leading lowercase ones, and only when a type segment follows. +pub(crate) fn split_package_prefix(type_path: &str) -> (Option<&str>, &str) { + let mut offset = 0; + for segment in type_path.split('.') { + if segment.starts_with_uppercase() { + return match offset { + 0 => (None, type_path), + _ => (Some(&type_path[..offset - 1]), &type_path[offset..]), + }; + } + offset += segment.len() + 1; + } + (None, type_path) +} + +/// The package a declaration's file declares. +fn declared_package(indexer: &Indexer, location: &Location) -> Option { + indexer + .files + .get(location.uri.as_str()) + .or_else(|| indexer.jar_files.get(location.uri.as_str())) + .and_then(|file_data| file_data.package.clone()) +} + pub(super) fn resolve_qualified( indexer: &Indexer, name: &str, @@ -72,7 +116,14 @@ pub(super) fn resolve_qualified( from_uri: &Url, io: ResolveIo, ) -> Vec { - resolve_qualified_in_type_scope(indexer, name, qualifier, from_uri, from_uri, io) + resolve_qualified_in_type_scope( + indexer, + name, + qualifier, + &TypeScope::in_file(from_uri), + from_uri, + io, + ) } /// [`resolve_qualified`] for a qualifier whose TYPE NAME was written in a file @@ -86,7 +137,7 @@ pub(super) fn resolve_qualified_in_type_scope( indexer: &Indexer, name: &str, qualifier: &str, - type_scope: &Url, + type_scope: &TypeScope<'_>, from_uri: &Url, io: ResolveIo, ) -> Vec { @@ -157,10 +208,15 @@ pub(crate) fn resolve_type_path_declarations( scope_uri: &Url, io: ResolveIo, ) -> Vec { - let QualifierRoot::TypePath { root, nested } = parse_qualifier(type_path) else { + let (package, bare_path) = split_package_prefix(type_path); + let QualifierRoot::TypePath { root, nested } = parse_qualifier(bare_path) else { return vec![]; }; - type_path_anchors(indexer, root, &nested, scope_uri, io) + let type_scope = TypeScope { + file: scope_uri, + package, + }; + type_path_anchors(indexer, root, &nested, &type_scope, io) .into_iter() .filter_map(|anchor| anchor.declaration) .collect() @@ -201,7 +257,7 @@ pub(super) struct ReceiverAnchor { pub(super) fn anchors_for( indexer: &Indexer, root: &QualifierRoot<'_>, - type_scope: &Url, + type_scope: &TypeScope<'_>, from_uri: &Url, io: ResolveIo, ) -> Vec { @@ -224,18 +280,37 @@ fn type_path_anchors( indexer: &Indexer, root: &str, nested: &[&str], - type_scope: &Url, + type_scope: &TypeScope<'_>, io: ResolveIo, ) -> Vec { // Honors the caller's IO policy — an IndexOnly caller (the // resolution-accuracy benchmark's own index-only path) must not spawn // rg/fd resolving the qualifier root any more than it may for a bare // reference. - let root_locations = if matches!(io, ResolveIo::IndexOnly) { - resolve_symbol_index_only(indexer, root, None, type_scope) + let mut root_locations = if matches!(io, ResolveIo::IndexOnly) { + resolve_symbol_index_only(indexer, root, None, type_scope.file) } else { - resolve_symbol(indexer, root, None, type_scope) + resolve_symbol(indexer, root, None, type_scope.file) }; + if let Some(package) = type_scope.package { + // A package-qualified spelling names its type exactly: the fully + // qualified index answers it even where the simple name is not + // imported; otherwise the scope's candidates must at least declare + // that package. + let fully_qualified = indexer + .qualified + .get(&format!("{package}.{root}")) + .and_then(|symbol_loc| indexer.file_table.location(*symbol_loc.value())); + root_locations = match fully_qualified { + Some(location) => vec![location], + None => { + root_locations.retain(|location| { + declared_package(indexer, location).as_deref() == Some(package) + }); + root_locations + } + }; + } let anchors: Vec = root_locations .iter() diff --git a/src/resolver/resolve.rs b/src/resolver/resolve.rs index 73e35352..04220ca7 100644 --- a/src/resolver/resolve.rs +++ b/src/resolver/resolve.rs @@ -37,7 +37,7 @@ use super::package_scope::{ }; use super::platform_types::resolve_kotlin_builtin_type_platform_equivalent; use super::qualified::{ - resolve_from_class_hierarchy, resolve_qualified, resolve_qualified_in_type_scope, + resolve_from_class_hierarchy, resolve_qualified, resolve_qualified_in_type_scope, TypeScope, }; use super::tie_break::ambiguity_safe_tail_with_denylist; @@ -154,7 +154,7 @@ pub(crate) fn resolve_symbol_in_type_scope( indexer: &Indexer, name: &str, qualifier: &str, - type_scope: &Url, + type_scope: &TypeScope<'_>, from_uri: &Url, io: ResolveIo, ) -> Vec { diff --git a/src/resolver/tests.rs b/src/resolver/tests.rs index 192cb894..1e180620 100644 --- a/src/resolver/tests.rs +++ b/src/resolver/tests.rs @@ -10491,7 +10491,7 @@ fn anchors_for_a_nested_type_path_anchors_on_the_leaf_not_the_root() { let anchors = anchors_for( &idx, &parse_qualifier("Outer.Inner"), - &caller_uri, + &TypeScope::in_file(&caller_uri), &caller_uri, ResolveIo::Full, ); @@ -10553,7 +10553,7 @@ fn anchors_for_a_missing_nested_segment_yields_no_anchor_not_a_root_fallback() { let anchors = anchors_for( &idx, &parse_qualifier("Outer.Missing"), - &caller_uri, + &TypeScope::in_file(&caller_uri), &caller_uri, ResolveIo::Full, ); @@ -10584,7 +10584,7 @@ fn anchors_for_a_value_path_anchors_on_the_inferred_receiver_type() { let anchors = anchors_for( &idx, &parse_qualifier("account"), - &caller_uri, + &TypeScope::in_file(&caller_uri), &caller_uri, ResolveIo::Full, ); @@ -10624,7 +10624,7 @@ fn anchors_for_an_unindexed_type_root_still_yields_a_declaration_less_anchor() { let anchors = anchors_for( &idx, &parse_qualifier("Unindexed"), - &caller_uri, + &TypeScope::in_file(&caller_uri), &caller_uri, ResolveIo::Full, );