dart-lang / dart_style

An opinionated formatter/linter for Dart code
https://pub.dev/packages/dart_style
BSD 3-Clause "New" or "Revised" License
646 stars 121 forks source link

New tall style: Automated trailing commas and other formatting changes #1253

Open munificent opened 1 year ago

munificent commented 1 year ago

TL;DR: We're proposing a set of style changes to dart format that would affect about 10% of all Dart code. We want to know what you think.

The main change is that you no longer need to manually add or remove trailing commas. The formatter adds them to any argument or parameter list that splits across multiple lines and removes them from ones that don't. When an argument list or parameter list splits, it is formatted like:

longFunction(
  longArgument,
  anotherLongArgument,
);

This change means less work writing and refactoring code, and no more reminding people to add or remove trailing commas in code reviews. We have a prototype implementation that you can try out. There is a detailed feedback form below, but you can give us your overall impression by reacting to the issue:

We have always been very cautious about changing the formatter's style rules to minimize churn in already-formatted code. Most of these style rules were chosen before Flutter existed and before we knew what idiomatic Flutter code looked like.

We've learned a lot about what idiomatic Dart looks like since then. In particular, large deeply nested function calls of "data-like" code are common. To accommodate them, the formatter lets you partially opt into a different formatting style by explicitly authoring trailing commas in argument and parameter lists. I think we can do better, but doing so would be the largest change we've ever made to the formatter.

I'm starting this change process to determine whether or not it's a change the community is in favor of. Details are below, but in short, I propose:

The overall goal is to produce output that is as beautiful and readable as possible for the kind of code most Dart users are writing today, and to give you that without any additional effort on your part.

Background

The formatter currently treats a trailing comma in an argument or parameter list as an explicit hand-authored signal to select one of two formatting styles:

// Page width:               |

// No trailing comma = "short" style:
noTrailingComma(
    longArgument,
    anotherLongArgument);

// Trailing comma = "tall" style:
withTrailingComma(
  longArgument,
  anotherLongArgument,
);

A trailing comma also forces the surrounding construct to split even when it would otherwise fit within a single line:

force(
  arg,
);

The pros of this approach are:

The cons are:

I believe the cons outweigh the pros, which leads to this proposal.

Proposal

There are basically three interelated pieces to the proposal:

Trailing commas as whitespace

Trailing commas in argument and parameter lists are treated as "whitespace" characters and under the formatter's purview to add and remove. (Arguably, they really are whitespace characters, since they have zero effect on program semantics.)

The rule for adding and removing them is simple:

The last part means that users can no longer hand-author a trailing comma to mean, "I know it fits on one line but force it to split anyway because I want it to." I think it's worth losing that capability in order to preserve reversibility. If the formatter treated trailing commas as user-authored intent, but also inserted them itself, then once a piece of code has been formatted once, it can no longer tell which commas came from a human and which came from itself.

Always use tall style argument and parameter lists

If a trailing comma no longer lets a user choose between a short or tall style, then the formatter has to choose. I think it should always choose a tall style.

Both inside Google and externally, the clear trend is users adding trailing commas to opt into the tall style. An analysis of the 2,000 most recently published Pub packages shows:

-- Arg list (2672877 total) --
1522870 ( 56.975%): Single-line  ===========================
 455266 ( 17.033%): Empty        =========
 347996 ( 13.020%): Tall         =======
 208585 (  7.804%): Block-like   ====
 137682 (  5.151%): Short        ===
    478 (  0.018%): Other        =

The two lines that are relevant here are:

-- Arg list (2672877 total) --
 347996 ( 13.020%): Tall         =======
 137682 (  5.151%): Short        ===

Of the argument lists where either a short or tall style preference can be distinguished, users prefer the tall style ~71% of the time, even though they have to remember to hand-author a trailing comma on every argument list to get it.

Block-like argument lists

I've been talking about "short" and "tall" argument lists, but there are actually three ways that argument lists get formatted:

// Page width:               |

// Short style:
longFunctionName(
    veryLongArgument,
    anotherLongArgument);

// Tall style:
longFunctionName(
  veryLongArgument,
  anotherLongArgument,
);

// Block style:
longFunctionName(() {
  closure();
});

The third style is used when some of the arguments are function expressions or collection literals and formatting the argument list almost as if it were a built-in statement in the language looks better. The most common example is in tests:

main() {
  group('Some group', () {
    test('A test', () {
      expect(1 + 1, 2);
    });
  });
}

This proposal still supports block-style argument lists. It does somewhat tweak the heuristics the formatter uses to decide when an argument list should be block-like or not. The current heuristics are very complex, subtle, and still don't always look right.

Format the rest of the language holistically to match

The set of formatting rules for the different language features were not designed to make each feature look nice in isolation. Instead, the goal was a coherent set of style rules that hang together and make an entire source file clear and readable.

Those rules currently assume that argument and parameter lists have short formatting. With this proposal, we also adjust many of those other rules and heuristics now that we know that when an argument or parameter list splits, it will split in a tall style way.

There are many of these mostly small-scale tweaks. Some examples:

I used the Flutter repository as a reference, which uses tall style and has been very carefully hand-formatted for maximum readability, and tweaked the rules to follow that wherever I could. Many of the changes are subtle in ways that are hard to describe here. The best way to see them in action is to try out the prototype implementation, described below.

Risks

While I believe the proposed style looks better for most Dart code and makes users' lives simpler by not having to worry about maintaining trailing commas, this is a large change with some downsides:

Churn

Formatting a previously formatted codebase with this new style will cause about 10% of the lines of code to change. That's a large diff and can be disruptive to codebases where there are many in-progress changes.

Migrating to the new style may be painful for users, though of course it is totally automated.

