Repository: scalameta/scalameta
Pull request #3411 (2023-12-16 02:37:47)

ScannerTokens: make `isLeadingInfix` compliant
The implementation was incorrect, didn't check the case when the infix op was on a line by itself.

However, the fix is incomplete as it doesn't handle the very rare case when the infix expression is at the file level and not nested within a block of any kind. This will be fixed in the next commit.

Also, add a correlated fix in `getOutdentIfNeeded`.

Helps with scalameta/scalafmt#3720.

kitbellew: @tgodzik when you have some time ...

Patch:
diff --git a/scalameta/parsers/shared/src/main/scala/scala/meta/internal/parsers/ScalametaParser.scala b/scalameta/parsers/shared/src/main/scala/scala/meta/internal/parsers/ScalametaParser.scala
index 5ef0a96ed..39263f9f1 100644
--- a/scalameta/parsers/shared/src/main/scala/scala/meta/internal/parsers/ScalametaParser.scala
+++ b/scalameta/parsers/shared/src/main/scala/scala/meta/internal/parsers/ScalametaParser.scala
@@ -961,13 +961,11 @@ class ScalametaParser(input: Input)(implicit dialect: Dialect) { parser =>
           reduce(rhs, None)
         case _: Ident | _: Unquote =>
           loop(getNextRhs(typeName(), rhs))
-        case _: EOL if dialect.allowInfixOperatorAfterNL =>
-          tryGetNextInfixOpIfLeading(t.startTokenPos)(Type.Name.apply) match {
+        case _ =>
+          tryGetNextInfixOpIfLeading(Type.Name.apply) match {
             case Some(op) => loop(getNextRhs(op, rhs))
             case _ => reduce(rhs, None)
           }
-        case _ =>
-          reduce(rhs, None)
       }
       loop(t)
     }
@@ -2059,34 +2057,21 @@ class ScalametaParser(input: Input)(implicit dialect: Dialect) { parser =>
         at = at
       )
 
