Flatten chained conditionals. (#1489)

When testing the new formatter on a large corpus, I noticed the solver
would get stuck on large conditional chains because (unfortunately), we
can't separately format the else clause of a split conditional
expression. By merging long conditional chains into a single InfixPiece,
we can separately format all but the very last dangling else clause.

While I was at it, I also put a hard cap in the number of solutions the
solver will try in case it still gets stuck. The old formatter has a
similar limit. It's rare for real-world code to hit this limit in the
new solver, but it's better than getting totally stuck when it happens.
diff --git a/benchmark/case/conditional.expect b/benchmark/case/conditional.expect
new file mode 100644
index 0000000..48e6f22
--- /dev/null
+++ b/benchmark/case/conditional.expect
@@ -0,0 +1,29 @@
+void securityItem() {
+  return SelectableText(
+    itemSecurityScheme.securitySchemeType == SecuritySchemeType.QueryAPIKey
+        ? Constants.oneTwoThreeTxt
+        : itemSecurityScheme.securitySchemeType ==
+                SecuritySchemeType.HeaderAPIKey ||
+            itemSecurityScheme.securitySchemeType ==
+                SecuritySchemeType.CookieAPIKey
+        ? Constants.oneTwoThreeTxt
+        : itemSecurityScheme.securitySchemeType == SecuritySchemeType.BasicHTTP
+        ? Constants.demoUsernameTxt
+        : itemSecurityScheme.securitySchemeType == SecuritySchemeType.BearerHTTP
+        ? Constants.oneTwoThreeTxt
+        : itemSecurityScheme.securitySchemeType == SecuritySchemeType.DigestHTTP
+        ? Constants.digestDemoTxt
+        : itemSecurityScheme.securitySchemeType ==
+                SecuritySchemeType.OAuth2PasswordFlow ||
+            itemSecurityScheme.securitySchemeType ==
+                SecuritySchemeType.OAuth2ClientFlow
+        ? Constants.emptyTxt
+        : itemSecurityScheme.securitySchemeType ==
+            SecuritySchemeType.OAuth2ImplicitFlow
+        ? Constants.emptyTxt
+        : itemSecurityScheme.securitySchemeType ==
+            SecuritySchemeType.OAuth2CodeFlow
+        ? Constants.emptyTxt
+        : Constants.emptyTxt,
+  );
+}
diff --git a/benchmark/case/conditional.expect_short b/benchmark/case/conditional.expect_short
new file mode 100644
index 0000000..62ba360
--- /dev/null
+++ b/benchmark/case/conditional.expect_short
@@ -0,0 +1,32 @@
+void securityItem() {
+  return SelectableText(
+    itemSecurityScheme.securitySchemeType == SecuritySchemeType.QueryAPIKey
+        ? Constants.oneTwoThreeTxt
+        : itemSecurityScheme.securitySchemeType ==
+                    SecuritySchemeType.HeaderAPIKey ||
+                itemSecurityScheme.securitySchemeType ==
+                    SecuritySchemeType.CookieAPIKey
+            ? Constants.oneTwoThreeTxt
+            : itemSecurityScheme.securitySchemeType ==
+                    SecuritySchemeType.BasicHTTP
+                ? Constants.demoUsernameTxt
+                : itemSecurityScheme.securitySchemeType ==
+                        SecuritySchemeType.BearerHTTP
+                    ? Constants.oneTwoThreeTxt
+                    : itemSecurityScheme.securitySchemeType ==
+                            SecuritySchemeType.DigestHTTP
+                        ? Constants.digestDemoTxt
+                        : itemSecurityScheme.securitySchemeType ==
+                                    SecuritySchemeType.OAuth2PasswordFlow ||
+                                itemSecurityScheme.securitySchemeType ==
+                                    SecuritySchemeType.OAuth2ClientFlow
+                            ? Constants.emptyTxt
+                            : itemSecurityScheme.securitySchemeType ==
+                                    SecuritySchemeType.OAuth2ImplicitFlow
+                                ? Constants.emptyTxt
+                                : itemSecurityScheme.securitySchemeType ==
+                                        SecuritySchemeType.OAuth2CodeFlow
+                                    ? Constants.emptyTxt
+                                    : Constants.emptyTxt,
+  );
+}
diff --git a/benchmark/case/conditional.unit b/benchmark/case/conditional.unit
new file mode 100644
index 0000000..33ea6bd
--- /dev/null
+++ b/benchmark/case/conditional.unit
@@ -0,0 +1,23 @@
+void securityItem() {
+return SelectableText(
+  itemSecurityScheme.securitySchemeType == SecuritySchemeType.QueryAPIKey
+  ? Constants.oneTwoThreeTxt
+  : itemSecurityScheme.securitySchemeType == SecuritySchemeType.HeaderAPIKey ||
+    itemSecurityScheme.securitySchemeType == SecuritySchemeType.CookieAPIKey
+  ? Constants.oneTwoThreeTxt
+  : itemSecurityScheme.securitySchemeType == SecuritySchemeType.BasicHTTP
+  ? Constants.demoUsernameTxt
+  : itemSecurityScheme.securitySchemeType == SecuritySchemeType.BearerHTTP
+  ? Constants.oneTwoThreeTxt
+  : itemSecurityScheme.securitySchemeType == SecuritySchemeType.DigestHTTP
+  ? Constants.digestDemoTxt
+  : itemSecurityScheme.securitySchemeType == SecuritySchemeType.OAuth2PasswordFlow ||
+    itemSecurityScheme.securitySchemeType == SecuritySchemeType.OAuth2ClientFlow
+  ? Constants.emptyTxt
+  : itemSecurityScheme.securitySchemeType == SecuritySchemeType.OAuth2ImplicitFlow
+  ? Constants.emptyTxt
+  : itemSecurityScheme.securitySchemeType == SecuritySchemeType.OAuth2CodeFlow
+  ? Constants.emptyTxt
+  : Constants.emptyTxt,
+);
+}
diff --git a/lib/src/back_end/solver.dart b/lib/src/back_end/solver.dart
index 94983ea..43e4770 100644
--- a/lib/src/back_end/solver.dart
+++ b/lib/src/back_end/solver.dart
@@ -9,6 +9,13 @@
 import 'solution.dart';
 import 'solution_cache.dart';
 
+/// To ensure the solver doesn't go totally pathological on giant code, we cap
+/// it at a fixed number of attempts.
+///
+/// If the optimal solution isn't found after this many tries, it just uses the
+/// best it found so far.
+const _maxAttempts = 10000;
+
 /// Selects states for each piece in a tree of pieces to find the best set of
 /// line splits that minimizes overflow characters and line splitting costs.
 ///
@@ -85,18 +92,17 @@
     // The lowest cost solution found so far that does overflow.
     var best = solution;
 
-    var tries = 0;
+    var attempts = 0;
 
-    // TODO(perf): Consider bailing out after a certain maximum number of tries,
-    // so that it outputs suboptimal formatting instead of hanging entirely.
-    while (_queue.isNotEmpty) {
+    while (_queue.isNotEmpty && attempts < _maxAttempts) {
       Profile.begin('Solver dequeue');
       var solution = _queue.removeFirst();
       Profile.end('Solver dequeue');
 
+      attempts++;
+
       if (debug.traceSolver) {
-        tries++;
-        debug.log(debug.bold('Try #$tries $solution'));
+        debug.log(debug.bold('Try #$attempts $solution'));
         debug.log(solution.text);
         debug.log('');
       }
diff --git a/lib/src/front_end/ast_node_visitor.dart b/lib/src/front_end/ast_node_visitor.dart
index 8cd9d35..403b71e 100644
--- a/lib/src/front_end/ast_node_visitor.dart
+++ b/lib/src/front_end/ast_node_visitor.dart
@@ -324,21 +324,44 @@
     // conditional expression to split.
     var leadingComments = pieces.takeCommentsBefore(node.firstNonCommentToken);
 
-    var condition = nodePiece(node.condition);
+    // Flatten a series of else-if-like chained conditionals into a single long
+    // infix piece. This produces a flattened style like:
+    //
+    //     condition
+    //     ? thenBranch
+    //     : condition2
+    //     ? thenBranch2
+    //     : elseBranch;
+    //
+    // This (arguably) looks nicer. More importantly, it means that all but the
+    // last operand can be formatted separately, which is important to avoid
+    // pathological performance in the solved with long nested conditional
+    // chains.
+    var operands = [nodePiece(node.condition)];
 
-    var thenPiece = pieces.build(() {
-      pieces.token(node.question);
-      pieces.space();
-      pieces.visit(node.thenExpression, context: NodeContext.conditionalBranch);
-    });
+    void addOperand(Token operator, Expression operand) {
+      operands.add(pieces.build(() {
+        pieces.token(operator);
+        pieces.space();
+        pieces.visit(operand, context: NodeContext.conditionalBranch);
+      }));
+    }
 
-    var elsePiece = pieces.build(() {
-      pieces.token(node.colon);
-      pieces.space();
-      pieces.visit(node.elseExpression, context: NodeContext.conditionalBranch);
-    });
+    var conditional = node;
+    while (true) {
+      addOperand(conditional.question, conditional.thenExpression);
 
-    var piece = InfixPiece(leadingComments, [condition, thenPiece, elsePiece]);
+      var elseBranch = conditional.elseExpression;
+      if (elseBranch is ConditionalExpression) {
+        addOperand(conditional.colon, elseBranch.condition);
+        conditional = elseBranch;
+      } else {
+        addOperand(conditional.colon, conditional.elseExpression);
+        break;
+      }
+    }
+
+    var piece = InfixPiece(leadingComments, operands);
 
     // If conditional expressions are directly nested, force them all to split,
     // both parents and children.
diff --git a/test/tall/expression/condition.stmt b/test/tall/expression/condition.stmt
index b06ae45..991d2df 100644
--- a/test/tall/expression/condition.stmt
+++ b/test/tall/expression/condition.stmt
@@ -43,9 +43,22 @@
     a
         ? b
         : c
-            ? d
-            : e;
+        ? d
+        : e;
 >>> Don't force split conditionals when indirectly nested.
 var kind = a ? b : (c ? d : e);
 <<<
-var kind = a ? b : (c ? d : e);
\ No newline at end of file
+var kind = a ? b : (c ? d : e);
+>>> Flatten a chain of else-if conditionals.
+var kind = c1 ? e1 : c2 ? e2 : c3 ? e3 : c4 ? e4 : e5;
+<<<
+var kind =
+    c1
+        ? e1
+        : c2
+        ? e2
+        : c3
+        ? e3
+        : c4
+        ? e4
+        : e5;
\ No newline at end of file
diff --git a/test/tall/regression/0400/0407.unit b/test/tall/regression/0400/0407.unit
index fb18fa2..82a882f 100644
--- a/test/tall/regression/0400/0407.unit
+++ b/test/tall/regression/0400/0407.unit
@@ -32,11 +32,11 @@
             _total == 0
                 ? ""
                 : _chartType == "PieChart"
-                    ? _formatter.formatAsPercent(
-                      item.value / _total,
-                      fractionDigits: 1,
-                    )
-                    : _formatter.formatValue(item.value, item.valueType);
+                ? _formatter.formatAsPercent(
+                  item.value / _total,
+                  fractionDigits: 1,
+                )
+                : _formatter.formatValue(item.value, item.valueType);
     }
 >>> (indent 6)
 main() {
@@ -55,9 +55,9 @@
               _total == 0
                   ? ""
                   : _chartType == "PieChart"
-                      ? _formatter.formatAsPercent(
-                        item.value / _total,
-                        fractionDigits: 1,
-                      )
-                      : _formatter.formatValue(item.value, item.valueType);
+                  ? _formatter.formatAsPercent(
+                    item.value / _total,
+                    fractionDigits: 1,
+                  )
+                  : _formatter.formatValue(item.value, item.valueType);
       }
\ No newline at end of file
diff --git a/test/tall/regression/0700/0713.stmt b/test/tall/regression/0700/0713.stmt
index e1fa594..2ad4f72 100644
--- a/test/tall/regression/0700/0713.stmt
+++ b/test/tall/regression/0700/0713.stmt
@@ -8,7 +8,7 @@
     status == 'OK'
         ? 'notices'
         : status == 'NO'
-            ? 'warnings'
-            : status == 'BAD'
-                ? 'errors'
-                : '';
\ No newline at end of file
+        ? 'warnings'
+        : status == 'BAD'
+        ? 'errors'
+        : '';
\ No newline at end of file
diff --git a/test/tall/regression/0700/0722.stmt b/test/tall/regression/0700/0722.stmt
index 2d12cc9..663ea2e 100644
--- a/test/tall/regression/0700/0722.stmt
+++ b/test/tall/regression/0700/0722.stmt
@@ -22,15 +22,12 @@
       project.locked
           ? Icon(Icons.lock)
           : project.fav
-              ? Icon(Icons.star)
-              : project.taps == null
-                  ? Icon(Icons.notifications)
-                  : Text(
-                    suffixNumber(project.taps),
-                    textAlign: TextAlign.center,
-                    style: TextStyle(
-                      fontSize: 18.0,
-                      fontWeight: FontWeight.w600,
-                    ),
-                  ),
+          ? Icon(Icons.star)
+          : project.taps == null
+          ? Icon(Icons.notifications)
+          : Text(
+            suffixNumber(project.taps),
+            textAlign: TextAlign.center,
+            style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600),
+          ),
 );
\ No newline at end of file
diff --git a/test/tall/regression/0900/0927.unit b/test/tall/regression/0900/0927.unit
index fa2bd64..0448d84 100644
--- a/test/tall/regression/0900/0927.unit
+++ b/test/tall/regression/0900/0927.unit
@@ -9,6 +9,6 @@
       _currentSunAngleDeg < 0
           ? 1
           : _currentSunAngleDeg < 10
-              ? 2
-              : 3;
+          ? 2
+          : 3;
 }
\ No newline at end of file