## Summary
This PR adds support for parenthesized comments. A parenthesized comment
is a comment that appears within a parenthesis, but not within the range
of the expression enclosed by the parenthesis. For example, the comment
here is a parenthesized comment:
```python
if (
# comment
True
):
...
```
The parentheses enclose the `True`, but the range of `True` doesn’t
include the `# comment`.
There are at least two problems associated with parenthesized comments:
(1) associating the comment with the correct (i.e., enclosed) node; and
(2) formatting the comment correctly, once it has been associated with
the enclosed node.
The solution proposed here for (1) is to search for parentheses between
preceding and following node, and use open and close parentheses to
break ties, rather than always assigning to the preceding node.
For (2), we handle these special parenthesized comments in `FormatExpr`.
The biggest risk with this approach is that we forget some codepath that
force-disables parenthesization (by passing in `Parentheses::Never`).
I've audited all usages of that enum and added additional handling +
test coverage for such cases.
Closes https://github.com/astral-sh/ruff/issues/6390.
## Test Plan
`cargo test` with new cases.
Before:
| project | similarity index |
|--------------|------------------|
| build | 0.75623 |
| cpython | 0.75472 |
| django | 0.99804 |
| transformers | 0.99618 |
| typeshed | 0.74233 |
| warehouse | 0.99601 |
| zulip | 0.99727 |
After:
| project | similarity index |
|--------------|------------------|
| build | 0.75623 |
| cpython | 0.75472 |
| django | 0.99804 |
| transformers | 0.99618 |
| typeshed | 0.74237 |
| warehouse | 0.99601 |
| zulip | 0.99727 |
140 lines
5.1 KiB
Rust
140 lines
5.1 KiB
Rust
use crate::comments::SourceComment;
|
|
use ruff_formatter::write;
|
|
use ruff_python_ast::node::AstNode;
|
|
use ruff_python_ast::{Arguments, Expr, Ranged};
|
|
use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer};
|
|
use ruff_text_size::{TextRange, TextSize};
|
|
|
|
use crate::expression::expr_generator_exp::GeneratorExpParentheses;
|
|
use crate::expression::parentheses::{empty_parenthesized, parenthesized, Parentheses};
|
|
use crate::prelude::*;
|
|
use crate::FormatNodeRule;
|
|
|
|
#[derive(Default)]
|
|
pub struct FormatArguments;
|
|
|
|
impl FormatNodeRule<Arguments> for FormatArguments {
|
|
fn fmt_fields(&self, item: &Arguments, f: &mut PyFormatter) -> FormatResult<()> {
|
|
// We have a case with `f()` without any argument, which is a special case because we can
|
|
// have a comment with no node attachment inside:
|
|
// ```python
|
|
// f(
|
|
// # This call has a dangling comment.
|
|
// )
|
|
// ```
|
|
if item.args.is_empty() && item.keywords.is_empty() {
|
|
let comments = f.context().comments().clone();
|
|
let dangling = comments.dangling_comments(item);
|
|
return write!(f, [empty_parenthesized("(", dangling, ")")]);
|
|
}
|
|
|
|
let all_arguments = format_with(|f: &mut PyFormatter| {
|
|
let source = f.context().source();
|
|
let mut joiner = f.join_comma_separated(item.end());
|
|
match item.args.as_slice() {
|
|
[arg] if item.keywords.is_empty() => {
|
|
match arg {
|
|
Expr::GeneratorExp(generator_exp) => joiner.entry(
|
|
generator_exp,
|
|
&generator_exp
|
|
.format()
|
|
.with_options(GeneratorExpParentheses::StripIfOnlyFunctionArg),
|
|
),
|
|
other => {
|
|
let parentheses =
|
|
if is_single_argument_parenthesized(arg, item.end(), source) {
|
|
Parentheses::Always
|
|
} else {
|
|
// Note: no need to handle opening-parenthesis comments, since
|
|
// an opening-parenthesis comment implies that the argument is
|
|
// parenthesized.
|
|
Parentheses::Never
|
|
};
|
|
joiner.entry(other, &other.format().with_options(parentheses))
|
|
}
|
|
};
|
|
}
|
|
args => {
|
|
joiner
|
|
.entries(
|
|
// We have the parentheses from the call so the item never need any
|
|
args.iter()
|
|
.map(|arg| (arg, arg.format().with_options(Parentheses::Preserve))),
|
|
)
|
|
.nodes(item.keywords.iter());
|
|
}
|
|
}
|
|
|
|
joiner.finish()
|
|
});
|
|
|
|
// If the arguments are non-empty, then a dangling comment indicates a comment on the
|
|
// same line as the opening parenthesis, e.g.:
|
|
// ```python
|
|
// f( # This call has a dangling comment.
|
|
// a,
|
|
// b,
|
|
// c,
|
|
// )
|
|
let comments = f.context().comments().clone();
|
|
let dangling_comments = comments.dangling_comments(item.as_any_node_ref());
|
|
|
|
write!(
|
|
f,
|
|
[
|
|
// The outer group is for things like:
|
|
// ```python
|
|
// get_collection(
|
|
// hey_this_is_a_very_long_call,
|
|
// it_has_funny_attributes_asdf_asdf,
|
|
// too_long_for_the_line,
|
|
// really=True,
|
|
// )
|
|
// ```
|
|
// The inner group is for things like:
|
|
// ```python
|
|
// get_collection(
|
|
// hey_this_is_a_very_long_call, it_has_funny_attributes_asdf_asdf, really=True
|
|
// )
|
|
// ```
|
|
parenthesized("(", &group(&all_arguments), ")")
|
|
.with_dangling_comments(dangling_comments)
|
|
]
|
|
)
|
|
}
|
|
|
|
fn fmt_dangling_comments(
|
|
&self,
|
|
_dangling_comments: &[SourceComment],
|
|
_f: &mut PyFormatter,
|
|
) -> FormatResult<()> {
|
|
// Handled in `fmt_fields`
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn is_single_argument_parenthesized(argument: &Expr, call_end: TextSize, source: &str) -> bool {
|
|
let mut has_seen_r_paren = false;
|
|
|
|
for token in
|
|
SimpleTokenizer::new(source, TextRange::new(argument.end(), call_end)).skip_trivia()
|
|
{
|
|
match token.kind() {
|
|
SimpleTokenKind::RParen => {
|
|
if has_seen_r_paren {
|
|
return true;
|
|
}
|
|
has_seen_r_paren = true;
|
|
}
|
|
// Skip over any trailing comma
|
|
SimpleTokenKind::Comma => continue,
|
|
_ => {
|
|
// Passed the arguments
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|