Improve API exposed on ExprStringLiteral nodes (#16192)

## Summary

This PR makes the following changes:
- It adjusts various callsites to use the new
`ast::StringLiteral::contents_range()` method that was introduced in
https://github.com/astral-sh/ruff/pull/16183. This is less verbose and
more type-safe than using the `ast::str::raw_contents()` helper
function.
- It adds a new `ast::ExprStringLiteral::as_unconcatenated_literal()`
helper method, and adjusts various callsites to use it. This addresses
@MichaReiser's review comment at
https://github.com/astral-sh/ruff/pull/16183#discussion_r1957334365.
There is no functional change here, but it helps readability to make it
clearer that we're differentiating between implicitly concatenated
strings and unconcatenated strings at various points.
- It renames the `StringLiteralValue::flags()` method to
`StringLiteralFlags::first_literal_flags()`. If you're dealing with an
implicitly concatenated string `string_node`,
`string_node.value.flags().closer_len()` could give an incorrect result;
this renaming makes it clearer that the `StringLiteralFlags` instance
returned by the method is only guaranteed to give accurate information
for the first `StringLiteral` contained in the `ExprStringLiteral` node.
- It deletes the unused `BytesLiteralValue::flags()` method. This seems
prone to misuse in the same way as `StringLiteralValue::flags()`: if
it's an implicitly concatenated bytestring, the `BytesLiteralFlags`
instance returned by the method would only give accurate information for
the first `BytesLiteral` in the bytestring.

## Test Plan

`cargo test`
This commit is contained in:
Alex Waygood 2025-02-17 07:58:54 +00:00 committed by GitHub
parent 21999b3be7
commit b6b1947010
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 37 additions and 43 deletions

View file

@ -1,5 +1,4 @@
use ruff_db::source::source_text; use ruff_db::source::source_text;
use ruff_python_ast::str::raw_contents;
use ruff_python_ast::{self as ast, ModExpression}; use ruff_python_ast::{self as ast, ModExpression};
use ruff_python_parser::Parsed; use ruff_python_parser::Parsed;
use ruff_text_size::Ranged; use ruff_text_size::Ranged;
@ -138,9 +137,8 @@ pub(crate) fn parse_string_annotation(
let _span = tracing::trace_span!("parse_string_annotation", string=?string_expr.range(), file=%file.path(db)).entered(); let _span = tracing::trace_span!("parse_string_annotation", string=?string_expr.range(), file=%file.path(db)).entered();
let source = source_text(db.upcast(), file); let source = source_text(db.upcast(), file);
let node_text = &source[string_expr.range()];
if let [string_literal] = string_expr.value.as_slice() { if let Some(string_literal) = string_expr.as_unconcatenated_literal() {
let prefix = string_literal.flags.prefix(); let prefix = string_literal.flags.prefix();
if prefix.is_raw() { if prefix.is_raw() {
context.report_lint( context.report_lint(
@ -150,9 +148,7 @@ pub(crate) fn parse_string_annotation(
); );
// Compare the raw contents (without quotes) of the expression with the parsed contents // Compare the raw contents (without quotes) of the expression with the parsed contents
// contained in the string literal. // contained in the string literal.
} else if raw_contents(node_text) } else if &source[string_literal.content_range()] == string_literal.as_str() {
.is_some_and(|raw_contents| raw_contents == string_literal.as_str())
{
match ruff_python_parser::parse_string_annotation(source.as_str(), string_literal) { match ruff_python_parser::parse_string_annotation(source.as_str(), string_literal) {
Ok(parsed) => return Some(parsed), Ok(parsed) => return Some(parsed),
Err(parse_error) => context.report_lint( Err(parse_error) => context.report_lint(

View file

@ -182,9 +182,8 @@ pub(crate) fn definitions(checker: &mut Checker) {
continue; continue;
}; };
// If the `ExprStringLiteral` has multiple parts, it is implicitly concatenated. // We don't recognise implicitly concatenated strings as valid docstrings in our model currently.
// We don't support recognising such strings as docstrings in our model currently. let Some(sole_string_part) = string_literal.as_unconcatenated_literal() else {
let [sole_string_part] = string_literal.value.as_slice() else {
#[allow(deprecated)] #[allow(deprecated)]
let location = checker let location = checker
.locator .locator

View file

@ -1537,7 +1537,7 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
} }
} }
if checker.enabled(Rule::MissingFStringSyntax) { if checker.enabled(Rule::MissingFStringSyntax) {
for string_literal in value.as_slice() { for string_literal in value {
ruff::rules::missing_fstring_syntax(checker, string_literal); ruff::rules::missing_fstring_syntax(checker, string_literal);
} }
} }

View file

@ -159,11 +159,17 @@ fn split_default(str_value: &StringLiteralValue, max_split: i32) -> Option<Expr>
} }
Ordering::Equal => { Ordering::Equal => {
let list_items: Vec<&str> = vec![str_value.to_str()]; let list_items: Vec<&str> = vec![str_value.to_str()];
Some(construct_replacement(&list_items, str_value.flags())) Some(construct_replacement(
&list_items,
str_value.first_literal_flags(),
))
} }
Ordering::Less => { Ordering::Less => {
let list_items: Vec<&str> = str_value.to_str().split_whitespace().collect(); let list_items: Vec<&str> = str_value.to_str().split_whitespace().collect();
Some(construct_replacement(&list_items, str_value.flags())) Some(construct_replacement(
&list_items,
str_value.first_literal_flags(),
))
} }
} }
} }
@ -187,7 +193,7 @@ fn split_sep(
} }
}; };
construct_replacement(&list_items, str_value.flags()) construct_replacement(&list_items, str_value.first_literal_flags())
} }
/// Returns the value of the `maxsplit` argument as an `i32`, if it is a numeric value. /// Returns the value of the `maxsplit` argument as an `i32`, if it is a numeric value.

