Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(js_formatter): Match Prettier's breaking strategy for ArrowChain layouts #934

Merged
merged 5 commits into from
Nov 28, 2023

Conversation

faultyserver
Copy link
Contributor

Summary

This introduces a new ParameterLayout::Compact to force parameters to print with no line breaks at all (through a new prevent_soft_line_breaks option on the formatting node), helping the FormatJsCallArguments node properly test the fit of chained/curried arrow functions.

In short:

// Before, incorrect.
foo((a) =>
  (b) => 
  (c) => {});

// Now, correct.
foo(
  (a) =>
    (b) =>
    (c) => {},
);

Longer Playground Link

Background

After spending an entire week trying to guess how the printer for Biome was acting different from Prettier's for what seemed like the same content, I eventually landed on the fact that Biome organizes arrow chains as a flat list of signatures followed by a single body, while Prettier just handles them recursively (each arrow is the body of the previous arrow).

I messed around for a while trying to understand what that meant and learning how the Printer works and how groups and line breaks get measured along with how BestFitting works, all to no avail, but finally realized that the primary difference between Biome and Prettier is that Prettier can remove all line breaks from a doc sequence when trying to format things in a flat manner. Biome can't currently do this, and was using soft_line_break_or_space() in a few places where Prettier ended up forcing a simple space character instead.

As a result, when Biome would use BestFitting to try to layout arguments in a call expression, it would conveniently find these line breaks and split there, stating that the group fits because the break happened before reaching the maximum width.

This meant that even though the arrow expression would have to break across multiple lines, it wouldn't actually cause the parent call expression to expand, resulting in a weird mixed of flat and expanded layout.

foo((parameter1) =>
  (parameter2) =>
  (parameter3) => {});

While the logic for comparing "most flat", "middle", and "most expanded" variants for call arguments matched Prettier, it was this inadvertent line breaking that was causing the issues. The crux of Prettier's logic is that the chain of arrow signatures either must fit on a single line. Then, the "most flat" variant tries to format the tail body as a single line, and the "middle" variant allows the body to break onto multiple lines. Importantly, though, the signatures must still fit on a single line. If they still don't, then the middle variant also fails, and the printer falls back to the most expanded layout.

By preventing those line breaks from being inserted in the parameter list (using a new ParameterLayout::Compact), the Printer now appropriately realizes that the arrow signature chain doesn't fit on a single line and forces it to fall back to the most expanded, just like Prettier.

A few other changes also have to be made to accommodate that behavior and not affect any other formatting logic, just around how exactly parameters and spaces are grouped, all of which just brings the resulting IR closer to how Prettier renders these elements, while still retaining the flat structure for the whole arrow chain.

Test Plan

The Prettier diff snapshots are deleted, since the output now matches Prettier, and no other tests were inadvertently updated.

@github-actions github-actions bot added A-Formatter Area: formatter L-JavaScript Language: JavaScript and super languages labels Nov 28, 2023
@faultyserver
Copy link
Contributor Author

!bench_formatter

