From 2767738ae17b70be44fdbf0b1a16947d6e61a86f Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sat, 15 Aug 2026 16:24:19 +0200 Subject: [PATCH 01/55] Fix eight Scala parser round-trip bugs found by a corpus sweep Sweeping 1,693 real Scala files (cats-effect plus scala3's own library and compiler) through the parser surfaced 103 files that fail the print-idempotency check, which makes each of them a ParseError and blocks ingestion. This fixes the eight defects behind roughly a third of them, each with a minimal reproducer added to the matching existing test class. Most share a root cause: the parser advances a manual cursor, but also jumps it to a dotty span end, and anything the cursor never consumed in between is silently dropped or smuggled into a Space. - Comma-separated parents. `class C extends A, B` printed as `extends Awith, B` because `with` was hard-coded as the separator, so the `,` was never consumed and leaked into the next parent's prefix. Scala 3 accepts either, and the two may be mixed, so each parent now records the separator introducing it. - Primary-constructor access modifier. `final class X private ()` lost the whole clause, because a constructor was only recognized when the next non-whitespace character was `(`. - Clause keyword ordering. `(using @deprecated ctx: String)` printed as `( @deprecatedusing ctx: String)`; `using`/`implicit` open the clause and now print ahead of parameter annotations, on both the constructor and method paths. - Higher-kinded variance. `F[-_, +_]` printed as `F[_, _]`. - Literal types. `def f(): true` printed as `true.type`. Adds S.LiteralType, mirroring JS.LiteralType, holding an Expression so `-1` is covered too. - Type-parameter variance modeling. `+`/`-` was crammed into the identifier's name, so `Foo[+A]` had a type parameter literally named `+A`. It is a modifier and now lives in J.TypeParameter.modifiers, as Kotlin already does. - Scala 3 end markers. `end X` was dropped or left sitting inside a Space, producing an invalid LST. Now captured on class, object, trait, def, val, given, if, while and match, braced and braceless. A `val`'s marker sits beyond dotty's span for the definition, so it is claimed by name, which is also the language's rule and stops a nested definition taking its parent's marker. The corpus goes from 103 to at most 72 failing files. Remaining families are capture-checking syntax, parenthesized types used as parents, unmapped `new`, and polymorphic function types. --- .../org/openrewrite/scala/ScalaPrinter.java | 101 ++++-- .../org/openrewrite/scala/ScalaVisitor.java | 8 + .../java/org/openrewrite/scala/tree/S.java | 45 +++ .../scala/internal/ScalaTreeVisitor.scala | 306 ++++++++++++++---- .../scala/marker/ScalaMarkers.scala | 42 +++ .../scala/MethodDeclarationTest.java | 40 +++ .../scala/tree/ClassDeclarationTest.java | 102 ++++++ .../scala/tree/ControlFlowTest.java | 49 +++ .../scala/tree/SingletonTypeTreeTest.java | 13 + .../scala/tree/VariableDeclarationsTest.java | 28 ++ 10 files changed, 644 insertions(+), 90 deletions(-) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index 74ea6d02660..6347b4e101b 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -141,6 +141,11 @@ public J visitTypeParameter(J.TypeParameter typeParam, PrintOutputCapture

p) // Print type parameter, but bounds use Scala syntax beforeSyntax(typeParam, Space.Location.TYPE_PARAMETERS_PREFIX, p); visit(typeParam.getAnnotations(), p); + // Variance binds directly to the name + for (J.Modifier m : typeParam.getModifiers()) { + visitSpace(m.getPrefix(), Space.Location.MODIFIER_PREFIX, p); + p.append(m.getKeyword()); + } visit(typeParam.getName(), p); // Print bounds if present using Scala syntax. @@ -483,10 +488,19 @@ public J visitMethodDeclaration(J.MethodDeclaration method, PrintOutputCapture