Users may dislike the style

Obviously, if a large enough fraction of users don't want this change, we won't do it, which is what the change process aims to discover. But even if 90% of the users prefer the new style, that still leaves 10% who now feel the tool is worse than it was before.

Realistically, no change of this scale will please everyone. One of the main challenges in maintaining an opinionated formatter that only supports one consistent style is that some users are always unhappy with it. At least by rarely changing the style, we avoid drawing user attention to the style when they don't like it. This large, disruptive change will make all users at least briefly very aware of automated formatting.

Two configurable styles

An obvious solution to user dislike is to make the style configurable: We could let you specify whether you want the old formatting rules or the new ones. The formatter could support two separate holistic styles, without going all the way towards full configurability (which is an anti-goal for the tool).

There are engineering challenges with this. The internal representation the formatter uses to determine where to split lines has grown piecemeal over the formatter's history. The result is hard to extend and limited in the kind of style rules it can represent. When new language features are added it's often difficult to express the style rules we want in terms that the internal representation supports. Many long-standing bugs can't be fixed because the rule a user wants can't be modeled in the current representation.

This became clear when working on a prototype of the proposal. Getting it working was difficult and there are edge cases where I can't get it to model the rules I want.

If this proposal is accepted and we make large-scale changes to the formatting rules, we intend to take the opportunity to also implement a better internal representation. That's a large piece of work for a tool that generally doesn't have a lot of engineering resources allocated to it.

We don't have the bandwidth during this change to write a new IR, a new set of style rules and migrate the old style rules to the new IR. There may be old rules the new IR can't represent well.

We could keep the old IR around for the old style and use the new IR only for the new style. But going forward, we don't have the resources to maintain two sets of style rules and two separate internal representations, one for each. Every time a new language feature ships, the formatter needs support for it. Bugs would have to get fixed twice (or, more likely, only fixed for one style).

We might be able to temporarily support both styles in order to offer a migration period. But we don't have the resources to support both styles in perpetuity. We would rather spend those resources supporting one style really well instead of two styles poorly.

Evaluation

This is a large style change. Because the formatter is deliberately opinionated and non-configurable, it will affect all users of dart format. That means it's important to make sure that it's a good change in the eyes of as many users as possible. We can't please everyone, but we should certainly please a clear majority.

To that end, we need your feedback. That feedback is most useful when it's grounded in concrete experience.

Prototype implementation

To help with that, we have a prototype implementation of the new style rules. The prototype has known performance regressions and doesn't get the style exactly right in some cases. But, for the most part, it should show you the proposed behavior.

Diff corpus

We ran this prototype on a randomly selected corpus of code, which yields the resulting diff. This shows you how the new formatting compares to the behavior of the current formatter. Keep in mind that a diff focuses your attention on places where the style is different. It doesn't highlight the majority of lines of code that are formatted exactly the same under this proposal as they are today.

Running it yourself

To get a better sense of what it feels like to use this proposed behavior, you can install the prototype branch of the formatter from Git:

$ dart pub global activate \
    --source=git https://github.com/dart-lang/dart_style \
    --git-ref=flutter-style-experiment

You can run this using the same command-line options as dart format. For example, to reformat the current directly and its subdirectories, you would run:

$ dart pub global run dart_style:format .

To format just a single file, example.dart, you would run:

$ dart pub global run dart_style:format example.dart

Give us your feedback

Once you've tried it out, let us know what you think by taking this survey. You can also reply directly on this issue, but the survey will help us aggregate the responses more easily. We'll take the survey responses and any comments here into account and try our best to do what's right for the community.

After a week or, to give people time to reply, I'll update with what we've learned.

This is an uncomfortably large change to propose (and a hard one to implement!), so I appreciate your patience and understanding while we work through the process.

Thank you!

– Bob

suragch commented 1 year ago

I like it. I manually add trailing commas a lot in just the style that this proposal would handle automatically. This would save me time and effort.

Below are a few comments after trying out the prototype implementation.

Tall style readability

I often have a series of )))) end parentheses (or brackets or braces) in my code like so:

@override
Widget build(BuildContext context) {
  return const MaterialApp(
    home: Scaffold(
        body: Padding(
            padding: EdgeInsets.all(8.0),
            child: Center(
                child: Column(children: [
              Text('Hello'),
              Text('Dart'),
              Text('World')
            ])))),
  );
}

I usually add in trailing commas like this:

@override
Widget build(BuildContext context) {
  return const MaterialApp(
    debugShowCheckedModeBanner: false,
    home: Scaffold(
      body: Padding(
        padding: EdgeInsets.all(8.0),
        child: Center(
          child: Column(
            children: [
              Text('Hello'),
              Text('Dart'),
              Text('World'),
            ],
          ),
        ),
      ),
    ),
  );
}

However, I noticed the proposed formatter takes my carefully crafted formatting and does the following with it:

@override
Widget build(BuildContext context) {
  return const MaterialApp(
    debugShowCheckedModeBanner: false,
    home: Scaffold(
      body: Padding(
        padding: EdgeInsets.all(8.0),
        child: Center(
          child:
              Column(children: [Text('Hello'), Text('Dart'), Text('World')]),
        ),
      ),
    ),
  );
}

That's not terrible, but putting all of the Column elements on one line just because they fit isn't the most readable way of displaying them. It's possible to use a comment hack (see the next section) to force tall style, but I don't know if that's what you intend to become common practice.

Forcing tall style

When I make enums, sometimes I like the tall style even if they would fit on one line:

enum Colors {
  red,
  green,
  blue,
}

The proposed formatter wouldn't allow that anymore and would reformat the code above to the following:

enum Colors { red, green, blue }

That's no problem with me. I can get used to that.

And if I really want to keep the tall style, I can add a comment after the last item like so:

enum Colors {
  red,
  green,
  blue, //
}

Now the proposed formatter leaves my code style alone.

The following is a bug, I think, but if I add a comment after the second item like so:

enum Colors {
  red,
  green, //
  blue,
}

The proposed formatter breaks the code (and its promise not change more than the style):

enum Colors { red, green, // blue }
munificent commented 1 year ago

Thank you for the feedback!

Re:

          child:
              Column(children: [Text('Hello'), Text('Dart'), Text('World')]),

Yeah, that looks like a bug in the prototype. I wouldn't expect it to split after a named parameter like this. It should produce output closer to what you have with the current formatter and explicit trailing commas.

The proposed formatter breaks the code (and its promise not change more than the style):

enum Colors { red, green, // blue }

That's definitely a bug. The prototype is, uh, pretty prototype-y. :)

rakudrama commented 1 year ago

I find the proposed style quite a bit less consistent.

This example comes from https://github.com/dart-lang/sdk/blob/main/pkg/js_ast/lib/src/nodes.dart

Old - there are basically two formats for the visitor methods, one-line and two-line. The two-line version keeps the expression intact, and it is easy to see these methods all do the same thing. (If I was hand-formatting I might make the middle one break after => just like the others.)

  T visitVariableDeclarationList(VariableDeclarationList node) =>
      visitExpression(node);

  T visitAssignment(Assignment node) => visitExpression(node);

  T visitVariableInitialization(VariableInitialization node) =>
      visitExpression(node);

New - there are now three formats, depending on where the line splits are.

  T visitVariableDeclarationList(
    VariableDeclarationList node,
  ) => visitExpression(node);

  T visitAssignment(Assignment node) => visitExpression(node);

  T visitVariableInitialization(VariableInitialization node) => visitExpression(
    node,
  );

The formatting does not help me see these methods all do the same thing.

The line-breaks here fight against a cognitive principle. Programs, like sentences, have structure. The big things are composed of little things, and it is helpful to ingest the small concepts completely. Line breaks in the middle of small things hinder that. Breaks higher up the semantic structure are less disruptive:

The nervous chicken quickly crosses the dangerous road.

is clearer than

The nervous chicken quickly crosses the dangerous road.

The proposed formatting is too much like the latter.

Here is an example from the same package where the main point gets lost

  Instantiator visitLiteralExpression(LiteralExpression node) =>
      TODO('visitLiteralExpression');

  Instantiator visitLiteralStatement(LiteralStatement node) => TODO(
    'visitLiteralStatement',
  );

I believe there is a middle ground that is a little bit of the old style for smaller statements, expressions and definitions, and a little bit of the proposed style for larger constructs.

bsutton commented 1 year ago

Generally in agreement.

I do generally prefer my commas a the start of the line as it's easier to see that it's a continuation but I suspect I'm in the minority.

           callSomething(a
               , b
               , c);
BirjuVachhani commented 1 year ago

I agree. Manually adding trailing commas has been such a pain! Having to not type them manually for better formatting would be great!

Levi-Lesches commented 1 year ago

Love it! The formatter using "short style" is the main reason why I actually don't use it, and tell my team to disable it when working on Flutter projects. Tall style is much more my style and I'd be way more likely to use the formatter consistently if it switched to that.

Regarding

  T visitVariableInitialization(VariableInitialization node) =>
      visitExpression(node);

vs

