Fix panic when formatting comments in unary expressions (#21501)

## Summary

This is another attempt at https://github.com/astral-sh/ruff/pull/21410
that fixes https://github.com/astral-sh/ruff/issues/19226.

@MichaReiser helped me get something working in a very helpful pairing
session. I pushed one additional commit moving the comments back from
leading comments to trailing comments, which I think retains more of the
input formatting.

I was inspired by Dylan's PR (#21185) to make one of these tables:

<table>
                <thead>
                    <tr>
                    <th scope="col">Input</th>
                    <th scope="col">Main</th>
                    <th scope="col">PR</th>
                    </tr>
                </thead>
                <tbody>
<tr>
<td><pre lang="python">
if (
    not
    # comment
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
):
    pass
</pre></td>
<td><pre lang="python">
if (
    # comment
    not aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
    + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
):
    pass

</pre></td>
<td><pre lang="python">
if (
    not
    # comment
    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
    + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
):
    pass

</pre></td>
</tr>
<tr>
<td><pre lang="python">
if (
    # unary comment
    not
    # operand comment
    (
        # comment
        aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
        + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
    )
):
    pass
</pre></td>
<td><pre lang="python">
if (
    # unary comment
    # operand comment
    not (
        # comment
        aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
        + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
    )
):
    pass

</pre></td>
<td><pre lang="python">
if (
    # unary comment
    not
    # operand comment
    (
        # comment
        aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
        + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
    )
):
    pass

</pre></td>
</tr>
<tr>
<td><pre lang="python">
if (
    not # comment
    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
    + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
):
    pass
</pre></td>
<td><pre lang="python">
if (  # comment
    not aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
    + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
):
    pass

</pre></td>
<td><pre lang="python">
if (
    not aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa  # comment
    + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
):
    pass

</pre></td>
</tr>
</tbody>
            </table>

hopefully it helps even though the snippets are much wider here.

The two main differences are (1) that we now retain own-line comments
between the unary operator and its operand instead of moving these to
leading comments on the operator itself, and (2) that we move
end-of-line comments between the operator and operand to dangling
end-of-line comments on the operand (the last example in the table).

## Test Plan

Existing tests, plus new ones based on the issue. As I noted below, I
also ran the output from main on the unary.py file back through this
branch to check that we don't reformat code from main. This made me feel
a bit better about not preview-gating the changes in this PR.

```shell
> git show main:crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/unary.py | ruff format - | ./target/debug/ruff format --diff -
> echo $?
0
```

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
Co-authored-by: Takayuki Maeda <takoyaki0316@gmail.com>
This commit is contained in:
Brent Westbrook
2025-11-18 10:48:14 -05:00
committed by GitHub
parent 7043d51df0
commit cbc6863b8c
5 changed files with 269 additions and 48 deletions

View File

@@ -1890,9 +1890,11 @@ fn handle_lambda_comment<'a>(
CommentPlacement::Default(comment)
}
/// Move comment between a unary op and its operand before the unary op by marking them as trailing.
/// Move an end-of-line comment between a unary op and its operand after the operand by marking
/// it as dangling.
///
/// For example, given:
///
/// ```python
/// (
/// not # comment
@@ -1900,8 +1902,13 @@ fn handle_lambda_comment<'a>(
/// )
/// ```
///
/// The `# comment` will be attached as a dangling comment on the enclosing node, to ensure that
/// it remains on the same line as the operator.
/// the `# comment` will be attached as a dangling comment on the unary op and formatted as:
///
/// ```python
/// (
/// not True # comment
/// )
/// ```
fn handle_unary_op_comment<'a>(
comment: DecoratedComment<'a>,
unary_op: &'a ast::ExprUnaryOp,
@@ -1923,8 +1930,8 @@ fn handle_unary_op_comment<'a>(
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)
if comment.end() < up_to && comment.line_position().is_end_of_line() {
CommentPlacement::dangling(unary_op, comment)
} else {
CommentPlacement::Default(comment)
}

View File

@@ -1,6 +1,8 @@
use ruff_python_ast::AnyNodeRef;
use ruff_python_ast::ExprUnaryOp;
use ruff_python_ast::UnaryOp;
use ruff_python_ast::parenthesize::parenthesized_range;
use ruff_text_size::Ranged;
use crate::comments::trailing_comments;
use crate::expression::parentheses::{
@@ -39,20 +41,25 @@ 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
// ```
if comments.has_leading(operand.as_ref())
&& !is_expression_parenthesized(
operand.as_ref().into(),
f.context().comments().ranges(),
f.context().source(),
)
{
if needs_line_break(item, f.context()) {
hard_line_break().fmt(f)?;
} else if op.is_not() {
space().fmt(f)?;
@@ -76,17 +83,51 @@ impl NeedsParentheses for ExprUnaryOp {
context: &PyFormatContext,
) -> OptionalParentheses {
if parent.is_expr_await() {
OptionalParentheses::Always
} else if is_expression_parenthesized(
return OptionalParentheses::Always;
}
if needs_line_break(self, context) {
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()) {
OptionalParentheses::Always
} else {
self.operand.needs_parentheses(self.into(), context)
return OptionalParentheses::Never;
}
if context.comments().has(self.operand.as_ref()) {
return OptionalParentheses::Always;
}
self.operand.needs_parentheses(self.into(), context)
}
}
/// Returns `true` if the unary operator will have a hard line break between the operator and its
/// operand and thus requires parentheses.
fn needs_line_break(item: &ExprUnaryOp, context: &PyFormatContext) -> bool {
let comments = context.comments();
let parenthesized_operand_range = parenthesized_range(
item.operand.as_ref().into(),
item.into(),
comments.ranges(),
context.source(),
);
let leading_operand_comments = comments.leading(item.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())
});
!leading_operand_comments.is_empty()
&& !is_expression_parenthesized(
item.operand.as_ref().into(),
context.comments().ranges(),
context.source(),
)
|| has_leading_comments_before_parens
}