diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/blank_line_after_nested_stub_class.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/blank_line_after_nested_stub_class.options.json new file mode 100644 index 00000000000000..e8f35d9827789d --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/blank_line_after_nested_stub_class.options.json @@ -0,0 +1,6 @@ +[ + { + "source_type": "Stub", + "preview": "enabled" + } +] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/blank_line_after_nested_stub_class.pyi b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/blank_line_after_nested_stub_class.pyi new file mode 100644 index 00000000000000..3ce823d9ae765c --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/blank_line_after_nested_stub_class.pyi @@ -0,0 +1,183 @@ +class Top1: + pass +class Top2: + pass + +class Top: + class Ellipsis: ... + class Ellipsis: ... + +class Top: + class Ellipsis: ... + class Pass: + pass + +class Top: + class Ellipsis: ... + class_variable = 1 + +class Top: + class TrailingComment: + pass + # comment + class Other: + pass + +class Top: + class CommentWithEllipsis: ... + # comment + class Other: ... + +class Top: + class TrailingCommentWithMultipleBlankLines: + pass + + + # comment + class Other: + pass + +class Top: + class Nested: + pass + + # comment + class LeadingComment: + pass + +class Top: + @decorator + class Ellipsis: ... + class Ellipsis: ... + +class Top: + @decorator + class Ellipsis: ... + @decorator + class Ellipsis: ... + +class Top: + @decorator + class Ellipsis: ... + @decorator + class Pass: + pass + +class Top: + class Foo: + pass + + + + + class AfterMultipleEmptyLines: + pass + +class Top: + class Nested11: + class Nested12: + pass + class Nested21: + pass + +class Top: + class Nested11: + class Nested12: + pass + # comment + class Nested21: + pass + +class Top: + class Nested11: + class Nested12: + pass + # comment + class Nested21: + pass + # comment + +class Top1: + class Nested: + pass +class Top2: + pass + +class Top1: + class Nested: + pass + # comment +class Top2: + pass + +class Top1: + class Nested: + pass +# comment +class Top2: + pass + +if foo: + class Nested1: + pass + class Nested2: + pass +else: + pass + +if foo: + class Nested1: + pass + class Nested2: + pass + # comment +elif bar: + class Nested1: + pass +# comment +else: + pass + +if top1: + class Nested: + pass +if top2: + pass + +if top1: + class Nested: + pass + # comment +if top2: + pass + +if top1: + class Nested: + pass +# comment +if top2: + pass + +try: + class Try: + pass +except: + class Except: + pass +foo = 1 + +match foo: + case 1: + class Nested: + pass + case 2: + class Nested: + pass + case _: + class Nested: + pass +foo = 1 + +class Eof: + class Nested: + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/blank_line_after_nested_stub_class_eof.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/blank_line_after_nested_stub_class_eof.options.json new file mode 100644 index 00000000000000..e8f35d9827789d --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/blank_line_after_nested_stub_class_eof.options.json @@ -0,0 +1,6 @@ +[ + { + "source_type": "Stub", + "preview": "enabled" + } +] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/blank_line_after_nested_stub_class_eof.pyi b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/blank_line_after_nested_stub_class_eof.pyi new file mode 100644 index 00000000000000..50007377e19e1e --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/blank_line_after_nested_stub_class_eof.pyi @@ -0,0 +1,17 @@ +# A separate file to test out the behavior when there are a mix of blank lines +# and comments at EOF just after a nested stub class. + +class Top: + class Nested1: + class Nested12: + pass + # comment + class Nested2: + pass + + + +# comment + + + diff --git a/crates/ruff_python_formatter/src/comments/format.rs b/crates/ruff_python_formatter/src/comments/format.rs index abf4833dac59d3..a180f5c371be4d 100644 --- a/crates/ruff_python_formatter/src/comments/format.rs +++ b/crates/ruff_python_formatter/src/comments/format.rs @@ -1,8 +1,7 @@ use std::borrow::Cow; use ruff_formatter::{format_args, write, FormatError, FormatOptions, SourceCode}; -use ruff_python_ast::PySourceType; -use ruff_python_ast::{AnyNodeRef, AstNode}; +use ruff_python_ast::{AnyNodeRef, AstNode, NodeKind, PySourceType}; use ruff_python_trivia::{ is_pragma_comment, lines_after, lines_after_ignoring_trivia, lines_before, }; @@ -11,6 +10,8 @@ use ruff_text_size::{Ranged, TextLen, TextRange}; use crate::comments::{CommentLinePosition, SourceComment}; use crate::context::NodeLevel; use crate::prelude::*; +use crate::preview::is_blank_line_after_nested_stub_class_enabled; +use crate::statement::suite::blank_line_after_nested_stub_class_condition; /// Formats the leading comments of a node. pub(crate) fn leading_node_comments(node: &T) -> FormatLeadingComments @@ -85,45 +86,61 @@ pub(crate) struct FormatLeadingAlternateBranchComments<'a> { impl Format> for FormatLeadingAlternateBranchComments<'_> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { + let empty_line_condition = if is_blank_line_after_nested_stub_class_enabled(f.context()) { + self.last_node.map_or(false, |preceding| { + blank_line_after_nested_stub_class_condition(preceding, None, &f) + }) + } else { + false + }; + if let Some(first_leading) = self.comments.first() { - // Leading comments only preserves the lines after the comment but not before. - // Insert the necessary lines. - write!( - f, - [empty_lines(lines_before( - first_leading.start(), - f.context().source() - ))] - )?; + if empty_line_condition { + write!(f, [empty_line()])?; + } else { + // Leading comments only preserves the lines after the comment but not before. + // Insert the necessary lines. + write!( + f, + [empty_lines(lines_before( + first_leading.start(), + f.context().source() + ))] + )?; + } write!(f, [leading_comments(self.comments)])?; } else if let Some(last_preceding) = self.last_node { - // The leading comments formatting ensures that it preserves the right amount of lines - // after We need to take care of this ourselves, if there's no leading `else` comment. - // Since the `last_node` could be a compound node, we need to skip _all_ trivia. - // - // For example, here, when formatting the `if` statement, the `last_node` (the `while`) - // would end at the end of `pass`, but we want to skip _all_ comments: - // ```python - // if True: - // while True: - // pass - // # comment - // - // # comment - // else: - // ... - // ``` - // - // `lines_after_ignoring_trivia` is safe here, as we _know_ that the `else` doesn't - // have any leading comments. - write!( - f, - [empty_lines(lines_after_ignoring_trivia( - last_preceding.end(), - f.context().source() - ))] - )?; + if empty_line_condition { + write!(f, [empty_line()])?; + } else { + // The leading comments formatting ensures that it preserves the right amount of lines + // after We need to take care of this ourselves, if there's no leading `else` comment. + // Since the `last_node` could be a compound node, we need to skip _all_ trivia. + // + // For example, here, when formatting the `if` statement, the `last_node` (the `while`) + // would end at the end of `pass`, but we want to skip _all_ comments: + // ```python + // if True: + // while True: + // pass + // # comment + // + // # comment + // else: + // ... + // ``` + // + // `lines_after_ignoring_trivia` is safe here, as we _know_ that the `else` doesn't + // have any leading comments. + write!( + f, + [empty_lines(lines_after_ignoring_trivia( + last_preceding.end(), + f.context().source() + ))] + )?; + } } Ok(()) @@ -513,14 +530,32 @@ fn strip_comment_prefix(comment_text: &str) -> FormatResult<&str> { /// ``` /// /// This builder will insert two empty lines before the comment. +/// +/// # Preview +/// +/// For preview style, this builder will insert a single empty line after a +/// class definition in a stub file. +/// +/// For example, given: +/// ```python +/// class Foo: +/// pass +/// # comment +/// ``` +/// +/// This builder will insert a single empty line before the comment. pub(crate) fn empty_lines_before_trailing_comments<'a>( f: &PyFormatter, comments: &'a [SourceComment], + node_kind: NodeKind, ) -> FormatEmptyLinesBeforeTrailingComments<'a> { // Black has different rules for stub vs. non-stub and top level vs. indented let empty_lines = match (f.options().source_type(), f.context().node_level()) { (PySourceType::Stub, NodeLevel::TopLevel(_)) => 1, - (PySourceType::Stub, _) => 0, + (PySourceType::Stub, _) => u32::from( + is_blank_line_after_nested_stub_class_enabled(f.context()) + && node_kind == NodeKind::StmtClassDef, + ), (_, NodeLevel::TopLevel(_)) => 2, (_, _) => 1, }; diff --git a/crates/ruff_python_formatter/src/preview.rs b/crates/ruff_python_formatter/src/preview.rs index f610da5fd41d36..0f7d8d1d323c8a 100644 --- a/crates/ruff_python_formatter/src/preview.rs +++ b/crates/ruff_python_formatter/src/preview.rs @@ -48,6 +48,13 @@ pub(crate) const fn is_wrap_multiple_context_managers_in_parens_enabled( context.is_preview() } +/// Returns `true` if the [`blank_line_after_nested_stub_class`](https://github.com/astral-sh/ruff/issues/8891) preview style is enabled. +pub(crate) const fn is_blank_line_after_nested_stub_class_enabled( + context: &PyFormatContext, +) -> bool { + context.is_preview() +} + /// Returns `true` if the [`module_docstring_newlines`](https://github.com/astral-sh/ruff/issues/7995) preview style is enabled. pub(crate) const fn is_module_docstring_newlines_enabled(context: &PyFormatContext) -> bool { context.is_preview() diff --git a/crates/ruff_python_formatter/src/statement/stmt_class_def.rs b/crates/ruff_python_formatter/src/statement/stmt_class_def.rs index 8c1d1c3944033e..a53004a0694b92 100644 --- a/crates/ruff_python_formatter/src/statement/stmt_class_def.rs +++ b/crates/ruff_python_formatter/src/statement/stmt_class_def.rs @@ -1,5 +1,5 @@ use ruff_formatter::write; -use ruff_python_ast::{Decorator, StmtClassDef}; +use ruff_python_ast::{Decorator, NodeKind, StmtClassDef}; use ruff_python_trivia::lines_after_ignoring_end_of_line_trivia; use ruff_text_size::Ranged; @@ -152,7 +152,10 @@ impl FormatNodeRule for FormatStmtClassDef { // // # comment // ``` - empty_lines_before_trailing_comments(f, comments.trailing(item)).fmt(f) + empty_lines_before_trailing_comments(f, comments.trailing(item), NodeKind::StmtClassDef) + .fmt(f)?; + + Ok(()) } fn fmt_dangling_comments( diff --git a/crates/ruff_python_formatter/src/statement/stmt_function_def.rs b/crates/ruff_python_formatter/src/statement/stmt_function_def.rs index 5ad5f2f53904ea..17957f5df1dd87 100644 --- a/crates/ruff_python_formatter/src/statement/stmt_function_def.rs +++ b/crates/ruff_python_formatter/src/statement/stmt_function_def.rs @@ -1,5 +1,5 @@ use ruff_formatter::write; -use ruff_python_ast::StmtFunctionDef; +use ruff_python_ast::{NodeKind, StmtFunctionDef}; use crate::comments::format::{ empty_lines_after_leading_comments, empty_lines_before_trailing_comments, @@ -87,7 +87,8 @@ impl FormatNodeRule for FormatStmtFunctionDef { // // # comment // ``` - empty_lines_before_trailing_comments(f, comments.trailing(item)).fmt(f) + empty_lines_before_trailing_comments(f, comments.trailing(item), NodeKind::StmtFunctionDef) + .fmt(f) } fn fmt_dangling_comments( diff --git a/crates/ruff_python_formatter/src/statement/suite.rs b/crates/ruff_python_formatter/src/statement/suite.rs index 7623ef9449e90b..962a41f9a62c88 100644 --- a/crates/ruff_python_formatter/src/statement/suite.rs +++ b/crates/ruff_python_formatter/src/statement/suite.rs @@ -12,8 +12,8 @@ use crate::context::{NodeLevel, TopLevelStatementPosition, WithIndentLevel, With use crate::expression::expr_string_literal::ExprStringLiteralKind; use crate::prelude::*; use crate::preview::{ - is_dummy_implementations_enabled, is_module_docstring_newlines_enabled, - is_no_blank_line_before_class_docstring_enabled, + is_blank_line_after_nested_stub_class_enabled, is_dummy_implementations_enabled, + is_module_docstring_newlines_enabled, is_no_blank_line_before_class_docstring_enabled, }; use crate::statement::stmt_expr::FormatStmtExpr; use crate::verbatim::{ @@ -472,15 +472,28 @@ fn stub_file_empty_lines( || !stub_suite_can_omit_empty_line(preceding, following, f); match kind { SuiteKind::TopLevel => { - if empty_line_condition { + if empty_line_condition + || (is_blank_line_after_nested_stub_class_enabled(f.context()) + && blank_line_after_nested_stub_class_condition( + preceding.into(), + Some(following.into()), + f, + )) + { empty_line().fmt(f) } else { hard_line_break().fmt(f) } } SuiteKind::Class | SuiteKind::Other | SuiteKind::Function => { - if empty_line_condition - && lines_after_ignoring_end_of_line_trivia(preceding.end(), source) > 1 + if (empty_line_condition + && lines_after_ignoring_end_of_line_trivia(preceding.end(), source) > 1) + || (is_blank_line_after_nested_stub_class_enabled(f.context()) + && blank_line_after_nested_stub_class_condition( + preceding.into(), + Some(following.into()), + f, + )) { empty_line().fmt(f) } else { @@ -490,6 +503,40 @@ fn stub_file_empty_lines( } } +/// Checks if an empty line should be inserted between the preceding and, optionally, +/// the following node according to the [`blank_line_after_nested_stub_class`] +/// (https://github.com/astral-sh/ruff/issues/8891) preview style. +/// +/// If `following` is `None`, then the preceding node is the last node in the suite. +pub(crate) fn blank_line_after_nested_stub_class_condition( + preceding: AnyNodeRef<'_>, + following: Option>, + f: &PyFormatter, +) -> bool { + let comments = f.context().comments(); + match preceding.as_stmt_class_def() { + Some(class) if contains_only_an_ellipsis(&class.body, comments) => { + !class.decorator_list.is_empty() + || match following { + Some(AnyNodeRef::StmtClassDef(ast::StmtClassDef { + body, + decorator_list, + .. + })) => !contains_only_an_ellipsis(body, comments) || !decorator_list.is_empty(), + Some(AnyNodeRef::StmtFunctionDef(_)) | None => true, + _ => false, + } + } + Some(_) => !comments.has_trailing_own_line(preceding), + None => std::iter::successors( + preceding.last_child_in_body(), + AnyNodeRef::last_child_in_body, + ) + .take_while(|last_child| !comments.has_trailing_own_line(*last_child)) + .any(|last_child| last_child.is_stmt_class_def()), + } +} + /// Only a function to compute it lazily fn stub_suite_can_omit_empty_line(preceding: &Stmt, following: &Stmt, f: &PyFormatter) -> bool { // Two subsequent class definitions that both have an ellipsis only body diff --git a/crates/ruff_python_formatter/tests/snapshots/format@stub_files__blank_line_after_nested_stub_class.pyi.snap b/crates/ruff_python_formatter/tests/snapshots/format@stub_files__blank_line_after_nested_stub_class.pyi.snap new file mode 100644 index 00000000000000..67f3160940b4f2 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@stub_files__blank_line_after_nested_stub_class.pyi.snap @@ -0,0 +1,418 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/blank_line_after_nested_stub_class.pyi +--- +## Input +```python +class Top1: + pass +class Top2: + pass + +class Top: + class Ellipsis: ... + class Ellipsis: ... + +class Top: + class Ellipsis: ... + class Pass: + pass + +class Top: + class Ellipsis: ... + class_variable = 1 + +class Top: + class TrailingComment: + pass + # comment + class Other: + pass + +class Top: + class CommentWithEllipsis: ... + # comment + class Other: ... + +class Top: + class TrailingCommentWithMultipleBlankLines: + pass + + + # comment + class Other: + pass + +class Top: + class Nested: + pass + + # comment + class LeadingComment: + pass + +class Top: + @decorator + class Ellipsis: ... + class Ellipsis: ... + +class Top: + @decorator + class Ellipsis: ... + @decorator + class Ellipsis: ... + +class Top: + @decorator + class Ellipsis: ... + @decorator + class Pass: + pass + +class Top: + class Foo: + pass + + + + + class AfterMultipleEmptyLines: + pass + +class Top: + class Nested11: + class Nested12: + pass + class Nested21: + pass + +class Top: + class Nested11: + class Nested12: + pass + # comment + class Nested21: + pass + +class Top: + class Nested11: + class Nested12: + pass + # comment + class Nested21: + pass + # comment + +class Top1: + class Nested: + pass +class Top2: + pass + +class Top1: + class Nested: + pass + # comment +class Top2: + pass + +class Top1: + class Nested: + pass +# comment +class Top2: + pass + +if foo: + class Nested1: + pass + class Nested2: + pass +else: + pass + +if foo: + class Nested1: + pass + class Nested2: + pass + # comment +elif bar: + class Nested1: + pass +# comment +else: + pass + +if top1: + class Nested: + pass +if top2: + pass + +if top1: + class Nested: + pass + # comment +if top2: + pass + +if top1: + class Nested: + pass +# comment +if top2: + pass + +try: + class Try: + pass +except: + class Except: + pass +foo = 1 + +match foo: + case 1: + class Nested: + pass + case 2: + class Nested: + pass + case _: + class Nested: + pass +foo = 1 + +class Eof: + class Nested: + pass +``` + +## Outputs +### Output 1 +``` +indent-style = space +line-width = 88 +indent-width = 4 +quote-style = Double +line-ending = LineFeed +magic-trailing-comma = Respect +docstring-code = Disabled +docstring-code-line-width = "dynamic" +preview = Enabled +target_version = Py38 +``` + +```python +class Top1: + pass + +class Top2: + pass + +class Top: + class Ellipsis: ... + class Ellipsis: ... + +class Top: + class Ellipsis: ... + + class Pass: + pass + +class Top: + class Ellipsis: ... + class_variable = 1 + +class Top: + class TrailingComment: + pass + + # comment + class Other: + pass + +class Top: + class CommentWithEllipsis: ... + # comment + class Other: ... + +class Top: + class TrailingCommentWithMultipleBlankLines: + pass + + # comment + class Other: + pass + +class Top: + class Nested: + pass + + # comment + class LeadingComment: + pass + +class Top: + @decorator + class Ellipsis: ... + + class Ellipsis: ... + +class Top: + @decorator + class Ellipsis: ... + + @decorator + class Ellipsis: ... + +class Top: + @decorator + class Ellipsis: ... + + @decorator + class Pass: + pass + +class Top: + class Foo: + pass + + class AfterMultipleEmptyLines: + pass + +class Top: + class Nested11: + class Nested12: + pass + + class Nested21: + pass + +class Top: + class Nested11: + class Nested12: + pass + + # comment + + class Nested21: + pass + +class Top: + class Nested11: + class Nested12: + pass + + # comment + class Nested21: + pass + + # comment + +class Top1: + class Nested: + pass + +class Top2: + pass + +class Top1: + class Nested: + pass + + # comment + +class Top2: + pass + +class Top1: + class Nested: + pass + +# comment +class Top2: + pass + +if foo: + class Nested1: + pass + + class Nested2: + pass + +else: + pass + +if foo: + class Nested1: + pass + + class Nested2: + pass + + # comment +elif bar: + class Nested1: + pass + +# comment +else: + pass + +if top1: + class Nested: + pass + +if top2: + pass + +if top1: + class Nested: + pass + + # comment +if top2: + pass + +if top1: + class Nested: + pass + +# comment +if top2: + pass + +try: + class Try: + pass + +except: + class Except: + pass + +foo = 1 + +match foo: + case 1: + class Nested: + pass + + case 2: + class Nested: + pass + + case _: + class Nested: + pass + +foo = 1 + +class Eof: + class Nested: + pass +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@stub_files__blank_line_after_nested_stub_class_eof.pyi.snap b/crates/ruff_python_formatter/tests/snapshots/format@stub_files__blank_line_after_nested_stub_class_eof.pyi.snap new file mode 100644 index 00000000000000..3cba0307c189bb --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@stub_files__blank_line_after_nested_stub_class_eof.pyi.snap @@ -0,0 +1,59 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/blank_line_after_nested_stub_class_eof.pyi +--- +## Input +```python +# A separate file to test out the behavior when there are a mix of blank lines +# and comments at EOF just after a nested stub class. + +class Top: + class Nested1: + class Nested12: + pass + # comment + class Nested2: + pass + + + +# comment + + + +``` + +## Outputs +### Output 1 +``` +indent-style = space +line-width = 88 +indent-width = 4 +quote-style = Double +line-ending = LineFeed +magic-trailing-comma = Respect +docstring-code = Disabled +docstring-code-line-width = "dynamic" +preview = Enabled +target_version = Py38 +``` + +```python +# A separate file to test out the behavior when there are a mix of blank lines +# and comments at EOF just after a nested stub class. + +class Top: + class Nested1: + class Nested12: + pass + + # comment + + class Nested2: + pass + +# comment +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@stub_files__suite.pyi.snap b/crates/ruff_python_formatter/tests/snapshots/format@stub_files__suite.pyi.snap index 9edc789b5fabd2..cea732f045fe3c 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@stub_files__suite.pyi.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@stub_files__suite.pyi.snap @@ -309,4 +309,27 @@ class ComplexStatements: ``` +## Preview changes +```diff +--- Stable ++++ Preview +@@ -110,6 +110,7 @@ + + class InnerClass5: + def a(self): ... ++ + field1 = 1 + + class InnerClass6: +@@ -119,6 +120,7 @@ + + class InnerClass7: + def a(self): ... ++ + print("hi") + + class InnerClass8: +``` + +