  T visitVariableInitialization(VariableInitialization node) => visitExpression(
    node,
  );

I would personally aim for the first version for shorter constructs, but in general, the second version does scale better for longer expressions. So I'd understand if the formatter picked the "wrong" one because humans disagree on this too.

filiph commented 1 year ago

I really like the proposal!

One nit I have is this:

// Before:
var something =
    function(argument);

// After:
var something = function(
  argument,
);

I like Before better. To me, in this particular case, it's more readable, because we're keeping the verb(noun) together as long as possible, and the var something = line is very readable/skimmable to me (it's clear that it continues).

I feel quite strongly about this when it comes to functions. I'm not so sure when it comes to constructors (Widget(...)) but my preference still holds.

I agree with @Levi-Lesches's comment above (https://github.com/dart-lang/dart_style/issues/1253#issuecomment-1689464277).

bshlomo commented 1 year ago

A good idea but to know if the implementation is indeed good will take time less coding is always better. We will check and use it. 10x

mateusfccp commented 1 year ago

There are a few cases where I don't use trailing comma.

  1. Single arguments, when the argument is a single token:
// Single token single argument
foo(10); // <--- No trailing comma

// Multi token single argument
foo(
  numberFromText('ten'),
);
  1. When arguments are many but short (usually number literals)
    
    foo(1, 2, 3); // <--- No trailing comma

// Instead of foo( // <--- Unnecessarily long 1, 2, 3, )



Other than that, I always use trailing comma.

Overall, I think this is a good change. I think I would only avoid the case (1), as it's really unnecessary, but it would obviously make things more complicated.
modulovalue commented 1 year ago

My observation is that adding optional trailing commas almost always improves code readability, because it forces the formatter to put everything on a separate line, which implicitly enforces a "one concept per line"-rule, and I have found that to be very helpful when reading code.

The main change is that you no longer need to manually add or remove trailing commas

I think that automatically adding optional trailing commas could be helpful and I wouldn't be against that, but I think I wouldn't want the formatter to remove anything from my code except empty newlines. I want things to be on separate lines more often than not, and an explicit optional trailing comma has worked well as a way to tell the formatter that that's what I want.


If this moves forward, I think it would be great if this could be part of a bigger effort to promote more optional trailing commas as the preferred style (in, e.g., the form of recommended guidelines) + new syntax to support optional trailing commas in more places (e.g. https://github.com/dart-lang/language/issues/2430)

lesnitsky commented 1 year ago

Could this also account for pattern-matching formatting?

Current

final child = switch (a) {
  B() => BWrapper(
      child: const Text('Hello B'),
    ), // two spaces that feel redundant
  C() => CWrapper(
      child: const Text('Hello C'),
    ),  // two spaces that feel redundant
};

Desired

final child = switch (a) {
  B() => BWrapper(
    child: const Text('Hello B'),
  ),
  C() => CWrapper(
    child: const Text('Hello C'),
  ),
};

UPD: I've used dart pub global run dart_style:format and it doesn't add trailing comma:

final child = switch (a) {
  B() =>
    BWrapper(child: const Text('Hello B'), prop: 'Some long text goes here'),
  C() =>
    CWrapper(child: const Text('Hello C'), prop: 'Some long text goes here'),
};
munificent commented 1 year ago

Could this also account for pattern-matching formatting?

Yeah, there's probably some tweaks needed there to harmonize with the proposal. Thanks for bringing that example up. :)

lucavenir commented 1 year ago
var something =
   function(argument);

This hurts my eyes so bad 😆 Getting rid of this would lift a lot of OCD pain when writing code

greglittlefield-wf commented 1 year ago

Thanks for getting this prototype together and soliciting feedback!

After trying it on some of our code, the main piece of feedback that wasn't already mentioned already is that being able to force the "tall" style with trailing commas is unfortunate.

It doesn't happen often, but when using longer line lengths where function calls don't "need" to wrap as often, you can get code that's a bit harder to read (especially when it's parentheses-heavy 😅).

For example, at line length 120:

      // Current
      (Dom.div()..addProps(getModalBodyScrollerProps()))(
        renderCalloutIcons(),
        props.calloutHeader,
        props.children,
      ),
      // Prototype
      (Dom.div()..addProps(getModalBodyScrollerProps()))(renderCalloutIcons(), props.calloutHeader, props.children),

This problem also exists for map literals, which, unlike the example above, seem to be affected more often at smaller line lengths.

We'd probably end up decreasing our line lengths to get better wrapping, which isn't ideal for some of our packages with longer class and variable names. I realize, though, that 120 is quite a bit larger that dart_style's preferred line length of 80, so I'd understand if we'd have to move closer to that to get good formatting.

But for map literals, perhaps the rules could be tweaked to prefer "tall" style more often? For example, force “tall” style if a map has more than 2 or 3 elements, or if it contains more than 1 if or for element.

nex3 commented 1 year ago

I prefer the old version generally, but I'm willing to accept that I'm in the minority on that one and global consistency is more important than my preferences.

I'll echo that I don't like the splitting behavior for formerly-2-line => functions. It also seems very strange that, for example,

ModifiableCssSupportsRule copyWithoutChildren() =>
    ModifiableCssSupportsRule(condition, span);

becomes

ModifiableCssSupportsRule copyWithoutChildren() => ModifiableCssSupportsRule(
  condition,
  span,
);

which is actually taller than

ModifiableCssSupportsRule copyWithoutChildren() {
  return ModifiableCssSupportsRule(condition, span);
}

...which goes against the grain of => notionally being the "shorthand" method syntax.

mernen commented 1 year ago

Nice! Overall, I'm in favor, though I'm not sure which heuristics would work well for all cases, particularly involving collections. For example, in a layout container, I'd never want it to smash all children into a single line:

return Center(
  child: Column(
    children: [Text('Loading... please wait'), CircularProgressIndicator()],
  ),
);

Also, since this extra indentation of 2 levels goes completely against the grain, I'm guessing it wasn't intentional?

 placeholder: (context, url) => Center(
-  child: CircularProgressIndicator(
-    backgroundColor: Colors.white,
-  ),
-),
+      child: CircularProgressIndicator(
+        backgroundColor: Colors.white,
+      ),
+    ),
satvikpendem commented 1 year ago

I think enums should always be in the tall style just because reading each enumeration one line at a time makes it much easier to grasp what it's doing. I'd even support the tall style for enums that have just one member.

For functions, perhaps we should switch short vs tall based on the number of arguments, where one or two is short but 3 or more becomes tall (which is usually what I already do manually by adding commas), as well as basing it on column widths. We can even combine short and tall, perhaps:

ModifiableCssSupportsRule copyWithoutChildren() =>
  ModifiableCssSupportsRule(
    condition,
    span,
    anotherArgument,
  );

This is because I generally don't want to scroll all the way right just to see what is being returned in the arrow syntax. It is in the same vein as:

return Center(
  child: Column(
    children: ...
  ),
);

// instead of
return Center(child:
  Column(children: 
    ...
  ),
);

That is to say, the first (and current) example keeps things as left aligned as possible which makes scanning files much easier. Tthe combination of both short and tall as above feels to me to be a good compromise and is even more readable left to right, top to bottom, than either short or tall alone.

With regards to column widths, the formatter should check that first, then the number of arguments in a function or elements in an array or map, ie even if the following only has two elements, it should still force the tall version:

return Center(
  child: Column(
    children: [
      Text('Loading... please wait'),
      CircularProgressIndicator(),
    ],
  ),
);

Beyond that, I like the proposal as it saves a lot of time manually adding commas. I generally prefer the tall style for consistency.

shovelmn12 commented 1 year ago

Why not adding a formater config so users can choose their style....

dartformat.yaml

julemand101 commented 1 year ago

Why not adding a formater config so users can choose their style....

dartformat.yaml

https://github.com/dart-lang/dart_style/wiki/FAQ#why-cant-i-configure-it

munificent commented 1 year ago

Also, since this extra indentation of 2 levels goes completely against the grain, I'm guessing it wasn't intentional?

