Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,19 @@ def foo():
not (aaaaaaaaaaaaaaaaaaaaa[bbbbbbbb, ccccccc]) and dddddddddd < eeeeeeeeeeeeeee
):
pass

# Regression tests for https://github.com/astral-sh/ruff/issues/19226
if '' and (not #
0):
pass

if '' and (not #
(0)
):
pass

if '' and (not
( #
0
)):
pass
22 changes: 4 additions & 18 deletions crates/ruff_python_formatter/src/comments/placement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use std::cmp::Ordering;

use crate::comments::visitor::{CommentPlacement, DecoratedComment};
use crate::expression::expr_slice::{ExprSliceCommentSection, assign_comment_in_slice};
use crate::expression::expr_unary_op::operand_start;
use crate::expression::parentheses::is_expression_parenthesized;
use crate::other::parameters::{
assign_argument_separator_comment_placement, find_parameter_separators,
Expand Down Expand Up @@ -1907,24 +1908,9 @@ fn handle_unary_op_comment<'a>(
unary_op: &'a ast::ExprUnaryOp,
source: &str,
) -> CommentPlacement<'a> {
let mut tokenizer = SimpleTokenizer::new(
source,
TextRange::new(unary_op.start(), unary_op.operand.start()),
)
.skip_trivia();
let op_token = tokenizer.next();
debug_assert!(op_token.is_some_and(|token| matches!(
token.kind,
SimpleTokenKind::Tilde
| SimpleTokenKind::Not
| SimpleTokenKind::Plus
| SimpleTokenKind::Minus
)));
let up_to = tokenizer
.find(|token| token.kind == SimpleTokenKind::LParen)
.map_or(unary_op.operand.start(), |lparen| lparen.start());
if comment.end() < up_to {
CommentPlacement::leading(unary_op, comment)
let up_to = operand_start(unary_op, source);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you update the method description to match our new behavior

if comment.end() < up_to && comment.line_position().is_end_of_line() {
CommentPlacement::dangling(unary_op, comment)
} else {
CommentPlacement::Default(comment)
}
Expand Down
73 changes: 67 additions & 6 deletions crates/ruff_python_formatter/src/expression/expr_unary_op.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use ruff_python_ast::AnyNodeRef;
use ruff_python_ast::ExprUnaryOp;
use ruff_python_ast::UnaryOp;
use ruff_python_ast::parenthesize::parenthesized_range;
use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer};
use ruff_text_size::{Ranged, TextRange, TextSize};

