Skip to content
1,817 changes: 1,817 additions & 0 deletions docs/superpowers/plans/2026-09-16-extension-follow-ups-plan.md

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions src/features/rename.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,24 @@ pub(crate) async fn rename_impl(
"identity is ambiguous — could not resolve a single definition",
));
};
// A same-arity extension collision (two unrelated `Modifier.weight`
// declarations, say) reaches this refusal BY DESIGN since PR #321:
// `resolve_identity` correctly returns the whole overload set rather than
// one arbitrary member of it, and silently renaming an arbitrarily chosen
// one of two genuinely distinct declarations is the unsound behaviour that
// PR replaced. Listing both candidates in the message would not help --
// an LSP error is surfaced as a toast with no navigation affordance -- so a
// real disambiguation UX needs a client-side picker this server has no
// protocol hook for. Reviewed and kept as-is, 2026-09-16.
//
// The 2026-09-16 extension-registry-follow-ups cluster raised how OFTEN
// this fires, not whether it should: its Task 2 widened extension-scope
// recognition (default-import packages now count), so more real
// candidates reach `resolve_extension_in_scope` per receiver, and a
// real-corpus measurement across that whole cluster showed FilteredCandidate
// rising by 449 (see that plan's final review) -- some fraction of which
// is this refusal firing on receivers it previously never reached at all.
// The reasoning above still holds; only its frequency changed.
if definitions.len() != 1 {
return Err(refusal(
"identity is ambiguous — matches more than one definition",
Expand Down
109 changes: 57 additions & 52 deletions src/resolver/extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,51 +4,67 @@
use tower_lsp::lsp_types::{Location, Range, Url};

use crate::indexer::{CallShape, Indexer};
use crate::types::FileData;
use crate::types::{FileData, SymbolEntry};

use super::resolve::resolve_symbol;

// ─── step implementations ────────────────────────────────────────────────────

/// Select the `Range` of the declaring symbol for one extension registry
/// `entry` inside its declaring file's already-parsed symbol table.
/// Select the declaring symbol for one extension registry `entry` inside its
/// declaring file's already-parsed symbol table.
///
/// Shared by [`resolve_extension_in_scope`] and
/// [`super::qualified::jar_extension_for_type_root`]: both now return every
/// same-named registry entry rather than the first, so both need this exact
/// Shared by [`resolve_extension_in_scope`],
/// [`super::qualified::jar_extension_for_type_root`], and
/// `implicit_receiver_extension_match`: all now consider every same-named
/// registry entry rather than just the first, so all three need this exact
/// two-step selection — a file may declare several overloads of the same
/// extension (same name, same receiver, same container), and
/// `extension_declaration_matches` alone can't tell them apart. Prefer the
/// declaring symbol whose `detail` (full signature text) exactly matches this
/// entry's own `detail`; when nothing matches exactly (e.g. the registry
/// entry's detail was computed slightly differently than the symbol table's,
/// or the file has since drifted), fall back to the first declaration with a
/// matching name/receiver/container shape rather than returning no range at
/// matching name/receiver/container shape rather than returning nothing at
/// all.
pub(super) fn select_extension_symbol<'file_data>(
file_data: &'file_data FileData,
name: &str,
receiver_base: &str,
container: Option<&String>,
detail: &str,
) -> Option<&'file_data SymbolEntry> {
let is_declaring_symbol = |symbol: &&SymbolEntry| {
crate::resolver::infer::extension_declaration_matches(
symbol,
name,
receiver_base,
container,
)
};
// Two non-allocating passes instead of collecting every shape-matching
// declaration into a `Vec` first: this is called once per registry entry
// by `implicit_receiver_extension_match`'s own per-entry loop, so an
// allocation and a full re-scan of every overload on every entry (the
// `Vec` version's cost even when the very first declaration is an exact
// match) is real, measurable overhead the old single-entry-point
// `.find(extension_declaration_matches)` never paid.
file_data
.symbols
.iter()
.find(|symbol| is_declaring_symbol(symbol) && symbol.detail == detail)
.or_else(|| file_data.symbols.iter().find(is_declaring_symbol))
}

/// The `Range` half of [`select_extension_symbol`], for callers that only
/// need the location and not the rest of the symbol (params/arity).
pub(super) fn select_extension_symbol_range(
file_data: &FileData,
name: &str,
receiver_base: &str,
container: Option<&String>,
detail: &str,
) -> Range {
let declaring_symbols: Vec<_> = file_data
.symbols
.iter()
.filter(|symbol| {
crate::resolver::infer::extension_declaration_matches(
symbol,
name,
receiver_base,
container,
)
})
.collect();
let exact_signature_match = declaring_symbols
.iter()
.find(|symbol| symbol.detail == detail);
let selected = exact_signature_match.or_else(|| declaring_symbols.first());
selected
select_extension_symbol(file_data, name, receiver_base, container, detail)
.map(|symbol| symbol.selection_range)
.unwrap_or_default()
}
Expand Down Expand Up @@ -88,15 +104,11 @@ pub(super) fn resolve_extension_in_scope(
if entry.name != name {
continue;
}
let in_scope = crate::resolver::infer::extension_is_in_scope(
entry.package.as_ref(),
&entry.name,
entry.container.as_ref(),
entry.visibility,
entry.file_uri == from_uri.as_str(),
if !crate::resolver::infer::extension_entry_is_in_scope(
entry,
from_uri,
caller_file_data_ref,
);
if !in_scope {
) {
continue;
}
let Ok(uri) = Url::parse(&entry.file_uri) else {
Expand Down Expand Up @@ -175,15 +187,11 @@ fn implicit_receiver_extension_match(
if entry.name != name {
continue;
}
let in_scope = crate::resolver::infer::extension_is_in_scope(
entry.package.as_ref(),
&entry.name,
entry.container.as_ref(),
entry.visibility,
entry.file_uri == from_uri.as_str(),
if !crate::resolver::infer::extension_entry_is_in_scope(
entry,
from_uri,
caller_file_data_ref,
);
if !in_scope {
) {
continue;
}
let Ok(uri) = Url::parse(&entry.file_uri) else {
Expand All @@ -193,18 +201,15 @@ fn implicit_receiver_extension_match(
.files
.get(&entry.file_uri)
.or_else(|| indexer.jar_files.get(&entry.file_uri))
.and_then(|fd| {
fd.symbols
.iter()
.find(|s| {
crate::resolver::infer::extension_declaration_matches(
s,
name,
receiver_base,
entry.container.as_ref(),
)
})
.cloned()
.and_then(|file_data| {
select_extension_symbol(
&file_data,
name,
receiver_base,
entry.container.as_ref(),
&entry.detail,
)
.cloned()
});
let Some(symbol) = symbol else { continue };
let is_vararg = symbol.params.contains("vararg ") || symbol.params.contains("vararg\t");
Expand Down
2 changes: 1 addition & 1 deletion src/resolver/imports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub(super) const KOTLIN_DEFAULT_IMPORT_PACKAGES: &[&str] = &[
/// Kotlin's implicit default-import packages (JVM target): names declared directly
/// in these are in scope in every file without an `import`. Narrower than
/// [`is_stdlib`] — `android`/`androidx`/most `java.*` are *not* auto-imported.
fn is_default_import_package(pkg: &str) -> bool {
pub(super) fn is_default_import_package(pkg: &str) -> bool {
KOTLIN_DEFAULT_IMPORT_PACKAGES.contains(&pkg)
}

Expand Down
114 changes: 92 additions & 22 deletions src/resolver/infer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1788,6 +1788,11 @@ pub(crate) fn find_method_return_type(
/// `Some`, `entry_visibility` and `is_same_file` gate `private`/`protected`
/// member extensions, which — unlike the package/import checks below — are
/// otherwise skipped entirely for a member extension (see the branch itself).
///
/// An extension-registry consumer should generally call
/// [`extension_entry_is_in_scope`] instead — it wraps this function with
/// Kotlin's default-import package rule, which this function alone does not
/// know about.
pub(crate) fn extension_is_in_scope(
entry_package: Option<&String>,
entry_name: &str,
Expand Down Expand Up @@ -1849,6 +1854,57 @@ pub(crate) fn extension_is_in_scope(
})
}

/// Whether extension registry `entry` is callable from the file at `from_uri`.
///
/// [`extension_is_in_scope`]'s package/import rules, plus the one rule they
/// cannot express: a Kotlin file implicitly imports every name declared
/// directly in Kotlin's own default-import packages (see
/// [`crate::resolver::imports::KOTLIN_DEFAULT_IMPORT_PACKAGES`]), so
/// `kotlin.text`'s `isNotEmpty` or `kotlin.collections`'s `firstOrNull` is in
/// scope at every call site with no `import` line anywhere — and no real file
/// ever writes one.
///
/// Gated on the CALLING file's language, for the same reason
/// [`crate::resolver::tie_break`]'s `default_kotlin_import_tie_break` gates
/// its own use of the same set: Kotlin's default imports are a fact about
/// Kotlin source files, and `resolve_qualified` runs over indexed `.java` and
/// `.swift` files too.
///
/// Measured on the Moneta corpus: the registry holds 24897 entries across 2360
/// receiver buckets; 3641 of those entries live in a default-import package,
/// 3638 of them top-level, and every one was rejected here for every caller.
///
/// Deliberately NOT folded into [`extension_is_in_scope`] itself. Of its other
/// three direct callers: `candidate_declaration_is_reachable` and
/// `Indexer::jar_candidate_is_reachable` are receiver-less by-name fallbacks
/// with no `ExtensionEntry` at all, so widening the shared predicate would
/// change bare-name JAR candidate preference corpus-wide for callers this
/// rule was never about. `nullable_call_diagnostics`'s `extension_in_scope_here`
/// IS extension-registry-shaped but layers its own stricter member-extension
/// rule on top — a different contract, kept on the original predicate.
pub(crate) fn extension_entry_is_in_scope(
entry: &crate::types::ExtensionEntry,
from_uri: &Url,
caller_file_data: Option<&FileData>,
) -> bool {
if extension_is_in_scope(
entry.package.as_ref(),
&entry.name,
entry.container.as_ref(),
entry.visibility,
entry.file_uri == from_uri.as_str(),
caller_file_data,
) {
return true;
}
let caller_is_kotlin = crate::Language::from_path(from_uri.as_str()) == crate::Language::Kotlin;
let entry_is_default_imported = entry
.package
.as_ref()
.is_some_and(|package| crate::resolver::imports::is_default_import_package(package));
caller_is_kotlin && entry_is_default_imported
Comment on lines +1900 to +1905
}

/// Whether `SymbolEntry` `symbol` is the actual declaration a matched extension
/// candidate (`name` on `receiver_base`, with `entry_container` from its
/// `ExtensionEntry`) refers to. Its own `container` must equal
Expand Down Expand Up @@ -1933,14 +1989,7 @@ fn find_extension_fn_return_type_scoped(
if !matches!(entry.kind, SymbolKind::FUNCTION | SymbolKind::METHOD) {
continue;
}
if !extension_is_in_scope(
entry.package.as_ref(),
&entry.name,
entry.container.as_ref(),
entry.visibility,
entry.file_uri == from_uri.as_str(),
caller_file_data_ref,
) {
if !extension_entry_is_in_scope(entry, from_uri, caller_file_data_ref) {
continue;
}
// Try detail first; fall back to source lines when detail is truncated.
Expand All @@ -1952,22 +2001,34 @@ fn find_extension_fn_return_type_scoped(
// `extension_by_receiver` already had it, which — per the comment
// above `entries` — only happens once Tier-2 materialization has
// already populated `jar_files` for this same jar.
let file_data = indexer
//
// Neither lookup below may abort the whole search with `?`: an entry
// whose declaring file isn't loaded, or whose declaration can't be
// matched, must be skipped so later entries in `entries` still get a
// chance — one unusable entry must not hide every usable one behind
// it in iteration order.
let Some(file_data) = indexer
.files
.get(&entry.file_uri)
.or_else(|| indexer.jar_files.get(&entry.file_uri))?;
let start_line = file_data
.symbols
.iter()
.find(|s| {
extension_declaration_matches(
s,
method_name,
receiver_base,
entry.container.as_ref(),
)
})?
.selection_start() as usize;
.or_else(|| indexer.jar_files.get(&entry.file_uri))
else {
continue;
};
// PR #321's `select_extension_symbol` (see its doc comment in
// `resolver::extension`) prefers the declaration whose full `detail`
// exactly matches this registry entry's own, so a second overload's
// return type is reachable through this fallback too — not just the
// first declaration matching (name, receiver, container).
let Some(declaring_symbol) = crate::resolver::extension::select_extension_symbol(
&file_data,
method_name,
receiver_base,
entry.container.as_ref(),
&entry.detail,
) else {
continue;
};
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);
Expand All @@ -1976,6 +2037,15 @@ fn find_extension_fn_return_type_scoped(
None
}

// Part 0.4: this function has no `ExtensionEntry` and no `detail` to match
// against (it walks `file_data.symbols` directly), so the two-step
// `select_extension_symbol` disambiguation above does not apply here — and it
// doesn't need to, since `Indexer::find_method_return_type_for_type` always
// passes `Some(uri)`, so this fallback is not reachable on a production path.
// Decision D4: giving either function arity-aware overload selection needs a
// `CallShape` threaded through `Resolver::method_return_type`; 482 registry
// groups on the Moneta corpus have differing-return overloads, so that is
// real but is its own plan, not this one.
fn find_extension_fn_return_type_global(
indexer: &Indexer,
receiver_base: &str,
Expand Down
Loading
Loading