 placeholder: (context, url) => Center(
-  child: CircularProgressIndicator(
-    backgroundColor: Colors.white,
-  ),
-),
+      child: CircularProgressIndicator(
+        backgroundColor: Colors.white,
+      ),
+    ),

This one's a bug in the prototype. If a named argument's value is a closure, then it shouldn't add an extra +4 indentation like that. I'll fix that in the real implementation.

DanTup commented 1 year ago

Gets my 👍! I tend to use trailing commas a lot (even when not controlling the formatter, I like to not have extra modified lines in my diff if I'm appending new arguments to a "tall" list!).

I do have some questions though:

  1. Do I understand correctly that the formatter will actually add/remove commas (and not just format as if they were there)? I ask because in the analysis_server there's some code to compute a minimal set of edits from the formatter output (because replacing the entire file will mess up breakpoints/recent navigation/etc.) and the current implementation is very simple because it takes advantage of there only being whitespace changes. It would need some tweaks (or to become a real diff) if there could be non-whitespace changes.

  2. Will this functionality be opt-in (either permanently, or temporarily)? Many users have format-on-save enabled in VS Code and it could be surprising if in some future update (if you don't keep up with release notes etc.) you save a file you'd been working on and the formatting all changes (this exists a little today when there are minor formatting changes, but those tend to have less impact on the resulting diff). Hopefully you'd notice and could undo this before the undo buffer is lost and then migrate, but it's not guaranteed. Perhaps it'd be nice if the IDE could help with this - notifying that the formatting has changed and might produce significant edits and encouraging committing and reformatting the project/repo in one go?

munificent commented 1 year ago
  1. Do I understand correctly that the formatter will actually add/remove commas (and not just format as if they were there)?

Yes.

Will this functionality be opt-in (either permanently, or temporarily)?

There won't be any permanent opt-in or opt-out because we don't have the resources to maintain two formatting styles in perpetuity. There will be an experimental period where the new style is hidden behind a flag, mostly so that I can land in-progress work on master, but I expect few users to see that.

Once it's fully working and ready to ship, there may be a temporary opt-in (or out, not sure about the default) in order to ease the migration for users. I'm waiting for feedback to come in to get a sense of how important it is. So far, based on survey results, it doesn't seem to be a high priority for most users.

I'll try to get a better feel for what will help the community when a real implementation is farther along. I definitely want to minimize pain.

satvikpendem commented 1 year ago

I don't think it's necessarily high priority in terms of timeline (as I believe many will wait and adopt it when they wish to migrate) but it confers quite a large benefit to mental overhead as other languages don't really have this notion of changing commas to fix formatting, I've really only seen that in Dart. Most languages have their own fixed formatter, ie prettier, black, rustfmt, etc where most people don't really think about the formatting manually, but in Dart it seems we need to.

saierd commented 1 year ago

The last part means that users can no longer hand-author a trailing comma to mean, "I know it fits on one line but force it to split anyway because I want it to."

I think it is quite important to have this ability for Flutter code. Take for example this snippet:

Row(children: [
  Left(),
  Right(),
]);

This is not just a bunch of functions. It's a widget tree and formatting it in tall style reflects that. For this reason the Flutter documentation explicitly recommends to always add trailing commas in widgets to take advantage of this formatting behavior.

For users who prefer a tall style, they must be careful to add a trailing comma and then reformat whenever an existing argument or parameter list that used to fit on one line no longer does and gets split. By default, as soon as the list overflows, it will get the short style.

I find that enforcing trailing commas in places where wrapping happened anyway is not a problem in practice, since the require_trailing_commas rule exists and can automatically fix this.

hpoul commented 1 year ago

I always thought controlling the formatter by just appending a , felt really natural.. sometimes i want short lines to break and use tall formatting, either to stay consistent with other lines which happen to be a few characters longer, or to minimize diffs when more items are added to a list or enum, etc..

The workaround to use a // comment (https://github.com/dart-lang/dart_style/issues/1253#issuecomment-1689130905) to force wrapping would definitely not be an improvement 🙈

munificent commented 1 year ago

But for map literals, perhaps the rules could be tweaked to prefer "tall" style more often? For example, force “tall” style if a map has more than 2 or 3 elements, or if it contains more than 1 if or for element.

Yeah, that's not a bad idea. I'm interested in exploring this too. There is something roughly in the same direction in the current formatter in that it will split an outer collection literal if it contains another collection literal, even if the outer one otherwise doesn't need to split.

I've considered other rules around splitting when not strictly necessary to fit the line length, but I haven't really tried to implement anything because it's not clear what those rules should be and how they should best adapt to different line lengths.

One thing I've talked with @Hixie about is having the line length restriction work more like Prettier:

In code styleguides, maximum line length rules are often set to 100 or 120. However, when humans write code, they don’t strive to reach the maximum number of columns on every line. Developers often use whitespace to break up long lines for readability. In practice, the average line length often ends up well below the maximum.

Prettier’s printWidth option does not work the same way. It is not the hard upper allowed line length limit. It is a way to say to Prettier roughly how long you’d like lines to be. Prettier will make both shorter and longer lines, but generally strive to meet the specified printWidth.

So instead of treating it like a hard limit (which it currently does), it would be a softer boundary where lines are somewhat punished for going over when calculating the cost, but not heavily. I'm interested in exploring this approach, but I'm not planning to do that for this proposed set of changes. There are enough changes already queued up with this as it is. :)

alestiago commented 1 year ago

Thanks @munificent for this proposal. Overall, I'm quite happy with the change. I was wondering how would this affect (or if it has been considered) matrix representation, since currently it is not very convenient to read matrices.

I would like:

  Matrix4 m =
      Matrix4(11, 12, 13, 14, 21, 22, 23, 24, 31, 32, 33, 34, 41, 42, 43, 44);

To be something as:

  Matrix4 m = Matrix4(
      11, 12, 13, 14,
      21, 22, 23, 24,
      31, 32, 33, 34,
      41, 42, 43, 44);

Definitely not something as:

  Matrix4 m = Matrix4(
    11,
    12,
    13,
    14,
    21,
    22,
    23,
    24,
    31,
    32,
    33,
    34,
    41,
    42,
    43,
    44,
  );

The overall idea is simply to read the matrix as it is usually written in mathematics.

_This comment is somewhat related to what was mentioned here._

julemand101 commented 1 year ago

@alestiago I think it is going to be hard to know for sure how people wants to split their lists/arguments for best formatting. The current practice is to add comments to force the line breaks:

  Matrix4 m = Matrix4(
    11, 12, 13, 14, //
    21, 22, 23, 24, //
    31, 32, 33, 34, //
    41, 42, 43, 44, //
  );

I hope this is still going to be a valid solution after the suggested change in this proposal.

alestiago commented 1 year ago

@julemand101 , thanks for hopping in! I also use the comments to avoid the new line, and also hope that this behaviour is improved or kept the same after the proposed changed.

munificent commented 1 year ago

I hope this is still going to be a valid solution after the suggested change in this proposal.

It will, yes.

lucavenir commented 1 year ago

I'm not sure if this is possible, but considering how deep this change might be, can we consider the following formatting change, too?

Instead of this weird text wrap:

final test =
    veryLongFunctionName(veryLongParamName: veryLongInputtedParam).someMethod().anotherMethod();

Can't dartfmt always enforce this wrap, instead?

final test = veryLongFunctionName(veryLongParamName: veryLongInputtedParam) //
    .someMethod()
    .anotherMethod();

I'm bringing up this topic here because, to my understanding, this proposal would change the above by forcing a trailing comma just after veryLongInputtedParam; i.e. you'd obtain this:

final test = veryLongFunctionName(
  veryLongParamName: veryLongInputtedParam,
).someMethod().anotherMethod();

Is my assumption correct? Because if it is - I'll be honest - this is not desirable. Methods chain deserve a new line for each method, imho.

If anything, I'd prefer the following "mix" of the two:

final test = veryLongFunctionName(
    veryLongParamName: veryLongInputtedParam,
  ) //
  .someMethod()
  .anotherMethod();

In other words this is just a request to improve readability even more, combined with this proposal.

To me this solution feels really... tidy. Does anyone else agree with this?

munificent commented 1 year ago

Figuring out the exact heuristics between splitting method chains and argument lists is really hard. It's easy to come up with simple rules that look great some cases but end up looking much worse for others.

The formatter currently has heuristics that do a mostly good job most of the time but get a little weird sometimes. They're also pretty painfully complex. Some of that complexity is from limitations in the internal architecture. It has to eagerly make some decisions about how it might be allowed to split before it has any information on line length. My hope with this proposal is to also move to a better internal representation that lets us make those decisions during line splitting. That should enable us to come up with better heuristics for cases like this where you have a few constructs combined.

I expect there will be some tweaking and iteration of those rules once I have the basics in place.

I like your second example of the preferred formatting too. It's mostly a question of having it do that on code like this while not doing something worse on similar but slightly different code.

munificent commented 1 year ago

OK, I think we've had enough time to gather responses!

Based on the feedback, we have decided:

Thank you to everyone who read the proposal, filled out the survey, commented here, or left a reaction. It's your responses that decided this. Details on how we evaluated the responses are below.

While the feedback shows a clear majority of you prefer the proposed style, some don't. If you are in that latter set, I'm truly sorry. One of the real difficulties of maintaining an opinionated formatter and working to keep an ecosystem consistent is that some users don't get what they want. Often, my job feels like running the only ice cream store in town with a tiny freezer that only has room for a single flavor.

My own experience with the proposed style is that it looked strange at first, but I've grown to like it more over time. I hope that will be true for many of you as well. If not, I hope you still appreciate the value of a consistent style so that we can all read each other's code more easily and spend more time worrying about what our code does and less time worrying about how it looks.

Implementation plan

While most survey respondents are in favor of the change, the majority of Dart users are likely completely unaware of the proposal. Even if they ultimately like the new style, having all of their code spontaneously and dramatically reformatted when they upgrade to a new Dart SDK is not a good user experience.

I'll talk about this more when the implementation is farther along, but the basic idea is to support both styles for a while and to use language versioning to opt in to the new style.

This way, when a user installs Dart SDK 3.X, their formatting won't change. It will only change when they update to the latest language version by updating their SDK constraint in their pubspec. If it happens to be a bad time for them to reformat, they can always undo that change by going back to the previous language version and staying on the old style.

Evaluating feedback

I collected feedback in a few ways:

Here's the results:

Should we accept it?

Reactions:

YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY-NNNN

[Y] 👍  333 (93.3%)
[-] 👀    2 ( 0.6%)
[N] 👎   22 ( 6.2%)

Survey:

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaaaaaaaa---ddddDDDDDD

[A] Strong agree      15 (62.5%)
[a] Agree              5 (20.8%)
[-] Neutral            1 ( 4.2%)
[d] Disagree           1 ( 4.2%)
[D] Strong disagree    2 ( 8.3%)

Internal short survey:

YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY--NNNNNNNN

[Y] Yes    182 (87.1%)
[-] Maybe    6 ( 2.9%)
[N] No      21 (10.0%)

Internal long survey:

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaaaaaaaaaa-----dddddddddddddddddd

[A] Strong agree       8 (47.1%)
[a] Agree              4 (23.5%)
[-] Neutral            1 ( 5.9%)
[d] Disagree           4 (23.5%)
[D] Strong disagree    0 ( 0.0%)

For the short survey and reactions where we have feedback from the most users, they are overwhelmingly in favor. Long survey results are also in favor, though more mixed.

Qualitative feedback

The surveys also have a text box for qualitative feedback. I'm not sharing the detailed answers here because I didn't ask respondents if they were OK with that, but I'll try to summarize them.

There were 44 qualitative responses on the internal survey:

Of the 7 external qualitative responses:

Forcing argument lists to split

One feature of the current formatter is that a hand-authored trailing comma will force an argument list to split even if it would otherwise fit on one line. The proposal removes this feature.

The survey asks "I sometimes deliberately insert a trailing comma to force an argument or parameter list to split even though it would fit on one line.":

External:

AAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaaaaaaa----------ddddddddddddddddddddDDDDDDDDDD

[A] Strong agree       7 (29.2%)
[a] Agree              5 (20.8%)
[-] Neutral            3 (12.5%)
[d] Disagree           6 (25.0%)
[D] Strong disagree    3 (12.5%)

Internal:

AAAAAAAAAAAAAAAAAAAAaaaaa-----dddddddddddddddDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD

[A] Strong agree       4 (25.0%)
[a] Agree              1 ( 6.3%)
[-] Neutral            1 ( 6.3%)
[d] Disagree           3 (18.8%)
[D] Strong disagree    7 (43.8%)

A handful of qualitative responses mentioned wanting this capability this too.

Even with this proposal, users can force an argument list to split by placing a // line comment anywhere inside, but that may be annoying for what they're trying to accomplish.

My hope is that we can alleviate much of this desire by having better heuristics in the formatter around splitting an argument list even if it would technically fit when unsplit. We'll iterate on this some before it ships.

I don't plan to treat a trailing comma as always forcing a split like some users are requesting because that breaks reversibility—the formatter should be able to remove a trailing comma that it itself introduced.

Splitting argument lists instead of after "="

In addition to changing how trailing commas are handled, the proposal also tweaks many of the other formatting rules and heuristics to better accommodate that style. One example the proposal shows is preferring to split inside an argument list instead of after an =:

// Page width:               |

// Before:
var something =
    function(argument);

// After:
var something = function(
  argument,
);

Some of the negative qualitative feedback was about this example or generalizing to other argument lists. There are similar comments on the proposal issue.

I suspect some of this feedback is overfitting to an unfortunately small example chosen for the proposal. But I see enough comments on this, as well as some feedback on tests where users have run the prototype on real code to take the concern seriously.

I think the real implementation should have different heuristics than the prototype to allow it to sometimes split after = and keep the resulting argument list on a single line. It will take some iteration and testing on real codebases to figure out what those heuristics will be.

Churn and migration

Even if users like the proposal, they may feel it is a bridge too far. From the survey:

The migration will be painful:

AAAAAAAaaa----ddddddddddddddddddddddddddddddddddddddddDDDDDDDDDDDDDDDDDDDDDDDDDD

[A] Strong agree       2 ( 8.3%)
[a] Agree              1 ( 4.2%)
[-] Neutral            1 ( 4.2%)
[d] Disagree          12 (50.0%)
[D] Strong disagree    8 (33.3%)

Like the proposal but not worth the churn:

AAAA-------------dddddddddddddDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD

[A] Strong agree       1 ( 4.2%)
[a] Agree              0 ( 0.0%)
[-] Neutral            4 (16.7%)
[d] Disagree           4 (16.7%)
[D] Strong disagree   15 (62.5%)

And internally...

The migration will be painful:

AAAAAAaaaaa----------------dddddddddddDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD

[A] Strong agree       1 ( 6.7%)
[a] Agree              1 ( 6.7%)
[-] Neutral            3 (20.0%)
[d] Disagree           2 (13.3%)
[D] Strong disagree    8 (53.3%)

Like the proposal but not worth the churn:

AAAAAAAAAA----------dddddddddddddddDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD

[A] Strong agree       2 (12.5%)
[a] Agree              0 ( 0.0%)
[-] Neutral            2 (12.5%)
[d] Disagree           3 (18.8%)
[D] Strong disagree    9 (56.3%)

Note that the questions here are worded in the negative: Disagreement means they feel migration will be easy. Overall, users seem to think it's worth doing (which the initial "Should we accept the proposal?" question also implies). But there are some that are worried about the transition pain.

I believe the implementation plan and gradual opt in will address most of these concerns.

Conclusion

Overall, the results I see from the surveys, comments on the issue, email, and other informal discussions all point to this being a fairly clear mandate that it's what most of you want. There definitely are some users who don't like the proposed style, but an opinionated formatter will never please everyone.

I suspect there is a large silent majority who aren't on the mailing lists, didn't see the issue, and don't know about this proposal at all. I suspect that their preferences generally align with the survey responses, but they still may be unpleasantly surprised to have a new formatting style spontaneously get applied to their code if this proposal ships. We'll use proactive communication and a user-controlled opt in to avoid pain from this.

Thank you again for everyone who took the time to fill out the survey, read the proposal, comment on the issue, or otherwise get involved.

Now I'll get busy implementing it for real. :)

ykmnkmi commented 11 months ago

I wish the formatter could keep deep collections in one line if they fit:

print({'user': {'name': 'John Doe', 'email': 'john@doe.com'}});

vs

print({
  'user': {'name': 'John Doe', 'email': 'john@doe.com'}
});
TekExplorer commented 11 months ago

I wish the formatter could keep deep collections in one line if they fit:

print({'user': {'name': 'John Doe', 'email': 'john@doe.com'}});

vs

print({
  'user': {'name': 'John Doe', 'email': 'john@doe.com'}
});

I don't agree. I personally think that looks awful and unreadable. It would be much more readable if that split fully.

mateusfccp commented 11 months ago

I wish the formatter could keep deep collections in one line if they fit:

print({'user': {'name': 'John Doe', 'email': 'john@doe.com'}});

vs

print({
  'user': {'name': 'John Doe', 'email': 'john@doe.com'}
});

I would rather have:

print(
  {
    'user': {
      'name': 'John Doe',
      'email': 'john@doe.com',
    },
  },
);
notAxion commented 11 months ago

I would like to have mix of this last 2 when the line is less then 80 characters then i would something like

print({
  'user': {'name': 'John Doe', 'email': 'john@doe.com'}
});

and when its over 80 characters it would then break it into more lines and the line count would happen on a key value pair so if i have i have

print({ 'user': {'name': 'John Doe', 'email': 'john@doe.com', 'ids': [1,2,3,4,5,6,7,8,9]}});

i want neither

print(
  'user': {'name': 'John Doe', 'email': 'john@doe.com', 'ids': [1,2,3,4,5,6,7,8,9]}
);

nor

print(
  'user': {
    'name': 'John Doe',
    'email': 'john@doe.com',
    'ids': [
      1,
      2,
      3,
      4,
      5,
      6,
      7,
      8,
      9,
    ],
  },
);

I would have

print(
  'user': {
    'name': 'John Doe',
    'email': 'john@doe.com',
    'ids': [1,2,3,4,5,6,7,8,9],
  },
);

instead of int array there could be string array but if its not over 80 characters i wouldn't like to have it broken into pieces.

munificent commented 11 months ago

We may revisit the heuristics around when nested collections are forced to split, but this proposal involves enough changes as it is, so I don't want to open the can of worms and revisit every single style choice with it. We can also look at tweaking the rules around nested collection splitting as a separate change.

Number-3434 commented 8 months ago

I would like there to be an option for the formatter to either always use the most compact form possible, or only allow either 1-line or 3-line+ (removing trailing commas if necessary).

If it cannot be compacted down, it should insert any trailing commas.

(Basically full auto mode).

How long before this is implemented?

munificent commented 8 months ago

(Basically full auto mode).

Yes, that's the proposed behavior. It will unsplit things if it can (and remove any trailing commas to do so), and if it can't it will split and add trailing commas.

How long before this is implemented?

We are hard at work on it. We just got to the point where the entire language is supported with the new style. There are a bunch of TODOs to sort out and likely some tweaks to various heuristics, but it's getting close. I don't have a concrete estimate for when it will ship.

munificent commented 8 months ago

For anyone following along at home, I filed a #1403 tracking issue for where we are and what's left to do.

srawlins commented 7 months ago

I couldn't try the new style with the above command (dart pub global activate ... --git-ref=flutter-style-experiment), "Could not find git ref 'flutter-style-experiment'". Is there a new way to try tall style locally?

munificent commented 7 months ago

"Could not find git ref 'flutter-style-experiment'". Is there a new way to try tall style locally?

The flutter-style-experiment is the old prototype implementation. The real implementation of the new style is on the main branch behind an experiment flag. So if you dart pub global activate the main branch of the GitHub repo, you can then do:

$ dart pub global run dart_style:dartfmt --enable-experiment=tall-style -w <paths>

(Note that the binary you get from global activating still has the old pre dart format CLI, hence the -w.)

North101 commented 5 months ago

I like most changes except when it changes something from tall style to short style. I am making a conscious choice to use tall style because I find the short style to be harder to read

obayit commented 5 months ago

@munificent In what case a trailing comma can be useful in assert? I found this in the documentation too, but I really can't think of a way that a trailing comma is of any use!

eernstg commented 5 months ago

Maybe situations like this one?:

class A {
  A(int i)
      : assert(
          i < longExpressionThatWontReallyAllowThisAssertToBeOnASingleLine,
          "Long sentence about what went wrong during construction of this A",
        );
}
obayit commented 5 months ago

@eernstg I still don't get it, is it useful because if I copied the string's line to somewhere else (e.g in the middle of a list) I would still get the comma at the end, and won't have to add it manually?

It just looks like it have no practical use.

eernstg commented 5 months ago

The whole point of this proposal is to eliminate the need for manually maintaining trailing commas. The example I mentioned would not fit on a single line, which means that the formatter would use the tall style, and hence it would ensure that there is a trailing comma.

if I copied the string's line to somewhere else (e.g in the middle of a list) I would still get the comma at the end

That's one reason. It is probably more important that the preservation of the trailing comma in some cases will help us avoid a lot of reformatting changes: If your expression uses the tall style today and it won't fit on a single line then it just stays as it is (except for all the exceptions—it's @munificent who knows all the details). If the expression fits on a single line then it will be formatted as such. If there is a trailing comma then it will be removed.

If we had chosen to remove all trailing commas then the diff would be a lot bigger.

obayit commented 5 months ago

@eernstg thanks for the explanation.

munificent commented 4 months ago

In what case a trailing comma can be useful in assert?

There's not a lot of practical value, no. But assert() is syntactically very similar to a function call and there is a style meta-rule that says that syntactically similar constructs are generally formatted in similar ways. The in-progress tall style adds trailing commas to any construct that:

That covers argument lists, parameter lists, collection literals, switch expression cases, etc. Assertions also meet all of those requirements so they get a trailing comma too even though it isn't super useful.