use crate::comments::trailing_comments;
use crate::expression::parentheses::{
Expand Down Expand Up @@ -39,19 +42,43 @@ impl FormatNodeRule<ExprUnaryOp> for FormatExprUnaryOp {
// ```
trailing_comments(dangling).fmt(f)?;

// Insert a line break if the operand has comments but itself is not parenthesized.
// Insert a line break if the operand has comments but itself is not parenthesized or if the
// operand is parenthesized but has a leading comment before the parentheses.
// ```python
// if (
// not
// # comment
// a)
// a):
// pass
//
// if 1 and (
// not
// # comment
// (
// a
// )
// ):
// pass
// ```
let parenthesized_operand_range = parenthesized_range(
operand.into(),
item.into(),
comments.ranges(),
f.context().source(),
);
let has_leading_comments_before_parens = parenthesized_operand_range.is_some_and(|range| {
comments
.leading(operand.as_ref())
.iter()
.any(|comment| comment.start() < range.start())
});
if comments.has_leading(operand.as_ref())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's assign the leading commnts to a variable to avoid retrieving them twice

&& !is_expression_parenthesized(
operand.as_ref().into(),
f.context().comments().ranges(),
f.context().source(),
)
|| has_leading_comments_before_parens
{
hard_line_break().fmt(f)?;
} else if op.is_not() {
Expand All @@ -76,17 +103,51 @@ impl NeedsParentheses for ExprUnaryOp {
context: &PyFormatContext,
) -> OptionalParentheses {
if parent.is_expr_await() {
OptionalParentheses::Always
} else if is_expression_parenthesized(
return OptionalParentheses::Always;
}

if is_expression_parenthesized(
self.operand.as_ref().into(),
context.comments().ranges(),
context.source(),
) {
OptionalParentheses::Never
} else if context.comments().has(self.operand.as_ref()) {
return OptionalParentheses::Never;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to change the logic here too to match the logic for when we insert a hard line break in the unary formatting?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean something like this?

        if !context.comments().has_leading(self.operand.as_ref())
            || is_expression_parenthesized(
                self.operand.as_ref().into(),
                context.comments().ranges(),
                context.source(),
            )
        {
            return OptionalParentheses::Never;
        }

I played with a few variations on this and kept running into instabilities. It seems to be working okay without matching the check exactly, like on main.

@MichaReiser MichaReiser Nov 18, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, more like this:

        let parenthesized_operand_range = parenthesized_range(
            operand.into(),
            item.into(),
            comments.ranges(),
            f.context().source(),
        );
        let leading_operand_comments = comments.leading(operand.as_ref());
        let has_leading_comments_before_parens = parenthesized_operand_range.is_some_and(|range| {
            leading_operand_comments
                .iter()
                .any(|comment| comment.start() < range.start())
        });
        if !leading_operand_comments.is_empty()
            && !is_expression_parenthesized(
                operand.as_ref().into(),
                f.context().comments().ranges(),
                f.context().source(),
            )
            || has_leading_comments_before_parens

It's important that it exactly mirrors the case when we insert a hard line break in the formatting code because any line break will lead to invalid syntax if the if formatting doesn't add parentheses.

Here's an example where your PR produces invalid syntax:

if (
  not  
  # comment
  (a)):
    pass

We should add more tests that exercise the new leading comment placement (may even be true for the trailing comment placement, are there more combinations that you could test?)

}

let operand_start = operand_start(self, context.source());
if context
.comments()
.dangling(self)
.iter()
.any(|comment| comment.end() < operand_start)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can simplify this to returning Multiline when there's any dangling comment.

Does this need to take precedence over the Never case when the operand is parenthesized?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to work in both orders, at least with our current tests.

{
return OptionalParentheses::Multiline;
}

if context.comments().has(self.operand.as_ref()) {
OptionalParentheses::Always
} else {
self.operand.needs_parentheses(self.into(), context)
}
}
}

/// Returns the start of `unary_op`'s operand, or its leading parenthesis, if it has one.
pub(crate) fn operand_start(unary_op: &ExprUnaryOp, source: &str) -> TextSize {
let mut tokenizer = SimpleTokenizer::new(
source,
TextRange::new(unary_op.start(), unary_op.operand.start()),
)
.skip_trivia();
let op_token = tokenizer.next();
debug_assert!(op_token.is_some_and(|token| matches!(
token.kind,
SimpleTokenKind::Tilde
| SimpleTokenKind::Not
| SimpleTokenKind::Plus
| SimpleTokenKind::Minus
)));
tokenizer
.find(|token| token.kind == SimpleTokenKind::LParen)
.map_or(unary_op.operand.start(), |lparen| lparen.start())
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/unary.py
snapshot_kind: text
---
## Input
```python
Expand Down Expand Up @@ -200,6 +199,22 @@ def foo():
not (aaaaaaaaaaaaaaaaaaaaa[bbbbbbbb, ccccccc]) and dddddddddd < eeeeeeeeeeeeeee
):
pass

# Regression tests for https://github.com/astral-sh/ruff/issues/19226
if '' and (not #
0):
pass

if '' and (not #
(0)
):
pass

if '' and (not
( #
0
)):
pass
```

## Output
Expand Down Expand Up @@ -250,31 +265,35 @@ if +(
pass

if (
not
# comment
not aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
):
pass


if (
~
# comment
~aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
):
pass

if (
-
# comment
-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
):
pass


if (
+
# comment
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
):
pass
Expand All @@ -283,8 +302,9 @@ if (

if (
# unary comment
not
# operand comment
not (
(
# comment
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
Expand Down Expand Up @@ -318,31 +338,28 @@ if (

## Trailing operator comments

if ( # comment
not aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
if (
not aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # comment
+ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
):
pass


if (
# comment
~aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
~aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # comment
+ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
):
pass

if (
# comment
-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # comment
+ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
):
pass


if (
# comment
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # comment
+ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
):
pass
Expand All @@ -362,13 +379,14 @@ if (
pass

if (
not
# comment
not a
a
):
pass

if ( # comment
not a
if (
not a # comment
):
pass

Expand All @@ -385,9 +403,9 @@ if True:
# Regression test for: https://github.com/astral-sh/ruff/issues/7448
x = (
# a
# b
not # b
# c
not ( # d
( # d
# e
True
)
Expand Down Expand Up @@ -415,4 +433,23 @@ def foo():
not (aaaaaaaaaaaaaaaaaaaaa[bbbbbbbb, ccccccc]) and dddddddddd < eeeeeeeeeeeeeee
):
pass


# Regression tests for https://github.com/astral-sh/ruff/issues/19226
if "" and (
not 0 #
):
pass

if "" and (
not (0) #
):
pass

if "" and (
not ( #
0
)
):
pass
```
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/parentheses/expression_parentheses_comments.py
snapshot_kind: text
---
## Input
```python
Expand Down Expand Up @@ -179,13 +178,13 @@ nested_parentheses4 = [

x = (
# unary comment
not
# in-between comment
not (
(
# leading inner
"a"
),
# in-between comment
not (
not ( # in-between comment
# leading inner
"b"
),
Expand All @@ -194,17 +193,17 @@ x = (
"c"
),
# 1
# 2
not ( # 3
not ( # 2 # 3
# 4
"d"
),
)
Comment thread
ntBre marked this conversation as resolved.

if (
# unary comment
not
# in-between comment
not (
(
# leading inner
1
)
Expand Down