Comment on lines -37 to +49
.and_then(FormatAnyJsParameters::cast)
.and_then(JsParameters::cast)
.map_or(false, |parameters| {
should_hug_function_parameters(&parameters, f.comments()).unwrap_or(false)
should_hug_function_parameters(
&FormatAnyJsParameters::new(
AnyJsParameters::JsParameters(parameters),
FormatJsParametersOptions::default(),
),
f.comments(),
)
.unwrap_or(false)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

there might be a more Rust-y way to do this, but i'm not at all sure. it can't just be cast any more since the options field of the struct now has to be specified.

Comment on lines +58 to +69
pub(crate) struct FormatAnyJsParameters {
pub(crate) parameters: AnyJsParameters,
pub(crate) options: FormatJsParametersOptions,
}

impl FormatAnyJsParameters {
pub(crate) fn new(parameters: AnyJsParameters, options: FormatJsParametersOptions) -> Self {
Self {
parameters,
options,
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I copied most of this from FormatTemplateElement, as another example of a formatting node that takes in options for a union of node types.

Comment on lines +156 to +160
if !parentheses_not_needed {
write!(f, [l_paren_token.format()])?;
} else {
write!(f, [format_removed(&l_paren_token)])?;
}
Copy link
Contributor Author

@faultyserver faultyserver Nov 28, 2023

Choose a reason for hiding this comment

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

Seems like we could probably move these paren token writes outside of the match and reduce duplication, but not gonna do that for now.

Comment on lines +232 to +233
let should_hug =
is_test_call_argument(arrow.syntax())? || is_first_or_last_call_argument;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because single parameters and lists are handled separately, the group layout condition needs to be checked here, and it was easy enough to just tack onto the hug condition.

Comment on lines +515 to +517
vec![middle_variant, most_expanded.into_boxed_slice()]
} else {
vec![most_flat, middle_variant, most_expanded.into_boxed_slice()]
Copy link
Contributor Author

Choose a reason for hiding this comment

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

not sure why, but the best_fitting! macro really doesn't like playing along here with Box<[FormatElement]>, otherwise i'd prefer to use that than the unsafe block below.

Copy link
Contributor

Formatter Benchmark Results

group                                    main                                   pr
-----                                    ----                                   --
formatter/big5-added.json                1.00    357.2±8.17µs    47.3 MB/sec    1.00   358.9±11.79µs    47.1 MB/sec
formatter/canada.json                    1.02    165.8±6.82ms    12.9 MB/sec    1.00    162.0±4.58ms    13.3 MB/sec
formatter/checker.ts                     1.00    260.7±5.04ms    10.0 MB/sec    1.00    260.4±5.68ms    10.0 MB/sec
formatter/compiler.js                    1.00    146.2±3.40ms     7.2 MB/sec    1.00    146.1±3.40ms     7.2 MB/sec
formatter/d3.min.js                      1.00    115.0±2.95ms     2.3 MB/sec    1.00    115.2±3.22ms     2.3 MB/sec
formatter/db.json                        1.00     10.4±0.23ms    17.6 MB/sec    1.00     10.4±0.31ms    17.6 MB/sec
formatter/dojo.js                        1.00      8.2±0.19ms     8.4 MB/sec    1.01      8.3±0.19ms     8.3 MB/sec
formatter/eucjp.json                     1.01   615.5±12.65µs    63.6 MB/sec    1.00   611.6±13.32µs    64.0 MB/sec
formatter/ios.d.ts                       1.01    168.3±3.64ms    11.1 MB/sec    1.00    166.3±3.46ms    11.2 MB/sec
formatter/jquery.min.js                  1.01     33.3±0.78ms     2.5 MB/sec    1.00     33.1±0.70ms     2.5 MB/sec
formatter/math.js                        1.01    232.2±4.43ms     2.8 MB/sec    1.00    230.1±4.80ms     2.8 MB/sec
formatter/package-lock.json              1.00      4.4±0.10ms    31.2 MB/sec    1.02      4.5±0.32ms    30.5 MB/sec
formatter/parser.ts                      1.00      5.6±0.13ms     8.7 MB/sec    1.00      5.6±0.12ms     8.6 MB/sec
formatter/pixi.min.js                    1.00    124.0±2.79ms     3.5 MB/sec    1.01    125.1±3.02ms     3.5 MB/sec
formatter/react-dom.production.min.js    1.00     37.8±0.85ms     3.0 MB/sec    1.00     37.8±1.15ms     3.0 MB/sec
formatter/react.production.min.js        1.00  1930.3±39.54µs     3.2 MB/sec    1.00  1929.4±37.64µs     3.2 MB/sec
formatter/router.ts                      1.00  1981.0±45.10µs    11.9 MB/sec    1.00  1985.5±44.89µs    11.8 MB/sec
formatter/tex-chtml-full.js              1.00    296.6±5.98ms     3.1 MB/sec    1.00    297.4±5.51ms     3.1 MB/sec
formatter/three.min.js                   1.00    147.9±4.43ms     4.0 MB/sec    1.01    148.8±4.20ms     3.9 MB/sec
formatter/typescript.js                  1.00  1011.6±12.70ms     9.4 MB/sec    1.00  1016.5±11.22ms     9.3 MB/sec
formatter/vue.global.prod.js             1.00     50.3±1.17ms     2.4 MB/sec    1.00     50.3±1.23ms     2.4 MB/sec

@faultyserver
Copy link
Contributor Author

i'm actually a little astounded that this didn't affect the formatter performance more. But that was really my one worry, so i'm happy with this lol

@ematipico ematipico self-requested a review November 28, 2023 10:58
@faultyserver
Copy link
Contributor Author

Ended up reading a few different parts of the code as I started looking at some other things to fix and realized there is actually a RemoveSoftLinesBuffer, so I'd like to refactor this to use that instead. I'm not sure if it can completely get rid of the ParameterLayout::Compact, but it should definitely reduce the rest of the duplication here.

Copy link
Member

@ematipico ematipico left a comment

Choose a reason for hiding this comment

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

Damn, that was a great PR! Feel free to merge it once we address the comments :)

@faultyserver
Copy link
Contributor Author

I did some exploration into using RemoveSoftLinesBuffer and undoing the changes to use ParameterLayout::Compact, but it ended up causing other issues. The underlying reason seems to be that the parameter layout still uses soft_block_indent, which seems to cause the layout to either assume it can fit or break too eagerly compared to the expected middle ground that this PR currently reaches.

So I'm going to leave it as is for now. I think there's a larger refactor that can come here to switch up the branching hierarchy and reduce duplication, but that'll definitely be something to tackle later instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-Formatter Area: formatter L-JavaScript Language: JavaScript and super languages
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants