Skip to content

Commit

Permalink
Prototype of multiline string
Browse files Browse the repository at this point in the history
  • Loading branch information
MichaReiser committed Jan 9, 2024
1 parent 01a35f7 commit 8968a35
Show file tree
Hide file tree
Showing 8 changed files with 215 additions and 155 deletions.
17 changes: 13 additions & 4 deletions crates/ruff_formatter/src/printer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1472,6 +1472,11 @@ impl<'a, 'print> FitsMeasurer<'a, 'print> {
}

fn fits_text(&mut self, text: Text, args: PrintElementArgs) -> Fits {
fn exceeds_width(fits: &FitsMeasurer, args: PrintElementArgs) -> bool {
fits.state.line_width > fits.options().line_width.into()
&& !args.measure_mode().allows_text_overflow()
}

let indent = std::mem::take(&mut self.state.pending_indent);
self.state.line_width +=
u32::from(indent.level()) * self.options().indent_width() + u32::from(indent.align());
Expand All @@ -1493,7 +1498,13 @@ impl<'a, 'print> FitsMeasurer<'a, 'print> {
return Fits::No;
}
match args.measure_mode() {
MeasureMode::FirstLine => return Fits::Yes,
MeasureMode::FirstLine => {
return if exceeds_width(self, args) {
Fits::No
} else {
Fits::Yes
};
}
MeasureMode::AllLines
| MeasureMode::AllLinesAllowTextOverflow => {
self.state.line_width = 0;
Expand All @@ -1511,9 +1522,7 @@ impl<'a, 'print> FitsMeasurer<'a, 'print> {
}
}

if self.state.line_width > self.options().line_width.into()
&& !args.measure_mode().allows_text_overflow()
{
if exceeds_width(self, args) {
return Fits::No;
}

Expand Down
38 changes: 38 additions & 0 deletions crates/ruff_python_formatter/src/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,41 @@ impl<'fmt, 'ast, 'buf> JoinCommaSeparatedBuilder<'fmt, 'ast, 'buf> {
})
}
}

pub(crate) const fn indent_if_first_line_doesnt_fit<T>(
content: T,
) -> IndentIfFirstLineDoesntFit<T> {
IndentIfFirstLineDoesntFit { content }
}

/// Indents the content if it, up to the first line break, doesn't fit on the line. Doesn't indent the content otherwise.
pub(crate) struct IndentIfFirstLineDoesntFit<T> {
content: T,
}

impl<T, C> Format<C> for IndentIfFirstLineDoesntFit<T>
where
T: Format<C>,
{
fn fmt(&self, f: &mut Formatter<C>) -> FormatResult<()> {
// It's not immediately obvious how the below IR works so that it only indents content if the first line exceeds the configured line width.
// The trick is the first group that doesn't wrap `self.content`.
// * The group doesn't wrap `self.content` because we need to assume that `self.content` contains a hard line break and hard-line-breaks always
// expand the enclosing group.
// * The printer decides that a group fits if its content (in this case a `soft_line_break` that has a width of 0 and is guaranteed to fit)
// and the content coming after the group in expanded mode (`self.content`) fits on the line. The content coming after fits if the content
// up to the first soft or hard line break (or the end of the document) fits.
//
// This happens to be right what we want. The first group should add an indent and a soft line break if the content of `self.content`
// up to the first line break exceeds the configured line length, but not otherwise.
let indented = f.group_id("indented_content");
write!(
f,
[
group(&indent(&soft_line_break())).with_group_id(Some(indented)),
indent_if_group_breaks(&self.content, indented),
if_group_breaks(&soft_line_break()).with_group_id(Some(indented))
]
)
}
}
80 changes: 16 additions & 64 deletions crates/ruff_python_formatter/src/expression/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ use crate::expression::expr_generator_exp::is_generator_parenthesized;
use crate::expression::expr_string_literal::is_multiline_string;
use crate::expression::expr_tuple::is_tuple_parenthesized;
use crate::expression::parentheses::{
is_expression_parenthesized, optional_parentheses, parenthesized, NeedsParentheses,
OptionalParentheses, Parentheses, Parenthesize,
is_expression_parenthesized, optional_parentheses, parenthesized, HuggingStyle,
NeedsParentheses, OptionalParentheses, Parentheses, Parenthesize,
};
use crate::prelude::*;
use crate::preview::{
is_hug_parens_with_braces_and_square_brackets_enabled, is_multiline_string_handling_enabled,
};
use crate::string::{AnyString, StringPart};
use crate::string::AnyString;

