From abb54b32b0bbf5becfdf58fb9aae5e10b951141a Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Tue, 28 Oct 2025 10:37:41 -0400 Subject: [PATCH 1/9] Allow newlines after function headers without docstrings Summary -- This is a first step toward fixing #9745. After reviewing our open issues and several Black issues and PRs, I personally found the function case the most compelling, especially with very long argument lists: ```py def func( self, arg1: int, arg2: bool, arg3: bool, arg4: float, arg5: bool, ) -> tuple[...]: if arg2 and arg3: raise ValueError ``` or many annotations: ```py def function( self, data: torch.Tensor | tuple[torch.Tensor, ...], other_argument: int ) -> torch.Tensor | tuple[torch.Tensor, ...]: do_something(data) return something ``` I think docstrings help the situation substantially both because syntax highlighting will usually give a very clear separation between the annotations and the docstring and because we already allow a blank line _after_ the docstring: ```py def function( self, data: torch.Tensor | tuple[torch.Tensor, ...], other_argument: int ) -> torch.Tensor | tuple[torch.Tensor, ...]: """ A function doing something. And a longer description of the things it does. """ do_something(data) return something ``` There are still other comments on #9745, such as [this one] with 9 upvotes, where users specifically request blank lines in all block types, or at least including conditionals and loops. I'm sympathetic to that case as well, even if personally I don't find an [example] like this: ```py if blah: # Do some stuff that is logically related data = get_data() # Do some different stuff that is logically related results = calculate_results() return results ``` to be more readable at all than: ```py if blah: # Do some stuff that is logically related data = get_data() # Do some different stuff that is logically related results = calculate_results() return results ``` I'm probably just used to the latter from the formatters I've used, but I do prefer it. I also think that functions are the least susceptible to the accidental introduction of a newline after refactoring described in Micha's [comment] on #8893. I actually considered further restricting this change to functions with multiline headers. I don't think very short functions like: ```py def foo(): return 1 ``` benefit nearly as much from the allowed newline, but I just went with any function without a docstring or immediate comment for now. I guess a marginal case like: ```py def foo(a_long_parameter: ALongType, b_long_parameter: BLongType) -> CLongType: return 1 ``` might be a good argument for not restricting it. I caused a couple of syntax errors before adding special handling for the ellipsis-only case, so I suspect that there are some other interesting edge cases that may need to be handled better. Test Plan -- Existing tests, plus a few simple new ones. As noted above, I suspect that we may need a few more for edge cases I haven't considered. [this one]: https://github.com/astral-sh/ruff/issues/9745#issuecomment-2876771400 [example]: https://github.com/psf/black/issues/902#issuecomment-1562154809 [comment]: https://github.com/astral-sh/ruff/issues/8893#issuecomment-1867259744 --- .../resources/test/fixtures/ruff/newlines.py | 37 ++++++ crates/ruff_python_formatter/src/preview.rs | 7 ++ .../src/statement/suite.rs | 19 +++- .../tests/snapshots/format@newlines.py.snap | 105 +++++++++++++++++- .../format@range_formatting__indent.py.snap | 45 ++++++++ 5 files changed, 206 insertions(+), 7 deletions(-) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py index 2afbd182294f4..e9625e662c99d 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py @@ -335,3 +335,40 @@ def overload4(): # trailing comment def overload4(a: int): ... + + +# In preview, we preserve these newlines at the start of functions: +def preserved1(): + + return 1 + +def preserved2(): + + pass + + +# But we still discard these newlines: +def removed1(): + + "Docstring" + + return 1 + + +def removed2(): + + # Comment + + return 1 + + +def removed3(): + + ... + + +# And we discard empty lines after the first: +def partially_preserved1(): + + + return 1 diff --git a/crates/ruff_python_formatter/src/preview.rs b/crates/ruff_python_formatter/src/preview.rs index b6479ab1b43f0..5455fa9a12e56 100644 --- a/crates/ruff_python_formatter/src/preview.rs +++ b/crates/ruff_python_formatter/src/preview.rs @@ -36,3 +36,10 @@ pub(crate) const fn is_remove_parens_around_except_types_enabled( ) -> bool { context.is_preview() } + +/// Returns `true` if the +/// [`allow_newline_after_block_open`](https://github.com/astral-sh/ruff/pull/21110) preview style +/// is enabled. +pub(crate) const fn is_allow_newline_after_block_open_enabled(context: &PyFormatContext) -> bool { + context.is_preview() +} diff --git a/crates/ruff_python_formatter/src/statement/suite.rs b/crates/ruff_python_formatter/src/statement/suite.rs index 4071b4ba1fc52..1415d712d5fca 100644 --- a/crates/ruff_python_formatter/src/statement/suite.rs +++ b/crates/ruff_python_formatter/src/statement/suite.rs @@ -13,7 +13,9 @@ use crate::comments::{ use crate::context::{NodeLevel, TopLevelStatementPosition, WithIndentLevel, WithNodeLevel}; use crate::other::string_literal::StringLiteralKind; use crate::prelude::*; -use crate::preview::is_blank_line_before_decorated_class_in_stub_enabled; +use crate::preview::{ + is_allow_newline_after_block_open_enabled, is_blank_line_before_decorated_class_in_stub_enabled, +}; use crate::statement::stmt_expr::FormatStmtExpr; use crate::verbatim::{ suppressed_node, write_suppressed_statements_starting_with_leading_comment, @@ -169,6 +171,21 @@ impl FormatRule> for FormatSuite { false, ) } else { + // Allow an empty line after a function header in preview, if the function has no + // docstring and no initial comment and doesn't consist of a single ellipsis. + let allow_newline_after_block_open = + is_allow_newline_after_block_open_enabled(f.context()) + && matches!(self.kind, SuiteKind::Function) + && matches!(first, SuiteChildStatement::Other(_)) + && !comments.has_leading(first) + && !contains_only_an_ellipsis(statements, f.context().comments()); + + if allow_newline_after_block_open + && lines_before(first.start(), f.context().source()) > 1 + { + empty_line().fmt(f)?; + } + first.fmt(f)?; let empty_line_after_docstring = if matches!(first, SuiteChildStatement::Docstring(_)) diff --git a/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap index 84bd4283c469e..0c418c55fe10b 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap @@ -1,7 +1,6 @@ --- source: crates/ruff_python_formatter/tests/fixtures.rs input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py -snapshot_kind: text --- ## Input ```python @@ -342,6 +341,43 @@ def overload4(): # trailing comment def overload4(a: int): ... + + +# In preview, we preserve these newlines at the start of functions: +def preserved1(): + + return 1 + +def preserved2(): + + pass + + +# But we still discard these newlines: +def removed1(): + + "Docstring" + + return 1 + + +def removed2(): + + # Comment + + return 1 + + +def removed3(): + + ... + + +# And we discard empty lines after the first: +def partially_preserved1(): + + + return 1 ``` ## Output @@ -732,6 +768,36 @@ def overload4(): def overload4(a: int): ... + + +# In preview, we preserve these newlines at the start of functions: +def preserved1(): + return 1 + + +def preserved2(): + pass + + +# But we still discard these newlines: +def removed1(): + "Docstring" + + return 1 + + +def removed2(): + # Comment + + return 1 + + +def removed3(): ... + + +# And we discard empty lines after the first: +def partially_preserved1(): + return 1 ``` @@ -739,7 +805,15 @@ def overload4(a: int): ... ```diff --- Stable +++ Preview -@@ -277,6 +277,7 @@ +@@ -253,6 +253,7 @@ + + + def fakehttp(): ++ + class FakeHTTPConnection: + if mock_close: + +@@ -277,6 +278,7 @@ def a(): return 1 @@ -747,7 +821,7 @@ def overload4(a: int): ... else: pass -@@ -293,6 +294,7 @@ +@@ -293,6 +295,7 @@ def a(): return 1 @@ -755,7 +829,7 @@ def overload4(a: int): ... case 1: def a(): -@@ -303,6 +305,7 @@ +@@ -303,6 +306,7 @@ def a(): return 1 @@ -763,7 +837,7 @@ def overload4(a: int): ... except RuntimeError: def a(): -@@ -313,6 +316,7 @@ +@@ -313,6 +317,7 @@ def a(): return 1 @@ -771,7 +845,7 @@ def overload4(a: int): ... finally: def a(): -@@ -323,18 +327,22 @@ +@@ -323,18 +328,22 @@ def a(): return 1 @@ -794,4 +868,23 @@ def overload4(a: int): ... finally: def a(): +@@ -388,10 +397,12 @@ + + # In preview, we preserve these newlines at the start of functions: + def preserved1(): ++ + return 1 + + + def preserved2(): ++ + pass + + +@@ -413,4 +424,5 @@ + + # And we discard empty lines after the first: + def partially_preserved1(): ++ + return 1 ``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__indent.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__indent.py.snap index 1609cf657e9d7..01cfbcf7ec6d9 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__indent.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__indent.py.snap @@ -149,6 +149,21 @@ def test6 (): ``` +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -55,6 +55,7 @@ + + + def test6 (): ++ + print("Format") + print(3 + 4) + print("Format to fix indentation" ) +``` + + ### Output 2 ``` indent-style = tab @@ -228,6 +243,21 @@ def test6 (): ``` +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -55,6 +55,7 @@ + + + def test6 (): ++ + print("Format") + print(3 + 4) + print("Format to fix indentation") +``` + + ### Output 3 ``` indent-style = space @@ -305,3 +335,18 @@ def test6 (): print(3 + 4) print("Format to fix indentation") ``` + + +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -55,6 +55,7 @@ + + + def test6 (): ++ + print("Format") + print(3 + 4) + print("Format to fix indentation") +``` From 16bcabe85ad7d846515fc3dd0be419e771ae3269 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Wed, 29 Oct 2025 12:57:47 -0400 Subject: [PATCH 2/9] add another range formatting test --- .../fixtures/ruff/range_formatting/indent.py | 6 +++++ .../format@range_formatting__indent.py.snap | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/indent.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/indent.py index 1fb1522aa040f..e10ffe55ee7f9 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/indent.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/indent.py @@ -61,3 +61,9 @@ def test6 (): print("Format" ) print(3 + 4) print("Format to fix indentation" ) + + +def test7 (): + print("Format" ) + print(3 + 4) + print("Format to fix indentation" ) diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__indent.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__indent.py.snap index 01cfbcf7ec6d9..213c843da1542 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__indent.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__indent.py.snap @@ -67,6 +67,12 @@ def test6 (): print("Format" ) print(3 + 4) print("Format to fix indentation" ) + + +def test7 (): + print("Format" ) + print(3 + 4) + print("Format to fix indentation" ) ``` ## Outputs @@ -146,6 +152,12 @@ def test6 (): print("Format") print(3 + 4) print("Format to fix indentation" ) + + +def test7 (): + print("Format") + print(3 + 4) + print("Format to fix indentation" ) ``` @@ -240,6 +252,12 @@ def test6 (): print("Format") print(3 + 4) print("Format to fix indentation") + + +def test7 (): + print("Format") + print(3 + 4) + print("Format to fix indentation") ``` @@ -334,6 +352,12 @@ def test6 (): print("Format") print(3 + 4) print("Format to fix indentation") + + +def test7 (): + print("Format") + print(3 + 4) + print("Format to fix indentation") ``` From 758e207694dbe4cdc441a2f8fd17b40b470701e2 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Wed, 29 Oct 2025 13:03:35 -0400 Subject: [PATCH 3/9] test nested functions --- .../resources/test/fixtures/ruff/newlines.py | 12 ++++++ .../tests/snapshots/format@newlines.py.snap | 38 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py index e9625e662c99d..5ff3c0fe96965 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py @@ -346,6 +346,18 @@ def preserved2(): pass +def preserved3(): + + def inner(): ... + +def preserved4(): + + def inner(): + print("with a body") + return 1 + + return 2 + # But we still discard these newlines: def removed1(): diff --git a/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap index 0c418c55fe10b..e0ec26fc4e4dd 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap @@ -352,6 +352,18 @@ def preserved2(): pass +def preserved3(): + + def inner(): ... + +def preserved4(): + + def inner(): + print("with a body") + return 1 + + return 2 + # But we still discard these newlines: def removed1(): @@ -779,6 +791,18 @@ def preserved2(): pass +def preserved3(): + def inner(): ... + + +def preserved4(): + def inner(): + print("with a body") + return 1 + + return 2 + + # But we still discard these newlines: def removed1(): "Docstring" @@ -868,7 +892,7 @@ def partially_preserved1(): finally: def a(): -@@ -388,10 +397,12 @@ +@@ -388,18 +397,22 @@ # In preview, we preserve these newlines at the start of functions: def preserved1(): @@ -881,7 +905,17 @@ def partially_preserved1(): pass -@@ -413,4 +424,5 @@ + def preserved3(): ++ + def inner(): ... + + + def preserved4(): ++ + def inner(): + print("with a body") + return 1 +@@ -425,4 +438,5 @@ # And we discard empty lines after the first: def partially_preserved1(): From f424237fcfafa86c68f9d9dc45a0eb5aad520dd5 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Wed, 29 Oct 2025 13:19:05 -0400 Subject: [PATCH 4/9] test ellipsis with trailing comments --- .../resources/test/fixtures/ruff/newlines.py | 10 +++++++ .../tests/snapshots/format@newlines.py.snap | 28 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py index 5ff3c0fe96965..c18637203d0b7 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py @@ -358,6 +358,11 @@ def inner(): return 2 +def preserved5(): + + ... + # trailing comment prevents collapsing the stub + # But we still discard these newlines: def removed1(): @@ -379,6 +384,11 @@ def removed3(): ... +def removed4(): + + ... # trailing same-line comment does not prevent collapsing the stub + + # And we discard empty lines after the first: def partially_preserved1(): diff --git a/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap index e0ec26fc4e4dd..2fa0deba41d54 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap @@ -364,6 +364,11 @@ def preserved4(): return 2 +def preserved5(): + + ... + # trailing comment prevents collapsing the stub + # But we still discard these newlines: def removed1(): @@ -385,6 +390,11 @@ def removed3(): ... +def removed4(): + + ... # trailing same-line comment does not prevent collapsing the stub + + # And we discard empty lines after the first: def partially_preserved1(): @@ -803,6 +813,11 @@ def preserved4(): return 2 +def preserved5(): + ... + # trailing comment prevents collapsing the stub + + # But we still discard these newlines: def removed1(): "Docstring" @@ -819,6 +834,9 @@ def removed2(): def removed3(): ... +def removed4(): ... # trailing same-line comment does not prevent collapsing the stub + + # And we discard empty lines after the first: def partially_preserved1(): return 1 @@ -915,7 +933,15 @@ def partially_preserved1(): def inner(): print("with a body") return 1 -@@ -425,4 +438,5 @@ +@@ -408,6 +421,7 @@ + + + def preserved5(): ++ + ... + # trailing comment prevents collapsing the stub + +@@ -433,4 +447,5 @@ # And we discard empty lines after the first: def partially_preserved1(): From 84fbb6e08f5dd922159b287b17528caf5b73c3c0 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Wed, 29 Oct 2025 14:43:29 -0400 Subject: [PATCH 5/9] preserve newlines before comments --- .../resources/test/fixtures/ruff/newlines.py | 14 +++---- .../src/statement/suite.rs | 1 - .../tests/snapshots/format@newlines.py.snap | 38 +++++++++++-------- 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py index c18637203d0b7..6c30a8f2e2429 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py @@ -364,27 +364,27 @@ def preserved5(): # trailing comment prevents collapsing the stub -# But we still discard these newlines: -def removed1(): +def preserved6(): - "Docstring" + # Comment return 1 -def removed2(): +# But we still discard these newlines: +def removed1(): - # Comment + "Docstring" return 1 -def removed3(): +def removed2(): ... -def removed4(): +def removed3(): ... # trailing same-line comment does not prevent collapsing the stub diff --git a/crates/ruff_python_formatter/src/statement/suite.rs b/crates/ruff_python_formatter/src/statement/suite.rs index 1415d712d5fca..54eff3fdbff09 100644 --- a/crates/ruff_python_formatter/src/statement/suite.rs +++ b/crates/ruff_python_formatter/src/statement/suite.rs @@ -177,7 +177,6 @@ impl FormatRule> for FormatSuite { is_allow_newline_after_block_open_enabled(f.context()) && matches!(self.kind, SuiteKind::Function) && matches!(first, SuiteChildStatement::Other(_)) - && !comments.has_leading(first) && !contains_only_an_ellipsis(statements, f.context().comments()); if allow_newline_after_block_open diff --git a/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap index 2fa0deba41d54..b90ed795aa079 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap @@ -370,27 +370,27 @@ def preserved5(): # trailing comment prevents collapsing the stub -# But we still discard these newlines: -def removed1(): +def preserved6(): - "Docstring" + # Comment return 1 -def removed2(): +# But we still discard these newlines: +def removed1(): - # Comment + "Docstring" return 1 -def removed3(): +def removed2(): ... -def removed4(): +def removed3(): ... # trailing same-line comment does not prevent collapsing the stub @@ -818,23 +818,23 @@ def preserved5(): # trailing comment prevents collapsing the stub -# But we still discard these newlines: -def removed1(): - "Docstring" +def preserved6(): + # Comment return 1 -def removed2(): - # Comment +# But we still discard these newlines: +def removed1(): + "Docstring" return 1 -def removed3(): ... +def removed2(): ... -def removed4(): ... # trailing same-line comment does not prevent collapsing the stub +def removed3(): ... # trailing same-line comment does not prevent collapsing the stub # And we discard empty lines after the first: @@ -933,7 +933,7 @@ def partially_preserved1(): def inner(): print("with a body") return 1 -@@ -408,6 +421,7 @@ +@@ -408,11 +421,13 @@ def preserved5(): @@ -941,7 +941,13 @@ def partially_preserved1(): ... # trailing comment prevents collapsing the stub -@@ -433,4 +447,5 @@ + + def preserved6(): ++ + # Comment + + return 1 +@@ -433,4 +448,5 @@ # And we discard empty lines after the first: def partially_preserved1(): From 9ec86505867207e24d125acb33f4fb1a9d891450 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Wed, 29 Oct 2025 15:24:05 -0400 Subject: [PATCH 6/9] show bug from ecosystem check we're adding a blank line before a comment if the there's a blank line after it --- .../resources/test/fixtures/ruff/newlines.py | 29 ++++++++ .../tests/snapshots/format@newlines.py.snap | 70 ++++++++++++++++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py index 6c30a8f2e2429..47a2b6a26a59a 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py @@ -371,6 +371,15 @@ def preserved6(): return 1 +def preserved7(): + + # comment + # another line + # and a third + + return 0 + + # But we still discard these newlines: def removed1(): @@ -394,3 +403,23 @@ def partially_preserved1(): return 1 + + +# We only preserve blank lines, not add new ones +def untouched1(): + # comment + + return 0 + + +def untouched2(): + # comment + return 0 + + +def untouched3(): + # comment + # another line + # and a third + + return 0 diff --git a/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap index b90ed795aa079..719c04e937af9 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap @@ -377,6 +377,15 @@ def preserved6(): return 1 +def preserved7(): + + # comment + # another line + # and a third + + return 0 + + # But we still discard these newlines: def removed1(): @@ -400,6 +409,26 @@ def partially_preserved1(): return 1 + + +# We only preserve blank lines, not add new ones +def untouched1(): + # comment + + return 0 + + +def untouched2(): + # comment + return 0 + + +def untouched3(): + # comment + # another line + # and a third + + return 0 ``` ## Output @@ -824,6 +853,14 @@ def preserved6(): return 1 +def preserved7(): + # comment + # another line + # and a third + + return 0 + + # But we still discard these newlines: def removed1(): "Docstring" @@ -840,6 +877,26 @@ def removed3(): ... # trailing same-line comment does not prevent collapsing th # And we discard empty lines after the first: def partially_preserved1(): return 1 + + +# We only preserve blank lines, not add new ones +def untouched1(): + # comment + + return 0 + + +def untouched2(): + # comment + return 0 + + +def untouched3(): + # comment + # another line + # and a third + + return 0 ``` @@ -933,7 +990,7 @@ def partially_preserved1(): def inner(): print("with a body") return 1 -@@ -408,11 +421,13 @@ +@@ -408,17 +421,20 @@ def preserved5(): @@ -947,10 +1004,19 @@ def partially_preserved1(): # Comment return 1 -@@ -433,4 +448,5 @@ + + + def preserved7(): ++ + # comment + # another line + # and a third +@@ -441,6 +457,7 @@ # And we discard empty lines after the first: def partially_preserved1(): + return 1 + + ``` From 16b9b92182b7457478281e0cdfe4d14c74f33d13 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Wed, 29 Oct 2025 16:23:02 -0400 Subject: [PATCH 7/9] lines_before comment start, if present --- crates/ruff_python_formatter/src/statement/suite.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/ruff_python_formatter/src/statement/suite.rs b/crates/ruff_python_formatter/src/statement/suite.rs index 54eff3fdbff09..a74da9dfa0a3a 100644 --- a/crates/ruff_python_formatter/src/statement/suite.rs +++ b/crates/ruff_python_formatter/src/statement/suite.rs @@ -179,9 +179,12 @@ impl FormatRule> for FormatSuite { && matches!(first, SuiteChildStatement::Other(_)) && !contains_only_an_ellipsis(statements, f.context().comments()); - if allow_newline_after_block_open - && lines_before(first.start(), f.context().source()) > 1 - { + let start = comments + .leading(first) + .first() + .map_or_else(|| first.start(), Ranged::start); + + if allow_newline_after_block_open && lines_before(start, f.context().source()) > 1 { empty_line().fmt(f)?; } From 89b2621ac09528d86231b52c6c4584da88ddf9a4 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Fri, 31 Oct 2025 10:44:24 -0400 Subject: [PATCH 8/9] avoid suite formatting for single-ellipsis bodies this avoids needing to keep the two should_collapse checks in sync --- .../resources/test/fixtures/ruff/newlines.py | 5 ++++ .../src/statement/clause.rs | 13 ++------- .../src/statement/suite.rs | 29 ++++++++++--------- .../tests/snapshots/format@newlines.py.snap | 19 +++++++++++- 4 files changed, 42 insertions(+), 24 deletions(-) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py index 47a2b6a26a59a..18c810ead8aa8 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py @@ -380,6 +380,11 @@ def preserved7(): return 0 +def preserved8(): # this also prevents collapsing the stub + + ... + + # But we still discard these newlines: def removed1(): diff --git a/crates/ruff_python_formatter/src/statement/clause.rs b/crates/ruff_python_formatter/src/statement/clause.rs index a5c172f4f8743..1554c30d0fbb7 100644 --- a/crates/ruff_python_formatter/src/statement/clause.rs +++ b/crates/ruff_python_formatter/src/statement/clause.rs @@ -8,7 +8,7 @@ use ruff_python_trivia::{SimpleToken, SimpleTokenKind, SimpleTokenizer}; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::comments::{SourceComment, leading_alternate_branch_comments, trailing_comments}; -use crate::statement::suite::{SuiteKind, contains_only_an_ellipsis}; +use crate::statement::suite::{SuiteKind, as_only_an_ellipsis}; use crate::verbatim::write_suppressed_clause_header; use crate::{has_skip_comment, prelude::*}; @@ -449,17 +449,10 @@ impl Format> for FormatClauseBody<'_> { || matches!(self.kind, SuiteKind::Function | SuiteKind::Class); if should_collapse_stub - && contains_only_an_ellipsis(self.body, f.context().comments()) + && let Some(ellipsis) = as_only_an_ellipsis(self.body, f.context().comments()) && self.trailing_comments.is_empty() { - write!( - f, - [ - space(), - self.body.format().with_options(self.kind), - hard_line_break() - ] - ) + write!(f, [space(), ellipsis.format(), hard_line_break()]) } else { write!( f, diff --git a/crates/ruff_python_formatter/src/statement/suite.rs b/crates/ruff_python_formatter/src/statement/suite.rs index a74da9dfa0a3a..d1bc6d3716c51 100644 --- a/crates/ruff_python_formatter/src/statement/suite.rs +++ b/crates/ruff_python_formatter/src/statement/suite.rs @@ -172,12 +172,11 @@ impl FormatRule> for FormatSuite { ) } else { // Allow an empty line after a function header in preview, if the function has no - // docstring and no initial comment and doesn't consist of a single ellipsis. + // docstring and no initial comment. let allow_newline_after_block_open = is_allow_newline_after_block_open_enabled(f.context()) && matches!(self.kind, SuiteKind::Function) - && matches!(first, SuiteChildStatement::Other(_)) - && !contains_only_an_ellipsis(statements, f.context().comments()); + && matches!(first, SuiteChildStatement::Other(_)); let start = comments .leading(first) @@ -747,17 +746,21 @@ fn stub_suite_can_omit_empty_line(preceding: &Stmt, following: &Stmt, f: &PyForm /// Returns `true` if a function or class body contains only an ellipsis with no comments. pub(crate) fn contains_only_an_ellipsis(body: &[Stmt], comments: &Comments) -> bool { - match body { - [Stmt::Expr(ast::StmtExpr { value, .. })] => { - let [node] = body else { - return false; - }; - value.is_ellipsis_literal_expr() - && !comments.has_leading(node) - && !comments.has_trailing_own_line(node) - } - _ => false, + as_only_an_ellipsis(body, comments).is_some() +} + +/// Returns `Some(Stmt::Ellipsis)` if a function or class body contains only an ellipsis with no +/// comments. +pub(crate) fn as_only_an_ellipsis<'a>(body: &'a [Stmt], comments: &Comments) -> Option<&'a Stmt> { + if let [node @ Stmt::Expr(ast::StmtExpr { value, .. })] = body + && value.is_ellipsis_literal_expr() + && !comments.has_leading(node) + && !comments.has_trailing_own_line(node) + { + return Some(node); } + + None } /// Returns `true` if a [`Stmt`] is a class or function definition. diff --git a/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap index 719c04e937af9..260de915fc957 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@newlines.py.snap @@ -386,6 +386,11 @@ def preserved7(): return 0 +def preserved8(): # this also prevents collapsing the stub + + ... + + # But we still discard these newlines: def removed1(): @@ -861,6 +866,10 @@ def preserved7(): return 0 +def preserved8(): # this also prevents collapsing the stub + ... + + # But we still discard these newlines: def removed1(): "Docstring" @@ -1011,7 +1020,15 @@ def untouched3(): # comment # another line # and a third -@@ -441,6 +457,7 @@ +@@ -427,6 +443,7 @@ + + + def preserved8(): # this also prevents collapsing the stub ++ + ... + + +@@ -445,6 +462,7 @@ # And we discard empty lines after the first: def partially_preserved1(): From f70034f0476cffc07ed9d30e4922e7b9db54ee6e Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Fri, 31 Oct 2025 10:45:59 -0400 Subject: [PATCH 9/9] fix comment typo --- crates/ruff_python_formatter/src/statement/suite.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruff_python_formatter/src/statement/suite.rs b/crates/ruff_python_formatter/src/statement/suite.rs index d1bc6d3716c51..9ed32beb763b2 100644 --- a/crates/ruff_python_formatter/src/statement/suite.rs +++ b/crates/ruff_python_formatter/src/statement/suite.rs @@ -236,7 +236,7 @@ impl FormatRule> for FormatSuite { )?; } else { // Preserve empty lines after a stub implementation but don't insert a new one if there isn't any present in the source. - // This is useful when having multiple function overloads that should be grouped to getter by omitting new lines between them. + // This is useful when having multiple function overloads that should be grouped together by omitting new lines between them. let is_preceding_stub_function_without_empty_line = following .is_function_def_stmt() && preceding