[ruff] Preserve relative whitespace in multi-line expressions (RUF033) (#19647)

## Summary

Fixes #19581

I decided to add in a `indent_first_line` function into
[`textwrap.rs`](https://github.com/astral-sh/ruff/blob/main/crates/ruff_python_trivia/src/textwrap.rs),
as it solely focuses on text manipulation utilities. It follows the same
design as `indent()`, and there may be situations in the future where it
can be reused as well.

---------

Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com>
Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
This commit is contained in:
Dan Parizher 2025-08-27 15:15:44 -04:00 committed by GitHub
parent 4b80f5fa4f
commit 89ca493fd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 186 additions and 1 deletions

View file

@ -124,3 +124,19 @@ def fun_with_python_syntax():
... ...
return Foo return Foo
@dataclass
class C:
def __post_init__(self, x: tuple[int, ...] = (
1,
2,
)) -> None:
self.x = x
@dataclass
class D:
def __post_init__(self, x: int = """
""") -> None:
self.x = x

View file

@ -186,7 +186,7 @@ fn use_initvar(
let indentation = indentation_at_offset(post_init_def.start(), checker.source()) let indentation = indentation_at_offset(post_init_def.start(), checker.source())
.context("Failed to calculate leading indentation of `__post_init__` method")?; .context("Failed to calculate leading indentation of `__post_init__` method")?;
let content = textwrap::indent(&content, indentation); let content = textwrap::indent_first_line(&content, indentation);
let initvar_edit = Edit::insertion( let initvar_edit = Edit::insertion(
content.into_owned(), content.into_owned(),

View file

@ -455,3 +455,57 @@ help: Use `dataclasses.InitVar` instead
122 123 | , 122 123 | ,
123 124 | ) -> None: 123 124 | ) -> None:
124 125 | ... 124 125 | ...
RUF033 [*] `__post_init__` method with argument defaults
--> RUF033.py:131:50
|
129 | @dataclass
130 | class C:
131 | def __post_init__(self, x: tuple[int, ...] = (
| __________________________________________________^
132 | | 1,
133 | | 2,
134 | | )) -> None:
| |_____^
135 | self.x = x
|
help: Use `dataclasses.InitVar` instead
Unsafe fix
128 128 |
129 129 | @dataclass
130 130 | class C:
131 |- def __post_init__(self, x: tuple[int, ...] = (
131 |+ x: InitVar[tuple[int, ...]] = (
132 132 | 1,
133 133 | 2,
134 |- )) -> None:
134 |+ )
135 |+ def __post_init__(self, x: tuple[int, ...]) -> None:
135 136 | self.x = x
136 137 |
137 138 |
RUF033 [*] `__post_init__` method with argument defaults
--> RUF033.py:140:38
|
138 | @dataclass
139 | class D:
140 | def __post_init__(self, x: int = """
| ______________________________________^
141 | | """) -> None:
| |_______^
142 | self.x = x
|
help: Use `dataclasses.InitVar` instead
Unsafe fix
137 137 |
138 138 | @dataclass
139 139 | class D:
140 |- def __post_init__(self, x: int = """
141 |- """) -> None:
140 |+ x: InitVar[int] = """
141 |+ """
142 |+ def __post_init__(self, x: int) -> None:
142 143 | self.x = x

View file

@ -71,6 +71,66 @@ pub fn indent<'a>(text: &'a str, prefix: &str) -> Cow<'a, str> {
Cow::Owned(result) Cow::Owned(result)
} }
/// Indent only the first line by the given prefix.
///
/// This function is useful when you want to indent the first line of a multi-line
/// expression while preserving the relative indentation of subsequent lines.
///
/// # Examples
///
/// ```
/// # use ruff_python_trivia::textwrap::indent_first_line;
///
/// assert_eq!(indent_first_line("First line.\nSecond line.\n", " "),
/// " First line.\nSecond line.\n");
/// ```
///
/// When indenting, trailing whitespace is stripped from the prefix.
/// This means that empty lines remain empty afterwards:
///
/// ```
/// # use ruff_python_trivia::textwrap::indent_first_line;
///
/// assert_eq!(indent_first_line("\n\n\nSecond line.\n", " "),
/// "\n\n\nSecond line.\n");
/// ```
///
/// Leading and trailing whitespace coming from the text itself is
/// kept unchanged:
///
/// ```
/// # use ruff_python_trivia::textwrap::indent_first_line;
///
/// assert_eq!(indent_first_line(" \t Foo ", "->"), "-> \t Foo ");
/// ```
pub fn indent_first_line<'a>(text: &'a str, prefix: &str) -> Cow<'a, str> {
if prefix.is_empty() {
return Cow::Borrowed(text);
}
let mut lines = text.universal_newlines();
let Some(first_line) = lines.next() else {
return Cow::Borrowed(text);
};
let mut result = String::with_capacity(text.len() + prefix.len());
// Indent only the first line
if first_line.trim_whitespace().is_empty() {
result.push_str(prefix.trim_whitespace_end());
} else {
result.push_str(prefix);
}
result.push_str(first_line.as_full_str());
// Add remaining lines without indentation
for line in lines {
result.push_str(line.as_full_str());
}
Cow::Owned(result)
}
/// Removes common leading whitespace from each line. /// Removes common leading whitespace from each line.
/// ///
/// This function will look at each non-empty line and determine the /// This function will look at each non-empty line and determine the
@ -409,6 +469,61 @@ mod tests {
assert_eq!(dedent(text), text); assert_eq!(dedent(text), text);
} }
#[test]
fn indent_first_line_empty() {
assert_eq!(indent_first_line("\n", " "), "\n");
}
#[test]
#[rustfmt::skip]
fn indent_first_line_nonempty() {
let text = [
" foo\n",
"bar\n",
" baz\n",
].join("");
let expected = [
"// foo\n",
"bar\n",
" baz\n",
].join("");
assert_eq!(indent_first_line(&text, "// "), expected);
}
#[test]
#[rustfmt::skip]
fn indent_first_line_empty_line() {
let text = [
" foo",
"bar",
"",
" baz",
].join("\n");
let expected = [
"// foo",
"bar",
"",
" baz",
].join("\n");
assert_eq!(indent_first_line(&text, "// "), expected);
}
#[test]
#[rustfmt::skip]
fn indent_first_line_mixed_newlines() {
let text = [
" foo\r\n",
"bar\n",
" baz\r",
].join("");
let expected = [
"// foo\r\n",
"bar\n",
" baz\r",
].join("");
assert_eq!(indent_first_line(&text, "// "), expected);
}
#[test] #[test]
#[rustfmt::skip] #[rustfmt::skip]
fn adjust_indent() { fn adjust_indent() {