Skip to content

Commit e697b6e

Browse files
authored
Format adjacent strings. (#1364)
Format adjacent strings. Most of this was fairly straightforward, but the two tricky bits are: ### 1. Deciding whether or not to indent There are some rules around whether subsequent strings in the adjacent strings get indented or not. The answer is yes in some cases to avoid confusion: ``` var list = function( 'string 1', 'adjacent' 'string 2', 'string 3', ]; ``` But not in others since it looks nicer to line them up when possible: ``` var description = 'some text ' 'more text'; ``` ### 2. Handling `test()` and `group()` It's really important that test functions don't end up fully split because doing so would lead to the inner function expression getting indented +2: ``` test('this looks good', () { body; }); test( 'this looks bad', () { body; }, ); ``` Test descriptions often span multiple lines using adjacent strings: ``` test('this is a very long test description ' 'spanning multiple lines, () { body; }); ``` Normally, the newline inside the adjacent strings would cause the entire argument list to split. The old style handles that (I think) by allowing multiple block-formatted arguments and then treating both the adjacent strings and the function expressions as block arguments. The new style currently only allows a single block argument (because in almost all of the Flutter code I found using block formatting, one argument was sufficient). So I chose a more narrowly targeted rule here where we allow adjacent strings to not prevent block formatting only if the adjacent strings are the first argument and the block argument is a function expression as the next argument. I left a TODO to see if we want to iterate on that rule, but I think it works pretty well. ### Other stuff Unlike the old style, I chose to always split between adjacent strings. The old style will preserve newlines there but if a user chooses to deliberately put multiple adjacent strings on the same line and they fit, it will honor it. That didn't seem useful to me, so now they just always split. I don't think adjacent strings ever look good on the same line. I ended up moving the state to track which elements in a ListPiece out of ListPiece and into the ListElements themselves. I think it's clearer this way and will be easier to evolve if we end up supporting multiple block formatted elements in a single list.
1 parent 990df02 commit e697b6e

File tree

8 files changed

+605
-24
lines changed

8 files changed

+605
-24
lines changed

lib/src/ast_extensions.dart

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,63 @@ extension CascadeExpressionExtensions on CascadeExpression {
314314
}
315315
}
316316