-  private def tryGetNextInfixOpIfLeading[A <: Name](startPos: Int)(f: String => A): Option[A] =
-    peekToken match {
-      case opToken: Ident if opToken.isIdentSymbolicInfixOperator =>
-        tryAhead {
-          val opIndex = tokenPos
-          next()
-          newLineOpt()
-          val argToken = token
-          val opPos = opToken.pos
-          val argPos = argToken.pos
-          val opLine = opPos.startLine
-          val argLine = argPos.startLine
-          val opCol = opPos.startColumn
-          val okInfix = canBeLeadingInfixArg(argToken, tokenPos) && {
-            if (opLine == argLine) opPos.endColumn < argPos.startColumn // space on same line
-            else opLine == argLine - 1 && opCol <= argPos.startColumn // indent on next line
-          }
-          if (okInfix) Some(atPos(opIndex)(f(opToken.value)))
-          else {
-            val indentedWithoutArg = opLine != argLine &&
-              opCol > scannerTokens.getTokenAtLineStart(startPos).pos.startColumn
-            if (indentedWithoutArg)
-              syntaxError(s"Invalid indented leading infix operator found", at = opToken)
-            None
+  private def tryGetNextInfixOpIfLeading[A <: Name](f: String => A): Option[A] = token match {
+    case lf: InfixLF =>
+      peekToken match {
+        case opToken: Ident =>
+          tryAhead {
+            if (peekToken.is[Indentation]) None
+            else
+              lf.invalid.fold {
+                Some(atCurPos(next { newLineOpt(); f(opToken.value) }))
+              }(syntaxError(_, at = opToken))
           }
-        }
-      case _ => None
-    }
+        case _ => None
+      }
+    case _ => None
+  }
 
   def postfixExpr(allowRepeated: Boolean, location: Location = NoStat): Term = {
     val startPos = tokenPos
@@ -2181,11 +2166,8 @@ class ScalametaParser(input: Input)(implicit dialect: Dialect) { parser =>
           val op = atCurPosNext(Term.Name("match"))
           val lhs = getPrevLhs(op)
           Some(Right(matchClause(lhs, getLhsStartPos(lhs))))
-        case _: EOL if dialect.allowInfixOperatorAfterNL =>
-          tryGetNextInfixOpIfLeading(startPos)(Term.Name.apply).map { op =>
-            getNextRhs(op, emptyTypeArgs)
-          }
-        case _ => None
+        case _ =>
+          tryGetNextInfixOpIfLeading(Term.Name.apply).map(getNextRhs(_, emptyTypeArgs))
       }
       resOpt match {
         case Some(Left(x)) => x
diff --git a/scalameta/parsers/shared/src/main/scala/scala/meta/internal/parsers/ScannerTokens.scala b/scalameta/parsers/shared/src/main/scala/scala/meta/internal/parsers/ScannerTokens.scala
index 67ba8b464..840484881 100644
--- a/scalameta/parsers/shared/src/main/scala/scala/meta/internal/parsers/ScannerTokens.scala
+++ b/scalameta/parsers/shared/src/main/scala/scala/meta/internal/parsers/ScannerTokens.scala
@@ -96,19 +96,6 @@ final class ScannerTokens(val tokens: Tokens)(implicit dialect: Dialect) {
       case _ => true
     })
 
-  def getTokenAtLineStart(idx: Int): Token =
-    getTokenAtLineStart(idx, tokens(idx))
-
-  @tailrec
-  private def getTokenAtLineStart(idx: Int, nextNonSpace: Token): Token =
-    if (idx == 0) nextNonSpace
-    else
-      tokens(idx) match {
-        case _: AtEOL => nextNonSpace
-        case _: HSpace => getTokenAtLineStart(idx - 1, nextNonSpace)
-        case t => getTokenAtLineStart(idx - 1, t)
-      }
-
   val soft = new SoftKeywords(dialect)
 
   object TypeIntro {
@@ -693,30 +680,49 @@ final class ScannerTokens(val tokens: Tokens)(implicit dialect: Dialect) {
           (prevToken.is[Indentation.Outdent] || canEndStat(prev) || isEndMarker()) &&
           !CantStartStat(next))
           (regions match {
-            case Nil => Some(regions)
             case RegionDefType :: rs =>
               if (next.isAny[LeftParen, LeftBracket, Equals]) None else Some(rs)
             // `extends` and `with` are covered by canEndStat() and CantStartStat above
             case RegionTemplateMark :: rs => if (blankBraceOr(!derives(next))) Some(rs) else None
             case RegionTemplateInherit :: rs =>
               if (blankBraceOr(!derives(next) && !derives(prev))) Some(rs) else None
-            case (_: CanProduceLF) :: _ => Some(regions)
+            case Nil | (_: CanProduceLF) :: _ => Some(regions)
             case _ => None
           }).map(rs => Right(lastWhitespaceToken(rs)))
         else None
       }
 
-      def isLeadingInfix() =
-        !newlines && dialect.allowInfixOperatorAfterNL &&
-          next.is[Ident] && next.isIdentSymbolicInfixOperator && {
-            val argPos = getStrictNext(nextPos)
-            argPos > nextPos && tokens(nextPos + 1).is[HSpace] &&
-            canBeLeadingInfixArg(tokens(argPos), argPos)
-          }
+      // https://dotty.epfl.ch/docs/reference/changed-features/operators.html#syntax-change-1
+      lazy val isLeadingInfix = sepRegionsOrig match {
+        case Nil | (_: CanProduceLF) :: _
+            if !newlines && lastNewlinePos >= 0 && dialect.allowInfixOperatorAfterNL &&
+              next.is[Ident] && next.isIdentSymbolicInfixOperator =>
+          isLeadingInfixArg(nextPos + 1, nextIndent)
+        case _ => LeadingInfix.No
+      }
+
+      def getInfixLFIfNeeded() = {
+        def getInfixLF(invalid: Option[String]) = Some(Right {
+          val lf = tokens(lastNewlinePos)
+          val out = InfixLF(lf.input, lf.dialect, lf.start, lf.end, invalid)
+          TokenRef(sepRegionsOrig, out, lastNewlinePos, null)
+        })
+        isLeadingInfix match {
+          case LeadingInfix.Yes => getInfixLF(None)
+          case LeadingInfix.InvalidArg if (sepRegionsOrig match {
+                // see in `ieLeadingInfix` above, `x` is guaranteed to be CanProduceLF
+                case x :: _ => x.indent >= 0 && x.indent < nextIndent
+                case _ => false
+              }) =>
+            getInfixLF(Some("Invalid indented leading infix operator found"))
+          case _ => None
+        }
+      }
 
       val resOpt =
         if (!hasLF) None
-        else if (!dialect.allowSignificantIndentation) getIfCanProduceLF(sepRegionsOrig)
+        else if (!dialect.allowSignificantIndentation)
+          getInfixLFIfNeeded().orElse(getIfCanProduceLF(sepRegionsOrig))
         else {
 
           /**
@@ -748,7 +754,7 @@ final class ScannerTokens(val tokens: Tokens)(implicit dialect: Dialect) {
               case (_: RegionNonDelimNonIndented) :: rs if (prev match {
                     // [then]  else  do  [catch]  finally  yield  [match]
                     case _: KwThen | _: KwCatch | _: KwMatch => false
-                    case _ => shouldOutdent(rs)
+                    case _ => rs.find(_.isIndented).exists(_.indent >= nextIndent)
                   }) =>
                 (Right(true), rs)
               case regions @ (r: SepRegionIndented) :: _ if nextIndent >= r.indent =>
@@ -761,12 +767,10 @@ final class ScannerTokens(val tokens: Tokens)(implicit dialect: Dialect) {
                     // then  [else]  [do]  catch  [finally]  [yield]  match
                     case _: KwElse | _: KwDo | _: KwFinally | _: KwYield => false
                     // exclude leading infix op
-                    case _ => nextIndent == 0 || shouldOutdent(rs) || !isLeadingInfix()
+                    case _ => findIndent(rs) >= nextIndent || isLeadingInfix != LeadingInfix.Yes
                   }) =>
                 (Left(r), rs)
             }
-          def shouldOutdent(rs: List[SepRegion]): Boolean =
-            rs.find(_.isIndented).exists(_.indent >= nextIndent)
 
           /**
            * Indent is needed in the following cases:
@@ -853,7 +857,7 @@ final class ScannerTokens(val tokens: Tokens)(implicit dialect: Dialect) {
                     if (ko) None else emitIndent(rs)
                   case (x: RegionControlMaybeCond) :: rs if prev.is[RightParen] =>
                     val ko = next.is[Dot] || !x.isNotTerminatingTokenIfOptional(next) ||
-                      isLeadingInfix()
+                      isLeadingInfix == LeadingInfix.Yes
                     if (ko) None else emitIndent(x.asBody().fold(rs)(_ :: rs))
                   case (x: RegionControlMaybeCond) :: rs if x.isControlKeyword(prev) =>
                     emitIndent(x.asCond() :: rs)
@@ -881,7 +885,9 @@ final class ScannerTokens(val tokens: Tokens)(implicit dialect: Dialect) {
 
           @tailrec
           def iter(regions: List[SepRegion]): Option[Either[List[SepRegion], TokenRef]] = {
-            val res = regionsToRes(regions)
+            val res = regionsToRes(regions).orElse {
+              if (regions ne sepRegionsOrig) None else getInfixLFIfNeeded()
+            }
             if (res.isEmpty) regions match {
               case (r: RegionControl) :: rs
                   if !r.isControlKeyword(prev) &&
@@ -911,10 +917,38 @@ final class ScannerTokens(val tokens: Tokens)(implicit dialect: Dialect) {
     }
   }
 
+  private def isLeadingInfixArg(afterOpPos: Int, nextIndent: Int) = {
+    // we don't check for pos to be within bounds since we would exit on EOF first
+    @tailrec def iter(pos: Int, indent: Int, prevNoNL: Boolean): LeadingInfix = tokens(pos) match {
+      case _: EOL => if (prevNoNL) iter(pos + 1, 0, false) else LeadingInfix.InvalidArg
+      case _: Whitespace => iter(pos + 1, if (prevNoNL) indent else indent + 1, prevNoNL)
+      case c: Comment =>
+        val commentIndent = multilineCommentIndent(c)
+        iter(pos + 1, if (commentIndent < 0) indent else commentIndent, true)
+      case t =>
+        if (indent >= 0 && indent < nextIndent) LeadingInfix.InvalidArg
+        else if (canBeLeadingInfixArg(t, pos)) LeadingInfix.Yes
+        else LeadingInfix.InvalidArg
+    }
+    tokens(afterOpPos) match {
+      case _: EOL => iter(afterOpPos + 1, 0, false)
+      case _: Whitespace => iter(afterOpPos + 1, -1, true)
+      case _: Comment => LeadingInfix.InvalidArg
+      case _ => LeadingInfix.No
+    }
+  }
+
 }
 
 object ScannerTokens {
 
+  private sealed trait LeadingInfix
+  private object LeadingInfix {
+    object No extends LeadingInfix
+    object Yes extends LeadingInfix
+    object InvalidArg extends LeadingInfix
+  }
+
   def apply(input: Input)(implicit dialect: Dialect): ScannerTokens = {
     new ScannerTokens(input.tokenize.get)
   }
diff --git a/scalameta/tokens/shared/src/main/scala/scala/meta/tokens/Token.scala b/scalameta/tokens/shared/src/main/scala/scala/meta/tokens/Token.scala
index 8fe029de1..c21ed4022 100644
--- a/scalameta/tokens/shared/src/main/scala/scala/meta/tokens/Token.scala
+++ b/scalameta/tokens/shared/src/main/scala/scala/meta/tokens/Token.scala
@@ -173,6 +173,7 @@ object Token {
     def end = start
   }
   @freeform("\n\n") private[meta] class LFLF extends AtEOL
+  @freeform("\n") private[meta] class InfixLF(invalid: Option[String]) extends EOL
 
   // NOTE: in order to maintain conceptual compatibility with scala.reflect's implementation,
   // Ellipsis.rank = 1 means .., Ellipsis.rank = 2 means ..., etc


Test patch:
diff --git a/tests/jvm/src/test/scala/scala/meta/tests/tokens/ReflectionSuite.scala b/tests/jvm/src/test/scala/scala/meta/tests/tokens/ReflectionSuite.scala
index 917f97fd4..f21c7f4f5 100644
--- a/tests/jvm/src/test/scala/scala/meta/tests/tokens/ReflectionSuite.scala
+++ b/tests/jvm/src/test/scala/scala/meta/tests/tokens/ReflectionSuite.scala
@@ -34,6 +34,7 @@ class ReflectionSuite extends FunSuite {
       |Token.Ident
       |Token.Indentation.Indent
       |Token.Indentation.Outdent
+      |Token.InfixLF
       |Token.Interpolation.End
       |Token.Interpolation.Id
       |Token.Interpolation.Part
diff --git a/tests/shared/src/test/scala/scala/meta/tests/parsers/ParseSuite.scala b/tests/shared/src/test/scala/scala/meta/tests/parsers/ParseSuite.scala
index 9028d05a9..c1074d1ff 100644
--- a/tests/shared/src/test/scala/scala/meta/tests/parsers/ParseSuite.scala
+++ b/tests/shared/src/test/scala/scala/meta/tests/parsers/ParseSuite.scala
@@ -17,6 +17,7 @@ class ParseSuite extends TreeSuiteBase with CommonTrees {
   implicit def parseSource(code: String, dialect: Dialect): Source = source(code)(dialect)
   implicit def parseType(code: String, dialect: Dialect): Type = tpe(code)(dialect)
   implicit def parsePat(code: String, dialect: Dialect): Pat = pat(code)(dialect)
+  implicit def parseCaseTree(code: String, dialect: Dialect): Case = parseCase(code)(dialect)
 
   // This should eventually be replaced by DiffAssertions.assertNoDiff
   def assertSameLines(actual: String, expected: String) = {
diff --git a/tests/shared/src/test/scala/scala/meta/tests/parsers/PatSuite.scala b/tests/shared/src/test/scala/scala/meta/tests/parsers/PatSuite.scala
index b4b8d99e2..706dee0e2 100644
--- a/tests/shared/src/test/scala/scala/meta/tests/parsers/PatSuite.scala
+++ b/tests/shared/src/test/scala/scala/meta/tests/parsers/PatSuite.scala
@@ -308,4 +308,14 @@ class PatSuite extends ParseSuite {
     }
   }
 
+  test("case: break before `|`") {
+    val code =
+      """|case 'a'
+         |   | 'A' =>
+         |""".stripMargin
+    val layout = "case 'a' | 'A' =>"
+    val tree = Case(Pat.Alternative(Lit.Char('a'), Lit.Char('A')), None, Term.Block(Nil))
+    runTestAssert[Case](code, Some(layout))(tree)
+  }
+
 }
diff --git a/tests/shared/src/test/scala/scala/meta/tests/parsers/dotty/FewerBracesSuite.scala b/tests/shared/src/test/scala/scala/meta/tests/parsers/dotty/FewerBracesSuite.scala
index b296e2b1e..bb0bd6b7b 100644
--- a/tests/shared/src/test/scala/scala/meta/tests/parsers/dotty/FewerBracesSuite.scala
+++ b/tests/shared/src/test/scala/scala/meta/tests/parsers/dotty/FewerBracesSuite.scala
@@ -990,4 +990,659 @@ class FewerBracesSuite extends BaseDottySuite {
     runTestAssert[Stat](code, Some(layout))(tree)
   }
 
+  test("scalafmt #3720 1 simple, space after op, top-level, not indented") {
+    val code =
+      """
+        |baz:
+        |  arg
+        |++ qux
+        |""".stripMargin
+    val layout = "baz(arg) ++ qux"
+    val tree = Term.ApplyInfix(
+      Term.Apply(tname("baz"), List(tname("arg"))),
+      tname("++"),
+      Nil,
+      List(tname("qux"))
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 1 simple, space after op, top-level, indented") {
+    val code =
+      """
+        |  baz:
+        |    arg
+        |  ++ qux
+        |""".stripMargin
+    val layout = "baz(arg ++ qux)"
+    val tree = Term.Apply(
+      tname("baz"),
+      List(Term.ApplyInfix(tname("arg"), tname("++"), Nil, List(tname("qux"))))
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 1 simple, space after op, object in braces") {
+    val code =
+      """
+        |object a {
+        |  baz:
+        |    arg
+        |  ++ qux
+        |}
+        |""".stripMargin
+    val layout = "object a { baz(arg) ++ qux }"
+    val tree = Defn.Object(
+      Nil,
+      tname("a"),
+      tpl(
+        Term.ApplyInfix(
+          Term.Apply(tname("baz"), List(tname("arg"))),
+          tname("++"),
+          Nil,
+          List(tname("qux"))
+        ) :: Nil
+      )
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 1 simple, space after op, object indented") {
+    val code =
+      """
+        |object a:
+        |  baz:
+        |    arg
+        |  ++ qux
+        |""".stripMargin
+    val layout = "object a { baz(arg) ++ qux }"
+    val tree = Defn.Object(
+      Nil,
+      tname("a"),
+      tpl(
+        Term.ApplyInfix(
+          Term.Apply(tname("baz"), List(tname("arg"))),
+          tname("++"),
+          Nil,
+          List(tname("qux"))
+        ) :: Nil
+      )
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 2 complex, break after op, top-level, not indented") {
+    val code =
+      """
+        |foo.bar:
+        |  arg
+        |++
+        |  baz:
+        |    arg
+        |  ++
+        |  qux
+        |""".stripMargin
+    val layout =
+      """
+        |foo.bar(arg) ++ baz {
+        |  arg
+        |  ++
+        |}
+        |""".stripMargin
+    val tree = Term.ApplyInfix(
+      Term.Apply(Term.Select(tname("foo"), tname("bar")), List(tname("arg"))),
+      tname("++"),
+      Nil,
+      List(Term.Apply(tname("baz"), List(Term.Block(List(tname("arg"), tname("++"))))))
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 2 complex, break after op, top-level, indented") {
+    val code =
+      """
+        |  foo.bar:
+        |    arg
+        |  ++
+        |  baz:
+        |    arg
+        |  ++
+        |  qux
+        |""".stripMargin
+    val layout =
+      """
+        |foo.bar {
+        |  arg
+        |  ++
+        |}
+        |""".stripMargin
+    val tree = Term.Apply(
+      Term.Select(tname("foo"), tname("bar")),
+      List(Term.Block(List(tname("arg"), tname("++"))))
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 2 complex, break after op, objeect in braces") {
+    val code =
+      """
+        |object a {
+        |  foo.bar:
+        |    arg
+        |  ++
+        |  baz:
+        |    arg
+        |  ++
+        |  qux
+        |}
+        |""".stripMargin
+    val layout = "object a { foo.bar(arg) ++ baz(arg) ++ qux }"
+    val tree = Defn.Object(
+      Nil,
+      tname("a"),
+      tpl(
+        Term.ApplyInfix(
+          Term.ApplyInfix(
+            Term.Apply(Term.Select(tname("foo"), tname("bar")), List(tname("arg"))),
+            tname("++"),
+            Nil,
+            List(Term.Apply(tname("baz"), List(tname("arg"))))
+          ),
+          tname("++"),
+          Nil,
+          List(tname("qux"))
+        ) :: Nil
+      )
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 2 complex, break after op, objeect indented") {
+    val code =
+      """
+        |object a:
+        |  foo.bar:
+        |    arg
+        |  ++
+        |  baz:
+        |    arg
+        |  ++
+        |  qux
+        |""".stripMargin
+    val layout = "object a { foo.bar(arg) ++ baz(arg) ++ qux }"
+    val tree = Defn.Object(
+      Nil,
+      tname("a"),
+      tpl(
+        Term.ApplyInfix(
+          Term.ApplyInfix(
+            Term.Apply(Term.Select(tname("foo"), tname("bar")), List(tname("arg"))),
+            tname("++"),
+            Nil,
+            List(Term.Apply(tname("baz"), List(tname("arg"))))
+          ),
+          tname("++"),
+          Nil,
+          List(tname("qux"))
+        ) :: Nil
+      )
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 3 complex, space after op, top-level, not indented") {
+    val code =
+      """
+        |foo.bar:
+        |  arg
+        |++
+        |  baz:
+        |    arg
+        |  ++ qux
+        |""".stripMargin
+    val layout = "foo.bar(arg) ++ baz(arg ++ qux)"
+    val tree = Term.ApplyInfix(
+      Term.Apply(Term.Select(tname("foo"), tname("bar")), List(tname("arg"))),
+      tname("++"),
+      Nil,
+      Term.Apply(
+        tname("baz"),
+        List(Term.ApplyInfix(tname("arg"), tname("++"), Nil, List(tname("qux"))))
+      ) :: Nil
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 3 complex, space after op, top-level, indented") {
+    val code =
+      """
+        |  foo.bar:
+        |    arg
+        |  ++
+        |    baz:
+        |      arg
+        |    ++ qux
+        |""".stripMargin
+    val layout = "foo.bar(arg ++ baz(arg) ++ qux)"
+    val tree = Term.Apply(
+      Term.Select(tname("foo"), tname("bar")),
+      Term.ApplyInfix(
+        Term.ApplyInfix(
+          tname("arg"),
+          tname("++"),
+          Nil,
+          List(Term.Apply(tname("baz"), List(tname("arg"))))
+        ),
+        tname("++"),
+        Nil,
+        List(tname("qux"))
+      ) :: Nil
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 3 complex, space after op, object in braces") {
+    val code =
+      """
+        |object a {
+        |  foo.bar:
+        |    arg
+        |  ++
+        |    baz:
+        |      arg
+        |    ++ qux
+        |}
+        |""".stripMargin
+    val layout = "object a { foo.bar(arg) ++ baz(arg ++ qux) }"
+    val tree = Defn.Object(
+      Nil,
+      tname("a"),
+      tpl(
+        Term.ApplyInfix(
+          Term.Apply(Term.Select(tname("foo"), tname("bar")), List(tname("arg"))),
+          tname("++"),
+          Nil,
+          Term.Apply(
+            tname("baz"),
+            List(Term.ApplyInfix(tname("arg"), tname("++"), Nil, List(tname("qux"))))
+          ) :: Nil
+        ) :: Nil
+      )
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 3 complex, space after op, object indented") {
+    val code =
+      """
+        |object a:
+        |  foo.bar:
+        |    arg
+        |  ++
+        |    baz:
+        |      arg
+        |    ++ qux
+        |""".stripMargin
+    val layout = "object a { foo.bar(arg) ++ baz(arg ++ qux) }"
+    val tree = Defn.Object(
+      Nil,
+      tname("a"),
+      tpl(
+        Term.ApplyInfix(
+          Term.Apply(Term.Select(tname("foo"), tname("bar")), List(tname("arg"))),
+          tname("++"),
+          Nil,
+          Term.Apply(
+            tname("baz"),
+            List(Term.ApplyInfix(tname("arg"), tname("++"), Nil, List(tname("qux"))))
+          ) :: Nil
+        ) :: Nil
+      )
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 4 complex, partial outdents, unindented def") {
+    val code =
+      """
+        |def mtd =
+        |    abc:
+        |        arg1
+        |      ++
+        |        abc:
+        |            arg2
+        |          ++
+        |            abc:
+        |                arg3
+        |""".stripMargin
+    val layout = "def mtd = abc(arg1 ++ abc(arg2 ++ abc(arg3)))"
+    val tree = Defn.Def(
+      Nil,
+      tname("mtd"),
+      Nil,
+      None,
+      Term.Apply(
+        tname("abc"),
+        Term.ApplyInfix(
+          tname("arg1"),
+          tname("++"),
+          Nil,
+          Term.Apply(
+            tname("abc"),
+            Term.ApplyInfix(
+              tname("arg2"),
+              tname("++"),
+              Nil,
+              List(Term.Apply(tname("abc"), List(tname("arg3"))))
+            ) :: Nil
+          ) :: Nil
+        ) :: Nil
+      )
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 4 complex, partial outdents, indented def") {
+    val code =
+      """
+        |  def mtd =
+        |    abc:
+        |        arg1
+        |      ++
+        |        abc:
+        |            arg2
+        |          ++
+        |            abc:
+        |                arg3
+        |""".stripMargin
+    val layout = "def mtd = abc(arg1 ++ abc(arg2 ++ abc(arg3)))"
+    val tree = Defn.Def(
+      Nil,
+      tname("mtd"),
+      Nil,
+      None,
+      Term.Apply(
+        tname("abc"),
+        Term.ApplyInfix(
+          tname("arg1"),
+          tname("++"),
+          Nil,
+          Term.Apply(
+            tname("abc"),
+            Term.ApplyInfix(
+              tname("arg2"),
+              tname("++"),
+              Nil,
+              List(Term.Apply(tname("abc"), List(tname("arg3"))))
+            ) :: Nil
+          ) :: Nil
+        ) :: Nil
+      )
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 4 complex, partial outdents, indented def in braces") {
+    val code =
+      """
+        |  def mtd = {
+        |    abc:
+        |        arg1
+        |      ++
+        |        abc:
+        |            arg2
+        |          ++
+        |            abc:
+        |                arg3
+        |}
+        |""".stripMargin
+    val layout =
+      """
+        |def mtd = {
+        |  abc(arg1 ++ abc(arg2 ++ abc(arg3)))
+        |}
+        |""".stripMargin
+    val tree = Defn.Def(
+      Nil,
+      tname("mtd"),
+      Nil,
+      None,
+      Term.Block(
+        Term.Apply(
+          tname("abc"),
+          Term.ApplyInfix(
+            tname("arg1"),
+            tname("++"),
+            Nil,
+            Term.Apply(
+              tname("abc"),
+              Term.ApplyInfix(
+                tname("arg2"),
+                tname("++"),
+                Nil,
+                List(Term.Apply(tname("abc"), List(tname("arg3"))))
+              ) :: Nil
+            ) :: Nil
+          ) :: Nil
+        ) :: Nil
+      )
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 5 complex, full/partial outdents, unindented def") {
+    val code =
+      """
+        |def mtd =
+        |    abc:
+        |        arg1
+        |      ++
+        |        abc:
+        |            arg2
+        |        ++
+        |            abc:
+        |                arg3
+        |""".stripMargin
+    val layout = "def mtd = abc(arg1 ++ abc(arg2) ++ abc(arg3))"
+    val tree = Defn.Def(
+      Nil,
+      tname("mtd"),
+      Nil,
+      None,
+      Term.Apply(
+        tname("abc"),
+        Term.ApplyInfix(
+          Term.ApplyInfix(
+            tname("arg1"),
+            tname("++"),
+            Nil,
+            List(Term.Apply(tname("abc"), List(tname("arg2"))))
+          ),
+          tname("++"),
+          Nil,
+          List(Term.Apply(tname("abc"), List(tname("arg3"))))
+        ) :: Nil
+      )
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 5 complex, full/partial outdents, indented def") {
+    val code =
+      """
+        |  def mtd =
+        |    abc:
+        |        arg1
+        |      ++
+        |        abc:
+        |            arg2
+        |        ++
+        |            abc:
+        |                arg3
+        |""".stripMargin
+    val layout = "def mtd = abc(arg1 ++ abc(arg2) ++ abc(arg3))"
+    val tree = Defn.Def(
+      Nil,
+      tname("mtd"),
+      Nil,
+      None,
+      Term.Apply(
+        tname("abc"),
+        Term.ApplyInfix(
+          Term.ApplyInfix(
+            tname("arg1"),
+            tname("++"),
+            Nil,
+            List(Term.Apply(tname("abc"), List(tname("arg2"))))
+          ),
+          tname("++"),
+          Nil,
+          List(Term.Apply(tname("abc"), List(tname("arg3"))))
+        ) :: Nil
+      )
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 5 complex, full/partial outdents, indented def in braces") {
+    val code =
+      """
+        |  def mtd = {
+        |    abc:
+        |        arg1
+        |      ++
+        |        abc:
+        |            arg2
+        |        ++
+        |            abc:
+        |                arg3
+        |  }
+        |""".stripMargin
+    val layout =
+      """
+        |def mtd = {
+        |  abc(arg1 ++ abc(arg2) ++ abc(arg3))
+        |}
+        |""".stripMargin
+    val tree = Defn.Def(
+      Nil,
+      tname("mtd"),
+      Nil,
+      None,
+      Term.Block(
+        Term.Apply(
+          tname("abc"),
+          Term.ApplyInfix(
+            Term.ApplyInfix(
+              tname("arg1"),
+              tname("++"),
+              Nil,
+              List(Term.Apply(tname("abc"), List(tname("arg2"))))
+            ),
+            tname("++"),
+            Nil,
+            List(Term.Apply(tname("abc"), List(tname("arg3"))))
+          ) :: Nil
+        ) :: Nil
+      )
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 6 complex, with indented comments") {
+    val code =
+      """
+        |  def mtd =
+        |    abc:
+        |        arg1
+        |    ++ /* c1 */ // c2
+        |        abc:
+        |            arg2
+        |        ++ /*
+        |        c3
+        |        */ abc:
+        |                arg3
+        |""".stripMargin
+    val layout =
+      """
+        |def mtd = abc(arg1) ++ abc {
+        |  arg2
+        |  ++
+        |} abc arg3
+        |""".stripMargin
+    val tree = Defn.Def(
+      Nil,
+      tname("mtd"),
+      Nil,
+      None,
+      Term.ApplyInfix(
+        Term.ApplyInfix(
+          Term.Apply(tname("abc"), List(tname("arg1"))),
+          tname("++"),
+          Nil,
+          List(Term.Apply(tname("abc"), List(Term.Block(List(tname("arg2"), tname("++"))))))
+        ),
+        tname("abc"),
+        Nil,
+        List(tname("arg3"))
+      )
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 6 complex, with outindented comments") {
+    val code =
+      """
+        |  def mtd =
+        |    abc:
+        |        arg1
+        |    ++ /*
+        |   c2
+        |    */ abc:
+        |        arg2
+        |""".stripMargin
+    val layout = "def mtd = abc(arg1) ++ abc(arg2)"
+    val tree = Defn.Def(
+      Nil,
+      tname("mtd"),
+      Nil,
+      None,
+      Term.ApplyInfix(
+        Term.Apply(tname("abc"), List(tname("arg1"))),
+        tname("++"),
+        Nil,
+        List(Term.Apply(tname("abc"), List(tname("arg2"))))
+      )
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
+  test("scalafmt #3720 6 complex, with newline after multiline comment") {
+    val code =
+      """
+        |  def mtd =
+        |    abc:
+        |        arg1
+        |    ++ /*
+        |       c2
+        |    */
+        |    abc:
+        |        arg2
+        |""".stripMargin
+    val layout = "def mtd = abc(arg1) ++ abc(arg2)"
+    val tree = Defn.Def(
+      Nil,
+      tname("mtd"),
+      Nil,
+      None,
+      Term.ApplyInfix(
+        Term.Apply(tname("abc"), List(tname("arg1"))),
+        tname("++"),
+        Nil,
+        List(Term.Apply(tname("abc"), List(tname("arg2"))))
+      )
+    )
+    runTestAssert[Stat](code, Some(layout))(tree)
+  }
+
 }
diff --git a/tests/shared/src/test/scala/scala/meta/tests/parsers/dotty/InfixSuite.scala b/tests/shared/src/test/scala/scala/meta/tests/parsers/dotty/InfixSuite.scala
index 5fd4745fe..4bff0984c 100644
--- a/tests/shared/src/test/scala/scala/meta/tests/parsers/dotty/InfixSuite.scala
+++ b/tests/shared/src/test/scala/scala/meta/tests/parsers/dotty/InfixSuite.scala
@@ -591,4 +591,14 @@ class InfixSuite extends BaseDottySuite {
     )
   }
 
+  test("case: break before `|`") {
+    val code =
+      """|case 'a'
+         |   | 'A' =>
+         |""".stripMargin
+    val layout = "case 'a' | 'A' =>"
+    val tree = Case(Pat.Alternative(Lit.Char('a'), Lit.Char('A')), None, Term.Block(Nil))
+    runTestAssert[Case](code, Some(layout))(tree)
+  }
+
 }