View file

@ -72,7 +72,7 @@ fn build_fstring(joiner: &str, joinees: &[Expr], flags: FStringFlags) -> Option<
if let Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) = expr { if let Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) = expr {
if flags.is_none() { if flags.is_none() {
// take the flags from the first Expr // take the flags from the first Expr
flags = Some(value.flags()); flags = Some(value.first_literal_flags());
} }
Some(value.to_str()) Some(value.to_str())
} else { } else {

View file

@ -1287,6 +1287,17 @@ pub struct ExprStringLiteral {
pub value: StringLiteralValue, pub value: StringLiteralValue,
} }
impl ExprStringLiteral {
/// Return `Some(literal)` if the string only consists of a single `StringLiteral` part
/// (indicating that it is not implicitly concatenated). Otherwise, return `None`.
pub fn as_unconcatenated_literal(&self) -> Option<&StringLiteral> {
match &self.value.inner {
StringLiteralValueInner::Single(value) => Some(value),
StringLiteralValueInner::Concatenated(_) => None,
}
}
}
/// The value representing a [`ExprStringLiteral`]. /// The value representing a [`ExprStringLiteral`].
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct StringLiteralValue { pub struct StringLiteralValue {
@ -1304,7 +1315,7 @@ impl StringLiteralValue {
/// Returns the [`StringLiteralFlags`] associated with this string literal. /// Returns the [`StringLiteralFlags`] associated with this string literal.
/// ///
/// For an implicitly concatenated string, it returns the flags for the first literal. /// For an implicitly concatenated string, it returns the flags for the first literal.
pub fn flags(&self) -> StringLiteralFlags { pub fn first_literal_flags(&self) -> StringLiteralFlags {
self.iter() self.iter()
.next() .next()
.expect( .expect(
@ -1485,8 +1496,8 @@ bitflags! {
/// ///
/// If you're using a `Generator` from the `ruff_python_codegen` crate to generate a lint-rule fix /// If you're using a `Generator` from the `ruff_python_codegen` crate to generate a lint-rule fix
/// from an existing string literal, consider passing along the [`StringLiteral::flags`] field or /// from an existing string literal, consider passing along the [`StringLiteral::flags`] field or
/// the result of the [`StringLiteralValue::flags`] method. If you don't have an existing string but /// the result of the [`StringLiteralValue::first_literal_flags`] method. If you don't have an
/// have a `Checker` from the `ruff_linter` crate available, consider using /// existing string but have a `Checker` from the `ruff_linter` crate available, consider using
/// `Checker::default_string_flags` to create instances of this struct; this method will properly /// `Checker::default_string_flags` to create instances of this struct; this method will properly
/// handle surrounding f-strings. For usage that doesn't fit into one of these categories, the /// handle surrounding f-strings. For usage that doesn't fit into one of these categories, the
/// public constructor [`StringLiteralFlags::empty`] can be used. /// public constructor [`StringLiteralFlags::empty`] can be used.
@ -1791,16 +1802,6 @@ impl BytesLiteralValue {
pub fn bytes(&self) -> impl Iterator<Item = u8> + '_ { pub fn bytes(&self) -> impl Iterator<Item = u8> + '_ {
self.iter().flat_map(|part| part.as_slice().iter().copied()) self.iter().flat_map(|part| part.as_slice().iter().copied())
} }
/// Returns the [`BytesLiteralFlags`] associated with this literal.
///
/// For an implicitly concatenated literal, it returns the flags for the first literal.
pub fn flags(&self) -> BytesLiteralFlags {
self.iter()
.next()
.expect("There should always be at least one literal in an `ExprBytesLiteral` node")
.flags
}
} }
impl<'a> IntoIterator for &'a BytesLiteralValue { impl<'a> IntoIterator for &'a BytesLiteralValue {
@ -1890,12 +1891,11 @@ bitflags! {
/// ## Notes on usage /// ## Notes on usage
/// ///
/// If you're using a `Generator` from the `ruff_python_codegen` crate to generate a lint-rule fix /// If you're using a `Generator` from the `ruff_python_codegen` crate to generate a lint-rule fix
/// from an existing bytes literal, consider passing along the [`BytesLiteral::flags`] field or the /// from an existing bytes literal, consider passing along the [`BytesLiteral::flags`] field. If
/// result of the [`BytesLiteralValue::flags`] method. If you don't have an existing literal but /// you don't have an existing literal but have a `Checker` from the `ruff_linter` crate available,
/// have a `Checker` from the `ruff_linter` crate available, consider using /// consider using `Checker::default_bytes_flags` to create instances of this struct; this method
/// `Checker::default_bytes_flags` to create instances of this struct; this method will properly /// will properly handle surrounding f-strings. For usage that doesn't fit into one of these
/// handle surrounding f-strings. For usage that doesn't fit into one of these categories, the /// categories, the public constructor [`BytesLiteralFlags::empty`] can be used.
/// public constructor [`BytesLiteralFlags::empty`] can be used.
#[derive(Copy, Clone, Eq, PartialEq, Hash)] #[derive(Copy, Clone, Eq, PartialEq, Hash)]
pub struct BytesLiteralFlags(BytesLiteralFlagsInner); pub struct BytesLiteralFlags(BytesLiteralFlagsInner);

View file

@ -28,9 +28,7 @@ impl FormatRuleWithOptions<ExprStringLiteral, PyFormatContext<'_>> for FormatExp
impl FormatNodeRule<ExprStringLiteral> for FormatExprStringLiteral { impl FormatNodeRule<ExprStringLiteral> for FormatExprStringLiteral {
fn fmt_fields(&self, item: &ExprStringLiteral, f: &mut PyFormatter) -> FormatResult<()> { fn fmt_fields(&self, item: &ExprStringLiteral, f: &mut PyFormatter) -> FormatResult<()> {
let ExprStringLiteral { value, .. } = item; if let Some(string_literal) = item.as_unconcatenated_literal() {
if let [string_literal] = value.as_slice() {
string_literal.format().with_options(self.kind).fmt(f) string_literal.format().with_options(self.kind).fmt(f)
} else { } else {
// Always join strings that aren't parenthesized and thus, always on a single line. // Always join strings that aren't parenthesized and thus, always on a single line.

View file

@ -1,7 +1,6 @@
//! This module takes care of parsing a type annotation. //! This module takes care of parsing a type annotation.
use ruff_python_ast::relocate::relocate_expr; use ruff_python_ast::relocate::relocate_expr;
use ruff_python_ast::str::raw_contents;
use ruff_python_ast::{Expr, ExprStringLiteral, ModExpression, StringLiteral}; use ruff_python_ast::{Expr, ExprStringLiteral, ModExpression, StringLiteral};
use ruff_text_size::Ranged; use ruff_text_size::Ranged;
@ -57,14 +56,10 @@ pub fn parse_type_annotation(
string_expr: &ExprStringLiteral, string_expr: &ExprStringLiteral,
source: &str, source: &str,
) -> AnnotationParseResult { ) -> AnnotationParseResult {
let expr_text = &source[string_expr.range()]; if let Some(string_literal) = string_expr.as_unconcatenated_literal() {
if let [string_literal] = string_expr.value.as_slice() {
// Compare the raw contents (without quotes) of the expression with the parsed contents // Compare the raw contents (without quotes) of the expression with the parsed contents
// contained in the string literal. // contained in the string literal.
if raw_contents(expr_text) if &source[string_literal.content_range()] == string_literal.as_str() {
.is_some_and(|raw_contents| raw_contents == string_literal.as_str())
{
parse_simple_type_annotation(string_literal, source) parse_simple_type_annotation(string_literal, source)
} else { } else {
// The raw contents of the string doesn't match the parsed content. This could be the // The raw contents of the string doesn't match the parsed content. This could be the