Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 65 additions & 34 deletions pyrefly/lib/state/lsp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2470,6 +2470,8 @@ impl<'a> Transaction<'a> {
&& let Some(ast) = self.get_ast(handle)
&& let Some(module_info) = self.get_module_info(handle)
{
let mut autoimport_candidates = Vec::new();
let mut names_with_public = OrderedSet::new();
for (handle_to_import_from, name, export) in
self.search_exports_fuzzy(identifier.as_str())
{
Expand All @@ -2486,7 +2488,7 @@ impl<'a> Transaction<'a> {
&ast,
self.config_finder(),
handle.dupe(),
handle_to_import_from,
handle_to_import_from.clone(),
&name,
import_format,
);
Expand All @@ -2498,29 +2500,47 @@ impl<'a> Transaction<'a> {
};
let auto_import_label_detail = format!(" (import {imported_module})");

completions.push(CompletionItem {
label: name,
detail: Some(insert_text),
kind: export
.symbol_kind
.map_or(Some(CompletionItemKind::VARIABLE), |k| {
Some(k.to_lsp_completion_item_kind())
}),
additional_text_edits,
label_details: supports_completion_item_details.then_some(
CompletionItemLabelDetails {
detail: Some(auto_import_label_detail),
description: Some(module_description),
let is_private_import = handle_to_import_from
.module()
.components()
.last()
.is_some_and(|component| component.as_str().starts_with('_'));
if !is_private_import {
names_with_public.insert(name.clone());
}
autoimport_candidates.push((
CompletionItem {
label: name,
detail: Some(insert_text),
kind: export
.symbol_kind
.map_or(Some(CompletionItemKind::VARIABLE), |k| {
Some(k.to_lsp_completion_item_kind())
}),
additional_text_edits,
label_details: supports_completion_item_details.then_some(
CompletionItemLabelDetails {
detail: Some(auto_import_label_detail),
description: Some(module_description),
},
),
tags: if export.deprecation.is_some() {
Some(vec![CompletionItemTag::DEPRECATED])
} else {
None
},
),
tags: if export.deprecation.is_some() {
Some(vec![CompletionItemTag::DEPRECATED])
} else {
None
sort_text: Some(format!("4{}", depth)),
..Default::default()
},
sort_text: Some(format!("4{}", depth)),
..Default::default()
});
is_private_import,
));
}

for (mut item, is_private_import) in autoimport_candidates {
if is_private_import && names_with_public.contains(&item.label) {
item.sort_text = Some("b".to_owned());
}
completions.push(item);
}

for module_name in self.search_modules_fuzzy(identifier.as_str()) {
Expand Down Expand Up @@ -2995,20 +3015,23 @@ impl<'a> Transaction<'a> {
.as_ref()
.is_some_and(|tags| tags.contains(&CompletionItemTag::DEPRECATED))
{
"9"
"9".to_owned()
} else if item.additional_text_edits.is_some() {
"4"
if let Some(sort_text) = &item.sort_text {
format!("4{sort_text}")
} else {
"4".to_owned()
}
} else if item.label.starts_with("__") {
"3"
"3".to_owned()
} else if item.label.as_str().starts_with("_") {
"2"
"2".to_owned()
} else if let Some(sort_text) = &item.sort_text {
// 1 is reserved for re-exports
sort_text.as_str()
sort_text.clone()
} else {
"0"
}
.to_owned();
"0".to_owned()
};
item.sort_text = Some(sort_text);
}
(result, is_incomplete)
Expand All @@ -3035,12 +3058,14 @@ impl<'a> Transaction<'a> {
/// - Handles stdlib patterns where a public module (`io`) re-exports from a
/// private implementation module (`_io`).
fn should_include_reexport(original: &Handle, canonical: &Handle) -> bool {
let canonical_components = canonical.module().components();
let canonical_module = canonical.module();
let original_module = original.module();
let canonical_components = canonical_module.components();
let canonical_component = canonical_components
.last()
.map(|name| name.as_str())
.unwrap_or("");
let original_components = original.module().components();
let original_components = original_module.components();
let original_component = original_components
.last()
.map(|name| name.as_str())
Expand All @@ -3058,9 +3083,15 @@ impl<'a> Transaction<'a> {
.iter()
.zip(original_components.iter())
.all(|(c, o)| c == o)
} else {
false
}
// Some stdlib shims encode dotted modules with underscores (e.g. _collections_abc).
if canonical_module.as_str().starts_with('_') && original_module.as_str().contains('.') {
let canonical_trim = canonical_module.as_str().trim_start_matches('_');
if canonical_trim == original_module.as_str().replace('.', "_") {
return true;
}
}
Comment on lines +3087 to +3093
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for matching dotted-name private shims may produce false positives. It checks if the canonical module starts with '_' and the original contains '.', then compares by replacing dots with underscores. However, this could incorrectly match unrelated modules. For example, _foo_bar would match both foo.bar and foo_bar (a non-dotted module).

Consider adding more stringent checks, such as verifying that the canonical module is a top-level module (no dots in canonical_module), or using a more precise pattern matching approach to avoid false positives.

Copilot uses AI. Check for mistakes.
false
}

pub fn search_exports_exact(&self, name: &str) -> Vec<(Handle, Export)> {
Expand Down
35 changes: 35 additions & 0 deletions pyrefly/lib/test/lsp/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1809,6 +1809,41 @@ Completion Results:
);
}

#[test]
fn autoimport_prefers_public_reexport_for_dotted_private_module() {
let code = r#"
T = Thing
# ^
"#;
let report = get_batched_lsp_operations_report_allow_error(
&[
("main", code),
("_foo_bar", "Thing = 1\n"),
("foo.bar", "from _foo_bar import Thing\n"),
],
get_test_report(Default::default(), ImportFormat::Absolute),
);
assert_eq!(
r#"
# main.py
2 | T = Thing
^
Completion Results:
- (Variable) Thing: from foo.bar import Thing

- (Variable) Thing: from _foo_bar import Thing



# _foo_bar.py

# foo.bar.py
"#
.trim(),
report.trim(),
);
}
Comment on lines +1812 to +1845
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test validates the general pattern of preferring public re-exports over private modules for dotted module names (e.g., foo.bar vs _foo_bar), but it doesn't test the actual use case mentioned in issue #1685: collections.abc.Iterable vs _collections_abc.Iterable. Consider adding a test case that directly validates the stdlib pattern mentioned in the issue to ensure the fix works for the actual reported problem.

Copilot uses AI. Check for mistakes.

#[test]
fn autoimport_completions_set_label_details() {
let code = r#"
Expand Down
Loading