mod binary_like;
pub(crate) mod expr_attribute;
Expand Down Expand Up @@ -131,7 +131,7 @@ impl FormatRule<Expr, PyFormatContext<'_>> for FormatExpr {
let node_comments = comments.leading_dangling_trailing(expression);
if !node_comments.has_leading() && !node_comments.has_trailing() {
parenthesized("(", &format_expr, ")")
.with_indent(!is_expression_huggable(expression, f.context()))
.with_hugging(is_expression_huggable(expression, f.context()))
.fmt(f)
} else {
format_with_parentheses_comments(expression, &node_comments, f)
Expand Down Expand Up @@ -449,7 +449,7 @@ impl Format<PyFormatContext<'_>> for MaybeParenthesizeExpression<'_> {
OptionalParentheses::Never => match parenthesize {
Parenthesize::IfBreaksOrIfRequired => {
parenthesize_if_expands(&expression.format().with_options(Parentheses::Never))
.with_indent(!is_expression_huggable(expression, f.context()))
.with_indent(is_expression_huggable(expression, f.context()).is_none())
.fmt(f)
}

Expand Down Expand Up @@ -1115,15 +1115,19 @@ pub(crate) fn has_own_parentheses(
/// ]
/// )
/// ```
pub(crate) fn is_expression_huggable(expr: &Expr, context: &PyFormatContext) -> bool {
pub(crate) fn is_expression_huggable(
expr: &Expr,
context: &PyFormatContext,
) -> Option<HuggingStyle> {
match expr {
Expr::Tuple(_)
| Expr::List(_)
| Expr::Set(_)
| Expr::Dict(_)
| Expr::ListComp(_)
| Expr::SetComp(_)
| Expr::DictComp(_) => is_hug_parens_with_braces_and_square_brackets_enabled(context),
| Expr::DictComp(_) => is_hug_parens_with_braces_and_square_brackets_enabled(context)
.then_some(HuggingStyle::Always),

Expr::Starred(ast::ExprStarred { value, .. }) => is_expression_huggable(value, context),

Expand Down Expand Up @@ -1151,53 +1155,14 @@ pub(crate) fn is_expression_huggable(expr: &Expr, context: &PyFormatContext) ->
| Expr::NumberLiteral(_)
| Expr::BooleanLiteral(_)
| Expr::NoneLiteral(_)
| Expr::EllipsisLiteral(_) => false,
| Expr::EllipsisLiteral(_) => None,
}
}

/// Returns `true` if `string` is a
/// * multiline string
/// * ...that is not implicitly concatenated
/// * ...where the opening and closing quotes have no content on the same line.
///
/// ## Examples
///
/// ```python
/// call("""
/// ABCD
/// """)
/// ```
///
/// Returns `true` because the `"""` are n their own line
///
/// ```python
/// call("""ABCD
/// More
/// """)
/// ```
///
/// Returns `false` because there's content on the same line as the opening quotes.
///
/// ```python
/// call("""
/// ABCD
/// More"""
/// )
/// ```
///
/// Returns `false` because there's content on the same line as the closing quotes.
///
/// ```python
/// call("""\
/// ABCD
/// More
/// """)
/// ```
///
/// Returns `true` because there's only a line continuation after the opening quotes.
fn is_huggable_string(string: AnyString, context: &PyFormatContext) -> bool {
/// Returns `true` if `string` is a multiline string that is not implicitly concatenated.
fn is_huggable_string(string: AnyString, context: &PyFormatContext) -> Option<HuggingStyle> {
if string.is_implicit_concatenated() || !is_multiline_string_handling_enabled(context) {
return false;
return None;
}

let multiline = match string {
Expand All @@ -1211,20 +1176,7 @@ fn is_huggable_string(string: AnyString, context: &PyFormatContext) -> bool {
AnyString::FString(fstring) => is_multiline_fstring(fstring, context.source()),
};

if !multiline {
return false;
}

let locator = context.locator();
let part = StringPart::from_source(string.range(), &locator);
let source = part.source(&locator);

source
// allow `"""\`
.strip_prefix('\\')
.unwrap_or(source)
.starts_with(['\n', '\r'])
&& source.trim_end_matches(' ').ends_with('\n')
multiline.then_some(HuggingStyle::IfFirstLineFits)
}

/// The precedence of [python operators](https://docs.python.org/3/reference/expressions.html#operator-precedence) from
Expand Down
56 changes: 40 additions & 16 deletions crates/ruff_python_formatter/src/expression/parentheses.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::builders::indent_if_first_line_doesnt_fit;
use ruff_formatter::prelude::tag::Condition;
use ruff_formatter::{format_args, write, Argument, Arguments};
use ruff_python_ast::AnyNodeRef;
Expand Down Expand Up @@ -126,7 +127,7 @@ where
FormatParenthesized {
left,
comments: &[],
indent: true,
hug: None,
content: Argument::new(content),
right,
}
Expand All @@ -135,7 +136,7 @@ where
pub(crate) struct FormatParenthesized<'content, 'ast> {
left: &'static str,
comments: &'content [SourceComment],
indent: bool,
hug: Option<HuggingStyle>,
content: Argument<'content, PyFormatContext<'ast>>,
right: &'static str,
}
Expand All @@ -158,26 +159,35 @@ impl<'content, 'ast> FormatParenthesized<'content, 'ast> {
}

/// Whether to indent the content within the parentheses.
pub(crate) fn with_indent(self, indent: bool) -> FormatParenthesized<'content, 'ast> {
FormatParenthesized { indent, ..self }
pub(crate) fn with_hugging(
self,
hug: Option<HuggingStyle>,
) -> FormatParenthesized<'content, 'ast> {
FormatParenthesized { hug, ..self }
}
}

impl<'ast> Format<PyFormatContext<'ast>> for FormatParenthesized<'_, 'ast> {
fn fmt(&self, f: &mut Formatter<PyFormatContext<'ast>>) -> FormatResult<()> {
let current_level = f.context().node_level();

let content = format_with(|f| {
group(&format_with(|f| {
dangling_open_parenthesis_comments(self.comments).fmt(f)?;
if self.indent || !self.comments.is_empty() {
soft_block_indent(&Arguments::from(&self.content)).fmt(f)?;
} else {
Arguments::from(&self.content).fmt(f)?;
let indented = format_with(|f| {
let content = Arguments::from(&self.content);
if !self.comments.is_empty() {
group(&format_args![
dangling_open_parenthesis_comments(self.comments),
soft_block_indent(&content),
])
.fmt(f)
} else {
match self.hug {
None => group(&soft_block_indent(&content)).fmt(f),
Some(HuggingStyle::Always) => content.fmt(f),
Some(HuggingStyle::IfFirstLineFits) => {
indent_if_first_line_doesnt_fit(content).fmt(f)
}
}
Ok(())
}))
.fmt(f)
}
});

let inner = format_with(|f| {
Expand All @@ -186,12 +196,12 @@ impl<'ast> Format<PyFormatContext<'ast>> for FormatParenthesized<'_, 'ast> {
// This ensures that expanding this parenthesized expression does not expand the optional parentheses group.
write!(
f,
[fits_expanded(&content)
[fits_expanded(&indented)
.with_condition(Some(Condition::if_group_fits_on_line(group_id)))]
)
} else {
// It's not necessary to wrap the content if it is not inside of an optional_parentheses group.
content.fmt(f)
indented.fmt(f)
}
});

Expand All @@ -201,6 +211,20 @@ impl<'ast> Format<PyFormatContext<'ast>> for FormatParenthesized<'_, 'ast> {
}
}

#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub(crate) enum HuggingStyle {
/// Always hug the content (never indent).
Always,

/// Hug the content if the content up to the first line break fits into the configured line length. Otherwise indent the content.
///
/// This is different from [`HuggingStyle::Always`] in that it doesn't indent if the content contains a hard line break, and the content up to that hard line break fits into the configured line length.
///
/// This style is used for formatting multiline strings that, by definition, always break. The idea is to
/// only hug a multiline string if its content up to the first line breaks exceeds the configured line length.
IfFirstLineFits,
}

/// Wraps an expression in parentheses only if it still does not fit after expanding all expressions that start or end with
/// a parentheses (`()`, `[]`, `{}`).
pub(crate) fn optional_parentheses<'content, 'ast, Content>(
Expand Down
20 changes: 10 additions & 10 deletions crates/ruff_python_formatter/src/other/arguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ use ruff_text_size::{Ranged, TextRange, TextSize};
use crate::comments::SourceComment;
use crate::expression::expr_generator_exp::GeneratorExpParentheses;
use crate::expression::is_expression_huggable;
use crate::expression::parentheses::{empty_parenthesized, parenthesized, Parentheses};
use crate::expression::parentheses::{
empty_parenthesized, parenthesized, HuggingStyle, Parentheses,
};
use crate::other::commas;
use crate::prelude::*;

Expand Down Expand Up @@ -106,7 +108,7 @@ impl FormatNodeRule<Arguments> for FormatArguments {
// )
// ```
parenthesized("(", &group(&all_arguments), ")")
.with_indent(!is_argument_huggable(item, f.context()))
.with_hugging(is_arguments_huggable(item, f.context()))
.with_dangling_comments(dangling_comments)
]
)
Expand Down Expand Up @@ -176,25 +178,23 @@ fn is_single_argument_parenthesized(argument: &Expr, call_end: TextSize, source:
///
/// Hugging should only be applied to single-argument collections, like lists, or starred versions
/// of those collections.
fn is_argument_huggable(item: &Arguments, context: &PyFormatContext) -> bool {
fn is_arguments_huggable(item: &Arguments, context: &PyFormatContext) -> Option<HuggingStyle> {
// Find the lone argument or `**kwargs` keyword.
let arg = match (item.args.as_slice(), item.keywords.as_slice()) {
([arg], []) => arg,
([], [keyword]) if keyword.arg.is_none() && !context.comments().has(keyword) => {
&keyword.value
}
_ => return false,
_ => return None,
};

// If the expression itself isn't huggable, then we can't hug it.
if !is_expression_huggable(arg, context) {
return false;
}
let hugging_style = is_expression_huggable(arg, context)?;

// If the expression has leading or trailing comments, then we can't hug it.
let comments = context.comments().leading_dangling_trailing(arg);
if comments.has_leading() || comments.has_trailing() {
return false;
return None;
}

let options = context.options();
Expand All @@ -203,8 +203,8 @@ fn is_argument_huggable(item: &Arguments, context: &PyFormatContext) -> bool {
if options.magic_trailing_comma().is_respect()
&& commas::has_magic_trailing_comma(TextRange::new(arg.end(), item.end()), options, context)
{
return false;
return None;
}

true
Some(hugging_style)
}
Loading

0 comments on commit 8968a35

Please sign in to comment.