p) { return visitFunctionCall((S.FunctionCall) tree, p); } else if (tree instanceof S.ConstructorInvocation) { return visitConstructorInvocation((S.ConstructorInvocation) tree, p); + } else if (tree instanceof S.LiteralType) { + return visitLiteralType((S.LiteralType) tree, p); } else if (tree instanceof S.SingletonType) { return visitSingletonType((S.SingletonType) tree, p); } else if (tree instanceof S.RepeatedType) { @@ -964,6 +980,9 @@ public J visitClassDeclaration(J.ClassDeclaration classDecl, PrintOutputCapture< if (classDecl.getPadding().getPrimaryConstructor() != null && !classDecl.getPadding().getPrimaryConstructor().getMarkers().findFirst(OmitParentheses.class).isPresent()) { JContainer primaryConstructor = classDecl.getPadding().getPrimaryConstructor(); + primaryConstructor.getMarkers() + .findFirst(org.openrewrite.scala.marker.ConstructorModifier.class) + .ifPresent(m -> p.append(m.text())); visitSpace(primaryConstructor.getBefore(), Space.Location.RECORD_STATE_VECTOR, p); p.append('('); List> ctorElements = primaryConstructor.getPadding().getElements(); @@ -973,11 +992,18 @@ public J visitClassDeclaration(J.ClassDeclaration classDecl, PrintOutputCapture< if (element instanceof J.VariableDeclarations) { J.VariableDeclarations varDecl = (J.VariableDeclarations) element; visitSpace(varDecl.getPrefix(), Space.Location.VARIABLE_DECLARATIONS_PREFIX, p); + for (J.Modifier m : varDecl.getModifiers()) { + if (isClauseKeyword(m)) { + visit(m, p); + } + } visit(varDecl.getLeadingAnnotations(), p); // Print modifiers as-is: includes `val`/`var`/`private`/etc. when present // on a class constructor param; absent for plain `(name: T)` form. for (J.Modifier m : varDecl.getModifiers()) { - visit(m, p); + if (!isClauseKeyword(m)) { + visit(m, p); + } } boolean omitParamName = !varDecl.getVariables().isEmpty() && varDecl.getVariables().get(0).getMarkers().findFirst( @@ -1020,36 +1046,21 @@ public J visitClassDeclaration(J.ClassDeclaration classDecl, PrintOutputCapture< } if (classDecl.getPadding().getImplements() != null) { - // In Scala, implements are printed with "with" keyword - // The container already has the proper space before the first keyword - - String firstKeyword = ""; - String separator = ""; - - if (classDecl.getPadding().getExtends() != null) { - // If we have extends, traits use "with" - firstKeyword = "with"; - separator = "with"; - } else { - // If no extends, first trait uses "extends" - firstKeyword = "extends"; - separator = "with"; - } - // Custom handling for Scala traits JContainer implContainer = classDecl.getPadding().getImplements(); - visitSpace(implContainer.getBefore(), Space.Location.IMPLEMENTS, p); - p.append(firstKeyword); - List> elements = implContainer.getPadding().getElements(); + + visitSpace(implContainer.getBefore(), Space.Location.IMPLEMENTS, p); + // Without an `extends` clause the first parent carries the `extends` keyword + p.append(classDecl.getPadding().getExtends() == null ? "extends" : parentSeparator(elements.get(0))); + for (int i = 0; i < elements.size(); i++) { JRightPadded elem = elements.get(i); visit(elem.getElement(), p); - + if (i < elements.size() - 1) { - // Print space after element and the separator visitSpace(elem.getAfter(), Space.Location.IMPLEMENTS_SUFFIX, p); - p.append(separator); + p.append(parentSeparator(elements.get(i + 1))); } } } @@ -1068,6 +1079,26 @@ public J visitClassDeclaration(J.ClassDeclaration classDecl, PrintOutputCapture< } } + @Override + protected void afterSyntax(J j, PrintOutputCapture

p) { + // A Scala 3 end marker closes the element it is attached to, after its body + j.getMarkers().findFirst(org.openrewrite.scala.marker.EndMarker.class) + .ifPresent(m -> p.append(m.text())); + super.afterSyntax(j, p); + } + + /** `using`/`implicit` open a whole parameter clause, so they print ahead of any + * parameter annotations rather than with the other modifiers. */ + private static boolean isClauseKeyword(J.Modifier modifier) { + return "using".equals(modifier.getKeyword()) || "implicit".equals(modifier.getKeyword()); + } + + private static String parentSeparator(JRightPadded parent) { + return parent.getMarkers().findFirst(org.openrewrite.scala.marker.ParentSeparator.class) + .map(org.openrewrite.scala.marker.ParentSeparator::text) + .orElse("with"); + } + private void visitTypeParameters(@Nullable JContainer typeParams, PrintOutputCapture

p) { if (typeParams != null && !typeParams.getElements().isEmpty()) { // In Scala, type parameters use square brackets, not angle brackets @@ -1736,6 +1767,8 @@ public J visitTuplePattern(S.TuplePattern tuplePattern, PrintOutputCapture

p) public J visitWildcard(S.Wildcard wildcard, PrintOutputCapture

p) { beforeSyntax(wildcard, Space.Location.LANGUAGE_EXTENSION, p); + wildcard.getMarkers().findFirst(org.openrewrite.scala.marker.KindParameterVariance.class) + .ifPresent(m -> p.append(m.text())); p.append('_'); afterSyntax(wildcard, p); return wildcard; @@ -1848,6 +1881,13 @@ public J visitAnonymousGiven(S.AnonymousGiven g, PrintOutputCapture

p) { return g; } + public J visitLiteralType(S.LiteralType literalType, PrintOutputCapture

p) { + beforeSyntax(literalType, Space.Location.LANGUAGE_EXTENSION, p); + visit(literalType.getLiteral(), p); + afterSyntax(literalType, p); + return literalType; + } + public J visitSingletonType(S.SingletonType singletonType, PrintOutputCapture

p) { beforeSyntax(singletonType, Space.Location.LANGUAGE_EXTENSION, p); visit(singletonType.getQualifier(), p); @@ -2072,8 +2112,17 @@ public J visitExtensionMethods(S.ExtensionMethods ext, PrintOutputCapture

p) if (element instanceof J.VariableDeclarations) { J.VariableDeclarations varDecl = (J.VariableDeclarations) element; visitSpace(varDecl.getPrefix(), Space.Location.VARIABLE_DECLARATIONS_PREFIX, p); + for (J.Modifier m : varDecl.getModifiers()) { + if (isClauseKeyword(m)) { + visit(m, p); + } + } visit(varDecl.getLeadingAnnotations(), p); - visit(varDecl.getModifiers(), p); + for (J.Modifier m : varDecl.getModifiers()) { + if (!isClauseKeyword(m)) { + visit(m, p); + } + } if (!varDecl.getVariables().isEmpty()) { visit(varDecl.getVariables().get(0).getName(), p); } diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaVisitor.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaVisitor.java index bb44545d812..231941140b2 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaVisitor.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaVisitor.java @@ -246,6 +246,14 @@ public J visitConstructorInvocation(S.ConstructorInvocation constructorInvocatio return c; } + public J visitLiteralType(S.LiteralType literalType, P p) { + S.LiteralType l = literalType; + l = l.withPrefix(visitSpace(l.getPrefix(), Space.Location.LANGUAGE_EXTENSION, p)); + l = l.withMarkers(visitMarkers(l.getMarkers(), p)); + l = l.withLiteral(visitAndCast(l.getLiteral(), p)); + return l; + } + public J visitSingletonType(S.SingletonType singletonType, P p) { S.SingletonType s = singletonType; s = s.withPrefix(visitSpace(s.getPrefix(), Space.Location.LANGUAGE_EXTENSION, p)); diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/tree/S.java b/rewrite-scala/src/main/java/org/openrewrite/scala/tree/S.java index eca663cd4a0..cc0335ebb4a 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/tree/S.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/tree/S.java @@ -1742,6 +1742,51 @@ public S.ConstructorInvocation withArguments(JContainer arguments) { } } + /** + * A Scala literal type: the {@code true} of {@code def f(): true}, or {@code "a"}, {@code -1}. + * The literal itself is the type; there is no {@code .type} suffix as in {@link SingletonType}. + */ + @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) + @EqualsAndHashCode(callSuper = false, onlyExplicitlyIncluded = true) + final class LiteralType implements S, TypeTree, Expression { + + @With @Getter @EqualsAndHashCode.Include + UUID id; + + @With @Getter + Space prefix; + + @With @Getter + Markers markers; + + // Not `J.Literal` so that negated literals like `-1` are captured too + @With @Getter + Expression literal; + + @With @Getter + @Nullable + JavaType type; + + public LiteralType(UUID id, Space prefix, Markers markers, Expression literal, + @Nullable JavaType type) { + this.id = id; + this.prefix = prefix; + this.markers = markers; + this.literal = literal; + this.type = type; + } + + @Override + public

J acceptScala(ScalaVisitor

v, P p) { + return v.visitLiteralType(this, p); + } + + @Override + public CoordinateBuilder.Expression getCoordinates() { + return new CoordinateBuilder.Expression(this); + } + } + /** * Represents a Scala singleton type: {@code foo.type}. * The qualifier is any expression, typically an object/module reference. diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index a7cd2bf3d17..a95b5767db0 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -36,6 +36,9 @@ import org.openrewrite.scala.marker.IndentedSyntax import org.openrewrite.scala.marker.OmitBraces import org.openrewrite.scala.marker.OmitImportBraces import org.openrewrite.scala.marker.PackageObject +import org.openrewrite.scala.marker.EndMarker +import org.openrewrite.scala.marker.KindParameterVariance +import org.openrewrite.scala.marker.ParentSeparator import org.openrewrite.scala.marker.SObject import org.openrewrite.scala.marker.Semicolon import org.openrewrite.scala.marker.TrailingComma @@ -3428,9 +3431,12 @@ class ScalaTreeVisitor( } } + // Claim a trailing end marker before the cursor moves past it + val endMarkerMarkers = withEndMarker(Markers.EMPTY, vd.span, vd.name.toString) + // Update cursor to end of ValDef updateCursor(vd.span.end) - + // Create variable declarator val namedVariable = new J.VariableDeclarations.NamedVariable( Tree.randomId(), @@ -3456,8 +3462,10 @@ class ScalaTreeVisitor( if (isGiven) { markerList.add(org.openrewrite.scala.marker.Given(Tree.randomId())) } - val variableMarkers = - if (markerList.isEmpty) Markers.EMPTY else Markers.build(markerList) + val variableMarkers = { + val base = if (markerList.isEmpty) Markers.EMPTY else Markers.build(markerList) + endMarkerMarkers.findFirst(classOf[EndMarker]).map[Markers](m => base.add(m)).orElse(base) + } new J.VariableDeclarations( Tree.randomId(), @@ -3787,20 +3795,26 @@ class ScalaTreeVisitor( if (tmpl.parents.size > 1) { val implementsList = new util.ArrayList[JRightPadded[TypeTree]]() - // Find space before first "with" + val separators = new util.ArrayList[String]() + + // Find space before the separator introducing the first additional parent var containerSpace = Space.format(" ") if (cursor < source.length && tmpl.parents(1).span.exists) { val firstWithParentStart = Math.max(0, tmpl.parents(1).span.start - offsetAdjustment) if (cursor < firstWithParentStart) { val beforeFirstWith = source.substring(cursor, firstWithParentStart) - val withIdx = positionOfNextIn(beforeFirstWith, "with", 0) - if (withIdx >= 0) { - containerSpace = Space.format(beforeFirstWith.substring(0, withIdx)) - cursor = cursor + withIdx + "with".length + val (sepIdx, sep) = parentSeparatorIn(beforeFirstWith) + if (sepIdx >= 0) { + containerSpace = Space.format(beforeFirstWith.substring(0, sepIdx)) + cursor = cursor + sepIdx + sep.length + separators.add(sep) } } } - + if (separators.isEmpty) { + separators.add("with") + } + for (i <- 1 until tmpl.parents.size) { val parent = tmpl.parents(i) val visitedParent = visitTree(parent) @@ -3818,18 +3832,24 @@ class ScalaTreeVisitor( val nextStart = Math.max(0, tmpl.parents(i + 1).span.start - offsetAdjustment) if (thisEnd < nextStart && nextStart <= source.length) { val between = source.substring(thisEnd, nextStart) - val withIdx = positionOfNextIn(between, "with", 0) - if (withIdx >= 0) { - trailingSpace = Space.format(between.substring(0, withIdx)) - // Update cursor past "with" - cursor = thisEnd + withIdx + "with".length + val (sepIdx, sep) = parentSeparatorIn(between) + if (sepIdx >= 0) { + trailingSpace = Space.format(between.substring(0, sepIdx)) + cursor = thisEnd + sepIdx + sep.length + separators.add(sep) } else { trailingSpace = Space.format(between) + separators.add("with") } } } - - implementsList.add(new JRightPadded(implType, trailingSpace, Markers.EMPTY)) + + val sepMarkers = if (i - 1 < separators.size) { + Markers.EMPTY.add(ParentSeparator(Tree.randomId(), separators.get(i - 1))) + } else { + Markers.EMPTY + } + implementsList.add(new JRightPadded(implType, trailingSpace, sepMarkers)) } implementings = JContainer.build(containerSpace, implementsList, Markers.EMPTY) } @@ -3930,17 +3950,19 @@ class ScalaTreeVisitor( ).withMarkers(Markers.build(Collections.singletonList(new OmitBraces(Tree.randomId())))) } - // Update cursor to end of module def - if (md.span.exists) { - cursor = Math.max(cursor, md.span.end - offsetAdjustment) - } - // Create the class declaration with SObject marker (and PackageObject for `package object`) - val objectMarkers = if (isPackageObject) { + val objectBaseMarkers = if (isPackageObject) { Markers.build(Arrays.asList(SObject.create(), PackageObject(Tree.randomId()))) } else { Markers.build(Collections.singletonList(SObject.create())) } + val objectMarkers = withEndMarker(objectBaseMarkers, md.span) + + // Update cursor to end of module def + if (md.span.exists) { + cursor = Math.max(cursor, md.span.end - offsetAdjustment) + } + new J.ClassDeclaration( Tree.randomId(), prefix, @@ -4226,12 +4248,13 @@ class ScalaTreeVisitor( } } - // Update cursor to end of the if expression - updateCursor(ifTree.span.end) - - val ifMarkers = if (ifIsParenless) + val ifBaseMarkers = if (ifIsParenless) Markers.build(Collections.singletonList(new IndentedSyntax(Tree.randomId()))) else Markers.EMPTY + val ifMarkers = withEndMarker(ifBaseMarkers, ifTree.span) + + // Update cursor to end of the if expression + updateCursor(ifTree.span.end) new J.If( Tree.randomId(), @@ -4371,12 +4394,13 @@ class ScalaTreeVisitor( case null => throw unmappedException(whileTree) } - // Update cursor to end of the while loop - updateCursor(whileTree.span.end) - - val whileMarkers = if (whileIsParenless) + val whileBaseMarkers = if (whileIsParenless) Markers.build(Collections.singletonList(new IndentedSyntax(Tree.randomId()))) else Markers.EMPTY + val whileMarkers = withEndMarker(whileBaseMarkers, whileTree.span) + + // Update cursor to end of the while loop + updateCursor(whileTree.span.end) new J.WhileLoop( Tree.randomId(), @@ -4863,6 +4887,8 @@ class ScalaTreeVisitor( private def visitClassDef(td: Trees.TypeDef[?]): J.ClassDeclaration = { val hasAnnotations = td.mods.annotations.nonEmpty + // Set while extracting the body, read when the declaration's markers are built + var endMarkerText: String = null // Handle annotations first val leadingAnnotations = new util.ArrayList[J.Annotation]() @@ -5095,16 +5121,45 @@ class ScalaTreeVisitor( } } else None + // A primary constructor may carry an access modifier: `class X private (i: Int)`, + // `class X private[pkg] (i: Int)`. Returns the source offset just past it. + val afterCtorModifier: Int = { + var i = cursor + while (i < source.length && source.charAt(i).isWhitespace) i += 1 + val keyword = if (source.startsWith("private", i)) "private" + else if (source.startsWith("protected", i)) "protected" + else "" + if (keyword.isEmpty) { + cursor + } else { + val afterKeyword = i + keyword.length + val isWholeWord = afterKeyword >= source.length || + !(Character.isLetterOrDigit(source.charAt(afterKeyword)) || source.charAt(afterKeyword) == '_') + if (!isWholeWord) { + cursor + } else { + // optional qualifier, as in `private[pkg]` + var q = afterKeyword + while (q < source.length && source.charAt(q).isWhitespace) q += 1 + if (q < source.length && source.charAt(q) == '[') { + val close = positionOfNextIn(source, "]", q) + if (close >= 0) close + 1 else afterKeyword + } else afterKeyword + } + } + } + // Source has a primary-constructor parameter list iff the next non-whitespace char is `(`. val ctorParenPos: Int = { - var i = cursor + var i = afterCtorModifier while (i < source.length && source.charAt(i).isWhitespace) i += 1 if (i < source.length && source.charAt(i) == '(') i else -1 } val primaryConstructor: JContainer[Statement] = if (ctorParenPos >= 0) { val params = stripTrailingCommaArtifact(firstValueParamList.getOrElse(Nil)) - val parenSpace = ScalaSpace.format(source, cursor, ctorParenPos) + val ctorModifierText = source.substring(cursor, afterCtorModifier) + val parenSpace = ScalaSpace.format(source, afterCtorModifier, ctorParenPos) cursor = ctorParenPos + 1 val jParams = new util.ArrayList[JRightPadded[Statement]]() @@ -5181,12 +5236,16 @@ class ScalaTreeVisitor( keepScanning = false } } - val containerMarkers: Markers = if (extraListsBuf.nonEmpty) { + var containerMarkers: Markers = if (extraListsBuf.nonEmpty) { cursor = scanCursor Markers.build(Collections.singletonList( org.openrewrite.scala.marker.ExtraConstructorParamLists( Tree.randomId(), extraListsBuf.toString))) } else Markers.EMPTY + if (ctorModifierText.nonEmpty) { + containerMarkers = containerMarkers.add( + org.openrewrite.scala.marker.ConstructorModifier(Tree.randomId(), ctorModifierText)) + } JContainer.build(parenSpace, jParams, containerMarkers) } else { @@ -5254,7 +5313,10 @@ class ScalaTreeVisitor( if (sourceParents.size > 1) { val implementsList = new util.ArrayList[JRightPadded[TypeTree]]() - // Extract space before the first "with" or "extends" (if no extends clause) + // Separator introducing each parent after the first, in source order + val separators = new util.ArrayList[String]() + + // Extract space before the first separator, or "extends" if there is no extends clause var containerSpace = Space.EMPTY if (extendings == null && sourceParents.nonEmpty) { // No extends clause, so first trait uses "extends" @@ -5262,11 +5324,13 @@ class ScalaTreeVisitor( if (firstParent.span.exists) { containerSpace = sourceBefore("extends") } + separators.add("extends") } else if (extendings != null && sourceParents.size > 1) { - // We have extends, so look for first "with" - containerSpace = sourceBefore("with") + val (space, sep) = sourceBeforeParentSeparator() + containerSpace = space + separators.add(sep) } - + for (i <- 1 until sourceParents.size) { val parent = sourceParents(i) val savedCursorWith = cursor @@ -5278,14 +5342,21 @@ class ScalaTreeVisitor( throw unmappedException(parent) } + val sepMarkers = if (i - 1 < separators.size) { + Markers.EMPTY.add(ParentSeparator(Tree.randomId(), separators.get(i - 1))) + } else { + Markers.EMPTY + } + // Build the right-padded element val rightPadded = if (i < sourceParents.size - 1) { - // Not the last element, look for space before next "with" - val afterSpace = sourceBefore("with") - new JRightPadded(implType, afterSpace, Markers.EMPTY) + // Not the last element, consume the separator introducing the next one + val (afterSpace, sep) = sourceBeforeParentSeparator() + separators.add(sep) + new JRightPadded(implType, afterSpace, sepMarkers) } else { // Last element, no trailing space needed - JRightPadded.build(implType) + new JRightPadded(implType, Space.EMPTY, sepMarkers) } implementsList.add(rightPadded) @@ -5439,7 +5510,13 @@ class ScalaTreeVisitor( val classEnd = Math.max(0, td.span.end - offsetAdjustment) if (isClassBraceless) { if (cursor < classEnd) { - val es = ScalaSpace.format(source, cursor, Math.min(classEnd, source.length)) + val bodyEnd = Math.min(classEnd, source.length) + val es = endMarkerAt(cursor, bodyEnd) match { + case Some((start, text)) => + endMarkerText = source.substring(cursor, start + text.length) + Space.EMPTY + case None => ScalaSpace.format(source, cursor, bodyEnd) + } cursor = classEnd es } else Space.EMPTY @@ -5447,6 +5524,11 @@ class ScalaTreeVisitor( val remaining = source.substring(cursor, classEnd) val closeBraceIndex = remaining.lastIndexOf("}") if (closeBraceIndex >= 0) { + // An end marker follows the closing brace, outside the body + val afterBrace = cursor + closeBraceIndex + 1 + endMarkerAt(afterBrace, classEnd).foreach { case (start, text) => + endMarkerText = source.substring(afterBrace, start + text.length) + } cursor = classEnd Space.format(remaining.substring(0, closeBraceIndex)) } else Space.EMPTY @@ -5478,9 +5560,12 @@ class ScalaTreeVisitor( updateCursor(td.span.end) } - val classDeclMarkers = if (isEnumCaseClass) { + var classDeclMarkers = if (isEnumCaseClass) { Markers.build(Collections.singletonList(SObject.create())) } else Markers.EMPTY + if (endMarkerText != null) { + classDeclMarkers = classDeclMarkers.add(EndMarker(Tree.randomId(), endMarkerText)) + } new J.ClassDeclaration( Tree.randomId(), @@ -6620,7 +6705,12 @@ class ScalaTreeVisitor( // For procedure syntax, cursor is already correctly set by reparseProcedureBody. // Don't use dd.span.end because it extends past the actual method body due to synthetic ??? span. + var endMarkerText: String = null if (!isProcedureSyntax) { + val defEnd = Math.min(Math.max(0, dd.span.end - offsetAdjustment), source.length) + endMarkerAt(cursor, defEnd).foreach { case (start, text) => + endMarkerText = source.substring(cursor, start + text.length) + } updateCursor(dd.span.end) } @@ -6666,6 +6756,9 @@ class ScalaTreeVisitor( if (beforeEqualsSpace != Space.EMPTY) { markerList.add(org.openrewrite.scala.marker.MethodBodyEqualsPrefix.create(beforeEqualsSpace)) } + if (endMarkerText != null) { + markerList.add(EndMarker(Tree.randomId(), endMarkerText)) + } val methodMarkers = if (!markerList.isEmpty) Markers.build(markerList) else Markers.EMPTY new J.MethodDeclaration( @@ -7219,12 +7312,13 @@ class ScalaTreeVisitor( if (isBraceForm) cursor = cursor + braceIdx + 1 val casesBlock = buildCasesBlock(matchTree, isBraceForm).withPrefix(matchBraceSpace) - updateCursor(matchTree.span.end) - val selectorParens = new J.ControlParentheses[Expression](Tree.randomId(), Space.EMPTY, Markers.EMPTY, JRightPadded.build(selector).withAfter(matchKeywordSpace)) val markersList = new util.ArrayList[org.openrewrite.marker.Marker]() if (!isBraceForm) markersList.add(new IndentedSyntax(Tree.randomId())) if (isDottedMatch) markersList.add(DottedMatch.create()) - new J.Switch(Tree.randomId(), prefix, Markers.build(markersList), selectorParens, casesBlock) + val matchMarkers = withEndMarker(Markers.build(markersList), matchTree.span) + updateCursor(matchTree.span.end) + val selectorParens = new J.ControlParentheses[Expression](Tree.randomId(), Space.EMPTY, Markers.EMPTY, JRightPadded.build(selector).withAfter(matchKeywordSpace)) + new J.Switch(Tree.randomId(), prefix, matchMarkers, selectorParens, casesBlock) } /** @@ -7657,7 +7751,7 @@ class ScalaTreeVisitor( JContainer.build(Space.EMPTY, rpPatterns, Markers.EMPTY)) } - private def visitSingletonTypeTree(stt: Trees.SingletonTypeTree[?]): S.SingletonType = { + private def visitSingletonTypeTree(stt: Trees.SingletonTypeTree[?]): TypeTree = { // Singleton type reference: `None.type`, `obj.type`, `foo.bar.type` val prefix = extractPrefix(stt.span) val qualifier = visitTree(stt.ref) match { @@ -7666,14 +7760,19 @@ class ScalaTreeVisitor( case null => throw new UnsupportedOperationException( s"SingletonTypeTree.ref did not produce an Expression: ${stt.ref.getClass.getSimpleName}") } - // After visiting qualifier, cursor is at the end of qualifier. - // The remaining source should be whitespace followed by ".type". - val endPos = Math.max(0, stt.span.end - offsetAdjustment) - val between = if (cursor < endPos && endPos <= source.length) source.substring(cursor, endPos) else "" - val dotIdx = positionOfNextIn(between, ".", 0) - val beforeType = if (dotIdx > 0) Space.format(between.substring(0, dotIdx)) else Space.EMPTY - cursor = endPos - new S.SingletonType(Tree.randomId(), prefix, Markers.EMPTY, qualifier, beforeType, typeFor(stt.span)) + if (stt.ref.isInstanceOf[Trees.Literal[?]]) { + // Dotty wraps a literal type in a SingletonTypeTree + new S.LiteralType(Tree.randomId(), prefix, Markers.EMPTY, qualifier, typeFor(stt.span)) + } else { + // After visiting qualifier, cursor is at the end of qualifier. + // The remaining source should be whitespace followed by ".type". + val endPos = Math.max(0, stt.span.end - offsetAdjustment) + val between = if (cursor < endPos && endPos <= source.length) source.substring(cursor, endPos) else "" + val dotIdx = positionOfNextIn(between, ".", 0) + val beforeType = if (dotIdx > 0) Space.format(between.substring(0, dotIdx)) else Space.EMPTY + cursor = endPos + new S.SingletonType(Tree.randomId(), prefix, Markers.EMPTY, qualifier, beforeType, typeFor(stt.span)) + } } private def visitRefinedTypeTree(rtt: Trees.RefinedTypeTree[?]): S.RefinedType = { @@ -8915,6 +9014,16 @@ class ScalaTreeVisitor( * skipping over line/block comments. Similar to sourceBefore in * ReloadableJava17Parser. */ + /** Consumes the parent-list separator at the cursor, returning the space ahead of it + * and the separator itself. + */ + private def sourceBeforeParentSeparator(): (Space, String) = { + val withIdx = positionOfNext("with") + val commaIdx = positionOfNext(",") + val sep = if (withIdx >= 0 && (commaIdx < 0 || withIdx < commaIdx)) "with" else "," + (sourceBefore(sep), sep) + } + private def sourceBefore(untilDelim: String): Space = { val delimIndex = positionOfNext(untilDelim) if (delimIndex < 0) { @@ -8987,6 +9096,59 @@ class ScalaTreeVisitor( -1 } + /** Adds an {@link EndMarker} for an end marker sitting between the cursor and the end of + * {@code span}, if any. Call before advancing the cursor past the span, which would + * otherwise skip the marker. + */ + private def withEndMarker(markers: Markers, span: Spans.Span, name: String = null): Markers = { + // A `val`'s or `given`'s end marker sits beyond dotty's span for the definition, so when + // the name is known, bound the search by the source and claim the marker only if it names + // this definition — that is also what keeps a nested definition from taking its parent's. + val limit = + if (name != null) source.length + else if (span.exists) Math.min(Math.max(0, span.end - offsetAdjustment), source.length) + else return markers + val from = cursor + endMarkerAt(from, limit) match { + case Some((start, text)) if name == null || text.substring("end".length).trim == name => + cursor = start + text.length + markers.add(EndMarker(Tree.randomId(), source.substring(from, cursor))) + case _ => markers + } + } + + /** Locates a Scala 3 end marker (`end foo`, `end if`) in {@code source} between the + * cursor and {@code limit}, as (offset of `end`, marker text). None when absent. + * Dotty's spans cover a trailing end marker, so capture it before advancing the cursor. + */ + private def endMarkerAt(from: Int, limit: Int): Option[(Int, String)] = { + def isWordChar(c: Char): Boolean = Character.isLetterOrDigit(c) || c == '_' || c == '$' + var i = from + while (i < limit && i < source.length && source.charAt(i).isWhitespace) i += 1 + if (i + 3 > source.length || !source.startsWith("end", i)) { + None + } else if (i + 3 < source.length && isWordChar(source.charAt(i + 3))) { + None + } else { + var j = i + 3 + while (j < source.length && (source.charAt(j) == ' ' || source.charAt(j) == '\t')) j += 1 + var k = j + while (k < source.length && isWordChar(source.charAt(k))) k += 1 + if (k > j && k <= limit) Some((i, source.substring(i, k))) else None + } + } + + /** The parent-list separator nearest the start of {@code text}, as (index, keyword). + * Index is -1 when neither appears. + */ + private def parentSeparatorIn(text: String): (Int, String) = { + val withIdx = positionOfNextIn(text, "with", 0) + val commaIdx = positionOfNextIn(text, ",", 0) + if (withIdx >= 0 && (commaIdx < 0 || withIdx < commaIdx)) (withIdx, "with") + else if (commaIdx >= 0) (commaIdx, ",") + else (-1, "with") + } + /** Returns the index of the bracket that matches the open bracket assumed to be at * {@code afterOpen - 1}, searching forward from {@code afterOpen} in {@code source}. * Skips nested bracket pairs, {@code //} and {@code /* */} comments, and @@ -9236,6 +9398,8 @@ class ScalaTreeVisitor( val adjustedEnd = Math.max(0, tparam.span.end - offsetAdjustment) var namePrefix = Space.EMPTY var nameStr = tparam.name.toString + // `+`/`-` is a modifier on the type parameter, not part of its name + var varianceKeyword = "" if (adjustedStart < adjustedEnd && adjustedStart >= cursor && adjustedEnd <= source.length) { val paramSource = source.substring(adjustedStart, adjustedEnd) @@ -9246,8 +9410,7 @@ class ScalaTreeVisitor( } // Check if it starts with + or - (after stripping whitespace) if (stripped.startsWith("+") || stripped.startsWith("-")) { - val variance = stripped.charAt(0) - nameStr = variance.toString + tparam.name.toString + varianceKeyword = stripped.charAt(0).toString cursor = adjustedStart + (paramSource.length - stripped.length) + 1 } // Check for higher-kinded type params like F[_] or F[_, _] @@ -9265,7 +9428,7 @@ class ScalaTreeVisitor( i += 1 } if (depth == 0) { - nameStr = stripped.substring(0, i) + nameStr = stripped.substring(varianceKeyword.length, i) // Trim any trailing bound syntax (e.g., F[_] <: Bound) val trimmed = nameStr.trim if (trimmed.nonEmpty) nameStr = trimmed @@ -9277,7 +9440,7 @@ class ScalaTreeVisitor( // Use source scanning to find the actual end position. if (nameStr.contains("[")) { // Higher-kinded: find the matching ] after the name - val nameStart = adjustedStart + (if (nameStr.startsWith("+") || nameStr.startsWith("-")) 1 else 0) + val nameStart = adjustedStart + varianceKeyword.length val bracketStart = positionOfNext("[", nameStart) if (bracketStart >= 0) { var depth = 1 @@ -9292,7 +9455,7 @@ class ScalaTreeVisitor( } else { // Simple name: advance past leading whitespace + name val wsLen = if (adjustedStart < adjustedEnd) (source.substring(adjustedStart, adjustedEnd).length - source.substring(adjustedStart, adjustedEnd).stripLeading().length) else 0 - val nameEnd = adjustedStart + wsLen + nameStr.length + val nameEnd = adjustedStart + wsLen + varianceKeyword.length + nameStr.length if (nameEnd > cursor && nameEnd <= source.length) cursor = nameEnd } @@ -9303,10 +9466,18 @@ class ScalaTreeVisitor( source.substring(adjustedStart, adjustedEnd).length - source.substring(adjustedStart, adjustedEnd).stripLeading().length else 0 val tokenStart = adjustedStart + leadingWsLen + // The variance modifier carries the leading whitespace, so the name follows it directly + val modifiers = new util.ArrayList[J.Modifier]() + if (varianceKeyword.nonEmpty) { + modifiers.add(new J.Modifier(Tree.randomId(), namePrefix, Markers.EMPTY, + varianceKeyword, J.Modifier.Type.LanguageExtension, Collections.emptyList())) + } + val bareNamePrefix = if (varianceKeyword.isEmpty) namePrefix else Space.EMPTY + val name: Expression = tparam.rhs match { case lt: untpd.LambdaTypeTree if nameStr.contains("[") => - buildHigherKindedName(tparam, lt, tokenStart, namePrefix) - case _ => ident(nameStr, namePrefix) + buildHigherKindedName(tparam, lt, tokenStart + varianceKeyword.length, bareNamePrefix) + case _ => ident(nameStr, bareNamePrefix) } def contextBoundName(cxBound: Trees.Tree[?]): String = { @@ -9454,7 +9625,7 @@ class ScalaTreeVisitor( prefix, Markers.EMPTY, leadingAnnotations, - Collections.emptyList(), // modifiers + modifiers, name, bounds ) @@ -9504,7 +9675,14 @@ class ScalaTreeVisitor( buildHigherKindedName(inner, innerLt, innerStart, elemPrefix) case _ => val nm = inner.name.toString - if (nm == "_" || nm.startsWith("_$")) new S.Wildcard(Tree.randomId(), elemPrefix, Markers.EMPTY, null) + if (nm == "_" || nm.startsWith("_$")) { + // dotty's span starts at the variance marker, which the wildcard cannot carry + val variance = if (innerStart < source.length && + (source.charAt(innerStart) == '+' || source.charAt(innerStart) == '-')) { + Markers.EMPTY.add(KindParameterVariance(Tree.randomId(), source.charAt(innerStart).toString)) + } else Markers.EMPTY + new S.Wildcard(Tree.randomId(), elemPrefix, variance, null) + } else ident(source.substring(innerStart, innerEnd), elemPrefix) } val delimPos = if (idx < n - 1) { diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala index b6e3032f5eb..c4a66f8b78b 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala @@ -137,3 +137,45 @@ case class ExtraConstructorParamLists(id: UUID, text: String) extends Marker { override def getId(): UUID = id override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] } + +/** + * A Scala 3 end marker closing a definition, e.g. the `end X` of `class X: ... end X`. + * Holds the verbatim source from the end of the element's own content through the + * marker, so it covers the newline and indent ahead of it — a method body is an + * expression with no trailing space of its own. Dotty checks end markers and then + * discards them, so there is no tree to map them to. + */ +case class EndMarker(id: UUID, text: String) extends Marker { + override def getId(): UUID = id + override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] +} + +/** + * The `+` or `-` variance marker on a kind parameter of a higher-kinded type + * parameter, as in the `F[-_, +_]` of `def f[F[-_, +_]]`. The variance is source + * syntax the wildcard itself does not carry. + */ +case class KindParameterVariance(id: UUID, text: String) extends Marker { + override def getId(): UUID = id + override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] +} + +/** + * The access modifier on a class's primary constructor, e.g. the `private` in + * `class X private (i: Int)`. Holds the verbatim source text including the space + * ahead of it, which the printer emits between the class name and the `(`. + */ +case class ConstructorModifier(id: UUID, text: String) extends Marker { + override def getId(): UUID = id + override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] +} + +/** + * The keyword introducing one parent in a class declaration's parent list. Scala 3 + * accepts either `with` or `,` and the two may be mixed (`extends A, B with C`), so + * each parent after the first carries the separator that introduces it. + */ +case class ParentSeparator(id: UUID, text: String) extends Marker { + override def getId(): UUID = id + override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] +} diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java index 2ac3f21f044..e958ea0c2d8 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java @@ -1103,4 +1103,44 @@ void trailingCommaInTypeParametersSingleLine() { ) ); } + + @Test + void varianceOnHigherKindedTypeParameter() { + rewriteRun( + scala( + """ + trait X[To, From] { + def substituteBoth[F[-_, +_]](ftf: F[To, From]): F[From, To] + } + """ + ) + ); + } + + @Test + void endMarkerOnMethod() { + rewriteRun( + scala( + """ + object Test: + def foo(): Int = + 1 + end foo + """ + ) + ); + } + @Test + void implicitParameterWithAnnotation() { + rewriteRun( + scala( + """ + object Test { + def foo[B](implicit @implicitNotFound("m") ev: Ordering[B]): Unit = () + } + """ + ) + ); + } + } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java index 84b24699b08..687b38837ac 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java @@ -618,4 +618,106 @@ case class Matchup(users: Users): // score is x10 ) ); } + + @Test + void commaSeparatedParents() { + rewriteRun( + scala( + """ + trait A + trait B + class C extends A, B + """ + ) + ); + } + + @Test + void privateConstructor() { + rewriteRun( + scala( + """ + final class X private () + """ + ) + ); + } + + @Test + void qualifiedPrivateConstructor() { + rewriteRun( + scala( + """ + class X private[scala] (val x: Int) + """ + ) + ); + } + + @Test + void usingWithAnnotatedParameter() { + rewriteRun( + scala( + """ + class X(using @deprecated ctx: String) + """ + ) + ); + } + + @Test + void endMarker() { + rewriteRun( + scala( + """ + class X: + def f(): Int = 1 + end X + """ + ) + ); + } + + @Test + void endMarkerAfterBracedBody() { + rewriteRun( + scala( + """ + class X { + def f(): Int = 1 + } + end X + """ + ) + ); + } + + @Test + void endMarkerOnTrait() { + rewriteRun( + scala( + """ + trait T: + def f(): Int + end T + """ + ) + ); + } + + @Test + void nestedEndMarkers() { + rewriteRun( + scala( + """ + class X: + def f(): Int = + 1 + end f + end X + """ + ) + ); + } + } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java index f9c4bb22d0f..eef591a6526 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java @@ -408,4 +408,53 @@ void compoundAssignment() { ) ); } + + @Test + void endIf() { + rewriteRun( + scala( + """ + object O: + def f(b: Boolean): Int = + if b then + 1 + else + 2 + end if + """ + ) + ); + } + + @Test + void endWhile() { + rewriteRun( + scala( + """ + object O: + def f(): Unit = + var i = 0 + while i < 3 do + i = i + 1 + end while + """ + ) + ); + } + + @Test + void endMatch() { + rewriteRun( + scala( + """ + object O: + def f(i: Int): Int = + i match + case _ => 1 + end match + """ + ) + ); + } + } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/SingletonTypeTreeTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/SingletonTypeTreeTest.java index 3a7594d4ec4..f146809ef1f 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/SingletonTypeTreeTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/SingletonTypeTreeTest.java @@ -51,4 +51,17 @@ void qualifiedSingletonType() { ) ); } + + @Test + void literalTypeInReturnPosition() { + rewriteRun( + scala( + """ + object Test { + def unapply(s: String): true = true + } + """ + ) + ); + } } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java index 2610f11c3f5..13930a2d6ae 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java @@ -227,4 +227,32 @@ void significantCharactersInComments() { ) ); } + + @Test + void endMarkerOnVal() { + rewriteRun( + scala( + """ + object O: + val x = + 1 + end x + """ + ) + ); + } + + @Test + void endMarkerOnGiven() { + rewriteRun( + scala( + """ + object O: + given x: Int = 1 + end x + """ + ) + ); + } + } From 344a7ebfc70fbaf13647d968afc2a29753b3220f Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sat, 15 Aug 2026 16:37:56 +0200 Subject: [PATCH 02/55] Keep trailing comments and semicolons out of Scala whitespace Probing what non-whitespace can sit between a construct's own content and the end of dotty's span for it found comments and semicolons being swept into a Space. These produce an LST whose whitespace holds a comment instead of a Comment, so a recipe cannot see it and a formatting recipe could delete it. This class of defect is invisible to the parser's print-idempotency check, which only compares text, so these files parse successfully today and carry a silently invalid tree. - The compilation unit's EOF space was built with Space.build, which does no comment parsing, so any comment trailing the last statement landed in the whitespace field. It now goes through ScalaSpace.format like every other space in the parser. This covers a comment after an indented class body, method body, val, if or match. - An object body collected its statements with a bare JRightPadded, capturing no trailing separator, unlike the class body path which already used consumeTrailingSemicolon. Semicolons in class bodies, method bodies and blocks already round-tripped; only object bodies did not. - Compilation-unit statements were unpadded, so a `;` after a top-level class, import or val had nowhere to live. They are now JRightPadded and carry the Semicolon marker, and the printer walks the padding rather than the unwrapped statement list. Also removes a stray System.out.println from the converter. --- .../openrewrite/scala/ScalaParserVisitor.java | 16 ++- .../org/openrewrite/scala/ScalaPrinter.java | 9 +- .../scala/internal/ScalaASTConverter.scala | 63 ++++---- .../scala/internal/ScalaTreeVisitor.scala | 30 +++- .../scala/tree/CompilationUnitTest.java | 134 ++++++++++++++++++ 5 files changed, 208 insertions(+), 44 deletions(-) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaParserVisitor.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaParserVisitor.java index 4111b5861d0..99310b248ff 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaParserVisitor.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaParserVisitor.java @@ -73,16 +73,16 @@ public S.CompilationUnit visitCompilationUnit(ScalaParseResult parseResult) { CompilationUnitResult result = converter.convertToCompilationUnit(parseResult, source, typeFactory); J.Package packageDecl = result.getPackageDecl(); - List statements = result.getStatements(); + List> statements = result.getStatements(); // Filter out any Unknown statements that contain the entire source with package if (packageDecl != null) { final String packageName = packageDecl.getPackageName(); statements = statements.stream() - .filter(stmt -> { - if (stmt instanceof J.Unknown) { - String text = ((J.Unknown) stmt).getSource().getText().trim(); + .filter(rp -> { + if (rp.getElement() instanceof J.Unknown) { + String text = ((J.Unknown) rp.getElement()).getSource().getText().trim(); // Skip if this Unknown contains the same package declaration boolean shouldFilter = text.startsWith("package " + packageName); return !shouldFilter; @@ -117,12 +117,14 @@ public S.CompilationUnit visitCompilationUnit(ScalaParseResult parseResult) { unknownSource ); - statements.add(unknown); + statements.add(JRightPadded.build(unknown)); } // Get remaining source for EOF String remainingSource = converter.getRemainingSource(parseResult, source, result.getLastCursorPosition()); - Space eof = remainingSource.isEmpty() ? EMPTY : Space.build(remainingSource, Collections.emptyList()); + // Trailing source can hold comments, which belong in Space.comments rather than + // its whitespace + Space eof = remainingSource.isEmpty() ? EMPTY : ScalaSpace.format(remainingSource); // Build S.CompilationUnit return new S.CompilationUnit( @@ -135,7 +137,7 @@ public S.CompilationUnit visitCompilationUnit(ScalaParseResult parseResult) { charsetBomMarked, // boolean charsetBomMarked null, // Checksum checksum packageDecl == null ? null : JRightPadded.build(packageDecl), - JRightPadded.withElements(Collections.emptyList(), statements), + statements, eof // Space eof ); } diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index 6347b4e101b..3aafa0dd711 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -778,9 +778,12 @@ public J visitScalaCompilationUnit(S.CompilationUnit scu, PrintOutputCapture

visit(scu.getPackageDeclaration(), p); } - for (int i = 0; i < scu.getStatements().size(); i++) { - Statement statement = scu.getStatements().get(i); - visit(statement, p); + for (JRightPadded rp : scu.getPadding().getStatements()) { + visit(rp.getElement(), p); + visitSpace(rp.getAfter(), Space.Location.LANGUAGE_EXTENSION, p); + if (rp.getMarkers().findFirst(Semicolon.class).isPresent()) { + p.append(';'); + } } visitSpace(scu.getEof(), Space.Location.COMPILATION_UNIT_EOF, p); diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaASTConverter.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaASTConverter.scala index ebc77449703..09346c66893 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaASTConverter.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaASTConverter.scala @@ -35,12 +35,12 @@ import java.util.{Collections, List as JList} */ class CompilationUnitResult( val packageDecl: J.Package, - val statements: JList[Statement], + val statements: JList[JRightPadded[Statement]], val lastCursorPosition: Int ) { def getPackageDecl: J.Package = packageDecl - def getStatements: JList[Statement] = statements + def getStatements: JList[JRightPadded[Statement]] = statements def getLastCursorPosition: Int = lastCursorPosition } @@ -54,7 +54,7 @@ class ScalaASTConverter { * Converts a Scala parse result to compilation unit components. */ def convertToCompilationUnit(parseResult: ScalaParseResult, source: String, typeFactory: JavaTypeFactory = null): CompilationUnitResult = { - val statements = new util.ArrayList[Statement]() + val statements = new util.ArrayList[JRightPadded[Statement]]() var packageDecl: J.Package = null // Use the context from the parse result (carries type info from batch compilation) @@ -89,7 +89,7 @@ class ScalaASTConverter { case pkgDef: Trees.PackageDef[?] if isBracedPackage(pkgDef, visitor) => // A single top-level braced package is a scope that owns its body, so it // becomes a statement rather than the compilation unit's package header. - statements.add(buildBracedPackage(pkgDef, visitor)) + statements.add(JRightPadded.build(buildBracedPackage(pkgDef, visitor))) case pkgDef: Trees.PackageDef[?] => // Extract package declaration and create J.Package using the visitor // This ensures the cursor is properly updated @@ -120,18 +120,17 @@ class ScalaASTConverter { // Top-level import (no enclosing package). val converted = visitor.visitTree(imp) converted match { - case stmt: Statement => statements.add(stmt) + case stmt: Statement => statements.add(JRightPadded.build(stmt)) case null => // Skip null returns case _ => // Skip non-statements } case _ => // Single statement - System.out.println(s"Processing single statement: ${tree.getClass.getSimpleName}") val converted = visitor.visitTree(tree) converted match { case null => // Skip null returns case _: J.Empty => // Skip empty nodes - case stmt: Statement => statements.add(stmt) + case stmt: Statement => statements.add(JRightPadded.build(stmt)) case _ => // Skip non-statements } } @@ -145,28 +144,34 @@ class ScalaASTConverter { * Braced packages nested among the statements become [[S.PackageDeclaration]] so * they keep their own scope; non-braced nested packages are not yet modeled. */ - private def convertBody(rawStats: Seq[Trees.Tree[?]], visitor: ScalaTreeVisitor): util.ArrayList[Statement] = { - val out = new util.ArrayList[Statement]() + private def convertBody(rawStats: Seq[Trees.Tree[?]], visitor: ScalaTreeVisitor): util.ArrayList[JRightPadded[Statement]] = { + val out = new util.ArrayList[JRightPadded[Statement]]() // Sort by source position to ensure source order is preserved — // the Dotty parser may reorder brace imports internally. val sortedStats = rawStats.sortBy(s => if (s.span.exists) s.span.start else Int.MaxValue) - sortedStats.foreach { - case pkg: Trees.PackageDef[?] if isBracedPackage(pkg, visitor) => - out.add(buildBracedPackage(pkg, visitor)) - case _: Trees.PackageDef[?] => - // Non-braced nested package — not yet modeled, skip. - case imp: Trees.Import[?] => - visitor.visitTree(imp) match { - case stmt: Statement => out.add(stmt) - case _ => - } - case stat => - visitor.visitTree(stat) match { - case null => - case _: J.Empty => - case stmt: Statement => out.add(stmt) - case _ => - } + sortedStats.zipWithIndex.foreach { case (stat, idx) => + val converted: Statement = stat match { + case pkg: Trees.PackageDef[?] if isBracedPackage(pkg, visitor) => + buildBracedPackage(pkg, visitor) + case _: Trees.PackageDef[?] => + // Non-braced nested package — not yet modeled, skip. + null + case other => + visitor.visitTree(other) match { + case _: J.Empty => null + case stmt: Statement => stmt + case _ => null + } + } + if (converted != null) { + // An explicit `;` separating top-level statements rides on the padding + val statEnd = if (stat.span.exists) Math.max(0, stat.span.end - visitor.getOffsetAdjustment) else visitor.getCursor + val nextStart = sortedStats.drop(idx + 1).collectFirst { + case n if n.span.exists => Math.max(0, n.span.start - visitor.getOffsetAdjustment) + }.getOrElse(visitor.getSourceLength) + val (trailingSpace, markers) = visitor.consumeTrailingSemicolon(statEnd, nextStart) + out.add(new JRightPadded[Statement](converted, trailingSpace, markers)) + } } out } @@ -221,7 +226,7 @@ class ScalaASTConverter { val bodyStmts = convertBody(pkgDef.stats, visitor) val rpStmts = new util.ArrayList[JRightPadded[Statement]]() - bodyStmts.forEach(s => rpStmts.add(JRightPadded.build(s))) + rpStmts.addAll(bodyStmts) // Consume the space before `}` (becomes the block end) and the `}` itself. val afterStart = visitor.getCursor - srcOffset @@ -345,7 +350,9 @@ class ScalaASTConverter { * Converts a Scala parse result to a list of statements (backward compatibility). */ def convertToStatements(parseResult: ScalaParseResult, source: String): JList[Statement] = { - convertToCompilationUnit(parseResult, source, null).statements + val out = new util.ArrayList[Statement]() + convertToCompilationUnit(parseResult, source, null).statements.forEach(rp => out.add(rp.getElement)) + out } /** diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index a95b5767db0..56335c3c7c1 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -207,6 +207,8 @@ class ScalaTreeVisitor( def getOffsetAdjustment: Int = offsetAdjustment + def getSourceLength: Int = source.length + def updateCursor(position: Int): Unit = { val adjustedPosition = Math.max(0, position - offsetAdjustment) if (adjustedPosition > cursor && adjustedPosition <= source.length) { @@ -3896,11 +3898,27 @@ class ScalaTreeVisitor( containsTripleQuestion(stat) } if (stat.span.exists && !isSynth && visitedSpans.add(stat.span.start)) { - visitTree(stat) match { - case stmt: Statement => statements.add(JRightPadded.build(stmt)) - case expr: Expression => - statements.add(JRightPadded.build(new S.ExpressionStatement(Tree.randomId(), expr))) - case _ => + val statEnd = Math.max(0, stat.span.end - offsetAdjustment) + val visited: Statement = visitTree(stat) match { + case stmt: Statement => stmt + case expr: Expression => new S.ExpressionStatement(Tree.randomId(), expr) + case _ => null + } + if (visited != null) { + val nextStart = { + var k = sortedBody.indexOf(stat) + 1 + var ns = if (md.span.exists) Math.max(0, md.span.end - offsetAdjustment) else source.length + while (k < sortedBody.size) { + val nxt = sortedBody(k) + if (nxt.span.exists && !nxt.span.isSynthetic) { + ns = Math.max(0, nxt.span.start - offsetAdjustment) + k = sortedBody.size + } else k += 1 + } + ns + } + val (trailingSpace, rpMarkers) = consumeTrailingSemicolon(statEnd, nextStart) + statements.add(new JRightPadded[Statement](visited, trailingSpace, rpMarkers)) } } } @@ -9222,7 +9240,7 @@ class ScalaTreeVisitor( * separator on the same line and consume it. Returns the JRightPadded * trailing space and markers; advances `cursor`. */ - private def consumeTrailingSemicolon(statEnd: Int, nextStart: Int): (Space, Markers) = { + def consumeTrailingSemicolon(statEnd: Int, nextStart: Int): (Space, Markers) = { val trailStart = Math.max(statEnd, cursor) // When the statement's rhs sits on its own line, Dotty extends the statement // span to include the trailing `;`, so the cursor already moved past it. The diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/CompilationUnitTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/CompilationUnitTest.java index dd5f151a2b8..c9c3c0c3176 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/CompilationUnitTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/CompilationUnitTest.java @@ -593,4 +593,138 @@ void docCommentOnAnnotatedClassBelongsToTheClass() { ); } + @Test + void trailingSemicolonAfterTopLevelClass() { + rewriteRun( + scala( + """ + class X; + """ + ) + ); + } + + @Test + void semicolonBetweenTopLevelClasses() { + rewriteRun( + scala( + """ + class A; + class B + """ + ) + ); + } + + @Test + void trailingSemicolonAfterTopLevelImport() { + rewriteRun( + scala( + """ + import scala.collection.mutable; + class X + """ + ) + ); + } + + @Test + void semicolonAfterTopLevelVals() { + rewriteRun( + scala( + """ + val a = 1; + val b = 2; + """ + ) + ); + } + + @Test + void semicolonInObjectBody() { + rewriteRun( + scala( + """ + object O { + val a = 1; + def f(): Int = 1; + } + """ + ) + ); + } + + @Test + void commentAfterLastStatementInIndentedBody() { + rewriteRun( + scala( + """ + class X: + val a = 1 + // trailing + """ + ) + ); + } + + @Test + void commentAfterIndentedMethodBody() { + rewriteRun( + scala( + """ + object O: + def f(): Int = + 1 + // trailing + """ + ) + ); + } + + @Test + void commentAfterIndentedVal() { + rewriteRun( + scala( + """ + object O: + val x = + 1 + // note + """ + ) + ); + } + + @Test + void commentAfterIfExpression() { + rewriteRun( + scala( + """ + object O: + def f(b: Boolean): Int = + if b then + 1 + else + 2 + // done + """ + ) + ); + } + + @Test + void commentAfterMatch() { + rewriteRun( + scala( + """ + object O: + def f(i: Int): Int = + i match + case _ => 1 + // done + """ + ) + ); + } + } From 135c0a3a47f49c9261b379225063413a41ffe1e0 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sat, 15 Aug 2026 17:02:38 +0200 Subject: [PATCH 03/55] Keep a bare `*` out of the Scala comment buffer ScalaSpace.format treated every `*` that was not preceded by `/` as comment text, so a wildcard import reaching a Space lost its selector: `import core.*` printed as `import core.`. Only a `*` inside a block comment belongs in the comment buffer; elsewhere it is ordinary prefix text. This surfaced once the compilation unit's EOF space started parsing comments. Files with chained package clauses put their whole body in that space, because a non-braced nested package is not modeled, so the wildcard passed through the formatter. --- .../openrewrite/scala/internal/ScalaSpace.java | 5 ++++- .../org/openrewrite/scala/tree/ImportTest.java | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/internal/ScalaSpace.java b/rewrite-scala/src/main/java/org/openrewrite/scala/internal/ScalaSpace.java index cd657c85d45..44a930e5574 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/internal/ScalaSpace.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/internal/ScalaSpace.java @@ -109,8 +109,11 @@ public static Space format(String formatting, int beginIndex, int toIndex) { } else if (last == '/' && blockDepth > 0) { blockDepth++; comment.append(c); // the '/' is already in the comment buffer - } else { + } else if (blockDepth > 0) { comment.append(c); + } else { + // A bare `*` outside a comment, as in a `import a.*` wildcard + prefix.append(c); } break; default: diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ImportTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ImportTest.java index 98827b47766..5ab8a8cd3cc 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ImportTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ImportTest.java @@ -420,4 +420,20 @@ class B { val y = 2 } ) ); } + @Test + void wildcardImportAfterTrailingComment() { + // The `*` reaches the compilation unit's EOF space, which parses comments; + // a bare `*` there is not a block-comment delimiter. + rewriteRun( + scala( + """ + package p + + import core.* + // trailing + """ + ) + ); + } + } From 65c0140b007fc4943d61149df6e1dfcc7e155c99 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sat, 15 Aug 2026 17:22:05 +0200 Subject: [PATCH 04/55] Model chained package clauses instead of skipping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-braced nested package clause was skipped, so for package dotty.tools package dotc class X only the first clause became the package declaration and everything after it — imports, classes, the whole body — landed in the compilation unit's EOF space. The text round-tripped, so the print check stayed green while nothing in the file was reachable to a recipe. The idiom is pervasive in Scala codebases that mirror a deep directory layout. A chained clause scopes everything after it exactly as a braced one scopes its body, so it now becomes an S.PackageDeclaration whose block omits the braces, reusing the shape braced packages already use. Measured over 1,693 files of cats-effect and the scala3 library and compiler, counting a file as sound only when it parses and its LST holds no source in whitespace: 674 sound files before, 1,047 after. Parse errors rise from 74 to 205 because these bodies are now genuinely visited for the first time and meet defects that were previously hidden behind the whitespace, chiefly unmapped parenthesized function types and capture-checking syntax. --- .../scala/internal/ScalaASTConverter.scala | 36 ++++++++++++++++--- .../scala/tree/CompilationUnitTest.java | 32 +++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaASTConverter.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaASTConverter.scala index 09346c66893..338e2cd387b 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaASTConverter.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaASTConverter.scala @@ -21,7 +21,7 @@ import org.openrewrite.Tree import org.openrewrite.java.internal.JavaTypeFactory import org.openrewrite.java.tree.* import org.openrewrite.marker.Markers -import org.openrewrite.scala.marker.{IndentedSyntax, PackageSemicolon} +import org.openrewrite.scala.marker.{IndentedSyntax, OmitBraces, PackageSemicolon} import org.openrewrite.scala.tree.S import java.util @@ -153,9 +153,8 @@ class ScalaASTConverter { val converted: Statement = stat match { case pkg: Trees.PackageDef[?] if isBracedPackage(pkg, visitor) => buildBracedPackage(pkg, visitor) - case _: Trees.PackageDef[?] => - // Non-braced nested package — not yet modeled, skip. - null + case pkg: Trees.PackageDef[?] => + buildChainedPackage(pkg, visitor) case other => visitor.visitTree(other) match { case _: J.Empty => null @@ -200,6 +199,35 @@ class ScalaASTConverter { * (so nested/sibling braced packages are handled uniformly). The `{`/`}` are owned * by the body [[J.Block]]; the [[J.Package]] head carries only `package `. */ + /** + * A chained package clause (`package a` followed by `package b`) scopes everything after + * it, exactly as a braced one scopes its body, so it becomes an S.PackageDeclaration whose + * block omits the braces. + */ + private def buildChainedPackage(pkgDef: Trees.PackageDef[?], visitor: ScalaTreeVisitor): S.PackageDeclaration = { + val prefix = visitor.extractPrefix(pkgDef.span) + + val packageExpr: Expression = TypeTree.build(packageNameFromSource(pkgDef, visitor), '`') + val namePkg = new J.Package( + Tree.randomId(), + Space.EMPTY, + Markers.EMPTY, + packageExpr.withPrefix(Space.build(" ", Collections.emptyList())), + Collections.emptyList() + ) + visitor.updateCursor(pkgDef.pid.span.end) + + val body = new J.Block( + Tree.randomId(), + Space.EMPTY, + Markers.build(Collections.singletonList(new OmitBraces(Tree.randomId()))), + JRightPadded.build(false), + convertBody(pkgDef.stats, visitor), + Space.EMPTY + ) + new S.PackageDeclaration(Tree.randomId(), prefix, Markers.EMPTY, namePkg, body) + } + private def buildBracedPackage(pkgDef: Trees.PackageDef[?], visitor: ScalaTreeVisitor): S.PackageDeclaration = { val prefix = visitor.extractPrefix(pkgDef.span) diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/CompilationUnitTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/CompilationUnitTest.java index c9c3c0c3176..3bc2f545cf2 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/CompilationUnitTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/CompilationUnitTest.java @@ -727,4 +727,36 @@ def f(i: Int): Int = ); } + @Test + void chainedPackageClauses() { + rewriteRun( + scala( + """ + package a + package b + + class X + """ + ) + ); + } + + @Test + void chainedPackageClausesWithImportAndMembers() { + rewriteRun( + scala( + """ + package dotty.tools + package dotc + package cc + + import core.* + + class X + object O + """ + ) + ); + } + } From 5c1ac4a927c1de0674e85d7506461230f2457283 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sat, 15 Aug 2026 19:01:01 +0200 Subject: [PATCH 05/55] Model self-type clauses and `derives` clauses Both sit in a template between syntax the parser already handles, and neither had anywhere to live, so their source was absorbed into a Space: the LST round-tripped as text while hiding the clause from recipes. A self-type (`trait T { self => ... }`, `this: U =>`) is kept out of the body by dotty, so nothing claims it. It is captured between the body delimiter and the first statement, where a `=>` can only be a self type because any body statement starts later, and rides on the body block for class, trait and object bodies in both braced and indented form. A `derives` clause sits between the parent list and the body delimiter, so it is split off the text that would otherwise become the body's prefix. Over 1,693 files of cats-effect and the scala3 library and compiler, files that parse and hold no source in whitespace go from 1,047 to 1,172. --- .../org/openrewrite/scala/ScalaPrinter.java | 25 ++++++ .../scala/internal/ScalaTreeVisitor.scala | 60 +++++++++++++- .../scala/marker/ScalaMarkers.scala | 20 +++++ .../scala/tree/ClassDeclarationTest.java | 79 +++++++++++++++++++ 4 files changed, 180 insertions(+), 4 deletions(-) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index 3aafa0dd711..56a7f47f11e 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -1072,6 +1072,8 @@ public J visitClassDeclaration(J.ClassDeclaration classDecl, PrintOutputCapture< visitContainer(" permits", classDecl.getPadding().getPermits(), JContainer.Location.PERMITS, ",", "", p); } + classDecl.getMarkers().findFirst(org.openrewrite.scala.marker.DerivesClause.class) + .ifPresent(m -> p.append(m.text())); visit(classDecl.getBody(), p); afterSyntax(classDecl, p); return classDecl; @@ -1118,10 +1120,30 @@ private void visitTypeParameters(@Nullable JContainer typeParam @Override public J visitBlock(J.Block block, PrintOutputCapture

p) { + String selfType = block.getMarkers() + .findFirst(org.openrewrite.scala.marker.SelfType.class) + .map(org.openrewrite.scala.marker.SelfType::text) + .orElse(null); + if (selfType != null && + !block.getMarkers().findFirst(org.openrewrite.scala.marker.OmitBraces.class).isPresent() && + !block.getMarkers().findFirst(IndentedSyntax.class).isPresent()) { + // Braced body: the clause follows the `{` + beforeSyntax(block, Space.Location.BLOCK_PREFIX, p); + p.append('{'); + p.append(selfType); + visitStatements(block.getPadding().getStatements(), JRightPadded.Location.BLOCK_STATEMENT, p); + visitSpace(block.getEnd(), Space.Location.BLOCK_END, p); + p.append('}'); + afterSyntax(block, p); + return block; + } // OmitBraces blocks print statements without { } — used for braceless bodies, // synthetic lambda body blocks, and expression-position blocks if (block.getMarkers().findFirst(org.openrewrite.scala.marker.OmitBraces.class).isPresent()) { beforeSyntax(block, Space.Location.BLOCK_PREFIX, p); + if (selfType != null) { + p.append(selfType); + } visitStatements(block.getPadding().getStatements(), JRightPadded.Location.BLOCK_STATEMENT, p); visitSpace(block.getEnd(), Space.Location.BLOCK_END, p); afterSyntax(block, p); @@ -1131,6 +1153,9 @@ public J visitBlock(J.Block block, PrintOutputCapture

p) { if (block.getMarkers().findFirst(IndentedSyntax.class).isPresent()) { beforeSyntax(block, Space.Location.BLOCK_PREFIX, p); p.append(':'); + if (selfType != null) { + p.append(selfType); + } visitStatements(block.getPadding().getStatements(), JRightPadded.Location.BLOCK_STATEMENT, p); visitSpace(block.getEnd(), Space.Location.BLOCK_END, p); afterSyntax(block, p); diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 56335c3c7c1..95343dc7131 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -36,10 +36,12 @@ import org.openrewrite.scala.marker.IndentedSyntax import org.openrewrite.scala.marker.OmitBraces import org.openrewrite.scala.marker.OmitImportBraces import org.openrewrite.scala.marker.PackageObject +import org.openrewrite.scala.marker.DerivesClause import org.openrewrite.scala.marker.EndMarker import org.openrewrite.scala.marker.KindParameterVariance import org.openrewrite.scala.marker.ParentSeparator import org.openrewrite.scala.marker.SObject +import org.openrewrite.scala.marker.SelfType import org.openrewrite.scala.marker.Semicolon import org.openrewrite.scala.marker.TrailingComma import org.openrewrite.scala.marker.TypeProjection @@ -3886,6 +3888,8 @@ class ScalaTreeVisitor( val visitedSpans = new java.util.HashSet[Int]() // Sort by source position to preserve source order (Dotty may reorder imports) val sortedBody = tmpl.body.sortBy(s => if (s.span.exists) s.span.start else Int.MaxValue) + val moduleEnd = if (md.span.exists) Math.max(0, md.span.end - offsetAdjustment) else source.length + val selfTypeText = consumeSelfType(sortedBody, moduleEnd) sortedBody.foreach { stat => val isSynth = stat.span.isSynthetic || { def containsTripleQuestion(t: Trees.Tree[?]): Boolean = t match { @@ -3943,9 +3947,12 @@ class ScalaTreeVisitor( } } - val blockMarkers = if (isBraceless) { + var blockMarkers = if (isBraceless) { Markers.build(Collections.singletonList(new IndentedSyntax(Tree.randomId()))) } else Markers.EMPTY + if (selfTypeText != null) { + blockMarkers = blockMarkers.add(SelfType(Tree.randomId(), selfTypeText)) + } new J.Block( Tree.randomId(), @@ -4907,6 +4914,7 @@ class ScalaTreeVisitor( val hasAnnotations = td.mods.annotations.nonEmpty // Set while extracting the body, read when the declaration's markers are built var endMarkerText: String = null + var derivesText: String = null // Handle annotations first val leadingAnnotations = new util.ArrayList[J.Annotation]() @@ -5452,12 +5460,12 @@ class ScalaTreeVisitor( result } if (braceIndex >= 0 && (colonIndex < 0 || braceIndex < colonIndex)) { - val prefix = Space.format(afterCursor.substring(0, braceIndex)) + val prefix = splitDerives(afterCursor.substring(0, braceIndex), t => derivesText = t) cursor = cursor + braceIndex + 1 prefix } else if (colonIndex >= 0) { isClassBraceless = true - val prefix = Space.format(afterCursor.substring(0, colonIndex)) + val prefix = splitDerives(afterCursor.substring(0, colonIndex), t => derivesText = t) cursor = cursor + colonIndex + 1 prefix } else { @@ -5480,6 +5488,9 @@ class ScalaTreeVisitor( // Sort by source position to preserve source order val sortedBody = template.body.sortBy(s => if (s.span.exists) s.span.start else Int.MaxValue) val classEndForBody = if (td.span.exists) Math.max(0, td.span.end - offsetAdjustment) else source.length + // A self-type clause (`self =>`, `this: T =>`) sits between the body delimiter and the + // first statement. Dotty keeps it out of the body, so nothing else claims the source. + val selfTypeText = consumeSelfType(sortedBody, classEndForBody) for (idx <- sortedBody.indices) { val stat = sortedBody(idx) val isSyntheticStat = stat.span.isSynthetic || { @@ -5553,9 +5564,12 @@ class ScalaTreeVisitor( } else Space.EMPTY } else Space.EMPTY - val classBlockMarkers = if (isClassBraceless) { + var classBlockMarkers = if (isClassBraceless) { Markers.build(Collections.singletonList(new IndentedSyntax(Tree.randomId()))) } else Markers.EMPTY + if (selfTypeText != null) { + classBlockMarkers = classBlockMarkers.add(SelfType(Tree.randomId(), selfTypeText)) + } new J.Block( Tree.randomId(), @@ -5584,6 +5598,9 @@ class ScalaTreeVisitor( if (endMarkerText != null) { classDeclMarkers = classDeclMarkers.add(EndMarker(Tree.randomId(), endMarkerText)) } + if (derivesText != null) { + classDeclMarkers = classDeclMarkers.add(DerivesClause(Tree.randomId(), derivesText)) + } new J.ClassDeclaration( Tree.randomId(), @@ -9135,6 +9152,41 @@ class ScalaTreeVisitor( } } + /** Splits a `derives` clause out of the text ahead of a body delimiter, handing it to + * {@code claim}. What remains is whitespace and belongs in the body's prefix. + */ + private def splitDerives(text: String, claim: String => Unit): Space = { + val idx = positionOfNextIn(text, "derives", 0) + if (idx < 0) Space.format(text) + else { + // the clause keeps the space ahead of it; only the run before the delimiter is prefix + val clauseEnd = text.stripTrailing().length + claim(text.substring(0, clauseEnd)) + Space.format(text.substring(clauseEnd)) + } + } + + /** Consumes a template's self-type clause, returning its verbatim source or null. + * The `=>` can only belong to a self type here: any body statement starts later. + */ + private def consumeSelfType(sortedBody: Seq[Trees.Tree[?]], bodyEnd: Int): String = { + val firstStatStart = sortedBody.collectFirst { + case st if st.span.exists && !st.span.isSynthetic => Math.max(0, st.span.start - offsetAdjustment) + }.getOrElse(bodyEnd) + if (cursor >= firstStatStart || firstStatStart > source.length) { + null + } else { + val between = source.substring(cursor, firstStatStart) + val arrow = positionOfNextIn(between, "=>", 0) + if (arrow < 0) null + else { + val text = between.substring(0, arrow + 2) + cursor = cursor + arrow + 2 + text + } + } + } + /** Locates a Scala 3 end marker (`end foo`, `end if`) in {@code source} between the * cursor and {@code limit}, as (offset of `end`, marker text). None when absent. * Dotty's spans cover a trailing end marker, so capture it before advancing the cursor. diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala index c4a66f8b78b..d70d8db112a 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala @@ -138,6 +138,26 @@ case class ExtraConstructorParamLists(id: UUID, text: String) extends Marker { override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] } +/** + * A Scala 3 `derives` clause, as in `case class C(i: Int) derives CanEqual`. Holds the + * verbatim source from `derives` up to the body delimiter, which the printer emits + * between the parent list and the body. + */ +case class DerivesClause(id: UUID, text: String) extends Marker { + override def getId(): UUID = id + override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] +} + +/** + * A self-type clause opening a template body, as in `trait T { self => ... }` or + * `trait T:\n this: U =>`. Holds the verbatim source from the body delimiter through + * the `=>`. Dotty keeps the clause out of the body, so there is no statement to map it to. + */ +case class SelfType(id: UUID, text: String) extends Marker { + override def getId(): UUID = id + override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] +} + /** * A Scala 3 end marker closing a definition, e.g. the `end X` of `class X: ... end X`. * Holds the verbatim source from the end of the element's own content through the diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java index 687b38837ac..8d334ce6a9f 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java @@ -720,4 +720,83 @@ def f(): Int = ); } + @Test + void selfTypeAlias() { + rewriteRun( + scala( + """ + trait T { self => + def f(): Int = 1 + } + """ + ) + ); + } + + @Test + void selfTypeAnnotation() { + rewriteRun( + scala( + """ + trait U + trait T { this: U => + def f(): Int = 1 + } + """ + ) + ); + } + + @Test + void selfTypeInIndentedBody() { + rewriteRun( + scala( + """ + trait U + trait T: + this: U => + def f(): Int = 1 + """ + ) + ); + } + + @Test + void selfTypeOnObject() { + rewriteRun( + scala( + """ + object O { self => + val x = 1 + } + """ + ) + ); + } + + @Test + void derivesClause() { + rewriteRun( + scala( + """ + case class C(i: Int) derives CanEqual { + def f(): Int = i + } + """ + ) + ); + } + + @Test + void derivesMultipleWithIndentedBody() { + rewriteRun( + scala( + """ + case class C(i: Int) derives CanEqual, Show: + def f(): Int = i + """ + ) + ); + } + } From 49edc00c54a33db735f0ef734a466f4d951bf58a Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sat, 15 Aug 2026 19:11:44 +0200 Subject: [PATCH 06/55] Map a parenthesized template parent as a type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new (Int => Unit) { ... }` and `class C extends T with (Int => Unit)` put a parenthesized function type in a parent position. Dotty parses the parentheses as untpd.Parens, which the visitor mapped in expression position to J.Parentheses — not a TypeTree — so a parent list either threw "Unmapped Scala AST node: Parens" or failed casting to TypeTree, taking the whole file with it. A parenthesized parent is a type, so it is now visited in type position and kept parenthesized as a J.ParenthesizedTypeTree. Applied to the three parent sites: a sole parent, a mixin in `new X with ...`, and a parent in a class declaration. --- .../scala/internal/ScalaTreeVisitor.scala | 52 ++++++++++++++----- .../scala/tree/ClassDeclarationTest.java | 28 ++++++++++ .../openrewrite/scala/tree/NewClassTest.java | 31 +++++++++++ 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 95343dc7131..ecfd5c62e1f 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -2185,7 +2185,8 @@ class ScalaTreeVisitor( } } else { // Simple interface/trait: new Runnable { ... } - val typeTree = visitTree(firstParent).asInstanceOf[TypeTree] + val typeTree = visitParentType(firstParent) + if (typeTree == null) throw unmappedException(firstParent) (typeTree, null) } } @@ -2201,16 +2202,12 @@ class ScalaTreeVisitor( sourceBefore("with"), Markers.EMPTY)) for (i <- 1 until parents.size - 1) { - val tt = visitTree(parents(i)) match { - case t: TypeTree => t - case _ => throw unmappedException(parents(i)) - } + val tt = visitParentType(parents(i)) + if (tt == null) throw unmappedException(parents(i)) mixinElements.add(new JRightPadded[TypeTree](tt, sourceBefore("with"), Markers.EMPTY)) } - val lastTt = visitTree(parents.last) match { - case t: TypeTree => t - case _ => throw unmappedException(parents.last) - } + val lastTt = visitParentType(parents.last) + if (lastTt == null) throw unmappedException(parents.last) mixinElements.add(JRightPadded.build(lastTt)) new J.IntersectionType( Tree.randomId(), @@ -5361,11 +5358,13 @@ class ScalaTreeVisitor( val parent = sourceParents(i) val savedCursorWith = cursor - val implType: TypeTree = parentTypeTree(visitTree(parent)) match { - case tt: TypeTree => tt - case null => - cursor = savedCursorWith - throw unmappedException(parent) + val implType: TypeTree = parent match { + case _: untpd.Parens => visitParentType(parent) + case _ => parentTypeTree(visitTree(parent)) + } + if (implType == null) { + cursor = savedCursorWith + throw unmappedException(parent) } val sepMarkers = if (i - 1 < separators.size) { @@ -8956,6 +8955,31 @@ class ScalaTreeVisitor( * arguments stay first-class LST nodes instead of being crammed into a * `J.Identifier` name. Returns null when no rich mapping fits, so callers can fall back. */ + /** Visits a template parent. A parenthesized parent, as in `new X with (A => B)`, is a + * type rather than an expression, so it is visited in type position and stays + * parenthesized. Returns null when the parent maps to no type. + */ + private def visitParentType(tree: Trees.Tree[?]): TypeTree = tree match { + case p: untpd.Parens => + val prefix = extractPrefix(p.span) + val openIdx = positionOfNext("(", cursor) + if (openIdx >= 0) cursor = openIdx + 1 + val inner = visitTypeTree(p.t) + if (inner == null) null + else { + val beforeClose = sourceBefore(")") + new J.ParenthesizedTypeTree(Tree.randomId(), prefix, Markers.EMPTY, + Collections.emptyList(), + new J.Parentheses[TypeTree](Tree.randomId(), Space.EMPTY, Markers.EMPTY, + new JRightPadded[TypeTree](inner, beforeClose, Markers.EMPTY))) + } + case other => + visitTree(other) match { + case t: TypeTree => t + case _ => null + } + } + private def parentTypeTree(visited: J): TypeTree = visited match { case tt: TypeTree => tt case nc: J.NewClass if nc.getClazz != null && nc.getPadding.getArguments != null => diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java index 8d334ce6a9f..98a5b76855b 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java @@ -799,4 +799,32 @@ def f(): Int = i ); } + @Test + void parentIsParenthesizedFunctionType() { + rewriteRun( + scala( + """ + trait T + class C extends T with (Int => Unit) { + def apply(i: Int): Unit = () + } + """ + ) + ); + } + + @Test + void parentIsParenthesizedByNameFunctionType() { + rewriteRun( + scala( + """ + trait T + class C extends T with (() => Unit) { + def apply(): Unit = () + } + """ + ) + ); + } + } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java index ea8de4a1812..0e94947d764 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java @@ -364,4 +364,35 @@ class Foo(a: Int)(b: Int) ) ); } + @Test + void soleParentIsParenthesizedFunctionType() { + rewriteRun( + scala( + """ + object O { + val f = new (Int => Unit) { + def apply(i: Int): Unit = () + } + } + """ + ) + ); + } + + @Test + void mixinIsParenthesizedFunctionType() { + rewriteRun( + scala( + """ + class B + object O { + val h = new B with (Int => Unit) { + def apply(i: Int): Unit = () + } + } + """ + ) + ); + } + } From 97bc82bcb2756f47bccbb01d5a15b4b2ca3deb84 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sat, 15 Aug 2026 19:33:55 +0200 Subject: [PATCH 07/55] Recognize Scala 3 definition modifiers and a bare constructor modifier `transparent`, `inline`, `opaque` and `infix` were missing from the table of definition-level modifier keywords, so their source was swept into a Space and hidden from recipes. A primary constructor's access modifier was only recognized when a parameter list followed it, so `final abstract class Byte private extends AnyVal` lost the `private` the same way. The modifier is now claimed whether or not a parameter list follows, and the printer emits it in both cases. `open` is left out: dotty does not surface it the same way and adding it duplicated the declaration on print. --- .../org/openrewrite/scala/ScalaPrinter.java | 9 ++++-- .../scala/internal/ScalaTreeVisitor.scala | 17 +++++++++-- .../scala/tree/ClassDeclarationTest.java | 28 +++++++++++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index 56a7f47f11e..904adc06715 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -980,12 +980,15 @@ public J visitClassDeclaration(J.ClassDeclaration classDecl, PrintOutputCapture< // J.VariableDeclarations modeled like a Scala parameter (no implicit val/var, // type comes after the name with `:`). We can't fall through to visitVariableDeclarations // because that one is for field/local declarations and always emits a val/var keyword. + if (classDecl.getPadding().getPrimaryConstructor() != null) { + // The modifier can stand alone, without a parameter list + classDecl.getPadding().getPrimaryConstructor().getMarkers() + .findFirst(org.openrewrite.scala.marker.ConstructorModifier.class) + .ifPresent(m -> p.append(m.text())); + } if (classDecl.getPadding().getPrimaryConstructor() != null && !classDecl.getPadding().getPrimaryConstructor().getMarkers().findFirst(OmitParentheses.class).isPresent()) { JContainer primaryConstructor = classDecl.getPadding().getPrimaryConstructor(); - primaryConstructor.getMarkers() - .findFirst(org.openrewrite.scala.marker.ConstructorModifier.class) - .ifPresent(m -> p.append(m.text())); visitSpace(primaryConstructor.getBefore(), Space.Location.RECORD_STATE_VECTOR, p); p.append('('); List> ctorElements = primaryConstructor.getPadding().getElements(); diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index ecfd5c62e1f..3ff24888564 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -5274,8 +5274,15 @@ class ScalaTreeVisitor( } else { // No `(` in source — non-constructor class definition. Emit an empty container // marked with OmitParentheses so the printer skips emitting `(...)`. - JContainer.build(Space.EMPTY, new util.ArrayList[JRightPadded[Statement]](), - Markers.build(Collections.singletonList(new OmitParentheses(Tree.randomId())))) + // The modifier can still stand alone, as in `class Byte private extends AnyVal`. + val ctorMarkers = new util.ArrayList[org.openrewrite.marker.Marker]() + ctorMarkers.add(new OmitParentheses(Tree.randomId())) + if (afterCtorModifier > cursor) { + ctorMarkers.add(org.openrewrite.scala.marker.ConstructorModifier( + Tree.randomId(), source.substring(cursor, afterCtorModifier))) + cursor = afterCtorModifier + } + JContainer.build(Space.EMPTY, new util.ArrayList[JRightPadded[Statement]](), Markers.build(ctorMarkers)) } // Extract extends/implements from Template @@ -9439,7 +9446,11 @@ class ScalaTreeVisitor( ("override", J.Modifier.Type.LanguageExtension), ("implicit", J.Modifier.Type.LanguageExtension), ("sealed", J.Modifier.Type.Sealed), - ("lazy", J.Modifier.Type.LanguageExtension) + ("lazy", J.Modifier.Type.LanguageExtension), + ("transparent", J.Modifier.Type.LanguageExtension), + ("inline", J.Modifier.Type.LanguageExtension), + ("opaque", J.Modifier.Type.LanguageExtension), + ("infix", J.Modifier.Type.LanguageExtension) ) /** Modifiers legal on a class/trait primary-constructor parameter. */ diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java index 98a5b76855b..30f86921684 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java @@ -827,4 +827,32 @@ def apply(): Unit = () ); } + @Test + void constructorModifierWithoutParameterList() { + rewriteRun( + scala( + """ + final abstract class Byte private extends AnyVal { + def toByte: Byte + } + """ + ) + ); + } + + @Test + void scala3Modifiers() { + rewriteRun( + scala( + """ + object O { + transparent inline def f(): Int = 1 + private inline def g(): Int = 2 + infix def and(o: Int): Int = o + } + """ + ) + ); + } + } From 88f301b99a4240e8c2ca145331431e0d2a9fb972 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sat, 15 Aug 2026 19:46:29 +0200 Subject: [PATCH 08/55] Keep a capture-set suffix on the type it follows Scala 3 capture checking writes a capture set as a suffix, `IterableOnce[A]^` or `C^{it}`. Dotty desugars it to a synthetic `retains` annotation that has no `@` in source, so converting the annotation to a J.Annotation threw and took the whole file with it: this was the largest single family of parse failures. The suffix is now recognized where it sits and kept verbatim on the type it follows, leaving annotated types and annotated expressions on their existing paths. Over 1,693 files of cats-effect and the scala3 library and compiler, parse errors drop from 202 to 167. The `^{...}` form parses but still hides the `=` that follows a return type carrying one, so those files remain unsound. That is an improvement on failing to parse at all, and the bare `^` form round-trips. --- .../org/openrewrite/scala/ScalaPrinter.java | 2 + .../scala/internal/ScalaTreeVisitor.scala | 40 +++++++++++++++++++ .../scala/marker/ScalaMarkers.scala | 10 +++++ .../scala/tree/AnnotatedExprTest.java | 28 +++++++++++++ 4 files changed, 80 insertions(+) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index 904adc06715..11830913459 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -1092,6 +1092,8 @@ protected void afterSyntax(J j, PrintOutputCapture

p) { // A Scala 3 end marker closes the element it is attached to, after its body j.getMarkers().findFirst(org.openrewrite.scala.marker.EndMarker.class) .ifPresent(m -> p.append(m.text())); + j.getMarkers().findFirst(org.openrewrite.scala.marker.CaptureSet.class) + .ifPresent(m -> p.append(m.text())); super.afterSyntax(j, p); } diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 3ff24888564..60e03563ff3 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -54,6 +54,7 @@ import org.openrewrite.scala.marker.TypeAscription import org.openrewrite.scala.marker.UnderscorePlaceholderLambda import org.openrewrite.scala.marker.PartialFunctionLiteral import org.openrewrite.scala.marker.ContextFunctionArrow +import org.openrewrite.scala.marker.CaptureSet import org.openrewrite.scala.marker.Curried import org.openrewrite.scala.marker.InfixNotation import org.openrewrite.scala.marker.RightAssociative @@ -7881,6 +7882,27 @@ class ScalaTreeVisitor( // the latter. val prefix = extractPrefix(ann.span) val arg: J = visitTree(ann.arg) + // Capture-checking syntax (`T^`, `T^{it}`) desugars to a synthetic `retains` annotation + // with no `@` in source, so it stays a suffix on the type it follows. + val captureText = consumeCaptureSet() + if (captureText != null) { + updateCursor(ann.span.end) + // The wrapped type carries the space ahead of it. Callers that position the cursor + // at the type leave the extracted prefix empty, so recover it from the source. + val typePrefix = if (prefix != Space.EMPTY) prefix else { + val start = Math.max(0, ann.span.start - offsetAdjustment) + var b = start + while (b > 0 && (source.charAt(b - 1) == ' ' || source.charAt(b - 1) == '\t')) b -= 1 + if (b < start) Space.format(source.substring(b, start)) else Space.EMPTY + } + return arg match { + case tt: TypeTree => + val prefixed: TypeTree = tt.withPrefix[TypeTree](typePrefix) + prefixed.withMarkers[TypeTree]( + prefixed.getMarkers.add(CaptureSet(Tree.randomId(), captureText))).asInstanceOf[J] + case other => other + } + } val annotStart = Math.max(0, ann.annot.span.start - offsetAdjustment) val between = if (cursor < annotStart && annotStart <= source.length) source.substring(cursor, annotStart) else "" val colonIdx = positionOfNextIn(between, ":", 0) @@ -8962,6 +8984,24 @@ class ScalaTreeVisitor( * arguments stay first-class LST nodes instead of being crammed into a * `J.Identifier` name. Returns null when no rich mapping fits, so callers can fall back. */ + /** Consumes a capture-set suffix (`^`, `^{it}`) at the cursor, returning its source. */ + private def consumeCaptureSet(): String = { + var i = cursor + while (i < source.length && (source.charAt(i) == ' ' || source.charAt(i) == '\t')) i += 1 + if (i >= source.length || source.charAt(i) != '^') { + null + } else { + var end = i + 1 + if (end < source.length && source.charAt(end) == '{') { + val close = positionOfMatchingClose('{', '}', end + 1) + if (close >= 0) end = close + 1 + } + val text = source.substring(cursor, end) + cursor = end + text + } + } + /** Visits a template parent. A parenthesized parent, as in `new X with (A => B)`, is a * type rather than an expression, so it is visited in type position and stays * parenthesized. Returns null when the parent maps to no type. diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala index d70d8db112a..6bf3b29539d 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala @@ -148,6 +148,16 @@ case class DerivesClause(id: UUID, text: String) extends Marker { override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] } +/** + * A Scala 3 capture set written as a suffix on a type: the `^` of `IterableOnce[A]^` or + * the `^{it}` of `C^{it}`. Dotty desugars it to a synthetic `retains` annotation that has + * no `@` in source, so the suffix is kept verbatim and printed after the type. + */ +case class CaptureSet(id: UUID, text: String) extends Marker { + override def getId(): UUID = id + override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] +} + /** * A self-type clause opening a template body, as in `trait T { self => ... }` or * `trait T:\n this: U =>`. Holds the verbatim source from the body delimiter through diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java index 8b99b4fad8f..c86d796f91c 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java @@ -54,4 +54,32 @@ def f(x: Any): String = (x: @unchecked) match { ) ); } + @Test + void captureSetSuffixOnType() { + rewriteRun( + scala( + """ + import language.experimental.captureChecking + trait T { + def f(xs: IterableOnce[Int]^): Int = 1 + } + """ + ) + ); + } + + @Test + void captureSetSuffixInReturnType() { + rewriteRun( + scala( + """ + import language.experimental.captureChecking + trait T { + def f(): Iterator[Int]^ = Iterator.empty + } + """ + ) + ); + } + } From 5b4051041a38560ddfe9c992dd9c62a8e7abbc89 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sat, 15 Aug 2026 21:11:43 +0200 Subject: [PATCH 09/55] Do not mistake a capture set's brace for a procedure-syntax body Scala 2 procedure syntax is detected by scanning for whether a `{` or an `=` comes first after the method name. A capture set writes a brace in the return type, so `def f(): List[Int]^{this} = Nil` looked like `def f() { ... }` and the `=` was never consumed, leaving it inside the body's prefix. The scan already skips parameter and type-argument brackets; it now skips a capture set the same way. --- .../scala/internal/ScalaTreeVisitor.scala | 5 +++++ .../openrewrite/scala/tree/AnnotatedExprTest.java | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 60e03563ff3..07668168727 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -9002,6 +9002,7 @@ class ScalaTreeVisitor( } } + /** Visits a template parent. A parenthesized parent, as in `new X with (A => B)`, is a * type rather than an expression, so it is visited in type position and stays * parenthesized. Returns null when the parent maps to no type. @@ -9346,6 +9347,10 @@ class ScalaTreeVisitor( else if (c == '(' || c == '[') { val close = positionOfMatchingClose(c, if (c == '(') ')' else ']', i + 1) i = if (close >= 0) close + 1 else end + } else if (c == '^' && i + 1 < end && source.charAt(i + 1) == '{') { + // a capture set's brace belongs to the return type, not the body + val close = positionOfMatchingClose('{', '}', i + 2) + i = if (close >= 0) close + 1 else end } else if (c == '{') return true else if (c == '=') return false else i += 1 diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java index c86d796f91c..1a49e73f6e1 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java @@ -82,4 +82,18 @@ def f(): Iterator[Int]^ = Iterator.empty ); } + @Test + void captureSetWithExplicitSet() { + rewriteRun( + scala( + """ + import language.experimental.captureChecking + trait T { + def f(): List[Int]^{this} = Nil + } + """ + ) + ); + } + } From 83d035a6690f2ba2342f6e35a2447d9b6f6b2340 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sat, 15 Aug 2026 21:33:25 +0200 Subject: [PATCH 10/55] Keep a call-site `using` keyword with the argument list `f(using ctx)` puts the keyword between the `(` and the first argument, where nothing claimed it, so it was absorbed into that argument's prefix and hidden from recipes. It was the largest single cause of unsound trees. The keyword rides on the first argument rather than on the argument container, because every printer path reaches an argument through `visit`, while the container is opened by half a dozen different call sites. One override of `beforeSyntax` then emits it wherever the arguments are printed. Covers a plain call, a call on a select, a curried call's later list, several arguments, and the keyword on its own line. Over 1,693 files of cats-effect and the scala3 library and compiler, files that parse and hold no source in whitespace go from 1,222 to 1,300. --- .../org/openrewrite/scala/ScalaPrinter.java | 9 +++ .../scala/internal/ScalaTreeVisitor.scala | 36 ++++++++++++ .../scala/marker/ScalaMarkers.scala | 10 ++++ .../scala/tree/MethodInvocationTest.java | 57 +++++++++++++++++++ 4 files changed, 112 insertions(+) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index 11830913459..51aee241291 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -21,6 +21,7 @@ import org.openrewrite.Tree; import org.openrewrite.java.JavaPrinter; import org.openrewrite.java.marker.ImplicitReturn; +import org.openrewrite.marker.Markers; import org.openrewrite.java.marker.OmitParentheses; import org.openrewrite.java.marker.Quoted; import org.openrewrite.java.tree.Expression; @@ -1087,6 +1088,14 @@ public J visitClassDeclaration(J.ClassDeclaration classDecl, PrintOutputCapture< } } + @Override + protected void beforeSyntax(Space prefix, Markers markers, Space.@Nullable Location loc, PrintOutputCapture

p) { + // `f(using ctx)`: the keyword opens the argument list, ahead of the first argument + markers.findFirst(org.openrewrite.scala.marker.UsingArguments.class) + .ifPresent(m -> p.append(m.text())); + super.beforeSyntax(prefix, markers, loc, p); + } + @Override protected void afterSyntax(J j, PrintOutputCapture

p) { // A Scala 3 end marker closes the element it is attached to, after its body diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 07668168727..a0ce4f4fe20 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -44,6 +44,7 @@ import org.openrewrite.scala.marker.SObject import org.openrewrite.scala.marker.SelfType import org.openrewrite.scala.marker.Semicolon import org.openrewrite.scala.marker.TrailingComma +import org.openrewrite.scala.marker.UsingArguments import org.openrewrite.scala.marker.TypeProjection import org.openrewrite.scala.marker.ScalaForLoop import org.openrewrite.scala.marker.BlockArgument @@ -497,6 +498,7 @@ class ScalaTreeVisitor( val openIdx = positionOfNext("(", cursor) argContainerPrefix = if (openIdx > cursor) ScalaSpace.format(source, cursor, openIdx) else Space.EMPTY if (openIdx >= 0) cursor = openIdx + 1 + val usingText1 = consumeUsingKeyword() for (i <- app.args.indices) { val arg = app.args(i) val argExpr = asExpression(visitTree(arg)) @@ -514,6 +516,7 @@ class ScalaTreeVisitor( } val closeParen = positionOfNext(")", cursor) if (closeParen >= 0) cursor = closeParen + 1 + withUsing(args, usingText1) } updateCursor(app.span.end) @@ -849,6 +852,7 @@ class ScalaTreeVisitor( val args = new util.ArrayList[JRightPadded[Expression]]() var argContainerPrefix = Space.EMPTY + var usingText2: String = null val markers = new util.ArrayList[org.openrewrite.marker.Marker]() import org.openrewrite.scala.marker.FunctionApplication markers.add(FunctionApplication.create()) @@ -896,6 +900,7 @@ class ScalaTreeVisitor( } cursor = parenPos + 1 } + usingText2 = consumeUsingKeyword() for ((arg, i) <- app.args.zipWithIndex) { val visited = visitTree(arg) @@ -933,6 +938,7 @@ class ScalaTreeVisitor( } val methodName = ident("apply") + withUsing(args, usingText2) new J.MethodInvocation( Tree.randomId(), @@ -1088,6 +1094,7 @@ class ScalaTreeVisitor( val functionAfterSpace = if (firstNonWs > cursor) ScalaSpace.format(source, cursor, firstNonWs) else Space.EMPTY if (firstNonWs < source.length) cursor = firstNonWs + 1 // past `(` + val fnCallUsing = consumeUsingKeyword() for (i <- app.args.indices) { val arg = app.args(i) @@ -1108,6 +1115,7 @@ class ScalaTreeVisitor( val closeParen = positionOfNext(")", cursor) if (closeParen >= 0) cursor = closeParen + 1 finishAtAppEnd() + withUsing(outerArgs, fnCallUsing) return S.FunctionCall.build(Tree.randomId(), prefix, Markers.EMPTY, new JRightPadded(fn, functionAfterSpace, Markers.EMPTY), JContainer.build(Space.EMPTY, outerArgs, Markers.EMPTY), methodType) @@ -1123,6 +1131,7 @@ class ScalaTreeVisitor( val select = asExpression(visitTree(app.fun)) val openIdx = positionOfNext("(", cursor) if (openIdx >= 0) cursor = openIdx + 1 + val curriedUsing = consumeUsingKeyword() val outerArgs = new util.ArrayList[JRightPadded[Expression]]() for (i <- app.args.indices) { val arg = app.args(i) @@ -1145,6 +1154,7 @@ class ScalaTreeVisitor( val end = Math.max(0, app.span.end - offsetAdjustment) if (end > cursor && end <= source.length) cursor = end } + withUsing(outerArgs, curriedUsing) val mt = typeFor(app.span) match { case m: JavaType.Method => m; case _ => null } val nameId = new J.Identifier(Tree.randomId(), Space.EMPTY, Markers.EMPTY, Collections.emptyList(), "apply", null, null) @@ -1164,6 +1174,7 @@ class ScalaTreeVisitor( val isColonArg = !isBlockArg && firstArgNonWs >= 0 && firstArgNonWs < source.length && source.charAt(firstArgNonWs) == ':' + var usingText3: String = null var argContainerPrefix = Space.EMPTY val args = new util.ArrayList[JRightPadded[Expression]]() @@ -1211,6 +1222,7 @@ class ScalaTreeVisitor( } cursor = parenPos + 1 } + usingText3 = consumeUsingKeyword() } for (i <- app.args.indices) { @@ -1270,6 +1282,7 @@ class ScalaTreeVisitor( val name = ident(methodName, nameSpace, quoted = methodNameQuoted) + withUsing(args, usingText3) val argContainer = JContainer.build( argContainerPrefix, args, @@ -9003,6 +9016,29 @@ class ScalaTreeVisitor( } + /** Consumes a call-site `using` keyword at the cursor, returning the source through it. */ + private def consumeUsingKeyword(): String = { + var i = cursor + while (i < source.length && source.charAt(i).isWhitespace) i += 1 + val end = i + "using".length + if (end <= source.length && source.startsWith("using", i) && + (end >= source.length || !(Character.isLetterOrDigit(source.charAt(end)) || source.charAt(end) == '_'))) { + val text = source.substring(cursor, end) + cursor = end + text + } else null + } + + /** Attaches a consumed `using` keyword to the first argument, which every printer path emits. */ + private def withUsing(args: util.ArrayList[JRightPadded[Expression]], text: String): Unit = { + if (text != null && !args.isEmpty) { + val first = args.get(0) + val elem = first.getElement + val marked = elem.withMarkers[Expression](elem.getMarkers.add(UsingArguments(Tree.randomId(), text))) + args.set(0, first.withElement(marked)) + } + } + /** Visits a template parent. A parenthesized parent, as in `new X with (A => B)`, is a * type rather than an expression, so it is visited in type position and stays * parenthesized. Returns null when the parent maps to no type. diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala index 6bf3b29539d..7ecb2315881 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala @@ -158,6 +158,16 @@ case class CaptureSet(id: UUID, text: String) extends Marker { override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] } +/** + * The `using` keyword opening a call-site argument list, as in `f(using ctx)`. Carried by + * the first argument, which every printer path emits, and holds the source from the `(` + * through the keyword. + */ +case class UsingArguments(id: UUID, text: String) extends Marker { + override def getId(): UUID = id + override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] +} + /** * A self-type clause opening a template body, as in `trait T { self => ... }` or * `trait T:\n this: U =>`. Holds the verbatim source from the body delimiter through diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java index e46c8252f36..30715b17fa0 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java @@ -514,4 +514,61 @@ def g(): Seq[Int] = Seq(1, 2) ); } } + @Test + void usingArgument() { + rewriteRun( + scala( + """ + object O { + def f(using s: String): Int = 1 + val r = f(using "a") + } + """ + ) + ); + } + + @Test + void usingArgumentInCurriedCall() { + rewriteRun( + scala( + """ + object O { + def f(x: Int)(using s: String): Int = x + val r = f(1)(using "a") + } + """ + ) + ); + } + + @Test + void usingArgumentOnSelect() { + rewriteRun( + scala( + """ + class C { def g(using s: String): Int = 1 } + object O { + val c = new C + val r = c.g(using "a") + } + """ + ) + ); + } + + @Test + void usingArgumentsMultiple() { + rewriteRun( + scala( + """ + object O { + def f(using a: Int, b: Int): Int = a + val r = f(using 1, 2) + } + """ + ) + ); + } + } From 5f6458bada9abe1fe33609f3ced5419114924afe Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sat, 15 Aug 2026 23:25:24 +0200 Subject: [PATCH 11/55] Recognize `inline`, `transparent` and `opaque` on val and given definitions The definition-level modifier table already knew these keywords, but a val, var or given runs through its own hand-rolled modifier scan, which stopped at the Scala 2 set. Anything it did not recognize stayed in the whitespace ahead of the keyword, so `inline val X = 8` hid the modifier from recipes. The scan now handles the same three keywords, following the shape of the branches beside it. `inline` on a parameter is still absorbed; that position is read through a different path and the untyped tree carries no flag for it. --- .../scala/internal/ScalaTreeVisitor.scala | 16 +++++++++++ .../scala/tree/VariableDeclarationsTest.java | 27 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index a0ce4f4fe20..e9b0abc79af 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -3196,6 +3196,21 @@ class ScalaTreeVisitor( "sealed", J.Modifier.Type.Sealed, Collections.emptyList())) lastModifierKeywordEnd = modifierEndPos + "sealed".length modifierEndPos += "sealed ".length + } else if (remaining.startsWith("transparent ")) { + modifiers.add(new J.Modifier(Tree.randomId(), modSpace, Markers.EMPTY, + "transparent", J.Modifier.Type.LanguageExtension, Collections.emptyList())) + lastModifierKeywordEnd = modifierEndPos + "transparent".length + modifierEndPos += "transparent ".length + } else if (remaining.startsWith("inline ")) { + modifiers.add(new J.Modifier(Tree.randomId(), modSpace, Markers.EMPTY, + "inline", J.Modifier.Type.LanguageExtension, Collections.emptyList())) + lastModifierKeywordEnd = modifierEndPos + "inline".length + modifierEndPos += "inline ".length + } else if (remaining.startsWith("opaque ")) { + modifiers.add(new J.Modifier(Tree.randomId(), modSpace, Markers.EMPTY, + "opaque", J.Modifier.Type.LanguageExtension, Collections.emptyList())) + lastModifierKeywordEnd = modifierEndPos + "opaque".length + modifierEndPos += "opaque ".length } else { scanning = false } @@ -9536,6 +9551,7 @@ class ScalaTreeVisitor( /** Modifiers legal on a class/trait primary-constructor parameter. */ private val constructorParamModifierKeywords: List[(String, J.Modifier.Type)] = List( + ("inline", J.Modifier.Type.LanguageExtension), ("override", J.Modifier.Type.LanguageExtension), ("private", J.Modifier.Type.Private), ("protected", J.Modifier.Type.Protected), diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java index 13930a2d6ae..9446f6e5299 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java @@ -255,4 +255,31 @@ void endMarkerOnGiven() { ); } + @Test + void inlineVal() { + rewriteRun( + scala( + """ + object O { + inline val X = 8 + private inline val Y = 9 + } + """ + ) + ); + } + + @Test + void inlineGiven() { + rewriteRun( + scala( + """ + object O { + inline given x: Int = 1 + } + """ + ) + ); + } + } From d64489a457a54c65ee6c6ca07f7705cc757ab40f Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sat, 15 Aug 2026 23:57:56 +0200 Subject: [PATCH 12/55] Claim `end` markers on extension blocks and try expressions An extension block and a try expression each ended by absorbing a trailing `end extension` or `end try` into a Space, the same way a class body did before end markers were modeled. Both now claim the marker before the cursor moves past it, reusing the existing mechanism. `end extension` is the most common end marker in the corpus by a wide margin. Two shapes remain absorbed: `end for` and `end new` after an indented body, where the body block's own end space claims the text first. A braced `for` with `end for`, and an indented `for` without one, both round-trip today. --- .../scala/internal/ScalaTreeVisitor.scala | 27 +++++++++++++++---- .../scala/MethodDeclarationTest.java | 27 +++++++++++++++++++ .../org/openrewrite/scala/tree/TryTest.java | 17 ++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index e9b0abc79af..e949a704ea4 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -4701,12 +4701,13 @@ class ScalaTreeVisitor( case null => throw unmappedException(forTree.body) } + val forMarkers = withEndMarker(Markers.EMPTY.addIfAbsent(ScalaForLoop.create()), forTree.span) updateCursor(forTree.span.end) val forEachLoop = new J.ForEachLoop( Tree.randomId(), prefix, - Markers.EMPTY.addIfAbsent(ScalaForLoop.create()), + forMarkers, control, JRightPadded.build(body) ) @@ -7212,8 +7213,11 @@ class ScalaTreeVisitor( val finallyBlock = buildTryFinalizer(parsedTry.finalizer) + val parsedTryMarkers = withEndMarker(Markers.EMPTY, parsedTry.span) updateCursor(parsedTry.span.end) - buildTryNode(prefix, body, catches, finallyBlock) + val parsedTryNode = buildTryNode(prefix, body, catches, finallyBlock) + if (parsedTryMarkers.getMarkers.isEmpty) parsedTryNode + else parsedTryNode.withMarkers[J](parsedTryNode.getMarkers.add(parsedTryMarkers.getMarkers.get(0))) } private def visitTryImpl(tryTree: Trees.Try[?]): J = { @@ -7228,8 +7232,11 @@ class ScalaTreeVisitor( val finallyBlock = buildTryFinalizer(tryTree.finalizer) + val tryMarkers = withEndMarker(Markers.EMPTY, tryTree.span) updateCursor(tryTree.span.end) - buildTryNode(prefix, body, catches, finallyBlock) + val tryNode = buildTryNode(prefix, body, catches, finallyBlock) + if (tryMarkers.getMarkers.isEmpty) tryNode + else tryNode.withMarkers[J](tryNode.getMarkers.add(tryMarkers.getMarkers.get(0))) } /** Visit the `try` body, wrapping a bare statement/expression in an OmitBraces block. */ @@ -8118,8 +8125,15 @@ class ScalaTreeVisitor( val endPos = Math.max(0, ext.span.end - offsetAdjustment) val remaining = if (cursor < endPos && endPos <= source.length) source.substring(cursor, endPos) else "" + var extEndMarker: String = null val endSpace = if (isExtBraceless) { - Space.format(remaining) + // dotty's span covers a trailing `end extension`, which is not whitespace + endMarkerAt(cursor, endPos) match { + case Some((start, text)) => + extEndMarker = source.substring(cursor, start + text.length) + Space.EMPTY + case None => Space.format(remaining) + } } else { val closeBrace = remaining.lastIndexOf('}') if (closeBrace > 0) Space.format(remaining.substring(0, closeBrace)) else Space.EMPTY @@ -8130,7 +8144,10 @@ class ScalaTreeVisitor( } else Markers.EMPTY val body = new J.Block(Tree.randomId(), blockPrefix, blockMarkers, JRightPadded.build(false), methodStmts, endSpace) - S.ExtensionMethods.build(Tree.randomId(), prefix, Markers.EMPTY, typeParameters, parameters, body) + val extMarkers = + if (extEndMarker == null) Markers.EMPTY + else Markers.EMPTY.add(EndMarker(Tree.randomId(), extEndMarker)) + S.ExtensionMethods.build(Tree.randomId(), prefix, extMarkers, typeParameters, parameters, body) } /** Build the type-parameter clause `[A, B]` of an extension, advancing the cursor past `]`. */ diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java index e958ea0c2d8..4c80d7b7083 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java @@ -1143,4 +1143,31 @@ void implicitParameterWithAnnotation() { ); } + @Test + void endMarkerOnExtension() { + rewriteRun( + scala( + """ + extension (x: Int) + def double: Int = x * 2 + end extension + """ + ) + ); + } + + @Test + void endMarkerOnExtensionWithSeveralMethods() { + rewriteRun( + scala( + """ + extension (x: Int) + def a: Int = x + def b: Int = x + end extension + """ + ) + ); + } + } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/TryTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/TryTest.java index 3413110a034..5fd1ef6f89b 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/TryTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/TryTest.java @@ -535,4 +535,21 @@ void significantCharactersInComments() { """ )); } + @Test + void endMarkerOnTry() { + rewriteRun( + scala( + """ + object O: + def f(): Int = + try + 1 + catch + case _: Exception => 0 + end try + """ + ) + ); + } + } From f521f469ad3ee1e51533d4f70a41e4751ec89e31 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 00:23:50 +0200 Subject: [PATCH 13/55] Find a lambda's arrow after its parameter list, and keep backticks on annotations Two print failures, both from reading source out of token order. A lambda took the first `=>` in its own source as its arrow, but a parameter's type can hold one first, so `(cb: Int => Unit) => 1` split at the type's arrow and re-emitted the tail of the parameter list. The search now starts after the parameter list. An annotation built its name without asking whether the source backticked it, so `@`inline`` printed as `@inline`. The identifier helper already takes a quoting flag; the annotation path now passes it. Over 1,693 files of cats-effect and the scala3 library and compiler, parse errors drop from 164 to 140. --- .../scala/internal/ScalaTreeVisitor.scala | 20 ++++- .../org/openrewrite/scala/ScalaSweepTest.java | 77 +++++++++++++++++++ .../scala/tree/AnnotationTest.java | 13 ++++ .../openrewrite/scala/tree/LambdaTest.java | 14 ++++ 4 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index e949a704ea4..56efa174194 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -622,7 +622,7 @@ class ScalaTreeVisitor( // Create the annotation type: an Ident for a simple name (@deprecated) or a // Select chain for a qualified name (@scala.annotation.implicitNotFound). val annotTypeTree: NameTree = tpt match { - case id: Trees.Ident[?] => ident(id.name.toString) + case id: Trees.Ident[?] => ident(id.name.toString, quoted = isBacktickQuoted(id.span)) case sel: Trees.Select[?] => // Qualified name: skip the leading '@', then map the Select chain to a J.FieldAccess. val selStart = Math.max(0, sel.span.start - offsetAdjustment) @@ -10197,7 +10197,23 @@ class ScalaTreeVisitor( val funcSource = extractSource(func.span) cursor = savedCursorBeforeFunc hasParentheses = funcSource.trim.startsWith("(") - val arrowIndex = positionOfNextIn(funcSource, "=>", 0) + // A parameter's own type can contain an arrow (`(cb: Int => Unit) => 1`), so the + // lambda's arrow is the first one after the parameter list. + val afterParamList = if (hasParentheses) { + var depth = 0 + var i = funcSource.indexOf('(') + var close = -1 + while (i >= 0 && i < funcSource.length && close < 0) { + funcSource.charAt(i) match { + case '(' => depth += 1 + case ')' => depth -= 1; if (depth == 0) close = i + case _ => + } + i += 1 + } + if (close >= 0) close + 1 else 0 + } else 0 + val arrowIndex = positionOfNextIn(funcSource, "=>", afterParamList) // When parenthesized, advance cursor past `(` so the first parameter's // extractPrefix captures the whitespace between `(` and the first arg. diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java new file mode 100644 index 00000000000..450b956f4be --- /dev/null +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java @@ -0,0 +1,77 @@ +package org.openrewrite.scala; +import org.junit.jupiter.api.Test; +import org.openrewrite.*; +import org.openrewrite.tree.ParseError; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.file.*; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +class ScalaSweepTest { + static final String[] ROOTS = {"/tmp/scala-corpus/cats-effect", + "/tmp/scala-corpus/scala3/library/src", "/tmp/scala-corpus/scala3/compiler/src"}; + + @Test + void sweep() throws IOException { + List files = new ArrayList<>(); + for (String root : ROOTS) { + Path p = Paths.get(root); + if (!Files.exists(p)) continue; + try (Stream walk = Files.walk(p)) { + walk.filter(f -> f.toString().endsWith(".scala")).sorted().forEach(files::add); + } + } + Map> causes = new LinkedHashMap<>(); + int pf = 0; + ScalaParser parser = ScalaParser.builder().build(); + for (int i = 0; i < files.size(); i += 20) { + List chunk = files.subList(i, Math.min(i + 20, files.size())); + List in = chunk.stream().map(f -> new Parser.Input(f, () -> { + try { return Files.newInputStream(f); } catch (IOException e) { throw new RuntimeException(e); } + })).collect(Collectors.toList()); + List res; + try { res = parser.parseInputs(in, null, new InMemoryExecutionContext(t -> {})).collect(Collectors.toList()); } + catch (Throwable t) { pf += chunk.size(); continue; } + for (SourceFile sf : res) { + if (!(sf instanceof ParseError)) continue; + pf++; + String msg = sf.getMarkers().findFirst(ParseExceptionResult.class) + .map(ParseExceptionResult::getMessage).orElse("?"); + causes.computeIfAbsent(cause(msg), k -> new ArrayList<>()).add(sf.getSourcePath().toString()); + } + } + try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(Paths.get("/tmp/pe.txt")))) { + w.printf("parseErrors=%d%n%n", pf); + causes.entrySet().stream() + .sorted(Comparator.>>comparingInt(e -> e.getValue().size()).reversed()) + .forEach(e -> { w.printf("%4d %s%n", e.getValue().size(), e.getKey()); + e.getValue().stream().limit(2).forEach(f -> w.printf(" %s%n", f)); }); + } + } + + private static String cause(String m) { + String head = m.split("\n")[0].trim(); + if (m.contains("CapturesAndResult") || m.contains("did not produce a J.Annotation")) return "capture: annotation/result"; + if (m.contains("Unmapped Scala AST node: New")) return "capture?: unmapped New"; + if (m.contains("PolyFunction")) return "polymorphic function type"; + if (m.contains("Quote") || m.contains("Splice")) return "quote/splice"; + if (!m.contains("is not print idempotent")) return "throw: " + head.replaceAll("/\\S+/", "").replaceAll("\\d+","N"); + List d = new ArrayList<>(); + for (String l : m.split("\n")) { + if ((l.startsWith("-") || l.startsWith("+")) && !l.startsWith("---") && !l.startsWith("+++")) { + d.add(l.trim()); + if (d.size() == 2) break; + } + } + String j = String.join(" || ", d); + if (j.contains("=>")) return "print: arrow =>"; + if (j.contains("using")) return "print: using"; + if (j.contains("end ")) return "print: end marker"; + if (j.contains("inline")) return "print: inline"; + if (j.contains(";")) return "print: semicolon"; + if (j.contains("^")) return "print: capture"; + return "print: other | " + (j.length() > 70 ? j.substring(0, 70) : j); + } +} diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotationTest.java index 6c1d05b4230..2dbdd8cd9a1 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotationTest.java @@ -365,4 +365,17 @@ class Test { ) ); } + @Test + void backtickedAnnotationName() { + rewriteRun( + scala( + """ + object O { + @`inline` def f(): Int = 1 + } + """ + ) + ); + } + } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/LambdaTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/LambdaTest.java index 12b81af9905..1f457b4fead 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/LambdaTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/LambdaTest.java @@ -388,4 +388,18 @@ void trailingCommaInLambdaParams() { ) ); } + @Test + void parameterTypeContainsAnArrow() { + rewriteRun( + scala( + """ + object O { + val f = (cb: Int => Unit) => 1 + val g = (acc: Boolean, cb: Int => Unit) => acc + } + """ + ) + ); + } + } From dd5930a546d4c3a60b712fa07594d777e41b6536 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 00:28:45 +0200 Subject: [PATCH 14/55] Find a function type's arrow after its parameter list `(A => Unit) => B` split at the arrow inside its own parenthesized parameter list, re-emitting the tail of that list and printing `=>>`. The arrow search now starts past a leading parenthesized group, matching the fix made for lambdas. Parse errors over the corpus drop from 140 to 131. --- .../scala/internal/ScalaTreeVisitor.scala | 25 +++++++++++-- .../org/openrewrite/scala/OneFileTest.java | 35 +++++++++++++++++++ .../scala/tree/FunctionTypeTest.java | 15 ++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 56efa174194..0370b51db78 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -8806,6 +8806,26 @@ class ScalaTreeVisitor( * argument is itself a synthetic `ValDef` whose `tpt` carries the actual parameter * type and source span. */ + /** Index just past a leading parenthesized group in {@code text}, or 0 when it has none. */ + private def closeOfLeadingParen(text: String): Int = { + val open = text.indexWhere(!Character.isWhitespace(_)) + if (open < 0 || text.charAt(open) != '(') 0 + else { + var depth = 0 + var i = open + var close = -1 + while (i < text.length && close < 0) { + text.charAt(i) match { + case '(' => depth += 1 + case ')' => depth -= 1; if (depth == 0) close = i + case _ => + } + i += 1 + } + if (close >= 0) close + 1 else 0 + } + } + private def visitFunctionType(func: untpd.Function): S.FunctionType = { val funcStart = Math.max(0, func.span.start - offsetAdjustment) val funcEnd = Math.max(0, func.span.end - offsetAdjustment) @@ -8818,8 +8838,9 @@ class ScalaTreeVisitor( source.substring(funcStart, funcEnd) } else "" - // Find the arrow within the function-type source. - val relArrowIdx = positionOfNextIn(funcSource, "=>", 0) + // Find the arrow within the function-type source. A parenthesized parameter list can + // hold an arrow of its own (`(A => Unit) => B`), so the type's arrow follows the list. + val relArrowIdx = positionOfNextIn(funcSource, "=>", closeOfLeadingParen(funcSource)) val arrowAbs = if (relArrowIdx >= 0) funcStart + relArrowIdx else funcEnd // Detect whether the parameter list is parenthesized. A single unnamed param like diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java new file mode 100644 index 00000000000..7fe7ce19189 --- /dev/null +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java @@ -0,0 +1,35 @@ +package org.openrewrite.scala; +import org.junit.jupiter.api.Test; +import org.openrewrite.*; +import org.openrewrite.tree.ParseError; +import java.nio.file.*; +import java.util.*; +import java.util.stream.Collectors; + +class OneFileTest { + @Test + void one() throws Exception { + String[] paths = { + "/tmp/scala-corpus/cats-effect/kernel/jvm/src/main/scala/cats/effect/kernel/AsyncPlatform.scala", + "/tmp/scala-corpus/cats-effect/core/shared/src/main/scala/cats/effect/IO.scala"}; + StringBuilder sb = new StringBuilder(); + for (String ps : paths) { + Path path = Paths.get(ps); + List in = List.of(new Parser.Input(path, () -> { + try { return Files.newInputStream(path); } catch (Exception e) { throw new RuntimeException(e); } + })); + for (SourceFile sf : ScalaParser.builder().build() + .parseInputs(in, null, new InMemoryExecutionContext(t -> {})).collect(Collectors.toList())) { + sb.append("==== ").append(ps.substring(ps.lastIndexOf('/') + 1)).append('\n'); + if (sf instanceof ParseError) { + String m = sf.getMarkers().findFirst(ParseExceptionResult.class) + .map(ParseExceptionResult::getMessage).orElse("?"); + for (String l : m.split("\n")) { + if (l.startsWith("-") || l.startsWith("+") || l.startsWith("@@")) sb.append(l).append('\n'); + } + } else sb.append("OK\n"); + } + } + Files.write(Paths.get("/tmp/one.txt"), sb.toString().getBytes()); + } +} diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/FunctionTypeTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/FunctionTypeTest.java index 817f6d1c749..1450f042c12 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/FunctionTypeTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/FunctionTypeTest.java @@ -158,4 +158,19 @@ public J visitFunctionType(S.FunctionType functionType, Integer p) { assertThat(ref.get()).as("should find an S.FunctionType").isNotNull(); return ref.get(); } + @Test + void parameterListHoldsAnArrow() { + rewriteRun( + scala( + """ + object O { + def f(k: (Int => Unit) => Unit): Int = 1 + def g(k: (Int => Unit, Long) => Unit): Int = 1 + def h(): (Int => Unit) => Unit = ??? + } + """ + ) + ); + } + } From 9099dc5576167bc4b471a293221faadde312925a Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 00:32:59 +0200 Subject: [PATCH 15/55] Print a curried clause keyword ahead of its parameter annotations `def map[B](f: Int => B)(implicit @implicitNotFound(msg) ev: Ordering[B])` put the annotation before the keyword, giving `( @implicitNotFound(msg)implicit ev`. A curried parameter list prints through the lambda-parameter path, which had not been given the ordering already applied to constructor and method parameters. Parse errors over the corpus drop from 131 to 125. --- .../java/org/openrewrite/scala/ScalaPrinter.java | 11 ++++++++++- .../openrewrite/scala/MethodDeclarationTest.java | 13 +++++++++++++ .../java/org/openrewrite/scala/OneFileTest.java | 4 ++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index 51aee241291..bc178199138 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -659,8 +659,17 @@ private void printLambdaParamsAsCurried(J.Lambda.Parameters lambdaParams, PrintO if (elem instanceof J.VariableDeclarations) { J.VariableDeclarations vd = (J.VariableDeclarations) elem; visitSpace(vd.getPrefix(), Space.Location.VARIABLE_DECLARATIONS_PREFIX, p); + for (J.Modifier m : vd.getModifiers()) { + if (isClauseKeyword(m)) { + visit(m, p); + } + } visit(vd.getLeadingAnnotations(), p); - visit(vd.getModifiers(), p); + for (J.Modifier m : vd.getModifiers()) { + if (!isClauseKeyword(m)) { + visit(m, p); + } + } boolean omitName = !vd.getVariables().isEmpty() && vd.getVariables().get(0).getMarkers().findFirst( org.openrewrite.scala.marker.OmitName.class).isPresent(); if (!omitName && !vd.getVariables().isEmpty()) { diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java index 4c80d7b7083..393b2aebfa2 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java @@ -1170,4 +1170,17 @@ void endMarkerOnExtensionWithSeveralMethods() { ); } + @Test + void curriedImplicitParameterWithAnnotation() { + rewriteRun( + scala( + """ + object O { + def map[B](f: Int => B)(implicit @implicitNotFound("m") ev: Ordering[B]): Int = 1 + } + """ + ) + ); + } + } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java index 7fe7ce19189..8db21461f2f 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java @@ -10,8 +10,8 @@ class OneFileTest { @Test void one() throws Exception { String[] paths = { - "/tmp/scala-corpus/cats-effect/kernel/jvm/src/main/scala/cats/effect/kernel/AsyncPlatform.scala", - "/tmp/scala-corpus/cats-effect/core/shared/src/main/scala/cats/effect/IO.scala"}; + "/tmp/scala-corpus/scala3/library/src/scala/Enumeration.scala", + "/tmp/scala-corpus/cats-effect/tests/shared/src/test/scala/cats/effect/std/QueueSuite.scala"}; StringBuilder sb = new StringBuilder(); for (String ps : paths) { Path path = Paths.get(ps); From 5952b6a57a20c0a4742d100da207c24d80fd5b27 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 03:41:36 +0200 Subject: [PATCH 16/55] Remove corpus-sweep scratch tests from the repository Two diagnostic harnesses were committed by accident. They hard-code paths under /tmp and are not part of the module's test suite. --- .../org/openrewrite/scala/OneFileTest.java | 35 --------- .../org/openrewrite/scala/ScalaSweepTest.java | 77 ------------------- 2 files changed, 112 deletions(-) delete mode 100644 rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java delete mode 100644 rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java deleted file mode 100644 index 8db21461f2f..00000000000 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.openrewrite.scala; -import org.junit.jupiter.api.Test; -import org.openrewrite.*; -import org.openrewrite.tree.ParseError; -import java.nio.file.*; -import java.util.*; -import java.util.stream.Collectors; - -class OneFileTest { - @Test - void one() throws Exception { - String[] paths = { - "/tmp/scala-corpus/scala3/library/src/scala/Enumeration.scala", - "/tmp/scala-corpus/cats-effect/tests/shared/src/test/scala/cats/effect/std/QueueSuite.scala"}; - StringBuilder sb = new StringBuilder(); - for (String ps : paths) { - Path path = Paths.get(ps); - List in = List.of(new Parser.Input(path, () -> { - try { return Files.newInputStream(path); } catch (Exception e) { throw new RuntimeException(e); } - })); - for (SourceFile sf : ScalaParser.builder().build() - .parseInputs(in, null, new InMemoryExecutionContext(t -> {})).collect(Collectors.toList())) { - sb.append("==== ").append(ps.substring(ps.lastIndexOf('/') + 1)).append('\n'); - if (sf instanceof ParseError) { - String m = sf.getMarkers().findFirst(ParseExceptionResult.class) - .map(ParseExceptionResult::getMessage).orElse("?"); - for (String l : m.split("\n")) { - if (l.startsWith("-") || l.startsWith("+") || l.startsWith("@@")) sb.append(l).append('\n'); - } - } else sb.append("OK\n"); - } - } - Files.write(Paths.get("/tmp/one.txt"), sb.toString().getBytes()); - } -} diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java deleted file mode 100644 index 450b956f4be..00000000000 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java +++ /dev/null @@ -1,77 +0,0 @@ -package org.openrewrite.scala; -import org.junit.jupiter.api.Test; -import org.openrewrite.*; -import org.openrewrite.tree.ParseError; -import java.io.IOException; -import java.io.PrintWriter; -import java.nio.file.*; -import java.util.*; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -class ScalaSweepTest { - static final String[] ROOTS = {"/tmp/scala-corpus/cats-effect", - "/tmp/scala-corpus/scala3/library/src", "/tmp/scala-corpus/scala3/compiler/src"}; - - @Test - void sweep() throws IOException { - List files = new ArrayList<>(); - for (String root : ROOTS) { - Path p = Paths.get(root); - if (!Files.exists(p)) continue; - try (Stream walk = Files.walk(p)) { - walk.filter(f -> f.toString().endsWith(".scala")).sorted().forEach(files::add); - } - } - Map> causes = new LinkedHashMap<>(); - int pf = 0; - ScalaParser parser = ScalaParser.builder().build(); - for (int i = 0; i < files.size(); i += 20) { - List chunk = files.subList(i, Math.min(i + 20, files.size())); - List in = chunk.stream().map(f -> new Parser.Input(f, () -> { - try { return Files.newInputStream(f); } catch (IOException e) { throw new RuntimeException(e); } - })).collect(Collectors.toList()); - List res; - try { res = parser.parseInputs(in, null, new InMemoryExecutionContext(t -> {})).collect(Collectors.toList()); } - catch (Throwable t) { pf += chunk.size(); continue; } - for (SourceFile sf : res) { - if (!(sf instanceof ParseError)) continue; - pf++; - String msg = sf.getMarkers().findFirst(ParseExceptionResult.class) - .map(ParseExceptionResult::getMessage).orElse("?"); - causes.computeIfAbsent(cause(msg), k -> new ArrayList<>()).add(sf.getSourcePath().toString()); - } - } - try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(Paths.get("/tmp/pe.txt")))) { - w.printf("parseErrors=%d%n%n", pf); - causes.entrySet().stream() - .sorted(Comparator.>>comparingInt(e -> e.getValue().size()).reversed()) - .forEach(e -> { w.printf("%4d %s%n", e.getValue().size(), e.getKey()); - e.getValue().stream().limit(2).forEach(f -> w.printf(" %s%n", f)); }); - } - } - - private static String cause(String m) { - String head = m.split("\n")[0].trim(); - if (m.contains("CapturesAndResult") || m.contains("did not produce a J.Annotation")) return "capture: annotation/result"; - if (m.contains("Unmapped Scala AST node: New")) return "capture?: unmapped New"; - if (m.contains("PolyFunction")) return "polymorphic function type"; - if (m.contains("Quote") || m.contains("Splice")) return "quote/splice"; - if (!m.contains("is not print idempotent")) return "throw: " + head.replaceAll("/\\S+/", "").replaceAll("\\d+","N"); - List d = new ArrayList<>(); - for (String l : m.split("\n")) { - if ((l.startsWith("-") || l.startsWith("+")) && !l.startsWith("---") && !l.startsWith("+++")) { - d.add(l.trim()); - if (d.size() == 2) break; - } - } - String j = String.join(" || ", d); - if (j.contains("=>")) return "print: arrow =>"; - if (j.contains("using")) return "print: using"; - if (j.contains("end ")) return "print: end marker"; - if (j.contains("inline")) return "print: inline"; - if (j.contains(";")) return "print: semicolon"; - if (j.contains("^")) return "print: capture"; - return "print: other | " + (j.length() > 70 ? j.substring(0, 70) : j); - } -} From 4fce185a3812d464ddf544cff621ea7f811818e9 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 03:53:20 +0200 Subject: [PATCH 17/55] Size a wildcard kind parameter by its source, and print the pure function arrow Two more places that read a name's length or an arrow's spelling from the tree rather than from the source in front of the cursor. A nested higher-kinded parameter took its bracket offset from dotty's name for the wildcard, `_$1`, which is three characters where the source has one, so `F[_[_], _]` re-emitted the inner kind list as `F[_[_[_], _], _]`. Capture checking writes a pure function as `A -> B`. With no `=>` to find, the whole type was taken as the parameter and a second `=> B` was appended. The arrow is now recognized and carried on a marker, beside the existing one for the context-function arrow `?=>`. --- .../org/openrewrite/scala/ScalaPrinter.java | 3 +++ .../scala/internal/ScalaTreeVisitor.scala | 17 ++++++++++++++--- .../openrewrite/scala/marker/ScalaMarkers.scala | 8 ++++++++ .../scala/tree/ClassDeclarationTest.java | 13 +++++++++++++ .../scala/tree/FunctionTypeTest.java | 14 ++++++++++++++ 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index bc178199138..1142760a027 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -2084,6 +2084,9 @@ public J visitFunctionType(S.FunctionType functionType, PrintOutputCapture

p) visitSpace(rt.getBefore(), Space.Location.LANGUAGE_EXTENSION, p); if (functionType.getMarkers().findFirst(ContextFunctionArrow.class).isPresent()) { p.append("?=>"); + } else if (functionType.getMarkers() + .findFirst(org.openrewrite.scala.marker.PureFunctionArrow.class).isPresent()) { + p.append("->"); } else { p.append("=>"); } diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 0370b51db78..ff83bfc0618 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -36,6 +36,7 @@ import org.openrewrite.scala.marker.IndentedSyntax import org.openrewrite.scala.marker.OmitBraces import org.openrewrite.scala.marker.OmitImportBraces import org.openrewrite.scala.marker.PackageObject +import org.openrewrite.scala.marker.PureFunctionArrow import org.openrewrite.scala.marker.DerivesClause import org.openrewrite.scala.marker.EndMarker import org.openrewrite.scala.marker.KindParameterVariance @@ -8840,7 +8841,12 @@ class ScalaTreeVisitor( // Find the arrow within the function-type source. A parenthesized parameter list can // hold an arrow of its own (`(A => Unit) => B`), so the type's arrow follows the list. - val relArrowIdx = positionOfNextIn(funcSource, "=>", closeOfLeadingParen(funcSource)) + val arrowSearchFrom = closeOfLeadingParen(funcSource) + val fatArrowIdx = positionOfNextIn(funcSource, "=>", arrowSearchFrom) + // capture checking writes a pure function as `A -> B` + val pureArrowIdx = positionOfNextIn(funcSource, "->", arrowSearchFrom) + val isPureArrow = pureArrowIdx >= 0 && (fatArrowIdx < 0 || pureArrowIdx < fatArrowIdx) + val relArrowIdx = if (isPureArrow) pureArrowIdx else fatArrowIdx val arrowAbs = if (relArrowIdx >= 0) funcStart + relArrowIdx else funcEnd // Detect whether the parameter list is parenthesized. A single unnamed param like @@ -8927,7 +8933,9 @@ class ScalaTreeVisitor( if (funcEnd > cursor) cursor = funcEnd - val funcMarkers = if (isContextArrow) + val funcMarkers = if (isPureArrow) + Markers.build(Collections.singletonList(PureFunctionArrow(Tree.randomId()))) + else if (isContextArrow) Markers.build(Collections.singletonList(new ContextFunctionArrow(java.util.UUID.randomUUID()))) else Markers.EMPTY @@ -9886,7 +9894,10 @@ class ScalaTreeVisitor( baseNameStart: Int, prefix: Space): J.ParameterizedType = { val varianceLen = if (baseNameStart < source.length && (source.charAt(baseNameStart) == '+' || source.charAt(baseNameStart) == '-')) 1 else 0 - val nameLen = td.name.toString.length + varianceLen + // a wildcard kind parameter is one character in source, whatever dotty named it + val rawName = td.name.toString + val sourceNameLen = if (rawName == "_" || rawName.startsWith("_$")) 1 else rawName.length + val nameLen = sourceNameLen + varianceLen val bracketPos = baseNameStart + nameLen val clazzName = source.substring(baseNameStart, Math.min(source.length, bracketPos)) val clazz: NameTree = ident(clazzName, Space.EMPTY) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala index 7ecb2315881..c7d80353eae 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala @@ -168,6 +168,14 @@ case class UsingArguments(id: UUID, text: String) extends Marker { override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] } +/** + * A function type written with the capture-checking pure arrow, `A -> B`, rather than `=>`. + */ +case class PureFunctionArrow(id: UUID) extends Marker { + override def getId(): UUID = id + override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] +} + /** * A self-type clause opening a template body, as in `trait T { self => ... }` or * `trait T:\n this: U =>`. Holds the verbatim source from the body delimiter through diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java index 30f86921684..bc5fae8b68d 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java @@ -855,4 +855,17 @@ infix def and(o: Int): Int = o ); } + @Test + void nestedHigherKindedTypeParameter() { + rewriteRun( + scala( + """ + trait Q[F[_[_], _]] { + def f(): Int = 1 + } + """ + ) + ); + } + } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/FunctionTypeTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/FunctionTypeTest.java index 1450f042c12..0fb4c9b4252 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/FunctionTypeTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/FunctionTypeTest.java @@ -173,4 +173,18 @@ def h(): (Int => Unit) => Unit = ??? ); } + @Test + void pureFunctionArrow() { + rewriteRun( + scala( + """ + import language.experimental.captureChecking + object O { + def f(g: Int -> Long): Int = 1 + } + """ + ) + ); + } + } From 9e23fb21bcf7ad7432faca3b7b04f14ea3d1e15e Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 04:06:53 +0200 Subject: [PATCH 18/55] Unwrap the capture-checking result wrapper With capture checking enabled, dotty wraps a by-name parameter's result type in a `CapturesAndResult` node. It has no syntax of its own, so a plain `elem: => T` threw "Unmapped Scala AST node: CapturesAndResult" and failed the whole file. The node is now unwrapped to the type it carries. This was the largest remaining group of parse failures. Over the corpus they drop from 114 to 94. --- .../scala/internal/ScalaTreeVisitor.scala | 2 ++ .../openrewrite/scala/tree/FunctionTypeTest.java | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index ff83bfc0618..1ae4eebf1f2 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -278,6 +278,8 @@ class ScalaTreeVisitor( case stt: Trees.SingletonTypeTree[?] => visitSingletonTypeTree(stt) case rtt: Trees.RefinedTypeTree[?] => visitRefinedTypeTree(rtt) case ann: Trees.Annotated[?] => visitAnnotated(ann) + // Capture checking wraps a result type; the wrapper itself has no source of its own + case car: untpd.CapturesAndResult => visitTree(car.parent) case mac: untpd.MacroTree => visitMacroTree(mac) case ext: untpd.ExtMethods => visitExtMethods(ext) case forYield: untpd.ForYield => visitForYield(forYield) diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/FunctionTypeTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/FunctionTypeTest.java index 0fb4c9b4252..21c7c2d2bda 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/FunctionTypeTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/FunctionTypeTest.java @@ -187,4 +187,19 @@ def f(g: Int -> Long): Int = 1 ); } + @Test + void byNameParameterUnderCaptureChecking() { + rewriteRun( + scala( + """ + import language.experimental.captureChecking + object O { + def fill[T](n: Int)(elem: => T): Int = n + def f(g: => Int^): Int = 1 + } + """ + ) + ); + } + } From bafa88c400c9b1355cbfd756f18c4f20b9dc30b5 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 04:33:55 +0200 Subject: [PATCH 19/55] Keep `new` out of a curried constructor call's prefix Dotty's Apply span for `new M[A](x)(f)(g)` starts at the type, so the `new` keyword sits in the gap ahead of the span. The outer call absorbed it as whitespace and the NewClass then printed its own, giving `new newM[A]`. An application's prefix now stops at a `new` keyword and leaves the cursor on it, so the constructor call consumes it as it already does for two argument lists. --- .../scala/internal/ScalaTreeVisitor.scala | 21 ++++++++++++++++++- .../openrewrite/scala/tree/NewClassTest.java | 14 +++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 1ae4eebf1f2..d735f06d97c 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -955,8 +955,27 @@ class ScalaTreeVisitor( ) } + /** Prefix for an application whose callee is a `new` expression. Dotty's Apply span for a + * curried constructor call starts at the type, leaving `new` in the gap ahead of it, where + * it would be absorbed as whitespace and then printed a second time by the NewClass. + */ + private def extractPrefixKeepingNew(span: Spans.Span): Space = { + val start = Math.max(0, span.start - offsetAdjustment) + if (start <= cursor || start > source.length) extractPrefix(span) + else { + val gap = source.substring(cursor, start) + val idx = positionOfNextIn(gap, "new", 0) + if (idx < 0) extractPrefix(span) + else { + val sp = ScalaSpace.format(source, cursor, cursor + idx) + cursor = cursor + idx + sp + } + } + } + private def visitMethodInvocation(app: Trees.Apply[?]): J = { - val prefix = extractPrefix(app.span) + val prefix = extractPrefixKeepingNew(app.span) // Note: We deliberately don't create J.ArrayAccess for explicit .apply() calls. // In Scala, arr.apply(0) is an explicit method call and should be represented as such. diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java index 0e94947d764..669c7c0abbb 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java @@ -395,4 +395,18 @@ def apply(i: Int): Unit = () ); } + @Test + void constructorWithThreeArgumentLists() { + rewriteRun( + scala( + """ + class M[A](s: String)(f: Int => Int)(g: Int => Int) + object O { + val m = new M[Int]("x")(identity)(identity) + } + """ + ) + ); + } + } From 11f147ad6b52853ee263af6c5ef3783336425ce3 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 04:56:48 +0200 Subject: [PATCH 20/55] Scan an object's modifiers from the cursor, not from its span A companion object's ModuleDef span starts back at the companion class, so the guard that compared the span start against the cursor skipped the modifier scan entirely. The modifier then leaked into whitespace and the kind keyword was emitted twice: `private object B` printed as `privateobject object B`. The snippet was already taken from the cursor, which is past everything consumed so far, so only the guard needed to change. --- .../scala/internal/ScalaTreeVisitor.scala | 5 +++-- .../scala/tree/ObjectDeclarationTest.java | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index d735f06d97c..6f5d6d5d0a4 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -3666,8 +3666,9 @@ class ScalaTreeVisitor( var isEnumCase = false // When annotations were consumed, cursor sits after them; modifierText must // be derived from the post-annotation position, not the ModuleDef span start. - val modifierScanStart = if (hasAnnotations) cursor else adjustedStart - if (modifierScanStart >= cursor && adjustedEnd <= source.length) { + // A companion object's span starts back at its class, so scan from the cursor, which is + // already past everything consumed so far. + if (cursor < adjustedEnd && adjustedEnd <= source.length) { val sourceSnippet = source.substring(cursor, adjustedEnd) objectIndex = findKeyword(sourceSnippet, "object") val caseIndex = findKeyword(sourceSnippet, "case") diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ObjectDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ObjectDeclarationTest.java index 920a9b42fc1..893553b68c5 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ObjectDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ObjectDeclarationTest.java @@ -148,4 +148,21 @@ void significantCharactersInComments() { ) ); } + @Test + void privateCompanionObject() { + rewriteRun( + scala( + """ + package p + + private[p] final class B + + private object B { + val x = 1 + } + """ + ) + ); + } + } From 83a217e6a87ae4c2d9448080537bde197b79808a Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 05:15:12 +0200 Subject: [PATCH 21/55] Claim an `end` marker closing an object's indented body The class path already split a trailing `end` marker out of a braceless body's end space; the object path did not, so `end Conversion` closing an indented object stayed in whitespace and the tree hid it from recipes. Objects are a common home for the marker, including at column zero after an extension block and on a nested object. Over 1,693 files of cats-effect and the scala3 library and compiler, files that parse and hold no source in whitespace go from 1,383 to 1,438, and end markers no longer appear among the causes of unsound trees. --- .../scala/internal/ScalaTreeVisitor.scala | 24 ++++++++++++++-- .../scala/tree/ObjectDeclarationTest.java | 28 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 6f5d6d5d0a4..a30c21fe824 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -3643,6 +3643,8 @@ class ScalaTreeVisitor( } private def visitModuleDef(md: untpd.ModuleDef): J.ClassDeclaration = { + // Set while extracting a braceless body, read when the declaration's markers are built + var moduleEndMarker: String = null val hasAnnotations = md.mods != null && md.mods.annotations.nonEmpty val leadingAnnotations = new util.ArrayList[J.Annotation]() @@ -3981,9 +3983,17 @@ class ScalaTreeVisitor( if (cursor < source.length && md.span.exists) { val endPos = Math.max(0, md.span.end - offsetAdjustment) if (isBraceless) { - // No closing brace for braceless syntax + // No closing brace for braceless syntax. Dotty's span covers a trailing `end` + // marker, which is not whitespace. if (cursor < endPos) { - endSpace = ScalaSpace.format(source, cursor, Math.min(endPos, source.length)) + val bodyEnd = Math.min(endPos, source.length) + endMarkerAt(cursor, bodyEnd) match { + case Some((start, text)) => + moduleEndMarker = source.substring(cursor, start + text.length) + endSpace = Space.EMPTY + case None => + endSpace = ScalaSpace.format(source, cursor, bodyEnd) + } } cursor = endPos } else { @@ -4030,7 +4040,11 @@ class ScalaTreeVisitor( } else { Markers.build(Collections.singletonList(SObject.create())) } - val objectMarkers = withEndMarker(objectBaseMarkers, md.span) + // an `end Foo` closing an indented object can sit beyond dotty's span; try the span first, + // then claim by name + val objectMarkers = if (moduleEndMarker != null) + objectBaseMarkers.add(EndMarker(Tree.randomId(), moduleEndMarker)) + else withEndMarker(objectBaseMarkers, md.span) // Update cursor to end of module def if (md.span.exists) { @@ -5660,6 +5674,10 @@ class ScalaTreeVisitor( if (derivesText != null) { classDeclMarkers = classDeclMarkers.add(DerivesClause(Tree.randomId(), derivesText)) } + if (endMarkerText == null) { + // an `end Foo` closing an indented body can sit beyond dotty's span + classDeclMarkers = withEndMarker(classDeclMarkers, td.span, td.name.toString) + } new J.ClassDeclaration( Tree.randomId(), diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ObjectDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ObjectDeclarationTest.java index 893553b68c5..edc58da0446 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ObjectDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ObjectDeclarationTest.java @@ -165,4 +165,32 @@ void privateCompanionObject() { ); } + @Test + void endMarkerOnIndentedObject() { + rewriteRun( + scala( + """ + object Conversion: + extension [T](x: T) + def underlying: T = x + end Conversion + """ + ) + ); + } + + @Test + void endMarkerOnNestedObject() { + rewriteRun( + scala( + """ + object Outer: + object experimental: + val x = 1 + end experimental + """ + ) + ); + } + } From a90d4193e945f3f0f5c5eaf3f117e05927ec253c Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 05:29:32 +0200 Subject: [PATCH 22/55] Keep a context bound written on a higher-kinded type parameter Dotty records a context bound on the parameter's rhs only for a plain parameter. For a higher-kinded one the rhs is the kind's LambdaTypeTree, so the `: Monad` of `[F[_]: Monad]` was left unclaimed and absorbed into whitespace, hiding it from recipes. It is now read from source and printed after the name. This was the largest remaining cause of unsound trees. --- .../org/openrewrite/scala/ScalaPrinter.java | 2 + .../scala/internal/ScalaTreeVisitor.scala | 38 ++++++- .../scala/marker/ScalaMarkers.scala | 10 ++ .../scala/MethodDeclarationTest.java | 16 +++ .../org/openrewrite/scala/OneFileTest.java | 33 ++++++ .../org/openrewrite/scala/ScalaSweepTest.java | 101 ++++++++++++++++++ 6 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java create mode 100644 rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index 1142760a027..609db2aec62 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -148,6 +148,8 @@ public J visitTypeParameter(J.TypeParameter typeParam, PrintOutputCapture

p) p.append(m.getKeyword()); } visit(typeParam.getName(), p); + typeParam.getMarkers().findFirst(org.openrewrite.scala.marker.ContextBoundSuffix.class) + .ifPresent(m -> p.append(m.text())); // Print bounds if present using Scala syntax. // Each bound element may be a J.TypeBound (with explicit Kind) or a plain diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index a30c21fe824..6d76b99c160 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -57,6 +57,7 @@ import org.openrewrite.scala.marker.UnderscorePlaceholderLambda import org.openrewrite.scala.marker.PartialFunctionLiteral import org.openrewrite.scala.marker.ContextFunctionArrow import org.openrewrite.scala.marker.CaptureSet +import org.openrewrite.scala.marker.ContextBoundSuffix import org.openrewrite.scala.marker.Curried import org.openrewrite.scala.marker.InfixNotation import org.openrewrite.scala.marker.RightAssociative @@ -9667,6 +9668,32 @@ class ScalaTreeVisitor( case _ => null } + /** Consumes `: Bound` clauses sitting at the cursor inside a type-parameter list, returning + * their verbatim source. Stops at the `,` or `]` that closes the parameter. + */ + private def consumeStrayContextBounds(): String = { + var i = cursor + while (i < source.length && (source.charAt(i) == ' ' || source.charAt(i) == '\t')) i += 1 + if (i >= source.length || source.charAt(i) != ':') null + else { + var depth = 0 + var end = i + var done = false + while (!done && end < source.length) { + source.charAt(end) match { + case '[' | '(' => depth += 1; end += 1 + case ']' | ')' if depth > 0 => depth -= 1; end += 1 + case ']' | ',' if depth == 0 => done = true + case '\n' => done = true + case _ => end += 1 + } + } + val text = source.substring(cursor, end) + cursor = end + text + } + } + private def visitTypeParameter(tparam: Trees.TypeDef[?]): J.TypeParameter = { val prefix = extractPrefix(tparam.span) @@ -9819,6 +9846,13 @@ class ScalaTreeVisitor( // - TypeBoundsTree: upper/lower bounds like `<: Comparable` or `>: Null` // - untpd.ContextBounds: context bounds like `: ClassTag` (wraps TypeBoundsTree + cxBounds list) // Each bound is wrapped in J.TypeBound to carry its Kind (Upper/Lower). + // Dotty does not record a context bound on a higher-kinded parameter's rhs, so `F[_]: Monad` + // leaves the bound unclaimed in source. + val strayContextBounds: String = tparam.rhs match { + case _: untpd.LambdaTypeTree => consumeStrayContextBounds() + case _ => null + } + val bounds: JContainer[TypeTree] = tparam.rhs match { case cb: untpd.ContextBounds => // Context bounds: [T: ClassTag] or [T: Ordering : Show] @@ -9916,7 +9950,9 @@ class ScalaTreeVisitor( modifiers, name, bounds - ) + ).withMarkers( + if (strayContextBounds == null) Markers.EMPTY + else Markers.EMPTY.add(ContextBoundSuffix(Tree.randomId(), strayContextBounds))) } /** diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala index c7d80353eae..fd2b425c887 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala @@ -176,6 +176,16 @@ case class PureFunctionArrow(id: UUID) extends Marker { override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] } +/** + * Context bounds on a higher-kinded type parameter, as in the `: Monad` of `[F[_]: Monad]`. + * Dotty records context bounds on the parameter's rhs only for a plain parameter, so for a + * higher-kinded one the source is kept verbatim and printed after the name. + */ +case class ContextBoundSuffix(id: UUID, text: String) extends Marker { + override def getId(): UUID = id + override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] +} + /** * A self-type clause opening a template body, as in `trait T { self => ... }` or * `trait T:\n this: U =>`. Holds the verbatim source from the body delimiter through diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java index 393b2aebfa2..b1e7aa0f2d7 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java @@ -1183,4 +1183,20 @@ void curriedImplicitParameterWithAnnotation() { ); } + @Test + void contextBoundOnHigherKindedTypeParameter() { + rewriteRun( + scala( + """ + trait Monad[F[_]] + trait Par[F[_]] + object Test { + def f[F[_]: Monad, A](x: A): A = x + def g[F[_]: Monad: Par](x: Int): Int = x + } + """ + ) + ); + } + } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java new file mode 100644 index 00000000000..7e2ca351e54 --- /dev/null +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java @@ -0,0 +1,33 @@ +package org.openrewrite.scala; +import org.junit.jupiter.api.Test; +import org.openrewrite.*; +import org.openrewrite.tree.ParseError; +import java.nio.file.*; +import java.util.*; +import java.util.stream.Collectors; + +class OneFileTest { + @Test + void one() throws Exception { + String[] paths = { + "/tmp/scala-corpus/scala3/compiler/src/dotty/tools/dotc/cc/Setup.scala", + "/tmp/scala-corpus/scala3/compiler/src/dotty/tools/backend/sjs/JSCodeGen.scala"}; + StringBuilder sb = new StringBuilder(); + for (String ps : paths) { + Path path = Paths.get(ps); + List in = List.of(new Parser.Input(path, () -> { + try { return Files.newInputStream(path); } catch (Exception e) { throw new RuntimeException(e); } + })); + for (SourceFile sf : ScalaParser.builder().build() + .parseInputs(in, null, new InMemoryExecutionContext(t -> {})).collect(Collectors.toList())) { + sb.append("==== ").append(ps.substring(ps.lastIndexOf('/') + 1)).append('\n'); + if (sf instanceof ParseError) { + String m = sf.getMarkers().findFirst(ParseExceptionResult.class) + .map(ParseExceptionResult::getMessage).orElse("?"); + for (String l : m.split("\n")) { if (l.startsWith("-") || l.startsWith("+")) sb.append(l).append('\n'); } + } else sb.append("OK\n"); + } + } + Files.write(Paths.get("/tmp/one.txt"), sb.toString().getBytes()); + } +} diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java new file mode 100644 index 00000000000..c6c06abb165 --- /dev/null +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java @@ -0,0 +1,101 @@ +package org.openrewrite.scala; +import org.junit.jupiter.api.Test; +import org.openrewrite.*; +import org.openrewrite.internal.WhitespaceValidationService; +import org.openrewrite.tree.ParseError; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.file.*; +import java.util.*; +import java.util.regex.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +class ScalaSweepTest { + static final String[] ROOTS = {"/tmp/scala-corpus/cats-effect", + "/tmp/scala-corpus/scala3/library/src", "/tmp/scala-corpus/scala3/compiler/src"}; + + @Test + void sweep() throws IOException { + List files = new ArrayList<>(); + for (String root : ROOTS) { + Path p = Paths.get(root); + if (!Files.exists(p)) continue; + try (Stream walk = Files.walk(p)) { + walk.filter(f -> f.toString().endsWith(".scala")).sorted().forEach(files::add); + } + } + int ok = 0, pf = 0, ws = 0; + Map peCause = new LinkedHashMap<>(), wsCause = new LinkedHashMap<>(); + List wsSamples = new ArrayList<>(); + for (int i = 0; i < files.size(); i += 20) { + List chunk = files.subList(i, Math.min(i + 20, files.size())); + List in = chunk.stream().map(f -> new Parser.Input(f, () -> { + try { return Files.newInputStream(f); } catch (IOException e) { throw new RuntimeException(e); } + })).collect(Collectors.toList()); + ExecutionContext ctx = new InMemoryExecutionContext(t -> {}); + List res; + try { res = ScalaParser.builder().build().parseInputs(in, null, ctx).collect(Collectors.toList()); } + catch (Throwable t) { pf += chunk.size(); continue; } + for (SourceFile sf : res) { + if (sf instanceof ParseError) { + pf++; + peCause.merge(peCause(sf.getMarkers().findFirst(ParseExceptionResult.class) + .map(ParseExceptionResult::getMessage).orElse("?")), 1, Integer::sum); + continue; + } + ok++; + try { + WhitespaceValidationService s = sf.service(WhitespaceValidationService.class); + SourceFile v = (SourceFile) s.getVisitor().visit(sf, ctx); + if (v != null && v != sf) { + ws++; + Matcher mm = Pattern.compile("~~\\(non-whitespace\\)~~>(.{0,25})", Pattern.DOTALL).matcher(v.printAll()); + String c = mm.find() ? wsCause(mm.group(1)) : "?"; + wsCause.merge(c, 1, Integer::sum); + if (c.equals("type ascription") && wsSamples.size() < 10) wsSamples.add(sf.getSourcePath() + " |" + mm.group(1).replace("\n","\\n") + "|"); + } + } catch (UnsupportedOperationException ignored) {} + } + } + try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(Paths.get("/tmp/both-fresh.txt")))) { + w.printf("files=%d parseErrors=%d unsound=%d sound=%d%n%n== parse errors ==%n", files.size(), pf, ws, ok - ws); + peCause.entrySet().stream().sorted(Map.Entry.comparingByValue().reversed()) + .limit(14).forEach(e -> w.printf("%4d %s%n", e.getValue(), e.getKey())); + w.printf("%n== samples ==%n"); + wsSamples.forEach(x2 -> w.printf(" %s%n", x2)); + w.printf("%n== unsound ==%n"); + wsCause.entrySet().stream().sorted(Map.Entry.comparingByValue().reversed()) + .limit(10).forEach(e -> w.printf("%4d %s%n", e.getValue(), e.getKey())); + } + } + + private static String peCause(String m) { + if (m.contains("CapturesAndResult") || m.contains("did not produce a J.Annotation")) return "capture checking"; + if (m.contains("PolyFunction")) return "polymorphic function type"; + if (m.contains("Quote") || m.contains("Splice")) return "quote/splice"; + if (!m.contains("is not print idempotent")) return "throw: " + m.split("\n")[0].replaceAll("/\\S+/","").replaceAll("\\d+","N").trim(); + List d = new ArrayList<>(); + for (String l : m.split("\n")) if ((l.startsWith("-")||l.startsWith("+")) && !l.startsWith("---") && !l.startsWith("+++")) { d.add(l.trim()); if (d.size()==2) break; } + String j = String.join(" || ", d); + if (j.contains("=>")) return "print: arrow | " + (j.length() > 70 ? j.substring(0, 70) : j); + if (j.contains("using")) return "print: using"; + if (j.contains("end ")) return "print: end marker"; + if (j.contains(";")) return "print: semicolon"; + return "print: other | " + (j.length()>60 ? j.substring(0,60) : j); + } + + private static String wsCause(String s) { + String t = s.trim(); + if (t.startsWith("using")) return "using args"; + if (t.startsWith("inline")) return "inline modifier"; + if (t.startsWith("end ")) return "end marker"; + if (t.startsWith("=")) return "method body ="; + if (t.contains("=>")) return "self type / arrow"; + if (t.startsWith("^")) return "capture set"; + if (t.startsWith(":")) return "type ascription"; + if (t.startsWith(",")) return "comma"; + if (t.startsWith("private")||t.startsWith("protected")) return "access modifier"; + return "other: " + (t.length()>16 ? t.substring(0,16) : t); + } +} From 2d55403c66c6c39ad58e643e2ed3623cb7912047 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 05:37:02 +0200 Subject: [PATCH 23/55] Keep an upper bound written on a higher-kinded type parameter Same shape as the context bound fixed alongside it: dotty gives a higher-kinded parameter's rhs as the kind itself, so `[It[a] <: Iterable[a]]` left the bound unclaimed and it was absorbed into whitespace. The capture now covers `:`, `<:` and `>:`, and the marker is named for bounds generally rather than context bounds alone. --- .../org/openrewrite/scala/ScalaPrinter.java | 2 +- .../scala/internal/ScalaTreeVisitor.scala | 26 +++++++++++-------- .../scala/marker/ScalaMarkers.scala | 9 ++++--- .../scala/MethodDeclarationTest.java | 14 ++++++++++ 4 files changed, 35 insertions(+), 16 deletions(-) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index 609db2aec62..ac9cb3a5428 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -148,7 +148,7 @@ public J visitTypeParameter(J.TypeParameter typeParam, PrintOutputCapture

p) p.append(m.getKeyword()); } visit(typeParam.getName(), p); - typeParam.getMarkers().findFirst(org.openrewrite.scala.marker.ContextBoundSuffix.class) + typeParam.getMarkers().findFirst(org.openrewrite.scala.marker.TypeParameterBounds.class) .ifPresent(m -> p.append(m.text())); // Print bounds if present using Scala syntax. diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 6d76b99c160..232dda0b7aa 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -57,7 +57,7 @@ import org.openrewrite.scala.marker.UnderscorePlaceholderLambda import org.openrewrite.scala.marker.PartialFunctionLiteral import org.openrewrite.scala.marker.ContextFunctionArrow import org.openrewrite.scala.marker.CaptureSet -import org.openrewrite.scala.marker.ContextBoundSuffix +import org.openrewrite.scala.marker.TypeParameterBounds import org.openrewrite.scala.marker.Curried import org.openrewrite.scala.marker.InfixNotation import org.openrewrite.scala.marker.RightAssociative @@ -9668,13 +9668,17 @@ class ScalaTreeVisitor( case _ => null } - /** Consumes `: Bound` clauses sitting at the cursor inside a type-parameter list, returning - * their verbatim source. Stops at the `,` or `]` that closes the parameter. + /** Consumes bound clauses sitting at the cursor inside a type-parameter list — `: Bound`, + * `<: Bound` or `>: Bound` — returning their verbatim source. Stops at the `,` or `]` that + * closes the parameter. */ - private def consumeStrayContextBounds(): String = { + private def consumeStrayBounds(): String = { var i = cursor while (i < source.length && (source.charAt(i) == ' ' || source.charAt(i) == '\t')) i += 1 - if (i >= source.length || source.charAt(i) != ':') null + val opensBound = i < source.length && (source.charAt(i) == ':' || + ((source.charAt(i) == '<' || source.charAt(i) == '>') && + i + 1 < source.length && source.charAt(i + 1) == ':')) + if (!opensBound) null else { var depth = 0 var end = i @@ -9846,10 +9850,10 @@ class ScalaTreeVisitor( // - TypeBoundsTree: upper/lower bounds like `<: Comparable` or `>: Null` // - untpd.ContextBounds: context bounds like `: ClassTag` (wraps TypeBoundsTree + cxBounds list) // Each bound is wrapped in J.TypeBound to carry its Kind (Upper/Lower). - // Dotty does not record a context bound on a higher-kinded parameter's rhs, so `F[_]: Monad` - // leaves the bound unclaimed in source. - val strayContextBounds: String = tparam.rhs match { - case _: untpd.LambdaTypeTree => consumeStrayContextBounds() + // Dotty records bounds on a higher-kinded parameter's rhs as the kind itself, so the bounds + // written in source, `F[_]: Monad` or `It[a] <: Iterable[a]`, are left unclaimed. + val strayBounds: String = tparam.rhs match { + case _: untpd.LambdaTypeTree => consumeStrayBounds() case _ => null } @@ -9951,8 +9955,8 @@ class ScalaTreeVisitor( name, bounds ).withMarkers( - if (strayContextBounds == null) Markers.EMPTY - else Markers.EMPTY.add(ContextBoundSuffix(Tree.randomId(), strayContextBounds))) + if (strayBounds == null) Markers.EMPTY + else Markers.EMPTY.add(TypeParameterBounds(Tree.randomId(), strayBounds))) } /** diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala index fd2b425c887..dbba300c0da 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala @@ -177,11 +177,12 @@ case class PureFunctionArrow(id: UUID) extends Marker { } /** - * Context bounds on a higher-kinded type parameter, as in the `: Monad` of `[F[_]: Monad]`. - * Dotty records context bounds on the parameter's rhs only for a plain parameter, so for a - * higher-kinded one the source is kept verbatim and printed after the name. + * Bounds written on a higher-kinded type parameter: the `: Monad` of `[F[_]: Monad]` or the + * `<: Iterable[a]` of `[It[a] <: Iterable[a]]`. Dotty records bounds on the parameter's rhs + * only for a plain parameter, so for a higher-kinded one the rhs is the kind itself and the + * source is kept verbatim, to be printed after the name. */ -case class ContextBoundSuffix(id: UUID, text: String) extends Marker { +case class TypeParameterBounds(id: UUID, text: String) extends Marker { override def getId(): UUID = id override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java index b1e7aa0f2d7..233b90f0242 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java @@ -1199,4 +1199,18 @@ void contextBoundOnHigherKindedTypeParameter() { ); } + @Test + void upperBoundOnHigherKindedTypeParameter() { + rewriteRun( + scala( + """ + object Test { + def f[It[a] <: Iterable[a], A](x: A): A = x + def g[It1[a] <: Iterable[a], El2, It2[a] <: Iterable[a]](x: El2): El2 = x + } + """ + ) + ); + } + } From dd0ffee1413be623c8c9257b46eae221778a9b3f Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 05:52:43 +0200 Subject: [PATCH 24/55] Keep a trailing comma in an argument list Scala 3 allows a trailing comma before the closing paren of an argument list. The run before that paren was taken as whitespace, so the comma was hidden from recipes. It now rides on the last argument's padding through the TrailingComma marker the parameter lists already use, which the printer honours. Covers a call's arguments and a constructor's, and the shared builder used for annotations. --- .../scala/internal/ScalaTreeVisitor.scala | 78 ++++++++++++++----- .../scala/tree/MethodInvocationTest.java | 17 ++++ .../openrewrite/scala/tree/NewClassTest.java | 17 ++++ 3 files changed, 92 insertions(+), 20 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 232dda0b7aa..521cc820c11 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -507,16 +507,21 @@ class ScalaTreeVisitor( val arg = app.args(i) val argExpr = asExpression(visitTree(arg)) val argEnd = Math.max(0, arg.span.end - offsetAdjustment) + var lastMarkers = Markers.EMPTY val afterSpace = if (i == app.args.size - 1) { val closePos = positionOfNext(")", Math.max(cursor, argEnd)) - if (closePos > argEnd) ScalaSpace.format(source, argEnd, closePos) else Space.EMPTY + if (closePos > argEnd) { + val (sp, mk) = trailingCommaBefore(argEnd, closePos) + lastMarkers = mk + sp + } else Space.EMPTY } else { val commaPos = positionOfNext(",", Math.max(cursor, argEnd)) val space = if (commaPos > argEnd) ScalaSpace.format(source, argEnd, commaPos) else Space.EMPTY if (commaPos >= cursor) cursor = commaPos + 1 space } - args.add(new JRightPadded(argExpr, afterSpace, Markers.EMPTY)) + args.add(new JRightPadded(argExpr, afterSpace, lastMarkers)) } val closeParen = positionOfNext(")", cursor) if (closeParen >= 0) cursor = closeParen + 1 @@ -926,10 +931,10 @@ class ScalaTreeVisitor( } else { val argEnd = Math.max(0, arg.span.end - offsetAdjustment) val closePos = positionOfNext(")", Math.max(cursor, argEnd)) - val beforeClose = if (closePos > argEnd) { - ScalaSpace.format(source, argEnd, closePos) - } else Space.EMPTY - args.add(new JRightPadded(expr, beforeClose, Markers.EMPTY)) + val (beforeClose, lastMarkers) = if (closePos > argEnd) { + trailingCommaBefore(argEnd, closePos) + } else (Space.EMPTY, Markers.EMPTY) + args.add(new JRightPadded(expr, beforeClose, lastMarkers)) } } } @@ -975,6 +980,20 @@ class ScalaTreeVisitor( } } + /** Splits the run before a closing `)` into a trailing-comma marker and the space that + * follows it, so a source trailing comma round-trips instead of sitting in whitespace. + */ + private def trailingCommaBefore(from: Int, to: Int): (Space, Markers) = { + if (from >= to || to > source.length) (Space.EMPTY, Markers.EMPTY) + else { + val between = source.substring(from, to) + val commaIdx = positionOfNextIn(between, ",", 0) + if (commaIdx < 0) (ScalaSpace.format(source, from, to), Markers.EMPTY) + else (Space.format(between.substring(commaIdx + 1)), + Markers.EMPTY.add(TrailingComma.create(Space.format(between.substring(0, commaIdx))))) + } + } + private def visitMethodInvocation(app: Trees.Apply[?]): J = { val prefix = extractPrefixKeepingNew(app.span) @@ -1123,16 +1142,21 @@ class ScalaTreeVisitor( val arg = app.args(i) val argExpr = asExpression(visitTree(arg)) val argEnd = Math.max(0, arg.span.end - offsetAdjustment) + var lastMarkers = Markers.EMPTY val afterSpace = if (i == app.args.size - 1) { val closePos = positionOfNext(")", Math.max(cursor, argEnd)) - if (closePos > argEnd) ScalaSpace.format(source, argEnd, closePos) else Space.EMPTY + if (closePos > argEnd) { + val (sp, mk) = trailingCommaBefore(argEnd, closePos) + lastMarkers = mk + sp + } else Space.EMPTY } else { val commaPos = positionOfNext(",", Math.max(cursor, argEnd)) val space = if (commaPos > argEnd) ScalaSpace.format(source, argEnd, commaPos) else Space.EMPTY if (commaPos >= cursor) cursor = commaPos + 1 space } - outerArgs.add(new JRightPadded(argExpr, afterSpace, Markers.EMPTY)) + outerArgs.add(new JRightPadded(argExpr, afterSpace, lastMarkers)) } val closeParen = positionOfNext(")", cursor) @@ -1160,16 +1184,21 @@ class ScalaTreeVisitor( val arg = app.args(i) val argExpr = asExpression(visitTree(arg)) val argEnd = Math.max(0, arg.span.end - offsetAdjustment) + var lastMarkers = Markers.EMPTY val afterSpace = if (i == app.args.size - 1) { val closePos = positionOfNext(")", Math.max(cursor, argEnd)) - if (closePos > argEnd) ScalaSpace.format(source, argEnd, closePos) else Space.EMPTY + if (closePos > argEnd) { + val (sp, mk) = trailingCommaBefore(argEnd, closePos) + lastMarkers = mk + sp + } else Space.EMPTY } else { val commaPos = positionOfNext(",", Math.max(cursor, argEnd)) val space = if (commaPos > argEnd) ScalaSpace.format(source, argEnd, commaPos) else Space.EMPTY if (commaPos >= cursor) cursor = commaPos + 1 space } - outerArgs.add(new JRightPadded(argExpr, afterSpace, Markers.EMPTY)) + outerArgs.add(new JRightPadded(argExpr, afterSpace, lastMarkers)) } val closeParen = positionOfNext(")", cursor) if (closeParen >= 0) cursor = closeParen + 1 @@ -1985,15 +2014,15 @@ class ScalaTreeVisitor( // Apply the prefix space to the expression val exprWithPrefix: Expression = expr.withPrefix(argPrefix) - // For the last arg, capture trailing whitespace before ')'. - val afterSpace = if (i == app.args.size - 1) { + // For the last arg, capture the run before ')', which can hold a trailing comma. + val (afterSpace, lastMarkers) = if (i == app.args.size - 1) { val argEnd = Math.max(0, arg.span.end - offsetAdjustment) val closePos = positionOfNext(")", Math.max(cursor, argEnd)) - if (closePos > argEnd) ScalaSpace.format(source, argEnd, closePos) - else Space.EMPTY - } else Space.EMPTY + if (closePos > argEnd) trailingCommaBefore(argEnd, closePos) + else (Space.EMPTY, Markers.EMPTY) + } else (Space.EMPTY, Markers.EMPTY) - args.add(new JRightPadded[Expression](exprWithPrefix, afterSpace, Markers.EMPTY)) + args.add(new JRightPadded[Expression](exprWithPrefix, afterSpace, lastMarkers)) case j: J => // Scala statement-as-expression (e.g. if/else, match, block, try) wrapped so it // can sit in an argument list. Apply argPrefix and trailing space the same way @@ -9199,10 +9228,19 @@ class ScalaTreeVisitor( var i = 0 while (i < items.size) { val elem = convert(items(i)) - val after = - if (i < last) sourceBefore(",") - else sourceBefore(endDelim) - padded.add(JRightPadded.build(elem).withAfter(after)) + if (i < last) { + padded.add(JRightPadded.build(elem).withAfter(sourceBefore(","))) + } else { + // the run before the closing delimiter can hold a trailing comma + val delimIdx = positionOfNext(endDelim) + if (delimIdx < 0) { + padded.add(JRightPadded.build(elem).withAfter(sourceBefore(endDelim))) + } else { + val (after, elemMarkers) = trailingCommaBefore(cursor, delimIdx) + cursor = delimIdx + endDelim.length + padded.add(new JRightPadded(elem, after, elemMarkers)) + } + } i += 1 } JContainer.build(prefix, padded, markers) diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java index 30715b17fa0..bda8ec6f0ac 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java @@ -571,4 +571,21 @@ def f(using a: Int, b: Int): Int = a ); } + @Test + void trailingCommaInArguments() { + rewriteRun( + scala( + """ + object O { + def f(a: Int, b: Int): Int = a + val r = f( + 1, + 2, + ) + } + """ + ) + ); + } + } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java index 669c7c0abbb..897a01f2e75 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java @@ -409,4 +409,21 @@ class M[A](s: String)(f: Int => Int)(g: Int => Int) ); } + @Test + void trailingCommaInConstructorArguments() { + rewriteRun( + scala( + """ + class C(a: Int, b: Int) + object O { + val c = new C( + 1, + 2, + ) + } + """ + ) + ); + } + } From 8609d629712688337b331f516f74f732ac88e3c4 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 06:03:19 +0200 Subject: [PATCH 25/55] Keep `inline` written on a method parameter The untyped tree carries no flag for an inline parameter, and the parameter's span opens on the keyword rather than on the name, so neither the flag nor a search of the gap ahead of the name could find it and `inline op: Boolean` lost the modifier into whitespace. It is read from the source at the cursor, beside the existing handling for `using` and `implicit`. This was the largest remaining cause of unsound trees. --- .../scala/internal/ScalaTreeVisitor.scala | 18 +++++++++++++++++- .../scala/MethodDeclarationTest.java | 15 +++++++++++++++ .../org/openrewrite/scala/ScalaSweepTest.java | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 521cc820c11..6e72033bbd4 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -7101,7 +7101,23 @@ class ScalaTreeVisitor( val paramModifiers = new util.ArrayList[J.Modifier]() val isUsing = vd.mods != null && vd.mods.is(Flags.Given) val isScala2Implicit = vd.mods != null && vd.mods.is(Flags.Implicit) && !isUsing - val prefix: Space = if (isScala2Implicit || isUsing) { + // The untyped tree carries no flag for an `inline` parameter and its span opens on the + // keyword, so read it from the source at the cursor. + val inlineAt = if (isUsing || (vd.mods != null && vd.mods.is(Flags.Implicit))) -1 else { + var i = cursor + while (i < source.length && (source.charAt(i) == ' ' || source.charAt(i) == '\t')) i += 1 + val end = i + "inline".length + if (source.startsWith("inline", i) && + (end >= source.length || !(Character.isLetterOrDigit(source.charAt(end)) || source.charAt(end) == '_'))) i + else -1 + } + val prefix: Space = if (inlineAt >= 0) { + paramModifiers.add(new J.Modifier(Tree.randomId(), + if (inlineAt > cursor) Space.format(source.substring(cursor, inlineAt)) else Space.EMPTY, + Markers.EMPTY, "inline", J.Modifier.Type.LanguageExtension, Collections.emptyList())) + cursor = inlineAt + "inline".length + Space.EMPTY + } else if (isScala2Implicit || isUsing) { val keyword = if (isUsing) "using" else "implicit" val spanStart = Math.max(0, vd.span.start - offsetAdjustment) if (cursor < spanStart && spanStart <= source.length) { diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java index 233b90f0242..853743ff64d 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java @@ -1213,4 +1213,19 @@ void upperBoundOnHigherKindedTypeParameter() { ); } + @Test + void inlineParameter() { + rewriteRun( + scala( + """ + object Test { + inline def f(inline op: Boolean): Boolean = op + inline def g(a: Int, inline op: Boolean, b: Int): Boolean = op + inline def h(inline op: => Int): Int = op + } + """ + ) + ); + } + } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java index c6c06abb165..b56cf9b16c0 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java @@ -53,7 +53,7 @@ void sweep() throws IOException { Matcher mm = Pattern.compile("~~\\(non-whitespace\\)~~>(.{0,25})", Pattern.DOTALL).matcher(v.printAll()); String c = mm.find() ? wsCause(mm.group(1)) : "?"; wsCause.merge(c, 1, Integer::sum); - if (c.equals("type ascription") && wsSamples.size() < 10) wsSamples.add(sf.getSourcePath() + " |" + mm.group(1).replace("\n","\\n") + "|"); + if (c.equals("inline modifier") && wsSamples.size() < 10) wsSamples.add(sf.getSourcePath() + " |" + mm.group(1).replace("\n","\\n") + "|"); } } catch (UnsupportedOperationException ignored) {} } From f0790dc9c70ae37d96b381a0c2ab0dbeaf8c176d Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 06:12:54 +0200 Subject: [PATCH 26/55] Remove corpus-sweep scratch tests from the repository They hard-code paths under /tmp and are diagnostic harnesses, not part of the module's test suite. A directory-wide 'git add' re-added them after an earlier removal. --- .../org/openrewrite/scala/OneFileTest.java | 33 ------ .../org/openrewrite/scala/ScalaSweepTest.java | 101 ------------------ 2 files changed, 134 deletions(-) delete mode 100644 rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java delete mode 100644 rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java deleted file mode 100644 index 7e2ca351e54..00000000000 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/OneFileTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.openrewrite.scala; -import org.junit.jupiter.api.Test; -import org.openrewrite.*; -import org.openrewrite.tree.ParseError; -import java.nio.file.*; -import java.util.*; -import java.util.stream.Collectors; - -class OneFileTest { - @Test - void one() throws Exception { - String[] paths = { - "/tmp/scala-corpus/scala3/compiler/src/dotty/tools/dotc/cc/Setup.scala", - "/tmp/scala-corpus/scala3/compiler/src/dotty/tools/backend/sjs/JSCodeGen.scala"}; - StringBuilder sb = new StringBuilder(); - for (String ps : paths) { - Path path = Paths.get(ps); - List in = List.of(new Parser.Input(path, () -> { - try { return Files.newInputStream(path); } catch (Exception e) { throw new RuntimeException(e); } - })); - for (SourceFile sf : ScalaParser.builder().build() - .parseInputs(in, null, new InMemoryExecutionContext(t -> {})).collect(Collectors.toList())) { - sb.append("==== ").append(ps.substring(ps.lastIndexOf('/') + 1)).append('\n'); - if (sf instanceof ParseError) { - String m = sf.getMarkers().findFirst(ParseExceptionResult.class) - .map(ParseExceptionResult::getMessage).orElse("?"); - for (String l : m.split("\n")) { if (l.startsWith("-") || l.startsWith("+")) sb.append(l).append('\n'); } - } else sb.append("OK\n"); - } - } - Files.write(Paths.get("/tmp/one.txt"), sb.toString().getBytes()); - } -} diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java deleted file mode 100644 index b56cf9b16c0..00000000000 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java +++ /dev/null @@ -1,101 +0,0 @@ -package org.openrewrite.scala; -import org.junit.jupiter.api.Test; -import org.openrewrite.*; -import org.openrewrite.internal.WhitespaceValidationService; -import org.openrewrite.tree.ParseError; -import java.io.IOException; -import java.io.PrintWriter; -import java.nio.file.*; -import java.util.*; -import java.util.regex.*; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -class ScalaSweepTest { - static final String[] ROOTS = {"/tmp/scala-corpus/cats-effect", - "/tmp/scala-corpus/scala3/library/src", "/tmp/scala-corpus/scala3/compiler/src"}; - - @Test - void sweep() throws IOException { - List files = new ArrayList<>(); - for (String root : ROOTS) { - Path p = Paths.get(root); - if (!Files.exists(p)) continue; - try (Stream walk = Files.walk(p)) { - walk.filter(f -> f.toString().endsWith(".scala")).sorted().forEach(files::add); - } - } - int ok = 0, pf = 0, ws = 0; - Map peCause = new LinkedHashMap<>(), wsCause = new LinkedHashMap<>(); - List wsSamples = new ArrayList<>(); - for (int i = 0; i < files.size(); i += 20) { - List chunk = files.subList(i, Math.min(i + 20, files.size())); - List in = chunk.stream().map(f -> new Parser.Input(f, () -> { - try { return Files.newInputStream(f); } catch (IOException e) { throw new RuntimeException(e); } - })).collect(Collectors.toList()); - ExecutionContext ctx = new InMemoryExecutionContext(t -> {}); - List res; - try { res = ScalaParser.builder().build().parseInputs(in, null, ctx).collect(Collectors.toList()); } - catch (Throwable t) { pf += chunk.size(); continue; } - for (SourceFile sf : res) { - if (sf instanceof ParseError) { - pf++; - peCause.merge(peCause(sf.getMarkers().findFirst(ParseExceptionResult.class) - .map(ParseExceptionResult::getMessage).orElse("?")), 1, Integer::sum); - continue; - } - ok++; - try { - WhitespaceValidationService s = sf.service(WhitespaceValidationService.class); - SourceFile v = (SourceFile) s.getVisitor().visit(sf, ctx); - if (v != null && v != sf) { - ws++; - Matcher mm = Pattern.compile("~~\\(non-whitespace\\)~~>(.{0,25})", Pattern.DOTALL).matcher(v.printAll()); - String c = mm.find() ? wsCause(mm.group(1)) : "?"; - wsCause.merge(c, 1, Integer::sum); - if (c.equals("inline modifier") && wsSamples.size() < 10) wsSamples.add(sf.getSourcePath() + " |" + mm.group(1).replace("\n","\\n") + "|"); - } - } catch (UnsupportedOperationException ignored) {} - } - } - try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(Paths.get("/tmp/both-fresh.txt")))) { - w.printf("files=%d parseErrors=%d unsound=%d sound=%d%n%n== parse errors ==%n", files.size(), pf, ws, ok - ws); - peCause.entrySet().stream().sorted(Map.Entry.comparingByValue().reversed()) - .limit(14).forEach(e -> w.printf("%4d %s%n", e.getValue(), e.getKey())); - w.printf("%n== samples ==%n"); - wsSamples.forEach(x2 -> w.printf(" %s%n", x2)); - w.printf("%n== unsound ==%n"); - wsCause.entrySet().stream().sorted(Map.Entry.comparingByValue().reversed()) - .limit(10).forEach(e -> w.printf("%4d %s%n", e.getValue(), e.getKey())); - } - } - - private static String peCause(String m) { - if (m.contains("CapturesAndResult") || m.contains("did not produce a J.Annotation")) return "capture checking"; - if (m.contains("PolyFunction")) return "polymorphic function type"; - if (m.contains("Quote") || m.contains("Splice")) return "quote/splice"; - if (!m.contains("is not print idempotent")) return "throw: " + m.split("\n")[0].replaceAll("/\\S+/","").replaceAll("\\d+","N").trim(); - List d = new ArrayList<>(); - for (String l : m.split("\n")) if ((l.startsWith("-")||l.startsWith("+")) && !l.startsWith("---") && !l.startsWith("+++")) { d.add(l.trim()); if (d.size()==2) break; } - String j = String.join(" || ", d); - if (j.contains("=>")) return "print: arrow | " + (j.length() > 70 ? j.substring(0, 70) : j); - if (j.contains("using")) return "print: using"; - if (j.contains("end ")) return "print: end marker"; - if (j.contains(";")) return "print: semicolon"; - return "print: other | " + (j.length()>60 ? j.substring(0,60) : j); - } - - private static String wsCause(String s) { - String t = s.trim(); - if (t.startsWith("using")) return "using args"; - if (t.startsWith("inline")) return "inline modifier"; - if (t.startsWith("end ")) return "end marker"; - if (t.startsWith("=")) return "method body ="; - if (t.contains("=>")) return "self type / arrow"; - if (t.startsWith("^")) return "capture set"; - if (t.startsWith(":")) return "type ascription"; - if (t.startsWith(",")) return "comma"; - if (t.startsWith("private")||t.startsWith("protected")) return "access modifier"; - return "other: " + (t.length()>16 ? t.substring(0,16) : t); - } -} From 4b176bd0602322dc41025e671496a546273b9b38 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 07:01:04 +0200 Subject: [PATCH 27/55] Visit a parenthesized or tuple annotated type in type position `(Context ?=> Symbol) @unchecked` and `Iterable[(K, V) @unchecked]` annotate a type written with parentheses. Visiting the annotated element in expression position gave a J.Parentheses or a tuple expression, neither of which is a TypeTree, so the annotation threw and took the file with it. Both forms are now visited as types, which also keeps them usable as expressions. Parse errors over the corpus drop from 81 to what these ten files free up. --- .../scala/internal/ScalaTreeVisitor.scala | 15 +++++++++- .../scala/tree/AnnotatedExprTest.java | 30 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 6e72033bbd4..6ac4de1a543 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -8003,7 +8003,14 @@ class ScalaTreeVisitor( // right LST node: `S.AnnotatedExpression` for the former, `S.AnnotatedType` for // the latter. val prefix = extractPrefix(ann.span) - val arg: J = visitTree(ann.arg) + // A parenthesized annotated arg is a type, `(Context ?=> Symbol) @unchecked`, and + // J.Parentheses is not a TypeTree. The parenthesized type form is also an Expression, + // so it still serves an annotated expression. + val arg: J = ann.arg match { + case p: untpd.Parens => Option(visitParentType(p)).map(_.asInstanceOf[J]).getOrElse(visitTree(ann.arg)) + case t: untpd.Tuple => Option(visitTypeTree(t)).map(_.asInstanceOf[J]).getOrElse(visitTree(ann.arg)) + case _ => visitTree(ann.arg) + } // Capture-checking syntax (`T^`, `T^{it}`) desugars to a synthetic `retains` annotation // with no `@` in source, so it stays a suffix on the type it follows. val captureText = consumeCaptureSet() @@ -8041,6 +8048,12 @@ class ScalaTreeVisitor( if (isAnnotatedType) { val typeExpr: TypeTree = arg match { case tt: TypeTree => tt + // `(Context ?=> Symbol) @unchecked`: the parenthesized form is a type, and + // J.Parentheses is not a TypeTree + case par: J.Parentheses[?] if par.getTree.isInstanceOf[TypeTree] => + val inner = par.withPrefix[J.Parentheses[TypeTree]](Space.EMPTY) + new J.ParenthesizedTypeTree(Tree.randomId(), par.getPrefix, Markers.EMPTY, + Collections.emptyList(), inner) case _ => throw new UnsupportedOperationException( s"Annotated.arg in type position did not produce a TypeTree: ${ann.arg.getClass.getSimpleName}") } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java index 1a49e73f6e1..bf337167b0c 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java @@ -96,4 +96,34 @@ def f(): List[Int]^{this} = Nil ); } + @Test + void parenthesizedAnnotatedType() { + rewriteRun( + scala( + """ + object O { + def f(g: (Int => Long) @unchecked): Int = 1 + def h(g: (Int ?=> Long) @unchecked): Int = 1 + } + """ + ) + ); + } + + @Test + void tupleAnnotatedTypeArgument() { + rewriteRun( + scala( + """ + object O { + def f(x: Any): Int = x match { + case it: Iterable[(Int, Long) @unchecked] => 1 + case _ => 0 + } + } + """ + ) + ); + } + } From ef6d7096e2eec88d440469fb696b4c6e41771e00 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 08:11:14 +0200 Subject: [PATCH 28/55] Keep a trailing comma in import selectors Scala 3 allows a trailing comma before the brace closing an import's selector list. The loop consumed that comma as an ordinary separator and then found whitespace where it required the `}`, failing the whole file. The last selector now carries the comma on the TrailingComma marker the printer already honours. --- .../scala/internal/ScalaTreeVisitor.scala | 11 ++++++++++- .../org/openrewrite/scala/tree/ImportTest.java | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 6ac4de1a543..23c68ef8a43 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -2728,7 +2728,16 @@ class ScalaTreeVisitor( if (afterChar == ',') { val sepSpace = ScalaSpace.format(source.substring(afterSel, q)) cursor = q + 1 - selectorElems.add(new JRightPadded(selectorNode.withPrefix(selPrefix), sepSpace, Markers.EMPTY)) + if (idx == selectors.size - 1) { + // Scala 3 allows a trailing comma before the closing brace + val afterComma = indexOfNextNonWhitespace(cursor) + val tailSpace = ScalaSpace.format(source.substring(cursor, afterComma)) + cursor = afterComma + selectorElems.add(new JRightPadded(selectorNode.withPrefix(selPrefix), tailSpace, + Markers.EMPTY.add(TrailingComma.create(sepSpace)))) + } else { + selectorElems.add(new JRightPadded(selectorNode.withPrefix(selPrefix), sepSpace, Markers.EMPTY)) + } } else if (afterChar == '}') { val tailSpace = ScalaSpace.format(source.substring(afterSel, q)) cursor = q diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ImportTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ImportTest.java index 5ab8a8cd3cc..fc83afce1d0 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ImportTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ImportTest.java @@ -436,4 +436,20 @@ void wildcardImportAfterTrailingComment() { ); } + @Test + void trailingCommaInSelectors() { + rewriteRun( + scala( + """ + import java.math.{ + BigDecimal => BigDec, + MathContext, + RoundingMode => JRM, + } + class X + """ + ) + ); + } + } From 02e38a7404b004ff431f59aba6b2f5185f9d4c5a Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 08:26:55 +0200 Subject: [PATCH 29/55] Print `final var` as written, and keep the space after an annotation's `@` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The printer decided between `val` and `var` by looking for a Final modifier, so an explicit `final` turned `final var` into `final val`. The two are orthogonal in Scala — `final` governs overriding, `val`/`var` mutability — and the standard library writes `private final var`. The keyword the source used is now recorded by the parser instead of being inferred. An annotation built its name without the run between the `@` and the name, so `String @ unchecked` printed as `String @unchecked`. --- .../java/org/openrewrite/scala/ScalaPrinter.java | 5 +++++ .../scala/internal/ScalaTreeVisitor.scala | 11 ++++++++++- .../openrewrite/scala/marker/ScalaMarkers.scala | 9 +++++++++ .../scala/tree/AnnotatedExprTest.java | 16 ++++++++++++++++ .../scala/tree/VariableDeclarationsTest.java | 13 +++++++++++++ 5 files changed, 53 insertions(+), 1 deletion(-) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index ac9cb3a5428..678e6b635d6 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -1349,6 +1349,11 @@ public J visitVariableDeclarations(J.VariableDeclarations multiVariable, PrintOu } } + // An explicit `final` does not decide the keyword: `final var` is legal + if (multiVariable.getMarkers().findFirst(org.openrewrite.scala.marker.VarKeyword.class).isPresent()) { + valVarKeyword = "var"; + } + // Print val/var/given (unless it's a lambda parameter) if (!isLambdaParam) { if (valVarPrefix != null) { diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 23c68ef8a43..c5ac23cccf5 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -631,7 +631,13 @@ class ScalaTreeVisitor( // Create the annotation type: an Ident for a simple name (@deprecated) or a // Select chain for a qualified name (@scala.annotation.implicitNotFound). val annotTypeTree: NameTree = tpt match { - case id: Trees.Ident[?] => ident(id.name.toString, quoted = isBacktickQuoted(id.span)) + case id: Trees.Ident[?] => + // `T @ unchecked`: the space between `@` and the name belongs to the name + val nameStart = Math.max(0, id.span.start - offsetAdjustment) + val atIdx = if (nameStart <= source.length) source.lastIndexOf('@', nameStart) else -1 + val afterAt = if (atIdx >= 0 && atIdx + 1 < nameStart) Space.format(source.substring(atIdx + 1, nameStart)) + else Space.EMPTY + ident(id.name.toString, afterAt, quoted = isBacktickQuoted(id.span)) case sel: Trees.Select[?] => // Qualified name: skip the leading '@', then map the Select chain to a J.FieldAccess. val selStart = Math.max(0, sel.span.start - offsetAdjustment) @@ -3553,6 +3559,9 @@ class ScalaTreeVisitor( if (isGiven) { markerList.add(org.openrewrite.scala.marker.Given(Tree.randomId())) } + if (valVarKeyword == "var") { + markerList.add(org.openrewrite.scala.marker.VarKeyword(Tree.randomId())) + } val variableMarkers = { val base = if (markerList.isEmpty) Markers.EMPTY else Markers.build(markerList) endMarkerMarkers.findFirst(classOf[EndMarker]).map[Markers](m => base.add(m)).orElse(base) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala index dbba300c0da..1eb4fbe9200 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala @@ -88,6 +88,15 @@ case class Curried(id: UUID) extends Marker { * Carries the source text between the last annotation/modifier and the * `val`/`var`/`given` keyword for Scala variable declarations. */ +/** + * Marks a `J.VariableDeclarations` written with `var`. A `val` is implicitly final, but an + * explicit `final` says nothing about which keyword the source used, and `final var` is legal. + */ +case class VarKeyword(id: UUID) extends Marker { + override def getId(): UUID = id + override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] +} + case class ValVarKeyword(id: UUID, beforeKeyword: String) extends Marker { override def getId(): UUID = id override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java index bf337167b0c..e4a8402f8ad 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java @@ -126,4 +126,20 @@ def f(x: Any): Int = x match { ); } + @Test + void spaceBetweenAtAndAnnotationName() { + rewriteRun( + scala( + """ + object O { + def f(x: Any): Int = x match { + case tree: String @ unchecked => 1 + case _ => 0 + } + } + """ + ) + ); + } + } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java index 9446f6e5299..3b4b81d89bd 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java @@ -282,4 +282,17 @@ void inlineGiven() { ); } + @Test + void finalVar() { + rewriteRun( + scala( + """ + class C { + private final var isBlocked: Boolean = false + } + """ + ) + ); + } + } From 3958acee19643957463ddfb7de7fe0df7059af8b Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 08:43:23 +0200 Subject: [PATCH 30/55] Record an empty run before a method body's `=`, and visit extension parameters as parameters Where the source wrote `def desc(sym: Int)= {`, the parser recorded no prefix for the `=` and the printer fell back to the body's prefix, printing that space on both sides of the `=`. The run is now recorded whenever an `=` is consumed, empty or not. An extension's parameters went through the general definition path, which reads a name from the ValDef's span. That span opens on a modifier, so `extension (inline x: String)` printed as `(inlinexnline x: String)`. They now use the parameter visitor, which reads the modifier before the name. --- .../scala/internal/ScalaTreeVisitor.scala | 13 +++++++-- .../scala/MethodDeclarationTest.java | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index c5ac23cccf5..14f962f799e 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -6820,6 +6820,7 @@ class ScalaTreeVisitor( // Handle method body var beforeEqualsSpace: Space = Space.EMPTY + var sawEquals = false val body: J.Block = dd.rhs match { case rhs if isProcedureSyntax && rhs.span.isSynthetic => // Procedure syntax: Scala 3 parser replaces body with `_root_.scala.Predef.???`. @@ -6834,6 +6835,7 @@ class ScalaTreeVisitor( if (equalsIdx >= 0) { beforeEquals = Space.format(beforeBody.substring(0, equalsIdx)) cursor = cursor + equalsIdx + 1 + sawEquals = true } } beforeEqualsSpace = beforeEquals @@ -6913,7 +6915,9 @@ class ScalaTreeVisitor( if (isCurried) { markerList.add(new Curried(Tree.randomId())) } - if (beforeEqualsSpace != Space.EMPTY) { + // Record the run before `=` even when empty, so the printer does not fall back to the + // body's prefix and print it on both sides of the `=`. + if (sawEquals) { markerList.add(org.openrewrite.scala.marker.MethodBodyEqualsPrefix.create(beforeEqualsSpace)) } if (endMarkerText != null) { @@ -8177,7 +8181,12 @@ class ScalaTreeVisitor( var i = 0 while (i < firstClause.size) { val pTree = firstClause.get(i) - val pJ = visitTree(pTree) + // Parameter position: a ValDef here can carry `inline`, whose keyword the general + // definition path would mistake for part of the name. + val pJ = pTree match { + case vd: Trees.ValDef[?] => visitMethodParameter(vd) + case other => visitTree(other) + } val pStmt: Statement = pJ match { case s: Statement => s case _ => throw new UnsupportedOperationException( diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java index 853743ff64d..409601a29c7 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java @@ -1228,4 +1228,33 @@ inline def h(inline op: => Int): Int = op ); } + @Test + void noSpaceBeforeBodyEquals() { + rewriteRun( + scala( + """ + object Test { + def desc(sym: Int)= { + 1 + } + } + """ + ) + ); + } + + @Test + void inlineExtensionParameter() { + rewriteRun( + scala( + """ + object Test { + extension (inline x: String) + inline def foo: Int = 1 + } + """ + ) + ); + } + } From f22ac72008ff6e27e5cfd1367005c297cad7d56c Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 10:46:29 +0200 Subject: [PATCH 31/55] Find the `match` selector dot outside comments A block comment ahead of `match`, as in `tp/*.dealias*/ match`, contains a `.` that made the parser read the expression as a selector-style `tp.match`, splitting the comment across the keyword space and the marker. The dot search now skips comment text, so only a real `.` selects that form. --- .../scala/internal/ScalaTreeVisitor.scala | 6 +++--- .../java/org/openrewrite/scala/tree/MatchTest.java | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 14f962f799e..14c4e9f21d0 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -7482,11 +7482,11 @@ class ScalaTreeVisitor( // Scala 3 selector-style match `selector.match { ... }`: the `match` keyword is preceded // by a `.`. Record it via a marker so the dot isn't stored as (non-whitespace) Space. val beforeMatch = if (mi > 0) ms.substring(0, mi) else "" - val dotIdx = beforeMatch.indexOf('.') + val dotIdx = positionOfNextIn(beforeMatch, ".", 0) val isDottedMatch = dotIdx >= 0 val matchKeywordSpace = - if (isDottedMatch) Space.format(beforeMatch.substring(0, dotIdx)) - else if (mi > 0) Space.format(beforeMatch) + if (isDottedMatch) ScalaSpace.format(beforeMatch.substring(0, dotIdx)) + else if (mi > 0) ScalaSpace.format(beforeMatch) else Space.EMPTY if (mi >= 0) cursor = cursor + mi + 5 // Scala 3 `x match\n case ...` has no `{` before the cases — detect that form. diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MatchTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MatchTest.java index c65a77ca723..4053b246fa3 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MatchTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MatchTest.java @@ -605,4 +605,17 @@ void significantCharactersInComments() { ) ); } + + @Test + void blockCommentBeforeMatchKeyword() { + rewriteRun( + scala( + """ + def tupleArity(tp: Int): Int = tp/*.dealias*/ match { + case _ => 1 + } + """ + ) + ); + } } From 3bdbc41a8a427af7c76a9a3fb71b3d0d037a806e Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 10:49:09 +0200 Subject: [PATCH 32/55] Skip comments when scanning for keywords in the parser The modifier scan matched any whole-word keyword in its text window, so a commented-out modifier such as the `/*final*/` of `private /*final*/ case class C` became a real modifier and split the comment across two spaces. Keyword lookup now uses the comment-aware search, which also covers the `val`/`var`/`given`, `class`/`trait`/`enum`, `object`, `case` and `package` lookups. --- .../scala/internal/ScalaTreeVisitor.scala | 15 ++------------- .../scala/tree/ClassDeclarationTest.java | 8 ++++++++ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 14c4e9f21d0..102ac6a6cdc 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -9655,19 +9655,8 @@ class ScalaTreeVisitor( i } - /** Find a keyword in text, ensuring it's a whole word (not part of an identifier). */ - private def findKeyword(text: String, keyword: String): Int = { - var pos = 0 - while (pos < text.length) { - val idx = text.indexOf(keyword, pos) - if (idx < 0) return -1 - val before = idx == 0 || !Character.isLetterOrDigit(text.charAt(idx - 1)) - val after = idx + keyword.length >= text.length || !Character.isLetterOrDigit(text.charAt(idx + keyword.length)) - if (before && after) return idx - pos = idx + 1 - } - -1 - } + /** Find a keyword in text, as a whole word and outside of comments. */ + private def findKeyword(text: String, keyword: String): Int = positionOfNextIn(text, keyword, 0) /** * Parse modifier keywords out of a raw text window — the source text between the diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java index bc5fae8b68d..b2fcc31b64d 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java @@ -455,6 +455,14 @@ class C[A /* <: */ <: AnyRef] """ ) ); + // parseModifierKeywords — modifier keyword in block comment between modifiers + rewriteRun( + scala( + """ + private /*final*/ case class SomePrintedTree(phase: String) + """ + ) + ); } @Test From 13cecc8ffc6568f9fac49ab1bdfd045d5a02955d Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 11:01:23 +0200 Subject: [PATCH 33/55] Consume curried parameter lists when the first list is empty The loop that visits a method's curried parameter lists sat inside the branch for a non-empty first list, so `def f()(c: Int): Int` left its second list unconsumed. The return-type scan then took that list's `:` for the return-type colon and swallowed `Int):` into the return type's prefix, hiding it from recipes. --- .../scala/internal/ScalaTreeVisitor.scala | 19 +++++++++++-------- .../scala/MethodDeclarationTest.java | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 102ac6a6cdc..1d6b165aa90 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -6756,14 +6756,6 @@ class ScalaTreeVisitor( if (closeParen >= 0) cursor = closeParen + 1 } - // Build additional param lists as J.Lambda.Parameters for later wrapping - for (extraList <- valueParamLists.tail) { - val extraParams = extraList.collect { case vd: Trees.ValDef[?] => vd } - if (extraParams.nonEmpty) { - curriedParamLists.add(visitParamListAsLambdaParams(extraParams)) - } - } - JContainer.build(parenSpace, jParams, Markers.EMPTY) } else if (hasParensInSource) { buildEmptyParamList() @@ -6780,6 +6772,17 @@ class ScalaTreeVisitor( Markers.build(Collections.singletonList(new org.openrewrite.scala.marker.OmitBraces(Tree.randomId())))) } + // Visited here so the cursor stays in token order: the curried lists sit between the + // first list and the return type, whose `:` the scan below looks for. + if (valueParamLists.nonEmpty) { + for (extraList <- valueParamLists.tail) { + val extraParams = extraList.collect { case vd: Trees.ValDef[?] => vd } + if (extraParams.nonEmpty) { + curriedParamLists.add(visitParamListAsLambdaParams(extraParams)) + } + } + } + // Handle return type `: ReturnType` — only if explicitly written in source val returnTypeExpression: TypeTree = dd.tpt match { case tpt if tpt != untpd.EmptyTree && tpt.span.exists => diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java index 409601a29c7..9b3811d6ee9 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java @@ -372,6 +372,22 @@ def add(x:Int)(y:Int) = x + y ); } + @Test + void curriedMethodWithEmptyFirstParameterList() { + rewriteRun( + scala( + """ + object Test { + def f()(c: Int): Int = c + def g()(using c: Int): Unit = { + () + } + } + """ + ) + ); + } + @Test void multilineParameterListWithClosingParenOnOwnLine() { rewriteRun( From 4d2b198e988231fdbf9e13cc0eb9b6b8bc91d1fd Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 11:14:56 +0200 Subject: [PATCH 34/55] Print the body prefix of a curried method A curried method with a braceless single-statement body printed the statement without the block's own prefix. An auxiliary constructor keeps the space after the `=` there, because Dotty wraps its body in a block, so `def this()(implicit o: Ordering[K]) = this(null)` came back as `=this(null)`. --- .../java/org/openrewrite/scala/ScalaPrinter.java | 1 + .../openrewrite/scala/MethodDeclarationTest.java | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index 678e6b635d6..a5fe55c6890 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -595,6 +595,7 @@ public J visitMethodDeclaration(J.MethodDeclaration method, PrintOutputCapture

Date: Sun, 16 Aug 2026 11:19:06 +0200 Subject: [PATCH 35/55] Leave the space ahead of a capture-set type to its enclosing type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A type carrying a capture set took the space ahead of it from the source whenever its extracted prefix was empty. Inside an enclosing type — the `|` of `Iterator[Int]^{this} | Null` or the `=>` of a function type — that space belongs to the enclosing type, which had already emitted it, so it was printed twice. --- .../scala/internal/ScalaTreeVisitor.scala | 12 +++--------- .../scala/tree/AnnotatedExprTest.java | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 1d6b165aa90..9f4c980d562 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -8041,17 +8041,11 @@ class ScalaTreeVisitor( val captureText = consumeCaptureSet() if (captureText != null) { updateCursor(ann.span.end) - // The wrapped type carries the space ahead of it. Callers that position the cursor - // at the type leave the extracted prefix empty, so recover it from the source. - val typePrefix = if (prefix != Space.EMPTY) prefix else { - val start = Math.max(0, ann.span.start - offsetAdjustment) - var b = start - while (b > 0 && (source.charAt(b - 1) == ' ' || source.charAt(b - 1) == '\t')) b -= 1 - if (b < start) Space.format(source.substring(b, start)) else Space.EMPTY - } return arg match { case tt: TypeTree => - val prefixed: TypeTree = tt.withPrefix[TypeTree](typePrefix) + // The wrapped type carries the space ahead of it, which an enclosing type such as + // the `|` of `T^{this} | Null` has already claimed when the prefix comes back empty. + val prefixed: TypeTree = tt.withPrefix[TypeTree](prefix) prefixed.withMarkers[TypeTree]( prefixed.getMarkers.add(CaptureSet(Tree.randomId(), captureText))).asInstanceOf[J] case other => other diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java index e4a8402f8ad..b54cd1dec45 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AnnotatedExprTest.java @@ -126,6 +126,22 @@ def f(x: Any): Int = x match { ); } + @Test + void captureSetSuffixInsideEnclosingType() { + rewriteRun( + scala( + """ + import language.experimental.captureChecking + class C { + var it: Iterator[Int]^{this} | Null = null + def go(f: Iterable[Int]^{this} => Int): Int = 1 + def trySplit(): Iterator[Int]^{this} | Null = null + } + """ + ) + ); + } + @Test void spaceBetweenAtAndAnnotationName() { rewriteRun( From fee33043b34e128c8449ce8d396e2c6a2156f299 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 11:25:40 +0200 Subject: [PATCH 36/55] Model the `then` after a parenthesized if condition Scala 3 accepts `if (cond) then ...` as well as the parenless `if cond then ...`. Only the latter consumed the keyword, so in the former it rode along inside the then-branch's prefix, where recipes cannot see it. It is now kept on a marker and printed between the condition and the branch. --- .../org/openrewrite/scala/ScalaPrinter.java | 16 ++++++++++++- .../scala/internal/ScalaTreeVisitor.scala | 23 +++++++++++-------- .../scala/marker/ScalaMarkers.scala | 9 ++++++++ .../scala/tree/ControlFlowTest.java | 18 +++++++++++++++ 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index a5fe55c6890..8cf44eaf44d 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -43,6 +43,7 @@ import org.openrewrite.scala.marker.SObject; import org.openrewrite.scala.marker.Semicolon; import org.openrewrite.scala.marker.TrailingComma; +import org.openrewrite.scala.marker.ThenKeyword; import org.openrewrite.scala.marker.TypeProjection; import org.openrewrite.scala.marker.ScalaForLoop; import org.openrewrite.scala.marker.TypeAscription; @@ -280,7 +281,20 @@ public J visitWhileLoop(J.WhileLoop whileLoop, PrintOutputCapture

p) { @Override public J visitIf(J.If iff, PrintOutputCapture

p) { if (!iff.getMarkers().findFirst(IndentedSyntax.class).isPresent()) { - return super.visitIf(iff, p); + Optional then = iff.getMarkers().findFirst(ThenKeyword.class); + if (!then.isPresent()) { + return super.visitIf(iff, p); + } + beforeSyntax(iff, Space.Location.IF_PREFIX, p); + p.append("if"); + visit(iff.getIfCondition(), p); + p.append(then.get().text()); + visit(iff.getThenPart(), p); + if (iff.getElsePart() != null) { + visit(iff.getElsePart(), p); + } + afterSyntax(iff, p); + return iff; } // Scala 3 paren-less form: `if cond then thenp [else elsep]` beforeSyntax(iff, Space.Location.IF_PREFIX, p); diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 9f4c980d562..210db07a510 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -42,6 +42,7 @@ import org.openrewrite.scala.marker.EndMarker import org.openrewrite.scala.marker.KindParameterVariance import org.openrewrite.scala.marker.ParentSeparator import org.openrewrite.scala.marker.SObject +import org.openrewrite.scala.marker.ThenKeyword import org.openrewrite.scala.marker.SelfType import org.openrewrite.scala.marker.Semicolon import org.openrewrite.scala.marker.TrailingComma @@ -4325,15 +4326,17 @@ class ScalaTreeVisitor( // For Scala 3 `if cond then ...`, advance the cursor past the `then` keyword // (which sits between the condition and the then-branch). The space before `then` // is absorbed into afterCondSpace; space after is the thenp's prefix. - if (ifIsParenless) { - val thenpStart = Math.max(0, ifTree.thenp.span.start - offsetAdjustment) - if (cursor < thenpStart && thenpStart <= source.length) { - val between = source.substring(cursor, thenpStart) - val thenIdx = positionOfNextIn(between, "then", 0) - if (thenIdx >= 0) { - afterCondSpace = Space.format(between.substring(0, thenIdx)) - cursor = cursor + thenIdx + 4 - } + var thenKeywordText: String = null + val thenpStart = Math.max(0, ifTree.thenp.span.start - offsetAdjustment) + if (cursor < thenpStart && thenpStart <= source.length) { + val between = source.substring(cursor, thenpStart) + val thenIdx = positionOfNextIn(between, "then", 0) + if (thenIdx >= 0) { + // A parenthesized condition may be followed by `then` as well, and there the + // keyword sits outside the control parentheses. + if (ifIsParenless) afterCondSpace = Space.format(between.substring(0, thenIdx)) + else thenKeywordText = between.substring(0, thenIdx + 4) + cursor = cursor + thenIdx + 4 } } @@ -4386,6 +4389,8 @@ class ScalaTreeVisitor( val ifBaseMarkers = if (ifIsParenless) Markers.build(Collections.singletonList(new IndentedSyntax(Tree.randomId()))) + else if (thenKeywordText != null) + Markers.build(Collections.singletonList(ThenKeyword(Tree.randomId(), thenKeywordText))) else Markers.EMPTY val ifMarkers = withEndMarker(ifBaseMarkers, ifTree.span) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala index 1eb4fbe9200..7d86a9df416 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala @@ -238,6 +238,15 @@ case class ConstructorModifier(id: UUID, text: String) extends Marker { override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] } +/** + * The `then` of a Scala 3 `if (cond) then ...`, which is optional after a parenthesized + * condition. Holds the verbatim source from the closing `)` through the keyword. + */ +case class ThenKeyword(id: UUID, text: String) extends Marker { + override def getId(): UUID = id + override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] +} + /** * The keyword introducing one parent in a class declaration's parent list. Scala 3 * accepts either `with` or `,` and the two may be mixed (`extends A, B with C`), so diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java index eef591a6526..6eab5cdf0b9 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java @@ -253,6 +253,24 @@ void ifElseIfElse() { ); } + @Test + void thenAfterParenthesizedCondition() { + rewriteRun( + scala( + """ + object Test { + def f(x: Boolean): Int = if (x) then 1 else 2 + def g(x: Boolean): Int = + if (x) then + 1 + else + 2 + } + """ + ) + ); + } + @Test void forLoop() { rewriteRun( From 02a0bb7ca3c1703d2e008a03b8455cd83f20a04f Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 11:36:32 +0200 Subject: [PATCH 37/55] Model the `case` of a pattern-filtering for generator `for case (k, v) <- pairs` filters by pattern. Dotty leaves the keyword out of both the enumerator and the pattern it introduces, so it rode along in a Space where recipes cannot see it. It is now marked on the enumerator and printed ahead of the pattern. --- .../org/openrewrite/scala/ScalaPrinter.java | 4 ++++ .../scala/internal/ScalaTreeVisitor.scala | 21 +++++++++++++++++-- .../scala/marker/ScalaMarkers.scala | 9 ++++++++ .../openrewrite/scala/tree/ForYieldTest.java | 21 +++++++++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index 8cf44eaf44d..b755c0fe55f 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -43,6 +43,7 @@ import org.openrewrite.scala.marker.SObject; import org.openrewrite.scala.marker.Semicolon; import org.openrewrite.scala.marker.TrailingComma; +import org.openrewrite.scala.marker.CasePattern; import org.openrewrite.scala.marker.ThenKeyword; import org.openrewrite.scala.marker.TypeProjection; import org.openrewrite.scala.marker.ScalaForLoop; @@ -2264,6 +2265,9 @@ public J visitFor(S.For forLoop, PrintOutputCapture

p) { public J visitForEnumerator(S.For.Enumerator enumerator, PrintOutputCapture

p) { beforeSyntax(enumerator.getPrefix(), enumerator.getMarkers(), Space.Location.LANGUAGE_EXTENSION, p); + if (enumerator.getMarkers().findFirst(CasePattern.class).isPresent()) { + p.append("case"); + } switch (enumerator.getKind()) { case Generator: if (enumerator.getLhs() != null) visit(enumerator.getLhs(), p); diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 210db07a510..112d3c4fa4d 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -41,6 +41,7 @@ import org.openrewrite.scala.marker.DerivesClause import org.openrewrite.scala.marker.EndMarker import org.openrewrite.scala.marker.KindParameterVariance import org.openrewrite.scala.marker.ParentSeparator +import org.openrewrite.scala.marker.CasePattern import org.openrewrite.scala.marker.SObject import org.openrewrite.scala.marker.ThenKeyword import org.openrewrite.scala.marker.SelfType @@ -8340,6 +8341,7 @@ class ScalaTreeVisitor( case _ => (S.For.Enumerator.Kind.Guard, null, enumTree, "if") } + var casePattern = false val enumPrefix: Space = if (kind == S.For.Enumerator.Kind.Guard) { // Locate the `if` keyword. Everything before `if` is this enumerator's prefix. val rhsStart = Math.max(0, rhsTree.span.start - offsetAdjustment) @@ -8351,7 +8353,19 @@ class ScalaTreeVisitor( s } else Space.EMPTY } else { - extractPrefix(enumTree.span) + // `for case (k, v) <- pairs` filters by pattern. Dotty leaves the keyword out of both + // the enumerator and the pattern, so consume it here; the pattern keeps the space + // that follows. + val lhsStart = Math.max(0, lhsTree.span.start - offsetAdjustment) + val lead = if (cursor < lhsStart && lhsStart <= source.length) source.substring(cursor, lhsStart) else "" + val caseIdx = positionOfNextIn(lead, "case", 0) + if (caseIdx >= 0) { + casePattern = true + cursor = cursor + caseIdx + 4 + Space.format(lead.substring(0, caseIdx)) + } else { + extractPrefix(enumTree.span) + } } var lhs: J = null @@ -8418,7 +8432,10 @@ class ScalaTreeVisitor( } else Space.EMPTY } - val enumerator = new S.For.Enumerator(Tree.randomId(), enumPrefix, Markers.EMPTY, + val enumMarkers = if (casePattern) + Markers.build(Collections.singletonList(CasePattern(Tree.randomId()))) + else Markers.EMPTY + val enumerator = new S.For.Enumerator(Tree.randomId(), enumPrefix, enumMarkers, kind, lhs, beforeOp, rhs) new JRightPadded(enumerator, after, rpMarkers) } diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala index 7d86a9df416..036d3197f51 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala @@ -238,6 +238,15 @@ case class ConstructorModifier(id: UUID, text: String) extends Marker { override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] } +/** + * The `case` of a for-comprehension generator that filters by pattern, + * `for case (k, v) <- pairs do ...`. + */ +case class CasePattern(id: UUID) extends Marker { + override def getId(): UUID = id + override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] +} + /** * The `then` of a Scala 3 `if (cond) then ...`, which is optional after a parenthesized * condition. Holds the verbatim source from the closing `)` through the keyword. diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ForYieldTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ForYieldTest.java index 0acf89c81fb..0c9727ee7f5 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ForYieldTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ForYieldTest.java @@ -151,6 +151,27 @@ void anonymousGivenBinding() { ); } + @Test + void casePatternGenerator() { + rewriteRun( + scala( + """ + object Test { + def f(xs: List[(Int, Int)]): List[Int] = + for case (a, b) <- xs yield a + def g(xs: List[Any]): Unit = + for case s: String <- xs do println(s) + def h(xs: List[Any]): Unit = { + for { + case s: String <- xs + } println(s) + } + } + """ + ) + ); + } + @Test void namedGivenBinding() { rewriteRun( From f40b3c9a1be65938cfc7ecd13fd46debdccc1487 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 11:48:11 +0200 Subject: [PATCH 38/55] Model the `do` after a parenthesized loop head Scala 3 accepts `while (cond) do ...` and `for (x <- xs) do ...` as well as the parenless forms. Only the latter consumed the keyword, so in the former it rode along inside the body's prefix, where recipes cannot see it. It is now kept on a marker and printed between the head and the body, for both the single-generator and multi-generator shapes of `for`. --- .../org/openrewrite/scala/ScalaPrinter.java | 17 +++++- .../scala/internal/ScalaTreeVisitor.scala | 61 +++++++++++++------ .../scala/marker/ScalaMarkers.scala | 10 +++ .../scala/tree/ControlFlowTest.java | 19 ++++++ 4 files changed, 86 insertions(+), 21 deletions(-) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index b755c0fe55f..ffd06511c14 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -44,6 +44,7 @@ import org.openrewrite.scala.marker.Semicolon; import org.openrewrite.scala.marker.TrailingComma; import org.openrewrite.scala.marker.CasePattern; +import org.openrewrite.scala.marker.DoKeyword; import org.openrewrite.scala.marker.ThenKeyword; import org.openrewrite.scala.marker.TypeProjection; import org.openrewrite.scala.marker.ScalaForLoop; @@ -263,7 +264,17 @@ public J visitAssignmentOperation(J.AssignmentOperation assignOp, PrintOutputCap @Override public J visitWhileLoop(J.WhileLoop whileLoop, PrintOutputCapture

p) { if (!whileLoop.getMarkers().findFirst(IndentedSyntax.class).isPresent()) { - return super.visitWhileLoop(whileLoop, p); + Optional doKeyword = whileLoop.getMarkers().findFirst(DoKeyword.class); + if (!doKeyword.isPresent()) { + return super.visitWhileLoop(whileLoop, p); + } + beforeSyntax(whileLoop, Space.Location.WHILE_PREFIX, p); + p.append("while"); + visit(whileLoop.getCondition(), p); + p.append(doKeyword.get().text()); + visit(whileLoop.getBody(), p); + afterSyntax(whileLoop, p); + return whileLoop; } // Scala 3 paren-less form: `while cond do body` beforeSyntax(whileLoop, Space.Location.WHILE_PREFIX, p); @@ -1251,6 +1262,7 @@ public J visitForEachLoop(J.ForEachLoop forEachLoop, PrintOutputCapture

p) { visit(iterable.getElement(), p); visitSpace(iterable.getAfter(), JRightPadded.Location.FOREACH_ITERABLE.getAfterLocation(), p); p.append(')'); + forEachLoop.getMarkers().findFirst(DoKeyword.class).ifPresent(m -> p.append(m.text())); // Print the body visitStatement(forEachLoop.getPadding().getBody(), JRightPadded.Location.FOR_BODY, p); @@ -2253,10 +2265,13 @@ public J visitFor(S.For forLoop, PrintOutputCapture

p) { p.append(close); } visitSpace(forLoop.getBeforeBody(), Space.Location.LANGUAGE_EXTENSION, p); + Optional doKeyword = forLoop.getMarkers().findFirst(DoKeyword.class); if (forLoop.isYielding()) { p.append("yield"); } else if (parenless) { p.append("do"); + } else if (doKeyword.isPresent()) { + p.append(doKeyword.get().text()); } visit(forLoop.getBody(), p); afterSyntax(forLoop, p); diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 112d3c4fa4d..d6a0e88f90a 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -42,6 +42,7 @@ import org.openrewrite.scala.marker.EndMarker import org.openrewrite.scala.marker.KindParameterVariance import org.openrewrite.scala.marker.ParentSeparator import org.openrewrite.scala.marker.CasePattern +import org.openrewrite.scala.marker.DoKeyword import org.openrewrite.scala.marker.SObject import org.openrewrite.scala.marker.ThenKeyword import org.openrewrite.scala.marker.SelfType @@ -4517,15 +4518,15 @@ class ScalaTreeVisitor( } // Scala 3 `while cond do ...` — advance the cursor past the `do` keyword. - if (whileIsParenless) { - val bodyStart = Math.max(0, whileTree.body.span.start - offsetAdjustment) - if (cursor < bodyStart && bodyStart <= source.length) { - val between = source.substring(cursor, bodyStart) - val doIdx = positionOfNextIn(between, "do", 0) - if (doIdx >= 0) { - afterCondSpace = Space.format(between.substring(0, doIdx)) - cursor = cursor + doIdx + 2 - } + var doKeywordText: String = null + val whileBodyStart = Math.max(0, whileTree.body.span.start - offsetAdjustment) + if (cursor < whileBodyStart && whileBodyStart <= source.length) { + val between = source.substring(cursor, whileBodyStart) + val doIdx = positionOfNextIn(between, "do", 0) + if (doIdx >= 0) { + if (whileIsParenless) afterCondSpace = Space.format(between.substring(0, doIdx)) + else doKeywordText = between.substring(0, doIdx + 2) + cursor = cursor + doIdx + 2 } } @@ -4538,6 +4539,8 @@ class ScalaTreeVisitor( val whileBaseMarkers = if (whileIsParenless) Markers.build(Collections.singletonList(new IndentedSyntax(Tree.randomId()))) + else if (doKeywordText != null) + Markers.build(Collections.singletonList(DoKeyword(Tree.randomId(), doKeywordText))) else Markers.EMPTY val whileMarkers = withEndMarker(whileBaseMarkers, whileTree.span) @@ -4784,6 +4787,18 @@ class ScalaTreeVisitor( JRightPadded.build(iterable).withAfter(iterableAfter) ) + // Scala 3 allows `do` after the parenthesized generator. + var doKeywordText: String = null + val forBodyStart = Math.max(0, forTree.body.span.start - offsetAdjustment) + if (cursor < forBodyStart && forBodyStart <= source.length) { + val between = source.substring(cursor, forBodyStart) + val doIdx = positionOfNextIn(between, "do", 0) + if (doIdx >= 0) { + doKeywordText = between.substring(0, doIdx + 2) + cursor = cursor + doIdx + 2 + } + } + // Visit the body — wrap non-Statement expressions so `for (x <- xs) x + 1` parses val bodyJ = visitTree(forTree.body) val body: Statement = bodyJ match { @@ -4792,7 +4807,10 @@ class ScalaTreeVisitor( case null => throw unmappedException(forTree.body) } - val forMarkers = withEndMarker(Markers.EMPTY.addIfAbsent(ScalaForLoop.create()), forTree.span) + val forBaseMarkers = if (doKeywordText != null) + Markers.EMPTY.addIfAbsent(ScalaForLoop.create()).add(DoKeyword(Tree.randomId(), doKeywordText)) + else Markers.EMPTY.addIfAbsent(ScalaForLoop.create()) + val forMarkers = withEndMarker(forBaseMarkers, forTree.span) updateCursor(forTree.span.end) val forEachLoop = new J.ForEachLoop( @@ -8610,6 +8628,7 @@ class ScalaTreeVisitor( } // Capture space before body / `yield` / `do` + var doKeywordText: String = null val bodyStart = Math.max(0, body.span.start - offsetAdjustment) val rawBetween = if (cursor < bodyStart && bodyStart <= source.length) source.substring(cursor, bodyStart) else "" val beforeBody: Space = if (yielding) { @@ -8619,24 +8638,26 @@ class ScalaTreeVisitor( cursor = cursor + yieldIdx + "yield".length s } else Space.EMPTY - } else if (isParenless) { - // Paren-less `do` form: capture space before `do`, advance past `do`. - // Whitespace after `do` becomes the body's prefix. + } else { + // Capture the space before `do` and advance past it; whitespace after `do` + // becomes the body's prefix. val doIdx = positionOfNextIn(rawBetween, "do", 0) if (doIdx >= 0) { - val s = Space.format(rawBetween.substring(0, doIdx)) + if (!isParenless) doKeywordText = rawBetween.substring(0, doIdx + "do".length) cursor = cursor + doIdx + "do".length - s - } else Space.EMPTY - } else { - Space.format(rawBetween) + if (isParenless) Space.format(rawBetween.substring(0, doIdx)) else Space.EMPTY + } else if (isParenless) Space.EMPTY + else Space.format(rawBetween) } - if (!yielding && !isParenless) cursor = bodyStart + if (!yielding && !isParenless && doKeywordText == null) cursor = bodyStart val bodyJ = visitTree(body) updateCursor(spanEnd) - S.For.build(Tree.randomId(), prefix, Markers.EMPTY, enumerators, openBracket, + val forMarkers = if (doKeywordText != null) + Markers.build(Collections.singletonList(DoKeyword(Tree.randomId(), doKeywordText))) + else Markers.EMPTY + S.For.build(Tree.randomId(), prefix, forMarkers, enumerators, openBracket, yielding, beforeBody, bodyJ, null) } diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala index 036d3197f51..e8d7b23f6d2 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala @@ -256,6 +256,16 @@ case class ThenKeyword(id: UUID, text: String) extends Marker { override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] } +/** + * The `do` of a Scala 3 `while (cond) do ...` or `for (...) do ...`, which is optional + * after a parenthesized head. Holds the verbatim source from the closing `)` through + * the keyword. + */ +case class DoKeyword(id: UUID, text: String) extends Marker { + override def getId(): UUID = id + override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] +} + /** * The keyword introducing one parent in a class declaration's parent list. Scala 3 * accepts either `with` or `,` and the two may be mixed (`extends A, B with C`), so diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java index 6eab5cdf0b9..be152356ac6 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java @@ -253,6 +253,25 @@ void ifElseIfElse() { ); } + @Test + void doAfterParenthesizedHead() { + rewriteRun( + scala( + """ + object Test { + def f(it: Iterator[Int]): Unit = + while (it.hasNext) do + println(it.next()) + def g(xs: List[Int]): Unit = for (x <- xs) do println(x) + def h(xs: List[Int], ys: List[Int]): Unit = + for (x <- xs; y <- ys) do + println(x + y) + } + """ + ) + ); + } + @Test void thenAfterParenthesizedCondition() { rewriteRun( From e7cc3a0ea7565c46f6249ecc26e6fa6fc66b4a33 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 12:10:08 +0200 Subject: [PATCH 39/55] Keep the empty argument list of an anonymous class Dotty models `new Foo { ... }` and `new Foo() { ... }` with the same empty `Apply`, and the parser read the absent parentheses from that rather than from the source, so the `()` of the second form ended up in the body's prefix. The list is now taken from the source, where an empty container prints `()` and a null one prints nothing. --- .../scala/internal/ScalaTreeVisitor.scala | 24 ++++++++++++++++++- .../openrewrite/scala/tree/NewClassTest.java | 20 ++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index d6a0e88f90a..e7ccc3d28ee 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -2242,7 +2242,29 @@ class ScalaTreeVisitor( JContainer.build(beforeParenSpace, args, Markers.EMPTY) } else { - null + // Dotty models `new Foo { ... }` and `new Foo() { ... }` alike, so the + // empty list is read from the source: a container prints `()`, null + // prints nothing. + val typeEnd = Math.max(0, newInner.tpt.span.end - offsetAdjustment) + val searchStart = Math.max(cursor, typeEnd) + val appEnd = Math.max(0, app.span.end - offsetAdjustment) + val between = if (searchStart < appEnd && appEnd <= source.length) + source.substring(searchStart, appEnd) else "" + val open = positionOfNextIn(between, "(", 0) + val close = if (open >= 0) positionOfNextIn(between, ")", open + 1) else -1 + if (close > open) { + val elements = new util.ArrayList[JRightPadded[Expression]]() + if (close > open + 1) { + val interior = new J.Empty(Tree.randomId(), + ScalaSpace.format(between.substring(open + 1, close)), Markers.EMPTY) + elements.add(JRightPadded.build(interior.asInstanceOf[Expression])) + } + val beforeParenSpace = ScalaSpace.format(between.substring(0, open)) + updateCursor(searchStart + close + 1) + JContainer.build(beforeParenSpace, elements, Markers.EMPTY) + } else { + null + } } // Capture any remaining curried parameter lists verbatim. diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java index 897a01f2e75..da9b17a945b 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java @@ -409,6 +409,26 @@ class M[A](s: String)(f: Int => Int)(g: Int => Int) ); } + @Test + void anonymousClassWithEmptyArgumentList() { + rewriteRun( + scala( + """ + object O { + val a = new Object() { + override def toString: String = "a" + } + val b = new Object { + override def toString: String = "b" + } + val c = new Object(): + override def toString: String = "c" + } + """ + ) + ); + } + @Test void trailingCommaInConstructorArguments() { rewriteRun( From e9ab506fd4e62e74e242befabb3a525e234c982c Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 12:19:04 +0200 Subject: [PATCH 40/55] Recognize a variable's access modifier in any position The scan for the modifiers of a `val`/`var`/`given` matched `private` and `protected` only ahead of the loop that reads the rest, so `implicit protected val x` kept the `protected` in the whitespace before the keyword, where recipes cannot see it. Access modifiers are now read by that loop like any other, scope suffix included. --- .../scala/internal/ScalaTreeVisitor.scala | 63 ++++++++----------- .../scala/tree/VariableDeclarationsTest.java | 16 +++++ 2 files changed, 41 insertions(+), 38 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index e7ccc3d28ee..5843b5f90c0 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -3208,43 +3208,8 @@ class ScalaTreeVisitor( val leadingWs = sourceSnippet.length - trimmedSnippet.length modifierEndPos = leadingWs - // Check for access modifiers (including scoped: private[scope], protected[this]) - if (trimmedSnippet.startsWith("private")) { - var keyword = "private" - var keyLen = keyword.length - val afterKw = trimmedSnippet.substring(keyLen) - if (afterKw.startsWith("[")) { - val cb = positionOfNextIn(afterKw, "]", 0) - if (cb >= 0) { keyword = "private" + afterKw.substring(0, cb + 1); keyLen = keyword.length } - } - val modPrefix = if (leadingWs > 0) ScalaSpace.format(sourceSnippet.substring(0, leadingWs)) else Space.EMPTY - modifiers.add(new J.Modifier( - Tree.randomId(), modPrefix, Markers.EMPTY, - keyword, J.Modifier.Type.Private, Collections.emptyList() - )) - modifierEndPos = leadingWs + keyLen - lastModifierKeywordEnd = modifierEndPos - if (modifierEndPos < sourceSnippet.length && sourceSnippet.charAt(modifierEndPos) == ' ') modifierEndPos += 1 - } else if (trimmedSnippet.startsWith("protected")) { - var keyword = "protected" - var keyLen = keyword.length - val afterKw = trimmedSnippet.substring(keyLen) - if (afterKw.startsWith("[")) { - val cb = positionOfNextIn(afterKw, "]", 0) - if (cb >= 0) { keyword = "protected" + afterKw.substring(0, cb + 1); keyLen = keyword.length } - } - val modPrefix = if (leadingWs > 0) ScalaSpace.format(sourceSnippet.substring(0, leadingWs)) else Space.EMPTY - modifiers.add(new J.Modifier( - Tree.randomId(), modPrefix, Markers.EMPTY, - keyword, J.Modifier.Type.Protected, Collections.emptyList() - )) - modifierEndPos = leadingWs + keyLen - lastModifierKeywordEnd = modifierEndPos - if (modifierEndPos < sourceSnippet.length && sourceSnippet.charAt(modifierEndPos) == ' ') modifierEndPos += 1 - } - - // Check for remaining modifiers (in any order) before val/var/def - // These include: implicit, override, abstract, final, lazy, sealed + // Modifiers in any order before val/var/given: the access modifiers (which may carry + // a scope, `private[scope]`), plus implicit, override, abstract, final, lazy, sealed var scanning = true while (scanning && modifierEndPos < sourceSnippet.length) { val remaining = sourceSnippet.substring(modifierEndPos) @@ -3256,7 +3221,29 @@ class ScalaTreeVisitor( else if (leadingWs > 0) ScalaSpace.format(sourceSnippet.substring(0, leadingWs)) else Space.EMPTY - if (remaining.startsWith("final ")) { + val accessKeyword = + if (remaining.startsWith("private")) "private" + else if (remaining.startsWith("protected")) "protected" + else null + val scopedAccessKeyword = if (accessKeyword == null) null else { + val afterKw = remaining.substring(accessKeyword.length) + val closeBracket = if (afterKw.startsWith("[")) positionOfNextIn(afterKw, "]", 0) else -1 + if (closeBracket >= 0) accessKeyword + afterKw.substring(0, closeBracket + 1) else accessKeyword + } + // An identifier can open with the same letters, as in `privateName` + val isAccessModifier = scopedAccessKeyword != null && + remaining.length > scopedAccessKeyword.length && + !Character.isLetterOrDigit(remaining.charAt(scopedAccessKeyword.length)) + + if (isAccessModifier) { + modifiers.add(new J.Modifier(Tree.randomId(), modSpace, Markers.EMPTY, + scopedAccessKeyword, + if (accessKeyword == "private") J.Modifier.Type.Private else J.Modifier.Type.Protected, + Collections.emptyList())) + lastModifierKeywordEnd = modifierEndPos + scopedAccessKeyword.length + modifierEndPos = lastModifierKeywordEnd + if (modifierEndPos < sourceSnippet.length && sourceSnippet.charAt(modifierEndPos) == ' ') modifierEndPos += 1 + } else if (remaining.startsWith("final ")) { hasExplicitFinal = true modifiers.add(new J.Modifier(Tree.randomId(), modSpace, Markers.EMPTY, "final", J.Modifier.Type.Final, Collections.emptyList())) diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java index 3b4b81d89bd..2f4fa5592a6 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/VariableDeclarationsTest.java @@ -282,6 +282,22 @@ void inlineGiven() { ); } + @Test + void accessModifierAfterAnotherModifier() { + rewriteRun( + scala( + """ + class C { + override implicit protected val a: String = "a" + implicit protected val b: String = "b" + lazy private val c: String = "c" + override private[this] val d: String = "d" + } + """ + ) + ); + } + @Test void finalVar() { rewriteRun( From 0234ad6ca3dcab75fc64700a936292e0926ae3e7 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 12:30:44 +0200 Subject: [PATCH 41/55] Leave the comma of an import group to its continuation A block's statement loop advanced the cursor to the next statement, past the comma that separates `import a.*, b.*`. The continuation reads back to that comma for its own prefix, so it found the cursor already beyond it and the file failed to parse. The loop now stops at the comma when the next statement is an import or export. --- .../scala/internal/ScalaTreeVisitor.scala | 9 ++++++++- .../org/openrewrite/scala/tree/ImportTest.java | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 5843b5f90c0..721e6e9bc0f 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -4934,7 +4934,14 @@ class ScalaTreeVisitor( } idx } - if (semiIdx >= 0) { + // The continuation of a comma-separated import group (`import a.*, b.*`) owns + // the comma ahead of it, which it reads back to for its own prefix. + val nextIsImportContinuation = i < block.stats.length - 1 && + block.stats(i + 1).isInstanceOf[Trees.ImportOrExport[?]] && + positionOfNextIn(between, ",", 0) >= 0 + if (nextIsImportContinuation) { + cursor = trailStart + } else if (semiIdx >= 0) { trailingSpace = if (semiIdx > 0) Space.format(between.substring(0, semiIdx)) else Space.EMPTY rpMarkers = Markers.EMPTY.add(new Semicolon(Tree.randomId())) cursor = trailStart + semiIdx + 1 diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ImportTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ImportTest.java index fc83afce1d0..1c81a8ac73a 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ImportTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ImportTest.java @@ -374,6 +374,23 @@ def foo(): Int = ); } + @Test + void commaContinuationInsideBlock() { + rewriteRun( + scala( + """ + object O { + def f: Int = { + import scala.util.*, scala.math.* + import java.util.List, java.util.Map, java.util.Set + 1 + } + } + """ + ) + ); + } + @Test void commaContinuationBrace() { rewriteRun( From a374928675696df35afa618127c4ae2be86638c0 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 12:32:00 +0200 Subject: [PATCH 42/55] Recognize `inline` on a using parameter An `inline` parameter is read from the source, since the untyped tree carries no flag for it, but the lookup was skipped for a context parameter. In `def f(using inline x: T)` the keyword follows `using`, so it stayed in the whitespace between the two. The lookup now also runs once the `using` or `implicit` keyword has been consumed. --- .../scala/internal/ScalaTreeVisitor.scala | 29 +++++++++++-------- .../scala/MethodDeclarationTest.java | 1 + 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 721e6e9bc0f..1341777f5f9 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -7167,22 +7167,22 @@ class ScalaTreeVisitor( val isUsing = vd.mods != null && vd.mods.is(Flags.Given) val isScala2Implicit = vd.mods != null && vd.mods.is(Flags.Implicit) && !isUsing // The untyped tree carries no flag for an `inline` parameter and its span opens on the - // keyword, so read it from the source at the cursor. - val inlineAt = if (isUsing || (vd.mods != null && vd.mods.is(Flags.Implicit))) -1 else { + // keyword, so read it from the source at the cursor. It may follow `using`, as in + // `def f(using inline x: T)`. + def consumeInlineKeyword(): Boolean = { var i = cursor while (i < source.length && (source.charAt(i) == ' ' || source.charAt(i) == '\t')) i += 1 val end = i + "inline".length if (source.startsWith("inline", i) && - (end >= source.length || !(Character.isLetterOrDigit(source.charAt(end)) || source.charAt(end) == '_'))) i - else -1 - } - val prefix: Space = if (inlineAt >= 0) { - paramModifiers.add(new J.Modifier(Tree.randomId(), - if (inlineAt > cursor) Space.format(source.substring(cursor, inlineAt)) else Space.EMPTY, - Markers.EMPTY, "inline", J.Modifier.Type.LanguageExtension, Collections.emptyList())) - cursor = inlineAt + "inline".length - Space.EMPTY - } else if (isScala2Implicit || isUsing) { + (end >= source.length || !(Character.isLetterOrDigit(source.charAt(end)) || source.charAt(end) == '_'))) { + paramModifiers.add(new J.Modifier(Tree.randomId(), + if (i > cursor) Space.format(source.substring(cursor, i)) else Space.EMPTY, + Markers.EMPTY, "inline", J.Modifier.Type.LanguageExtension, Collections.emptyList())) + cursor = i + "inline".length + true + } else false + } + val prefix: Space = if (isScala2Implicit || isUsing) { val keyword = if (isUsing) "using" else "implicit" val spanStart = Math.max(0, vd.span.start - offsetAdjustment) if (cursor < spanStart && spanStart <= source.length) { @@ -7193,9 +7193,14 @@ class ScalaTreeVisitor( paramModifiers.add(new J.Modifier(Tree.randomId(), modPrefix, Markers.EMPTY, keyword, J.Modifier.Type.LanguageExtension, Collections.emptyList())) cursor += kwIdx + keyword.length + consumeInlineKeyword() Space.EMPTY } else extractPrefix(vd.span) } else extractPrefix(vd.span) + } else if (vd.mods != null && vd.mods.is(Flags.Implicit)) { + extractPrefix(vd.span) + } else if (consumeInlineKeyword()) { + Space.EMPTY } else extractPrefix(vd.span) val paramStart = Math.max(0, vd.span.start - offsetAdjustment) val paramEnd = Math.max(0, vd.span.end - offsetAdjustment) diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java index 26581283bb5..8043c6dc35a 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java @@ -1251,6 +1251,7 @@ void inlineParameter() { inline def f(inline op: Boolean): Boolean = op inline def g(a: Int, inline op: Boolean, b: Int): Boolean = op inline def h(inline op: => Int): Int = op + inline def i[A](using inline z: List[A]): List[A] = z } """ ) From 0c4e9c67c0a3076de909bea2a27adb011b6482c7 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 12:35:43 +0200 Subject: [PATCH 43/55] Model the `inline` of a Scala 3 `inline if` The keyword opens the if expression's span, and the parser advanced straight to the `if`, dropping it from the printed output and failing the file. It is now kept on a marker and printed ahead of the `if`, for both the parenless and parenthesized forms. --- .../org/openrewrite/scala/ScalaPrinter.java | 3 +++ .../scala/internal/ScalaTreeVisitor.scala | 18 +++++++++++++++++- .../scala/marker/ScalaMarkers.scala | 10 ++++++++++ .../scala/tree/ControlFlowTest.java | 15 +++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index ffd06511c14..f7e0110a55b 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -1132,6 +1132,9 @@ protected void beforeSyntax(Space prefix, Markers markers, Space.@Nullable Locat markers.findFirst(org.openrewrite.scala.marker.UsingArguments.class) .ifPresent(m -> p.append(m.text())); super.beforeSyntax(prefix, markers, loc, p); + // Scala 3 `inline if`: the keyword precedes the expression it modifies + markers.findFirst(org.openrewrite.scala.marker.InlineKeyword.class) + .ifPresent(m -> p.append(m.text())); } @Override diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 1341777f5f9..e57e25932d7 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -33,6 +33,7 @@ import org.openrewrite.scala.marker.Implicit import org.openrewrite.scala.marker.InfixTypeNotation import org.openrewrite.scala.marker.LambdaParameter import org.openrewrite.scala.marker.IndentedSyntax +import org.openrewrite.scala.marker.InlineKeyword import org.openrewrite.scala.marker.OmitBraces import org.openrewrite.scala.marker.OmitImportBraces import org.openrewrite.scala.marker.PackageObject @@ -4239,6 +4240,18 @@ class ScalaTreeVisitor( private def visitIf(ifTree: Trees.If[?]): J = { val prefix = extractPrefix(ifTree.span) + // Scala 3 `inline if`: the keyword opens the tree's span, ahead of the `if`. + var inlineKeywordText: String = null + val beforeIfLimit = Math.min(source.length, Math.max(cursor, Math.max(0, ifTree.cond.span.start - offsetAdjustment))) + if (cursor < beforeIfLimit) { + val head = source.substring(cursor, beforeIfLimit) + val ifIdx = positionOfNextIn(head, "if", 0) + if (ifIdx > 0 && positionOfNextIn(head.substring(0, ifIdx), "inline", 0) >= 0) { + inlineKeywordText = head.substring(0, ifIdx) + cursor = cursor + ifIdx + } + } + // Find where the condition parentheses start val adjustedStart = Math.max(0, ifTree.span.start - offsetAdjustment) @@ -4403,7 +4416,10 @@ class ScalaTreeVisitor( else if (thenKeywordText != null) Markers.build(Collections.singletonList(ThenKeyword(Tree.randomId(), thenKeywordText))) else Markers.EMPTY - val ifMarkers = withEndMarker(ifBaseMarkers, ifTree.span) + val ifInlineMarkers = if (inlineKeywordText != null) + ifBaseMarkers.add(InlineKeyword(Tree.randomId(), inlineKeywordText)) + else ifBaseMarkers + val ifMarkers = withEndMarker(ifInlineMarkers, ifTree.span) // Update cursor to end of the if expression updateCursor(ifTree.span.end) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala index e8d7b23f6d2..fa8f5cad768 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/marker/ScalaMarkers.scala @@ -256,6 +256,16 @@ case class ThenKeyword(id: UUID, text: String) extends Marker { override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] } +/** + * The `inline` of a Scala 3 `inline if`, which modifies an expression rather than a + * declaration and so has no modifier list to sit in. Holds the verbatim source from the + * keyword up to the expression's own keyword. + */ +case class InlineKeyword(id: UUID, text: String) extends Marker { + override def getId(): UUID = id + override def withId[M <: Marker](newId: UUID): M = copy(id = newId).asInstanceOf[M] +} + /** * The `do` of a Scala 3 `while (cond) do ...` or `for (...) do ...`, which is optional * after a parenthesized head. Holds the verbatim source from the closing `)` through diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java index be152356ac6..480e515b780 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ControlFlowTest.java @@ -253,6 +253,21 @@ void ifElseIfElse() { ); } + @Test + void inlineIf() { + rewriteRun( + scala( + """ + object Test { + inline def f(inline b: Boolean): Int = + inline if b then 1 else 2 + inline def g(inline b: Boolean): Int = inline if (b) 1 else 2 + } + """ + ) + ); + } + @Test void doAfterParenthesizedHead() { rewriteRun( From 06e4958b8d2479f4a18a4c87f2f7aa66c1010a7f Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 13:09:53 +0200 Subject: [PATCH 44/55] Recognize `inline` on a parameter across a line break The scan for the keyword skipped only spaces and tabs, so a parameter list that puts each parameter on its own line kept the `inline` of `inline x: Int` as raw text in the whitespace ahead of it. It now skips whitespace of any kind. Also drops the arm for a Scala 2 `implicit` parameter, which the preceding condition already covers. --- .../org/openrewrite/scala/internal/ScalaTreeVisitor.scala | 4 +--- .../java/org/openrewrite/scala/MethodDeclarationTest.java | 3 +++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index e57e25932d7..666adf91945 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -7187,7 +7187,7 @@ class ScalaTreeVisitor( // `def f(using inline x: T)`. def consumeInlineKeyword(): Boolean = { var i = cursor - while (i < source.length && (source.charAt(i) == ' ' || source.charAt(i) == '\t')) i += 1 + while (i < source.length && Character.isWhitespace(source.charAt(i))) i += 1 val end = i + "inline".length if (source.startsWith("inline", i) && (end >= source.length || !(Character.isLetterOrDigit(source.charAt(end)) || source.charAt(end) == '_'))) { @@ -7213,8 +7213,6 @@ class ScalaTreeVisitor( Space.EMPTY } else extractPrefix(vd.span) } else extractPrefix(vd.span) - } else if (vd.mods != null && vd.mods.is(Flags.Implicit)) { - extractPrefix(vd.span) } else if (consumeInlineKeyword()) { Space.EMPTY } else extractPrefix(vd.span) diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java index 8043c6dc35a..84951763a16 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/MethodDeclarationTest.java @@ -1252,6 +1252,9 @@ inline def f(inline op: Boolean): Boolean = op inline def g(a: Int, inline op: Boolean, b: Int): Boolean = op inline def h(inline op: => Int): Int = op inline def i[A](using inline z: List[A]): List[A] = z + inline def j( + inline op: Boolean + ): Boolean = op } """ ) From 9bc65c4958e68bd5c432b379aa875616cddad78c Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 13:10:13 +0200 Subject: [PATCH 45/55] Take an anonymous class's argument list from the token after the type The search for the `()` of `new Foo() { ... }` ran to the end of the constructor call, so it could reach a `(` belonging to a member of the body. It now looks only at the token directly after the type, which is where the list opens if there is one. --- .../scala/internal/ScalaTreeVisitor.scala | 16 ++++++++-------- .../org/openrewrite/scala/tree/NewClassTest.java | 3 +++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 666adf91945..35230716e77 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -2248,20 +2248,20 @@ class ScalaTreeVisitor( // prints nothing. val typeEnd = Math.max(0, newInner.tpt.span.end - offsetAdjustment) val searchStart = Math.max(cursor, typeEnd) - val appEnd = Math.max(0, app.span.end - offsetAdjustment) - val between = if (searchStart < appEnd && appEnd <= source.length) - source.substring(searchStart, appEnd) else "" - val open = positionOfNextIn(between, "(", 0) - val close = if (open >= 0) positionOfNextIn(between, ")", open + 1) else -1 + // The list opens on the token right after the type, so a `(` further + // along — in the body, say — is not one. + val open = indexOfNextNonWhitespace(searchStart) + val close = if (open < source.length && source.charAt(open) == '(') + positionOfNextIn(source, ")", open + 1) else -1 if (close > open) { val elements = new util.ArrayList[JRightPadded[Expression]]() if (close > open + 1) { val interior = new J.Empty(Tree.randomId(), - ScalaSpace.format(between.substring(open + 1, close)), Markers.EMPTY) + ScalaSpace.format(source.substring(open + 1, close)), Markers.EMPTY) elements.add(JRightPadded.build(interior.asInstanceOf[Expression])) } - val beforeParenSpace = ScalaSpace.format(between.substring(0, open)) - updateCursor(searchStart + close + 1) + val beforeParenSpace = ScalaSpace.format(source.substring(searchStart, open)) + updateCursor(close + 1) JContainer.build(beforeParenSpace, elements, Markers.EMPTY) } else { null diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java index da9b17a945b..459bbecfb2b 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/NewClassTest.java @@ -423,6 +423,9 @@ void anonymousClassWithEmptyArgumentList() { } val c = new Object(): override def toString: String = "c" + val d = new Object { + def f(i: Int): Int = i + } } """ ) From 25e3887bae2d2d1b8470aa681bd2e927726860eb Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sun, 16 Aug 2026 13:12:28 +0200 Subject: [PATCH 46/55] Name the sentinel for an anonymous class with no body The pair returned when no body delimiter is found carried a literal NUL character, which makes the file read as binary to grep and other text tools. --- .../org/openrewrite/scala/internal/ScalaTreeVisitor.scala | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 35230716e77..a0da1a5873b 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -2348,10 +2348,10 @@ class ScalaTreeVisitor( } else if (colonIndex >= 0) { (':', colonIndex) } else { - ('', -1) + (NoBodyDelimiter, -1) } } else { - ('', -1) + (NoBodyDelimiter, -1) } val body = if (bodyDelimiterIndex >= 0) { @@ -8081,6 +8081,9 @@ class ScalaTreeVisitor( new S.RefinedType(Tree.randomId(), prefix, Markers.EMPTY, parent, refinements, typeFor(rtt.span)) } + /** Stands in for the delimiter of an anonymous class that has no body. */ + private val NoBodyDelimiter: Char = '\u0000' + private def visitAnnotated(ann: Trees.Annotated[?]): J = { // Dotty's `Annotated` covers both annotated expressions (`e: @ann`, with colon) and // annotated types (`T @ann`, without colon). Branch on the source to produce the From 15262ee075dd869e56e62561fb81e8eeb3dac375 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Mon, 17 Aug 2026 08:06:02 +0200 Subject: [PATCH 47/55] Take the space after the `package` keyword from the source The package name was given a one-space prefix, so `package com.example` came back with the run collapsed to a single space. It is now read from the source, which also covers a name on the line below the keyword. --- .../scala/internal/ScalaASTConverter.scala | 19 ++++++++++++++++--- .../scala/tree/CompilationUnitTest.java | 13 +++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaASTConverter.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaASTConverter.scala index 338e2cd387b..dfcd4136a39 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaASTConverter.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaASTConverter.scala @@ -212,7 +212,7 @@ class ScalaASTConverter { Tree.randomId(), Space.EMPTY, Markers.EMPTY, - packageExpr.withPrefix(Space.build(" ", Collections.emptyList())), + packageExpr.withPrefix(spaceBeforePackageName(pkgDef, visitor)), Collections.emptyList() ) visitor.updateCursor(pkgDef.pid.span.end) @@ -236,7 +236,7 @@ class ScalaASTConverter { Tree.randomId(), Space.EMPTY, Markers.EMPTY, - packageExpr.withPrefix(Space.build(" ", Collections.emptyList())), + packageExpr.withPrefix(spaceBeforePackageName(pkgDef, visitor)), Collections.emptyList() ) @@ -326,7 +326,7 @@ class ScalaASTConverter { Tree.randomId(), prefix, markers, - packageExpr.withPrefix(Space.build(" ", Collections.emptyList())), + packageExpr.withPrefix(spaceBeforePackageName(pkgDef, visitor)), Collections.emptyList() ) } @@ -350,6 +350,19 @@ class ScalaASTConverter { * marker, which the printer renders back with backticks. Falls back to * [[extractPackageName]] when the span is unusable. */ + /** The run between the `package` keyword and the package name, which need not be one space. */ + private def spaceBeforePackageName(pkgDef: Trees.PackageDef[?], visitor: ScalaTreeVisitor): Space = { + if (pkgDef.pid.span.exists) { + val srcText = visitor.getSourceText + val nameStart = pkgDef.pid.span.start - visitor.getOffsetAdjustment + val keyword = if (nameStart <= srcText.length) srcText.lastIndexOf("package", nameStart) else -1 + if (keyword >= 0 && keyword + "package".length <= nameStart) { + return ScalaSpace.format(srcText.substring(keyword + "package".length, nameStart)) + } + } + Space.build(" ", Collections.emptyList()) + } + private def packageNameFromSource(pkgDef: Trees.PackageDef[?], visitor: ScalaTreeVisitor): String = { if (pkgDef.pid.span.exists) { val srcText = visitor.getSourceText diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/CompilationUnitTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/CompilationUnitTest.java index 3bc2f545cf2..c0bfa390943 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/CompilationUnitTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/CompilationUnitTest.java @@ -103,6 +103,19 @@ void withNestedPackage() { ); } + @Test + void packageKeywordFollowedByMoreThanOneSpace() { + rewriteRun( + scala( + """ + package com.example + + val x = 42 + """ + ) + ); + } + @Test void packageWithBacktickedSegment() { rewriteRun( From 41ea6a4667828ad6e18d1b9b00fcc284afa5453c Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Mon, 17 Aug 2026 08:06:02 +0200 Subject: [PATCH 48/55] Keep the handler of `try expr catch handler` A catch clause whose handler is a partial-function expression rather than a list of `case` clauses has no cases to build from, and the clause was dropped along with the handler's source. The expression is now the body of the catch block. --- .../scala/internal/ScalaTreeVisitor.scala | 27 ++++++++++++++++--- .../org/openrewrite/scala/tree/TryTest.java | 14 ++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index a0da1a5873b..aca1e5c912b 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -7375,11 +7375,10 @@ class ScalaTreeVisitor( // The handler is a synthetic Match whose case clauses are the catch patterns. val catches: JLeftPadded[J.Block] = if (!parsedTry.handler.isEmpty && parsedTry.handler.span.exists) { - val cases: List[Trees.CaseDef[?]] = parsedTry.handler match { - case matchTree: Trees.Match[?] => matchTree.cases - case _ => Nil + parsedTry.handler match { + case matchTree: Trees.Match[?] => buildCatchBlock(matchTree.cases) + case handler => buildCatchExpression(handler) } - buildCatchBlock(cases) } else null val finallyBlock = buildTryFinalizer(parsedTry.finalizer) @@ -7464,6 +7463,26 @@ class ScalaTreeVisitor( JLeftPadded.build(casesBlock).withBefore(beforeCatch) } + /** Build the handler of `try expr catch handler`, where the handler is a partial-function + * expression rather than a list of `case` clauses. */ + private def buildCatchExpression(handler: Trees.Tree[?]): JLeftPadded[J.Block] = { + val catchAbs = positionOfNext("catch") + val beforeCatch = if (catchAbs > cursor) ScalaSpace.format(source, cursor, catchAbs) else Space.EMPTY + if (catchAbs >= cursor) cursor = catchAbs + "catch".length + val stmt: Statement = visitTree(handler) match { + case s: Statement => s + case e: Expression => new S.ExpressionStatement(Tree.randomId(), e) + case j: J => new S.ExpressionStatement(Tree.randomId(), new S.StatementExpression(Tree.randomId(), j)) + case null => throw unmappedException(handler) + } + val stmts = new util.ArrayList[JRightPadded[Statement]]() + stmts.add(JRightPadded.build(stmt)) + val block = new J.Block(Tree.randomId(), Space.EMPTY, + Markers.build(Collections.singletonList(new OmitBraces(Tree.randomId()))), + JRightPadded.build(false), stmts, Space.EMPTY) + JLeftPadded.build(block).withBefore(beforeCatch) + } + /** Visit the `finally` block, if present, capturing the space before the `finally` keyword. */ private def buildTryFinalizer(finalizer: Trees.Tree[?]): JLeftPadded[J.Block] = { if (finalizer.isEmpty || !finalizer.span.exists) return null diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/TryTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/TryTest.java index 5fd1ef6f89b..f7c88422823 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/TryTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/TryTest.java @@ -552,4 +552,18 @@ def f(): Int = ); } + @Test + void catchWithPartialFunctionExpression() { + rewriteRun( + scala( + """ + object O { + def f(part0: String, badPart: PartialFunction[Throwable, String]): String = + try part0 catch badPart + } + """ + ) + ); + } + } From 8af240509be86b33bd2284a4854809beb99b6227 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Mon, 17 Aug 2026 09:19:11 +0200 Subject: [PATCH 49/55] Keep an `end` marker closing a braced object body Dotty's span for the object reaches past the closing brace to a trailing `end` marker, and the body consumed the whole span, so `object O { ... }\nend O` lost the marker. The body now stops at the brace and the marker is claimed by name, as it already is for a class. --- .../scala/internal/ScalaTreeVisitor.scala | 6 ++++-- .../scala/tree/ClassDeclarationTest.java | 19 +++++++++++++++++++ .../scala/tree/ObjectDeclarationTest.java | 14 ++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index aca1e5c912b..9c9f9cc6618 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -4062,7 +4062,9 @@ class ScalaTreeVisitor( val closeBraceIdx = remaining.lastIndexOf('}') if (closeBraceIdx >= 0) { endSpace = Space.format(remaining.substring(0, closeBraceIdx)) - cursor = endPos + // Stop at the `}`. Dotty's span reaches past it to a trailing `end` marker, + // which the caller claims by name. + cursor = cursor + closeBraceIdx + 1 } } } @@ -4105,7 +4107,7 @@ class ScalaTreeVisitor( // then claim by name val objectMarkers = if (moduleEndMarker != null) objectBaseMarkers.add(EndMarker(Tree.randomId(), moduleEndMarker)) - else withEndMarker(objectBaseMarkers, md.span) + else withEndMarker(objectBaseMarkers, md.span, md.name.toString) // Update cursor to end of module def if (md.span.exists) { diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java index b2fcc31b64d..c23007fc7fc 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ClassDeclarationTest.java @@ -876,4 +876,23 @@ def f(): Int = 1 ); } + @Test + void accessModifierOfFollowingClassIsNotClaimed() { + rewriteRun( + scala( + """ + class Foo + + private class Bar + """, + spec -> spec.afterRecipe(cu -> { + J.ClassDeclaration bar = (J.ClassDeclaration) cu.getStatements().get(1); + assertThat(bar.getSimpleName()).isEqualTo("Bar"); + assertThat(bar.getModifiers()).singleElement() + .extracting(J.Modifier::getType).isEqualTo(J.Modifier.Type.Private); + }) + ) + ); + } + } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ObjectDeclarationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ObjectDeclarationTest.java index edc58da0446..afb2d78b7e8 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ObjectDeclarationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/ObjectDeclarationTest.java @@ -193,4 +193,18 @@ void endMarkerOnNestedObject() { ); } + @Test + void endMarkerAfterBracedBody() { + rewriteRun( + scala( + """ + object O { + def f: Int = 1 + } + end O + """ + ) + ); + } + } From 93c57fbfec2d9c33f8354e8643ae6153eed0d814 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Mon, 17 Aug 2026 09:19:37 +0200 Subject: [PATCH 50/55] Bound the primary-constructor modifier scan to the class The scan for `class X private (i: Int)` ran forward from the class name over any amount of whitespace, so a class with no constructor parentheses claimed the `private` of the next declaration: in `class Foo` followed by `private class Bar`, `Bar` came out with no modifiers. The text still round-tripped, because the verbatim marker reprinted it in the same place, which is why no test caught it. The scan now stops at the class's own span. --- .../scala/internal/ScalaTreeVisitor.scala | 7 +- .../org/openrewrite/scala/ScalaSweepTest.java | 101 ++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 9c9f9cc6618..9819e20b1f8 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -5320,9 +5320,12 @@ class ScalaTreeVisitor( // A primary constructor may carry an access modifier: `class X private (i: Int)`, // `class X private[pkg] (i: Int)`. Returns the source offset just past it. val afterCtorModifier: Int = { + // Bounded by the class's own span: past it the keyword belongs to the next declaration. + val classEnd = Math.min(source.length, Math.max(0, td.span.end - offsetAdjustment)) var i = cursor - while (i < source.length && source.charAt(i).isWhitespace) i += 1 - val keyword = if (source.startsWith("private", i)) "private" + while (i < classEnd && source.charAt(i).isWhitespace) i += 1 + val keyword = if (i >= classEnd) "" + else if (source.startsWith("private", i)) "private" else if (source.startsWith("protected", i)) "protected" else "" if (keyword.isEmpty) { diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java new file mode 100644 index 00000000000..b56cf9b16c0 --- /dev/null +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java @@ -0,0 +1,101 @@ +package org.openrewrite.scala; +import org.junit.jupiter.api.Test; +import org.openrewrite.*; +import org.openrewrite.internal.WhitespaceValidationService; +import org.openrewrite.tree.ParseError; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.file.*; +import java.util.*; +import java.util.regex.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +class ScalaSweepTest { + static final String[] ROOTS = {"/tmp/scala-corpus/cats-effect", + "/tmp/scala-corpus/scala3/library/src", "/tmp/scala-corpus/scala3/compiler/src"}; + + @Test + void sweep() throws IOException { + List files = new ArrayList<>(); + for (String root : ROOTS) { + Path p = Paths.get(root); + if (!Files.exists(p)) continue; + try (Stream walk = Files.walk(p)) { + walk.filter(f -> f.toString().endsWith(".scala")).sorted().forEach(files::add); + } + } + int ok = 0, pf = 0, ws = 0; + Map peCause = new LinkedHashMap<>(), wsCause = new LinkedHashMap<>(); + List wsSamples = new ArrayList<>(); + for (int i = 0; i < files.size(); i += 20) { + List chunk = files.subList(i, Math.min(i + 20, files.size())); + List in = chunk.stream().map(f -> new Parser.Input(f, () -> { + try { return Files.newInputStream(f); } catch (IOException e) { throw new RuntimeException(e); } + })).collect(Collectors.toList()); + ExecutionContext ctx = new InMemoryExecutionContext(t -> {}); + List res; + try { res = ScalaParser.builder().build().parseInputs(in, null, ctx).collect(Collectors.toList()); } + catch (Throwable t) { pf += chunk.size(); continue; } + for (SourceFile sf : res) { + if (sf instanceof ParseError) { + pf++; + peCause.merge(peCause(sf.getMarkers().findFirst(ParseExceptionResult.class) + .map(ParseExceptionResult::getMessage).orElse("?")), 1, Integer::sum); + continue; + } + ok++; + try { + WhitespaceValidationService s = sf.service(WhitespaceValidationService.class); + SourceFile v = (SourceFile) s.getVisitor().visit(sf, ctx); + if (v != null && v != sf) { + ws++; + Matcher mm = Pattern.compile("~~\\(non-whitespace\\)~~>(.{0,25})", Pattern.DOTALL).matcher(v.printAll()); + String c = mm.find() ? wsCause(mm.group(1)) : "?"; + wsCause.merge(c, 1, Integer::sum); + if (c.equals("inline modifier") && wsSamples.size() < 10) wsSamples.add(sf.getSourcePath() + " |" + mm.group(1).replace("\n","\\n") + "|"); + } + } catch (UnsupportedOperationException ignored) {} + } + } + try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(Paths.get("/tmp/both-fresh.txt")))) { + w.printf("files=%d parseErrors=%d unsound=%d sound=%d%n%n== parse errors ==%n", files.size(), pf, ws, ok - ws); + peCause.entrySet().stream().sorted(Map.Entry.comparingByValue().reversed()) + .limit(14).forEach(e -> w.printf("%4d %s%n", e.getValue(), e.getKey())); + w.printf("%n== samples ==%n"); + wsSamples.forEach(x2 -> w.printf(" %s%n", x2)); + w.printf("%n== unsound ==%n"); + wsCause.entrySet().stream().sorted(Map.Entry.comparingByValue().reversed()) + .limit(10).forEach(e -> w.printf("%4d %s%n", e.getValue(), e.getKey())); + } + } + + private static String peCause(String m) { + if (m.contains("CapturesAndResult") || m.contains("did not produce a J.Annotation")) return "capture checking"; + if (m.contains("PolyFunction")) return "polymorphic function type"; + if (m.contains("Quote") || m.contains("Splice")) return "quote/splice"; + if (!m.contains("is not print idempotent")) return "throw: " + m.split("\n")[0].replaceAll("/\\S+/","").replaceAll("\\d+","N").trim(); + List d = new ArrayList<>(); + for (String l : m.split("\n")) if ((l.startsWith("-")||l.startsWith("+")) && !l.startsWith("---") && !l.startsWith("+++")) { d.add(l.trim()); if (d.size()==2) break; } + String j = String.join(" || ", d); + if (j.contains("=>")) return "print: arrow | " + (j.length() > 70 ? j.substring(0, 70) : j); + if (j.contains("using")) return "print: using"; + if (j.contains("end ")) return "print: end marker"; + if (j.contains(";")) return "print: semicolon"; + return "print: other | " + (j.length()>60 ? j.substring(0,60) : j); + } + + private static String wsCause(String s) { + String t = s.trim(); + if (t.startsWith("using")) return "using args"; + if (t.startsWith("inline")) return "inline modifier"; + if (t.startsWith("end ")) return "end marker"; + if (t.startsWith("=")) return "method body ="; + if (t.contains("=>")) return "self type / arrow"; + if (t.startsWith("^")) return "capture set"; + if (t.startsWith(":")) return "type ascription"; + if (t.startsWith(",")) return "comma"; + if (t.startsWith("private")||t.startsWith("protected")) return "access modifier"; + return "other: " + (t.length()>16 ? t.substring(0,16) : t); + } +} From 17c3b60f8cd4a7ed33c5e55deca9c8adcbc3e1da Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Mon, 17 Aug 2026 09:29:27 +0200 Subject: [PATCH 51/55] Keep what sits between the parentheses of an empty argument list An argument list with no arguments left its parentheses unread, so `f(\n)` printed as `f()` and a comment between them was lost. The run is now carried by a J.Empty element, which the container prints between the parentheses. --- .../scala/internal/ScalaTreeVisitor.scala | 27 +++++++++++++++++++ .../scala/tree/MethodInvocationTest.java | 18 +++++++++++++ 2 files changed, 45 insertions(+) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 9819e20b1f8..a46eb112cd2 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -913,6 +913,7 @@ class ScalaTreeVisitor( } else { // Normal parenthesized arguments: Seq(1, 2) val parenPos = positionOfNext("(") + val parenIsNext = parenPos >= 0 && source.substring(cursor, parenPos).forall(_.isWhitespace) if (parenPos >= 0) { if (parenPos > cursor) { argContainerPrefix = ScalaSpace.format(source, cursor, parenPos) @@ -921,6 +922,16 @@ class ScalaTreeVisitor( } usingText2 = consumeUsingKeyword() + // The parentheses of an empty list can still hold whitespace or a comment, as in + // `f(\n)`, which a J.Empty carries so the container prints it back. + if (app.args.isEmpty && parenIsNext) { + val closePos = positionOfNext(")", cursor) + if (closePos > cursor) { + args.add(JRightPadded.build(new J.Empty(Tree.randomId(), + ScalaSpace.format(source, cursor, closePos), Markers.EMPTY).asInstanceOf[Expression])) + } + } + for ((arg, i) <- app.args.zipWithIndex) { val visited = visitTree(arg) val expr: Expression = visited match { @@ -1285,6 +1296,22 @@ class ScalaTreeVisitor( cursor = parenPos + 1 } usingText3 = consumeUsingKeyword() + } else { + // See the empty-list handling above; a call with no parentheses at all leaves the + // cursor where it is. + val open = indexOfNextNonWhitespace(cursor) + val appEnd = Math.min(source.length, Math.max(0, app.span.end - offsetAdjustment)) + if (open < appEnd && source.charAt(open) == '(') { + val closePos = positionOfNext(")", open + 1) + if (closePos > open && closePos <= appEnd) { + if (open > cursor) argContainerPrefix = ScalaSpace.format(source, cursor, open) + if (closePos > open + 1) { + args.add(JRightPadded.build(new J.Empty(Tree.randomId(), + ScalaSpace.format(source, open + 1, closePos), Markers.EMPTY).asInstanceOf[Expression])) + } + cursor = closePos + 1 + } + } } for (i <- app.args.indices) { diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java index bda8ec6f0ac..b7852773d84 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java @@ -588,4 +588,22 @@ def f(a: Int, b: Int): Int = a ); } + @Test + void emptyArgumentListWithInteriorSpace() { + rewriteRun( + scala( + """ + object O { + def f(): Int = 1 + val a = f( + ) + val b = f(/* none */) + val c = "x".trim( + ) + } + """ + ) + ); + } + } From 21940ebaa1f94893f963066be332afc70211cb20 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Mon, 17 Aug 2026 09:34:16 +0200 Subject: [PATCH 52/55] Parse a trailing-comma region with the Scala space parser The split around a trailing comma used the shared space parser, which ends a block comment at the first `*/`. Scala block comments nest, so `f(1, 2 /* x /* y */ z */,)` came back with the comment's tail mangled. Both halves now go through the Scala parser, as the no-comma branch already did. --- .../scala/internal/ScalaTreeVisitor.scala | 4 +- .../org/openrewrite/scala/ScalaSweepTest.java | 101 ------------------ .../scala/tree/MethodInvocationTest.java | 17 +++ 3 files changed, 19 insertions(+), 103 deletions(-) delete mode 100644 rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index a46eb112cd2..0f6158c20c2 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -1010,8 +1010,8 @@ class ScalaTreeVisitor( val between = source.substring(from, to) val commaIdx = positionOfNextIn(between, ",", 0) if (commaIdx < 0) (ScalaSpace.format(source, from, to), Markers.EMPTY) - else (Space.format(between.substring(commaIdx + 1)), - Markers.EMPTY.add(TrailingComma.create(Space.format(between.substring(0, commaIdx))))) + else (ScalaSpace.format(source, from + commaIdx + 1, to), + Markers.EMPTY.add(TrailingComma.create(ScalaSpace.format(source, from, from + commaIdx)))) } } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java deleted file mode 100644 index b56cf9b16c0..00000000000 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/ScalaSweepTest.java +++ /dev/null @@ -1,101 +0,0 @@ -package org.openrewrite.scala; -import org.junit.jupiter.api.Test; -import org.openrewrite.*; -import org.openrewrite.internal.WhitespaceValidationService; -import org.openrewrite.tree.ParseError; -import java.io.IOException; -import java.io.PrintWriter; -import java.nio.file.*; -import java.util.*; -import java.util.regex.*; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -class ScalaSweepTest { - static final String[] ROOTS = {"/tmp/scala-corpus/cats-effect", - "/tmp/scala-corpus/scala3/library/src", "/tmp/scala-corpus/scala3/compiler/src"}; - - @Test - void sweep() throws IOException { - List files = new ArrayList<>(); - for (String root : ROOTS) { - Path p = Paths.get(root); - if (!Files.exists(p)) continue; - try (Stream walk = Files.walk(p)) { - walk.filter(f -> f.toString().endsWith(".scala")).sorted().forEach(files::add); - } - } - int ok = 0, pf = 0, ws = 0; - Map peCause = new LinkedHashMap<>(), wsCause = new LinkedHashMap<>(); - List wsSamples = new ArrayList<>(); - for (int i = 0; i < files.size(); i += 20) { - List chunk = files.subList(i, Math.min(i + 20, files.size())); - List in = chunk.stream().map(f -> new Parser.Input(f, () -> { - try { return Files.newInputStream(f); } catch (IOException e) { throw new RuntimeException(e); } - })).collect(Collectors.toList()); - ExecutionContext ctx = new InMemoryExecutionContext(t -> {}); - List res; - try { res = ScalaParser.builder().build().parseInputs(in, null, ctx).collect(Collectors.toList()); } - catch (Throwable t) { pf += chunk.size(); continue; } - for (SourceFile sf : res) { - if (sf instanceof ParseError) { - pf++; - peCause.merge(peCause(sf.getMarkers().findFirst(ParseExceptionResult.class) - .map(ParseExceptionResult::getMessage).orElse("?")), 1, Integer::sum); - continue; - } - ok++; - try { - WhitespaceValidationService s = sf.service(WhitespaceValidationService.class); - SourceFile v = (SourceFile) s.getVisitor().visit(sf, ctx); - if (v != null && v != sf) { - ws++; - Matcher mm = Pattern.compile("~~\\(non-whitespace\\)~~>(.{0,25})", Pattern.DOTALL).matcher(v.printAll()); - String c = mm.find() ? wsCause(mm.group(1)) : "?"; - wsCause.merge(c, 1, Integer::sum); - if (c.equals("inline modifier") && wsSamples.size() < 10) wsSamples.add(sf.getSourcePath() + " |" + mm.group(1).replace("\n","\\n") + "|"); - } - } catch (UnsupportedOperationException ignored) {} - } - } - try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(Paths.get("/tmp/both-fresh.txt")))) { - w.printf("files=%d parseErrors=%d unsound=%d sound=%d%n%n== parse errors ==%n", files.size(), pf, ws, ok - ws); - peCause.entrySet().stream().sorted(Map.Entry.comparingByValue().reversed()) - .limit(14).forEach(e -> w.printf("%4d %s%n", e.getValue(), e.getKey())); - w.printf("%n== samples ==%n"); - wsSamples.forEach(x2 -> w.printf(" %s%n", x2)); - w.printf("%n== unsound ==%n"); - wsCause.entrySet().stream().sorted(Map.Entry.comparingByValue().reversed()) - .limit(10).forEach(e -> w.printf("%4d %s%n", e.getValue(), e.getKey())); - } - } - - private static String peCause(String m) { - if (m.contains("CapturesAndResult") || m.contains("did not produce a J.Annotation")) return "capture checking"; - if (m.contains("PolyFunction")) return "polymorphic function type"; - if (m.contains("Quote") || m.contains("Splice")) return "quote/splice"; - if (!m.contains("is not print idempotent")) return "throw: " + m.split("\n")[0].replaceAll("/\\S+/","").replaceAll("\\d+","N").trim(); - List d = new ArrayList<>(); - for (String l : m.split("\n")) if ((l.startsWith("-")||l.startsWith("+")) && !l.startsWith("---") && !l.startsWith("+++")) { d.add(l.trim()); if (d.size()==2) break; } - String j = String.join(" || ", d); - if (j.contains("=>")) return "print: arrow | " + (j.length() > 70 ? j.substring(0, 70) : j); - if (j.contains("using")) return "print: using"; - if (j.contains("end ")) return "print: end marker"; - if (j.contains(";")) return "print: semicolon"; - return "print: other | " + (j.length()>60 ? j.substring(0,60) : j); - } - - private static String wsCause(String s) { - String t = s.trim(); - if (t.startsWith("using")) return "using args"; - if (t.startsWith("inline")) return "inline modifier"; - if (t.startsWith("end ")) return "end marker"; - if (t.startsWith("=")) return "method body ="; - if (t.contains("=>")) return "self type / arrow"; - if (t.startsWith("^")) return "capture set"; - if (t.startsWith(":")) return "type ascription"; - if (t.startsWith(",")) return "comma"; - if (t.startsWith("private")||t.startsWith("protected")) return "access modifier"; - return "other: " + (t.length()>16 ? t.substring(0,16) : t); - } -} diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java index b7852773d84..5ec13bf78e4 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/MethodInvocationTest.java @@ -606,4 +606,21 @@ def f(): Int = 1 ); } + @Test + void nestedBlockCommentBeforeTrailingComma() { + rewriteRun( + scala( + """ + object O { + def f(a: Int, b: Int): Int = a + val x = f( + 1, + 2 /* x /* y */ z */, + ) + } + """ + ) + ); + } + } From f91ce6e5f18113b68e6b11b2bdc6822c657d3cb3 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Mon, 17 Aug 2026 09:52:33 +0200 Subject: [PATCH 53/55] Model `asInstanceOf` as the method invocation it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `asInstanceOf` is a method on `scala.Any`, and Dotty gives `x.asInstanceOf[T]` the same tree as any other generic call, which the parser peeled off by name into a J.TypeCast: the expression came second, a J.ControlParentheses stood in for the `[...]`, and the run before the `.` needed a marker of its own because the node had no slot for it. The run after the `.` had no slot at all, so `x.` followed by a newline and `asInstanceOf[T]` moved the dot onto the second line. As a J.MethodInvocation every part has a home — the select's padding, the name's prefix, the type-parameter container — and AsInstanceOfPrefix is gone. The printer keeps rendering a J.TypeCast the Scala way for recipes that build one. --- .../org/openrewrite/scala/ScalaPrinter.java | 8 +- .../scala/marker/AsInstanceOfPrefix.java | 39 -------- .../scala/internal/ScalaTreeVisitor.scala | 90 +++++++------------ ...ypeCastTest.java => AsInstanceOfTest.java} | 43 ++++++++- 4 files changed, 76 insertions(+), 104 deletions(-) delete mode 100644 rewrite-scala/src/main/java/org/openrewrite/scala/marker/AsInstanceOfPrefix.java rename rewrite-scala/src/test/java/org/openrewrite/scala/tree/{TypeCastTest.java => AsInstanceOfTest.java} (74%) diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index f7e0110a55b..687aeef7008 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -34,7 +34,6 @@ import org.openrewrite.java.tree.TypeTree; import org.openrewrite.marker.Marker; import org.openrewrite.scala.marker.AmpersandIntersection; -import org.openrewrite.scala.marker.AsInstanceOfPrefix; import org.openrewrite.scala.marker.BlockArgument; import org.openrewrite.scala.marker.DottedMatch; import org.openrewrite.scala.marker.Implicit; @@ -1319,13 +1318,10 @@ public J visitConstructorInvocation(S.ConstructorInvocation ci, PrintOutputCaptu @Override public J visitTypeCast(J.TypeCast typeCast, PrintOutputCapture

p) { - // asInstanceOf handling + // The parser builds a J.MethodInvocation for `asInstanceOf`; a J.TypeCast reaches + // here only from a recipe, and Scala spells a cast this way. beforeSyntax(typeCast, Space.Location.TYPE_CAST_PREFIX, p); visit(typeCast.getExpression(), p); - Optional asInstanceOfPrefix = typeCast.getMarkers().findFirst(AsInstanceOfPrefix.class); - if (asInstanceOfPrefix.isPresent()) { - visitSpace(asInstanceOfPrefix.get().getPrefix(), Space.Location.LANGUAGE_EXTENSION, p); - } p.append(".asInstanceOf"); if (typeCast.getClazz() instanceof J.ControlParentheses) { J.ControlParentheses controlParens = (J.ControlParentheses) typeCast.getClazz(); diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/marker/AsInstanceOfPrefix.java b/rewrite-scala/src/main/java/org/openrewrite/scala/marker/AsInstanceOfPrefix.java deleted file mode 100644 index a9129c45650..00000000000 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/marker/AsInstanceOfPrefix.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - *

- * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - *

- * https://docs.moderne.io/licensing/moderne-source-available-license - *

- * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.openrewrite.scala.marker; - -import lombok.Value; -import lombok.With; -import org.openrewrite.Tree; -import org.openrewrite.java.tree.Space; -import org.openrewrite.marker.Marker; - -import java.util.UUID; - -/** - * Stores the whitespace that appears before {@code .asInstanceOf} in Scala - * constructs like {@code expr.asInstanceOf[Type]} where the dot may be on a new line. - */ -@Value -@With -public class AsInstanceOfPrefix implements Marker { - UUID id; - Space prefix; - - public static AsInstanceOfPrefix create(Space prefix) { - return new AsInstanceOfPrefix(Tree.randomId(), prefix); - } -} diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 0f6158c20c2..65c5f775ab6 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -55,7 +55,6 @@ import org.openrewrite.scala.marker.ScalaForLoop import org.openrewrite.scala.marker.BlockArgument import org.openrewrite.scala.marker.CommaContinuation import org.openrewrite.scala.marker.FunctionApplication -import org.openrewrite.scala.marker.AsInstanceOfPrefix import org.openrewrite.scala.marker.TypeAscription import org.openrewrite.scala.marker.UnderscorePlaceholderLambda import org.openrewrite.scala.marker.PartialFunctionLiteral @@ -5900,76 +5899,51 @@ class ScalaTreeVisitor( case sel: Trees.Select[?] => // Check if this is asInstanceOf if (sel.name.toString == "asInstanceOf" && ta.args.size == 1) { - // This is a type cast operation: obj.asInstanceOf[Type] - - // Visit the expression being cast (with its own prefix) - // The expression (sel.qualifier) is the object before .asInstanceOf - val expr = visitTree(sel.qualifier) match { + // `asInstanceOf` is a method on `Any`, and Dotty gives it the same tree as any + // other generic call, so it is modelled as one: the name's prefix holds the run + // after the `.`, and the type argument list is the method's type parameters. + val select = visitTree(sel.qualifier) match { case e: Expression => e case j: J => new S.StatementExpression(Tree.randomId(), j) case null => throw unmappedException(ta) } - - // Capture whitespace between the qualifier and ".asInstanceOf" - // (e.g. when ".asInstanceOf" sits on its own line as part of a chain). - val asInstanceOfEnd = Math.max(0, sel.span.end - offsetAdjustment) - val asInstanceOfNameLen = "asInstanceOf".length - val dotPos = asInstanceOfEnd - asInstanceOfNameLen - 1 - val asInstanceOfPrefix: Space = - if (cursor >= 0 && cursor <= dotPos && dotPos <= source.length) { - ScalaSpace.format(source.substring(cursor, dotPos)) - } else { - Space.EMPTY - } - updateCursor(sel.span.end) - - // Now handle the type argument in brackets - // Extract any space before the opening bracket - val typeArgStart = ta.args.head.span.start - offsetAdjustment - val spaceBeforeBracket = if (cursor < typeArgStart && typeArgStart <= source.length) { - val between = source.substring(cursor, typeArgStart) - // Find the bracket position - val bracketPos = positionOfNextIn(between, "[", 0) - if (bracketPos >= 0) { - cursor = cursor + bracketPos + 1 // Move past the bracket - Space.format(between.substring(0, bracketPos)) - } else { - Space.EMPTY - } - } else { - Space.EMPTY - } - - // Visit the target type. Use the type-position helper so that function types - // (`A => B`), tuple types, union/intersection types, etc. are mapped to a - // `TypeTree` rather than being misread as expressions. + val dotIdx = positionOfNext(".", cursor) + val selectAfter = if (dotIdx > cursor) ScalaSpace.format(source, cursor, dotIdx) else Space.EMPTY + if (dotIdx >= cursor) cursor = dotIdx + 1 + val nameStart = indexOfNextNonWhitespace(cursor) + val namePrefix = if (nameStart > cursor) ScalaSpace.format(source, cursor, nameStart) else Space.EMPTY + cursor = nameStart + "asInstanceOf".length + + val openBracket = positionOfNext("[", cursor) + val beforeBracket = if (openBracket > cursor) ScalaSpace.format(source, cursor, openBracket) else Space.EMPTY + if (openBracket >= cursor) cursor = openBracket + 1 + + // The target type goes through the type-position helper so function types + // (`A => B`), tuple, union and intersection types map to a `TypeTree`. val targetType = visitTypeTree(ta.args.head) match { case tt: TypeTree => tt case null => throw unmappedException(ta) } - - // Update cursor past the closing bracket + val closeBracket = positionOfNext("]", cursor) + val beforeClose = if (closeBracket > cursor) ScalaSpace.format(source, cursor, closeBracket) else Space.EMPTY + if (closeBracket >= cursor) cursor = closeBracket + 1 updateCursor(ta.span.end) - val typeCastMarkers = - if (asInstanceOfPrefix.getWhitespace.nonEmpty || !asInstanceOfPrefix.getComments.isEmpty) { - Markers.EMPTY.addIfAbsent(AsInstanceOfPrefix.create(asInstanceOfPrefix)) - } else { - Markers.EMPTY - } + val typeArgs = new util.ArrayList[JRightPadded[Expression]]() + typeArgs.add(new JRightPadded(targetType.asInstanceOf[Expression], beforeClose, Markers.EMPTY)) + val noArgs = JContainer.build(Space.EMPTY, Collections.emptyList[JRightPadded[Expression]](), + Markers.build(Collections.singletonList(new OmitParentheses(Tree.randomId())))) - return new J.TypeCast( + return new J.MethodInvocation( Tree.randomId(), - Space.EMPTY, // TypeCast itself has no prefix - the space is handled by the variable initializer - typeCastMarkers, - new J.ControlParentheses[TypeTree]( - Tree.randomId(), - spaceBeforeBracket, - Markers.EMPTY, - JRightPadded.build(targetType) - ), - expr + Space.EMPTY, + Markers.EMPTY, + new JRightPadded(select, selectAfter, Markers.EMPTY), + JContainer.build(beforeBracket, typeArgs, Markers.EMPTY), + ident("asInstanceOf", namePrefix), + noArgs, + methodTypeOfTree(ta) ) } diff --git a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/TypeCastTest.java b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AsInstanceOfTest.java similarity index 74% rename from rewrite-scala/src/test/java/org/openrewrite/scala/tree/TypeCastTest.java rename to rewrite-scala/src/test/java/org/openrewrite/scala/tree/AsInstanceOfTest.java index 0810ada10e8..5881e9165a0 100644 --- a/rewrite-scala/src/test/java/org/openrewrite/scala/tree/TypeCastTest.java +++ b/rewrite-scala/src/test/java/org/openrewrite/scala/tree/AsInstanceOfTest.java @@ -16,11 +16,13 @@ package org.openrewrite.scala.tree; import org.junit.jupiter.api.Test; +import org.openrewrite.java.tree.J; import org.openrewrite.test.RewriteTest; +import static org.assertj.core.api.Assertions.assertThat; import static org.openrewrite.scala.Assertions.scala; -class TypeCastTest implements RewriteTest { +class AsInstanceOfTest implements RewriteTest { @Test void simpleCast() { @@ -173,4 +175,43 @@ void castToFunctionType() { ) ); } + + @Test + void newlineBetweenDotAndKeyword() { + rewriteRun( + scala( + """ + object Test { + val obj: Any = 1 + val num = obj. + asInstanceOf[Int] + } + """ + ) + ); + } + + @Test + void isAMethodInvocation() { + rewriteRun( + scala( + """ + object Test { + val obj: Any = 1 + val num = obj.asInstanceOf[Int] + } + """, + spec -> spec.afterRecipe(cu -> { + J.ClassDeclaration test = (J.ClassDeclaration) cu.getStatements().get(0); + J.VariableDeclarations num = (J.VariableDeclarations) test.getBody().getStatements().get(1); + J.MethodInvocation cast = (J.MethodInvocation) num.getVariables().get(0).getInitializer(); + assertThat(cast.getSimpleName()).isEqualTo("asInstanceOf"); + assertThat(cast.getArguments()).isEmpty(); + assertThat(cast.getTypeParameters()).singleElement() + .isInstanceOfSatisfying(J.Identifier.class, t -> assertThat(t.getSimpleName()).isEqualTo("Int")); + }) + ) + ); + } + } From d640068b509c9d3820c65d43e8ffe5c26b27ef6f Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Mon, 17 Aug 2026 09:54:05 +0200 Subject: [PATCH 54/55] Remove the unused type-ascription marker Type ascription is modelled by S.TypeAscription, and nothing has attached this marker since. Its javadoc described marking a J.TypeCast for `expr: Type`, which is the mapping rewrite-scala/CLAUDE.md rules out. --- .../org/openrewrite/scala/ScalaPrinter.java | 1 - .../scala/marker/TypeAscription.java | 50 ------------------- .../scala/internal/ScalaTreeVisitor.scala | 1 - 3 files changed, 52 deletions(-) delete mode 100644 rewrite-scala/src/main/java/org/openrewrite/scala/marker/TypeAscription.java diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java index 687aeef7008..6d1f6696962 100644 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java +++ b/rewrite-scala/src/main/java/org/openrewrite/scala/ScalaPrinter.java @@ -47,7 +47,6 @@ import org.openrewrite.scala.marker.ThenKeyword; import org.openrewrite.scala.marker.TypeProjection; import org.openrewrite.scala.marker.ScalaForLoop; -import org.openrewrite.scala.marker.TypeAscription; import org.openrewrite.scala.marker.PartialFunctionLiteral; import org.openrewrite.scala.marker.ContextFunctionArrow; import org.openrewrite.scala.marker.UnderscorePlaceholderLambda; diff --git a/rewrite-scala/src/main/java/org/openrewrite/scala/marker/TypeAscription.java b/rewrite-scala/src/main/java/org/openrewrite/scala/marker/TypeAscription.java deleted file mode 100644 index bbb5d9e62cc..00000000000 --- a/rewrite-scala/src/main/java/org/openrewrite/scala/marker/TypeAscription.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2025 the original author or authors. - *

- * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - *

- * https://docs.moderne.io/licensing/moderne-source-available-license - *

- * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.openrewrite.scala.marker; - -import org.openrewrite.Tree; -import org.openrewrite.marker.Marker; - -import java.util.UUID; - -/** - * Marks a {@link org.openrewrite.java.tree.J.TypeCast} that represents a Scala type - * ascription ({@code expr: Type}) rather than a Java-style cast ({@code (Type) expr}). - *

- * When this marker is present, the printer emits {@code expr: Type} instead of - * {@code (Type) expr}. - */ -public class TypeAscription implements Marker { - private final UUID id; - - public TypeAscription(UUID id) { - this.id = id; - } - - @Override - public UUID getId() { - return id; - } - - @Override - public TypeAscription withId(UUID id) { - return new TypeAscription(id); - } - - public static TypeAscription create() { - return new TypeAscription(Tree.randomId()); - } -} diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 65c5f775ab6..957217f3a73 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -55,7 +55,6 @@ import org.openrewrite.scala.marker.ScalaForLoop import org.openrewrite.scala.marker.BlockArgument import org.openrewrite.scala.marker.CommaContinuation import org.openrewrite.scala.marker.FunctionApplication -import org.openrewrite.scala.marker.TypeAscription import org.openrewrite.scala.marker.UnderscorePlaceholderLambda import org.openrewrite.scala.marker.PartialFunctionLiteral import org.openrewrite.scala.marker.ContextFunctionArrow From 83bd646d8c6a7af6f57896144e139e89c545ea4b Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Mon, 17 Aug 2026 10:01:59 +0200 Subject: [PATCH 55/55] State the end-marker and parenthesized-type rules once Several sites repeated why Dotty's span reaches past a definition to its end marker, and why a parenthesized annotated argument is a type. Each rule now sits at the helper it governs, and the comments that only restated the line below them are gone. --- .../scala/internal/ScalaTreeVisitor.scala | 38 +++++-------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala index 957217f3a73..df435398495 100644 --- a/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala +++ b/rewrite-scala/src/main/scala/org/openrewrite/scala/internal/ScalaTreeVisitor.scala @@ -1468,7 +1468,6 @@ class ScalaTreeVisitor( elements.asScala.toSeq, identity, ")", initPrefix, Markers.EMPTY) } - // Update cursor to end of expression updateCursor(app.span.end) new J.NewArray( @@ -3566,10 +3565,8 @@ class ScalaTreeVisitor( } } - // Claim a trailing end marker before the cursor moves past it val endMarkerMarkers = withEndMarker(Markers.EMPTY, vd.span, vd.name.toString) - // Update cursor to end of ValDef updateCursor(vd.span.end) // Create variable declarator @@ -4069,8 +4066,7 @@ class ScalaTreeVisitor( if (cursor < source.length && md.span.exists) { val endPos = Math.max(0, md.span.end - offsetAdjustment) if (isBraceless) { - // No closing brace for braceless syntax. Dotty's span covers a trailing `end` - // marker, which is not whitespace. + // No closing brace for braceless syntax. if (cursor < endPos) { val bodyEnd = Math.min(endPos, source.length) endMarkerAt(cursor, bodyEnd) match { @@ -4087,8 +4083,7 @@ class ScalaTreeVisitor( val closeBraceIdx = remaining.lastIndexOf('}') if (closeBraceIdx >= 0) { endSpace = Space.format(remaining.substring(0, closeBraceIdx)) - // Stop at the `}`. Dotty's span reaches past it to a trailing `end` marker, - // which the caller claims by name. + // The body ends at the `}`; a marker beyond it is the caller's to claim. cursor = cursor + closeBraceIdx + 1 } } @@ -4128,13 +4123,10 @@ class ScalaTreeVisitor( } else { Markers.build(Collections.singletonList(SObject.create())) } - // an `end Foo` closing an indented object can sit beyond dotty's span; try the span first, - // then claim by name val objectMarkers = if (moduleEndMarker != null) objectBaseMarkers.add(EndMarker(Tree.randomId(), moduleEndMarker)) else withEndMarker(objectBaseMarkers, md.span, md.name.toString) - // Update cursor to end of module def if (md.span.exists) { cursor = Math.max(cursor, md.span.end - offsetAdjustment) } @@ -4448,7 +4440,6 @@ class ScalaTreeVisitor( else ifBaseMarkers val ifMarkers = withEndMarker(ifInlineMarkers, ifTree.span) - // Update cursor to end of the if expression updateCursor(ifTree.span.end) new J.If( @@ -4596,7 +4587,6 @@ class ScalaTreeVisitor( else Markers.EMPTY val whileMarkers = withEndMarker(whileBaseMarkers, whileTree.span) - // Update cursor to end of the while loop updateCursor(whileTree.span.end) new J.WhileLoop( @@ -5088,7 +5078,6 @@ class ScalaTreeVisitor( } } - // Update cursor to end of the block updateCursor(block.span.end) val blockMarkers = if (!hasBraces) { @@ -5809,7 +5798,6 @@ class ScalaTreeVisitor( classDeclMarkers = classDeclMarkers.add(DerivesClause(Tree.randomId(), derivesText)) } if (endMarkerText == null) { - // an `end Foo` closing an indented body can sit beyond dotty's span classDeclMarkers = withEndMarker(classDeclMarkers, td.span, td.name.toString) } @@ -5919,10 +5907,11 @@ class ScalaTreeVisitor( if (openBracket >= cursor) cursor = openBracket + 1 // The target type goes through the type-position helper so function types - // (`A => B`), tuple, union and intersection types map to a `TypeTree`. + // (`A => B`), tuple, union and intersection types map to a `TypeTree`. The type + // argument container holds expressions, which every type legal here also is. val targetType = visitTypeTree(ta.args.head) match { - case tt: TypeTree => tt - case null => throw unmappedException(ta) + case tt: TypeTree with Expression => tt + case _ => throw unmappedException(ta) } val closeBracket = positionOfNext("]", cursor) val beforeClose = if (closeBracket > cursor) ScalaSpace.format(source, cursor, closeBracket) else Space.EMPTY @@ -5930,7 +5919,7 @@ class ScalaTreeVisitor( updateCursor(ta.span.end) val typeArgs = new util.ArrayList[JRightPadded[Expression]]() - typeArgs.add(new JRightPadded(targetType.asInstanceOf[Expression], beforeClose, Markers.EMPTY)) + typeArgs.add(new JRightPadded[Expression](targetType, beforeClose, Markers.EMPTY)) val noArgs = JContainer.build(Space.EMPTY, Collections.emptyList[JRightPadded[Expression]](), Markers.build(Collections.singletonList(new OmitParentheses(Tree.randomId())))) @@ -8036,8 +8025,6 @@ class ScalaTreeVisitor( // Dotty wraps a literal type in a SingletonTypeTree new S.LiteralType(Tree.randomId(), prefix, Markers.EMPTY, qualifier, typeFor(stt.span)) } else { - // After visiting qualifier, cursor is at the end of qualifier. - // The remaining source should be whitespace followed by ".type". val endPos = Math.max(0, stt.span.end - offsetAdjustment) val between = if (cursor < endPos && endPos <= source.length) source.substring(cursor, endPos) else "" val dotIdx = positionOfNextIn(between, ".", 0) @@ -8122,8 +8109,7 @@ class ScalaTreeVisitor( case t: untpd.Tuple => Option(visitTypeTree(t)).map(_.asInstanceOf[J]).getOrElse(visitTree(ann.arg)) case _ => visitTree(ann.arg) } - // Capture-checking syntax (`T^`, `T^{it}`) desugars to a synthetic `retains` annotation - // with no `@` in source, so it stays a suffix on the type it follows. + // Capture-checking syntax (`T^`, `T^{it}`) is a suffix on the type it follows. val captureText = consumeCaptureSet() if (captureText != null) { updateCursor(ann.span.end) @@ -8153,8 +8139,6 @@ class ScalaTreeVisitor( if (isAnnotatedType) { val typeExpr: TypeTree = arg match { case tt: TypeTree => tt - // `(Context ?=> Symbol) @unchecked`: the parenthesized form is a type, and - // J.Parentheses is not a TypeTree case par: J.Parentheses[?] if par.getTree.isInstanceOf[TypeTree] => val inner = par.withPrefix[J.Parentheses[TypeTree]](Space.EMPTY) new J.ParenthesizedTypeTree(Tree.randomId(), par.getPrefix, Markers.EMPTY, @@ -8337,7 +8321,6 @@ class ScalaTreeVisitor( val remaining = if (cursor < endPos && endPos <= source.length) source.substring(cursor, endPos) else "" var extEndMarker: String = null val endSpace = if (isExtBraceless) { - // dotty's span covers a trailing `end extension`, which is not whitespace endMarkerAt(cursor, endPos) match { case Some((start, text)) => extEndMarker = source.substring(cursor, start + text.length) @@ -9318,7 +9301,7 @@ class ScalaTreeVisitor( } else null } - /** Attaches a consumed `using` keyword to the first argument, which every printer path emits. */ + /** Attaches a consumed `using` keyword to the argument list. */ private def withUsing(args: util.ArrayList[JRightPadded[Expression]], text: String): Unit = { if (text != null && !args.isEmpty) { val first = args.get(0) @@ -9538,8 +9521,7 @@ class ScalaTreeVisitor( } /** Adds an {@link EndMarker} for an end marker sitting between the cursor and the end of - * {@code span}, if any. Call before advancing the cursor past the span, which would - * otherwise skip the marker. + * {@code span}, if any. */ private def withEndMarker(markers: Markers, span: Spans.Span, name: String = null): Markers = { // A `val`'s or `given`'s end marker sits beyond dotty's span for the definition, so when