317+
extension AdjacentStringsExtensions on AdjacentStrings {
318+
/// Whether subsequent strings should be indented relative to the first
319+
/// string.
320+
///
321+
/// We generally want to indent adjacent strings because it can be confusing
322+
/// otherwise when they appear in a list of expressions, like:
323+
///
324+
/// [
325+
/// "one",
326+
/// "two"
327+
/// "three",
328+
/// "four"
329+
/// ]
330+
///
331+
/// Especially when these strings are longer, it can be hard to tell that
332+
/// "three" is a continuation of the previous element.
333+
///
334+
/// However, the indentation is distracting in places that don't suffer from
335+
/// this ambiguity:
336+
///
337+
/// var description =
338+
/// "A very long description..."
339+
/// "this extra indentation is unnecessary.");
340+
///
341+
/// To balance these, we omit the indentation when an adjacent string
342+
/// expression is in a context where it's unlikely to be confusing.
343+
bool get indentStrings {
344+
bool hasOtherStringArgument(List<Expression> arguments) => arguments
345+
.any((argument) => argument != this && argument is StringLiteral);
346+
347+
return switch (parent) {
348+
ArgumentList(:var arguments) => hasOtherStringArgument(arguments),
349+
350+
// Treat asserts like argument lists.
351+
Assertion(:var condition, :var message) =>
352+
hasOtherStringArgument([condition, if (message != null) message]),
353+
354+
// Don't add extra indentation in a variable initializer or assignment:
355+
//
356+
// var variable =
357+
// "no extra"
358+
// "indent";
359+
VariableDeclaration() => false,
360+
AssignmentExpression(:var rightHandSide) when rightHandSide == this =>
361+
false,
362+
363+
// Don't indent when following `:`.
364+
MapLiteralEntry(:var value) when value == this => false,
365+
NamedExpression() => false,
366+
367+
// Don't indent when the body of a `=>` function.
368+
ExpressionFunctionBody() => false,
369+
_ => true,
370+
};
371+
}
372+
}
373+
317374
extension PatternExtensions on DartPattern {
318375
/// Whether this expression is a non-empty delimited container for inner
319376
/// expressions that allows "block-like" formatting in some contexts.

lib/src/front_end/ast_node_visitor.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import '../ast_extensions.dart';
1010
import '../constants.dart';
1111
import '../dart_formatter.dart';
1212
import '../piece/adjacent.dart';
13+
import '../piece/adjacent_strings.dart';
1314
import '../piece/assign.dart';
1415
import '../piece/block.dart';
1516
import '../piece/constructor.dart';
@@ -110,7 +111,8 @@ class AstNodeVisitor extends ThrowingAstVisitor<Piece> with PieceFactory {
110111

111112
@override
112113
Piece visitAdjacentStrings(AdjacentStrings node) {
113-
throw UnimplementedError();
114+
return AdjacentStringsPiece(node.strings.map(nodePiece).toList(),
115+
indent: node.indentStrings);
114116
}
115117

116118
@override

lib/src/front_end/delimited_list_builder.dart

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,10 @@ class DelimitedListBuilder {
5252
/// Creates the final [ListPiece] out of the added brackets, delimiters,
5353
/// elements, and style.
5454
ListPiece build() {
55-
var blockElement = -1;
56-
if (_style.allowBlockElement) blockElement = _findBlockElement();
55+
_setBlockElementFormatting();
5756

58-
var piece = ListPiece(_leftBracket, _elements, _blanksAfter, _rightBracket,
59-
_style, blockElement);
57+
var piece =
58+
ListPiece(_leftBracket, _elements, _blanksAfter, _rightBracket, _style);
6059
if (_mustSplit) piece.pin(State.split);
6160
return piece;
6261
}
@@ -155,6 +154,9 @@ class DelimitedListBuilder {
155154

156155
// See if it's an expression that supports block formatting.
157156
var format = switch (element) {
157+
AdjacentStrings(indentStrings: true) =>
158+
BlockFormat.indentedAdjacentStrings,
159+
AdjacentStrings() => BlockFormat.unindentedAdjacentStrings,
158160
FunctionExpression() when element.canBlockSplit => BlockFormat.function,
159161
Expression() when element.canBlockSplit => BlockFormat.block,
160162
DartPattern() when element.canBlockSplit => BlockFormat.block,
@@ -388,32 +390,86 @@ class DelimitedListBuilder {
388390
);
389391
}
390392

391-
/// If [_blockCandidates] contains a single expression that can receive
392-
/// block formatting, then returns its index. Otherwise returns `-1`.
393-
int _findBlockElement() {
393+
/// Looks at the [BlockFormat] types of all of the elements to determine if
394+
/// one of them should be block formatted.
395+
///
396+
/// Also, if an argument list has an adjacent strings expression followed by a
397+
/// block formattable function expression, we allow the adjacent strings to
398+
/// split without forcing the list to split so that it can continue to have
399+
/// block formatting. This is pretty special-cased, but it makes calls to
400+
/// `test()` and `group()` look better and those are so common that it's
401+
/// worth massaging them some. It allows:
402+
///
403+
/// test('some long description'
404+
/// 'split across multiple lines', () {
405+
/// expect(1, 1);
406+
/// });
407+
///
408+
/// Without this special rule, the newline in the adjacent strings would
409+
/// prevent block formatting and lead to the entire test body to be indented:
410+
///
411+
/// test(
412+
/// 'some long description'
413+
/// 'split across multiple lines',
414+
/// () {
415+
/// expect(1, 1);
416+
/// },
417+
/// );
418+
///
419+
/// Stores the result of this calculation by setting flags on the
420+
/// [ListElement]s.
421+
void _setBlockElementFormatting() {
394422
// TODO(tall): These heuristics will probably need some iteration.
395423
var functions = <int>[];
396424
var others = <int>[];
425+
var adjacentStrings = <int>[];
397426

398427
for (var i = 0; i < _elements.length; i++) {
399428
switch (_elements[i].blockFormat) {
400429
case BlockFormat.function:
401430
functions.add(i);
402431
case BlockFormat.block:
403432
others.add(i);
433+
case BlockFormat.indentedAdjacentStrings:
434+
case BlockFormat.unindentedAdjacentStrings:
435+
adjacentStrings.add(i);
404436
case BlockFormat.none:
405437
break; // Not a block element.
406438
}
407439
}
408440

409-
// A function expression takes precedence over other block arguments.
410-
if (functions.length == 1) return functions.first;
441+
switch ((functions, others, adjacentStrings)) {
442+
// Only allow block formatting in an argument list containing adjacent
443+
// strings when:
444+
//
445+
// 1. The block argument is a function expression.
446+
// 2. It is the second argument, following an adjacent strings expression.
447+
// 3. There are no other adjacent strings in the argument list.
448+
//
449+
// This matches the `test()` and `group()` and other similar APIs where
450+
// you have a message string followed by a block-like function expression
451+
// but little else.
452+
// TODO(tall): We may want to iterate on these heuristics. For now,
453+
// starting with something very narrowly targeted.
454+
case ([1], _, [0]):
455+
// The adjacent strings.
456+
_elements[0].allowNewlines = true;
457+
if (_elements[0].blockFormat == BlockFormat.unindentedAdjacentStrings) {
458+
_elements[0].indentWhenBlockFormatted = true;
459+
}
460+
461+
// The block-formattable function.
462+
_elements[1].allowNewlines = true;
411463

412-
// Otherwise, if there is single block argument, it can be block formatted.
413-
if (functions.isEmpty && others.length == 1) return others.first;
464+
// A function expression takes precedence over other block arguments.
465+
case ([var blockArgument], _, _):
466+
// Otherwise, if there one block argument, it can be block formatted.
467+
case ([], [var blockArgument], _):
468+
_elements[blockArgument].allowNewlines = true;
469+
}
414470

415-
// There are no block arguments, or it's ambiguous as to which one should
416-
// be it.
471+
// If we get here, there are no block arguments, or it's ambiguous as to
472+
// which one should be it so none are.
417473
// TODO(tall): The old formatter allows multiple block arguments, like:
418474
//
419475
// function(() {
@@ -426,6 +482,5 @@ class DelimitedListBuilder {
426482
// sometimes. We'll probably want to experiment to see if it's worth
427483
// supporting multiple block arguments. If so, we should at least require
428484
// them to be contiguous with no non-block arguments in the middle.
429-
return -1;
430485
}
431486
}

lib/src/piece/adjacent_strings.dart

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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 '../constants.dart';
6+
import 'piece.dart';
7+
8+
/// Piece for a series of adjacent strings, like:
9+
///
10+
/// var message =
11+
/// 'This is a long message '
12+
/// 'split into multiple strings';
13+
class AdjacentStringsPiece extends Piece {
14+
final List<Piece> _strings;
15+
16+
/// Whether strings after the first should be indented.
17+
final bool _indent;
18+
19+
AdjacentStringsPiece(this._strings, {bool indent = true}) : _indent = indent;
20+
21+
@override
22+
void format(CodeWriter writer, State state) {
23+
if (_indent) writer.setIndent(Indent.expression);
24+
25+
for (var i = 0; i < _strings.length; i++) {
26+
if (i > 0) writer.newline();
27+
writer.format(_strings[i]);
28+
}
29+
}
30+
31+
@override
32+
void forEachChild(void Function(Piece piece) callback) {
33+
_strings.forEach(callback);
34+
}
35+
}

lib/src/piece/list.dart

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,8 @@ class ListPiece extends Piece {
5555
/// The details of how this particular list should be formatted.
5656
final ListStyle _style;
5757

58-
/// If this list has an element that can receive block formatting, this is
59-
/// the elements's index. Otherwise `-1`.
60-
final int _blockElement;
61-
6258
ListPiece(this._before, this._elements, this._blanksAfter, this._after,
63-
this._style, this._blockElement);
59+
this._style);
6460

6561
@override
6662
List<State> get additionalStates => [if (_elements.isNotEmpty) State.split];
@@ -102,16 +98,27 @@ class ListPiece extends Piece {
10298
Commas.none => false,
10399
};
104100

105-
// Only allow newlines in the block element or in all elements if we're
106-
// fully split.
107-
writer.setAllowNewlines(i == _blockElement || state == State.split);
108-
109101
var element = _elements[i];
102+
103+
// Only some elements (usually a single block element) allow newlines
104+
// when the list itself isn't split.
105+
writer.setAllowNewlines(element.allowNewlines || state == State.split);
106+
107+
// If this element allows newlines when the list isn't split, add
108+
// indentation if it requires it.
109+
if (state == State.unsplit && element.indentWhenBlockFormatted) {
110+
writer.setIndent(Indent.expression);
111+
}
112+
110113
element.format(writer,
111114
appendComma: appendComma,
112115
// Only allow newlines in comments if we're fully split.
113116
allowNewlinesInComments: state == State.split);
114117

118+
if (state == State.unsplit && element.indentWhenBlockFormatted) {
119+
writer.setIndent(Indent.none);
120+
}
121+
115122
// Write a space or newline between elements.
116123
if (!isLast) {
117124
writer.splitIf(state != State.unsplit,
@@ -172,6 +179,35 @@ final class ListElement {
172179
/// What kind of block formatting can be applied to this element.
173180
final BlockFormat blockFormat;
174181

182+
/// Whether newlines are allowed in this element when this list is unsplit.
183+
///
184+
/// This is generally only true for a single "block" element, as in:
185+
///
186+
/// function(argument, [
187+
/// block,
188+
/// element,
189+
/// ], another);
190+
bool allowNewlines = false;
191+
192+
/// Whether we should increase indentation when formatting this element when
193+
/// the list isn't split.
194+
///
195+
/// This only comes into play for unsplit lists and is only relevant when the
196+
/// element contains newlines, which means that this is only ever useful when
197+
/// [allowNewlines] is also true.
198+
///
199+
/// This is used for adjacent strings expression at the beginning of an
200+
/// argument list followed by a function expression, like in a `test()` call.
201+
/// Since the adjacent strings may not require indentation when the list is
202+
/// fully split, this ensures that they are indented properly when the list
203+
/// isn't split. Avoids:
204+
//
205+
// test('long description'
206+
// 'that should be indented', () {
207+
// body;
208+
// });
209+
bool indentWhenBlockFormatted = false;
210+
175211
/// If this piece has an opening delimiter after the comma, this is its
176212
/// lexeme, otherwise an empty string.
177213
///
@@ -286,6 +322,16 @@ enum BlockFormat {
286322
/// can be block formatted.
287323
block,
288324

325+
/// The element is an adjacent strings expression that's in an list that
326+
/// requires its subsequent lines to be indented (because there are other
327+
/// string literal in the list).
328+
indentedAdjacentStrings,
329+
330+
/// The element is an adjacent strings expression that's in an list that
331+
/// doesn't require its subsequent lines to be indented (because there
332+
/// are no other string literals in the list).
333+
unindentedAdjacentStrings,
334+
289335
/// The element can't be block formatted.
290336
none,
291337
}

0 commit comments

Comments
 (0)