Skip to content

Commit 0a78529

Browse files
authored
Use all unsolved pieces on the offending line as expand pieces. (#1494)
The solver works by incrementally building a up a solution by binding pieces to states one at a time. To avoid wasting time exploring solutions that are pointless, it only looks at unbound pieces in play when the first line containing overflow characters or an invalid newline as written. Before this PR, it only looked at the *first* unbound piece on that line. Often, the first piece on the line is not the one that actually needs to split. For example, in: ```dart // | variable = function(argument); ``` Here, the first piece on the overflowing line is the AssignPiece for the `=`, but the piece we actually want to split at is the ListPiece for the argument list. To handle that, the solver currently tries binding the first piece to all values, including State.unsplit, even though that's effectively the value the current solution used, since unbound pieces behave like they have State.unsplit. The only reason it makes a new solution and binds the piece to State.unsplit is that if that piece turns out to *not* be the one that needs to split, we can now find the *next* piece on that same line. Now that the first piece is *bound* to State.unsplit, the second piece will be the first *unbound* one. But the end result is that we end up generating a lot of more or less redundant solutions that just bind a bunch of pieces to State.unsplit and then produce the exact same formatted result. Instead, this PR collects *all* of the unbound pieces on the first overflowing line. When it expands, it expands them all, but *doesn't* bind any of them to State.unsplit. The old formatter works the same way, but it wasn't clear to me that doing so was important for perf. It is! ``` Benchmark (tall) fastest median slowest average baseline ----------------------------- -------- ------- ------- ------- -------- block 0.065 0.067 0.131 0.070 96.3% chain 1.515 1.540 1.629 1.547 172.2% collection 0.169 0.173 0.189 0.175 98.6% collection_large 0.896 0.926 0.959 0.925 96.5% conditional 0.088 0.089 0.104 0.091 179.9% curry 1.651 1.667 1.689 1.668 147.9% flutter_popup_menu_test 0.409 0.422 0.448 0.423 116.8% flutter_scrollbar_test 0.154 0.159 0.176 0.159 94.2% function_call 1.428 1.457 1.625 1.463 98.1% infix_large 0.680 0.702 0.776 0.708 144.4% infix_small 0.163 0.167 0.193 0.169 97.5% interpolation 0.090 0.093 0.120 0.094 107.0% large 4.552 4.631 4.987 4.650 90.6% top_level 0.180 0.183 0.214 0.185 135.0% ``` My goal is to get the new formatter as fast as the old one on a real-world corpus. Here's the results for formatting the Flutter repo: ``` Current formatter 15.890 ======================================== Optimized 14.309 ==================================== Old formatter 7.131 ================= The current formatter is 55.12% slower than the old formatter. The optimized is 11.05% faster than the current formatter. The optimized is 50.16% slower than the old formatter. The optimization gets the formatter 18.05% of the way to the old one. ``` So not a huge improvement, but a big step in the right direction.
1 parent d2b88fc commit 0a78529

File tree

4 files changed

+74
-65
lines changed

4 files changed

+74
-65
lines changed

lib/src/back_end/code_writer.dart

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -72,22 +72,18 @@ class CodeWriter {
7272
/// this line.
7373
bool _foundExpandLine = false;
7474

75-
/// The first solvable piece on the first overflowing or invalid line, if
76-
/// we've found one.
75+
/// The solvable pieces on the first overflowing or invalid line, if we've
76+
/// found any.
7777
///
7878
/// A piece is "solvable" if we haven't already bound it to a state and there
7979
/// are multiple states it accepts. This is the piece whose states will be
8080
/// bound when we expand the [Solution] that this [CodeWriter] is building
8181
/// into further solutions.
8282
///
83-
/// If [_foundExpandLine] is `false`, then this is the first solvable piece
84-
/// that has written text to the current line. It may not actually be an
85-
/// expand piece. We don't know until we reach the end of the line to see if
86-
/// it overflows or is invalid. If the line is OK, then [_nextPieceToExpand]
87-
/// is cleared when the next line begins. If [_foundExpandLine] is `true`,
88-
/// then this known to be the piece that will be expanded next for this
89-
/// solution.
90-
Piece? _nextPieceToExpand;
83+
/// If [_foundExpandLine] is `true`, then this contains the list of unsolved
84+
/// pieces that were being formatted when text was written to the first
85+
/// problematic line.
86+
final List<Piece> _expandPieces = [];
9187

9288
/// The stack of solvable pieces currently being formatted.
9389
///
@@ -96,6 +92,10 @@ class CodeWriter {
9692
/// solution if the line ends up overflowing.
9793
final List<Piece> _currentUnsolvedPieces = [];
9894

95+
/// The set of unsolved pieces that were being formatted when text was
96+
/// written to the current line.
97+
final Set<Piece> _currentLinePieces = {};
98+
9999
/// [leadingIndent] is the number of spaces of leading indentation at the
100100
/// beginning of each line independent of indentation created by pieces being
101101
/// written.
@@ -106,12 +106,12 @@ class CodeWriter {
106106
_pendingIndent = leadingIndent;
107107
}
108108

109-
/// Returns the final formatted text and the next piece that can be expanded
109+
/// Returns the final formatted text and the next pieces that can be expanded
110110
/// from the solution this [CodeWriter] is writing, if any.
111-
(String, Piece?) finish() {
111+
(String, List<Piece>) finish() {
112112
_finishLine();
113113

114-
return (_buffer.toString(), _nextPieceToExpand);
114+
return (_buffer.toString(), _expandPieces);
115115
}
116116

117117
/// Appends [text] to the output.
@@ -128,11 +128,9 @@ class CodeWriter {
128128
_column += text.length;
129129

130130
// If we haven't found an overflowing line yet, then this line might be one
131-
// so keep track of the pieces we've encountered.
132-
if (!_foundExpandLine &&
133-
_nextPieceToExpand == null &&
134-
_currentUnsolvedPieces.isNotEmpty) {
135-
_nextPieceToExpand = _currentUnsolvedPieces.first;
131+
// so keep track of the unsolved pieces we've encountered on it.
132+
if (!_foundExpandLine) {
133+
_currentLinePieces.addAll(_currentUnsolvedPieces);
136134
}
137135
}
138136

@@ -377,17 +375,16 @@ class CodeWriter {
377375
_solution.addOverflow(_column - _pageWidth);
378376
}
379377

380-
// If we found a problematic line, and there is a piece on the line that
381-
// we can try to split, then remember that piece so that the solution will
382-
// expand it next.
383-
if (!_foundExpandLine &&
384-
_nextPieceToExpand != null &&
385-
(_column > _pageWidth || !_solution.isValid)) {
386-
// We found a problematic line, so remember it and the piece on it.
378+
// If we found a problematic line, and there is are pieces on the line that
379+
// we can try to split, then remember them so that the solution will expand
380+
// them next.
381+
if (!_foundExpandLine && (_column > _pageWidth || !_solution.isValid)) {
382+
// We found a problematic line, so remember the pieces on it.
387383
_foundExpandLine = true;
384+
_expandPieces.addAll(_currentLinePieces);
388385
} else if (!_foundExpandLine) {
389386
// This line was OK, so we don't need to expand the piece on it.
390-
_nextPieceToExpand = null;
387+
_currentLinePieces.clear();
391388
}
392389
}
393390
}

lib/src/back_end/solution.dart

Lines changed: 50 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -72,20 +72,20 @@ class Solution implements Comparable<Solution> {
7272
///
7373
/// So we skip past any pieces that aren't on overflowing lines or on lines
7474
/// whose newline led to an invalid solution. Further, it's also the case
75-
/// that splitting an earlier pieces will often reshuffle the formatting of
76-
/// much of the code following it.
75+
/// that splitting earlier pieces will often reshuffle the formatting of much
76+
/// of the code following it.
7777
///
78-
/// Thus we only worry about the *first* unsolved piece on the first
79-
/// problematic line when expanding. If selecting states for that piece still
80-
/// doesn't help, the solver will work its way through later pieces from those
81-
/// subsequenct partial solutions.
78+
/// Thus we only worry about unsolved pieces on the *first* problematic line
79+
/// when expanding. If selecting states for those pieces still doesn't help,
80+
/// the solver will work its way through later pieces from those subsequent
81+
/// partial solutions.
8282
///
8383
/// This lets us efficiently skip through almost all of the pieces that don't
8484
/// need to be touched in order to find a valid solution.
8585
///
86-
/// If this is `null`, then there are no further solutions to generate from
86+
/// If this is empty, then there are no further solutions to generate from
8787
/// this one. It's either a dead end or a winner.
88-
late final Piece? _nextPieceToExpand;
88+
late final List<Piece> _expandPieces;
8989

9090
/// The offset in [text] where the selection starts, or `null` if there is
9191
/// no selection.
@@ -125,10 +125,10 @@ class Solution implements Comparable<Solution> {
125125

126126
var writer = CodeWriter(pageWidth, leadingIndent, cache, this);
127127
writer.format(root);
128-
var (text, nextPieceToExpand) = writer.finish();
128+
var (text, expandPieces) = writer.finish();
129129

130130
_text = text;
131-
_nextPieceToExpand = nextPieceToExpand;
131+
_expandPieces = expandPieces;
132132
}
133133

134134
/// Attempt to eagerly bind [piece] to a state given that it must fit within
@@ -215,8 +215,8 @@ class Solution implements Comparable<Solution> {
215215
_invalidPiece = piece;
216216
}
217217

218-
/// Derives new potential solutions from this one by binding
219-
/// [_nextPieceToExpand] to all of its possible states.
218+
/// Derives new potential solutions from this one by binding [_expandPieces]
219+
/// to all of their possible states.
220220
///
221221
/// If there is no potential piece to expand, or all attempts to expand it
222222
/// fail, returns an empty list.
@@ -227,27 +227,48 @@ class Solution implements Comparable<Solution> {
227227
// the same way, so discard the whole solution tree hanging off this one.
228228
if (_invalidPiece case var piece? when isBound(piece)) return const [];
229229

230-
var expandPiece = _nextPieceToExpand;
231-
232230
// If there is no piece that we can expand on this solution, it's a dead
233231
// end (or a winner).
234-
if (expandPiece == null) return const [];
232+
if (_expandPieces.isEmpty) return const [];
235233

236-
// For each state that the expanding piece can be in, create a new solution
237-
// that inherits all of the bindings of this one, and binds the expanding
238-
// piece to that state (along with any further pieces constrained by that
239-
// one).
240234
var solutions = <Solution>[];
241-
for (var state in expandPiece.states) {
242-
var newStates = {..._pieceStates};
243-
244-
var additionalCost = _tryBind(newStates, expandPiece, state);
245-
246-
// Discard the solution if we hit a constraint violation.
247-
if (additionalCost == null) continue;
248-
249-
solutions.add(Solution._(cache, root, pageWidth, leadingIndent,
250-
cost + additionalCost, newStates));
235+
for (var i = 0; i < _expandPieces.length; i++) {
236+
// For each non-default state that the expanding piece can be in, create
237+
// a new solution that inherits all of the bindings of this one, and binds
238+
// the expanding piece to that state (along with any further pieces
239+
// constrained by that one).
240+
var expandPiece = _expandPieces[i];
241+
for (var state in expandPiece.additionalStates) {
242+
var newStates = {..._pieceStates};
243+
244+
// Bind all preceding expand pieces to their unsplit state. Their
245+
// other states have already been expanded by earlier iterations of
246+
// the outer for loop.
247+
var valid = true;
248+
var additionalCost = 0;
249+
for (var j = 0; j < i; j++) {
250+
if (_tryBind(newStates, _expandPieces[j], State.unsplit)
251+
case var cost?) {
252+
additionalCost += cost;
253+
} else {
254+
valid = false;
255+
break;
256+
}
257+
}
258+
259+
// Discard the solution if we hit a constraint violation.
260+
if (!valid) continue;
261+
262+
if (_tryBind(newStates, expandPiece, state) case var cost?) {
263+
additionalCost += cost;
264+
} else {
265+
// Discard the solution if we hit a constraint violation.
266+
continue;
267+
}
268+
269+
solutions.add(Solution._(cache, root, pageWidth, leadingIndent,
270+
cost + additionalCost, newStates));
271+
}
251272
}
252273

253274
return solutions;

lib/src/back_end/solver.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ class Solver {
6262
if (debug.traceSolver) {
6363
var unsolved = <Piece>[];
6464
void traverse(Piece piece) {
65-
if (piece.states.length > 1) unsolved.add(piece);
65+
if (piece.additionalStates.isNotEmpty) unsolved.add(piece);
6666

6767
piece.forEachChild(traverse);
6868
}

lib/src/piece/piece.dart

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,6 @@ typedef Constrain = void Function(Piece other, State constrainedState);
1515
/// formatting and line splitting. The final output is then determined by
1616
/// deciding which pieces split and how.
1717
abstract class Piece {
18-
/// The ordered list of ways this piece may split.
19-
///
20-
/// This is [State.unsplit], which all pieces support, followed by any other
21-
/// [additionalStates].
22-
List<State> get states {
23-
if (_pinnedState case var pinned?) return [pinned];
24-
return [State.unsplit, ...additionalStates];
25-
}
26-
2718
/// The ordered list of all possible ways this piece could split.
2819
///
2920
/// Piece subclasses should override this if they support being split in

0 commit comments

Comments
 (0)