Skip to content

Commit 7276774

Browse files
authored
Avoid gratuitous splits after =. (#1554)
This makes two changes that improve how assignments are formatted: 1. If a comment is before a cascade setter, don't force the setter to split. Prior to this change, if you had: ```dart target // Comment ..setter = value; ``` The formatter would give you: ```dart target // Comment ..setter = value; ``` It attached the comment to the `..setter`, which was the LHS of the assignment. Then, because there was a newline inside the LHS, it forced a split at the `=`. Instead, we hoist those leading comments out, similar to how we handle binary operators. In fact, I did some refactoring to get rid of duplicate code in InfixPiece that handled leading comments. 2. If the LHS of an assignment is a call chain, don't force the assignment to split if the chain does. In the process of fixing 1, I ran into a similar problem. If you had: ```dart target // Comment .setter = value; ``` The formatter would give you: ```dart target // Comment .setter = value; ``` However, the solution is different in this case. With cascade setters, the target of the `=` is just the `..setter`. With a non-cascade setter, the target is the entire `target // Comment \n.setter` part. That *does* contain a newline, and we can't hoist the comment out of the assignment because the target really is the entire `target // Comment \n.setter` expression. Instead, we treat call chains on the LHS of assignments as "block formattable". "Block" is kind of a misnomer here because what it really means is "allow a newline without splitting at the `=` or increasing indentation". I can't think of a good term for that. That change fixes the above example, but also generally improves how setter calls are formatted when the target is a large call chain expression like: ```dart // Before this PR: target.method( argument, ).setter = value; // After: target.method(argument) .setter = value; ``` This second change isn't entirely... principled? But I tested on a giant corpus and when it kicks in, it invariably produces nicer output. Fix #1429.
1 parent d3b5aed commit 7276774

File tree

9 files changed

+244
-47
lines changed

9 files changed

+244
-47
lines changed

lib/src/back_end/solver.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ class Solver {
134134
if (debug.traceSolver) {
135135
debug.unindent();
136136
debug.log(debug.bold('Solved $root to $best'));
137-
debug.log(solution.code.toDebugString());
137+
debug.log(best.code.toDebugString());
138138
debug.log('');
139139
}
140140

lib/src/front_end/ast_node_visitor.dart

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ class AstNodeVisitor extends ThrowingAstVisitor<void> with PieceFactory {
132132

133133
@override
134134
void visitAdjacentStrings(AdjacentStrings node) {
135-
var piece = InfixPiece(const [], node.strings.map(nodePiece).toList(),
135+
var piece = InfixPiece(node.strings.map(nodePiece).toList(),
136136
indent: node.indentStrings);
137137

138138
// Adjacent strings always split.
@@ -366,7 +366,7 @@ class AstNodeVisitor extends ThrowingAstVisitor<void> with PieceFactory {
366366
}
367367
}
368368

369-
var piece = InfixPiece(leadingComments, operands);
369+
var piece = InfixPiece(operands);
370370

371371
// If conditional expressions are directly nested, force them all to split,
372372
// both parents and children.
@@ -376,7 +376,7 @@ class AstNodeVisitor extends ThrowingAstVisitor<void> with PieceFactory {
376376
piece.pin(State.split);
377377
}
378378

379-
pieces.add(piece);
379+
pieces.add(prependLeadingComments(leadingComments, piece));
380380
}
381381

382382
@override
@@ -1766,8 +1766,7 @@ class AstNodeVisitor extends ThrowingAstVisitor<void> with PieceFactory {
17661766
var patternPiece = nodePiece(member.guardedPattern.pattern);
17671767

17681768
if (member.guardedPattern.whenClause case var whenClause?) {
1769-
pieces.add(
1770-
InfixPiece(const [], [patternPiece, nodePiece(whenClause)]));
1769+
pieces.add(InfixPiece([patternPiece, nodePiece(whenClause)]));
17711770
} else {
17721771
pieces.add(patternPiece);
17731772
}

lib/src/front_end/chain_builder.dart

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,29 @@ class ChainBuilder {
7575

7676
// When [_root] is a cascade, the chain is the series of cascade sections.
7777
for (var section in cascade.cascadeSections) {
78-
var piece = _visitor.nodePiece(section);
78+
// Hoist any leading comment out so that if the cascade section is a
79+
// setter, we don't unnecessarily split at the `=` like:
80+
//
81+
// target
82+
// // comment
83+
// ..setter =
84+
// value;
85+
var leadingComments =
86+
_visitor.pieces.takeCommentsBefore(section.firstNonCommentToken);
87+
88+
var piece = _visitor.prependLeadingComments(
89+
leadingComments, _visitor.nodePiece(section));
7990

8091
var callType = switch (section) {
92+
// Force the cascade to split if there are leading comments before
93+
// the cascade section to avoid:
94+
//
95+
// target// comment
96+
// ..method(
97+
// argument,
98+
// );
99+
_ when leadingComments.isNotEmpty => CallType.unsplittableCall,
100+
81101
// If the section is itself a method chain, then force the cascade to
82102
// split if the method does, as in:
83103
//

lib/src/front_end/piece_factory.dart

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import '../piece/control_flow.dart';
1212
import '../piece/for.dart';
1313
import '../piece/if_case.dart';
1414
import '../piece/infix.dart';
15+
import '../piece/leading_comment.dart';
1516
import '../piece/list.dart';
1617
import '../piece/piece.dart';
1718
import '../piece/sequence.dart';
@@ -284,6 +285,15 @@ mixin PieceFactory {
284285
return builder.build();
285286
}
286287

288+
/// If [leadingComments] is not empty, returns [piece] wrapped in a
289+
/// [LeadingCommentPiece] that prepends them.
290+
///
291+
/// Otherwise, just returns [piece].
292+
Piece prependLeadingComments(List<Piece> leadingComments, Piece piece) {
293+
if (leadingComments.isEmpty) return piece;
294+
return LeadingCommentPiece(leadingComments, piece);
295+
}
296+
287297
/// Writes the leading keyword and parenthesized expression at the beginning
288298
/// of an `if`, `while`, or `switch` expression or statement.
289299
void writeControlFlowStart(Token keyword, Token leftParenthesis,
@@ -853,7 +863,7 @@ mixin PieceFactory {
853863
switch (combinatorNode) {
854864
case HideCombinator(hiddenNames: var names):
855865
case ShowCombinator(shownNames: var names):
856-
clauses.add(InfixPiece(const [], [
866+
clauses.add(InfixPiece([
857867
tokenPiece(combinatorNode.keyword),
858868
for (var name in names) tokenPiece(name.token, commaAfter: true),
859869
]));
@@ -921,7 +931,8 @@ mixin PieceFactory {
921931
pieces.visit(right);
922932
});
923933

924-
pieces.add(InfixPiece(leadingComments, [leftPiece, rightPiece]));
934+
pieces.add(prependLeadingComments(
935+
leadingComments, InfixPiece([leftPiece, rightPiece])));
925936
}
926937

927938
/// Writes a chained infix operation: a binary operator expression, or
@@ -973,7 +984,8 @@ mixin PieceFactory {
973984
traverse(node);
974985
}));
975986

976-
pieces.add(InfixPiece(leadingComments, operands, indent: indent));
987+
pieces.add(prependLeadingComments(
988+
leadingComments, InfixPiece(operands, indent: indent)));
977989
}
978990

979991
/// Writes a [ListPiece] for the given bracket-delimited set of elements.
@@ -1225,7 +1237,7 @@ mixin PieceFactory {
12251237
var clauses = <Piece>[];
12261238

12271239
void typeClause(Token keyword, List<AstNode> types) {
1228-
clauses.add(InfixPiece(const [], [
1240+
clauses.add(InfixPiece([
12291241
tokenPiece(keyword),
12301242
for (var type in types) nodePiece(type, commaAfter: true),
12311243
]));
@@ -1321,6 +1333,26 @@ mixin PieceFactory {
13211333
// element,
13221334
// ];
13231335
canBlockSplitLeft |= switch (leftHandSide) {
1336+
// Treat method chains and cascades on the LHS as if they were blocks.
1337+
// They don't really fit the "block" term, but it looks much better to
1338+
// force a method chain to split on the left than to try to avoid
1339+
// splitting it and split at the assignment instead:
1340+
//
1341+
// // Worse:
1342+
// target.method(
1343+
// argument,
1344+
// ).setter =
1345+
// value;
1346+
//
1347+
// // Better:
1348+
// target.method(argument)
1349+
// .setter = value;
1350+
//
1351+
MethodInvocation() => true,
1352+
PropertyAccess() => true,
1353+
PrefixedIdentifier() => true,
1354+
1355+
// Otherwise, it must be an actual block construct.
13241356
Expression() => leftHandSide.canBlockSplit,
13251357
DartPattern() => leftHandSide.canBlockSplit,
13261358
_ => false

lib/src/piece/infix.dart

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,6 @@ import 'piece.dart';
99
///
1010
/// a + b + c
1111
class InfixPiece extends Piece {
12-
/// Pieces for leading comments that appear before the first operand.
13-
///
14-
/// We hoist these comments out from the first operand's first token so that
15-
/// a newline in these comments doesn't erroneously force the infix operator
16-
/// to split. For example:
17-
///
18-
/// value =
19-
/// // comment
20-
/// a + b;
21-
///
22-
/// Here, the `// comment` will be hoisted out and stored in
23-
/// [_leadingComments] instead of being a leading comment in the [CodePiece]
24-
/// for `a`. If we left the comment in `a`, then the newline after the line
25-
/// comment would force the `+` operator to split yielding:
26-
///
27-
/// value =
28-
/// // comment
29-
/// a +
30-
/// b;
31-
final List<Piece> _leadingComments;
32-
3312
/// The series of operands.
3413
///
3514
/// Since we don't split on both sides of the operator, the operators will be
@@ -41,26 +20,16 @@ class InfixPiece extends Piece {
4120
/// Whether operands after the first should be indented if split.
4221
final bool _indent;
4322

44-
InfixPiece(this._leadingComments, this._operands, {bool indent = true})
45-
: _indent = indent;
23+
InfixPiece(this._operands, {bool indent = true}) : _indent = indent;
4624

4725
@override
4826
List<State> get additionalStates => const [State.split];
4927

5028
@override
51-
bool allowNewlineInChild(State state, Piece child) {
52-
if (state == State.split) return true;
53-
54-
// Comments before the operands don't force the operator to split.
55-
return _leadingComments.contains(child);
56-
}
29+
bool allowNewlineInChild(State state, Piece child) => state == State.split;
5730

5831
@override
5932
void format(CodeWriter writer, State state) {
60-
for (var comment in _leadingComments) {
61-
writer.format(comment);
62-
}
63-
6433
if (_indent) writer.pushIndent(Indent.expression);
6534

6635
for (var i = 0; i < _operands.length; i++) {
@@ -78,7 +47,6 @@ class InfixPiece extends Piece {
7847

7948
@override
8049
void forEachChild(void Function(Piece piece) callback) {
81-
_leadingComments.forEach(callback);
8250
_operands.forEach(callback);
8351
}
8452

lib/src/piece/leading_comment.dart

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
import '../back_end/code_writer.dart';
5+
import 'piece.dart';
6+
7+
/// A piece for a series of leading comments preceding some other piece.
8+
///
9+
/// We use this and hoist comments out from the inner piece so that a newline
10+
/// in the comments doesn't erroneously force the inner piece to split. For
11+
/// example, if comments preceding an infix operator's left operand:
12+
///
13+
/// value =
14+
/// // comment
15+
/// a + b;
16+
///
17+
/// Here, the `// comment` will be hoisted out and stored in a
18+
/// [LeadingCommentPiece] instead of being a leading comment in the [CodePiece]
19+
/// for `a`. If we left the comment in `a`, then the newline after the line
20+
/// comment would force the `+` operator to split yielding:
21+
///
22+
/// value =
23+
/// // comment
24+
/// a +
25+
/// b;
26+
class LeadingCommentPiece extends Piece {
27+
final List<Piece> _comments;
28+
final Piece _piece;
29+
30+
LeadingCommentPiece(this._comments, this._piece);
31+
32+
@override
33+
void format(CodeWriter writer, State state) {
34+
for (var comment in _comments) {
35+
writer.format(comment);
36+
}
37+
38+
writer.format(_piece);
39+
}
40+
41+
@override
42+
void forEachChild(void Function(Piece piece) callback) {
43+
_comments.forEach(callback);
44+
callback(_piece);
45+
}
46+
}

test/tall/invocation/cascade_comment.stmt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,20 @@ receiver
4747
// comment 2
4848
..cascade2()
4949
// comment 3
50-
..cascade3();
50+
..cascade3();
51+
>>> Comment before setter.
52+
target
53+
// comment
54+
..setter = value;
55+
<<<
56+
target
57+
// comment
58+
..setter = value;
59+
>>> Comment before single method cascade.
60+
target
61+
// comment
62+
..method(argument);
63+
<<<
64+
target
65+
// comment
66+
..method(argument);

test/tall/invocation/chain_comment.stmt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,10 @@ target
9090
)
9191
.third(
9292
// c3
93-
);
93+
);
94+
>>> Line comment before setter.
95+
target // c
96+
.prop = value;
97+
<<<
98+
target // c
99+
.prop = value;

0 commit comments

Comments
 (0)