diff --git a/crates/ruff_linter/src/preview.rs b/crates/ruff_linter/src/preview.rs index 3cfca8379f749..197fba932eb3e 100644 --- a/crates/ruff_linter/src/preview.rs +++ b/crates/ruff_linter/src/preview.rs @@ -240,3 +240,8 @@ pub(crate) const fn is_a003_class_scope_shadowing_expansion_enabled( pub(crate) const fn is_refined_submodule_import_match_enabled(settings: &LinterSettings) -> bool { settings.preview.is_enabled() } + +// https://github.com/astral-sh/ruff/pull/20020 +pub(crate) const fn is_separate_unused_import_diag_enabled(settings: &LinterSettings) -> bool { + settings.preview.is_enabled() +} diff --git a/crates/ruff_linter/src/rules/pyupgrade/mod.rs b/crates/ruff_linter/src/rules/pyupgrade/mod.rs index bf8e7495f95b0..5fdf1ee83e85b 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/mod.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/mod.rs @@ -126,6 +126,8 @@ mod tests { } #[test_case(Rule::SuperCallWithParameters, Path::new("UP008.py"))] + #[test_case(Rule::UnnecessaryFutureImport, Path::new("UP010_0.py"))] + #[test_case(Rule::UnnecessaryFutureImport, Path::new("UP010_1.py"))] fn rules_preview(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!("{}__preview", path.to_string_lossy()); let diagnostics = test_path( diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_future_import.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_future_import.rs index 62eea80f2b536..65ed407e73527 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_future_import.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_future_import.rs @@ -1,16 +1,15 @@ -use std::collections::{BTreeSet, HashMap}; - use itertools::{Itertools, chain}; -use ruff_python_semantic::NodeId; - use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::name::{QualifiedName, QualifiedNameBuilder}; use ruff_python_ast::{self as ast, Alias, Stmt, StmtRef}; +use ruff_python_semantic::NodeId; use ruff_python_semantic::{NameImport, Scope}; -use ruff_text_size::Ranged; +use ruff_text_size::{Ranged, TextRange}; +use std::collections::{BTreeSet, HashMap}; use crate::checkers::ast::Checker; use crate::fix; +use crate::preview::is_separate_unused_import_diag_enabled; use crate::{AlwaysFixableViolation, Applicability, Fix}; /// ## What it does @@ -35,6 +34,11 @@ use crate::{AlwaysFixableViolation, Applicability, Fix}; /// print("Hello, world!") /// ``` /// +/// ## Preview +/// +/// When [preview] is enabled, this rule underlines each unused import individually +/// instead of grouping them together if there are two or more within an import statement. +/// /// ## Fix safety /// This fix is marked unsafe if applying it would delete a comment. /// @@ -43,6 +47,8 @@ use crate::{AlwaysFixableViolation, Applicability, Fix}; /// /// ## References /// - [Python documentation: `__future__` — Future statement definitions](https://docs.python.org/3/library/__future__.html) +/// +/// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] pub(crate) struct UnnecessaryFutureImport { pub names: Vec, @@ -130,6 +136,7 @@ pub(crate) fn is_import_required_by_isort( /// UP010 pub(crate) fn unnecessary_future_import(checker: &Checker, scope: &Scope) { let mut unused_imports: HashMap> = HashMap::new(); + let mut import_counts: HashMap = HashMap::new(); for future_name in chain(PY33_PLUS_REMOVE_FUTURES, PY37_PLUS_REMOVE_FUTURES).unique() { for binding_id in scope.get_all(future_name) { let binding = checker.semantic().binding(binding_id); @@ -140,6 +147,10 @@ pub(crate) fn unnecessary_future_import(checker: &Checker, scope: &Scope) { let stmt = checker.semantic().statement(node_id); if let Stmt::ImportFrom(ast::StmtImportFrom { names, .. }) = stmt { + import_counts + .entry(node_id) + .and_modify(|c| *c += names.len()) + .or_insert(names.len()); let Some(alias) = names .iter() .find(|alias| alias.name.as_str() == binding.name(checker.source())) @@ -163,46 +174,69 @@ pub(crate) fn unnecessary_future_import(checker: &Checker, scope: &Scope) { } } } - for (node_id, unused_aliases) in unused_imports { - let mut diagnostic = checker.report_diagnostic( - UnnecessaryFutureImport { - names: unused_aliases - .iter() - .map(|alias| alias.name.to_string()) - .sorted() - .collect(), - }, + create_diagnostic( + checker, + unused_aliases.as_slice(), + *import_counts.get(&node_id).unwrap_or(&unused_aliases.len()), checker.semantic().statement(node_id).range(), + node_id, ); - - diagnostic.try_set_fix(|| { - let statement = checker.semantic().statement(node_id); - let parent = checker.semantic().parent_statement(node_id); - let edit = fix::edits::remove_unused_imports( - unused_aliases - .iter() - .map(|alias| &alias.name) - .map(ast::Identifier::as_str), - statement, - parent, - checker.locator(), - checker.stylist(), - checker.indexer(), - )?; - - let range = edit.range(); - let applicability = if checker.comment_ranges().intersects(range) { - Applicability::Unsafe - } else { - Applicability::Safe - }; - - Ok( - Fix::applicable_edit(edit, applicability).isolate(Checker::isolation( - checker.semantic().current_statement_parent_id(), - )), - ) - }); } } +fn create_diagnostic( + checker: &Checker, + unused_aliases: &[&Alias], + import_counts: usize, + range: TextRange, + node_id: NodeId, +) { + let mut diagnostic = checker.report_diagnostic( + UnnecessaryFutureImport { + names: unused_aliases + .iter() + .map(|alias| alias.name.to_string()) + .sorted() + .collect(), + }, + range, + ); + + if is_separate_unused_import_diag_enabled(checker.settings()) && import_counts > 1 { + for unused_alias in unused_aliases { + diagnostic.secondary_annotation( + format!("Unused import `{}`", unused_alias.name), + unused_alias.range(), + ); + } + } + + diagnostic.try_set_fix(|| { + let statement = checker.semantic().statement(node_id); + let parent = checker.semantic().parent_statement(node_id); + let edit = fix::edits::remove_unused_imports( + unused_aliases + .iter() + .map(|alias| &alias.name) + .map(ast::Identifier::as_str), + statement, + parent, + checker.locator(), + checker.stylist(), + checker.indexer(), + )?; + + let range = edit.range(); + let applicability = if checker.comment_ranges().intersects(range) { + Applicability::Unsafe + } else { + Applicability::Safe + }; + + Ok( + Fix::applicable_edit(edit, applicability).isolate(Checker::isolation( + checker.semantic().current_statement_parent_id(), + )), + ) + }); +} diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP010_0.py__preview.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP010_0.py__preview.snap new file mode 100644 index 0000000000000..28a5ab36d6a0f --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP010_0.py__preview.snap @@ -0,0 +1,208 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +--- +UP010 [*] Unnecessary `__future__` imports `generators`, `nested_scopes` for target Python version + --> UP010_0.py:1:1 + | +1 | from __future__ import nested_scopes, generators + | ^^^^^^^^^^^^^^^^^^^^^^^-------------^^---------- + | | | + | | Unused import `generators` + | Unused import `nested_scopes` +2 | from __future__ import with_statement, unicode_literals +3 | from __future__ import absolute_import, division + | +help: Remove unnecessary `__future__` import + - from __future__ import nested_scopes, generators +1 | from __future__ import with_statement, unicode_literals +2 | from __future__ import absolute_import, division +3 | from __future__ import generator_stop + +UP010 [*] Unnecessary `__future__` imports `unicode_literals`, `with_statement` for target Python version + --> UP010_0.py:2:1 + | +1 | from __future__ import nested_scopes, generators +2 | from __future__ import with_statement, unicode_literals + | ^^^^^^^^^^^^^^^^^^^^^^^--------------^^---------------- + | | | + | | Unused import `unicode_literals` + | Unused import `with_statement` +3 | from __future__ import absolute_import, division +4 | from __future__ import generator_stop + | +help: Remove unnecessary `__future__` import +1 | from __future__ import nested_scopes, generators + - from __future__ import with_statement, unicode_literals +2 | from __future__ import absolute_import, division +3 | from __future__ import generator_stop +4 | from __future__ import print_function, generator_stop + +UP010 [*] Unnecessary `__future__` imports `absolute_import`, `division` for target Python version + --> UP010_0.py:3:1 + | +1 | from __future__ import nested_scopes, generators +2 | from __future__ import with_statement, unicode_literals +3 | from __future__ import absolute_import, division + | ^^^^^^^^^^^^^^^^^^^^^^^---------------^^-------- + | | | + | | Unused import `division` + | Unused import `absolute_import` +4 | from __future__ import generator_stop +5 | from __future__ import print_function, generator_stop + | +help: Remove unnecessary `__future__` import +1 | from __future__ import nested_scopes, generators +2 | from __future__ import with_statement, unicode_literals + - from __future__ import absolute_import, division +3 | from __future__ import generator_stop +4 | from __future__ import print_function, generator_stop +5 | from __future__ import invalid_module, generators + +UP010 [*] Unnecessary `__future__` import `generator_stop` for target Python version + --> UP010_0.py:4:1 + | +2 | from __future__ import with_statement, unicode_literals +3 | from __future__ import absolute_import, division +4 | from __future__ import generator_stop + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +5 | from __future__ import print_function, generator_stop +6 | from __future__ import invalid_module, generators + | +help: Remove unnecessary `__future__` import +1 | from __future__ import nested_scopes, generators +2 | from __future__ import with_statement, unicode_literals +3 | from __future__ import absolute_import, division + - from __future__ import generator_stop +4 | from __future__ import print_function, generator_stop +5 | from __future__ import invalid_module, generators +6 | + +UP010 [*] Unnecessary `__future__` imports `generator_stop`, `print_function` for target Python version + --> UP010_0.py:5:1 + | +3 | from __future__ import absolute_import, division +4 | from __future__ import generator_stop +5 | from __future__ import print_function, generator_stop + | ^^^^^^^^^^^^^^^^^^^^^^^--------------^^-------------- + | | | + | | Unused import `generator_stop` + | Unused import `print_function` +6 | from __future__ import invalid_module, generators + | +help: Remove unnecessary `__future__` import +2 | from __future__ import with_statement, unicode_literals +3 | from __future__ import absolute_import, division +4 | from __future__ import generator_stop + - from __future__ import print_function, generator_stop +5 | from __future__ import invalid_module, generators +6 | +7 | if True: + +UP010 [*] Unnecessary `__future__` import `generators` for target Python version + --> UP010_0.py:6:1 + | +4 | from __future__ import generator_stop +5 | from __future__ import print_function, generator_stop +6 | from __future__ import invalid_module, generators + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^---------- + | | + | Unused import `generators` +7 | +8 | if True: + | +help: Remove unnecessary `__future__` import +3 | from __future__ import absolute_import, division +4 | from __future__ import generator_stop +5 | from __future__ import print_function, generator_stop + - from __future__ import invalid_module, generators +6 + from __future__ import invalid_module +7 | +8 | if True: +9 | from __future__ import generator_stop + +UP010 [*] Unnecessary `__future__` import `generator_stop` for target Python version + --> UP010_0.py:9:5 + | + 8 | if True: + 9 | from __future__ import generator_stop + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +10 | from __future__ import generators + | +help: Remove unnecessary `__future__` import +6 | from __future__ import invalid_module, generators +7 | +8 | if True: + - from __future__ import generator_stop +9 | from __future__ import generators +10 | +11 | if True: + +UP010 [*] Unnecessary `__future__` import `generators` for target Python version + --> UP010_0.py:10:5 + | + 8 | if True: + 9 | from __future__ import generator_stop +10 | from __future__ import generators + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +11 | +12 | if True: + | +help: Remove unnecessary `__future__` import +7 | +8 | if True: +9 | from __future__ import generator_stop + - from __future__ import generators +10 | +11 | if True: +12 | from __future__ import generator_stop + +UP010 [*] Unnecessary `__future__` import `generator_stop` for target Python version + --> UP010_0.py:13:5 + | +12 | if True: +13 | from __future__ import generator_stop + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +14 | from __future__ import invalid_module, generators +15 | from __future__ import generators # comment + | +help: Remove unnecessary `__future__` import +10 | from __future__ import generators +11 | +12 | if True: + - from __future__ import generator_stop +13 | from __future__ import invalid_module, generators +14 | from __future__ import generators # comment + +UP010 [*] Unnecessary `__future__` import `generators` for target Python version + --> UP010_0.py:14:5 + | +12 | if True: +13 | from __future__ import generator_stop +14 | from __future__ import invalid_module, generators + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^---------- + | | + | Unused import `generators` +15 | from __future__ import generators # comment + | +help: Remove unnecessary `__future__` import +11 | +12 | if True: +13 | from __future__ import generator_stop + - from __future__ import invalid_module, generators +14 + from __future__ import invalid_module +15 | from __future__ import generators # comment + +UP010 [*] Unnecessary `__future__` import `generators` for target Python version + --> UP010_0.py:15:5 + | +13 | from __future__ import generator_stop +14 | from __future__ import invalid_module, generators +15 | from __future__ import generators # comment + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Remove unnecessary `__future__` import +12 | if True: +13 | from __future__ import generator_stop +14 | from __future__ import invalid_module, generators + - from __future__ import generators # comment +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP010_1.py__preview.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP010_1.py__preview.snap new file mode 100644 index 0000000000000..4c8ed0b1e1a2e --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP010_1.py__preview.snap @@ -0,0 +1,98 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +--- +UP010 [*] Unnecessary `__future__` imports `generators`, `nested_scopes` for target Python version + --> UP010_1.py:1:1 + | +1 | from __future__ import nested_scopes, generators + | ^^^^^^^^^^^^^^^^^^^^^^^-------------^^---------- + | | | + | | Unused import `generators` + | Unused import `nested_scopes` +2 | from __future__ import with_statement, unicode_literals + | +help: Remove unnecessary `__future__` import + - from __future__ import nested_scopes, generators +1 | from __future__ import with_statement, unicode_literals +2 | +3 | from __future__ import absolute_import, division + +UP010 [*] Unnecessary `__future__` import `unicode_literals` for target Python version + --> UP010_1.py:2:1 + | +1 | from __future__ import nested_scopes, generators +2 | from __future__ import with_statement, unicode_literals + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^---------------- + | | + | Unused import `unicode_literals` +3 | +4 | from __future__ import absolute_import, division + | +help: Remove unnecessary `__future__` import +1 | from __future__ import nested_scopes, generators + - from __future__ import with_statement, unicode_literals +2 + from __future__ import with_statement +3 | +4 | from __future__ import absolute_import, division +5 | from __future__ import generator_stop + +UP010 [*] Unnecessary `__future__` import `absolute_import` for target Python version + --> UP010_1.py:4:1 + | +2 | from __future__ import with_statement, unicode_literals +3 | +4 | from __future__ import absolute_import, division + | ^^^^^^^^^^^^^^^^^^^^^^^---------------^^^^^^^^^^ + | | + | Unused import `absolute_import` +5 | from __future__ import generator_stop +6 | from __future__ import print_function, nested_scopes, generator_stop + | +help: Remove unnecessary `__future__` import +1 | from __future__ import nested_scopes, generators +2 | from __future__ import with_statement, unicode_literals +3 | + - from __future__ import absolute_import, division +4 + from __future__ import division +5 | from __future__ import generator_stop +6 | from __future__ import print_function, nested_scopes, generator_stop +7 | + +UP010 [*] Unnecessary `__future__` import `generator_stop` for target Python version + --> UP010_1.py:5:1 + | +4 | from __future__ import absolute_import, division +5 | from __future__ import generator_stop + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +6 | from __future__ import print_function, nested_scopes, generator_stop + | +help: Remove unnecessary `__future__` import +2 | from __future__ import with_statement, unicode_literals +3 | +4 | from __future__ import absolute_import, division + - from __future__ import generator_stop +5 | from __future__ import print_function, nested_scopes, generator_stop +6 | +7 | print(with_statement) + +UP010 [*] Unnecessary `__future__` import `nested_scopes` for target Python version + --> UP010_1.py:6:1 + | +4 | from __future__ import absolute_import, division +5 | from __future__ import generator_stop +6 | from __future__ import print_function, nested_scopes, generator_stop + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-------------^^^^^^^^^^^^^^^^ + | | + | Unused import `nested_scopes` +7 | +8 | print(with_statement) + | +help: Remove unnecessary `__future__` import +3 | +4 | from __future__ import absolute_import, division +5 | from __future__ import generator_stop + - from __future__ import print_function, nested_scopes, generator_stop +6 + from __future__ import print_function, generator_stop +7 | +8 | print(with_statement) +9 | generators = 1