ruff/crates/ruff_python_formatter/src/statement/stmt_with.rs
2025-05-16 13:25:28 +02:00

366 lines
13 KiB
Rust

use ruff_formatter::{FormatContext, FormatError, format_args, write};
use ruff_python_ast::PythonVersion;
use ruff_python_ast::{StmtWith, WithItem};
use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer};
use ruff_text_size::{Ranged, TextRange};
use crate::builders::parenthesize_if_expands;
use crate::comments::SourceComment;
use crate::expression::can_omit_optional_parentheses;
use crate::expression::parentheses::{
is_expression_parenthesized, optional_parentheses, parenthesized,
};
use crate::other::commas;
use crate::other::with_item::WithItemLayout;
use crate::prelude::*;
use crate::statement::clause::{ClauseHeader, clause_body, clause_header};
use crate::statement::suite::SuiteKind;
#[derive(Default)]
pub struct FormatStmtWith;
impl FormatNodeRule<StmtWith> for FormatStmtWith {
fn fmt_fields(&self, with_stmt: &StmtWith, f: &mut PyFormatter) -> FormatResult<()> {
// The `with` statement can have one dangling comment on the open parenthesis, like:
// ```python
// with ( # comment
// CtxManager() as example
// ):
// ...
// ```
//
// Any other dangling comments are trailing comments on the colon, like:
// ```python
// with CtxManager() as example: # comment
// ...
// ```
let comments = f.context().comments().clone();
let dangling_comments = comments.dangling(with_stmt);
let partition_point = dangling_comments.partition_point(|comment| {
with_stmt
.items
.first()
.is_some_and(|with_item| with_item.start() > comment.start())
});
let (parenthesized_comments, colon_comments) = dangling_comments.split_at(partition_point);
write!(
f,
[
clause_header(
ClauseHeader::With(with_stmt),
colon_comments,
&format_with(|f| {
write!(
f,
[
with_stmt
.is_async
.then_some(format_args![token("async"), space()]),
token("with"),
space()
]
)?;
let layout = WithItemsLayout::from_statement(
with_stmt,
f.context(),
parenthesized_comments,
)?;
match layout {
WithItemsLayout::SingleWithTarget(single) => {
optional_parentheses(&single.format().with_options(
WithItemLayout::ParenthesizedContextManagers { single: true },
))
.fmt(f)
}
WithItemsLayout::SingleWithoutTarget(single) => single
.format()
.with_options(WithItemLayout::SingleWithoutTarget)
.fmt(f),
WithItemsLayout::SingleParenthesizedContextManager(single) => single
.format()
.with_options(WithItemLayout::SingleParenthesizedContextManager)
.fmt(f),
WithItemsLayout::ParenthesizeIfExpands => {
parenthesize_if_expands(&format_with(|f| {
let mut joiner = f.join_comma_separated(
with_stmt.body.first().unwrap().start(),
);
for item in &with_stmt.items {
joiner.entry_with_line_separator(
item,
&item.format().with_options(
WithItemLayout::ParenthesizedContextManagers {
single: with_stmt.items.len() == 1,
},
),
soft_line_break_or_space(),
);
}
joiner.finish()
}))
.fmt(f)
}
WithItemsLayout::Python38OrOlder => f
.join_with(format_args![token(","), space()])
.entries(with_stmt.items.iter().map(|item| {
item.format().with_options(WithItemLayout::Python38OrOlder {
single: with_stmt.items.len() == 1,
})
}))
.finish(),
WithItemsLayout::Parenthesized => parenthesized(
"(",
&format_with(|f: &mut PyFormatter| {
let mut joiner = f.join_comma_separated(
with_stmt.body.first().unwrap().start(),
);
for item in &with_stmt.items {
joiner.entry(
item,
&item.format().with_options(
WithItemLayout::ParenthesizedContextManagers {
single: with_stmt.items.len() == 1,
},
),
);
}
joiner.finish()
}),
")",
)
.with_dangling_comments(parenthesized_comments)
.fmt(f),
}
})
),
clause_body(&with_stmt.body, SuiteKind::other(true), colon_comments)
]
)
}
}
#[derive(Clone, Copy, Debug)]
enum WithItemsLayout<'a> {
/// The with statement's only item has a parenthesized context manager.
///
/// ```python
/// with (
/// a + b
/// ):
/// ...
///
/// with (
/// a + b
/// ) as b:
/// ...
/// ```
///
/// In this case, prefer keeping the parentheses around the context expression instead of parenthesizing the entire
/// with item.
///
/// Ensure that this layout is compatible with [`Self::SingleWithoutTarget`] because removing the parentheses
/// results in the formatter taking that layout when formatting the file again
SingleParenthesizedContextManager(&'a WithItem),
/// The with statement's only item has no target.
///
/// ```python
/// with a + b:
/// ...
/// ```
///
/// In this case, use [`maybe_parenthesize_expression`] to format the context expression
/// to get the exact same formatting as when formatting an expression in any other clause header.
///
/// Only used for Python 3.9+
///
/// Be careful that [`Self::SingleParenthesizedContextManager`] and this layout are compatible because
/// adding parentheses around a [`WithItem`] will result in the context expression being parenthesized in
/// the next formatting pass.
SingleWithoutTarget(&'a WithItem),
/// It's a single with item with a target. Use the optional parentheses layout (see [`optional_parentheses`])
/// to mimic the `maybe_parenthesize_expression` behavior.
///
/// ```python
/// with (
/// a + b as b
/// ):
/// ...
/// ```
///
/// Only used for Python 3.9+
SingleWithTarget(&'a WithItem),
/// The target python version doesn't support parenthesized context managers because it is Python 3.8 or older.
///
/// In this case, never add parentheses and join the with items with spaces.
///
/// ```python
/// with ContextManager1(
/// aaaaaaaaaaaaaaa, b
/// ), ContextManager2(), ContextManager3(), ContextManager4():
/// pass
/// ```
Python38OrOlder,
/// Wrap the with items in parentheses if they don't fit on a single line and join them by soft line breaks.
///
/// ```python
/// with (
/// ContextManager1(aaaaaaaaaaaaaaa, b),
/// ContextManager1(),
/// ContextManager1(),
/// ContextManager1(),
/// ):
/// pass
/// ```
///
/// Only used for Python 3.9+.
ParenthesizeIfExpands,
/// Always parenthesize because the context managers open-parentheses have a trailing comment:
///
/// ```python
/// with ( # comment
/// CtxManager() as example
/// ):
/// ...
/// ```
///
/// Or because it is a single item with a trailing or leading comment.
///
/// ```python
/// with (
/// # leading
/// CtxManager()
/// # trailing
/// ): pass
/// ```
Parenthesized,
}
impl<'a> WithItemsLayout<'a> {
fn from_statement(
with: &'a StmtWith,
context: &PyFormatContext,
parenthesized_comments: &[SourceComment],
) -> FormatResult<Self> {
// The with statement already has parentheses around the entire with items. Guaranteed to be Python 3.9 or newer
// ```
// with ( # comment
// CtxManager() as example
// ):
// pass
// ```
if !parenthesized_comments.is_empty() {
return Ok(Self::Parenthesized);
}
// A trailing comma at the end guarantees that the context managers are parenthesized and that it is Python 3.9 or newer syntax.
// ```python
// with ( # comment
// CtxManager() as example,
// ):
// pass
// ```
if has_magic_trailing_comma(with, context) {
return Ok(Self::ParenthesizeIfExpands);
}
if let [single] = with.items.as_slice() {
// If the with item itself has comments (not the context expression), then keep the parentheses
// ```python
// with (
// # leading
// a
// ): pass
// ```
if context.comments().has_leading(single) || context.comments().has_trailing(single) {
return Ok(Self::Parenthesized);
}
// Preserve the parentheses around the context expression instead of parenthesizing the entire
// with items.
if is_expression_parenthesized(
(&single.context_expr).into(),
context.comments().ranges(),
context.source(),
) {
return Ok(Self::SingleParenthesizedContextManager(single));
}
}
let can_parenthesize = context.options().target_version() >= PythonVersion::PY39
|| are_with_items_parenthesized(with, context)?;
// If the target version doesn't support parenthesized context managers and they aren't
// parenthesized by the user, bail out.
if !can_parenthesize {
return Ok(Self::Python38OrOlder);
}
Ok(match with.items.as_slice() {
[single] => {
if single.optional_vars.is_none() {
Self::SingleWithoutTarget(single)
} else if can_omit_optional_parentheses(&single.context_expr, context) {
Self::SingleWithTarget(single)
} else {
Self::ParenthesizeIfExpands
}
}
// Always parenthesize multiple items
[..] => Self::ParenthesizeIfExpands,
})
}
}
fn has_magic_trailing_comma(with: &StmtWith, context: &PyFormatContext) -> bool {
let Some(last_item) = with.items.last() else {
return false;
};
commas::has_magic_trailing_comma(TextRange::new(last_item.end(), with.end()), context)
}
fn are_with_items_parenthesized(with: &StmtWith, context: &PyFormatContext) -> FormatResult<bool> {
let [first_item, _, ..] = with.items.as_slice() else {
return Ok(false);
};
let before_first_item = TextRange::new(with.start(), first_item.start());
let mut tokenizer = SimpleTokenizer::new(context.source(), before_first_item)
.skip_trivia()
.skip_while(|t| t.kind() == SimpleTokenKind::Async);
let with_keyword = tokenizer.next().ok_or(FormatError::syntax_error(
"Expected a with keyword, didn't find any token",
))?;
debug_assert_eq!(
with_keyword.kind(),
SimpleTokenKind::With,
"Expected with keyword but at {with_keyword:?}"
);
match tokenizer.next() {
Some(left_paren) => {
debug_assert_eq!(left_paren.kind(), SimpleTokenKind::LParen);
Ok(true)
}
None => Ok(false),
}
}