diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_quotes/singles.py b/crates/ruff_linter/resources/test/fixtures/flake8_quotes/singles.py index e9a96a105cb6e..06aa7504f7140 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_quotes/singles.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_quotes/singles.py @@ -5,3 +5,5 @@ # https://github.com/astral-sh/ruff/issues/10546 x: "Literal['foo', 'bar']" +# https://github.com/astral-sh/ruff/issues/10761 +f"Before {f'x {x}' if y else f'foo {z}'} after" diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index 62de2dfe0d0e4..6af274cb8f331 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -32,8 +32,8 @@ use itertools::Itertools; use log::debug; use ruff_python_ast::{ self as ast, all::DunderAllName, Comprehension, ElifElseClause, ExceptHandler, Expr, - ExprContext, Keyword, MatchCase, Parameter, ParameterWithDefault, Parameters, Pattern, Stmt, - Suite, UnaryOp, + ExprContext, FStringElement, Keyword, MatchCase, Parameter, ParameterWithDefault, Parameters, + Pattern, Stmt, Suite, UnaryOp, }; use ruff_text_size::{Ranged, TextRange, TextSize}; @@ -1580,6 +1580,15 @@ impl<'a> Visitor<'a> for Checker<'a> { .push((bound, self.semantic.snapshot())); } } + + fn visit_f_string_element(&mut self, f_string_element: &'a FStringElement) { + let snapshot = self.semantic.flags; + if f_string_element.is_expression() { + self.semantic.flags |= SemanticModelFlags::F_STRING_REPLACEMENT_FIELD; + } + visitor::walk_f_string_element(self, f_string_element); + self.semantic.flags = snapshot; + } } impl<'a> Checker<'a> { diff --git a/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs b/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs index fc4ff375053ec..7c01db8c5af8f 100644 --- a/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs +++ b/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs @@ -449,13 +449,8 @@ pub(crate) fn check_string_quotes(checker: &mut Checker, string_like: StringLike return; } - // If the string is part of a f-string, ignore it. - if checker - .indexer() - .fstring_ranges() - .outermost(string_like.start()) - .is_some_and(|outer| outer.start() < string_like.start() && string_like.end() < outer.end()) - { + // TODO(dhruvmanila): Support checking for escaped quotes in f-strings. + if checker.semantic().in_f_string_replacement_field() { return; } diff --git a/crates/ruff_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs index 07dce98d1e919..ac735d97acd29 100644 --- a/crates/ruff_python_semantic/src/model.rs +++ b/crates/ruff_python_semantic/src/model.rs @@ -1525,6 +1525,12 @@ impl<'a> SemanticModel<'a> { self.flags.intersects(SemanticModelFlags::F_STRING) } + /// Return `true` if the model is in an f-string replacement field. + pub const fn in_f_string_replacement_field(&self) -> bool { + self.flags + .intersects(SemanticModelFlags::F_STRING_REPLACEMENT_FIELD) + } + /// Return `true` if the model is in boolean test. pub const fn in_boolean_test(&self) -> bool { self.flags.intersects(SemanticModelFlags::BOOLEAN_TEST) @@ -1960,6 +1966,15 @@ bitflags! { /// ``` const DUNDER_ALL_DEFINITION = 1 << 22; + /// The model is in an f-string replacement field. + /// + /// For example, the model could be visiting `x` or `y` in: + /// + /// ```python + /// f"first {x} second {y}" + /// ``` + const F_STRING_REPLACEMENT_FIELD = 1 << 23; + /// The context is in any type annotation. const ANNOTATION = Self::TYPING_ONLY_ANNOTATION.bits() | Self::RUNTIME_EVALUATED_ANNOTATION.bits() | Self::RUNTIME_REQUIRED_ANNOTATION.bits();