diff --git a/_typos.toml b/_typos.toml index c4ff9f6151..11c3cad523 100644 --- a/_typos.toml +++ b/_typos.toml @@ -4,6 +4,7 @@ extend-exclude = [ "crates/ty_vendored/vendor/**/*", "**/resources/**/*", "**/snapshots/**/*", + "crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/collection_literal.rs", # Completion tests tend to have a lot of incomplete # words naturally. It's annoying to have to make all # of them actually words. So just ignore typos here. diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_implicit_str_concat/ISC004.py b/crates/ruff_linter/resources/test/fixtures/flake8_implicit_str_concat/ISC004.py new file mode 100644 index 0000000000..974667544a --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_implicit_str_concat/ISC004.py @@ -0,0 +1,66 @@ +facts = ( + "Lobsters have blue blood.", + "The liver is the only human organ that can fully regenerate itself.", + "Clarinets are made almost entirely out of wood from the mpingo tree." + "In 1971, astronaut Alan Shepard played golf on the moon.", +) + +facts = [ + "Lobsters have blue blood.", + "The liver is the only human organ that can fully regenerate itself.", + "Clarinets are made almost entirely out of wood from the mpingo tree." + "In 1971, astronaut Alan Shepard played golf on the moon.", +] + +facts = { + "Lobsters have blue blood.", + "The liver is the only human organ that can fully regenerate itself.", + "Clarinets are made almost entirely out of wood from the mpingo tree." + "In 1971, astronaut Alan Shepard played golf on the moon.", +} + +facts = { + ( + "Clarinets are made almost entirely out of wood from the mpingo tree." + "In 1971, astronaut Alan Shepard played golf on the moon." + ), +} + +facts = ( + "Octopuses have three hearts." + # Missing comma here. + "Honey never spoils.", +) + +facts = [ + "Octopuses have three hearts." + # Missing comma here. + "Honey never spoils.", +] + +facts = { + "Octopuses have three hearts." + # Missing comma here. + "Honey never spoils.", +} + +facts = ( + ( + "Clarinets are made almost entirely out of wood from the mpingo tree." + "In 1971, astronaut Alan Shepard played golf on the moon." + ), +) + +facts = [ + ( + "Clarinets are made almost entirely out of wood from the mpingo tree." + "In 1971, astronaut Alan Shepard played golf on the moon." + ), +] + +facts = ( + "Lobsters have blue blood.\n" + "The liver is the only human organ that can fully regenerate itself.\n" + "Clarinets are made almost entirely out of wood from the mpingo tree.\n" + "In 1971, astronaut Alan Shepard played golf on the moon.\n" +) diff --git a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs index 53081e3681..0957aee346 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs @@ -214,6 +214,13 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) { range: _, node_index: _, }) => { + if checker.is_rule_enabled(Rule::ImplicitStringConcatenationInCollectionLiteral) { + flake8_implicit_str_concat::rules::implicit_string_concatenation_in_collection_literal( + checker, + expr, + elts, + ); + } if ctx.is_store() { let check_too_many_expressions = checker.is_rule_enabled(Rule::ExpressionsInStarAssignment); @@ -1329,6 +1336,13 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) { } } Expr::Set(set) => { + if checker.is_rule_enabled(Rule::ImplicitStringConcatenationInCollectionLiteral) { + flake8_implicit_str_concat::rules::implicit_string_concatenation_in_collection_literal( + checker, + expr, + &set.elts, + ); + } if checker.is_rule_enabled(Rule::DuplicateValue) { flake8_bugbear::rules::duplicate_value(checker, set); } diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index ebec5f4acc..b005c6e914 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -454,6 +454,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8ImplicitStrConcat, "001") => rules::flake8_implicit_str_concat::rules::SingleLineImplicitStringConcatenation, (Flake8ImplicitStrConcat, "002") => rules::flake8_implicit_str_concat::rules::MultiLineImplicitStringConcatenation, (Flake8ImplicitStrConcat, "003") => rules::flake8_implicit_str_concat::rules::ExplicitStringConcatenation, + (Flake8ImplicitStrConcat, "004") => rules::flake8_implicit_str_concat::rules::ImplicitStringConcatenationInCollectionLiteral, // flake8-print (Flake8Print, "1") => rules::flake8_print::rules::Print, diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs index f02a049c5a..086ba8ce4c 100644 --- a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs @@ -32,6 +32,10 @@ mod tests { Path::new("ISC_syntax_error_2.py") )] #[test_case(Rule::ExplicitStringConcatenation, Path::new("ISC.py"))] + #[test_case( + Rule::ImplicitStringConcatenationInCollectionLiteral, + Path::new("ISC004.py") + )] fn rules(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); let diagnostics = test_path( diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/collection_literal.rs b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/collection_literal.rs new file mode 100644 index 0000000000..b82072dcac --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/collection_literal.rs @@ -0,0 +1,103 @@ +use ruff_macros::{ViolationMetadata, derive_message_formats}; +use ruff_python_ast::token::parenthesized_range; +use ruff_python_ast::{Expr, StringLike}; +use ruff_text_size::Ranged; + +use crate::checkers::ast::Checker; +use crate::{Edit, Fix, FixAvailability, Violation}; + +/// ## What it does +/// Checks for implicitly concatenated strings inside list, tuple, and set literals. +/// +/// ## Why is this bad? +/// In collection literals, implicit string concatenation is often the result of +/// a missing comma between elements, which can silently merge items together. +/// +/// ## Example +/// ```python +/// facts = ( +/// "Lobsters have blue blood.", +/// "The liver is the only human organ that can fully regenerate itself.", +/// "Clarinets are made almost entirely out of wood from the mpingo tree." +/// "In 1971, astronaut Alan Shepard played golf on the moon.", +/// ) +/// ``` +/// +/// Instead, you likely intended: +/// ```python +/// facts = ( +/// "Lobsters have blue blood.", +/// "The liver is the only human organ that can fully regenerate itself.", +/// "Clarinets are made almost entirely out of wood from the mpingo tree.", +/// "In 1971, astronaut Alan Shepard played golf on the moon.", +/// ) +/// ``` +/// +/// If the concatenation is intentional, wrap it in parentheses to make it +/// explicit: +/// ```python +/// facts = ( +/// "Lobsters have blue blood.", +/// "The liver is the only human organ that can fully regenerate itself.", +/// ( +/// "Clarinets are made almost entirely out of wood from the mpingo tree." +/// "In 1971, astronaut Alan Shepard played golf on the moon." +/// ), +/// ) +/// ``` +/// +/// ## Fix safety +/// The fix is safe in that it does not change the semantics of your code. +/// However, the issue is that you may often want to change semantics +/// by adding a missing comma. +#[derive(ViolationMetadata)] +#[violation_metadata(preview_since = "0.14.10")] +pub(crate) struct ImplicitStringConcatenationInCollectionLiteral; + +impl Violation for ImplicitStringConcatenationInCollectionLiteral { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Always; + + #[derive_message_formats] + fn message(&self) -> String { + "Unparenthesized implicit string concatenation in collection".to_string() + } + + fn fix_title(&self) -> Option { + Some("Wrap implicitly concatenated strings in parentheses".to_string()) + } +} + +/// ISC004 +pub(crate) fn implicit_string_concatenation_in_collection_literal( + checker: &Checker, + expr: &Expr, + elements: &[Expr], +) { + for element in elements { + let Ok(string_like) = StringLike::try_from(element) else { + continue; + }; + if !string_like.is_implicit_concatenated() { + continue; + } + if parenthesized_range( + string_like.as_expression_ref(), + expr.into(), + checker.tokens(), + ) + .is_some() + { + continue; + } + + let mut diagnostic = checker.report_diagnostic( + ImplicitStringConcatenationInCollectionLiteral, + string_like.range(), + ); + diagnostic.help("Did you forget a comma?"); + diagnostic.set_fix(Fix::unsafe_edits( + Edit::insertion("(".to_string(), string_like.range().start()), + [Edit::insertion(")".to_string(), string_like.range().end())], + )); + } +} diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/mod.rs b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/mod.rs index 8ec813567d..bbcc443a24 100644 --- a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/mod.rs @@ -1,5 +1,7 @@ +pub(crate) use collection_literal::*; pub(crate) use explicit::*; pub(crate) use implicit::*; +mod collection_literal; mod explicit; mod implicit; diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC004_ISC004.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC004_ISC004.py.snap new file mode 100644 index 0000000000..e2b954b79f --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC004_ISC004.py.snap @@ -0,0 +1,149 @@ +--- +source: crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs +--- +ISC004 [*] Unparenthesized implicit string concatenation in collection + --> ISC004.py:4:5 + | +2 | "Lobsters have blue blood.", +3 | "The liver is the only human organ that can fully regenerate itself.", +4 | / "Clarinets are made almost entirely out of wood from the mpingo tree." +5 | | "In 1971, astronaut Alan Shepard played golf on the moon.", + | |______________________________________________________________^ +6 | ) + | +help: Wrap implicitly concatenated strings in parentheses +help: Did you forget a comma? +1 | facts = ( +2 | "Lobsters have blue blood.", +3 | "The liver is the only human organ that can fully regenerate itself.", + - "Clarinets are made almost entirely out of wood from the mpingo tree." + - "In 1971, astronaut Alan Shepard played golf on the moon.", +4 + ("Clarinets are made almost entirely out of wood from the mpingo tree." +5 + "In 1971, astronaut Alan Shepard played golf on the moon."), +6 | ) +7 | +8 | facts = [ +note: This is an unsafe fix and may change runtime behavior + +ISC004 [*] Unparenthesized implicit string concatenation in collection + --> ISC004.py:11:5 + | + 9 | "Lobsters have blue blood.", +10 | "The liver is the only human organ that can fully regenerate itself.", +11 | / "Clarinets are made almost entirely out of wood from the mpingo tree." +12 | | "In 1971, astronaut Alan Shepard played golf on the moon.", + | |______________________________________________________________^ +13 | ] + | +help: Wrap implicitly concatenated strings in parentheses +help: Did you forget a comma? +8 | facts = [ +9 | "Lobsters have blue blood.", +10 | "The liver is the only human organ that can fully regenerate itself.", + - "Clarinets are made almost entirely out of wood from the mpingo tree." + - "In 1971, astronaut Alan Shepard played golf on the moon.", +11 + ("Clarinets are made almost entirely out of wood from the mpingo tree." +12 + "In 1971, astronaut Alan Shepard played golf on the moon."), +13 | ] +14 | +15 | facts = { +note: This is an unsafe fix and may change runtime behavior + +ISC004 [*] Unparenthesized implicit string concatenation in collection + --> ISC004.py:18:5 + | +16 | "Lobsters have blue blood.", +17 | "The liver is the only human organ that can fully regenerate itself.", +18 | / "Clarinets are made almost entirely out of wood from the mpingo tree." +19 | | "In 1971, astronaut Alan Shepard played golf on the moon.", + | |______________________________________________________________^ +20 | } + | +help: Wrap implicitly concatenated strings in parentheses +help: Did you forget a comma? +15 | facts = { +16 | "Lobsters have blue blood.", +17 | "The liver is the only human organ that can fully regenerate itself.", + - "Clarinets are made almost entirely out of wood from the mpingo tree." + - "In 1971, astronaut Alan Shepard played golf on the moon.", +18 + ("Clarinets are made almost entirely out of wood from the mpingo tree." +19 + "In 1971, astronaut Alan Shepard played golf on the moon."), +20 | } +21 | +22 | facts = { +note: This is an unsafe fix and may change runtime behavior + +ISC004 [*] Unparenthesized implicit string concatenation in collection + --> ISC004.py:30:5 + | +29 | facts = ( +30 | / "Octopuses have three hearts." +31 | | # Missing comma here. +32 | | "Honey never spoils.", + | |_________________________^ +33 | ) + | +help: Wrap implicitly concatenated strings in parentheses +help: Did you forget a comma? +27 | } +28 | +29 | facts = ( + - "Octopuses have three hearts." +30 + ("Octopuses have three hearts." +31 | # Missing comma here. + - "Honey never spoils.", +32 + "Honey never spoils."), +33 | ) +34 | +35 | facts = [ +note: This is an unsafe fix and may change runtime behavior + +ISC004 [*] Unparenthesized implicit string concatenation in collection + --> ISC004.py:36:5 + | +35 | facts = [ +36 | / "Octopuses have three hearts." +37 | | # Missing comma here. +38 | | "Honey never spoils.", + | |_________________________^ +39 | ] + | +help: Wrap implicitly concatenated strings in parentheses +help: Did you forget a comma? +33 | ) +34 | +35 | facts = [ + - "Octopuses have three hearts." +36 + ("Octopuses have three hearts." +37 | # Missing comma here. + - "Honey never spoils.", +38 + "Honey never spoils."), +39 | ] +40 | +41 | facts = { +note: This is an unsafe fix and may change runtime behavior + +ISC004 [*] Unparenthesized implicit string concatenation in collection + --> ISC004.py:42:5 + | +41 | facts = { +42 | / "Octopuses have three hearts." +43 | | # Missing comma here. +44 | | "Honey never spoils.", + | |_________________________^ +45 | } + | +help: Wrap implicitly concatenated strings in parentheses +help: Did you forget a comma? +39 | ] +40 | +41 | facts = { + - "Octopuses have three hearts." +42 + ("Octopuses have three hearts." +43 | # Missing comma here. + - "Honey never spoils.", +44 + "Honey never spoils."), +45 | } +46 | +47 | facts = ( +note: This is an unsafe fix and may change runtime behavior diff --git a/ruff.schema.json b/ruff.schema.json index 1c8a092042..0dd54c0a07 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -3482,6 +3482,7 @@ "ISC001", "ISC002", "ISC003", + "ISC004", "LOG", "LOG0", "LOG00",