diff --git a/crates/ruff_diagnostics/src/edit.rs b/crates/ruff_diagnostics/src/edit.rs index 8c05474d21d392..5bd4e629b4c956 100644 --- a/crates/ruff_diagnostics/src/edit.rs +++ b/crates/ruff_diagnostics/src/edit.rs @@ -7,7 +7,7 @@ use ruff_text_size::{Ranged, TextRange, TextSize}; /// A text edit to be applied to a source file. Inserts, deletes, or replaces /// content at a given location. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct Edit { /// The start location of the edit. diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote.py b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote.py index c7540c3ecce1d6..de3d609d074448 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote.py @@ -72,3 +72,18 @@ def f(): def baz() -> DataFrame | Series: ... + + +def f(): + from pandas import DataFrame, Series + + def baz() -> ( + DataFrame | + Series + ): + ... + + class C: + x: DataFrame[ + int + ] = 1 diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs b/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs index 1fc4ade6fda9a7..2eaab9dd27788b 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs @@ -5,7 +5,7 @@ use ruff_diagnostics::Edit; use ruff_python_ast::call_path::from_qualified_name; use ruff_python_ast::helpers::{map_callable, map_subscript}; use ruff_python_ast::{self as ast, Expr}; -use ruff_python_codegen::Stylist; +use ruff_python_codegen::{Generator, Stylist}; use ruff_python_semantic::{ Binding, BindingId, BindingKind, NodeId, ResolvedReference, SemanticModel, }; @@ -215,6 +215,7 @@ pub(crate) fn quote_annotation( semantic: &SemanticModel, locator: &Locator, stylist: &Stylist, + generator: Generator, ) -> Result { let expr = semantic.expression(node_id).expect("Expression not found"); if let Some(parent_id) = semantic.parent_expression_id(node_id) { @@ -224,7 +225,7 @@ pub(crate) fn quote_annotation( // If we're quoting the value of a subscript, we need to quote the entire // expression. For example, when quoting `DataFrame` in `DataFrame[int]`, we // should generate `"DataFrame[int]"`. - return quote_annotation(parent_id, semantic, locator, stylist); + return quote_annotation(parent_id, semantic, locator, stylist, generator); } } Some(Expr::Attribute(parent)) => { @@ -232,7 +233,7 @@ pub(crate) fn quote_annotation( // If we're quoting the value of an attribute, we need to quote the entire // expression. For example, when quoting `DataFrame` in `pd.DataFrame`, we // should generate `"pd.DataFrame"`. - return quote_annotation(parent_id, semantic, locator, stylist); + return quote_annotation(parent_id, semantic, locator, stylist, generator); } } Some(Expr::Call(parent)) => { @@ -240,7 +241,7 @@ pub(crate) fn quote_annotation( // If we're quoting the function of a call, we need to quote the entire // expression. For example, when quoting `DataFrame` in `DataFrame()`, we // should generate `"DataFrame()"`. - return quote_annotation(parent_id, semantic, locator, stylist); + return quote_annotation(parent_id, semantic, locator, stylist, generator); } } Some(Expr::BinOp(parent)) => { @@ -248,27 +249,30 @@ pub(crate) fn quote_annotation( // If we're quoting the left or right side of a binary operation, we need to // quote the entire expression. For example, when quoting `DataFrame` in // `DataFrame | Series`, we should generate `"DataFrame | Series"`. - return quote_annotation(parent_id, semantic, locator, stylist); + return quote_annotation(parent_id, semantic, locator, stylist, generator); } } _ => {} } } - let annotation = locator.slice(expr); - // If the annotation already contains a quote, avoid attempting to re-quote it. For example: // ```python // from typing import Literal // // Set[Literal["Foo"]] // ``` - if annotation.contains('\'') || annotation.contains('"') { + let text = locator.slice(expr); + if text.contains('\'') || text.contains('"') { return Err(anyhow::anyhow!("Annotation already contains a quote")); } - // If we're quoting a name, we need to quote the entire expression. + // Quote the entire expression. let quote = stylist.quote(); - let annotation = format!("{quote}{annotation}{quote}"); - Ok(Edit::range_replacement(annotation, expr.range())) + let annotation = generator.expr(expr); + + Ok(Edit::range_replacement( + format!("{quote}{annotation}{quote}"), + expr.range(), + )) } diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs index 377b4ca7effa2b..7f43ebbfd548da 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs @@ -274,6 +274,7 @@ fn quote_imports(checker: &Checker, node_id: NodeId, imports: &[ImportBinding]) checker.semantic(), checker.locator(), checker.stylist(), + checker.generator(), )) } else { None @@ -282,7 +283,7 @@ fn quote_imports(checker: &Checker, node_id: NodeId, imports: &[ImportBinding]) }) .collect::>>()?; - let mut rest = quote_reference_edits.into_iter().dedup(); + let mut rest = quote_reference_edits.into_iter().unique(); let head = rest.next().expect("Expected at least one reference"); Ok(Fix::unsafe_edits(head, rest).isolate(Checker::isolation( checker.semantic().parent_statement_id(node_id), diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs index e868418fec4ec1..2ad9753362d11c 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs @@ -494,6 +494,7 @@ fn fix_imports(checker: &Checker, node_id: NodeId, imports: &[ImportBinding]) -> checker.semantic(), checker.locator(), checker.stylist(), + checker.generator(), )) } else { None @@ -507,7 +508,7 @@ fn fix_imports(checker: &Checker, node_id: NodeId, imports: &[ImportBinding]) -> add_import_edit .into_edits() .into_iter() - .chain(quote_reference_edits.into_iter().dedup()), + .chain(quote_reference_edits.into_iter().unique()), ) .isolate(Checker::isolation( checker.semantic().parent_statement_id(node_id), diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote.py.snap index eb208bedce285c..3a4417a661d873 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote.py.snap +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote.py.snap @@ -223,6 +223,8 @@ quote.py:71:24: TCH002 [*] Move third-party import `pandas.DataFrame` into a typ 73 |- def baz() -> DataFrame | Series: 76 |+ def baz() -> "DataFrame | Series": 74 77 | ... +75 78 | +76 79 | quote.py:71:35: TCH002 [*] Move third-party import `pandas.Series` into a type-checking block | @@ -251,5 +253,81 @@ quote.py:71:35: TCH002 [*] Move third-party import `pandas.Series` into a type-c 73 |- def baz() -> DataFrame | Series: 76 |+ def baz() -> "DataFrame | Series": 74 77 | ... +75 78 | +76 79 | + +quote.py:78:24: TCH002 [*] Move third-party import `pandas.DataFrame` into a type-checking block + | +77 | def f(): +78 | from pandas import DataFrame, Series + | ^^^^^^^^^ TCH002 +79 | +80 | def baz() -> ( + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from pandas import DataFrame, Series +1 5 | def f(): +2 6 | from pandas import DataFrame +3 7 | +-------------------------------------------------------------------------------- +75 79 | +76 80 | +77 81 | def f(): +78 |- from pandas import DataFrame, Series +79 82 | +80 83 | def baz() -> ( +81 |- DataFrame | +82 |- Series + 84 |+ "DataFrame | Series" +83 85 | ): +84 86 | ... +85 87 | +86 88 | class C: +87 |- x: DataFrame[ +88 |- int +89 |- ] = 1 + 89 |+ x: "DataFrame[int]" = 1 + +quote.py:78:35: TCH002 [*] Move third-party import `pandas.Series` into a type-checking block + | +77 | def f(): +78 | from pandas import DataFrame, Series + | ^^^^^^ TCH002 +79 | +80 | def baz() -> ( + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from pandas import DataFrame, Series +1 5 | def f(): +2 6 | from pandas import DataFrame +3 7 | +-------------------------------------------------------------------------------- +75 79 | +76 80 | +77 81 | def f(): +78 |- from pandas import DataFrame, Series +79 82 | +80 83 | def baz() -> ( +81 |- DataFrame | +82 |- Series + 84 |+ "DataFrame | Series" +83 85 | ): +84 86 | ... +85 87 | +86 88 | class C: +87 |- x: DataFrame[ +88 |- int +89 |- ] = 1 + 89 |+ x: "DataFrame[int]" = 1