Skip to content

Commit 28fa4be

Browse files
authored
Support "block argument" formatting. (#1322)
Support "block argument" formatting. Sometimes in an argument list, we don't want to force it to full split even if an argument contains an inner newline. In other words, we want to prefer: ``` test('hi', () { body; }); ``` Over: ``` test( 'hi', () { body; }, ); ``` The latter is what you would normally get when an argument contains a newline. This change enables the former. The heuristics for what kinds of arguments are allowed to have this block-like formatting, how many of them, and what other arguments are allowed are pretty fuzzy. I picked a simple set of rules here (which follow the prototype), but left some TODOs because I suspect we will iterate on them later once we can run the new formatter on larger codebases. The heuristic for what kinds of arguments are block-like is the same rule that we use for "delimited" expressions after an assignment. I think it's good that those two agree and helps make the style overall more consistent. Basically, any time the right-hand side of an `=` or `:` doesn't need to split, that same kind of expression can be block formatted: ``` var list = [ element, ]; function([ element, ]); ``` Block formatting is used for argument lists, asserts, and switch values. I also simplified AssignPiece. It used an extra unnecessary to allow newlines inside delimited value expressions without splitting after "=". Also renamed "isDelimited" (never a great name) to "canBlockSplit", which I think better captures its use.
1 parent fd6ed34 commit 28fa4be

18 files changed

+877
-133
lines changed

lib/src/ast_extensions.dart

Lines changed: 71 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,11 @@ extension AstNodeExtensions on AstNode {
8181
if (node is SpreadElement) {
8282
var expression = node.expression;
8383
if (expression is ListLiteral) {
84-
if (!expression.elements.isEmptyBody(expression.rightBracket)) {
84+
if (expression.elements.canSplit(expression.rightBracket)) {
8585
return expression.leftBracket;
8686
}
8787
} else if (expression is SetOrMapLiteral) {
88-
if (!expression.elements.isEmptyBody(expression.rightBracket)) {
88+
if (expression.elements.canSplit(expression.rightBracket)) {
8989
return expression.leftBracket;
9090
}
9191
}
@@ -99,44 +99,91 @@ extension AstIterableExtensions on Iterable<AstNode> {
9999
/// Whether there is a comma token immediately following this.
100100
bool get hasCommaAfter => isNotEmpty && last.hasCommaAfter;
101101

102-
/// Whether the collection literal or block containing these nodes and
103-
/// terminated by [rightBracket] is empty or not.
102+
/// Whether the delimited construct containing these nodes and terminated by
103+
/// [rightBracket] can have a split inside it.
104104
///
105-
/// An empty collection must have no elements or comments inside. Collections
106-
/// like that are treated specially because they cannot be split inside.
107-
bool isEmptyBody(Token rightBracket) =>
108-
isEmpty && rightBracket.precedingComments == null;
105+
/// We disallow splitting for entirely empty delimited constructs like `[]`,
106+
/// but allow a split if there are elements or comments inside.
107+
bool canSplit(Token rightBracket) =>
108+
isNotEmpty || rightBracket.precedingComments != null;
109109
}
110110

111111
extension ExpressionExtensions on Expression {
112-
/// Whether this expression is a "delimited" one that allows block-like
113-
/// formatting in some contexts. For example, in an assignment, a split in
114-
/// the assigned value is usually indented:
112+
/// Whether this expression is a non-empty delimited container for inner
113+
/// expressions that allows "block-like" formatting in some contexts. For
114+
/// example, in an assignment, a split in the assigned value is usually
115+
/// indented:
115116
///
116117
/// ```
117118
/// var variableName =
118119
/// longValue;
119120
/// ```
120121
///
121-
/// But not if the initializer is a delimited expression and we don't split
122-
/// at the `=`:
122+
/// But if the initializer is block-like, we don't split at the `=`:
123123
///
124124
/// ```
125125
/// var variableName = [
126126
/// element,
127127
/// ];
128128
/// ```
129-
bool get isDelimited => switch (this) {
130-
FunctionExpression() => true,
131-
InstanceCreationExpression() => true,
132-
ListLiteral() => true,
133-
MethodInvocation() => true,
134-
ParenthesizedExpression(:var expression) => expression.isDelimited,
135-
RecordLiteral() => true,
136-
SetOrMapLiteral() => true,
137-
SwitchExpression() => true,
138-
_ => false,
139-
};
129+
///
130+
/// Likewise, in an argument list, block-like expressions can avoid splitting
131+
/// the surrounding argument list:
132+
///
133+
/// ```
134+
/// function([
135+
/// element,
136+
/// ]);
137+
/// ```
138+
///
139+
/// Completely empty delimited constructs like `[]` and `foo()` don't allow
140+
/// splitting inside them, so are not considered block-like.
141+
bool get canBlockSplit {
142+
// Unwrap named expressions to get the real expression inside.
143+
var expression = this;
144+
if (expression is NamedExpression) {
145+
expression = expression.expression;
146+
}
147+
148+
// TODO(tall): We should also allow multi-line strings to be formatted
149+
// like block arguments, at least in some cases like:
150+
//
151+
// ```
152+
// function('''
153+
// Lots of
154+
// text
155+
// ''');
156+
// ```
157+
158+
// TODO(tall): Consider whether immediately-invoked function expressions
159+
// should be block argument candidates, like:
160+
//
161+
// ```
162+
// function(() {
163+
// body;
164+
// }());
165+
// ```
166+
return switch (expression) {
167+
// A function expression can use either a non-empty parameter list or a
168+
// non-empty block body for block formatting.
169+
FunctionExpression(:var parameters?, :var body) =>
170+
parameters.parameters.canSplit(parameters.rightParenthesis) ||
171+
(body is BlockFunctionBody &&
172+
body.block.statements.canSplit(body.block.rightBracket)),
173+
ListLiteral(:var elements, :var rightBracket) ||
174+
SetOrMapLiteral(:var elements, :var rightBracket) =>
175+
elements.canSplit(rightBracket),
176+
RecordLiteral(:var fields, :var rightParenthesis) =>
177+
fields.canSplit(rightParenthesis),
178+
SwitchExpression(:var cases, :var rightBracket) =>
179+
cases.canSplit(rightBracket),
180+
InstanceCreationExpression(:var argumentList) ||
181+
MethodInvocation(:var argumentList) =>
182+
argumentList.arguments.canSplit(argumentList.rightParenthesis),
183+
ParenthesizedExpression(:var expression) => expression.canBlockSplit,
184+
_ => false,
185+
};
186+
}
140187

141188
/// Whether this is an argument in an argument list with a trailing comma.
142189
bool get isTrailingCommaArgument {

lib/src/front_end/ast_node_visitor.dart

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,8 @@ class AstNodeVisitor extends ThrowingAstVisitor<void>
115115

116116
@override
117117
void visitArgumentList(ArgumentList node) {
118-
createList(
119-
leftBracket: node.leftParenthesis,
120-
node.arguments,
121-
rightBracket: node.rightParenthesis);
118+
createArgumentList(
119+
node.leftParenthesis, node.arguments, node.rightParenthesis);
122120
}
123121

124122
@override
@@ -134,14 +132,13 @@ class AstNodeVisitor extends ThrowingAstVisitor<void>
134132
@override
135133
void visitAssertStatement(AssertStatement node) {
136134
token(node.assertKeyword);
137-
createList(
138-
[
139-
node.condition,
140-
if (node.message case var message?) message,
141-
],
142-
leftBracket: node.leftParenthesis,
143-
rightBracket: node.rightParenthesis,
144-
);
135+
createArgumentList(
136+
node.leftParenthesis,
137+
[
138+
node.condition,
139+
if (node.message case var message?) message,
140+
],
141+
node.rightParenthesis);
145142
token(node.semicolon);
146143
}
147144

@@ -438,7 +435,6 @@ class AstNodeVisitor extends ThrowingAstVisitor<void>
438435
spaceWhenUnsplit: true, splitListIfBeforeSplits: true));
439436
builder.leftBracket(node.leftBracket);
440437
node.constants.forEach(builder.visit);
441-
442438
builder.rightBracket(semicolon: node.semicolon, node.rightBracket);
443439
pieces.give(builder.build());
444440
} else {
@@ -959,8 +955,10 @@ class AstNodeVisitor extends ThrowingAstVisitor<void>
959955

960956
@override
961957
void visitMethodInvocation(MethodInvocation node) {
962-
// TODO(tall): Support method invocation with explicit target expressions.
963-
if (node.target != null) throw UnimplementedError();
958+
// TODO(tall): Support splitting at `.` or `?.`. Right now we just format
959+
// it inline so that we can use method calls in other tests.
960+
visit(node.target);
961+
token(node.operator);
964962

965963
visit(node.methodName);
966964
visit(node.typeArguments);

lib/src/front_end/comment_writer.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ import 'piece_writer.dart';
3434
/// couple of different ways.
3535
///
3636
/// Comments between top-level declarations, member declarations inside types,
37-
/// and statements are handled directly by [SequenceBuilder].
37+
/// and statements are handled directly by [SequenceBuilder]. Comments inside
38+
/// argument lists, collection literals, and other similar constructs are
39+
/// handled directly be [DelimitedPieceBuilder].
3840
///
3941
/// All other comments occur inside the middle of some expression or other
4042
/// construct. These get directly embedded in the [TextPiece] of the code being
4143
/// written. When that [TextPiece] is output later, it will include the comments
4244
/// as well.
43-
// TODO(tall): When argument lists and their comment handling is supported,
44-
// mention that here.
4545
mixin CommentWriter {
4646
PieceWriter get pieces;
4747

lib/src/front_end/delimited_list_builder.dart

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,15 @@ class DelimitedListBuilder {
4444
/// literal, etc.
4545
DelimitedListBuilder(this._visitor, [this._style = const ListStyle()]);
4646

47-
ListPiece build() =>
48-
ListPiece(_leftBracket, _elements, _blanksAfter, _rightBracket, _style);
47+
/// Creates the final [ListPiece] out of the added brackets, delimiters,
48+
/// elements, and style.
49+
ListPiece build() {
50+
var blockElement = -1;
51+
if (_style.allowBlockElement) blockElement = _findBlockElement();
52+
53+
return ListPiece(_leftBracket, _elements, _blanksAfter, _rightBracket,
54+
_style, blockElement);
55+
}
4956

5057
/// Adds the opening [bracket] to the built list.
5158
///
@@ -119,8 +126,8 @@ class DelimitedListBuilder {
119126
/// [addCommentsBefore()] for the first token in the [piece].
120127
///
121128
/// Assumes there is no comma after this piece.
122-
void add(Piece piece) {
123-
_elements.add(ListElement(piece));
129+
void add(Piece piece, [BlockFormat format = BlockFormat.none]) {
130+
_elements.add(ListElement(piece, format));
124131
_commentsBeforeComma = CommentSequence.empty;
125132
}
126133

@@ -136,9 +143,16 @@ class DelimitedListBuilder {
136143
// Handle comments between the preceding element and this one.
137144
addCommentsBefore(element.firstNonCommentToken);
138145

146+
// See if it's an expression that supports block formatting.
147+
var format = switch (element) {
148+
FunctionExpression() when element.canBlockSplit => BlockFormat.function,
149+
Expression() when element.canBlockSplit => BlockFormat.block,
150+
_ => BlockFormat.none,
151+
};
152+
139153
// Traverse the element itself.
140154
_visitor.visit(element);
141-
add(_visitor.pieces.split());
155+
add(_visitor.pieces.split(), format);
142156

143157
var nextToken = element.endToken.next!;
144158
if (nextToken.lexeme == ',') {
@@ -373,4 +387,47 @@ class DelimitedListBuilder {
373387
leading: leadingComments
374388
);
375389
}
390+
391+
/// If [_blockCandidates] contains a single expression that can receive
392+
/// block formatting, then returns its index. Otherwise returns `-1`.
393+
int _findBlockElement() {
394+
// TODO(tall): These heuristics will probably need some iteration.
395+
var functions = <int>[];
396+
var others = <int>[];
397+
398+
for (var i = 0; i < _elements.length; i++) {
399+
switch (_elements[i].blockFormat) {
400+
case BlockFormat.function:
401+
functions.add(i);
402+
case BlockFormat.block:
403+
others.add(i);
404+
case BlockFormat.none:
405+
break; // Not a block element.
406+
}
407+
}
408+
409+
// A function expression takes precedence over other block arguments.
410+
if (functions.length == 1) return functions.first;
411+
412+
// Otherwise, if there is single block argument, it can be block formatted.
413+
if (functions.isEmpty && others.length == 1) return others.first;
414+
415+
// There are no block arguments, or it's ambiguous as to which one should
416+
// be it.
417+
// TODO(tall): The old formatter allows multiple block arguments, like:
418+
//
419+
// ```
420+
// function(() {
421+
// body;
422+
// }, () {
423+
// more;
424+
// });
425+
// ```
426+
//
427+
// This doesn't seem very common in the Flutter repo, but does occur
428+
// sometimes. We'll probably want to experiment to see if it's worth
429+
// supporting multiple block arguments. If so, we should at least require
430+
// them to be contiguous with no non-block arguments in the middle.
431+
return -1;
432+
}
376433
}

lib/src/front_end/piece_factory.dart

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ typedef BinaryOperation = (AstNode left, Token operator, AstNode right);
4949
mixin PieceFactory implements CommentWriter {
5050
void visit(AstNode? node, {void Function()? before, void Function()? after});
5151

52+
/// Creates a [ListPiece] for an argument list.
53+
void createArgumentList(
54+
Token leftBracket, Iterable<AstNode> elements, Token rightBracket) {
55+
return createList(
56+
leftBracket: leftBracket,
57+
elements,
58+
rightBracket: rightBracket,
59+
style: const ListStyle(allowBlockElement: true));
60+
}
61+
5262
/// Creates a [BlockPiece] for a given bracket-delimited block or declaration
5363
/// body.
5464
///
@@ -425,20 +435,16 @@ mixin PieceFactory implements CommentWriter {
425435
/// Visits the `switch (expr)` part of a switch statement or expression.
426436
void createSwitchValue(Token switchKeyword, Token leftParenthesis,
427437
Expression value, Token rightParenthesis) {
428-
// Format like an argument list since it is an expression surrounded by
429-
// parentheses.
430-
var builder = DelimitedListBuilder(
431-
this, const ListStyle(commas: Commas.none, splitCost: 2));
432-
433438
// Attach the `switch ` as part of the `(`.
434439
token(switchKeyword);
435440
space();
436441

437-
builder.leftBracket(leftParenthesis);
438-
builder.visit(value);
439-
builder.rightBracket(rightParenthesis);
440-
441-
pieces.give(builder.build());
442+
createList(
443+
leftBracket: leftParenthesis,
444+
[value],
445+
rightBracket: rightParenthesis,
446+
style: const ListStyle(
447+
commas: Commas.none, splitCost: 2, allowBlockElement: true));
442448
}
443449

444450
/// Creates a class, enum, extension, mixin, or mixin application class
@@ -605,7 +611,7 @@ mixin PieceFactory implements CommentWriter {
605611

606612
var initializer = pieces.take();
607613
pieces.give(AssignPiece(target, initializer,
608-
isValueDelimited: rightHandSide.isDelimited));
614+
isValueDelimited: rightHandSide.canBlockSplit));
609615
}
610616

611617
/// Finishes writing a named function declaration or anonymous function

0 commit comments

Comments
 (0)