Repository: scalameta/scalameta
Pull request #3419 (2023-12-25 01:08:59)

ScannerTokens: improve obtaining lastNewlinePos
Since we had already obtained the current indent position, which points to the final newline (either token or multiline comment), we no longer need to traverse all trivia tokens between prevPos and nextPos and just backtrack from the indent position, to find the EOL token and then again to check whether we have a blank line.

Helps with scalameta/scalafmt#3720.


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 effdcc743..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
@@ -536,10 +536,9 @@ class ScalametaParser(input: Input)(implicit dialect: Dialect) { parser =>
   def isIdentOf(tok: Token, name: String) = isIdentAnd(tok, _ == name)
   @inline def isStar: Boolean = isStar(token)
   def isStar(tok: Token): Boolean = isIdentOf(tok, "*")
-  def isBar: Boolean = isIdentOf(token, "|")
   def isVarargStarParam(allowRepeated: Boolean) =
     allowRepeated && dialect.allowPostfixStarVarargSplices &&
-      isStar(token) && peekToken.is[RightParen]
+      isStar && peekToken.is[RightParen]
 
   private trait MacroIdent {
     protected def ident(token: Token): Option[String]
@@ -962,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)
     }
@@ -2060,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
@@ -2143,10 +2127,12 @@ class ScalametaParser(input: Input)(implicit dialect: Dialect) { parser =>
         Left(term)
       }
 
+      def emptyTypeArgs = autoPos(Type.ArgClause(Nil))
+
       def getPostfixOrNextRhs(op: Term.Name): Either[Term, Term] = {
         // Infix chain continues.
         // In the running example, we're at `a [+] b`.
-        val targs = if (token.is[LeftBracket]) exprTypeArgs() else autoPos(Type.ArgClause(Nil))
+        val targs = if (token.is[LeftBracket]) exprTypeArgs() else emptyTypeArgs
 
         // Check whether we're still infix or already postfix by testing the current token.
         // In the running example, we're at `a + [b]` (infix).
@@ -2180,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, autoPos(Type.ArgClause(Nil)))
-          }
-        case _ => None
+        case _ =>
+          tryGetNextInfixOpIfLeading(Term.Name.apply).map(getNextRhs(_, emptyTypeArgs))
       }
       resOpt match {
         case Some(Left(x)) => x
@@ -2662,7 +2645,7 @@ class ScalametaParser(input: Input)(implicit dialect: Dialect) { parser =>
     private def patternAlternatives(pats: List[Pat]): Pat = {
       val pat = pattern1()
       checkNoTripleDot(pat)
-      if (isBar) {
+      if (isIdentOf(token, "|")) {
         next()
         patternAlternatives(pat :: pats)
       } else if (pats.isEmpty) {
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 758b83fb1..7afdc0aa9 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 {
@@ -656,24 +643,22 @@ final class ScannerTokens(val tokens: Tokens)(implicit dialect: Dialect) {
       if (isTrailingComma) nextToken(curr, currPos, currPos + 1, sepRegionsOrig)
       else nonTrivial(getNonTrivialRegions(sepRegionsOrig))
     else {
-      var i = prevPos + 1
-      var lastNewlinePos = -1
-      var newlineStreak = false
-      var newlines = false
-      var hasLF = false
-      while (i < nextPos) {
-        val token = tokens(i)
-        if (token.is[EOL]) {
-          lastNewlinePos = i
-          hasLF = true
-          if (newlineStreak) newlines = true
-          newlineStreak = true
-        } else if (!token.is[Whitespace]) {
-          newlineStreak = false
-          hasLF |= MultilineComment(token)
-        }
-        i += 1
-      }
+      @tailrec
+      def findLastEOL(pos: Int): Int =
+        if (pos == prevPos) -1 else if (tokens(pos).is[EOL]) pos else findLastEOL(pos - 1)
+      @tailrec
+      def hasBlank(pos: Int, hadEOL: Boolean): Boolean =
+        if (pos == prevPos) false
+        else
+          tokens(pos) match {
+            case _: EOL => hadEOL || hasBlank(pos - 1, true)
+            case _: Whitespace => hasBlank(pos - 1, hadEOL)
+            case _ => hasBlank(pos - 1, false)
+          }
+
+      val hasLF = indentPos > prevPos // includes indentPos = -1
+      val lastNewlinePos = if (hasLF) findLastEOL(indentPos) else -1
+      val newlines = lastNewlinePos >= 0 && hasBlank(lastNewlinePos - 1, true)
 
       def lastWhitespaceToken(regions: List[SepRegion]) = {
         val token = tokens(lastNewlinePos)
@@ -693,30 +678,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 +752,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 +765,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 +855,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)
@@ -875,11 +877,15 @@ final class ScannerTokens(val tokens: Tokens)(implicit dialect: Dialect) {
             }
           }
 
+          val regionsToRes: List[SepRegion] => Option[Either[List[SepRegion], TokenRef]] =
+            if (prevToken.is[Indentation]) _ => None
+            else getOutdentIfNeeded(_).fold(getIndentIfNeeded, x => Some(Right(x)))
+
           @tailrec
           def iter(regions: List[SepRegion]): Option[Either[List[SepRegion], TokenRef]] = {
-            val res =
-              if (prevToken.is[Indentation]) None
-              else getOutdentIfNeeded(regions).fold(getIndentIfNeeded, x => Some(Right(x)))
+            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) &&
@@ -909,10 +915,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)
   }
@@ -921,7 +955,7 @@ object ScannerTokens {
     val str: String = t.value
     @tailrec
     def loop(idx: Int, indent: Int): Int =
-      if (idx <= 0) indent
+      if (idx <= 0) -1
       else
         str.charAt(idx) match {
           case '\n' => indent
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 d363385bb..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
@@ -172,10 +172,11 @@ object Token {
     def this(input: Input, dialect: Dialect) = this(input, dialect, input.chars.length)
     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
-  @freeform("\n\n") private[meta] class LFLF extends AtEOL
   @freeform("ellipsis") private[meta] class Ellipsis(rank: Int) extends Token
   @freeform("unquote") private[meta] class Unquote extends Token
 


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)
+  }
+
 }