Skip to content

Commit 89ca493

Browse files
danparizherntBre
andauthored
[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 <[email protected]> Co-authored-by: Brent Westbrook <[email protected]>
1 parent 4b80f5f commit 89ca493

File tree

4 files changed

+186
-1
lines changed

4 files changed

+186
-1
lines changed

crates/ruff_linter/resources/test/fixtures/ruff/RUF033.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,19 @@ def __post_init__(
124124
...
125125

126126
return Foo
127+
128+
129+
@dataclass
130+
class C:
131+
def __post_init__(self, x: tuple[int, ...] = (
132+
1,
133+
2,
134+
)) -> None:
135+
self.x = x
136+
137+
138+
@dataclass
139+
class D:
140+
def __post_init__(self, x: int = """
141+
""") -> None:
142+
self.x = x

crates/ruff_linter/src/rules/ruff/rules/post_init_default.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ fn use_initvar(
186186

187187
let indentation = indentation_at_offset(post_init_def.start(), checker.source())
188188
.context("Failed to calculate leading indentation of `__post_init__` method")?;
189-
let content = textwrap::indent(&content, indentation);
189+
let content = textwrap::indent_first_line(&content, indentation);
190190

191191
let initvar_edit = Edit::insertion(
192192
content.into_owned(),

crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF033_RUF033.py.snap

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,3 +455,57 @@ help: Use `dataclasses.InitVar` instead
455455
122 123 | ,
456456
123 124 | ) -> None:
457457
124 125 | ...
458+
459+
RUF033 [*] `__post_init__` method with argument defaults
460+
--> RUF033.py:131:50
461+
|
462+
129 | @dataclass
463+
130 | class C:
464+
131 | def __post_init__(self, x: tuple[int, ...] = (
465+
| __________________________________________________^
466+
132 | | 1,
467+
133 | | 2,
468+
134 | | )) -> None:
469+
| |_____^
470+
135 | self.x = x
471+
|
472+
help: Use `dataclasses.InitVar` instead
473+
474+
Unsafe fix
475+
128 128 |
476+
129 129 | @dataclass
477+
130 130 | class C:
478+
131 |- def __post_init__(self, x: tuple[int, ...] = (
479+
131 |+ x: InitVar[tuple[int, ...]] = (
480+
132 132 | 1,
481+
133 133 | 2,
482+
134 |- )) -> None:
483+
134 |+ )
484+
135 |+ def __post_init__(self, x: tuple[int, ...]) -> None:
485+
135 136 | self.x = x
486+
136 137 |
487+
137 138 |
488+
489+
RUF033 [*] `__post_init__` method with argument defaults
490+
--> RUF033.py:140:38
491+
|
492+
138 | @dataclass
493+
139 | class D:
494+
140 | def __post_init__(self, x: int = """
495+
| ______________________________________^
496+
141 | | """) -> None:
497+
| |_______^
498+
142 | self.x = x
499+
|
500+
help: Use `dataclasses.InitVar` instead
501+
502+
Unsafe fix
503+
137 137 |
504+
138 138 | @dataclass
505+
139 139 | class D:
506+
140 |- def __post_init__(self, x: int = """
507+
141 |- """) -> None:
508+
140 |+ x: InitVar[int] = """
509+
141 |+ """
510+
142 |+ def __post_init__(self, x: int) -> None:
511+
142 143 | self.x = x

crates/ruff_python_trivia/src/textwrap.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,66 @@ pub fn indent<'a>(text: &'a str, prefix: &str) -> Cow<'a, str> {
7171
Cow::Owned(result)
7272
}
7373

74+
/// Indent only the first line by the given prefix.
75+
///
76+
/// This function is useful when you want to indent the first line of a multi-line
77+
/// expression while preserving the relative indentation of subsequent lines.
78+
///
79+
/// # Examples
80+
///
81+
/// ```
82+
/// # use ruff_python_trivia::textwrap::indent_first_line;
83+
///
84+
/// assert_eq!(indent_first_line("First line.\nSecond line.\n", " "),
85+
/// " First line.\nSecond line.\n");
86+
/// ```
87+
///
88+
/// When indenting, trailing whitespace is stripped from the prefix.
89+
/// This means that empty lines remain empty afterwards:
90+
///
91+
/// ```
92+
/// # use ruff_python_trivia::textwrap::indent_first_line;
93+
///
94+
/// assert_eq!(indent_first_line("\n\n\nSecond line.\n", " "),
95+
/// "\n\n\nSecond line.\n");
96+
/// ```
97+
///
98+
/// Leading and trailing whitespace coming from the text itself is
99+
/// kept unchanged:
100+
///
101+
/// ```
102+
/// # use ruff_python_trivia::textwrap::indent_first_line;
103+
///
104+
/// assert_eq!(indent_first_line(" \t Foo ", "->"), "-> \t Foo ");
105+
/// ```
106+
pub fn indent_first_line<'a>(text: &'a str, prefix: &str) -> Cow<'a, str> {
107+
if prefix.is_empty() {
108+
return Cow::Borrowed(text);
109+
}
110+
111+
let mut lines = text.universal_newlines();
112+
let Some(first_line) = lines.next() else {
113+
return Cow::Borrowed(text);
114+
};
115+
116+
let mut result = String::with_capacity(text.len() + prefix.len());
117+
118+
// Indent only the first line
119+
if first_line.trim_whitespace().is_empty() {
120+
result.push_str(prefix.trim_whitespace_end());
121+
} else {
122+
result.push_str(prefix);
123+
}
124+
result.push_str(first_line.as_full_str());
125+
126+
// Add remaining lines without indentation
127+
for line in lines {
128+
result.push_str(line.as_full_str());
129+
}
130+
131+
Cow::Owned(result)
132+
}
133+
74134
/// Removes common leading whitespace from each line.
75135
///
76136
/// This function will look at each non-empty line and determine the
@@ -409,6 +469,61 @@ mod tests {
409469
assert_eq!(dedent(text), text);
410470
}
411471

472+
#[test]
473+
fn indent_first_line_empty() {
474+
assert_eq!(indent_first_line("\n", " "), "\n");
475+
}
476+
477+
#[test]
478+
#[rustfmt::skip]
479+
fn indent_first_line_nonempty() {
480+
let text = [
481+
" foo\n",
482+
"bar\n",
483+
" baz\n",
484+
].join("");
485+
let expected = [
486+
"// foo\n",
487+
"bar\n",
488+
" baz\n",
489+
].join("");
490+
assert_eq!(indent_first_line(&text, "// "), expected);
491+
}
492+
493+
#[test]
494+
#[rustfmt::skip]
495+
fn indent_first_line_empty_line() {
496+
let text = [
497+
" foo",
498+
"bar",
499+
"",
500+
" baz",
501+
].join("\n");
502+
let expected = [
503+
"// foo",
504+
"bar",
505+
"",
506+
" baz",
507+
].join("\n");
508+
assert_eq!(indent_first_line(&text, "// "), expected);
509+
}
510+
511+
#[test]
512+
#[rustfmt::skip]
513+
fn indent_first_line_mixed_newlines() {
514+
let text = [
515+
" foo\r\n",
516+
"bar\n",
517+
" baz\r",
518+
].join("");
519+
let expected = [
520+
"// foo\r\n",
521+
"bar\n",
522+
" baz\r",
523+
].join("");
524+
assert_eq!(indent_first_line(&text, "// "), expected);
525+
}
526+
412527
#[test]
413528
#[rustfmt::skip]
414529
fn adjust_indent() {

0 commit comments

Comments
 (0)