From 60e40be8a8e41cd770198072e16b39e651e66a64 Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Tue, 4 Aug 2026 16:03:06 +0200 Subject: [PATCH 01/22] [formatter] Small refactorings of KotlinInputAstVisitor --- build.gradle.kts | 1 - .../ktfmt/format/KotlinInputAstVisitor.kt | 719 +++++------------- .../com/facebook/ktfmt/format/OpsUtils.kt | 164 ++++ .../com/facebook/ktfmt/format/PsiUtils.kt | 212 +++++- 4 files changed, 565 insertions(+), 531 deletions(-) create mode 100644 core/src/main/java/com/facebook/ktfmt/format/OpsUtils.kt diff --git a/build.gradle.kts b/build.gradle.kts index 695df045e..b6e71c90e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -66,7 +66,6 @@ class KtfmtArgumentsProvider( @get:Input val check: Boolean, ) : CommandLineArgumentProvider { override fun asArguments(): Iterable = buildList { - add("--quiet") if (check) { add("--dry-run") add("--set-exit-if-changed") diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 909e8de2d..487286a80 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -22,16 +22,14 @@ import com.facebook.ktfmt.util.ownValOrVarKeywordText import com.google.common.base.Throwables import com.google.common.collect.ImmutableList import com.google.googlejavaformat.Doc -import com.google.googlejavaformat.Doc.Level import com.google.googlejavaformat.FormattingError import com.google.googlejavaformat.Indent import com.google.googlejavaformat.Indent.Const.ZERO import com.google.googlejavaformat.OpsBuilder +import com.google.googlejavaformat.OpsBuilder.BlankLineWanted import com.google.googlejavaformat.Output.BreakTag import java.util.ArrayDeque import java.util.Optional -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.contract import kotlin.jvm.optionals.getOrNull import org.jetbrains.kotlin.com.intellij.psi.PsiComment import org.jetbrains.kotlin.com.intellij.psi.PsiElement @@ -132,7 +130,6 @@ import org.jetbrains.kotlin.psi.KtWhenConditionWithExpression import org.jetbrains.kotlin.psi.KtWhenExpression import org.jetbrains.kotlin.psi.KtWhileExpression import org.jetbrains.kotlin.psi.psiUtil.children -import org.jetbrains.kotlin.psi.psiUtil.getPrevSiblingIgnoringWhitespace import org.jetbrains.kotlin.psi.psiUtil.startOffset import org.jetbrains.kotlin.psi.psiUtil.startsWithComment import org.jetbrains.kotlin.psi.stubs.elements.KtStubElementTypes @@ -254,9 +251,7 @@ open class KotlinInputAstVisitor( // TODO(strulovich): Should this have the same indentation behaviour as `x && y`? visit(type.getLeftTypeRef()) - builder.space() - builder.token("&") - builder.space() + builder.spacedToken("&") visit(type.getRightTypeRef()) } @@ -277,13 +272,11 @@ open class KotlinInputAstVisitor( val typeReference = typeProjection.typeReference when (typeProjection.projectionKind) { KtProjectionKind.IN -> { - builder.token("in") - builder.space() + builder.tokenThenSpace("in") visit(typeReference) } KtProjectionKind.OUT -> { - builder.token("out") - builder.space() + builder.tokenThenSpace("out") visit(typeReference) } KtProjectionKind.STAR -> builder.token("*") @@ -342,7 +335,7 @@ open class KotlinInputAstVisitor( builder.block(ZERO) { if (receiverTypeReference != null) { visit(receiverTypeReference) - builder.breakOp(Doc.FillMode.INDEPENDENT, "", expressionBreakIndent) + builder.breakToFill(expressionBreakIndent) builder.token(".") } if (name != null) { @@ -355,8 +348,7 @@ open class KotlinInputAstVisitor( builder.token("(") builder.token(")") emitTypeOrDelegationCall { - builder.breakOp(Doc.FillMode.INDEPENDENT, " ", expressionBreakIndent) - builder.block(expressionBreakIndent) { visit(typeOrDelegationCall) } + builder.breakToFillThenBlock(" ", expressionBreakIndent) { visit(typeOrDelegationCall) } } } } else { @@ -378,9 +370,7 @@ open class KotlinInputAstVisitor( } } - if (typeConstraintList != null) { - visit(typeConstraintList) - } + visit(typeConstraintList) if (bodyExpression is KtBlockExpression) { builder.space() visit(bodyExpression) @@ -388,34 +378,30 @@ open class KotlinInputAstVisitor( builder.space() builder.block(ZERO) { builder.token("=") - if (isLambdaOrScopingFunction(bodyExpression)) { + if (bodyExpression.isLambdaOrScopingFunction) { visitLambdaOrScopingFunction(bodyExpression) - } else if (isChainedScopingFunction(bodyExpression)) { + } else if (bodyExpression.isChainedScopingFunction) { visitChainedScopingFunction(bodyExpression, emitLeadingBreak = true) - } else if (isBlockLikeCall(bodyExpression)) { + } else if (bodyExpression.isBlockLikeCall) { builder.space() visit(bodyExpression) - } else if (isChainedBlockLikeCall(bodyExpression)) { + } else if (bodyExpression.isChainedBlockLikeCall) { visitChainedBlockLikeCall(bodyExpression, emitLeadingBreak = true) } else { builder.block(expressionBreakIndent) { - builder.breakOp(Doc.FillMode.INDEPENDENT, " ", ZERO) + builder.breakToFill(" ") builder.block(ZERO) { visit(bodyExpression) } } } } } - builder.guessToken(";") + builder.guessSemicolon() } if (forceTrailingBreak) { builder.forcedBreak() } } - private fun genSym(): BreakTag { - return BreakTag() - } - private fun emitBracedBlock( bodyBlockExpression: PsiElement, emitChildren: (Array) -> Unit, @@ -425,27 +411,27 @@ open class KotlinInputAstVisitor( if (statements.isNotEmpty()) { builder.block(blockIndent) { builder.forcedBreak() - builder.blankLineWanted(OpsBuilder.BlankLineWanted.PRESERVE) + builder.blankLineWanted(BlankLineWanted.PRESERVE) emitChildren(statements) } builder.forcedBreak() - builder.blankLineWanted(OpsBuilder.BlankLineWanted.NO) + builder.blankLineWanted(BlankLineWanted.NO) } builder.token("}", blockIndent) } private fun visitStatement(statement: PsiElement) { builder.block(ZERO) { visit(statement) } - builder.guessToken(";") + builder.guessSemicolon() } private fun visitStatements(statements: Array) { var first = true - builder.guessToken(";") + builder.guessSemicolon() for (statement in statements) { builder.forcedBreak() if (!first) { - builder.blankLineWanted(OpsBuilder.BlankLineWanted.PRESERVE) + builder.blankLineWanted(BlankLineWanted.PRESERVE) } first = false markForPartialFormat() @@ -457,8 +443,8 @@ open class KotlinInputAstVisitor( override fun visitProperty(property: KtProperty) { builder.sync(property) builder.block(ZERO) { - declareOne( - kind = DeclarationKind.FIELD, + emitVariableLikeDeclaration( + isField = true, modifiers = property.modifierList, valOrVarKeyword = property.valOrVarKeyword.text, typeParameters = property.typeParameterList, @@ -472,12 +458,13 @@ open class KotlinInputAstVisitor( backingField = property.fieldDeclaration, ) } - builder.guessToken(";") + builder.guessSemicolon() if (property.parent !is KtWhenExpression) { builder.forcedBreak() } } + @Deprecated("Kept for backwards compatibility, will be removed in the future") fun visitBackingField(backingField: KtBackingField) { emitBackingField(backingField) } @@ -504,7 +491,7 @@ open class KotlinInputAstVisitor( receiver is KtStringTemplateExpression -> { builder.block(expressionBreakIndent) { visit(receiver) - builder.breakOp(Doc.FillMode.UNIFIED, "", ZERO) + builder.breakOp() builder.token(expression.operationSign.value) visit(expression.selectorExpression) } @@ -516,12 +503,12 @@ open class KotlinInputAstVisitor( visit(expression.selectorExpression) } } - isChainedScopingFunction(expression) && - isMultilineScopingFunction(chainRoot(expression)) && - chainedSelectorsHaveNoValueArguments(expression) -> { + expression.isChainedScopingFunction && + expression.chainRoot.isMultilineScopingFunction && + !chainedSelectorsHaveValueArguments(expression) -> { visitChainedScopingFunction(expression, emitLeadingBreak = false) } - isChainedBlockLikeCall(expression) -> { + expression.isChainedBlockLikeCall -> { visitChainedBlockLikeCall(expression, emitLeadingBreak = false) } else -> { @@ -552,10 +539,10 @@ open class KotlinInputAstVisitor( val useBlockLikeLambdaStyle = parts.last().isLambda() && parts.count { it.isLambda() } == 1 val groupingInfos = computeGroupingInfo(parts, useBlockLikeLambdaStyle) builder.block(expressionBreakIndent) { - val nameTag = genSym() // allows adjusting arguments indentation if a break will be made + val nameTag = BreakTag() // allows adjusting arguments indentation if a break will be made for ((index, ktExpression) in parts.withIndex()) { if (ktExpression is KtQualifiedExpression) { - builder.breakOp(Doc.FillMode.UNIFIED, "", ZERO, Optional.of(nameTag)) + builder.breakOp("", ZERO, Optional.of(nameTag)) } repeat(groupingInfos[index].groupOpenCount) { builder.open(ZERO) } when (ktExpression) { @@ -588,7 +575,7 @@ open class KotlinInputAstVisitor( // manages trailing commas, exploded chained calls keep the regular extra indent. val isLastPartOrBlockLikeCall = index == parts.size - 1 || - !options.manageTrailingCommas && isBlockLikeCall(selectorExpression) + !options.manageTrailingCommas && selectorExpression.isBlockLikeCall val argsIndentElse = if (isLastPartOrBlockLikeCall) ZERO else expressionBreakIndent val lambdaIndentElse = if (isTrailingLambda) expressionBreakNegativeIndent else ZERO val negativeLambdaIndentElse = if (isTrailingLambda) expressionBreakIndent else ZERO @@ -622,31 +609,6 @@ open class KotlinInputAstVisitor( } } - /** - * Decomposes a qualified expression into parts, so `rainbow.red.orange.yellow` becomes `[rainbow, - * rainbow.red, rainbow.red.orange, rainbow.orange.yellow]` - */ - private fun breakIntoParts(expression: KtExpression): List { - val parts = ArrayDeque() - - // use an ArrayDeque and add elements to the beginning so the innermost expression comes first - // foo.bar.yay -> [yay, bar.yay, foo.bar.yay] - - var node: KtExpression? = expression - while (node != null) { - parts.addFirst(node) - node = - when (node) { - is KtQualifiedExpression -> node.receiverExpression - is KtArrayAccessExpression -> node.arrayExpression - is KtPostfixExpression -> node.baseExpression - else -> null - } - } - - return parts.toList() - } - /** * Generates the [GroupingInfo] array to go with an array of [KtQualifiedExpression] parts * @@ -872,8 +834,8 @@ open class KotlinInputAstVisitor( arguments.any { argument -> val argumentExpression = argument.getArgumentExpression() argumentExpression != null && - (isBlockLikeCall(argumentExpression) || - isChainedBlockLikeCall(argumentExpression)) + (argumentExpression.isBlockLikeCall || + argumentExpression.isChainedBlockLikeCall) } wrapInBlock = !options.manageTrailingCommas breakBeforePostfix = @@ -959,23 +921,22 @@ open class KotlinInputAstVisitor( builder.token(",") builder.forcedBreak() } else if (hasParams) { - builder.breakOp(Doc.FillMode.INDEPENDENT, " ", ZERO) + builder.breakToFill(" ") } builder.token("->") } } if (hasParams || hasArrow || hasStatements || hasComments) { - builder.breakOp(Doc.FillMode.UNIFIED, " ", bracePlusZeroIndent) + builder.breakOp(" ", bracePlusZeroIndent) } if (hasStatements) { - builder.breakOp(Doc.FillMode.UNIFIED, "", bracePlusBlockIndent) - builder.block(bracePlusBlockIndent) { - builder.blankLineWanted(OpsBuilder.BlankLineWanted.NO) + builder.breakOpThenBlock(bracePlusBlockIndent) { + builder.blankLineWanted(BlankLineWanted.NO) val shouldForceMultiline = - options.preserveLambdaBreaks && hasSourceNewlineInLambdaBody(lambdaExpression) + options.preserveLambdaBreaks && lambdaExpression.hasSourceNewlineInLambdaBody if ( !shouldForceMultiline && @@ -987,15 +948,14 @@ open class KotlinInputAstVisitor( } else { visitStatements(expressionStatements) } - builder.breakOp(Doc.FillMode.UNIFIED, " ", bracePlusZeroIndent) + builder.breakOp(" ", bracePlusZeroIndent) } } else if (hasComments) { val blockComments = bodyExpression.children().filter { it is PsiComment && it.text.startsWith("/*") }.toList() - builder.breakOp(Doc.FillMode.UNIFIED, "", bracePlusBlockIndent) - builder.block(bracePlusBlockIndent) { + builder.breakOpThenBlock(bracePlusBlockIndent) { builder.fenceComments() - builder.blankLineWanted(OpsBuilder.BlankLineWanted.NO) + builder.blankLineWanted(BlankLineWanted.NO) if (blockComments.size == 1) { builder.token(blockComments[0].text) } else { @@ -1006,13 +966,13 @@ open class KotlinInputAstVisitor( builder.token(comment.text) } } - builder.breakOp(Doc.FillMode.UNIFIED, " ", bracePlusZeroIndent) + builder.breakOp(" ", bracePlusZeroIndent) } } if (hasParams || hasArrow || hasStatements || hasComments) { // If we had to break in the body, ensure there is a break before the closing brace - builder.breakOp(Doc.FillMode.UNIFIED, "", bracePlusZeroIndent) + builder.breakOp(bracePlusZeroIndent) } builder.block(bracePlusZeroIndent) { builder.fenceComments() @@ -1141,12 +1101,12 @@ open class KotlinInputAstVisitor( breakBeforePostfix: Boolean = options.manageTrailingCommas, ): BreakTag? { val breakAfterLastElement = hasTrailingComma || (postfix != null && breakBeforePostfix) - val nameTag = if (breakAfterLastElement) null else genSym() + val nameTag = if (breakAfterLastElement) null else BreakTag() if (prefix != null) { builder.token(prefix) if (breakAfterPrefix) { - builder.breakOp(Doc.FillMode.UNIFIED, "", ZERO, Optional.ofNullable(nameTag)) + builder.breakOp("", ZERO, Optional.ofNullable(nameTag)) } } @@ -1218,8 +1178,7 @@ open class KotlinInputAstVisitor( val isLambda = argument.getArgumentExpression() is KtLambdaExpression if (hasArgName) { visit(argument.getArgumentName()) - builder.space() - builder.token("=") + builder.spaceThenToken("=") if (isLambda) { builder.space() } @@ -1227,7 +1186,7 @@ open class KotlinInputAstVisitor( val indent = if (hasArgName && !isLambda) expressionBreakIndent else ZERO builder.block(indent, isEnabled = wrapInBlock) { if (hasArgName && !isLambda) { - builder.breakOp(Doc.FillMode.INDEPENDENT, " ", ZERO) + builder.breakToFill(" ") } if (argument.isSpread) { builder.token("*") @@ -1257,7 +1216,7 @@ open class KotlinInputAstVisitor( builder.space() visit(returnedExpression) } - builder.guessToken(";") + builder.guessSemicolon() } /** @@ -1271,11 +1230,10 @@ open class KotlinInputAstVisitor( builder.sync(expression) val op = expression.operationToken - if (KtTokens.ALL_ASSIGNMENTS.contains(op) && isLambdaOrScopingFunction(expression.right)) { + if (KtTokens.ALL_ASSIGNMENTS.contains(op) && expression.right.isLambdaOrScopingFunction) { // Assignments are statements in Kotlin; we don't have to worry about compound assignment. visit(expression.left) - builder.space() - builder.token(expression.operationReference.text) + builder.spaceThenToken(expression.operationReference.text) visitLambdaOrScopingFunction(expression.right) return } @@ -1306,9 +1264,8 @@ open class KotlinInputAstVisitor( if (isFirst) { builder.open(expressionBreakIndent) } - builder.breakOp(Doc.FillMode.UNIFIED, " ", ZERO) - builder.token(leftExpression.operationReference.text) - builder.space() + builder.breakOp(" ") + builder.tokenThenSpace(leftExpression.operationReference.text) } else -> { builder.space() @@ -1317,7 +1274,7 @@ open class KotlinInputAstVisitor( } builder.token(leftExpression.operationReference.text) val fillMode = - if (hasLineBreakingCommentBefore(leftExpression.operationReference)) + if (leftExpression.operationReference.hasLineBreakingCommentBefore) Doc.FillMode.INDEPENDENT else Doc.FillMode.UNIFIED builder.breakOp(fillMode, " ", ZERO) @@ -1328,28 +1285,6 @@ open class KotlinInputAstVisitor( builder.close() } - /** - * Checks if a line-breaking comment precedes [element] in the PSI tree. - * - * Line comments (`//`) always force a break. Block comments (`/* */`) only count if they are on - * their own line (preceded by whitespace with a newline). Inline block comments like `x /*tag*/ - * ||` do not force a break and should not trigger INDEPENDENT fill mode. - */ - private fun hasLineBreakingCommentBefore(element: PsiElement): Boolean { - var prev = element.prevSibling - while (prev is PsiWhiteSpace) { - prev = prev.prevSibling - } - if (prev !is PsiComment) return false - - // Line comments always force a line break - if (prev.text.startsWith("//")) return true - - // Block comments force a break only if on their own line - val beforeComment = prev.prevSibling - return beforeComment is PsiWhiteSpace && beforeComment.text.contains('\n') - } - override fun visitPostfixExpression(expression: KtPostfixExpression) { builder.sync(expression) builder.block(ZERO) { @@ -1393,11 +1328,6 @@ open class KotlinInputAstVisitor( visit(expression.baseExpression) } - internal enum class DeclarationKind { - FIELD, - PARAMETER, - } - /** * Declare one variable or variable-like thing. * @@ -1406,8 +1336,8 @@ open class KotlinInputAstVisitor( * - `a: Int` * - `private val b: */ - private fun declareOne( - kind: DeclarationKind, + private fun emitVariableLikeDeclaration( + isField: Boolean, modifiers: KtModifierList?, valOrVarKeyword: String?, typeParameters: KtTypeParameterList? = null, @@ -1420,20 +1350,16 @@ open class KotlinInputAstVisitor( accessors: List? = null, backingField: KtBackingField? = null, ): Int { - val verticalAnnotationBreak = genSym() - - val isField = kind == DeclarationKind.FIELD - + val verticalAnnotationBreak = BreakTag() if (isField) { - builder.blankLineWanted(OpsBuilder.BlankLineWanted.conditional(verticalAnnotationBreak)) + builder.blankLineWanted(BlankLineWanted.conditional(verticalAnnotationBreak)) } visit(modifiers) builder.block(ZERO) { builder.block(ZERO) { if (valOrVarKeyword != null) { - builder.token(valOrVarKeyword) - builder.space() + builder.tokenThenSpace(valOrVarKeyword) } if (typeParameters != null) { @@ -1457,7 +1383,7 @@ open class KotlinInputAstVisitor( if (type != null) { if (name != null) { builder.token(":") - builder.breakOp(Doc.FillMode.UNIFIED, " ", ZERO) + builder.breakOp(" ") } visit(type) } @@ -1471,45 +1397,26 @@ open class KotlinInputAstVisitor( // for example `by lazy { compute() }` if (delegate != null) { - builder.space() - builder.token("by") + builder.spaceThenToken("by") val delegateExpr = delegate.expression - if (isLambdaOrScopingFunction(delegateExpr)) { + if (delegateExpr.isLambdaOrScopingFunction) { builder.space() visit(delegate) - } else if (delegateExpr != null && isChainedScopingFunction(delegateExpr)) { + } else if (delegateExpr != null && delegateExpr.isChainedScopingFunction) { visitChainedScopingFunction(delegateExpr, emitLeadingBreak = true) - } else if (isBlockLikeCall(delegateExpr)) { + } else if (delegateExpr.isBlockLikeCall) { builder.space() visit(delegate) - } else if (delegateExpr != null && isChainedBlockLikeCall(delegateExpr)) { + } else if (delegateExpr != null && delegateExpr.isChainedBlockLikeCall) { visitChainedBlockLikeCall(delegateExpr, emitLeadingBreak = true) } else { - builder.breakOp(Doc.FillMode.UNIFIED, " ", expressionBreakIndent) - builder.block(expressionBreakIndent) { + builder.breakOpThenBlock(" ", expressionBreakIndent) { builder.fenceComments() visit(delegate) } } } else if (initializer != null) { - builder.space() - builder.token("=") - if (isLambdaOrScopingFunction(initializer)) { - visitLambdaOrScopingFunction(initializer) - } else if (isChainedScopingFunction(initializer)) { - visitChainedScopingFunction(initializer, emitLeadingBreak = true) - } else if (isBlockLikeCall(initializer)) { - builder.space() - visit(initializer) - } else if (isChainedBlockLikeCall(initializer)) { - visitChainedBlockLikeCall(initializer, emitLeadingBreak = true) - } else { - builder.breakOp(Doc.FillMode.UNIFIED, " ", expressionBreakIndent) - builder.block(expressionBreakIndent) { - builder.fenceComments() - visit(initializer) - } - } + emitInitializer(initializer) } } // for example `field = value`, `private set`, or `get = 2 * field` @@ -1527,7 +1434,7 @@ open class KotlinInputAstVisitor( for (component in propertyComponents) { builder.forcedBreak() // The semicolon must come after the newline, or the output code will not parse. - builder.guessToken(";") + builder.guessSemicolon() when (component) { is KtPropertyAccessor -> { @@ -1553,15 +1460,37 @@ open class KotlinInputAstVisitor( } } - builder.guessToken(";") + builder.guessSemicolon() if (isField) { - builder.blankLineWanted(OpsBuilder.BlankLineWanted.conditional(verticalAnnotationBreak)) + builder.blankLineWanted(BlankLineWanted.conditional(verticalAnnotationBreak)) } return 0 } + /** + * Emits `= `, laying the initializer out according to the kind of expression it is. + */ + private fun emitInitializer(initializer: KtExpression) { + builder.spaceThenToken("=") + if (initializer.isLambdaOrScopingFunction) { + visitLambdaOrScopingFunction(initializer) + } else if (initializer.isChainedScopingFunction) { + visitChainedScopingFunction(initializer, emitLeadingBreak = true) + } else if (initializer.isBlockLikeCall) { + builder.space() + visit(initializer) + } else if (initializer.isChainedBlockLikeCall) { + visitChainedBlockLikeCall(initializer, emitLeadingBreak = true) + } else { + builder.breakOpThenBlock(" ", expressionBreakIndent) { + builder.fenceComments() + visit(initializer) + } + } + } + private fun emitBackingField(backingField: KtBackingField) { builder.sync(backingField) builder.block(ZERO) { @@ -1571,31 +1500,14 @@ open class KotlinInputAstVisitor( if (type != null) { builder.block(expressionBreakIndent) { builder.token(":") - builder.breakOp(Doc.FillMode.UNIFIED, " ", ZERO) + builder.breakOp(" ") visit(type) } } val initializer = backingField.initializer if (initializer != null) { - builder.space() - builder.token("=") - if (isLambdaOrScopingFunction(initializer)) { - visitLambdaOrScopingFunction(initializer) - } else if (isChainedScopingFunction(initializer)) { - visitChainedScopingFunction(initializer, emitLeadingBreak = true) - } else if (isBlockLikeCall(initializer)) { - builder.space() - visit(initializer) - } else if (isChainedBlockLikeCall(initializer)) { - visitChainedBlockLikeCall(initializer, emitLeadingBreak = true) - } else { - builder.breakOp(Doc.FillMode.UNIFIED, " ", expressionBreakIndent) - builder.block(expressionBreakIndent) { - builder.fenceComments() - visit(initializer) - } - } + emitInitializer(initializer) } } } @@ -1629,114 +1541,6 @@ open class KotlinInputAstVisitor( } } - /** - * Returns whether an expression is a lambda or initializer expression in which case we will want - * to avoid indenting the lambda block - * - * Examples: - * 1. '... = { ... }' is a lambda expression - * 2. '... = Runnable { ... }' is considered a scoping function - * 3. '... = scope { ... }' '... = apply { ... }' is a scoping function - * 4. '... = scope.launch { ... }' is a dot-qualified scoping function - * - * but not: - * 1. '... = foo() { ... }' due to the empty parenthesis - * 2. '... = Runnable @Annotation { ... }' due to the annotation - */ - private fun isLambdaOrScopingFunction(expression: KtExpression?): Boolean { - if (expression == null) return false - val prev = expression.getPrevSiblingIgnoringWhitespace() - if (prev is PsiComment && prev.text.startsWith("//")) { - return false // Leading line comments cause weird indentation; block comments are ok. - } - - var carry = expression - if (carry is KtQualifiedExpression && carry.receiverExpression is KtSimpleNameExpression) { - carry = carry.selectorExpression - } - if (carry is KtCallExpression) { - if ( - carry.valueArgumentList?.leftParenthesis == null && - carry.lambdaArguments.isNotEmpty() && - carry.typeArgumentList?.arguments.isNullOrEmpty() - ) { - carry = carry.lambdaArguments[0].getArgumentExpression() - } else { - return false - } - } - if (carry is KtLabeledExpression) { - carry = carry.baseExpression - } - if (carry is KtLambdaExpression) { - return true - } - - return false - } - - /** - * Returns true when [expression] is a chain whose innermost receiver is a scoping function call. - * - * For example, this matches `runnnnn { ... }.baz()` (innermost receiver `runnnnn { ... }` is a - * scoping function). It does not match a chain whose root is a plain identifier or a non-scoping - * call, since those don't have a block-like opener to anchor the chain against. - */ - @OptIn(ExperimentalContracts::class) - private fun isChainedScopingFunction(expression: KtExpression): Boolean { - contract { returns(true) implies (expression is KtQualifiedExpression) } - - if (expression !is KtQualifiedExpression) return false - return isLambdaOrScopingFunction(chainRoot(expression)) - } - - /** Returns the innermost receiver of a (possibly nested) qualified [expression]. */ - private fun chainRoot(expression: KtExpression): KtExpression { - var root: KtExpression = expression - while (root is KtQualifiedExpression) { - root = root.receiverExpression - } - return root - } - - /** - * Returns true when [expression] is a call that is forced onto multiple lines regardless of the - * line width, either because its value argument list has a trailing comma (e.g. `foo(\n 1,\n - * 2,\n)`) or because one of its arguments is itself a block-like multiline call. - * - * Such calls are rendered "block-like": they stay on the same line as the preceding `=`/`by` - * operator (instead of breaking and indenting after it), and any chained selectors break onto - * their own line, mirroring how scoping functions and lambdas are handled. - */ - @OptIn(ExperimentalContracts::class) - private fun isBlockLikeCall(expression: KtExpression?): Boolean { - contract { returns(true) implies (expression is KtCallExpression) } - - if (expression == null) return false - val prev = expression.getPrevSiblingIgnoringWhitespace() - if (prev is PsiComment) { - return false // Leading comments cause weird indentation; keep the default layout. - } - - if (expression !is KtCallExpression) return false - val valueArgumentList = expression.valueArgumentList ?: return false - if (valueArgumentList.trailingComma != null) return true - return valueArgumentList.arguments.any { argument -> - val argumentExpression = argument.getArgumentExpression() - argumentExpression != null && - (isBlockLikeCall(argumentExpression) || isChainedBlockLikeCall(argumentExpression)) - } - } - - /** Returns true when [expression] is a chain whose innermost receiver is a [isBlockLikeCall]. */ - @OptIn(ExperimentalContracts::class) - private fun isChainedBlockLikeCall(expression: KtExpression): Boolean { - contract { returns(true) implies (expression is KtQualifiedExpression) } - - if (expression !is KtQualifiedExpression) return false - return isBlockLikeCall(chainRoot(expression)) - } - /** * Emit a `foo(\n ...,\n).bar().baz()` style chain whose innermost receiver is a block-like * multiline call: render the receiver call normally (so its closing paren sits at the surrounding @@ -1752,10 +1556,24 @@ open class KotlinInputAstVisitor( } visit(parts[0]) + emitChainedSelectors(parts, forceBreak = true) + } + + /** + * Emit the `.selector` parts of a chain (everything after the innermost receiver, [parts]`[0]`), + * each on its own line, indented by [expressionBreakIndent]. + * + * @param forceBreak whether the break before each selector is forced, or may stay flat + */ + private fun emitChainedSelectors(parts: List, forceBreak: Boolean) { builder.block(expressionBreakIndent) { for (i in 1 until parts.size) { val part = parts[i] as KtQualifiedExpression - builder.forcedBreak() + if (forceBreak) { + builder.forcedBreak() + } else { + builder.breakOp() + } builder.token(part.operationSign.value) val selectorExpression = part.selectorExpression if (selectorExpression is KtCallExpression) { @@ -1778,6 +1596,10 @@ open class KotlinInputAstVisitor( * value arguments (i.e. `.foo(a)` or `.fold({ ... }, { ... })`). Used to decide formatting style * for property initializers: value-arg chains stay on same line as `=`, while no-arg chains * break. + * + * Chains that pass regular value arguments are excluded from special chained handling in + * qualified expressions, since those are better served by the general qualified-expression + * layout, except in property initializer context where we handle them specially. */ private fun chainedSelectorsHaveValueArguments(expression: KtExpression): Boolean { var current: KtExpression = expression @@ -1791,17 +1613,6 @@ open class KotlinInputAstVisitor( return false } - /** - * Returns true when every chained selector after the innermost scoping-function receiver carries - * no value arguments (i.e. only `.foo()` or `.foo { ... }` with a trailing lambda). Selectors - * that pass regular value arguments are excluded from special chained handling in qualified - * expressions, since those chains are better served by the general qualified-expression layout - * except in property initializer context where we handle them specially. - */ - private fun chainedSelectorsHaveNoValueArguments(expression: KtExpression): Boolean { - return !chainedSelectorsHaveValueArguments(expression) - } - /** * Emit `runnnnn { ... }.baz().qux()` style: render the innermost scoping-function receiver * block-like (so the lambda braces sit at the surrounding indent), then emit each `.selector` @@ -1816,81 +1627,18 @@ open class KotlinInputAstVisitor( ) { val parts = breakIntoParts(expression) val root = parts[0] - val forceBreakBeforeChain = isMultilineScopingFunction(root) + val forceBreakBeforeChain = root.isMultilineScopingFunction visitLambdaOrScopingFunction(root, emitLeadingBreak = emitLeadingBreak) - builder.block(expressionBreakIndent) { - for (i in 1 until parts.size) { - val part = parts[i] as KtQualifiedExpression - if (forceBreakBeforeChain) { - builder.forcedBreak() - } else { - builder.breakOp(Doc.FillMode.UNIFIED, "", ZERO) - } - builder.token(part.operationSign.value) - val selectorExpression = part.selectorExpression - if (selectorExpression is KtCallExpression) { - visit(selectorExpression.calleeExpression) - visitCallElement( - null, - selectorExpression.typeArgumentList, - selectorExpression.valueArgumentList, - selectorExpression.lambdaArguments, - ) - } else { - visit(selectorExpression) - } - } - } - } - - /** - * Returns true when [expression] is a scoping-function call whose lambda body has source-level - * newlines (i.e. spans multiple lines). Used to decide whether chained selectors after the - * lambda's closing brace must break onto a new line. - */ - private fun isMultilineScopingFunction(expression: KtExpression): Boolean { - var carry: KtExpression? = expression - if (carry is KtQualifiedExpression && carry.receiverExpression is KtSimpleNameExpression) { - carry = carry.selectorExpression - } - if (carry is KtCallExpression) { - carry = carry.lambdaArguments.firstOrNull()?.getArgumentExpression() - } - if (carry is KtLabeledExpression) { - carry = carry.baseExpression - } - if (carry is KtLambdaExpression) { - return hasSourceNewlineInLambdaBody(carry) - } - return false - } - - /** - * Returns true if the source code contains a newline anywhere inside the body of - * [lambdaExpression] — that is, between the opening `{` and the closing `}` of the function - * literal. Used by [FormattingOptions.preserveLambdaBreaks] to keep user-authored multi-line - * lambdas multi-line. - */ - private fun hasSourceNewlineInLambdaBody(lambdaExpression: KtLambdaExpression): Boolean { - val functionLiteral = lambdaExpression.functionLiteral - for (child in functionLiteral.node.children()) { - if (child.psi is PsiWhiteSpace && child.textContains('\n')) return true - } - return false + emitChainedSelectors(parts, forceBreak = forceBreakBeforeChain) } /** See [isLambdaOrScopingFunction] for examples. */ private fun visitLambdaOrScopingFunction(expr: PsiElement?, emitLeadingBreak: Boolean = true) { - val breakToExpr = genSym() + val breakToExpr = BreakTag() val breakSpace = if (emitLeadingBreak) " " else "" - builder.breakOp( - Doc.FillMode.INDEPENDENT, - breakSpace, - expressionBreakIndent, - Optional.of(breakToExpr), - ) + builder.breakToFill(breakSpace, expressionBreakIndent, Optional.of(breakToExpr)) var carry = expr if (carry is KtQualifiedExpression && carry.receiverExpression is KtSimpleNameExpression) { @@ -1933,8 +1681,7 @@ open class KotlinInputAstVisitor( } val name = classOrObject.nameIdentifier if (name != null) { - builder.space() - builder.token(name.text) + builder.spaceThenToken(name.text) visit(classOrObject.typeParameterList) } visit(classOrObject.primaryConstructor) @@ -1943,7 +1690,7 @@ open class KotlinInputAstVisitor( builder.space() builder.block(ZERO) { builder.token(":") - builder.breakOp(Doc.FillMode.UNIFIED, " ", expressionBreakIndent) + builder.breakOp(" ", expressionBreakIndent) visit(superTypes) } } @@ -1968,7 +1715,7 @@ open class KotlinInputAstVisitor( builder.sync(constructor) builder.block(ZERO) { if (constructor.hasConstructorKeyword()) { - builder.breakOp(Doc.FillMode.UNIFIED, " ", ZERO) + builder.breakOp(" ") } visitFunctionLikeExpression( contextReceiverList = null, @@ -2022,8 +1769,7 @@ open class KotlinInputAstVisitor( override fun visitClassInitializer(initializer: KtClassInitializer) { builder.sync(initializer) - builder.token("init") - builder.space() + builder.tokenThenSpace("init") visit(initializer.body) } @@ -2045,8 +1791,7 @@ open class KotlinInputAstVisitor( if (directive.packageKeyword == null) { return } - builder.token("package") - builder.space() + builder.tokenThenSpace("package") var first = true for (packageName in directive.packageNames) { if (first) { @@ -2057,7 +1802,7 @@ open class KotlinInputAstVisitor( builder.token(packageName.getIdentifier()?.text ?: packageName.getReferencedName()) } - builder.guessToken(";") + builder.guessSemicolon() builder.forcedBreak() } @@ -2070,8 +1815,7 @@ open class KotlinInputAstVisitor( /** Example `import com.foo.A` */ override fun visitImportDirective(directive: KtImportDirective) { builder.sync(directive) - builder.token("import") - builder.space() + builder.tokenThenSpace("import") val importedReference = directive.importedReference if (importedReference != null) { @@ -2087,14 +1831,12 @@ open class KotlinInputAstVisitor( // Possible alias. val alias = directive.alias?.nameIdentifier if (alias != null) { - builder.space() - builder.token("as") - builder.space() + builder.spacedToken("as") builder.token(alias.text ?: fail()) } // Force a newline afterwards. - builder.guessToken(";") + builder.guessSemicolon() builder.forcedBreak() } @@ -2149,7 +1891,7 @@ open class KotlinInputAstVisitor( if (onlyAnnotationsSoFar && forceAnnotationBreaks && psi is KtAnnotationEntry) { builder.forcedBreak() } else if (onlyAnnotationsSoFar) { - builder.breakOp(Doc.FillMode.UNIFIED, " ", ZERO) + builder.breakOp(" ") } else { builder.space() } @@ -2181,7 +1923,7 @@ open class KotlinInputAstVisitor( val annotationEntries = expression.annotationEntries for (annotationEntry in annotationEntries) { if (annotationEntry !== annotationEntries.first()) { - builder.breakOp(Doc.FillMode.UNIFIED, " ", ZERO) + builder.breakOp(" ") } visit(annotationEntry) } @@ -2196,7 +1938,7 @@ open class KotlinInputAstVisitor( expression.parent is KtBlockExpression -> builder.forcedBreak() baseExpression is KtLambdaExpression -> builder.space() baseExpression is KtReturnExpression -> builder.forcedBreak() - else -> builder.breakOp(Doc.FillMode.UNIFIED, " ", ZERO) + else -> builder.breakOp(" ") } visit(expression.baseExpression) @@ -2223,10 +1965,10 @@ open class KotlinInputAstVisitor( builder.block(ZERO) { var first = true - builder.breakOp(Doc.FillMode.UNIFIED, "", ZERO) + builder.breakOp() for (value in annotation.entries) { if (!first) { - builder.breakOp(Doc.FillMode.UNIFIED, " ", ZERO) + builder.breakOp(" ") } first = false @@ -2298,9 +2040,7 @@ open class KotlinInputAstVisitor( override fun visitDelegatedSuperTypeEntry(specifier: KtDelegatedSuperTypeEntry) { builder.sync(specifier) visit(specifier.typeReference) - builder.space() - builder.token("by") - builder.space() + builder.spacedToken("by") visit(specifier.delegateExpression) } @@ -2316,7 +2056,7 @@ open class KotlinInputAstVisitor( builder.block(blockIndent) { if (index != 0) { // preserve new line if there's one - builder.blankLineWanted(OpsBuilder.BlankLineWanted.PRESERVE) + builder.blankLineWanted(BlankLineWanted.PRESERVE) } builder.forcedBreak() builder.block(ZERO) { @@ -2353,11 +2093,11 @@ open class KotlinInputAstVisitor( visit(whenExpression) } else { builder.block(expressionBreakIndent) { - builder.breakOp(Doc.FillMode.INDEPENDENT, " ", ZERO) + builder.breakToFill(" ") visit(whenExpression) } } - builder.guessToken(";") + builder.guessSemicolon() } builder.forcedBreak() } @@ -2373,7 +2113,7 @@ open class KotlinInputAstVisitor( if (enumEntryList != null) { builder.block(ZERO) { - builder.breakOp(Doc.FillMode.UNIFIED, "", ZERO) + builder.breakOp() for (value in enumEntryList.enumEntries) { visit(value) if (builder.peekToken().getOrNull() == ",") { @@ -2382,11 +2122,11 @@ open class KotlinInputAstVisitor( } } } - builder.guessToken(";") + builder.guessSemicolon() if (members.isNotEmpty()) { builder.forcedBreak() - builder.blankLineWanted(OpsBuilder.BlankLineWanted.YES) + builder.blankLineWanted(BlankLineWanted.YES) } } else { val parent = body.parent @@ -2400,18 +2140,18 @@ open class KotlinInputAstVisitor( for (curr in members) { val blankLineBetweenMembers = when { - prev == null -> OpsBuilder.BlankLineWanted.PRESERVE - prev !is KtProperty -> OpsBuilder.BlankLineWanted.YES - prev.getter != null || prev.setter != null -> OpsBuilder.BlankLineWanted.YES - curr is KtProperty -> OpsBuilder.BlankLineWanted.PRESERVE - else -> OpsBuilder.BlankLineWanted.YES + prev == null -> BlankLineWanted.PRESERVE + prev !is KtProperty -> BlankLineWanted.YES + prev.getter != null || prev.setter != null -> BlankLineWanted.YES + curr is KtProperty -> BlankLineWanted.PRESERVE + else -> BlankLineWanted.YES } builder.blankLineWanted(blankLineBetweenMembers) markForPartialFormat() builder.block(ZERO) { visit(curr) } markForPartialFormat() - builder.guessToken(";") + builder.guessSemicolon() builder.forcedBreak() prev = curr @@ -2431,8 +2171,7 @@ open class KotlinInputAstVisitor( override fun visitWhenConditionIsPattern(condition: KtWhenConditionIsPattern) { builder.sync(condition) - builder.token(if (condition.isNegated) "!is" else "is") - builder.space() + builder.tokenThenSpace(if (condition.isNegated) "!is" else "is") visit(condition.typeReference) } @@ -2442,8 +2181,7 @@ open class KotlinInputAstVisitor( // TODO: replace with 'condition.isNegated' once https://youtrack.jetbrains.com/issue/KT-34395 // is fixed. val isNegated = condition.firstChild?.node?.findChildByType(KtTokens.NOT_IN) != null - builder.token(if (isNegated) "!in" else "in") - builder.space() + builder.tokenThenSpace(if (isNegated) "!in" else "in") visit(condition.rangeExpression) } @@ -2456,8 +2194,7 @@ open class KotlinInputAstVisitor( builder.space() builder.block(ZERO) { visit(expression.then) } } else { - builder.breakOp(Doc.FillMode.INDEPENDENT, " ", expressionBreakIndent) - builder.block(expressionBreakIndent) { + builder.breakToFillThenBlock(" ", expressionBreakIndent) { builder.fenceComments() visit(expression.then) } @@ -2467,7 +2204,7 @@ open class KotlinInputAstVisitor( if (expression.then is KtBlockExpression) { builder.space() } else { - builder.breakOp(Doc.FillMode.UNIFIED, " ", ZERO) + builder.breakOp(" ") } builder.block(ZERO) { @@ -2476,8 +2213,7 @@ open class KotlinInputAstVisitor( builder.space() builder.block(ZERO) { visit(expression.`else`) } } else { - builder.breakOp(Doc.FillMode.INDEPENDENT, " ", expressionBreakIndent) - builder.block(expressionBreakIndent) { visit(expression.`else`) } + builder.breakToFillThenBlock(" ", expressionBreakIndent) { visit(expression.`else`) } } } } @@ -2502,8 +2238,7 @@ open class KotlinInputAstVisitor( private fun visitArrayAccessBrackets(expression: KtArrayAccessExpression) { builder.block(ZERO) { builder.token("[") - builder.breakOp(Doc.FillMode.UNIFIED, "", expressionBreakIndent) - builder.block(expressionBreakIndent) { + builder.breakOpThenBlock(expressionBreakIndent) { visitEachCommaSeparated( expression.indexExpressions, expression.trailingComma != null, @@ -2519,16 +2254,14 @@ open class KotlinInputAstVisitor( builder.sync(destructuringDeclaration) val valOrVarKeyword = destructuringDeclaration.valOrVarKeyword if (valOrVarKeyword != null) { - builder.token(valOrVarKeyword.text) - builder.space() + builder.tokenThenSpace(valOrVarKeyword.text) } val hasTrailingComma = destructuringDeclaration.trailingComma != null val openingDelimiter = destructuringDeclaration.lPar?.text ?: "(" val closingDelimiter = destructuringDeclaration.rPar?.text ?: ")" builder.block(ZERO) { builder.token(openingDelimiter) - builder.breakOp(Doc.FillMode.UNIFIED, "", expressionBreakIndent) - builder.block(expressionBreakIndent) { + builder.breakOpThenBlock(expressionBreakIndent) { visitEachCommaSeparated( destructuringDeclaration.entries, hasTrailingComma, @@ -2539,12 +2272,11 @@ open class KotlinInputAstVisitor( builder.token(closingDelimiter) val initializer = destructuringDeclaration.initializer if (initializer != null) { - builder.space() - builder.token("=") + builder.spaceThenToken("=") if (hasTrailingComma) { builder.space() } else { - builder.breakOp(Doc.FillMode.INDEPENDENT, " ", expressionBreakIndent) + builder.breakToFill(" ", expressionBreakIndent) } builder.block(expressionBreakIndent, !hasTrailingComma) { visit(initializer) } } @@ -2555,9 +2287,9 @@ open class KotlinInputAstVisitor( multiDeclarationEntry: KtDestructuringDeclarationEntry, ) { builder.sync(multiDeclarationEntry) - declareOne( + emitVariableLikeDeclaration( initializer = multiDeclarationEntry.initializer, - kind = DeclarationKind.PARAMETER, + isField = false, modifiers = multiDeclarationEntry.modifierList, name = multiDeclarationEntry.nameIdentifier?.text ?: fail(), type = multiDeclarationEntry.typeReference, @@ -2604,9 +2336,7 @@ open class KotlinInputAstVisitor( builder.token(parameter.nameIdentifier?.text ?: "") val extendsBound = parameter.extendsBound if (extendsBound != null) { - builder.space() - builder.token(":") - builder.space() + builder.spacedToken(":") visit(extendsBound) } } @@ -2614,10 +2344,10 @@ open class KotlinInputAstVisitor( /** Example `where T : View, T : Listener` */ override fun visitTypeConstraintList(list: KtTypeConstraintList) { builder.block(expressionBreakIndent) { - builder.breakOp(Doc.FillMode.INDEPENDENT, " ", ZERO) + builder.breakToFill(" ") builder.token("where") builder.block(expressionBreakIndent) { - builder.breakOp(Doc.FillMode.UNIFIED, " ", ZERO) + builder.breakOp(" ") builder.sync(list) visitEachCommaSeparated(list.constraints, wrapInBlock = false) } @@ -2629,9 +2359,7 @@ open class KotlinInputAstVisitor( builder.sync(constraint) // TODO(nreid260): What about annotations on the type reference? `where @A T : Int` visit(constraint.subjectTypeParameterName) - builder.space() - builder.token(":") - builder.space() + builder.spacedToken(":") visit(constraint.boundTypeReference) } @@ -2639,15 +2367,12 @@ open class KotlinInputAstVisitor( override fun visitForExpression(expression: KtForExpression) { builder.sync(expression) builder.block(ZERO) { - builder.token("for") - builder.space() + builder.tokenThenSpace("for") builder.token("(") visit(expression.loopParameter) - builder.space() - builder.token("in") + builder.spaceThenToken("in") builder.block(ZERO) { - builder.breakOp(Doc.FillMode.UNIFIED, " ", expressionBreakIndent) - builder.block(expressionBreakIndent) { visit(expression.loopRange) } + builder.breakOpThenBlock(" ", expressionBreakIndent) { visit(expression.loopRange) } } builder.token(")") builder.space() @@ -2666,8 +2391,7 @@ open class KotlinInputAstVisitor( /** Example `do { ... } while (a < b)` */ override fun visitDoWhileExpression(expression: KtDoWhileExpression) { builder.sync(expression) - builder.token("do") - builder.space() + builder.tokenThenSpace("do") if (expression.body != null) { visit(expression.body) builder.space() @@ -2699,14 +2423,13 @@ open class KotlinInputAstVisitor( builder.block(ZERO) { visit(destructuringDeclaration) if (typeReference != null) { - builder.token(":") - builder.space() + builder.tokenThenSpace(":") visit(typeReference) } } } else { - declareOne( - kind = DeclarationKind.PARAMETER, + emitVariableLikeDeclaration( + isField = false, modifiers = parameter.modifierList, valOrVarKeyword = parameter.valOrVarKeyword?.text, name = parameter.nameIdentifier?.text, @@ -2732,7 +2455,7 @@ open class KotlinInputAstVisitor( builder.block(expressionBreakIndent) { builder.token("::") - builder.breakOp(Doc.FillMode.INDEPENDENT, "", ZERO) + builder.breakToFill() visit(expression.callableReference) } } @@ -2778,9 +2501,7 @@ open class KotlinInputAstVisitor( ) } } - builder.space() - builder.token("->") - builder.space() + builder.spacedToken("->") builder.block(expressionBreakIndent) { visit(type.returnTypeReference) } } @@ -2797,13 +2518,12 @@ open class KotlinInputAstVisitor( parent is KtParenthesizedExpression || parent is KtContainerNode ) { - builder.breakOp(Doc.FillMode.UNIFIED, " ", expressionBreakIndent) + builder.breakOp(" ", expressionBreakIndent) } else { builder.space() } visit(expression.operationReference) - builder.breakOp(Doc.FillMode.INDEPENDENT, " ", expressionBreakIndent) - builder.block(expressionBreakIndent) { visit(expression.typeReference) } + builder.breakToFillThenBlock(" ", expressionBreakIndent) { visit(expression.typeReference) } builder.close() } @@ -2814,10 +2534,9 @@ open class KotlinInputAstVisitor( if (openGroupBeforeLeft) builder.open(ZERO) visit(expression.left) if (!openGroupBeforeLeft) builder.open(ZERO) - builder.breakOp(Doc.FillMode.UNIFIED, " ", expressionBreakIndent) + builder.breakOp(" ", expressionBreakIndent) visit(expression.operationReference) - builder.breakOp(Doc.FillMode.INDEPENDENT, " ", expressionBreakIndent) - builder.block(expressionBreakIndent) { visit(expression.right) } + builder.breakToFillThenBlock(" ", expressionBreakIndent) { visit(expression.right) } builder.close() } @@ -2844,8 +2563,7 @@ open class KotlinInputAstVisitor( override fun visitTryExpression(expression: KtTryExpression) { builder.sync(expression) - builder.token("try") - builder.space() + builder.tokenThenSpace("try") visit(expression.tryBlock) for (catchClause in expression.catchClauses) { visit(catchClause) @@ -2855,13 +2573,11 @@ open class KotlinInputAstVisitor( override fun visitCatchSection(catchClause: KtCatchClause) { builder.sync(catchClause) - builder.space() - builder.token("catch") - builder.space() + builder.spacedToken("catch") builder.block(ZERO) { builder.token("(") builder.block(expressionBreakIndent) { - builder.breakOp(Doc.FillMode.UNIFIED, "", ZERO) + builder.breakOp() visit(catchClause.catchParameter) builder.guessToken(",") } @@ -2873,16 +2589,13 @@ open class KotlinInputAstVisitor( override fun visitFinallySection(finallySection: KtFinallySection) { builder.sync(finallySection) - builder.space() - builder.token("finally") - builder.space() + builder.spacedToken("finally") visit(finallySection.finalExpression) } override fun visitThrowExpression(expression: KtThrowExpression) { builder.sync(expression) - builder.token("throw") - builder.space() + builder.tokenThenSpace("throw") visit(expression.thrownExpression) } @@ -2905,18 +2618,15 @@ open class KotlinInputAstVisitor( builder.sync(typeAlias) builder.block(ZERO) { visit(typeAlias.modifierList) - builder.token("typealias") - builder.space() + builder.tokenThenSpace("typealias") builder.token(typeAlias.nameIdentifier?.text ?: fail()) visit(typeAlias.typeParameterList) - builder.space() - builder.token("=") - builder.breakOp(Doc.FillMode.INDEPENDENT, " ", expressionBreakIndent) - builder.block(expressionBreakIndent) { + builder.spaceThenToken("=") + builder.breakToFillThenBlock(" ", expressionBreakIndent) { visit(typeAlias.getTypeReference()) visit(typeAlias.typeConstraintList) - builder.guessToken(";") + builder.guessSemicolon() } builder.forcedBreak() } @@ -2955,10 +2665,10 @@ open class KotlinInputAstVisitor( builder.blankLineWanted( when { - isFirst -> OpsBuilder.BlankLineWanted.NO + isFirst -> BlankLineWanted.NO child is PsiComment -> continue - child is KtScript && importListEmpty -> OpsBuilder.BlankLineWanted.PRESERVE - else -> OpsBuilder.BlankLineWanted.YES + child is KtScript && importListEmpty -> BlankLineWanted.PRESERVE + else -> BlankLineWanted.YES }, ) @@ -2982,17 +2692,17 @@ open class KotlinInputAstVisitor( builder.forcedBreak() val childGetsBlankLineBefore = child !is KtProperty if (first) { - builder.blankLineWanted(OpsBuilder.BlankLineWanted.PRESERVE) + builder.blankLineWanted(BlankLineWanted.PRESERVE) } else if (lastChildIsContextReceiver) { - builder.blankLineWanted(OpsBuilder.BlankLineWanted.NO) + builder.blankLineWanted(BlankLineWanted.NO) } else if ( child !is PsiComment && (childGetsBlankLineBefore || lastChildHadBlankLineBefore) ) { - builder.blankLineWanted(OpsBuilder.BlankLineWanted.YES) + builder.blankLineWanted(BlankLineWanted.YES) } builder.markForPartialFormat() visit(child) - builder.guessToken(";") + builder.guessSemicolon() builder.markForPartialFormat() lastChildHadBlankLineBefore = childGetsBlankLineBefore lastChildIsContextReceiver = @@ -3016,54 +2726,6 @@ open class KotlinInputAstVisitor( } } - /** - * Emit a [Doc.Token]. - * - * @param token the [String] to wrap in a [Doc.Token] - * @param plusIndentCommentsBefore extra block for comments before this token - */ - private fun OpsBuilder.token(token: String, plusIndentCommentsBefore: Indent = ZERO) { - token( - token, - Doc.Token.RealOrImaginary.REAL, - plusIndentCommentsBefore, - /* breakAndIndentTrailingComment */ Optional.empty(), - ) - } - - /** - * Opens a new level, emits into it and closes it. - * - * This is a helper method to make it easier to keep track of [OpsBuilder.open] and - * [OpsBuilder.close] calls - * - * @param plusIndent the block level to pass to the block - * @param block a code block to be run in this block level - */ - private fun OpsBuilder.block( - plusIndent: Indent = ZERO, - isEnabled: Boolean = true, - block: () -> Unit, - ) { - if (isEnabled) { - open(plusIndent) - } - block() - if (isEnabled) { - close() - } - } - - /** Helper method to sync the current offset to match any element in the AST */ - private fun OpsBuilder.sync(psiElement: PsiElement) { - sync(psiElement.startOffset) - } - - /** Prevent subsequent comments from being moved ahead of this point, into parent [Level]s. */ - private fun OpsBuilder.fenceComments() { - addAll(FenceCommentsOp.AS_LIST) - } - /** * Throws a formatting error * @@ -3096,16 +2758,15 @@ open class KotlinInputAstVisitor( } builder.block(ZERO) { - builder.token(keyword) - builder.space() + builder.tokenThenSpace(keyword) if (surroundConditionWithParens) { builder.token("(") } if (options.manageTrailingCommas) { builder.block(expressionBreakIndent) { - builder.breakOp(Doc.FillMode.UNIFIED, "", ZERO) + builder.breakOp() visit(condition) - builder.breakOp(Doc.FillMode.UNIFIED, "", expressionBreakNegativeIndent) + builder.breakOp(expressionBreakNegativeIndent) } } else { builder.block(ZERO) { visit(condition) } diff --git a/core/src/main/java/com/facebook/ktfmt/format/OpsUtils.kt b/core/src/main/java/com/facebook/ktfmt/format/OpsUtils.kt new file mode 100644 index 000000000..9ab48b68f --- /dev/null +++ b/core/src/main/java/com/facebook/ktfmt/format/OpsUtils.kt @@ -0,0 +1,164 @@ +package com.facebook.ktfmt.format + +import com.google.googlejavaformat.Doc +import com.google.googlejavaformat.Doc.Level +import com.google.googlejavaformat.Doc.Token +import com.google.googlejavaformat.Indent +import com.google.googlejavaformat.Indent.Const.ZERO +import com.google.googlejavaformat.OpsBuilder +import com.google.googlejavaformat.Output.BreakTag +import java.util.Optional +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.psi.psiUtil.startOffset + +/** + * Emit a [Doc.Token]. + * + * @param token the [String] to wrap in a [Doc.Token] + * @param plusIndentCommentsBefore extra block for comments before this token + */ +internal fun OpsBuilder.token(token: String, plusIndentCommentsBefore: Indent = ZERO) { + token( + token, + Token.RealOrImaginary.REAL, + plusIndentCommentsBefore, + /* breakAndIndentTrailingComment */ Optional.empty(), + ) +} + +/** Emit a [Doc.Token] followed by a [Doc.Space]. */ +internal fun OpsBuilder.tokenThenSpace(token: String) { + token(token) + space() +} + +/** Emit a [Doc.Space] followed by a [Doc.Token]. */ +internal fun OpsBuilder.spaceThenToken(token: String) { + space() + token(token) +} + +/** Emit a [Doc.Token] surrounded by [Doc.Space]s. */ +internal fun OpsBuilder.spacedToken(token: String) { + space() + token(token) + space() +} + +/** + * Emit a [Doc.Break] with a specified [flat] value and extra indent. + * + * [OpsBuilder] only provides overloads that set either [flat] or [plusIndent], but not both. + * + * @param flat the [Doc.Break] when not broken + * @param plusIndent extra indent if taken + * @param optionalTag an optional tag for remembering whether the break was taken + */ +internal fun OpsBuilder.breakOp( + flat: String, + plusIndent: Indent, + optionalTag: Optional = Optional.empty(), +) { + breakOp(Doc.FillMode.UNIFIED, flat, plusIndent, optionalTag) +} + +/** + * Emit a filled [Doc.Break] with extra indent. + * + * @param plusIndent extra indent if taken + */ +internal fun OpsBuilder.breakToFill(plusIndent: Indent) { + breakOp(Doc.FillMode.INDEPENDENT, "", plusIndent) +} + +/** + * Emit a filled [Doc.Break] with a specified [flat] value and extra indent. + * + * @param flat the [Doc.Break] when not broken + * @param plusIndent extra indent if taken + * @param optionalTag an optional tag for remembering whether the break was taken + */ +internal fun OpsBuilder.breakToFill( + flat: String, + plusIndent: Indent, + optionalTag: Optional = Optional.empty(), +) { + breakOp(Doc.FillMode.INDEPENDENT, flat, plusIndent, optionalTag) +} + +/** + * Opens a new level, emits into it and closes it. + * + * This is a helper method to make it easier to keep track of [OpsBuilder.open] and + * [OpsBuilder.close] calls + * + * @param plusIndent the block level to pass to the block + * @param block a code block to be run in this block level + */ +internal fun OpsBuilder.block( + plusIndent: Indent = ZERO, + isEnabled: Boolean = true, + block: () -> Unit, +) { + if (isEnabled) { + open(plusIndent) + } + block() + if (isEnabled) { + close() + } +} + +/** + * Emit a [Doc.Break], then open a level indented by the same amount, emit into it and close it. + * + * Breaking and then indenting the continuation by the same [plusIndent] is a very common + * combination, and stating the indent once keeps the two from drifting apart. + * + * @param plusIndent extra indent if the break is taken, and the indent of the level + * @param block a code block to be run in this block level + */ +internal fun OpsBuilder.breakOpThenBlock(plusIndent: Indent, block: () -> Unit) { + breakOpThenBlock("", plusIndent, block) +} + +/** + * Emit a [Doc.Break] with a specified [flat] value, then open a level indented by the same amount, + * emit into it and close it. + * + * @param flat the [Doc.Break] when not broken + * @param plusIndent extra indent if the break is taken, and the indent of the level + * @param block a code block to be run in this block level + */ +internal fun OpsBuilder.breakOpThenBlock(flat: String, plusIndent: Indent, block: () -> Unit) { + breakOp(flat, plusIndent) + block(plusIndent, block = block) +} + +/** + * Emit a filled [Doc.Break] with a specified [flat] value, then open a level indented by the same + * amount, emit into it and close it. + * + * @param flat the [Doc.Break] when not broken + * @param plusIndent extra indent if the break is taken, and the indent of the level + * @param block a code block to be run in this block level + */ +internal fun OpsBuilder.breakToFillThenBlock(flat: String, plusIndent: Indent, block: () -> Unit) { + breakToFill(flat, plusIndent) + block(plusIndent, block = block) +} + +/** Emit a `;` if the input has one at this point. */ +internal fun OpsBuilder.guessSemicolon() { + guessToken(";") +} + +/** Helper method to sync the current offset to match any element in the AST */ +internal fun OpsBuilder.sync(psiElement: PsiElement) { + sync(psiElement.startOffset) +} + +/** Prevent subsequent comments from being moved ahead of this point, into parent [Level]s. */ +internal fun OpsBuilder.fenceComments() { + addAll(FenceCommentsOp.AS_LIST) +} diff --git a/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt b/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt index acef0c8c6..3489b7664 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt @@ -16,12 +16,25 @@ package com.facebook.ktfmt.format +import java.util.ArrayDeque +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract +import org.jetbrains.kotlin.com.intellij.psi.PsiComment +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.com.intellij.psi.PsiWhiteSpace +import org.jetbrains.kotlin.psi.KtArrayAccessExpression import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtLabeledExpression +import org.jetbrains.kotlin.psi.KtLambdaExpression import org.jetbrains.kotlin.psi.KtParameterList +import org.jetbrains.kotlin.psi.KtPostfixExpression import org.jetbrains.kotlin.psi.KtQualifiedExpression +import org.jetbrains.kotlin.psi.KtSimpleNameExpression import org.jetbrains.kotlin.psi.KtValueArgumentList +import org.jetbrains.kotlin.psi.psiUtil.children import org.jetbrains.kotlin.psi.psiUtil.getNextSiblingIgnoringWhitespace +import org.jetbrains.kotlin.psi.psiUtil.getPrevSiblingIgnoringWhitespace /** Returns true if the expression represents an invocation that is also a lambda */ fun KtExpression.isLambda(): Boolean = this.callExpression?.lambdaArguments?.isNotEmpty() ?: false @@ -45,5 +58,202 @@ fun KtValueArgumentList.hasEmptyParens(): Boolean { * a qualified expression, or standing alone. This method makes it easier to handle both cases * uniformly. */ -private val KtExpression.callExpression: KtCallExpression? +internal val KtExpression.callExpression: KtCallExpression? get() = ((this as? KtQualifiedExpression)?.selectorExpression ?: this) as? KtCallExpression + +/** Returns the innermost receiver of a (possibly nested) qualified [KtExpression]. */ +internal val KtExpression.chainRoot: KtExpression + get() { + var root: KtExpression = this + while (root is KtQualifiedExpression) { + root = root.receiverExpression + } + return root + } + +/** + * Returns true when [KtExpression] is a call that is forced onto multiple lines regardless of the + * line width, either because its value argument list has a trailing comma (e.g. `foo(\n 1,\n + * 2,\n)`) or because one of its arguments is itself a block-like multiline call. + * + * Such calls are rendered "block-like": they stay on the same line as the preceding `=`/`by` + * operator (instead of breaking and indenting after it), and any chained selectors break onto their + * own line, mirroring how scoping functions and lambdas are handled. + */ +@OptIn(ExperimentalContracts::class) +internal val KtExpression?.isBlockLikeCall: Boolean + get() { + contract { returns(true) implies (this@isBlockLikeCall is KtCallExpression) } + + if (this == null) return false + val prev = getPrevSiblingIgnoringWhitespace() + if (prev is PsiComment) { + return false // Leading comments cause weird indentation; keep the default layout. + } + + if (this !is KtCallExpression) return false + val valueArgumentList = valueArgumentList ?: return false + return valueArgumentList.trailingComma != null || + valueArgumentList.arguments.any { argument -> + val argumentExpression = argument.getArgumentExpression() + argumentExpression != null && + (argumentExpression.isBlockLikeCall || argumentExpression.isChainedBlockLikeCall) + } + } + +/** Returns true when [KtExpression] is a chain whose innermost receiver is a [isBlockLikeCall]. */ +@OptIn(ExperimentalContracts::class) +internal val KtExpression.isChainedBlockLikeCall: Boolean + get() { + contract { returns(true) implies (this@isChainedBlockLikeCall is KtQualifiedExpression) } + + return this is KtQualifiedExpression && this.chainRoot.isBlockLikeCall + } + +/** + * Returns true when [KtExpression] is a chain whose innermost receiver is a scoping function call. + * + * For example, this matches `runnnnn { ... }.baz()` (innermost receiver `runnnnn { ... }` is a + * scoping function). It does not match a chain whose root is a plain identifier or a non-scoping + * call, since those don't have a block-like opener to anchor the chain against. + */ +@OptIn(ExperimentalContracts::class) +internal val KtExpression.isChainedScopingFunction: Boolean + get() { + contract { returns(true) implies (this@isChainedScopingFunction is KtQualifiedExpression) } + + return this is KtQualifiedExpression && this.chainRoot.isLambdaOrScopingFunction + } + +/** + * Returns whether an expression is a lambda or initializer expression in which case we will want to + * avoid indenting the lambda block + * + * Examples: + * 1. '... = { ... }' is a lambda expression + * 2. '... = Runnable { ... }' is considered a scoping function + * 3. '... = scope { ... }' '... = apply { ... }' is a scoping function + * 4. '... = scope.launch { ... }' is a dot-qualified scoping function + * + * but not: + * 1. '... = foo() { ... }' due to the empty parenthesis + * 2. '... = Runnable @Annotation { ... }' due to the annotation + */ +internal val KtExpression?.isLambdaOrScopingFunction: Boolean + get() { + if (this == null) return false + val prev = this.getPrevSiblingIgnoringWhitespace() + if (prev is PsiComment && prev.text.startsWith("//")) { + return false // Leading line comments cause weird indentation; block comments are ok. + } + + var carry = this + if (carry is KtQualifiedExpression && carry.receiverExpression is KtSimpleNameExpression) { + carry = carry.selectorExpression + } + if (carry is KtCallExpression) { + if ( + carry.valueArgumentList?.leftParenthesis == null && + carry.lambdaArguments.isNotEmpty() && + carry.typeArgumentList?.arguments.isNullOrEmpty() + ) { + carry = carry.lambdaArguments[0].getArgumentExpression() + } else { + return false + } + } + if (carry is KtLabeledExpression) { + carry = carry.baseExpression + } + if (carry is KtLambdaExpression) { + return true + } + + return false + } + +/** + * Returns true when [KtExpression] is a scoping-function call whose lambda body has source-level + * newlines (i.e. spans multiple lines). Used to decide whether chained selectors after the lambda's + * closing brace must break onto a new line. + */ +internal val KtExpression.isMultilineScopingFunction: Boolean + get() { + var carry: KtExpression? = this + if (carry is KtQualifiedExpression && carry.receiverExpression is KtSimpleNameExpression) { + carry = carry.selectorExpression + } + if (carry is KtCallExpression) { + carry = carry.lambdaArguments.firstOrNull()?.getArgumentExpression() + } + if (carry is KtLabeledExpression) { + carry = carry.baseExpression + } + if (carry is KtLambdaExpression) { + return carry.hasSourceNewlineInLambdaBody + } + return false + } + +/** + * Returns true if the source code contains a newline anywhere inside the body of + * [KtLambdaExpression] — that is, between the opening `{` and the closing `}` of the function + * literal. Used by [FormattingOptions.preserveLambdaBreaks] to keep user-authored multi-line + * lambdas multi-line. + */ +internal val KtLambdaExpression.hasSourceNewlineInLambdaBody: Boolean + get() { + val functionLiteral = this.functionLiteral + for (child in functionLiteral.node.children()) { + if (child.psi is PsiWhiteSpace && child.textContains('\n')) return true + } + return false + } + +/** + * Checks if a line-breaking comment precedes [PsiElement] in the PSI tree. + * + * Line comments (`//`) always force a break. Block comments (`/* */`) only count if they are on + * their own line (preceded by whitespace with a newline). Inline block comments like `x /*tag*/ ||` + * do not force a break and should not trigger INDEPENDENT fill mode. + */ +internal val PsiElement.hasLineBreakingCommentBefore: Boolean + get() { + var prev = this.prevSibling + while (prev is PsiWhiteSpace) { + prev = prev.prevSibling + } + if (prev !is PsiComment) return false + + // Line comments always force a line break + if (prev.text.startsWith("//")) return true + + // Block comments force a break only if on their own line + val beforeComment = prev.prevSibling + return beforeComment is PsiWhiteSpace && beforeComment.text.contains('\n') + } + +/** + * Decomposes a qualified expression into parts, so `rainbow.red.orange.yellow` becomes `[rainbow, + * rainbow.red, rainbow.red.orange, rainbow.orange.yellow]` + */ +internal fun breakIntoParts(expression: KtExpression): List { + val parts = ArrayDeque() + + // use an ArrayDeque and add elements to the beginning so the innermost expression comes first + // foo.bar.yay -> [yay, bar.yay, foo.bar.yay] + + var node: KtExpression? = expression + while (node != null) { + parts.addFirst(node) + node = + when (node) { + is KtQualifiedExpression -> node.receiverExpression + is KtArrayAccessExpression -> node.arrayExpression + is KtPostfixExpression -> node.baseExpression + else -> null + } + } + + return parts.toList() +} From 2ece59ee18dc5e0a16ce379b65d3d5ba2acb785a Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Tue, 4 Aug 2026 17:50:45 +0200 Subject: [PATCH 02/22] [formatter] Implement experimental new rule for break after assignment --- .../ktfmt/format/KotlinInputAstVisitor.kt | 247 +++++++++++++----- .../ktfmt/format/KotlinLangInputAstVisitor.kt | 1 + .../com/facebook/ktfmt/format/PsiUtils.kt | 35 ++- .../cases/new_codestyle/LongCallChain.input | 10 + .../new_codestyle/LongCallChain.new.output | 10 + .../cases/new_codestyle/LongCallChain.output | 11 + .../LongCallChainWithLineBreak.input | 10 + .../LongCallChainWithLineBreak.new.output | 11 + .../LongCallChainWithLineBreak.output | 11 + 9 files changed, 276 insertions(+), 70 deletions(-) create mode 100644 core/src/test/resources/cases/new_codestyle/LongCallChain.input create mode 100644 core/src/test/resources/cases/new_codestyle/LongCallChain.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/LongCallChain.output create mode 100644 core/src/test/resources/cases/new_codestyle/LongCallChainWithLineBreak.input create mode 100644 core/src/test/resources/cases/new_codestyle/LongCallChainWithLineBreak.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/LongCallChainWithLineBreak.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 487286a80..8a1771f8a 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -142,6 +142,7 @@ open class KotlinInputAstVisitor( ) : KtTreeVisitorVoid() { internal open val forceAnnotationBreaks: Boolean = false + internal open val forceLineBreakAfterAssignment: Boolean = true /** Standard indentation for a block */ private val blockIndent: Indent.Const = Indent.Const.make(options.blockIndent, 1) @@ -539,71 +540,176 @@ open class KotlinInputAstVisitor( val useBlockLikeLambdaStyle = parts.last().isLambda() && parts.count { it.isLambda() } == 1 val groupingInfos = computeGroupingInfo(parts, useBlockLikeLambdaStyle) builder.block(expressionBreakIndent) { - val nameTag = BreakTag() // allows adjusting arguments indentation if a break will be made - for ((index, ktExpression) in parts.withIndex()) { - if (ktExpression is KtQualifiedExpression) { - builder.breakOp("", ZERO, Optional.of(nameTag)) - } - repeat(groupingInfos[index].groupOpenCount) { builder.open(ZERO) } - when (ktExpression) { - is KtQualifiedExpression -> { - builder.token(ktExpression.operationSign.value) - val selectorExpression = ktExpression.selectorExpression - if (selectorExpression !is KtCallExpression) { - // selector is a simple field access - visit(selectorExpression) - if (groupingInfos[index].shouldCloseGroup) { - builder.close() - } - } else { - // selector is a function call, we may close a group after its name - // emit `doIt` from `doIt(1, 2) { it }` - visit(selectorExpression.calleeExpression) - // close groups according to instructions - if (groupingInfos[index].shouldCloseGroup) { - builder.close() - } - // close group due to last lambda to allow block-like style in `as.forEach { ... }` - val isTrailingLambda = useBlockLikeLambdaStyle && index == parts.size - 1 - if (isTrailingLambda) { - builder.close() - } - // A block-like (exploded) selector call is laid out like the last part: its - // arguments are indented once relative to the call itself, and its closing paren - // returns to the call's indent, even when chained selectors follow it. This only - // applies when trailing commas are preserved (the block-like style); when ktfmt - // manages trailing commas, exploded chained calls keep the regular extra indent. - val isLastPartOrBlockLikeCall = - index == parts.size - 1 || - !options.manageTrailingCommas && selectorExpression.isBlockLikeCall - val argsIndentElse = if (isLastPartOrBlockLikeCall) ZERO else expressionBreakIndent - val lambdaIndentElse = if (isTrailingLambda) expressionBreakNegativeIndent else ZERO - val negativeLambdaIndentElse = if (isTrailingLambda) expressionBreakIndent else ZERO - - // emit `(1, 2) { it }` from `doIt(1, 2) { it }` - visitCallElement( - null, - selectorExpression.typeArgumentList, - selectorExpression.valueArgumentList, - selectorExpression.lambdaArguments, - argumentsIndent = Indent.If.make(nameTag, expressionBreakIndent, argsIndentElse), - lambdaIndent = Indent.If.make(nameTag, ZERO, lambdaIndentElse), - negativeLambdaIndent = Indent.If.make(nameTag, ZERO, negativeLambdaIndentElse), - ) + emitQualifiedExpressionParts( + parts, + groupingInfos, + useBlockLikeLambdaStyle, + range = parts.indices, + nameTag = BreakTag(), + ) + } + } + + /** + * Lays out a chain of qualified expressions that follows an operator such as `=`, deciding + * whether to break after that operator from the width of the chain's receiver alone. + * + * [emitQualifiedExpression] puts the whole chain into a single level, so a break in front of it + * is taken whenever the *entire* chain doesn't fit -- even when only the selectors need to break. + * Here the receiver goes into a level of its own instead, so the break competes with the receiver + * and nothing else: + * ``` + * val testDataDir: Path = Path.of("") // receiver fits: it stays on the `=` line + * .resolve("tests") + * + * val testDataDir: Path = // receiver doesn't: the break is taken, and the + * Path.ofAVeryVeryLongName("") // selectors indent relative to the receiver + * .resolve("tests") + * ``` + * + * Returns false, having emitted nothing, when the chain's grouping spans the receiver and the + * selectors, so the two can't be put into separate levels. Callers fall back to breaking first + * and calling [emitQualifiedExpression]. + */ + private fun emitQualifiedExpressionAfterOperator(expression: KtExpression): Boolean { + val parts = breakIntoParts(expression) + val useBlockLikeLambdaStyle = parts.last().isLambda() && parts.count { it.isLambda() } == 1 + val groupingInfos = computeGroupingInfo(parts, useBlockLikeLambdaStyle) + val receiverEnd = + receiverSegmentEnd(parts, groupingInfos, useBlockLikeLambdaStyle) ?: return false + + val nameTag = BreakTag() // allows adjusting arguments indentation if a break will be made + val brokeAfterOperator = BreakTag() + // The receiver, preceded by the only break in this level: it is taken exactly when the + // receiver doesn't fit on the current line. The receiver's own contents indent one more level + // when that happens, since the receiver then starts a line of its own. + builder.block(expressionBreakIndent) { + builder.breakOp(" ", ZERO, Optional.of(brokeAfterOperator)) + builder.block(Indent.If.make(brokeAfterOperator, expressionBreakIndent, ZERO)) { + emitQualifiedExpressionParts( + parts, + groupingInfos, + useBlockLikeLambdaStyle, + range = 0..receiverEnd, + nameTag = nameTag, + ) + } + } + // The selectors, in a level of their own so that they can still share a line with the receiver + // when they fit. This level is entered after the break above was decided, so its indent can + // depend on it. + if (receiverEnd < parts.lastIndex) { + val selectorsIndent = + Indent.If.make(brokeAfterOperator, doubleExpressionBreakIndent, expressionBreakIndent) + builder.block(selectorsIndent) { + emitQualifiedExpressionParts( + parts, + groupingInfos, + useBlockLikeLambdaStyle, + range = receiverEnd + 1..parts.lastIndex, + nameTag = nameTag, + ) + } + } + return true + } + + /** + * The index of the last part of the chain's receiver -- the head that is kept together, such as + * `a.b[2]` in `a.b[2].c.d()` -- or null when every group opened in the chain is still open by the + * time the last part is emitted, so there is no point at which the chain can be split in two. + */ + private fun receiverSegmentEnd( + parts: List, + groupingInfos: List, + useBlockLikeLambdaStyle: Boolean, + ): Int? { + // The group of a block-like trailing lambda is opened at the root and closed at the very last + // part, so it always spans the whole chain. + if (useBlockLikeLambdaStyle) return null + + var openGroups = 0 + for ((index, part) in parts.withIndex()) { + openGroups += groupingInfos[index].groupOpenCount + if (groupingInfos[index].shouldCloseGroup) openGroups-- + if (part is KtArrayAccessExpression || part is KtPostfixExpression) openGroups-- + if (openGroups == 0) return index + } + return null + } + + /** Emits [range] of the parts of a chain, see [emitQualifiedExpression]. */ + private fun emitQualifiedExpressionParts( + parts: List, + groupingInfos: List, + useBlockLikeLambdaStyle: Boolean, + range: IntRange, + nameTag: BreakTag, + ) { + for (index in range) { + val ktExpression = parts[index] + if (ktExpression is KtQualifiedExpression) { + builder.breakOp("", ZERO, Optional.of(nameTag)) + } + repeat(groupingInfos[index].groupOpenCount) { builder.open(ZERO) } + when (ktExpression) { + is KtQualifiedExpression -> { + builder.token(ktExpression.operationSign.value) + val selectorExpression = ktExpression.selectorExpression + if (selectorExpression !is KtCallExpression) { + // selector is a simple field access + visit(selectorExpression) + if (groupingInfos[index].shouldCloseGroup) { + builder.close() } + } else { + // selector is a function call, we may close a group after its name + // emit `doIt` from `doIt(1, 2) { it }` + visit(selectorExpression.calleeExpression) + // close groups according to instructions + if (groupingInfos[index].shouldCloseGroup) { + builder.close() + } + // close group due to last lambda to allow block-like style in `as.forEach { ... }` + val isTrailingLambda = useBlockLikeLambdaStyle && index == parts.size - 1 + if (isTrailingLambda) { + builder.close() + } + // A block-like (exploded) selector call is laid out like the last part: its + // arguments are indented once relative to the call itself, and its closing paren + // returns to the call's indent, even when chained selectors follow it. This only + // applies when trailing commas are preserved (the block-like style); when ktfmt + // manages trailing commas, exploded chained calls keep the regular extra indent. + val isLastPartOrBlockLikeCall = + index == parts.size - 1 || + !options.manageTrailingCommas && selectorExpression.isBlockLikeCall + val argsIndentElse = if (isLastPartOrBlockLikeCall) ZERO else expressionBreakIndent + val lambdaIndentElse = if (isTrailingLambda) expressionBreakNegativeIndent else ZERO + val negativeLambdaIndentElse = if (isTrailingLambda) expressionBreakIndent else ZERO + + // emit `(1, 2) { it }` from `doIt(1, 2) { it }` + visitCallElement( + null, + selectorExpression.typeArgumentList, + selectorExpression.valueArgumentList, + selectorExpression.lambdaArguments, + argumentsIndent = Indent.If.make(nameTag, expressionBreakIndent, argsIndentElse), + lambdaIndent = Indent.If.make(nameTag, ZERO, lambdaIndentElse), + negativeLambdaIndent = Indent.If.make(nameTag, ZERO, negativeLambdaIndentElse), + ) } - is KtArrayAccessExpression -> { - visitArrayAccessBrackets(ktExpression) - builder.close() - } - is KtPostfixExpression -> { - builder.token(ktExpression.operationReference.text) - builder.close() - } - else -> { - check(index == 0) - visit(ktExpression) - } + } + is KtArrayAccessExpression -> { + visitArrayAccessBrackets(ktExpression) + builder.close() + } + is KtPostfixExpression -> { + builder.token(ktExpression.operationReference.text) + builder.close() + } + else -> { + check(index == 0) + visit(ktExpression) } } } @@ -1484,9 +1590,18 @@ open class KotlinInputAstVisitor( } else if (initializer.isChainedBlockLikeCall) { visitChainedBlockLikeCall(initializer, emitLeadingBreak = true) } else { - builder.breakOpThenBlock(" ", expressionBreakIndent) { - builder.fenceComments() - visit(initializer) + // A chain gets to keep its receiver on the `=` line when it fits there; everything else + // breaks after the `=` and is laid out one level in. + val laidOutAsChain = + !forceLineBreakAfterAssignment && + initializer.isPlainQualifiedChain && + !initializer.hasLeadingComment && + emitQualifiedExpressionAfterOperator(initializer) + if (!laidOutAsChain) { + builder.breakOpThenBlock(" ", expressionBreakIndent) { + builder.fenceComments() + visit(initializer) + } } } } diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt index ab88b2a5d..f2507d4a9 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt @@ -7,4 +7,5 @@ internal class KotlinLangInputAstVisitor( builder: OpsBuilder, ) : KotlinInputAstVisitor(options, builder) { override val forceAnnotationBreaks: Boolean = true + override val forceLineBreakAfterAssignment: Boolean = false } diff --git a/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt b/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt index 3489b7664..ac128099c 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt @@ -31,7 +31,9 @@ import org.jetbrains.kotlin.psi.KtParameterList import org.jetbrains.kotlin.psi.KtPostfixExpression import org.jetbrains.kotlin.psi.KtQualifiedExpression import org.jetbrains.kotlin.psi.KtSimpleNameExpression +import org.jetbrains.kotlin.psi.KtStringTemplateExpression import org.jetbrains.kotlin.psi.KtValueArgumentList +import org.jetbrains.kotlin.psi.KtWhenExpression import org.jetbrains.kotlin.psi.psiUtil.children import org.jetbrains.kotlin.psi.psiUtil.getNextSiblingIgnoringWhitespace import org.jetbrains.kotlin.psi.psiUtil.getPrevSiblingIgnoringWhitespace @@ -71,6 +73,16 @@ internal val KtExpression.chainRoot: KtExpression return root } +/** + * Whether a comment precedes this element. + * + * A leading comment brings its own forced break with it, which throws off any layout that decides + * indentation from whether the break before the element was taken. Such layouts fall back to the + * default one when this is true. + */ +internal val PsiElement.hasLeadingComment: Boolean + get() = getPrevSiblingIgnoringWhitespace() is PsiComment + /** * Returns true when [KtExpression] is a call that is forced onto multiple lines regardless of the * line width, either because its value argument list has a trailing comma (e.g. `foo(\n 1,\n @@ -86,10 +98,7 @@ internal val KtExpression?.isBlockLikeCall: Boolean contract { returns(true) implies (this@isBlockLikeCall is KtCallExpression) } if (this == null) return false - val prev = getPrevSiblingIgnoringWhitespace() - if (prev is PsiComment) { - return false // Leading comments cause weird indentation; keep the default layout. - } + if (hasLeadingComment) return false if (this !is KtCallExpression) return false val valueArgumentList = valueArgumentList ?: return false @@ -110,6 +119,24 @@ internal val KtExpression.isChainedBlockLikeCall: Boolean return this is KtQualifiedExpression && this.chainRoot.isBlockLikeCall } +/** + * Returns true when [KtExpression] is a chain of dotted parts that gets the regular chain layout, + * e.g. `a[5].b!!.c()[4].f()`. + * + * Chains whose receiver is special-cased elsewhere -- a string template (`"a".trim()`) or a `when` + * -- are excluded, as are the block-like and scoping-function chains that have their own layout. + */ +@OptIn(ExperimentalContracts::class) +internal val KtExpression.isPlainQualifiedChain: Boolean + get() { + contract { returns(true) implies (this@isPlainQualifiedChain is KtQualifiedExpression) } + + if (this !is KtQualifiedExpression) return false + if (receiverExpression is KtStringTemplateExpression) return false + if (receiverExpression is KtWhenExpression) return false + return !isChainedBlockLikeCall && !isChainedScopingFunction + } + /** * Returns true when [KtExpression] is a chain whose innermost receiver is a scoping function call. * diff --git a/core/src/test/resources/cases/new_codestyle/LongCallChain.input b/core/src/test/resources/cases/new_codestyle/LongCallChain.input new file mode 100644 index 000000000..24902f043 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/LongCallChain.input @@ -0,0 +1,10 @@ +fun f() { + val testDataDir: Path = Path.of("") + .absolute() + .parent + .parent + .resolve("tests") + .resolve("testData") + .resolve("loaders") + .relativeTo(Path.of("").absolute()) +} diff --git a/core/src/test/resources/cases/new_codestyle/LongCallChain.new.output b/core/src/test/resources/cases/new_codestyle/LongCallChain.new.output new file mode 100644 index 000000000..a0881eaa2 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/LongCallChain.new.output @@ -0,0 +1,10 @@ +fun f() { + val testDataDir: Path = Path.of("") + .absolute() + .parent + .parent + .resolve("tests") + .resolve("testData") + .resolve("loaders") + .relativeTo(Path.of("").absolute()) +} diff --git a/core/src/test/resources/cases/new_codestyle/LongCallChain.output b/core/src/test/resources/cases/new_codestyle/LongCallChain.output new file mode 100644 index 000000000..a8e30eba6 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/LongCallChain.output @@ -0,0 +1,11 @@ +fun f() { + val testDataDir: Path = + Path.of("") + .absolute() + .parent + .parent + .resolve("tests") + .resolve("testData") + .resolve("loaders") + .relativeTo(Path.of("").absolute()) +} diff --git a/core/src/test/resources/cases/new_codestyle/LongCallChainWithLineBreak.input b/core/src/test/resources/cases/new_codestyle/LongCallChainWithLineBreak.input new file mode 100644 index 000000000..16b395b7a --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/LongCallChainWithLineBreak.input @@ -0,0 +1,10 @@ +fun f() { + val testDataDir: Path = Path.ofAVeryVeryVeryLongCallNameHereSoThatWeHaveToBreakAfterAssignment("") + .absolute() + .parent + .parent + .resolve("tests") + .resolve("testData") + .resolve("loaders") + .relativeTo(Path.of("").absolute()) +} diff --git a/core/src/test/resources/cases/new_codestyle/LongCallChainWithLineBreak.new.output b/core/src/test/resources/cases/new_codestyle/LongCallChainWithLineBreak.new.output new file mode 100644 index 000000000..e219df107 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/LongCallChainWithLineBreak.new.output @@ -0,0 +1,11 @@ +fun f() { + val testDataDir: Path = + Path.ofAVeryVeryVeryLongCallNameHereSoThatWeHaveToBreakAfterAssignment("") + .absolute() + .parent + .parent + .resolve("tests") + .resolve("testData") + .resolve("loaders") + .relativeTo(Path.of("").absolute()) +} diff --git a/core/src/test/resources/cases/new_codestyle/LongCallChainWithLineBreak.output b/core/src/test/resources/cases/new_codestyle/LongCallChainWithLineBreak.output new file mode 100644 index 000000000..f9f3969b9 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/LongCallChainWithLineBreak.output @@ -0,0 +1,11 @@ +fun f() { + val testDataDir: Path = + Path.ofAVeryVeryVeryLongCallNameHereSoThatWeHaveToBreakAfterAssignment("") + .absolute() + .parent + .parent + .resolve("tests") + .resolve("testData") + .resolve("loaders") + .relativeTo(Path.of("").absolute()) +} From 0faeb232d14a7c3ad5ead42cad34a2e9b6484954 Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Tue, 4 Aug 2026 18:06:01 +0200 Subject: [PATCH 03/22] [formatter] Support the infix fun formatting --- .../ktfmt/format/KotlinInputAstVisitor.kt | 44 +++++++++++++++++++ .../ktfmt/format/KotlinLangInputAstVisitor.kt | 1 + .../com/facebook/ktfmt/format/PsiUtils.kt | 21 +++++++++ .../cases/new_codestyle/InfixCall.input | 4 ++ .../cases/new_codestyle/InfixCall.new.output | 4 ++ .../cases/new_codestyle/InfixCall.output | 6 +++ .../new_codestyle/InfixCallWithType.input | 4 ++ .../InfixCallWithType.new.output | 5 +++ .../new_codestyle/InfixCallWithType.output | 6 +++ 9 files changed, 95 insertions(+) create mode 100644 core/src/test/resources/cases/new_codestyle/InfixCall.input create mode 100644 core/src/test/resources/cases/new_codestyle/InfixCall.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/InfixCall.output create mode 100644 core/src/test/resources/cases/new_codestyle/InfixCallWithType.input create mode 100644 core/src/test/resources/cases/new_codestyle/InfixCallWithType.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/InfixCallWithType.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 8a1771f8a..0c5addb73 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -144,6 +144,11 @@ open class KotlinInputAstVisitor( internal open val forceAnnotationBreaks: Boolean = false internal open val forceLineBreakAfterAssignment: Boolean = true + /** + * Whether an [isInfixBlockLikeCall] hugs the operator it follows, see [emitInfixBlockLikeCall]. + */ + internal open val hugBlockLikeInfixCalls: Boolean = false + /** Standard indentation for a block */ private val blockIndent: Indent.Const = Indent.Const.make(options.blockIndent, 1) @@ -1589,6 +1594,8 @@ open class KotlinInputAstVisitor( visit(initializer) } else if (initializer.isChainedBlockLikeCall) { visitChainedBlockLikeCall(initializer, emitLeadingBreak = true) + } else if (hugBlockLikeInfixCalls && initializer.isInfixBlockLikeCall) { + emitInfixBlockLikeCall(initializer) } else { // A chain gets to keep its receiver on the `=` line when it fits there; everything else // breaks after the `=` and is laid out one level in. @@ -1656,6 +1663,43 @@ open class KotlinInputAstVisitor( } } + /** + * Emit an `a to Foo(\n ...,\n)` style infix call that follows an operator such as `=`, deciding + * whether to break after that operator from the width of the head alone. + */ + private fun emitInfixBlockLikeCall(expression: KtBinaryExpression) { + builder.sync(expression) + val right = checkNotNull(expression.right) + val call = checkNotNull(right.callExpression) + val brokeAfterOperator = BreakTag() + val callIndent = Indent.If.make(brokeAfterOperator, expressionBreakIndent, ZERO) + + builder.block(expressionBreakIndent) { + builder.breakOp(" ", ZERO, Optional.of(brokeAfterOperator)) + builder.block(callIndent) { + builder.fenceComments() + visit(expression.left) + builder.spaceThenToken(expression.operationReference.text) + builder.space() + // The call may be the selector of a qualifier, as in `a to Organizations.Override(...)`. + if (right is KtQualifiedExpression) { + visit(right.receiverExpression) + builder.token(right.operationSign.value) + } + builder.sync(call) + visit(call.calleeExpression) + builder.block(ZERO) { visit(call.typeArgumentList) } + } + } + // Emitted after the level above closed, so that its forced breaks are not part of the split the + // break after the operator is decided by. The extra level carries the head's indent over. + builder.block(callIndent) { + builder.block(expressionBreakIndent) { + visitValueArgumentListInternal(checkNotNull(call.valueArgumentList)) + } + } + } + /** * Emit a `foo(\n ...,\n).bar().baz()` style chain whose innermost receiver is a block-like * multiline call: render the receiver call normally (so its closing paren sits at the surrounding diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt index f2507d4a9..a203ba8cd 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt @@ -8,4 +8,5 @@ internal class KotlinLangInputAstVisitor( ) : KotlinInputAstVisitor(options, builder) { override val forceAnnotationBreaks: Boolean = true override val forceLineBreakAfterAssignment: Boolean = false + override val hugBlockLikeInfixCalls: Boolean = true } diff --git a/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt b/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt index ac128099c..5d79ca46b 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt @@ -22,7 +22,9 @@ import kotlin.contracts.contract import org.jetbrains.kotlin.com.intellij.psi.PsiComment import org.jetbrains.kotlin.com.intellij.psi.PsiElement import org.jetbrains.kotlin.com.intellij.psi.PsiWhiteSpace +import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.psi.KtArrayAccessExpression +import org.jetbrains.kotlin.psi.KtBinaryExpression import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtExpression import org.jetbrains.kotlin.psi.KtLabeledExpression @@ -110,6 +112,25 @@ internal val KtExpression?.isBlockLikeCall: Boolean } } +/** + * Returns true when [KtExpression] is an infix function call -- `a to b`, `x and y` -- whose + * right-hand operand ends in a [isBlockLikeCall] + */ +@OptIn(ExperimentalContracts::class) +internal val KtExpression?.isInfixBlockLikeCall: Boolean + get() { + contract { returns(true) implies (this@isInfixBlockLikeCall is KtBinaryExpression) } + + if (this !is KtBinaryExpression) return false + if (hasLeadingComment) return false + // An identifier as the operator is what distinguishes `a to b` from `a + b`. + if (operationToken != KtTokens.IDENTIFIER) return false + val right = right ?: return false + if (right.hasLeadingComment) return false + val call = right.callExpression ?: return false + return call.isBlockLikeCall && call.lambdaArguments.isEmpty() + } + /** Returns true when [KtExpression] is a chain whose innermost receiver is a [isBlockLikeCall]. */ @OptIn(ExperimentalContracts::class) internal val KtExpression.isChainedBlockLikeCall: Boolean diff --git a/core/src/test/resources/cases/new_codestyle/InfixCall.input b/core/src/test/resources/cases/new_codestyle/InfixCall.input new file mode 100644 index 000000000..5bab06767 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/InfixCall.input @@ -0,0 +1,4 @@ +val pair = orgInfo.id to OverrideOrganizations.Override( + fullName = substituteRaw(fullName), + displayName = substituteRaw(displayName), +) diff --git a/core/src/test/resources/cases/new_codestyle/InfixCall.new.output b/core/src/test/resources/cases/new_codestyle/InfixCall.new.output new file mode 100644 index 000000000..5bab06767 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/InfixCall.new.output @@ -0,0 +1,4 @@ +val pair = orgInfo.id to OverrideOrganizations.Override( + fullName = substituteRaw(fullName), + displayName = substituteRaw(displayName), +) diff --git a/core/src/test/resources/cases/new_codestyle/InfixCall.output b/core/src/test/resources/cases/new_codestyle/InfixCall.output new file mode 100644 index 000000000..d872a0fe9 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/InfixCall.output @@ -0,0 +1,6 @@ +val pair = + orgInfo.id to + OverrideOrganizations.Override( + fullName = substituteRaw(fullName), + displayName = substituteRaw(displayName), + ) diff --git a/core/src/test/resources/cases/new_codestyle/InfixCallWithType.input b/core/src/test/resources/cases/new_codestyle/InfixCallWithType.input new file mode 100644 index 000000000..82149bb35 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/InfixCallWithType.input @@ -0,0 +1,4 @@ +val pair: Pair = orgInfo.id to OverrideOrganizations.Override( + fullName = substituteRaw(fullName), + displayName = substituteRaw(displayName), +) \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/InfixCallWithType.new.output b/core/src/test/resources/cases/new_codestyle/InfixCallWithType.new.output new file mode 100644 index 000000000..77e5a277d --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/InfixCallWithType.new.output @@ -0,0 +1,5 @@ +val pair: Pair = + orgInfo.id to OverrideOrganizations.Override( + fullName = substituteRaw(fullName), + displayName = substituteRaw(displayName), + ) diff --git a/core/src/test/resources/cases/new_codestyle/InfixCallWithType.output b/core/src/test/resources/cases/new_codestyle/InfixCallWithType.output new file mode 100644 index 000000000..71dbc4722 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/InfixCallWithType.output @@ -0,0 +1,6 @@ +val pair: Pair = + orgInfo.id to + OverrideOrganizations.Override( + fullName = substituteRaw(fullName), + displayName = substituteRaw(displayName), + ) From d2c8f548d74953d26661cafe5580bd8fdebfc5b2 Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Wed, 5 Aug 2026 11:30:10 +0200 Subject: [PATCH 04/22] [formatter] Support the when formatting --- .../ktfmt/format/KotlinInputAstVisitor.kt | 139 ++++++++++++------ .../ktfmt/format/KotlinLangInputAstVisitor.kt | 1 + .../resources/cases/new_codestyle/When.input | 10 ++ .../cases/new_codestyle/When.new.output | 10 ++ .../resources/cases/new_codestyle/When.output | 11 ++ .../cases/new_codestyle/WhenWithType.input | 11 ++ .../new_codestyle/WhenWithType.new.output | 11 ++ .../cases/new_codestyle/WhenWithType.output | 11 ++ 8 files changed, 160 insertions(+), 44 deletions(-) create mode 100644 core/src/test/resources/cases/new_codestyle/When.input create mode 100644 core/src/test/resources/cases/new_codestyle/When.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/When.output create mode 100644 core/src/test/resources/cases/new_codestyle/WhenWithType.input create mode 100644 core/src/test/resources/cases/new_codestyle/WhenWithType.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/WhenWithType.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 0c5addb73..cedc35f1e 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -149,6 +149,12 @@ open class KotlinInputAstVisitor( */ internal open val hugBlockLikeInfixCalls: Boolean = false + /** + * Whether a `when` expression hugs the operator it follows, see + * [emitWhenExpressionAfterOperator]. + */ + internal open val hugWhenExpressions: Boolean = false + /** Standard indentation for a block */ private val blockIndent: Indent.Const = Indent.Const.make(options.blockIndent, 1) @@ -1596,6 +1602,10 @@ open class KotlinInputAstVisitor( visitChainedBlockLikeCall(initializer, emitLeadingBreak = true) } else if (hugBlockLikeInfixCalls && initializer.isInfixBlockLikeCall) { emitInfixBlockLikeCall(initializer) + } else if ( + hugWhenExpressions && initializer is KtWhenExpression && !initializer.hasLeadingComment + ) { + emitWhenExpressionAfterOperator(initializer) } else { // A chain gets to keep its receiver on the `=` line when it fits there; everything else // breaks after the `=` and is laid out one level in. @@ -1700,6 +1710,38 @@ open class KotlinInputAstVisitor( } } + /** + * Emit a `when (...) { ... }` that follows an operator such as `=`, deciding whether to break + * after that operator from the width of the `when (...) {` head alone: + * ``` + * val affected = when (event) { // the head fits: it stays on the `=` line + * is Update -> emptyList() + * } + * + * val affected: List = // the head doesn't: the break is taken, and the body + * when (event) { // indents relative to the `when` + * is Update -> emptyList() + * } + * ``` + * + * The mechanics are the same as in [emitInfixBlockLikeCall], with the body of the `when` playing + * the part of the argument list there. + */ + private fun emitWhenExpressionAfterOperator(expression: KtWhenExpression) { + builder.sync(expression) + val brokeAfterOperator = BreakTag() + val bodyIndent = Indent.If.make(brokeAfterOperator, expressionBreakIndent, ZERO) + + builder.block(expressionBreakIndent) { + builder.breakOp(" ", ZERO, Optional.of(brokeAfterOperator)) + builder.block(bodyIndent) { + builder.fenceComments() + emitWhenHead(expression) + } + } + builder.block(bodyIndent) { emitWhenBody(expression) } + } + /** * Emit a `foo(\n ...,\n).bar().baz()` style chain whose innermost receiver is a block-like * multiline call: render the receiver call normally (so its closing paren sits at the surrounding @@ -2206,62 +2248,71 @@ open class KotlinInputAstVisitor( override fun visitWhenExpression(expression: KtWhenExpression) { builder.sync(expression) builder.block(ZERO) { - emitKeywordWithCondition("when", expression.subjectExpression) + emitWhenHead(expression) + emitWhenBody(expression) + } + } - builder.space() - builder.token("{", Doc.Token.RealOrImaginary.REAL, blockIndent, Optional.of(blockIndent)) + /** Emits `when (subject) {`, the part of a `when` expression that opens its body. */ + private fun emitWhenHead(expression: KtWhenExpression) { + emitKeywordWithCondition("when", expression.subjectExpression) - expression.entries.forEachIndexed { index, whenEntry -> - builder.block(blockIndent) { - if (index != 0) { - // preserve new line if there's one - builder.blankLineWanted(BlankLineWanted.PRESERVE) - } - builder.forcedBreak() - builder.block(ZERO) { - if (whenEntry.elseKeyword != null) { - builder.token("else") - } else { - val conditions = whenEntry.conditions - for ((index, condition) in conditions.withIndex()) { - visit(condition) - builder.guessToken(",") - if (index != conditions.lastIndex) { - builder.forcedBreak() - } + builder.space() + builder.token("{", Doc.Token.RealOrImaginary.REAL, blockIndent, Optional.of(blockIndent)) + } + + /** Emits the entries of a `when` expression and the `}` closing its body. */ + private fun emitWhenBody(expression: KtWhenExpression) { + expression.entries.forEachIndexed { index, whenEntry -> + builder.block(blockIndent) { + if (index != 0) { + // preserve new line if there's one + builder.blankLineWanted(BlankLineWanted.PRESERVE) + } + builder.forcedBreak() + builder.block(ZERO) { + if (whenEntry.elseKeyword != null) { + builder.token("else") + } else { + val conditions = whenEntry.conditions + for ((index, condition) in conditions.withIndex()) { + visit(condition) + builder.guessToken(",") + if (index != conditions.lastIndex) { + builder.forcedBreak() } } - whenEntry.guard?.let { guard -> - builder.space() - emitKeywordWithCondition( - "if", - guard.getExpression(), - surroundConditionWithParens = false, - ) - } } - val whenExpression = whenEntry.expression - if (whenEntry.trailingComma != null) { - builder.forcedBreak() - } else { + whenEntry.guard?.let { guard -> builder.space() + emitKeywordWithCondition( + "if", + guard.getExpression(), + surroundConditionWithParens = false, + ) } - builder.token("->") - if (whenExpression is KtBlockExpression || whenExpression is KtLambdaExpression) { - builder.space() + } + val whenExpression = whenEntry.expression + if (whenEntry.trailingComma != null) { + builder.forcedBreak() + } else { + builder.space() + } + builder.token("->") + if (whenExpression is KtBlockExpression || whenExpression is KtLambdaExpression) { + builder.space() + visit(whenExpression) + } else { + builder.block(expressionBreakIndent) { + builder.breakToFill(" ") visit(whenExpression) - } else { - builder.block(expressionBreakIndent) { - builder.breakToFill(" ") - visit(whenExpression) - } } - builder.guessSemicolon() } - builder.forcedBreak() + builder.guessSemicolon() } - builder.token("}") + builder.forcedBreak() } + builder.token("}") } override fun visitClassBody(body: KtClassBody) { diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt index a203ba8cd..46a47f1dc 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt @@ -9,4 +9,5 @@ internal class KotlinLangInputAstVisitor( override val forceAnnotationBreaks: Boolean = true override val forceLineBreakAfterAssignment: Boolean = false override val hugBlockLikeInfixCalls: Boolean = true + override val hugWhenExpressions: Boolean = true } diff --git a/core/src/test/resources/cases/new_codestyle/When.input b/core/src/test/resources/cases/new_codestyle/When.input new file mode 100644 index 000000000..248cd5de3 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/When.input @@ -0,0 +1,10 @@ +fun f() { + val teamsAffected = when (val event = state.lastEvent) { + is CommentaryMessagesUpdate -> emptyList() + is InfoUpdate -> info.teams.keys.toList() + is RunUpdate -> { + lastSubmissionTime = maxOf(lastSubmissionTime, event.newInfo.time) + runsByTeamId.applyEvent(state) + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/When.new.output b/core/src/test/resources/cases/new_codestyle/When.new.output new file mode 100644 index 000000000..92c9b684e --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/When.new.output @@ -0,0 +1,10 @@ +fun f() { + val teamsAffected = when (val event = state.lastEvent) { + is CommentaryMessagesUpdate -> emptyList() + is InfoUpdate -> info.teams.keys.toList() + is RunUpdate -> { + lastSubmissionTime = maxOf(lastSubmissionTime, event.newInfo.time) + runsByTeamId.applyEvent(state) + } + } +} diff --git a/core/src/test/resources/cases/new_codestyle/When.output b/core/src/test/resources/cases/new_codestyle/When.output new file mode 100644 index 000000000..bce045a7c --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/When.output @@ -0,0 +1,11 @@ +fun f() { + val teamsAffected = + when (val event = state.lastEvent) { + is CommentaryMessagesUpdate -> emptyList() + is InfoUpdate -> info.teams.keys.toList() + is RunUpdate -> { + lastSubmissionTime = maxOf(lastSubmissionTime, event.newInfo.time) + runsByTeamId.applyEvent(state) + } + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhenWithType.input b/core/src/test/resources/cases/new_codestyle/WhenWithType.input new file mode 100644 index 000000000..1e9a206de --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhenWithType.input @@ -0,0 +1,11 @@ +fun f() { + val teamsAffected: List = + when (val event = state.lastEvent) { + is CommentaryMessagesUpdate -> emptyList() + is InfoUpdate -> info.teams.keys.toList() + is RunUpdate -> { + lastSubmissionTime = maxOf(lastSubmissionTime, event.newInfo.time) + runsByTeamId.applyEvent(state) + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/WhenWithType.new.output b/core/src/test/resources/cases/new_codestyle/WhenWithType.new.output new file mode 100644 index 000000000..5d618419d --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhenWithType.new.output @@ -0,0 +1,11 @@ +fun f() { + val teamsAffected: List = + when (val event = state.lastEvent) { + is CommentaryMessagesUpdate -> emptyList() + is InfoUpdate -> info.teams.keys.toList() + is RunUpdate -> { + lastSubmissionTime = maxOf(lastSubmissionTime, event.newInfo.time) + runsByTeamId.applyEvent(state) + } + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhenWithType.output b/core/src/test/resources/cases/new_codestyle/WhenWithType.output new file mode 100644 index 000000000..25d34f16d --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhenWithType.output @@ -0,0 +1,11 @@ +fun f() { + val teamsAffected: List = + when (val event = state.lastEvent) { + is CommentaryMessagesUpdate -> emptyList() + is InfoUpdate -> info.teams.keys.toList() + is RunUpdate -> { + lastSubmissionTime = maxOf(lastSubmissionTime, event.newInfo.time) + runsByTeamId.applyEvent(state) + } + } +} From 3283dee047f76813327b60351eafc416c6ffb96b Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Wed, 5 Aug 2026 11:45:49 +0200 Subject: [PATCH 05/22] [formatter] Support the boolean conditions indent --- .../ktfmt/format/KotlinInputAstVisitor.kt | 12 ++---------- .../ktfmt/format/KotlinLangInputAstVisitor.kt | 1 + .../org/jetbrains/ktfmt/FormatterTestFactory.kt | 1 + .../cases/new_codestyle/BooleanConditions.input | 16 ++++++++++++++++ .../new_codestyle/BooleanConditions.new.output | 16 ++++++++++++++++ .../cases/new_codestyle/BooleanConditions.output | 14 ++++++++++++++ 6 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 core/src/test/resources/cases/new_codestyle/BooleanConditions.input create mode 100644 core/src/test/resources/cases/new_codestyle/BooleanConditions.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/BooleanConditions.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index cedc35f1e..30ec17b3a 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -143,17 +143,9 @@ open class KotlinInputAstVisitor( internal open val forceAnnotationBreaks: Boolean = false internal open val forceLineBreakAfterAssignment: Boolean = true - - /** - * Whether an [isInfixBlockLikeCall] hugs the operator it follows, see [emitInfixBlockLikeCall]. - */ internal open val hugBlockLikeInfixCalls: Boolean = false - - /** - * Whether a `when` expression hugs the operator it follows, see - * [emitWhenExpressionAfterOperator]. - */ internal open val hugWhenExpressions: Boolean = false + internal open val indentBooleanConditions: Boolean = true /** Standard indentation for a block */ private val blockIndent: Indent.Const = Indent.Const.make(options.blockIndent, 1) @@ -1387,7 +1379,7 @@ open class KotlinInputAstVisitor( else -> { builder.space() if (isFirst) { - builder.open(expressionBreakIndent) + builder.open(if (indentBooleanConditions) expressionBreakIndent else ZERO) } builder.token(leftExpression.operationReference.text) val fillMode = diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt index 46a47f1dc..2ec1dde31 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt @@ -10,4 +10,5 @@ internal class KotlinLangInputAstVisitor( override val forceLineBreakAfterAssignment: Boolean = false override val hugBlockLikeInfixCalls: Boolean = true override val hugWhenExpressions: Boolean = true + override val indentBooleanConditions: Boolean = false } diff --git a/core/src/test/java/org/jetbrains/ktfmt/FormatterTestFactory.kt b/core/src/test/java/org/jetbrains/ktfmt/FormatterTestFactory.kt index b2d7183b3..eac0956e9 100644 --- a/core/src/test/java/org/jetbrains/ktfmt/FormatterTestFactory.kt +++ b/core/src/test/java/org/jetbrains/ktfmt/FormatterTestFactory.kt @@ -99,6 +99,7 @@ abstract class FormatterTestFactory( return directory .listDirectoryEntries() .filter { it.extension == "input" } + .filter { it.name == "BooleanConditions.input" } .sortedBy { it.name } .map { input -> val name = input.nameWithoutExtension diff --git a/core/src/test/resources/cases/new_codestyle/BooleanConditions.input b/core/src/test/resources/cases/new_codestyle/BooleanConditions.input new file mode 100644 index 000000000..789ff654f --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/BooleanConditions.input @@ -0,0 +1,16 @@ +fun f1() { + if ( + unwrapped !== rootCause && + unwrapped !== unwrappedCause && + unwrapped !is CancellationException && + seenExceptions.add(unwrapped) + ) { + rootCause.addSuppressed(unwrapped) + } +} + +fun f2() { + if (unwrapped !== rootCause) { + rootCause.addSuppressed(unwrapped) + } +} \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/BooleanConditions.new.output b/core/src/test/resources/cases/new_codestyle/BooleanConditions.new.output new file mode 100644 index 000000000..b2d6f4734 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/BooleanConditions.new.output @@ -0,0 +1,16 @@ +fun f1() { + if ( + unwrapped !== rootCause && + unwrapped !== unwrappedCause && + unwrapped !is CancellationException && + seenExceptions.add(unwrapped) + ) { + rootCause.addSuppressed(unwrapped) + } +} + +fun f2() { + if (unwrapped !== rootCause) { + rootCause.addSuppressed(unwrapped) + } +} diff --git a/core/src/test/resources/cases/new_codestyle/BooleanConditions.output b/core/src/test/resources/cases/new_codestyle/BooleanConditions.output new file mode 100644 index 000000000..6648251fb --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/BooleanConditions.output @@ -0,0 +1,14 @@ +fun f1() { + if (unwrapped !== rootCause && + unwrapped !== unwrappedCause && + unwrapped !is CancellationException && + seenExceptions.add(unwrapped)) { + rootCause.addSuppressed(unwrapped) + } +} + +fun f2() { + if (unwrapped !== rootCause) { + rootCause.addSuppressed(unwrapped) + } +} From 0391ba86067c5eb9bd1192a2091e87eeb21cf232 Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Wed, 5 Aug 2026 12:24:21 +0200 Subject: [PATCH 06/22] [formatter] Don't force line breaks after named parameters --- .../ktfmt/format/KotlinInputAstVisitor.kt | 64 +++++++++++++++++++ .../ktfmt/format/KotlinLangInputAstVisitor.kt | 1 + .../jetbrains/ktfmt/FormatterTestFactory.kt | 1 - .../new_codestyle/CallArgumentWrapping.input | 13 ++++ .../CallArgumentWrapping.new.output | 12 ++++ .../new_codestyle/CallArgumentWrapping.output | 13 ++++ .../CallArgumentWrappingWithBreak.input | 13 ++++ .../CallArgumentWrappingWithBreak.new.output | 13 ++++ .../CallArgumentWrappingWithBreak.output | 13 ++++ .../CallMultiArgumentWrapping.input | 22 +++++++ .../CallMultiArgumentWrapping.new.output | 20 ++++++ .../CallMultiArgumentWrapping.output | 22 +++++++ 12 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 core/src/test/resources/cases/new_codestyle/CallArgumentWrapping.input create mode 100644 core/src/test/resources/cases/new_codestyle/CallArgumentWrapping.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/CallArgumentWrapping.output create mode 100644 core/src/test/resources/cases/new_codestyle/CallArgumentWrappingWithBreak.input create mode 100644 core/src/test/resources/cases/new_codestyle/CallArgumentWrappingWithBreak.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/CallArgumentWrappingWithBreak.output create mode 100644 core/src/test/resources/cases/new_codestyle/CallMultiArgumentWrapping.input create mode 100644 core/src/test/resources/cases/new_codestyle/CallMultiArgumentWrapping.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/CallMultiArgumentWrapping.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 30ec17b3a..58b107b1c 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -143,6 +143,7 @@ open class KotlinInputAstVisitor( internal open val forceAnnotationBreaks: Boolean = false internal open val forceLineBreakAfterAssignment: Boolean = true + internal open val forceLineBreakAfterNamedParameter: Boolean = true internal open val hugBlockLikeInfixCalls: Boolean = false internal open val hugWhenExpressions: Boolean = false internal open val indentBooleanConditions: Boolean = true @@ -617,6 +618,65 @@ open class KotlinInputAstVisitor( return true } + /** + * Lays out a call that follows an operator such as the `=` of a named argument, deciding whether + * to break after that operator from the width of the callee alone. + * + * The default layout puts the whole call into a single level, so a break in front of it is taken + * whenever the *entire* call doesn't fit -- even when only its arguments need to break. Here the + * callee goes into a level of its own instead, so the break competes with the callee and nothing + * else: + * ``` + * add( + * queue = OverrideQueue( // callee fits: it stays on the `=` line + * waitTime, + * ), + * ) + * + * add( + * queue = // callee doesn't: the break is taken, and the arguments + * AVeryVeryLongQueue( // indent relative to the callee + * waitTime, + * ), + * ) + * ``` + * + * Returns false, having emitted nothing, when the call has no arguments to break at or is a shape + * whose layout is decided elsewhere. Callers fall back to breaking first and visiting the call. + */ + private fun emitCallAfterOperator(expression: KtExpression?): Boolean { + if (expression !is KtCallExpression) return false + // A leading comment brings its own forced break, which throws off the indents below. + if (expression.hasLeadingComment) return false + // A trailing lambda is laid out by visitCallElement, which indents the callee along with it. + if (expression.lambdaArguments.isNotEmpty()) return false + val callee = expression.calleeExpression ?: return false + val argumentList = expression.valueArgumentList ?: return false + // Without arguments there is nothing to break at, so keeping the callee here buys nothing. + if (argumentList.hasEmptyParens()) return false + + val brokeAfterOperator = BreakTag() + // The callee, preceded by the only break in this level: it is taken exactly when the callee + // doesn't fit on the current line. The callee's own contents indent one more level when that + // happens, since the callee then starts a line of its own. + builder.block(expressionBreakIndent) { + builder.breakOp(" ", ZERO, Optional.of(brokeAfterOperator)) + builder.block(Indent.If.make(brokeAfterOperator, expressionBreakIndent, ZERO)) { + visit(callee) + } + } + // The arguments, in a level of their own so that they can still share a line with the callee + // when they fit. This level is entered after the break above was decided, so its indent can + // depend on it. + val argumentsIndent = + Indent.If.make(brokeAfterOperator, doubleExpressionBreakIndent, expressionBreakIndent) + builder.block(argumentsIndent) { + builder.block(ZERO) { visit(expression.typeArgumentList) } + visitValueArgumentListInternal(argumentList) + } + return true + } + /** * The index of the last part of the chain's receiver -- the head that is kept together, such as * `a.b[2]` in `a.b[2].c.d()` -- or null when every group opened in the chain is still open by the @@ -1292,6 +1352,10 @@ open class KotlinInputAstVisitor( builder.space() } } + if (hasArgName && !isLambda && !argument.isSpread && !forceLineBreakAfterNamedParameter) { + if (emitCallAfterOperator(argument.getArgumentExpression())) return + } + val indent = if (hasArgName && !isLambda) expressionBreakIndent else ZERO builder.block(indent, isEnabled = wrapInBlock) { if (hasArgName && !isLambda) { diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt index 2ec1dde31..da3ffd7f8 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt @@ -8,6 +8,7 @@ internal class KotlinLangInputAstVisitor( ) : KotlinInputAstVisitor(options, builder) { override val forceAnnotationBreaks: Boolean = true override val forceLineBreakAfterAssignment: Boolean = false + override val forceLineBreakAfterNamedParameter: Boolean = false override val hugBlockLikeInfixCalls: Boolean = true override val hugWhenExpressions: Boolean = true override val indentBooleanConditions: Boolean = false diff --git a/core/src/test/java/org/jetbrains/ktfmt/FormatterTestFactory.kt b/core/src/test/java/org/jetbrains/ktfmt/FormatterTestFactory.kt index eac0956e9..b2d7183b3 100644 --- a/core/src/test/java/org/jetbrains/ktfmt/FormatterTestFactory.kt +++ b/core/src/test/java/org/jetbrains/ktfmt/FormatterTestFactory.kt @@ -99,7 +99,6 @@ abstract class FormatterTestFactory( return directory .listDirectoryEntries() .filter { it.extension == "input" } - .filter { it.name == "BooleanConditions.input" } .sortedBy { it.name } .map { input -> val name = input.nameWithoutExtension diff --git a/core/src/test/resources/cases/new_codestyle/CallArgumentWrapping.input b/core/src/test/resources/cases/new_codestyle/CallArgumentWrapping.input new file mode 100644 index 000000000..989d063a9 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/CallArgumentWrapping.input @@ -0,0 +1,13 @@ +fun f() { + add( + queue = + OverrideQueue( + queueSettings.waitTime, + queueSettings.firstToSolveWaitTime, + queueSettings.featuredRunWaitTime, + queueSettings.inProgressRunWaitTime, + queueSettings.maxQueueSize, + queueSettings.maxUntestedRun, + ) + ) +} \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/CallArgumentWrapping.new.output b/core/src/test/resources/cases/new_codestyle/CallArgumentWrapping.new.output new file mode 100644 index 000000000..b0d7a4aad --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/CallArgumentWrapping.new.output @@ -0,0 +1,12 @@ +fun f() { + add( + queue = OverrideQueue( + queueSettings.waitTime, + queueSettings.firstToSolveWaitTime, + queueSettings.featuredRunWaitTime, + queueSettings.inProgressRunWaitTime, + queueSettings.maxQueueSize, + queueSettings.maxUntestedRun, + ), + ) +} diff --git a/core/src/test/resources/cases/new_codestyle/CallArgumentWrapping.output b/core/src/test/resources/cases/new_codestyle/CallArgumentWrapping.output new file mode 100644 index 000000000..2730c16f0 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/CallArgumentWrapping.output @@ -0,0 +1,13 @@ +fun f() { + add( + queue = + OverrideQueue( + queueSettings.waitTime, + queueSettings.firstToSolveWaitTime, + queueSettings.featuredRunWaitTime, + queueSettings.inProgressRunWaitTime, + queueSettings.maxQueueSize, + queueSettings.maxUntestedRun, + ) + ) +} diff --git a/core/src/test/resources/cases/new_codestyle/CallArgumentWrappingWithBreak.input b/core/src/test/resources/cases/new_codestyle/CallArgumentWrappingWithBreak.input new file mode 100644 index 000000000..57291823a --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/CallArgumentWrappingWithBreak.input @@ -0,0 +1,13 @@ +fun f() { + add( + queue = + AVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLongOverrideQueue( + queueSettings.waitTime, + queueSettings.firstToSolveWaitTime, + queueSettings.featuredRunWaitTime, + queueSettings.inProgressRunWaitTime, + queueSettings.maxQueueSize, + queueSettings.maxUntestedRun, + ) + ) +} \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/CallArgumentWrappingWithBreak.new.output b/core/src/test/resources/cases/new_codestyle/CallArgumentWrappingWithBreak.new.output new file mode 100644 index 000000000..53d91e8cc --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/CallArgumentWrappingWithBreak.new.output @@ -0,0 +1,13 @@ +fun f() { + add( + queue = + AVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLongOverrideQueue( + queueSettings.waitTime, + queueSettings.firstToSolveWaitTime, + queueSettings.featuredRunWaitTime, + queueSettings.inProgressRunWaitTime, + queueSettings.maxQueueSize, + queueSettings.maxUntestedRun, + ), + ) +} diff --git a/core/src/test/resources/cases/new_codestyle/CallArgumentWrappingWithBreak.output b/core/src/test/resources/cases/new_codestyle/CallArgumentWrappingWithBreak.output new file mode 100644 index 000000000..a7fa132fe --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/CallArgumentWrappingWithBreak.output @@ -0,0 +1,13 @@ +fun f() { + add( + queue = + AVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLongOverrideQueue( + queueSettings.waitTime, + queueSettings.firstToSolveWaitTime, + queueSettings.featuredRunWaitTime, + queueSettings.inProgressRunWaitTime, + queueSettings.maxQueueSize, + queueSettings.maxUntestedRun, + ) + ) +} diff --git a/core/src/test/resources/cases/new_codestyle/CallMultiArgumentWrapping.input b/core/src/test/resources/cases/new_codestyle/CallMultiArgumentWrapping.input new file mode 100644 index 000000000..de32c6b2c --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/CallMultiArgumentWrapping.input @@ -0,0 +1,22 @@ +fun f() { + add( + queue = + OverrideQueue( + queueSettings.waitTime, + queueSettings.firstToSolveWaitTime, + queueSettings.featuredRunWaitTime, + queueSettings.inProgressRunWaitTime, + queueSettings.maxQueueSize, + queueSettings.maxUntestedRun, + ), + foo = + OverrideQueue( + queueSettings.waitTime, + queueSettings.firstToSolveWaitTime, + queueSettings.featuredRunWaitTime, + queueSettings.inProgressRunWaitTime, + queueSettings.maxQueueSize, + queueSettings.maxUntestedRun, + ), + ) +} \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/CallMultiArgumentWrapping.new.output b/core/src/test/resources/cases/new_codestyle/CallMultiArgumentWrapping.new.output new file mode 100644 index 000000000..068a14a0d --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/CallMultiArgumentWrapping.new.output @@ -0,0 +1,20 @@ +fun f() { + add( + queue = OverrideQueue( + queueSettings.waitTime, + queueSettings.firstToSolveWaitTime, + queueSettings.featuredRunWaitTime, + queueSettings.inProgressRunWaitTime, + queueSettings.maxQueueSize, + queueSettings.maxUntestedRun, + ), + foo = OverrideQueue( + queueSettings.waitTime, + queueSettings.firstToSolveWaitTime, + queueSettings.featuredRunWaitTime, + queueSettings.inProgressRunWaitTime, + queueSettings.maxQueueSize, + queueSettings.maxUntestedRun, + ), + ) +} diff --git a/core/src/test/resources/cases/new_codestyle/CallMultiArgumentWrapping.output b/core/src/test/resources/cases/new_codestyle/CallMultiArgumentWrapping.output new file mode 100644 index 000000000..f80ef3614 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/CallMultiArgumentWrapping.output @@ -0,0 +1,22 @@ +fun f() { + add( + queue = + OverrideQueue( + queueSettings.waitTime, + queueSettings.firstToSolveWaitTime, + queueSettings.featuredRunWaitTime, + queueSettings.inProgressRunWaitTime, + queueSettings.maxQueueSize, + queueSettings.maxUntestedRun, + ), + foo = + OverrideQueue( + queueSettings.waitTime, + queueSettings.firstToSolveWaitTime, + queueSettings.featuredRunWaitTime, + queueSettings.inProgressRunWaitTime, + queueSettings.maxQueueSize, + queueSettings.maxUntestedRun, + ), + ) +} From bdc5a85344dec14550a4927ddb20e7ada497adaa Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Wed, 5 Aug 2026 15:23:46 +0200 Subject: [PATCH 07/22] [formatter] Add supertypes formatting tests --- core/api/ktfmt.api | 1 - .../ktfmt/format/KotlinInputAstVisitor.kt | 748 ++++++++++-------- .../ktfmt/format/KotlinLangInputAstVisitor.kt | 1 + .../new_codestyle/ExpressionBodyHugging.input | 28 + .../ExpressionBodyHugging.new.output | 29 + .../ExpressionBodyHugging.output | 36 + .../cases/new_codestyle/Interfaces.input | 7 + .../cases/new_codestyle/Interfaces.new.output | 7 + .../cases/new_codestyle/Interfaces.output | 7 + .../cases/new_codestyle/Supertype.input | 7 + .../cases/new_codestyle/Supertype.new.output | 6 + .../cases/new_codestyle/Supertype.output | 7 + .../SupertypeAndInterfaces.input | 7 + .../SupertypeAndInterfaces.new.output | 6 + .../SupertypeAndInterfaces.output | 10 + 15 files changed, 576 insertions(+), 331 deletions(-) create mode 100644 core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.input create mode 100644 core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.output create mode 100644 core/src/test/resources/cases/new_codestyle/Interfaces.input create mode 100644 core/src/test/resources/cases/new_codestyle/Interfaces.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/Interfaces.output create mode 100644 core/src/test/resources/cases/new_codestyle/Supertype.input create mode 100644 core/src/test/resources/cases/new_codestyle/Supertype.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/Supertype.output create mode 100644 core/src/test/resources/cases/new_codestyle/SupertypeAndInterfaces.input create mode 100644 core/src/test/resources/cases/new_codestyle/SupertypeAndInterfaces.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/SupertypeAndInterfaces.output diff --git a/core/api/ktfmt.api b/core/api/ktfmt.api index 7056fddb4..cf7d81adf 100644 --- a/core/api/ktfmt.api +++ b/core/api/ktfmt.api @@ -192,7 +192,6 @@ public class com/facebook/ktfmt/format/KotlinInputAstVisitor : org/jetbrains/kot public fun visitAnnotationUseSiteTarget (Lorg/jetbrains/kotlin/psi/KtAnnotationUseSiteTarget;Ljava/lang/Void;)Ljava/lang/Void; public fun visitArgument (Lorg/jetbrains/kotlin/psi/KtValueArgument;)V public fun visitArrayAccessExpression (Lorg/jetbrains/kotlin/psi/KtArrayAccessExpression;)V - public final fun visitBackingField (Lorg/jetbrains/kotlin/psi/KtBackingField;)V public fun visitBinaryExpression (Lorg/jetbrains/kotlin/psi/KtBinaryExpression;)V public fun visitBinaryWithTypeRHSExpression (Lorg/jetbrains/kotlin/psi/KtBinaryExpressionWithTypeRHS;)V public fun visitBlockExpression (Lorg/jetbrains/kotlin/psi/KtBlockExpression;)V diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 58b107b1c..d07c50f25 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -34,7 +34,6 @@ import kotlin.jvm.optionals.getOrNull import org.jetbrains.kotlin.com.intellij.psi.PsiComment import org.jetbrains.kotlin.com.intellij.psi.PsiElement import org.jetbrains.kotlin.com.intellij.psi.PsiWhiteSpace -import org.jetbrains.kotlin.com.intellij.psi.stubs.PsiFileStubImpl import org.jetbrains.kotlin.lexer.KtModifierKeywordToken import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.psi.KtAnnotatedExpression @@ -132,8 +131,6 @@ import org.jetbrains.kotlin.psi.KtWhileExpression import org.jetbrains.kotlin.psi.psiUtil.children import org.jetbrains.kotlin.psi.psiUtil.startOffset import org.jetbrains.kotlin.psi.psiUtil.startsWithComment -import org.jetbrains.kotlin.psi.stubs.elements.KtStubElementTypes -import org.jetbrains.kotlin.psi.stubs.impl.KotlinPlaceHolderStubImpl /** An AST visitor that builds a stream of {@link Op}s to format. */ open class KotlinInputAstVisitor( @@ -144,6 +141,7 @@ open class KotlinInputAstVisitor( internal open val forceAnnotationBreaks: Boolean = false internal open val forceLineBreakAfterAssignment: Boolean = true internal open val forceLineBreakAfterNamedParameter: Boolean = true + internal open val forceLineBreakAfterSupertypeColon: Boolean = true internal open val hugBlockLikeInfixCalls: Boolean = false internal open val hugWhenExpressions: Boolean = false internal open val indentBooleanConditions: Boolean = true @@ -192,27 +190,28 @@ open class KotlinInputAstVisitor( } } - /** Example `Int`, `(String)` or `() -> Int` */ - override fun visitTypeReference(typeReference: KtTypeReference) { - builder.sync(typeReference) - // Normally we'd visit the children nodes through accessors on 'typeReference', and we wouldn't - // loop over children. - // But, in this case the modifier list can either be inside the parenthesis: - // ... (@Composable (x) -> Unit) - // or outside of them: - // ... @Composable ((x) -> Unit) - val modifierList = typeReference.modifierList - val typeElement = typeReference.typeElement - for (child in typeReference.node.children()) { + /** Emits a type together with the parentheses around it, by walking [element]'s children. */ + private fun emitParenthesizedType( + element: KtElement, + modifierList: KtModifierList?, + innerType: PsiElement?, + ) { + for (child in element.node.children()) { when { child.psi == modifierList -> visit(modifierList) - child.psi == typeElement -> visit(typeElement) + child.psi == innerType -> visit(innerType) child.elementType == KtTokens.LPAR -> builder.token("(") child.elementType == KtTokens.RPAR -> builder.token(")") } } } + /** Example `Int`, `(String)` or `() -> Int` */ + override fun visitTypeReference(typeReference: KtTypeReference) { + builder.sync(typeReference) + emitParenthesizedType(typeReference, typeReference.modifierList, typeReference.typeElement) + } + override fun visitDynamicType(type: KtDynamicType) { builder.token("dynamic") } @@ -220,18 +219,7 @@ open class KotlinInputAstVisitor( /** Example: `String?` or `((Int) -> Unit)?` */ override fun visitNullableType(nullableType: KtNullableType) { builder.sync(nullableType) - - // Normally we wouldn't loop over children, but there can be multiple layers of parens. - val modifierList = nullableType.modifierList - val innerType = nullableType.innerType - for (child in nullableType.node.children()) { - when { - child.psi == modifierList -> visit(modifierList) - child.psi == innerType -> visit(innerType) - child.elementType == KtTokens.LPAR -> builder.token("(") - child.elementType == KtTokens.RPAR -> builder.token(")") - } - } + emitParenthesizedType(nullableType, nullableType.modifierList, nullableType.innerType) builder.token("?") } @@ -383,16 +371,7 @@ open class KotlinInputAstVisitor( builder.space() builder.block(ZERO) { builder.token("=") - if (bodyExpression.isLambdaOrScopingFunction) { - visitLambdaOrScopingFunction(bodyExpression) - } else if (bodyExpression.isChainedScopingFunction) { - visitChainedScopingFunction(bodyExpression, emitLeadingBreak = true) - } else if (bodyExpression.isBlockLikeCall) { - builder.space() - visit(bodyExpression) - } else if (bodyExpression.isChainedBlockLikeCall) { - visitChainedBlockLikeCall(bodyExpression, emitLeadingBreak = true) - } else { + if (!emitExpressionAfterOperator(bodyExpression)) { builder.block(expressionBreakIndent) { builder.breakToFill(" ") builder.block(ZERO) { visit(bodyExpression) } @@ -431,14 +410,12 @@ open class KotlinInputAstVisitor( } private fun visitStatements(statements: Array) { - var first = true builder.guessSemicolon() - for (statement in statements) { + for ((index, statement) in statements.withIndex()) { builder.forcedBreak() - if (!first) { + if (index > 0) { builder.blankLineWanted(BlankLineWanted.PRESERVE) } - first = false markForPartialFormat() visitStatement(statement) markForPartialFormat() @@ -469,11 +446,6 @@ open class KotlinInputAstVisitor( } } - @Deprecated("Kept for backwards compatibility, will be removed in the future") - fun visitBackingField(backingField: KtBackingField) { - emitBackingField(backingField) - } - /** * Example: "com.facebook.bla.bla" in imports or "a.b.c.d" in expressions. * @@ -528,6 +500,28 @@ open class KotlinInputAstVisitor( var shouldCloseGroup = false } + /** + * A chain of qualified expressions, decomposed into everything needed to lay it out. + * + * @param useBlockLikeLambdaStyle whether we want to make a lambda look like a block, this makes + * Kotlin DSLs look as expected + */ + private data class ChainLayout( + val parts: List, + val useBlockLikeLambdaStyle: Boolean, + val groupingInfos: List, + ) + + private fun chainLayout(expression: KtExpression): ChainLayout { + val parts = breakIntoParts(expression) + val useBlockLikeLambdaStyle = parts.last().isLambda() && parts.count { it.isLambda() } == 1 + return ChainLayout( + parts, + useBlockLikeLambdaStyle, + computeGroupingInfo(parts, useBlockLikeLambdaStyle), + ) + } + /** * Handles a chain of qualified expressions, i.e. `a[5].b!!.c()[4].f()` * @@ -539,19 +533,49 @@ open class KotlinInputAstVisitor( * part, emitting it to the [builder] while closing and opening groups. */ private fun emitQualifiedExpression(expression: KtExpression) { - val parts = breakIntoParts(expression) - // whether we want to make a lambda look like a block, this make Kotlin DSLs look as expected - val useBlockLikeLambdaStyle = parts.last().isLambda() && parts.count { it.isLambda() } == 1 - val groupingInfos = computeGroupingInfo(parts, useBlockLikeLambdaStyle) + val chain = chainLayout(expression) builder.block(expressionBreakIndent) { - emitQualifiedExpressionParts( - parts, - groupingInfos, - useBlockLikeLambdaStyle, - range = parts.indices, - nameTag = BreakTag(), - ) + emitQualifiedExpressionParts(chain, range = chain.parts.indices, nameTag = BreakTag()) + } + } + + /** + * Lays out something that follows an operator such as `=` in two parts, deciding whether to break + * after that operator from the width of the first part alone. + * + * The head goes into a level of its own, preceded by the only break in that level, so the break + * is taken exactly when the head doesn't fit on the current line. The head's own contents indent + * one more level when that happens, since the head then starts a line of its own. The tail is + * emitted after that level closed, so its forced breaks are not part of the split the break is + * decided by, and so its indent can depend on the decision: + * ``` + * val affected = when (event) { // the head fits: it stays on the `=` line + * is Update -> emptyList() + * } + * + * val affected: List = // the head doesn't: the break is taken, and the tail + * when (event) { // indents relative to the head + * is Update -> emptyList() + * } + * ``` + * + * @param emitTail emits the rest, given the indents to lay it out at. A tail that aligns with the + * head -- the body of a `when` -- uses `headIndent`; one that continues the head's line -- an + * argument list -- uses `tailIndent`, or `headIndent` plus a level of its own. + */ + private fun emitSplitAfterOperator( + emitHead: () -> Unit, + emitTail: (headIndent: Indent, tailIndent: Indent) -> Unit, + ) { + val brokeAfterOperator = BreakTag() + val headIndent = Indent.If.make(brokeAfterOperator, expressionBreakIndent, ZERO) + val tailIndent = + Indent.If.make(brokeAfterOperator, doubleExpressionBreakIndent, expressionBreakIndent) + builder.block(expressionBreakIndent) { + builder.breakOp(" ", ZERO, Optional.of(brokeAfterOperator)) + builder.block(headIndent) { emitHead() } } + emitTail(headIndent, tailIndent) } /** @@ -560,8 +584,8 @@ open class KotlinInputAstVisitor( * * [emitQualifiedExpression] puts the whole chain into a single level, so a break in front of it * is taken whenever the *entire* chain doesn't fit -- even when only the selectors need to break. - * Here the receiver goes into a level of its own instead, so the break competes with the receiver - * and nothing else: + * Here the receiver plays the head of [emitSplitAfterOperator] instead, so the break competes + * with the receiver and nothing else: * ``` * val testDataDir: Path = Path.of("") // receiver fits: it stays on the `=` line * .resolve("tests") @@ -576,45 +600,27 @@ open class KotlinInputAstVisitor( * and calling [emitQualifiedExpression]. */ private fun emitQualifiedExpressionAfterOperator(expression: KtExpression): Boolean { - val parts = breakIntoParts(expression) - val useBlockLikeLambdaStyle = parts.last().isLambda() && parts.count { it.isLambda() } == 1 - val groupingInfos = computeGroupingInfo(parts, useBlockLikeLambdaStyle) - val receiverEnd = - receiverSegmentEnd(parts, groupingInfos, useBlockLikeLambdaStyle) ?: return false - + val chain = chainLayout(expression) + val receiverEnd = receiverSegmentEnd(chain) ?: return false + val lastIndex = chain.parts.lastIndex val nameTag = BreakTag() // allows adjusting arguments indentation if a break will be made - val brokeAfterOperator = BreakTag() - // The receiver, preceded by the only break in this level: it is taken exactly when the - // receiver doesn't fit on the current line. The receiver's own contents indent one more level - // when that happens, since the receiver then starts a line of its own. - builder.block(expressionBreakIndent) { - builder.breakOp(" ", ZERO, Optional.of(brokeAfterOperator)) - builder.block(Indent.If.make(brokeAfterOperator, expressionBreakIndent, ZERO)) { - emitQualifiedExpressionParts( - parts, - groupingInfos, - useBlockLikeLambdaStyle, - range = 0..receiverEnd, - nameTag = nameTag, - ) - } - } - // The selectors, in a level of their own so that they can still share a line with the receiver - // when they fit. This level is entered after the break above was decided, so its indent can - // depend on it. - if (receiverEnd < parts.lastIndex) { - val selectorsIndent = - Indent.If.make(brokeAfterOperator, doubleExpressionBreakIndent, expressionBreakIndent) - builder.block(selectorsIndent) { - emitQualifiedExpressionParts( - parts, - groupingInfos, - useBlockLikeLambdaStyle, - range = receiverEnd + 1..parts.lastIndex, - nameTag = nameTag, - ) - } - } + + emitSplitAfterOperator( + emitHead = { + emitQualifiedExpressionParts(chain, range = 0..receiverEnd, nameTag = nameTag) + }, + emitTail = { _, selectorsIndent -> + if (receiverEnd < lastIndex) { + builder.block(selectorsIndent) { + emitQualifiedExpressionParts( + chain, + range = receiverEnd + 1..lastIndex, + nameTag = nameTag, + ) + } + } + }, + ) return true } @@ -648,31 +654,99 @@ open class KotlinInputAstVisitor( if (expression !is KtCallExpression) return false // A leading comment brings its own forced break, which throws off the indents below. if (expression.hasLeadingComment) return false + return emitCallAfterOperator( + expression.calleeExpression, + expression.typeArgumentList, + expression.valueArgumentList, + expression.lambdaArguments, + ) + } + + /** + * The parts-wise version of [emitCallAfterOperator], for callers that aren't a [KtExpression]. + */ + private fun emitCallAfterOperator( + callee: KtExpression?, + typeArgumentList: KtTypeArgumentList?, + argumentList: KtValueArgumentList?, + lambdaArguments: List, + ): Boolean { + if (!canEmitCallAfterOperator(callee, argumentList, lambdaArguments)) return false + callee as KtExpression + argumentList as KtValueArgumentList + + emitSplitAfterOperator( + emitHead = { visit(callee) }, + emitTail = { _, argumentsIndent -> + builder.block(argumentsIndent) { + builder.block(ZERO) { visit(typeArgumentList) } + visitValueArgumentListInternal(argumentList) + } + }, + ) + return true + } + + /** + * Whether [emitCallAfterOperator] can lay out these call parts, so that callers can tell before + * they [OpsBuilder.sync] past anything. + */ + private fun canEmitCallAfterOperator( + callee: KtExpression?, + argumentList: KtValueArgumentList?, + lambdaArguments: List, + ): Boolean { // A trailing lambda is laid out by visitCallElement, which indents the callee along with it. - if (expression.lambdaArguments.isNotEmpty()) return false - val callee = expression.calleeExpression ?: return false - val argumentList = expression.valueArgumentList ?: return false + if (lambdaArguments.isNotEmpty()) return false + if (callee == null) return false // Without arguments there is nothing to break at, so keeping the callee here buys nothing. - if (argumentList.hasEmptyParens()) return false + return argumentList != null && !argumentList.hasEmptyParens() + } - val brokeAfterOperator = BreakTag() - // The callee, preceded by the only break in this level: it is taken exactly when the callee - // doesn't fit on the current line. The callee's own contents indent one more level when that - // happens, since the callee then starts a line of its own. - builder.block(expressionBreakIndent) { - builder.breakOp(" ", ZERO, Optional.of(brokeAfterOperator)) - builder.block(Indent.If.make(brokeAfterOperator, expressionBreakIndent, ZERO)) { - visit(callee) - } + /** + * Lays out a supertype list whose first entry is a constructor call on the class header line, so + * that only the call's arguments break and any remaining supertypes trail its closing paren: + * ``` + * object ClicsArchiveCommand : DumpFileCommand( + * name = "clics-archive", + * ), Runnable, Closeable {} + * ``` + * + * Returns false, having emitted nothing, when the first supertype isn't a constructor call with + * arguments to break at -- the list then gets one entry per line instead, which is the caller's + * default layout. + */ + private fun emitSuperTypeCallAfterColon(list: KtSuperTypeList): Boolean { + if (forceLineBreakAfterSupertypeColon) return false + val entries = list.entries + val call = entries.firstOrNull() as? KtSuperTypeCallEntry ?: return false + // A leading comment brings its own forced break, which throws off the indents below. + if (call.hasLeadingComment) return false + if ( + !canEmitCallAfterOperator( + call.calleeExpression, + call.valueArgumentList, + call.lambdaArguments, + ) + ) { + return false } - // The arguments, in a level of their own so that they can still share a line with the callee - // when they fit. This level is entered after the break above was decided, so its indent can - // depend on it. - val argumentsIndent = - Indent.If.make(brokeAfterOperator, doubleExpressionBreakIndent, expressionBreakIndent) - builder.block(argumentsIndent) { - builder.block(ZERO) { visit(expression.typeArgumentList) } - visitValueArgumentListInternal(argumentList) + + builder.sync(list) + builder.sync(call) + // The type arguments are part of the constructor callee, as in visitSuperTypeCallEntry. + emitCallAfterOperator(call.calleeExpression, null, call.valueArgumentList, call.lambdaArguments) + + // The remaining supertypes trail the call's closing paren, sharing its line when they fit and + // otherwise taking one line each. + if (entries.size > 1) { + builder.block(expressionBreakIndent) { + for (entry in entries.drop(1)) { + builder.token(",") + builder.breakOp(" ") + visit(entry) + } + } } return true } @@ -682,11 +756,8 @@ open class KotlinInputAstVisitor( * `a.b[2]` in `a.b[2].c.d()` -- or null when every group opened in the chain is still open by the * time the last part is emitted, so there is no point at which the chain can be split in two. */ - private fun receiverSegmentEnd( - parts: List, - groupingInfos: List, - useBlockLikeLambdaStyle: Boolean, - ): Int? { + private fun receiverSegmentEnd(chain: ChainLayout): Int? { + val (parts, useBlockLikeLambdaStyle, groupingInfos) = chain // The group of a block-like trailing lambda is opened at the root and closed at the very last // part, so it always spans the whole chain. if (useBlockLikeLambdaStyle) return null @@ -703,12 +774,11 @@ open class KotlinInputAstVisitor( /** Emits [range] of the parts of a chain, see [emitQualifiedExpression]. */ private fun emitQualifiedExpressionParts( - parts: List, - groupingInfos: List, - useBlockLikeLambdaStyle: Boolean, + chain: ChainLayout, range: IntRange, nameTag: BreakTag, ) { + val (parts, useBlockLikeLambdaStyle, groupingInfos) = chain for (index in range) { val ktExpression = parts[index] if (ktExpression is KtQualifiedExpression) { @@ -992,7 +1062,8 @@ open class KotlinInputAstVisitor( if (isSingleUnnamedLambda) { wrapInBlock = true breakBeforePostfix = false - leadingBreak = !hasEmptyParens && hasTrailingComma + // The lambda itself sits between the parens, so they are never empty here. + leadingBreak = hasTrailingComma breakAfterPrefix = false } else { // A call without a trailing comma that is nonetheless forced onto multiple lines (because one @@ -1125,15 +1196,11 @@ open class KotlinInputAstVisitor( builder.breakOpThenBlock(bracePlusBlockIndent) { builder.fenceComments() builder.blankLineWanted(BlankLineWanted.NO) - if (blockComments.size == 1) { - builder.token(blockComments[0].text) - } else { - for ((i, comment) in blockComments.withIndex()) { - if (i > 0) { - builder.forcedBreak() - } - builder.token(comment.text) + for ((i, comment) in blockComments.withIndex()) { + if (i > 0) { + builder.forcedBreak() } + builder.token(comment.text) } builder.breakOp(" ", bracePlusZeroIndent) } @@ -1219,9 +1286,14 @@ open class KotlinInputAstVisitor( * ``` * * @param wrapInBlock if true, place all the elements in a block. When there's no [leadingBreak], - * this will be negatively indented. Note that the [prefix] and [postfix] aren't included in the - * block. + * this will be negatively indented unless [compensateMissingLeadingBreak] says otherwise. Note + * that the [prefix] and [postfix] aren't included in the block. * @param leadingBreak if true, break before the first element. + * @param compensateMissingLeadingBreak if true, and there is no [leadingBreak], pull the block + * back by one level. Callers that emit a [prefix] are indented on the assumption that the + * leading break fires, so without it the elements have to be pulled back to meet the prefix. + * Callers that instead place the first element with a break of their own -- such as the `:` of + * a supertype list -- are already at the right level and pass false. * @param prefix if provided, emit this before the first element. * @param postfix if provided, emit this after the last element (or trailing comma). * @param breakAfterPrefix if true, emit a break after [prefix], but before the start of the @@ -1264,6 +1336,7 @@ open class KotlinInputAstVisitor( hasTrailingComma: Boolean = false, wrapInBlock: Boolean = true, leadingBreak: Boolean = true, + compensateMissingLeadingBreak: Boolean = true, prefix: String? = null, postfix: String? = null, breakAfterPrefix: Boolean = true, @@ -1285,16 +1358,15 @@ open class KotlinInputAstVisitor( builder.breakOp(breakType, " ", ZERO) } - val indent = if (leadingBreak) ZERO else expressionBreakNegativeIndent + val indent = + if (leadingBreak || !compensateMissingLeadingBreak) ZERO else expressionBreakNegativeIndent builder.block(indent, isEnabled = wrapInBlock) { if (leadingBreak) { builder.breakOp(breakType, "", ZERO) } - var first = true - for (value in list) { - if (!first) emitComma() - first = false + for ((index, value) in list.withIndex()) { + if (index > 0) emitComma() visit(value) } @@ -1522,7 +1594,7 @@ open class KotlinInputAstVisitor( delegate: KtPropertyDelegate? = null, accessors: List? = null, backingField: KtBackingField? = null, - ): Int { + ) { val verticalAnnotationBreak = BreakTag() if (isField) { builder.blankLineWanted(BlankLineWanted.conditional(verticalAnnotationBreak)) @@ -1572,17 +1644,18 @@ open class KotlinInputAstVisitor( if (delegate != null) { builder.spaceThenToken("by") val delegateExpr = delegate.expression - if (delegateExpr.isLambdaOrScopingFunction) { - builder.space() - visit(delegate) - } else if (delegateExpr != null && delegateExpr.isChainedScopingFunction) { - visitChainedScopingFunction(delegateExpr, emitLeadingBreak = true) - } else if (delegateExpr.isBlockLikeCall) { - builder.space() - visit(delegate) - } else if (delegateExpr != null && delegateExpr.isChainedBlockLikeCall) { - visitChainedBlockLikeCall(delegateExpr, emitLeadingBreak = true) - } else { + val laidOut = + delegateExpr != null && + emitExpressionAfterOperator( + delegateExpr, + scopingFunctionHugs = true, + // The delegate node carries the expression; visiting it keeps the `by` intact. + emitHugged = { + builder.space() + visit(delegate) + }, + ) + if (!laidOut) { builder.breakOpThenBlock(" ", expressionBreakIndent) { builder.fenceComments() visit(delegate) @@ -1593,15 +1666,8 @@ open class KotlinInputAstVisitor( } } // for example `field = value`, `private set`, or `get = 2 * field` - val propertyComponents = buildList { - if (backingField != null) { - add(backingField) - } - if (accessors != null) { - addAll(accessors) - } - } - .sortedBy { it.startOffset } + val propertyComponents = + (listOfNotNull(backingField) + accessors.orEmpty()).sortedBy { it.startOffset } if (propertyComponents.isNotEmpty()) { builder.block(blockIndent) { for (component in propertyComponents) { @@ -1619,7 +1685,7 @@ open class KotlinInputAstVisitor( typeParameters = null, receiverTypeReference = null, name = null, - parameterList = getParameterListWithBugFixes(component), + parameterList = component.parameterList, typeConstraintList = null, bodyExpression = component.bodyBlockExpression ?: component.bodyExpression, typeOrDelegationCall = component.returnTypeReference, @@ -1638,8 +1704,48 @@ open class KotlinInputAstVisitor( if (isField) { builder.blankLineWanted(BlankLineWanted.conditional(verticalAnnotationBreak)) } + } - return 0 + /** + * Lays out the right-hand side of an operator that a declaration is assigned across -- the `=` of + * an initializer or an expression body, or the `by` of a property delegate -- according to the + * kind of expression it is. + * + * Lambdas, scoping functions and block-like calls keep the operator's line instead of breaking + * after it, and in styles that hug them, so do `when` expressions and block-like infix calls. + * + * Returns false, having emitted nothing, when [expression] is not one of those shapes. Callers + * then emit their own default layout, which differs between them. + * + * @param scopingFunctionHugs whether a lambda or scoping function is emitted by [emitHugged] + * instead of by [visitLambdaOrScopingFunction]. The `by` of a delegate is already followed by + * its expression on the same line, so its lambda hugs it rather than breaking to it. + * @param emitHugged emits [expression] on the operator's line, after a plain space. Callers whose + * expression is wrapped in another PSI node -- the `by` of a delegate -- visit that node here. + */ + private fun emitExpressionAfterOperator( + expression: KtExpression, + scopingFunctionHugs: Boolean = false, + emitHugged: () -> Unit = { + builder.space() + visit(expression) + }, + ): Boolean { + when { + expression.isLambdaOrScopingFunction -> + if (scopingFunctionHugs) emitHugged() else visitLambdaOrScopingFunction(expression) + expression.isChainedScopingFunction -> + visitChainedScopingFunction(expression, emitLeadingBreak = true) + expression.isBlockLikeCall -> emitHugged() + expression.isChainedBlockLikeCall -> + visitChainedBlockLikeCall(expression, emitLeadingBreak = true) + hugBlockLikeInfixCalls && expression.isInfixBlockLikeCall -> + emitInfixBlockLikeCall(expression) + hugWhenExpressions && expression is KtWhenExpression && !expression.hasLeadingComment -> + emitWhenExpressionAfterOperator(expression) + else -> return false + } + return true } /** @@ -1647,34 +1753,20 @@ open class KotlinInputAstVisitor( */ private fun emitInitializer(initializer: KtExpression) { builder.spaceThenToken("=") - if (initializer.isLambdaOrScopingFunction) { - visitLambdaOrScopingFunction(initializer) - } else if (initializer.isChainedScopingFunction) { - visitChainedScopingFunction(initializer, emitLeadingBreak = true) - } else if (initializer.isBlockLikeCall) { - builder.space() - visit(initializer) - } else if (initializer.isChainedBlockLikeCall) { - visitChainedBlockLikeCall(initializer, emitLeadingBreak = true) - } else if (hugBlockLikeInfixCalls && initializer.isInfixBlockLikeCall) { - emitInfixBlockLikeCall(initializer) - } else if ( - hugWhenExpressions && initializer is KtWhenExpression && !initializer.hasLeadingComment - ) { - emitWhenExpressionAfterOperator(initializer) - } else { - // A chain gets to keep its receiver on the `=` line when it fits there; everything else - // breaks after the `=` and is laid out one level in. - val laidOutAsChain = - !forceLineBreakAfterAssignment && - initializer.isPlainQualifiedChain && - !initializer.hasLeadingComment && - emitQualifiedExpressionAfterOperator(initializer) - if (!laidOutAsChain) { - builder.breakOpThenBlock(" ", expressionBreakIndent) { - builder.fenceComments() - visit(initializer) - } + if (emitExpressionAfterOperator(initializer)) { + return + } + // A chain gets to keep its receiver on the `=` line when it fits there; everything else + // breaks after the `=` and is laid out one level in. + val laidOutAsChain = + !forceLineBreakAfterAssignment && + initializer.isPlainQualifiedChain && + !initializer.hasLeadingComment && + emitQualifiedExpressionAfterOperator(initializer) + if (!laidOutAsChain) { + builder.breakOpThenBlock(" ", expressionBreakIndent) { + builder.fenceComments() + visit(initializer) } } } @@ -1700,35 +1792,6 @@ open class KotlinInputAstVisitor( } } - // Bug in Kotlin 1.9.10: KtPropertyAccessor is the direct parent of the left and right paren - // elements. Also parameterList is always null for getters. As a workaround, we create our own - // fake KtParameterList. - // TODO: won't need this after https://youtrack.jetbrains.com/issue/KT-70922 - private fun getParameterListWithBugFixes(accessor: KtPropertyAccessor): KtParameterList? { - if (accessor.bodyExpression == null && accessor.bodyBlockExpression == null) return null - - val stub = accessor.stub ?: PsiFileStubImpl(accessor.containingFile) - - return object : - KtParameterList(KotlinPlaceHolderStubImpl(stub, KtStubElementTypes.VALUE_PARAMETER_LIST)) { - override fun getParameters(): List { - return accessor.valueParameters - } - - override fun getTrailingComma(): PsiElement? { - return accessor.parameterList?.trailingComma - } - - override fun getLeftParenthesis(): PsiElement? { - return accessor.parameterList?.leftParenthesis - } - - override fun getRightParenthesis(): PsiElement? { - return accessor.parameterList?.rightParenthesis - } - } - } - /** * Emit an `a to Foo(\n ...,\n)` style infix call that follows an operator such as `=`, deciding * whether to break after that operator from the width of the head alone. @@ -1737,33 +1800,31 @@ open class KotlinInputAstVisitor( builder.sync(expression) val right = checkNotNull(expression.right) val call = checkNotNull(right.callExpression) - val brokeAfterOperator = BreakTag() - val callIndent = Indent.If.make(brokeAfterOperator, expressionBreakIndent, ZERO) - builder.block(expressionBreakIndent) { - builder.breakOp(" ", ZERO, Optional.of(brokeAfterOperator)) - builder.block(callIndent) { - builder.fenceComments() - visit(expression.left) - builder.spaceThenToken(expression.operationReference.text) - builder.space() - // The call may be the selector of a qualifier, as in `a to Organizations.Override(...)`. - if (right is KtQualifiedExpression) { - visit(right.receiverExpression) - builder.token(right.operationSign.value) - } - builder.sync(call) - visit(call.calleeExpression) - builder.block(ZERO) { visit(call.typeArgumentList) } - } - } - // Emitted after the level above closed, so that its forced breaks are not part of the split the - // break after the operator is decided by. The extra level carries the head's indent over. - builder.block(callIndent) { - builder.block(expressionBreakIndent) { - visitValueArgumentListInternal(checkNotNull(call.valueArgumentList)) - } - } + emitSplitAfterOperator( + emitHead = { + builder.fenceComments() + visit(expression.left) + builder.spaceThenToken(expression.operationReference.text) + builder.space() + // The call may be the selector of a qualifier, as in `a to Organizations.Override(...)`. + if (right is KtQualifiedExpression) { + visit(right.receiverExpression) + builder.token(right.operationSign.value) + } + builder.sync(call) + visit(call.calleeExpression) + builder.block(ZERO) { visit(call.typeArgumentList) } + }, + // The extra level carries the head's indent over to the arguments. + emitTail = { callIndent, _ -> + builder.block(callIndent) { + builder.block(expressionBreakIndent) { + visitValueArgumentListInternal(checkNotNull(call.valueArgumentList)) + } + } + }, + ) } /** @@ -1785,17 +1846,14 @@ open class KotlinInputAstVisitor( */ private fun emitWhenExpressionAfterOperator(expression: KtWhenExpression) { builder.sync(expression) - val brokeAfterOperator = BreakTag() - val bodyIndent = Indent.If.make(brokeAfterOperator, expressionBreakIndent, ZERO) - - builder.block(expressionBreakIndent) { - builder.breakOp(" ", ZERO, Optional.of(brokeAfterOperator)) - builder.block(bodyIndent) { - builder.fenceComments() - emitWhenHead(expression) - } - } - builder.block(bodyIndent) { emitWhenBody(expression) } + emitSplitAfterOperator( + emitHead = { + builder.fenceComments() + emitWhenHead(expression) + }, + // The body's braces align with the `when`, so it is laid out at the head's own indent. + emitTail = { bodyIndent, _ -> builder.block(bodyIndent) { emitWhenBody(expression) } }, + ) } /** @@ -1850,13 +1908,11 @@ open class KotlinInputAstVisitor( /** * Returns true when any chained selector after the innermost scoping-function receiver carries - * value arguments (i.e. `.foo(a)` or `.fold({ ... }, { ... })`). Used to decide formatting style - * for property initializers: value-arg chains stay on same line as `=`, while no-arg chains - * break. + * value arguments (i.e. `.foo(a)` or `.fold({ ... }, { ... })`). * - * Chains that pass regular value arguments are excluded from special chained handling in - * qualified expressions, since those are better served by the general qualified-expression - * layout, except in property initializer context where we handle them specially. + * Such chains are excluded from the special scoping-function chain layout in + * [visitQualifiedExpression], since they are better served by the general qualified-expression + * layout. */ private fun chainedSelectorsHaveValueArguments(expression: KtExpression): Boolean { var current: KtExpression = expression @@ -1947,8 +2003,11 @@ open class KotlinInputAstVisitor( builder.space() builder.block(ZERO) { builder.token(":") - builder.breakOp(" ", expressionBreakIndent) - visit(superTypes) + // A lone supertype constructor call keeps the header line, breaking only its arguments. + if (!emitSuperTypeCallAfterColon(superTypes)) { + builder.breakOp(" ", expressionBreakIndent) + visit(superTypes) + } } } val typeConstraintList = classOrObject.typeConstraintList @@ -2049,11 +2108,8 @@ open class KotlinInputAstVisitor( return } builder.tokenThenSpace("package") - var first = true - for (packageName in directive.packageNames) { - if (first) { - first = false - } else { + for ((index, packageName) in directive.packageNames.withIndex()) { + if (index > 0) { builder.token(".") } builder.token(packageName.getIdentifier()?.text ?: packageName.getReferencedName()) @@ -2198,7 +2254,7 @@ open class KotlinInputAstVisitor( else -> builder.breakOp(" ") } - visit(expression.baseExpression) + visit(baseExpression) } } @@ -2221,14 +2277,11 @@ open class KotlinInputAstVisitor( builder.token("[") builder.block(ZERO) { - var first = true builder.breakOp() - for (value in annotation.entries) { - if (!first) { + for ((index, value) in annotation.entries.withIndex()) { + if (index > 0) { builder.breakOp(" ") } - first = false - visit(value) } } @@ -2271,6 +2324,9 @@ open class KotlinInputAstVisitor( data: Void?, ): Void? { for (child in fileAnnotationList.node.children()) { + // Leaf nodes -- whitespace and the tokens of the annotations themselves -- implement both + // ASTNode and PsiElement, while composite nodes do not. This skips the leaves, leaving the + // annotation entries to be visited. if (child is PsiElement) { continue } @@ -2283,7 +2339,13 @@ open class KotlinInputAstVisitor( override fun visitSuperTypeList(list: KtSuperTypeList) { builder.sync(list) - builder.block(expressionBreakIndent) { visitEachCommaSeparated(list.entries) } + builder.block(expressionBreakIndent) { + visitEachCommaSeparated( + list.entries, + leadingBreak = forceLineBreakAfterSupertypeColon, + compensateMissingLeadingBreak = false, + ) + } } override fun visitSuperTypeCallEntry(call: KtSuperTypeCallEntry) { @@ -2331,10 +2393,10 @@ open class KotlinInputAstVisitor( builder.token("else") } else { val conditions = whenEntry.conditions - for ((index, condition) in conditions.withIndex()) { + for ((conditionIndex, condition) in conditions.withIndex()) { visit(condition) builder.guessToken(",") - if (index != conditions.lastIndex) { + if (conditionIndex != conditions.lastIndex) { builder.forcedBreak() } } @@ -2396,7 +2458,7 @@ open class KotlinInputAstVisitor( } } else { val parent = body.parent - if (parent is KtClass && parent.isEnum() && children.isNotEmpty()) { + if (parent is KtClass && parent.isEnum()) { builder.token(";") builder.forcedBreak() } @@ -2498,21 +2560,35 @@ open class KotlinInputAstVisitor( } /** - * Example `[3]` in `a[3]` or `a[3].b` Separated since it needs to be used from a top level array - * expression (`a[3]`) and from within a qualified chain (`a[3].b) + * Emits a comma-separated list wrapped in delimiters, with the closing one outside the level the + * elements are in so that it returns to the surrounding indent. */ - private fun visitArrayAccessBrackets(expression: KtArrayAccessExpression) { + private fun emitDelimitedList( + elements: Iterable, + hasTrailingComma: Boolean, + openingDelimiter: String, + closingDelimiter: String, + ) { builder.block(ZERO) { - builder.token("[") + builder.token(openingDelimiter) builder.breakOpThenBlock(expressionBreakIndent) { - visitEachCommaSeparated( - expression.indexExpressions, - expression.trailingComma != null, - wrapInBlock = true, - ) + visitEachCommaSeparated(elements, hasTrailingComma, wrapInBlock = true) } } - builder.token("]") + builder.token(closingDelimiter) + } + + /** + * Example `[3]` in `a[3]` or `a[3].b` Separated since it needs to be used from a top level array + * expression (`a[3]`) and from within a qualified chain (`a[3].b) + */ + private fun visitArrayAccessBrackets(expression: KtArrayAccessExpression) { + emitDelimitedList( + expression.indexExpressions, + expression.trailingComma != null, + openingDelimiter = "[", + closingDelimiter = "]", + ) } /** Example `val (a, b: Int) = Pair(1, 2)` or `val [a, b] = Pair(1, 2)` */ @@ -2523,19 +2599,12 @@ open class KotlinInputAstVisitor( builder.tokenThenSpace(valOrVarKeyword.text) } val hasTrailingComma = destructuringDeclaration.trailingComma != null - val openingDelimiter = destructuringDeclaration.lPar?.text ?: "(" - val closingDelimiter = destructuringDeclaration.rPar?.text ?: ")" - builder.block(ZERO) { - builder.token(openingDelimiter) - builder.breakOpThenBlock(expressionBreakIndent) { - visitEachCommaSeparated( - destructuringDeclaration.entries, - hasTrailingComma, - wrapInBlock = true, - ) - } - } - builder.token(closingDelimiter) + emitDelimitedList( + destructuringDeclaration.entries, + hasTrailingComma, + openingDelimiter = destructuringDeclaration.lPar?.text ?: "(", + closingDelimiter = destructuringDeclaration.rPar?.text ?: ")", + ) val initializer = destructuringDeclaration.initializer if (initializer != null) { builder.spaceThenToken("=") @@ -2771,39 +2840,58 @@ open class KotlinInputAstVisitor( builder.block(expressionBreakIndent) { visit(type.returnTypeReference) } } + /** + * Emits an operation whose right-hand side is a type -- `a is Int`, `a as Int` -- as a single + * group, so that a break lands before the operator rather than inside the left-hand side. + * + * A qualified left-hand side lays out its own chain, so the group opens after it; anything else + * is enclosed by the group. + * + * @param emitSeparator emits what goes between the left-hand side and the operator + */ + private fun emitTypeOperation( + left: KtExpression?, + operationReference: PsiElement, + right: PsiElement?, + emitSeparator: () -> Unit, + ) { + val openGroupBeforeLeft = left !is KtQualifiedExpression + if (openGroupBeforeLeft) builder.open(ZERO) + visit(left) + if (!openGroupBeforeLeft) builder.open(ZERO) + emitSeparator() + visit(operationReference) + builder.breakToFillThenBlock(" ", expressionBreakIndent) { visit(right) } + builder.close() + } + /** Example `a is Int` or `b !is Int` */ override fun visitIsExpression(expression: KtIsExpression) { builder.sync(expression) - val openGroupBeforeLeft = expression.leftHandSide !is KtQualifiedExpression - if (openGroupBeforeLeft) builder.open(ZERO) - visit(expression.leftHandSide) - if (!openGroupBeforeLeft) builder.open(ZERO) - val parent = expression.parent - if ( - parent is KtValueArgument || - parent is KtParenthesizedExpression || - parent is KtContainerNode + emitTypeOperation( + expression.leftHandSide, + expression.operationReference, + expression.typeReference, ) { - builder.breakOp(" ", expressionBreakIndent) - } else { - builder.space() + val parent = expression.parent + if ( + parent is KtValueArgument || + parent is KtParenthesizedExpression || + parent is KtContainerNode + ) { + builder.breakOp(" ", expressionBreakIndent) + } else { + builder.space() + } } - visit(expression.operationReference) - builder.breakToFillThenBlock(" ", expressionBreakIndent) { visit(expression.typeReference) } - builder.close() } /** Example `a as Int` or `a as? Int` */ override fun visitBinaryWithTypeRHSExpression(expression: KtBinaryExpressionWithTypeRHS) { builder.sync(expression) - val openGroupBeforeLeft = expression.left !is KtQualifiedExpression - if (openGroupBeforeLeft) builder.open(ZERO) - visit(expression.left) - if (!openGroupBeforeLeft) builder.open(ZERO) - builder.breakOp(" ", expressionBreakIndent) - visit(expression.operationReference) - builder.breakToFillThenBlock(" ", expressionBreakIndent) { visit(expression.right) } - builder.close() + emitTypeOperation(expression.left, expression.operationReference, expression.right) { + builder.breakOp(" ", expressionBreakIndent) + } } /** @@ -2921,7 +3009,7 @@ open class KotlinInputAstVisitor( override fun visitKtFile(file: KtFile) { markForPartialFormat() - val importListEmpty = file.importList?.text?.isBlank() ?: true + val importListEmpty = file.importList?.text.isNullOrBlank() var isFirst = true for (child in file.children) { diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt index da3ffd7f8..b374cfdab 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt @@ -9,6 +9,7 @@ internal class KotlinLangInputAstVisitor( override val forceAnnotationBreaks: Boolean = true override val forceLineBreakAfterAssignment: Boolean = false override val forceLineBreakAfterNamedParameter: Boolean = false + override val forceLineBreakAfterSupertypeColon: Boolean = false override val hugBlockLikeInfixCalls: Boolean = true override val hugWhenExpressions: Boolean = true override val indentBooleanConditions: Boolean = false diff --git a/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.input b/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.input new file mode 100644 index 000000000..351f71de7 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.input @@ -0,0 +1,28 @@ +class Foo { + fun affectedTeams(event: TeamEvent) = when (event) { + is TeamUpdate -> emptyList() + is TeamDelete -> listOf(event.teamId) + else -> error("unexpected") + } + + val affectedTeamsProperty = when (event) { + is TeamUpdate -> emptyList() + is TeamDelete -> listOf(event.teamId) + else -> error("unexpected") + } + + val overrides get() = when (event) { + is TeamUpdate -> emptyList() + else -> error("unexpected") + } + + fun entry() = "clics" to DumpFileCommand( + name = "clics-archive", + waitTime = waitTime, + ) + + val entryProperty = "clics" to DumpFileCommand( + name = "clics-archive", + waitTime = waitTime, + ) +} diff --git a/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.new.output b/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.new.output new file mode 100644 index 000000000..1746c7376 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.new.output @@ -0,0 +1,29 @@ +class Foo { + fun affectedTeams(event: TeamEvent) = when (event) { + is TeamUpdate -> emptyList() + is TeamDelete -> listOf(event.teamId) + else -> error("unexpected") + } + + val affectedTeamsProperty = when (event) { + is TeamUpdate -> emptyList() + is TeamDelete -> listOf(event.teamId) + else -> error("unexpected") + } + + val overrides + get() = when (event) { + is TeamUpdate -> emptyList() + else -> error("unexpected") + } + + fun entry() = "clics" to DumpFileCommand( + name = "clics-archive", + waitTime = waitTime, + ) + + val entryProperty = "clics" to DumpFileCommand( + name = "clics-archive", + waitTime = waitTime, + ) +} diff --git a/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.output b/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.output new file mode 100644 index 000000000..1adc22630 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.output @@ -0,0 +1,36 @@ +class Foo { + fun affectedTeams(event: TeamEvent) = + when (event) { + is TeamUpdate -> emptyList() + is TeamDelete -> listOf(event.teamId) + else -> error("unexpected") + } + + val affectedTeamsProperty = + when (event) { + is TeamUpdate -> emptyList() + is TeamDelete -> listOf(event.teamId) + else -> error("unexpected") + } + + val overrides + get() = + when (event) { + is TeamUpdate -> emptyList() + else -> error("unexpected") + } + + fun entry() = + "clics" to + DumpFileCommand( + name = "clics-archive", + waitTime = waitTime, + ) + + val entryProperty = + "clics" to + DumpFileCommand( + name = "clics-archive", + waitTime = waitTime, + ) +} diff --git a/core/src/test/resources/cases/new_codestyle/Interfaces.input b/core/src/test/resources/cases/new_codestyle/Interfaces.input new file mode 100644 index 000000000..a1c567aec --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/Interfaces.input @@ -0,0 +1,7 @@ +object ClicsArchiveCommand : + Runnable, + Closeable, + KoinComponent, + AutoCloseable, + Comparable, + Iterable {} diff --git a/core/src/test/resources/cases/new_codestyle/Interfaces.new.output b/core/src/test/resources/cases/new_codestyle/Interfaces.new.output new file mode 100644 index 000000000..a1c567aec --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/Interfaces.new.output @@ -0,0 +1,7 @@ +object ClicsArchiveCommand : + Runnable, + Closeable, + KoinComponent, + AutoCloseable, + Comparable, + Iterable {} diff --git a/core/src/test/resources/cases/new_codestyle/Interfaces.output b/core/src/test/resources/cases/new_codestyle/Interfaces.output new file mode 100644 index 000000000..a1c567aec --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/Interfaces.output @@ -0,0 +1,7 @@ +object ClicsArchiveCommand : + Runnable, + Closeable, + KoinComponent, + AutoCloseable, + Comparable, + Iterable {} diff --git a/core/src/test/resources/cases/new_codestyle/Supertype.input b/core/src/test/resources/cases/new_codestyle/Supertype.input new file mode 100644 index 000000000..c3f0563f0 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/Supertype.input @@ -0,0 +1,7 @@ +object ClicsArchiveCommand : + DumpFileCommand( + name = "clics-archive", + help = "Dump CLICS contest archive (zip)", + defaultFileName = "contest-archive.zip", + outputHelp = "Path to new zip file", + ) {} \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/Supertype.new.output b/core/src/test/resources/cases/new_codestyle/Supertype.new.output new file mode 100644 index 000000000..8b035674d --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/Supertype.new.output @@ -0,0 +1,6 @@ +object ClicsArchiveCommand : DumpFileCommand( + name = "clics-archive", + help = "Dump CLICS contest archive (zip)", + defaultFileName = "contest-archive.zip", + outputHelp = "Path to new zip file", +) {} diff --git a/core/src/test/resources/cases/new_codestyle/Supertype.output b/core/src/test/resources/cases/new_codestyle/Supertype.output new file mode 100644 index 000000000..c2e1d0481 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/Supertype.output @@ -0,0 +1,7 @@ +object ClicsArchiveCommand : + DumpFileCommand( + name = "clics-archive", + help = "Dump CLICS contest archive (zip)", + defaultFileName = "contest-archive.zip", + outputHelp = "Path to new zip file", + ) {} diff --git a/core/src/test/resources/cases/new_codestyle/SupertypeAndInterfaces.input b/core/src/test/resources/cases/new_codestyle/SupertypeAndInterfaces.input new file mode 100644 index 000000000..f4076fcba --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/SupertypeAndInterfaces.input @@ -0,0 +1,7 @@ +object ClicsArchiveCommand : + DumpFileCommand( + name = "clics-archive", + help = "Dump CLICS contest archive (zip)", + defaultFileName = "contest-archive.zip", + outputHelp = "Path to new zip file", + ), Runnable, Closeable, KoinComponent {} \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/SupertypeAndInterfaces.new.output b/core/src/test/resources/cases/new_codestyle/SupertypeAndInterfaces.new.output new file mode 100644 index 000000000..dbcc162fd --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/SupertypeAndInterfaces.new.output @@ -0,0 +1,6 @@ +object ClicsArchiveCommand : DumpFileCommand( + name = "clics-archive", + help = "Dump CLICS contest archive (zip)", + defaultFileName = "contest-archive.zip", + outputHelp = "Path to new zip file", +), Runnable, Closeable, KoinComponent {} diff --git a/core/src/test/resources/cases/new_codestyle/SupertypeAndInterfaces.output b/core/src/test/resources/cases/new_codestyle/SupertypeAndInterfaces.output new file mode 100644 index 000000000..2c384e9cc --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/SupertypeAndInterfaces.output @@ -0,0 +1,10 @@ +object ClicsArchiveCommand : + DumpFileCommand( + name = "clics-archive", + help = "Dump CLICS contest archive (zip)", + defaultFileName = "contest-archive.zip", + outputHelp = "Path to new zip file", + ), + Runnable, + Closeable, + KoinComponent {} From 7b862dbdca3c00101b11943763bf28694dafca98 Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Wed, 5 Aug 2026 16:15:55 +0200 Subject: [PATCH 08/22] [formatter] Don't force break before property accessors --- .../ktfmt/format/KotlinInputAstVisitor.kt | 3 ++- .../ktfmt/format/KotlinLangInputAstVisitor.kt | 1 + .../new_codestyle/PropertyAccessors.input | 18 ++++++++++++++++++ .../new_codestyle/PropertyAccessors.new.output | 15 +++++++++++++++ .../new_codestyle/PropertyAccessors.output | 18 ++++++++++++++++++ 5 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 core/src/test/resources/cases/new_codestyle/PropertyAccessors.input create mode 100644 core/src/test/resources/cases/new_codestyle/PropertyAccessors.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/PropertyAccessors.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index d07c50f25..628f3320e 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -142,6 +142,7 @@ open class KotlinInputAstVisitor( internal open val forceLineBreakAfterAssignment: Boolean = true internal open val forceLineBreakAfterNamedParameter: Boolean = true internal open val forceLineBreakAfterSupertypeColon: Boolean = true + internal open val forceLineBreakBeforeAccessors: Boolean = true internal open val hugBlockLikeInfixCalls: Boolean = false internal open val hugWhenExpressions: Boolean = false internal open val indentBooleanConditions: Boolean = true @@ -1671,7 +1672,7 @@ open class KotlinInputAstVisitor( if (propertyComponents.isNotEmpty()) { builder.block(blockIndent) { for (component in propertyComponents) { - builder.forcedBreak() + if (forceLineBreakBeforeAccessors) builder.forcedBreak() else builder.breakOp(" ") // The semicolon must come after the newline, or the output code will not parse. builder.guessSemicolon() diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt index b374cfdab..1bc0c15e1 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt @@ -10,6 +10,7 @@ internal class KotlinLangInputAstVisitor( override val forceLineBreakAfterAssignment: Boolean = false override val forceLineBreakAfterNamedParameter: Boolean = false override val forceLineBreakAfterSupertypeColon: Boolean = false + override val forceLineBreakBeforeAccessors: Boolean = false override val hugBlockLikeInfixCalls: Boolean = true override val hugWhenExpressions: Boolean = true override val indentBooleanConditions: Boolean = false diff --git a/core/src/test/resources/cases/new_codestyle/PropertyAccessors.input b/core/src/test/resources/cases/new_codestyle/PropertyAccessors.input new file mode 100644 index 000000000..de70e8049 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/PropertyAccessors.input @@ -0,0 +1,18 @@ +class Properties { + public final override val context: CoroutineContext = parentContext + this + + public override val coroutineContext: CoroutineContext + get() = context + + override val isActive: Boolean + get() = super.isActive + + val isStopped: Boolean + get() = super.isStopped + + public override val propertyWithAVeryVeryVeryVeryVeryVeryVeryVeryVeryLongName: CoroutineContext + get() = context + + public override val property: CoroutineContext + get() = aVeryVeryVeryVeryVeryVeryVeryVeryVeryLongInitializer +} \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/PropertyAccessors.new.output b/core/src/test/resources/cases/new_codestyle/PropertyAccessors.new.output new file mode 100644 index 000000000..a604fb074 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/PropertyAccessors.new.output @@ -0,0 +1,15 @@ +class Properties { + public final override val context: CoroutineContext = parentContext + this + + public override val coroutineContext: CoroutineContext get() = context + + override val isActive: Boolean get() = super.isActive + + val isStopped: Boolean get() = super.isStopped + + public override val propertyWithAVeryVeryVeryVeryVeryVeryVeryVeryVeryLongName: CoroutineContext + get() = context + + public override val property: CoroutineContext + get() = aVeryVeryVeryVeryVeryVeryVeryVeryVeryLongInitializer +} diff --git a/core/src/test/resources/cases/new_codestyle/PropertyAccessors.output b/core/src/test/resources/cases/new_codestyle/PropertyAccessors.output new file mode 100644 index 000000000..e87061bca --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/PropertyAccessors.output @@ -0,0 +1,18 @@ +class Properties { + public final override val context: CoroutineContext = parentContext + this + + public override val coroutineContext: CoroutineContext + get() = context + + override val isActive: Boolean + get() = super.isActive + + val isStopped: Boolean + get() = super.isStopped + + public override val propertyWithAVeryVeryVeryVeryVeryVeryVeryVeryVeryLongName: CoroutineContext + get() = context + + public override val property: CoroutineContext + get() = aVeryVeryVeryVeryVeryVeryVeryVeryVeryLongInitializer +} From 20937f0a0829dee174fa306781b86a6a7f38b134 Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Wed, 5 Aug 2026 16:36:30 +0200 Subject: [PATCH 09/22] [formatter] Don't force line break before object expression --- .../facebook/ktfmt/format/KotlinInputAstVisitor.kt | 2 ++ .../resources/cases/new_codestyle/InlineObject.input | 8 ++++++++ .../cases/new_codestyle/InlineObject.new.output | 11 +++++++++++ .../resources/cases/new_codestyle/InlineObject.output | 8 ++++++++ 4 files changed, 29 insertions(+) create mode 100644 core/src/test/resources/cases/new_codestyle/InlineObject.input create mode 100644 core/src/test/resources/cases/new_codestyle/InlineObject.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/InlineObject.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 628f3320e..5e4231e8c 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -86,6 +86,7 @@ import org.jetbrains.kotlin.psi.KtLambdaExpression import org.jetbrains.kotlin.psi.KtModifierList import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtNullableType +import org.jetbrains.kotlin.psi.KtObjectLiteralExpression import org.jetbrains.kotlin.psi.KtPackageDirective import org.jetbrains.kotlin.psi.KtParameter import org.jetbrains.kotlin.psi.KtParameterList @@ -1740,6 +1741,7 @@ open class KotlinInputAstVisitor( expression.isBlockLikeCall -> emitHugged() expression.isChainedBlockLikeCall -> visitChainedBlockLikeCall(expression, emitLeadingBreak = true) + expression is KtObjectLiteralExpression -> emitHugged() hugBlockLikeInfixCalls && expression.isInfixBlockLikeCall -> emitInfixBlockLikeCall(expression) hugWhenExpressions && expression is KtWhenExpression && !expression.hasLeadingComment -> diff --git a/core/src/test/resources/cases/new_codestyle/InlineObject.input b/core/src/test/resources/cases/new_codestyle/InlineObject.input new file mode 100644 index 000000000..4d84a8b9c --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/InlineObject.input @@ -0,0 +1,8 @@ +class Tests { + fun testNotifications() = runTest { + val coroutine = + object : AVeryVeryVeryVeryVeryVeryLongSupertype(coroutineContext, true, false) { + fun foo(): String = "bar" + } + } +} diff --git a/core/src/test/resources/cases/new_codestyle/InlineObject.new.output b/core/src/test/resources/cases/new_codestyle/InlineObject.new.output new file mode 100644 index 000000000..e56ecc7c1 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/InlineObject.new.output @@ -0,0 +1,11 @@ +class Tests { + fun testNotifications() = runTest { + val coroutine = object : AVeryVeryVeryVeryVeryVeryLongSupertype( + coroutineContext, + true, + false, + ) { + fun foo(): String = "bar" + } + } +} diff --git a/core/src/test/resources/cases/new_codestyle/InlineObject.output b/core/src/test/resources/cases/new_codestyle/InlineObject.output new file mode 100644 index 000000000..917433722 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/InlineObject.output @@ -0,0 +1,8 @@ +class Tests { + fun testNotifications() = runTest { + val coroutine = object : + AVeryVeryVeryVeryVeryVeryLongSupertype(coroutineContext, true, false) { + fun foo(): String = "bar" + } + } +} From 7978cb7f71846b794888c6993b704120c9c12d1f Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Wed, 5 Aug 2026 18:08:39 +0200 Subject: [PATCH 10/22] [formatter] Don't force line break after `=` --- .../ktfmt/format/KotlinInputAstVisitor.kt | 94 ++++++------------- .../ktfmt/format/KotlinLangInputAstVisitor.kt | 1 + .../com/facebook/ktfmt/format/PsiUtils.kt | 30 ++++++ .../new_codestyle/BlockLambdaAssignment.input | 5 + .../BlockLambdaAssignment.new.output | 4 + .../BlockLambdaAssignment.output | 5 + .../new_codestyle/ExpressionBodyHugging.input | 7 ++ .../ExpressionBodyHugging.new.output | 7 ++ .../ExpressionBodyHugging.output | 8 ++ .../cases/new_codestyle/InlineObject.output | 8 +- 10 files changed, 99 insertions(+), 70 deletions(-) create mode 100644 core/src/test/resources/cases/new_codestyle/BlockLambdaAssignment.input create mode 100644 core/src/test/resources/cases/new_codestyle/BlockLambdaAssignment.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/BlockLambdaAssignment.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 5e4231e8c..f1160647b 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -145,6 +145,7 @@ open class KotlinInputAstVisitor( internal open val forceLineBreakAfterSupertypeColon: Boolean = true internal open val forceLineBreakBeforeAccessors: Boolean = true internal open val hugBlockLikeInfixCalls: Boolean = false + internal open val hugCallsWithTrailingLambda: Boolean = false internal open val hugWhenExpressions: Boolean = false internal open val indentBooleanConditions: Boolean = true @@ -373,7 +374,10 @@ open class KotlinInputAstVisitor( builder.space() builder.block(ZERO) { builder.token("=") - if (!emitExpressionAfterOperator(bodyExpression)) { + if ( + !emitExpressionAfterOperator(bodyExpression) && + !emitChainAfterOperator(bodyExpression) + ) { builder.block(expressionBreakIndent) { builder.breakToFill(" ") builder.block(ZERO) { visit(bodyExpression) } @@ -542,25 +546,11 @@ open class KotlinInputAstVisitor( } /** - * Lays out something that follows an operator such as `=` in two parts, deciding whether to break - * after that operator from the width of the first part alone. - * - * The head goes into a level of its own, preceded by the only break in that level, so the break - * is taken exactly when the head doesn't fit on the current line. The head's own contents indent - * one more level when that happens, since the head then starts a line of its own. The tail is - * emitted after that level closed, so its forced breaks are not part of the split the break is - * decided by, and so its indent can depend on the decision: - * ``` - * val affected = when (event) { // the head fits: it stays on the `=` line - * is Update -> emptyList() - * } - * - * val affected: List = // the head doesn't: the break is taken, and the tail - * when (event) { // indents relative to the head - * is Update -> emptyList() - * } - * ``` + * Lays out expression that follows an operator such as `=` in two parts, deciding whether to + * break after an operator, of after the head of the expression. * + * @param [emitHead] emits a head of the operator, i.e. something that can fit into the same line + * as the operator; e.g. `when (a) {`, `if (cond) {`, etc. * @param emitTail emits the rest, given the indents to lay it out at. A tail that aligns with the * head -- the body of a `when` -- uses `headIndent`; one that continues the head's line -- an * argument list -- uses `tailIndent`, or `headIndent` plus a level of its own. @@ -583,23 +573,6 @@ open class KotlinInputAstVisitor( /** * Lays out a chain of qualified expressions that follows an operator such as `=`, deciding * whether to break after that operator from the width of the chain's receiver alone. - * - * [emitQualifiedExpression] puts the whole chain into a single level, so a break in front of it - * is taken whenever the *entire* chain doesn't fit -- even when only the selectors need to break. - * Here the receiver plays the head of [emitSplitAfterOperator] instead, so the break competes - * with the receiver and nothing else: - * ``` - * val testDataDir: Path = Path.of("") // receiver fits: it stays on the `=` line - * .resolve("tests") - * - * val testDataDir: Path = // receiver doesn't: the break is taken, and the - * Path.ofAVeryVeryLongName("") // selectors indent relative to the receiver - * .resolve("tests") - * ``` - * - * Returns false, having emitted nothing, when the chain's grouping spans the receiver and the - * selectors, so the two can't be put into separate levels. Callers fall back to breaking first - * and calling [emitQualifiedExpression]. */ private fun emitQualifiedExpressionAfterOperator(expression: KtExpression): Boolean { val chain = chainLayout(expression) @@ -629,28 +602,6 @@ open class KotlinInputAstVisitor( /** * Lays out a call that follows an operator such as the `=` of a named argument, deciding whether * to break after that operator from the width of the callee alone. - * - * The default layout puts the whole call into a single level, so a break in front of it is taken - * whenever the *entire* call doesn't fit -- even when only its arguments need to break. Here the - * callee goes into a level of its own instead, so the break competes with the callee and nothing - * else: - * ``` - * add( - * queue = OverrideQueue( // callee fits: it stays on the `=` line - * waitTime, - * ), - * ) - * - * add( - * queue = // callee doesn't: the break is taken, and the arguments - * AVeryVeryLongQueue( // indent relative to the callee - * waitTime, - * ), - * ) - * ``` - * - * Returns false, having emitted nothing, when the call has no arguments to break at or is a shape - * whose layout is decided elsewhere. Callers fall back to breaking first and visiting the call. */ private fun emitCallAfterOperator(expression: KtExpression?): Boolean { if (expression !is KtCallExpression) return false @@ -1714,7 +1665,8 @@ open class KotlinInputAstVisitor( * kind of expression it is. * * Lambdas, scoping functions and block-like calls keep the operator's line instead of breaking - * after it, and in styles that hug them, so do `when` expressions and block-like infix calls. + * after it, and in styles that hug them, so do `when` expressions, block-like infix calls and + * calls that carry a trailing lambda alongside their value arguments. * * Returns false, having emitted nothing, when [expression] is not one of those shapes. Callers * then emit their own default layout, which differs between them. @@ -1741,7 +1693,8 @@ open class KotlinInputAstVisitor( expression.isBlockLikeCall -> emitHugged() expression.isChainedBlockLikeCall -> visitChainedBlockLikeCall(expression, emitLeadingBreak = true) - expression is KtObjectLiteralExpression -> emitHugged() + !forceLineBreakAfterAssignment && expression is KtObjectLiteralExpression -> emitHugged() + hugCallsWithTrailingLambda && expression.isCallWithTrailingLambda -> emitHugged() hugBlockLikeInfixCalls && expression.isInfixBlockLikeCall -> emitInfixBlockLikeCall(expression) hugWhenExpressions && expression is KtWhenExpression && !expression.hasLeadingComment -> @@ -1751,6 +1704,20 @@ open class KotlinInputAstVisitor( return true } + /** + * Lays out [expression] as a chain that keeps the receiver on the line of the operator it follows + * -- see [emitQualifiedExpressionAfterOperator] -- in the styles that allow it. + * + * Returns false, having emitted nothing, when [expression] is not a chain, when the style breaks + * after the operator unconditionally, or when the chain can't be split that way. Callers then + * emit their own default layout. + */ + private fun emitChainAfterOperator(expression: KtExpression): Boolean = + !forceLineBreakAfterAssignment && + expression.isPlainQualifiedChain && + !expression.hasLeadingComment && + emitQualifiedExpressionAfterOperator(expression) + /** * Emits `= `, laying the initializer out according to the kind of expression it is. */ @@ -1761,12 +1728,7 @@ open class KotlinInputAstVisitor( } // A chain gets to keep its receiver on the `=` line when it fits there; everything else // breaks after the `=` and is laid out one level in. - val laidOutAsChain = - !forceLineBreakAfterAssignment && - initializer.isPlainQualifiedChain && - !initializer.hasLeadingComment && - emitQualifiedExpressionAfterOperator(initializer) - if (!laidOutAsChain) { + if (!emitChainAfterOperator(initializer)) { builder.breakOpThenBlock(" ", expressionBreakIndent) { builder.fenceComments() visit(initializer) diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt index 1bc0c15e1..d27003982 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt @@ -12,6 +12,7 @@ internal class KotlinLangInputAstVisitor( override val forceLineBreakAfterSupertypeColon: Boolean = false override val forceLineBreakBeforeAccessors: Boolean = false override val hugBlockLikeInfixCalls: Boolean = true + override val hugCallsWithTrailingLambda: Boolean = true override val hugWhenExpressions: Boolean = true override val indentBooleanConditions: Boolean = false } diff --git a/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt b/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt index 5d79ca46b..5772ba4e2 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt @@ -220,6 +220,36 @@ internal val KtExpression?.isLambdaOrScopingFunction: Boolean return false } +/** + * Returns true when [KtExpression] is a call that carries both a value argument list and a trailing + * lambda, e.g. `launch(dispatcher) { ... }` or `scope.launch(dispatcher) { ... }`. + * + * These are exactly the calls [isLambdaOrScopingFunction] turns down because of their parentheses. + * Styles that hug them lay them out the same way: the call keeps the line of the `=`/`by` operator + * it follows, and the lambda body is indented one block in from the declaration. + */ +internal val KtExpression?.isCallWithTrailingLambda: Boolean + get() { + if (this == null) return false + val prev = this.getPrevSiblingIgnoringWhitespace() + if (prev is PsiComment && prev.text.startsWith("//")) { + return false // Leading line comments cause weird indentation; block comments are ok. + } + + var carry: KtExpression? = this + if (carry is KtQualifiedExpression && carry.receiverExpression is KtSimpleNameExpression) { + carry = carry.selectorExpression + } + if (carry !is KtCallExpression) return false + // Without parentheses this is a scoping function, which [isLambdaOrScopingFunction] covers. + if (carry.valueArgumentList?.leftParenthesis == null) return false + carry = carry.lambdaArguments.firstOrNull()?.getArgumentExpression() ?: return false + if (carry is KtLabeledExpression) { + carry = carry.baseExpression + } + return carry is KtLambdaExpression + } + /** * Returns true when [KtExpression] is a scoping-function call whose lambda body has source-level * newlines (i.e. spans multiple lines). Used to decide whether chained selectors after the lambda's diff --git a/core/src/test/resources/cases/new_codestyle/BlockLambdaAssignment.input b/core/src/test/resources/cases/new_codestyle/BlockLambdaAssignment.input new file mode 100644 index 000000000..75e58d030 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/BlockLambdaAssignment.input @@ -0,0 +1,5 @@ +val job = + GlobalScope.launch(dispatcher) { + ++capturedMutableState + expect(2) + } \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/BlockLambdaAssignment.new.output b/core/src/test/resources/cases/new_codestyle/BlockLambdaAssignment.new.output new file mode 100644 index 000000000..006fe6391 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/BlockLambdaAssignment.new.output @@ -0,0 +1,4 @@ +val job = GlobalScope.launch(dispatcher) { + ++capturedMutableState + expect(2) +} diff --git a/core/src/test/resources/cases/new_codestyle/BlockLambdaAssignment.output b/core/src/test/resources/cases/new_codestyle/BlockLambdaAssignment.output new file mode 100644 index 000000000..30ae77d68 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/BlockLambdaAssignment.output @@ -0,0 +1,5 @@ +val job = + GlobalScope.launch(dispatcher) { + ++capturedMutableState + expect(2) + } diff --git a/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.input b/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.input index 351f71de7..e61e0acd8 100644 --- a/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.input +++ b/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.input @@ -25,4 +25,11 @@ class Foo { name = "clics-archive", waitTime = waitTime, ) + + fun callChain() = StressOptions() + .iterations(20 * stressTestMultiplierSqrt) + .invocationsPerIteration(1_000 * stressTestMultiplierSqrt) + .commonConfiguration() + .customize(isStressTest) + .check(this::class) } diff --git a/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.new.output b/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.new.output index 1746c7376..68db7621d 100644 --- a/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.new.output +++ b/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.new.output @@ -26,4 +26,11 @@ class Foo { name = "clics-archive", waitTime = waitTime, ) + + fun callChain() = StressOptions() + .iterations(20 * stressTestMultiplierSqrt) + .invocationsPerIteration(1_000 * stressTestMultiplierSqrt) + .commonConfiguration() + .customize(isStressTest) + .check(this::class) } diff --git a/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.output b/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.output index 1adc22630..2c324c01d 100644 --- a/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.output +++ b/core/src/test/resources/cases/new_codestyle/ExpressionBodyHugging.output @@ -33,4 +33,12 @@ class Foo { name = "clics-archive", waitTime = waitTime, ) + + fun callChain() = + StressOptions() + .iterations(20 * stressTestMultiplierSqrt) + .invocationsPerIteration(1_000 * stressTestMultiplierSqrt) + .commonConfiguration() + .customize(isStressTest) + .check(this::class) } diff --git a/core/src/test/resources/cases/new_codestyle/InlineObject.output b/core/src/test/resources/cases/new_codestyle/InlineObject.output index 917433722..772ceaedc 100644 --- a/core/src/test/resources/cases/new_codestyle/InlineObject.output +++ b/core/src/test/resources/cases/new_codestyle/InlineObject.output @@ -1,8 +1,8 @@ class Tests { fun testNotifications() = runTest { - val coroutine = object : - AVeryVeryVeryVeryVeryVeryLongSupertype(coroutineContext, true, false) { - fun foo(): String = "bar" - } + val coroutine = + object : AVeryVeryVeryVeryVeryVeryLongSupertype(coroutineContext, true, false) { + fun foo(): String = "bar" + } } } From a016be728583f203a62ff2f7e156474682324047 Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Thu, 6 Aug 2026 11:46:50 +0200 Subject: [PATCH 11/22] [formatter] Fix formatting of property accessors --- .../facebook/ktfmt/format/KotlinInputAstVisitor.kt | 12 +++++++++++- .../cases/new_codestyle/PropertyAccessors.input | 4 ++++ .../cases/new_codestyle/PropertyAccessors.new.output | 5 +++++ .../cases/new_codestyle/PropertyAccessors.output | 6 ++++++ .../PropertyAccessorsWithModifiers.input | 8 ++++++++ .../PropertyAccessorsWithModifiers.new.output | 8 ++++++++ .../PropertyAccessorsWithModifiers.output | 8 ++++++++ 7 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 core/src/test/resources/cases/new_codestyle/PropertyAccessorsWithModifiers.input create mode 100644 core/src/test/resources/cases/new_codestyle/PropertyAccessorsWithModifiers.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/PropertyAccessorsWithModifiers.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index f1160647b..8e8d99c3a 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -1624,7 +1624,8 @@ open class KotlinInputAstVisitor( if (propertyComponents.isNotEmpty()) { builder.block(blockIndent) { for (component in propertyComponents) { - if (forceLineBreakBeforeAccessors) builder.forcedBreak() else builder.breakOp(" ") + if (forceBreakBeforePropertyComponent(component)) builder.forcedBreak() + else builder.breakOp(" ") // The semicolon must come after the newline, or the output code will not parse. builder.guessSemicolon() @@ -1659,6 +1660,15 @@ open class KotlinInputAstVisitor( } } + private fun forceBreakBeforePropertyComponent(component: KtExpression): Boolean = + when (component) { + is KtBackingField -> true + is KtPropertyAccessor -> { + forceLineBreakBeforeAccessors || component.modifierList != null + } + else -> true + } + /** * Lays out the right-hand side of an operator that a declaration is assigned across -- the `=` of * an initializer or an expression body, or the `by` of a property delegate -- according to the diff --git a/core/src/test/resources/cases/new_codestyle/PropertyAccessors.input b/core/src/test/resources/cases/new_codestyle/PropertyAccessors.input index de70e8049..d5852fc87 100644 --- a/core/src/test/resources/cases/new_codestyle/PropertyAccessors.input +++ b/core/src/test/resources/cases/new_codestyle/PropertyAccessors.input @@ -15,4 +15,8 @@ class Properties { public override val property: CoroutineContext get() = aVeryVeryVeryVeryVeryVeryVeryVeryVeryLongInitializer + + val propertyAccessorWIthBlockBody: String get() = synchronized(this) { + "foo" + } } \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/PropertyAccessors.new.output b/core/src/test/resources/cases/new_codestyle/PropertyAccessors.new.output index a604fb074..908a76bff 100644 --- a/core/src/test/resources/cases/new_codestyle/PropertyAccessors.new.output +++ b/core/src/test/resources/cases/new_codestyle/PropertyAccessors.new.output @@ -12,4 +12,9 @@ class Properties { public override val property: CoroutineContext get() = aVeryVeryVeryVeryVeryVeryVeryVeryVeryLongInitializer + + val propertyAccessorWIthBlockBody: String + get() = synchronized(this) { + "foo" + } } diff --git a/core/src/test/resources/cases/new_codestyle/PropertyAccessors.output b/core/src/test/resources/cases/new_codestyle/PropertyAccessors.output index e87061bca..a034af97b 100644 --- a/core/src/test/resources/cases/new_codestyle/PropertyAccessors.output +++ b/core/src/test/resources/cases/new_codestyle/PropertyAccessors.output @@ -15,4 +15,10 @@ class Properties { public override val property: CoroutineContext get() = aVeryVeryVeryVeryVeryVeryVeryVeryVeryLongInitializer + + val propertyAccessorWIthBlockBody: String + get() = + synchronized(this) { + "foo" + } } diff --git a/core/src/test/resources/cases/new_codestyle/PropertyAccessorsWithModifiers.input b/core/src/test/resources/cases/new_codestyle/PropertyAccessorsWithModifiers.input new file mode 100644 index 000000000..3a2fa9978 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/PropertyAccessorsWithModifiers.input @@ -0,0 +1,8 @@ +class Properties { + protected var slots: Array? = null + private set + + protected var nCollectors = 0 + protected get + private set +} \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/PropertyAccessorsWithModifiers.new.output b/core/src/test/resources/cases/new_codestyle/PropertyAccessorsWithModifiers.new.output new file mode 100644 index 000000000..33a60c24e --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/PropertyAccessorsWithModifiers.new.output @@ -0,0 +1,8 @@ +class Properties { + protected var slots: Array? = null + private set + + protected var nCollectors = 0 + protected get + private set +} diff --git a/core/src/test/resources/cases/new_codestyle/PropertyAccessorsWithModifiers.output b/core/src/test/resources/cases/new_codestyle/PropertyAccessorsWithModifiers.output new file mode 100644 index 000000000..ae837e858 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/PropertyAccessorsWithModifiers.output @@ -0,0 +1,8 @@ +class Properties { + protected var slots: Array? = null + private set + + protected var nCollectors = 0 + protected get + private set +} From ab7fac20d08048ccec6822e50b5f7ec6d2e1c019 Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Thu, 6 Aug 2026 12:02:43 +0200 Subject: [PATCH 12/22] [formatter] Fix formatting of try expression --- .../facebook/ktfmt/format/KotlinInputAstVisitor.kt | 1 + .../cases/new_codestyle/TryExpression.input | 12 ++++++++++++ .../cases/new_codestyle/TryExpression.new.output | 12 ++++++++++++ .../cases/new_codestyle/TryExpression.output | 13 +++++++++++++ 4 files changed, 38 insertions(+) create mode 100644 core/src/test/resources/cases/new_codestyle/TryExpression.input create mode 100644 core/src/test/resources/cases/new_codestyle/TryExpression.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/TryExpression.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 8e8d99c3a..6fcf39c3b 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -1704,6 +1704,7 @@ open class KotlinInputAstVisitor( expression.isChainedBlockLikeCall -> visitChainedBlockLikeCall(expression, emitLeadingBreak = true) !forceLineBreakAfterAssignment && expression is KtObjectLiteralExpression -> emitHugged() + !forceLineBreakAfterAssignment && expression is KtTryExpression -> emitHugged() hugCallsWithTrailingLambda && expression.isCallWithTrailingLambda -> emitHugged() hugBlockLikeInfixCalls && expression.isInfixBlockLikeCall -> emitInfixBlockLikeCall(expression) diff --git a/core/src/test/resources/cases/new_codestyle/TryExpression.input b/core/src/test/resources/cases/new_codestyle/TryExpression.input new file mode 100644 index 000000000..ccac581cd --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/TryExpression.input @@ -0,0 +1,12 @@ +fun f(block: () -> T): T? { + var thrown: Throwable? = null + val result = try { + block() + } catch (t: Throwable) { + thrown = t + null + } finally { + cleanup() + } + return result +} \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/TryExpression.new.output b/core/src/test/resources/cases/new_codestyle/TryExpression.new.output new file mode 100644 index 000000000..4ae7d4f97 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/TryExpression.new.output @@ -0,0 +1,12 @@ +fun f(block: () -> T): T? { + var thrown: Throwable? = null + val result = try { + block() + } catch (t: Throwable) { + thrown = t + null + } finally { + cleanup() + } + return result +} diff --git a/core/src/test/resources/cases/new_codestyle/TryExpression.output b/core/src/test/resources/cases/new_codestyle/TryExpression.output new file mode 100644 index 000000000..c2f70b394 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/TryExpression.output @@ -0,0 +1,13 @@ +fun f(block: () -> T): T? { + var thrown: Throwable? = null + val result = + try { + block() + } catch (t: Throwable) { + thrown = t + null + } finally { + cleanup() + } + return result +} From 2e0336b5a9d572c11e4a35f5e252cd93f3c254cd Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Thu, 6 Aug 2026 12:26:48 +0200 Subject: [PATCH 13/22] [formatter] Don't force when conditions list into separate lines --- .../ktfmt/format/KotlinInputAstVisitor.kt | 78 +++++++++++-------- .../ktfmt/format/KotlinLangInputAstVisitor.kt | 1 + .../CommaSeparatedWhenConditions.input | 14 ++++ .../CommaSeparatedWhenConditions.new.output | 14 ++++ .../CommaSeparatedWhenConditions.output | 15 ++++ 5 files changed, 90 insertions(+), 32 deletions(-) create mode 100644 core/src/test/resources/cases/new_codestyle/CommaSeparatedWhenConditions.input create mode 100644 core/src/test/resources/cases/new_codestyle/CommaSeparatedWhenConditions.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/CommaSeparatedWhenConditions.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 6fcf39c3b..d87ba52ce 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -127,6 +127,7 @@ import org.jetbrains.kotlin.psi.KtValueArgumentList import org.jetbrains.kotlin.psi.KtWhenConditionInRange import org.jetbrains.kotlin.psi.KtWhenConditionIsPattern import org.jetbrains.kotlin.psi.KtWhenConditionWithExpression +import org.jetbrains.kotlin.psi.KtWhenEntry import org.jetbrains.kotlin.psi.KtWhenExpression import org.jetbrains.kotlin.psi.KtWhileExpression import org.jetbrains.kotlin.psi.psiUtil.children @@ -148,6 +149,7 @@ open class KotlinInputAstVisitor( internal open val hugCallsWithTrailingLambda: Boolean = false internal open val hugWhenExpressions: Boolean = false internal open val indentBooleanConditions: Boolean = true + internal open val forceLineBreakInWhenConditionList: Boolean = true /** Standard indentation for a block */ private val blockIndent: Indent.Const = Indent.Const.make(options.blockIndent, 1) @@ -2364,42 +2366,34 @@ open class KotlinInputAstVisitor( builder.blankLineWanted(BlankLineWanted.PRESERVE) } builder.forcedBreak() - builder.block(ZERO) { - if (whenEntry.elseKeyword != null) { - builder.token("else") - } else { - val conditions = whenEntry.conditions - for ((conditionIndex, condition) in conditions.withIndex()) { - visit(condition) - builder.guessToken(",") - if (conditionIndex != conditions.lastIndex) { - builder.forcedBreak() - } - } + + val whenExpression = whenEntry.expression + val bodyIsBraced = + whenExpression is KtBlockExpression || whenExpression is KtLambdaExpression + // When comma-separated conditions are allowed to share a line, whether they actually fit + // depends on the `-> body` that trails them, so they have to be laid out in the same level + // as it. A braced body always breaks, so it is kept out of that level -- otherwise the + // conditions would always be broken apart too. + val conditionsShareLevelWithBody = !forceLineBreakInWhenConditionList && !bodyIsBraced + + builder.block(ZERO, isEnabled = conditionsShareLevelWithBody) { + builder.block(ZERO, isEnabled = !conditionsShareLevelWithBody) { + emitWhenEntryConditions(whenEntry) } - whenEntry.guard?.let { guard -> + if (whenEntry.trailingComma != null) { + builder.forcedBreak() + } else { builder.space() - emitKeywordWithCondition( - "if", - guard.getExpression(), - surroundConditionWithParens = false, - ) } - } - val whenExpression = whenEntry.expression - if (whenEntry.trailingComma != null) { - builder.forcedBreak() - } else { - builder.space() - } - builder.token("->") - if (whenExpression is KtBlockExpression || whenExpression is KtLambdaExpression) { - builder.space() - visit(whenExpression) - } else { - builder.block(expressionBreakIndent) { - builder.breakToFill(" ") + builder.token("->") + if (bodyIsBraced) { + builder.space() visit(whenExpression) + } else { + builder.block(expressionBreakIndent) { + builder.breakToFill(" ") + visit(whenExpression) + } } } builder.guessSemicolon() @@ -2409,6 +2403,26 @@ open class KotlinInputAstVisitor( builder.token("}") } + /** Emits `else`, or the comma-separated conditions of a `when` entry, followed by its guard. */ + private fun emitWhenEntryConditions(whenEntry: KtWhenEntry) { + if (whenEntry.elseKeyword != null) { + builder.token("else") + } else { + val conditions = whenEntry.conditions + for ((conditionIndex, condition) in conditions.withIndex()) { + visit(condition) + builder.guessToken(",") + if (conditionIndex != conditions.lastIndex) { + if (forceLineBreakInWhenConditionList) builder.forcedBreak() else builder.breakOp(" ") + } + } + } + whenEntry.guard?.let { guard -> + builder.space() + emitKeywordWithCondition("if", guard.getExpression(), surroundConditionWithParens = false) + } + } + override fun visitClassBody(body: KtClassBody) { builder.sync(body) emitBracedBlock(body) { children -> diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt index d27003982..bb639206a 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt @@ -15,4 +15,5 @@ internal class KotlinLangInputAstVisitor( override val hugCallsWithTrailingLambda: Boolean = true override val hugWhenExpressions: Boolean = true override val indentBooleanConditions: Boolean = false + override val forceLineBreakInWhenConditionList: Boolean = false } diff --git a/core/src/test/resources/cases/new_codestyle/CommaSeparatedWhenConditions.input b/core/src/test/resources/cases/new_codestyle/CommaSeparatedWhenConditions.input new file mode 100644 index 000000000..5622d47c5 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/CommaSeparatedWhenConditions.input @@ -0,0 +1,14 @@ +fun f1(x: Int) { + when (x) { + 0, 1 -> println("a or b") + } +} + +fun f2() { + when (it.resolvedCall.resultingDescriptor) { + is LocalVariableDescriptor, + is ValueParameterDescriptor, + is ReceiverParameterDescriptor -> true + else -> false + } +} \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/CommaSeparatedWhenConditions.new.output b/core/src/test/resources/cases/new_codestyle/CommaSeparatedWhenConditions.new.output new file mode 100644 index 000000000..2b4d8b63d --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/CommaSeparatedWhenConditions.new.output @@ -0,0 +1,14 @@ +fun f1(x: Int) { + when (x) { + 0, 1 -> println("a or b") + } +} + +fun f2() { + when (it.resolvedCall.resultingDescriptor) { + is LocalVariableDescriptor, + is ValueParameterDescriptor, + is ReceiverParameterDescriptor -> true + else -> false + } +} diff --git a/core/src/test/resources/cases/new_codestyle/CommaSeparatedWhenConditions.output b/core/src/test/resources/cases/new_codestyle/CommaSeparatedWhenConditions.output new file mode 100644 index 000000000..4dcf52483 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/CommaSeparatedWhenConditions.output @@ -0,0 +1,15 @@ +fun f1(x: Int) { + when (x) { + 0, + 1 -> println("a or b") + } +} + +fun f2() { + when (it.resolvedCall.resultingDescriptor) { + is LocalVariableDescriptor, + is ValueParameterDescriptor, + is ReceiverParameterDescriptor -> true + else -> false + } +} From 9f557c0531918ffeb3496486f0c277ff34843afb Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Thu, 6 Aug 2026 13:55:47 +0200 Subject: [PATCH 14/22] [formatter] Don't force new lines between empty class methods --- .../ktfmt/format/KotlinInputAstVisitor.kt | 6 ++++++ .../ktfmt/format/KotlinLangInputAstVisitor.kt | 1 + .../LineBreaksBetweenClassMembers.input | 11 +++++++++++ .../LineBreaksBetweenClassMembers.new.output | 11 +++++++++++ .../LineBreaksBetweenClassMembers.output | 19 +++++++++++++++++++ 5 files changed, 48 insertions(+) create mode 100644 core/src/test/resources/cases/new_codestyle/LineBreaksBetweenClassMembers.input create mode 100644 core/src/test/resources/cases/new_codestyle/LineBreaksBetweenClassMembers.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/LineBreaksBetweenClassMembers.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index d87ba52ce..cf1083738 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -73,6 +73,7 @@ import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtFileAnnotationList import org.jetbrains.kotlin.psi.KtFinallySection import org.jetbrains.kotlin.psi.KtForExpression +import org.jetbrains.kotlin.psi.KtFunction import org.jetbrains.kotlin.psi.KtFunctionType import org.jetbrains.kotlin.psi.KtIfExpression import org.jetbrains.kotlin.psi.KtImportDirective @@ -150,6 +151,7 @@ open class KotlinInputAstVisitor( internal open val hugWhenExpressions: Boolean = false internal open val indentBooleanConditions: Boolean = true internal open val forceLineBreakInWhenConditionList: Boolean = true + internal open val forceLineBreaksBetweenEmptyMethods: Boolean = true /** Standard indentation for a block */ private val blockIndent: Indent.Const = Indent.Const.make(options.blockIndent, 1) @@ -2459,6 +2461,10 @@ open class KotlinInputAstVisitor( val blankLineBetweenMembers = when { prev == null -> BlankLineWanted.PRESERVE + !forceLineBreaksBetweenEmptyMethods && + prev is KtFunction && + prev.bodyBlockExpression == null && + prev.bodyExpression == null -> BlankLineWanted.PRESERVE prev !is KtProperty -> BlankLineWanted.YES prev.getter != null || prev.setter != null -> BlankLineWanted.YES curr is KtProperty -> BlankLineWanted.PRESERVE diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt index bb639206a..aa1e2b5b3 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt @@ -16,4 +16,5 @@ internal class KotlinLangInputAstVisitor( override val hugWhenExpressions: Boolean = true override val indentBooleanConditions: Boolean = false override val forceLineBreakInWhenConditionList: Boolean = false + override val forceLineBreaksBetweenEmptyMethods: Boolean = false } diff --git a/core/src/test/resources/cases/new_codestyle/LineBreaksBetweenClassMembers.input b/core/src/test/resources/cases/new_codestyle/LineBreaksBetweenClassMembers.input new file mode 100644 index 000000000..98a4171f8 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/LineBreaksBetweenClassMembers.input @@ -0,0 +1,11 @@ +internal abstract class AbstractTimeSource { + abstract fun currentTimeMillis(): Long + abstract fun nanoTime(): Long + abstract fun wrapTask(block: Runnable): Runnable + abstract fun trackTask() + abstract fun unTrackTask() + abstract fun registerTimeLoopThread() + abstract fun unregisterTimeLoopThread() + abstract fun parkNanos(blocker: Any, nanos: Long) // should return immediately when nanos <= 0 + abstract fun unpark(thread: Thread) +} \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/LineBreaksBetweenClassMembers.new.output b/core/src/test/resources/cases/new_codestyle/LineBreaksBetweenClassMembers.new.output new file mode 100644 index 000000000..d54c39d61 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/LineBreaksBetweenClassMembers.new.output @@ -0,0 +1,11 @@ +internal abstract class AbstractTimeSource { + abstract fun currentTimeMillis(): Long + abstract fun nanoTime(): Long + abstract fun wrapTask(block: Runnable): Runnable + abstract fun trackTask() + abstract fun unTrackTask() + abstract fun registerTimeLoopThread() + abstract fun unregisterTimeLoopThread() + abstract fun parkNanos(blocker: Any, nanos: Long) // should return immediately when nanos <= 0 + abstract fun unpark(thread: Thread) +} diff --git a/core/src/test/resources/cases/new_codestyle/LineBreaksBetweenClassMembers.output b/core/src/test/resources/cases/new_codestyle/LineBreaksBetweenClassMembers.output new file mode 100644 index 000000000..cd8a39b9b --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/LineBreaksBetweenClassMembers.output @@ -0,0 +1,19 @@ +internal abstract class AbstractTimeSource { + abstract fun currentTimeMillis(): Long + + abstract fun nanoTime(): Long + + abstract fun wrapTask(block: Runnable): Runnable + + abstract fun trackTask() + + abstract fun unTrackTask() + + abstract fun registerTimeLoopThread() + + abstract fun unregisterTimeLoopThread() + + abstract fun parkNanos(blocker: Any, nanos: Long) // should return immediately when nanos <= 0 + + abstract fun unpark(thread: Thread) +} From d083afba509401364f59b5372b793a5b32334e84 Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Thu, 6 Aug 2026 15:05:55 +0200 Subject: [PATCH 15/22] [formatter] Don't force line break after block lambda in a call chain --- .../ktfmt/format/KotlinInputAstVisitor.kt | 49 +++++++++++++++---- .../ktfmt/format/KotlinLangInputAstVisitor.kt | 1 + .../com/facebook/ktfmt/format/PsiUtils.kt | 39 +++++++++++++-- .../new_codestyle/CallAfterBlockLambda.input | 4 ++ .../CallAfterBlockLambda.new.output | 4 ++ .../new_codestyle/CallAfterBlockLambda.output | 5 ++ 6 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 core/src/test/resources/cases/new_codestyle/CallAfterBlockLambda.input create mode 100644 core/src/test/resources/cases/new_codestyle/CallAfterBlockLambda.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/CallAfterBlockLambda.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index cf1083738..6ed8f6894 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -148,6 +148,7 @@ open class KotlinInputAstVisitor( internal open val forceLineBreakBeforeAccessors: Boolean = true internal open val hugBlockLikeInfixCalls: Boolean = false internal open val hugCallsWithTrailingLambda: Boolean = false + internal open val hugChainsAfterTrailingLambda: Boolean = false internal open val hugWhenExpressions: Boolean = false internal open val indentBooleanConditions: Boolean = true internal open val forceLineBreakInWhenConditionList: Boolean = true @@ -498,6 +499,8 @@ open class KotlinInputAstVisitor( expression.isChainedBlockLikeCall -> { visitChainedBlockLikeCall(expression, emitLeadingBreak = false) } + hugChainsAfterTrailingLambda && + visitChainAfterTrailingLambda(expression, emitLeadingBreak = false) -> Unit else -> { emitQualifiedExpression(expression) } @@ -662,16 +665,7 @@ open class KotlinInputAstVisitor( /** * Lays out a supertype list whose first entry is a constructor call on the class header line, so - * that only the call's arguments break and any remaining supertypes trail its closing paren: - * ``` - * object ClicsArchiveCommand : DumpFileCommand( - * name = "clics-archive", - * ), Runnable, Closeable {} - * ``` - * - * Returns false, having emitted nothing, when the first supertype isn't a constructor call with - * arguments to break at -- the list then gets one entry per line instead, which is the caller's - * default layout. + * that only the call's arguments break and any remaining supertypes trail its closing paren. */ private fun emitSuperTypeCallAfterColon(list: KtSuperTypeList): Boolean { if (forceLineBreakAfterSupertypeColon) return false @@ -1710,6 +1704,9 @@ open class KotlinInputAstVisitor( !forceLineBreakAfterAssignment && expression is KtObjectLiteralExpression -> emitHugged() !forceLineBreakAfterAssignment && expression is KtTryExpression -> emitHugged() hugCallsWithTrailingLambda && expression.isCallWithTrailingLambda -> emitHugged() + hugChainsAfterTrailingLambda && + !expression.hasLeadingComment && + visitChainAfterTrailingLambda(expression, emitLeadingBreak = true) -> Unit hugBlockLikeInfixCalls && expression.isInfixBlockLikeCall -> emitInfixBlockLikeCall(expression) hugWhenExpressions && expression is KtWhenExpression && !expression.hasLeadingComment -> @@ -1854,6 +1851,38 @@ open class KotlinInputAstVisitor( emitChainedSelectors(parts, forceBreak = true) } + /** + * Emit a `launch(dispatcher) { ... }.join()` style chain whose head is a call carrying a trailing + * lambda: render that call block-like, so its lambda body is indented one block in and its + * closing brace returns to the chain's own indent, then hang the remaining selectors off that + * brace: + * ``` + * GlobalScope.launch(Dispatchers.Main) { + * expect(2) + * }.join() + * ``` + * + * Unlike [visitChainedScopingFunction], the selectors are not forced onto their own line -- they + * only break when they don't fit, in which case they indent by [expressionBreakIndent]. + * + * Returns false, having emitted nothing, when [expression] isn't built on a trailing lambda that + * way; the caller then emits the regular chain layout. + */ + private fun visitChainAfterTrailingLambda( + expression: KtExpression, + emitLeadingBreak: Boolean, + ): Boolean { + val headIndex = expression.trailingLambdaChainHead ?: return false + val parts = breakIntoParts(expression) + if (emitLeadingBreak) { + builder.space() + } + + visit(parts[headIndex]) + emitChainedSelectors(parts.subList(headIndex, parts.size), forceBreak = false) + return true + } + /** * Emit the `.selector` parts of a chain (everything after the innermost receiver, [parts]`[0]`), * each on its own line, indented by [expressionBreakIndent]. diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt index aa1e2b5b3..284c10ea0 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt @@ -13,6 +13,7 @@ internal class KotlinLangInputAstVisitor( override val forceLineBreakBeforeAccessors: Boolean = false override val hugBlockLikeInfixCalls: Boolean = true override val hugCallsWithTrailingLambda: Boolean = true + override val hugChainsAfterTrailingLambda: Boolean = true override val hugWhenExpressions: Boolean = true override val indentBooleanConditions: Boolean = false override val forceLineBreakInWhenConditionList: Boolean = false diff --git a/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt b/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt index 5772ba4e2..892d58a7a 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt @@ -241,9 +241,42 @@ internal val KtExpression?.isCallWithTrailingLambda: Boolean carry = carry.selectorExpression } if (carry !is KtCallExpression) return false - // Without parentheses this is a scoping function, which [isLambdaOrScopingFunction] covers. - if (carry.valueArgumentList?.leftParenthesis == null) return false - carry = carry.lambdaArguments.firstOrNull()?.getArgumentExpression() ?: return false + return carry.hasTrailingLambdaAfterArguments + } + +/** + * The index into [breakIntoParts] of the part that ends the trailing lambda a chain is built on -- + * `scope.launch(dispatcher) { ... }` in `scope.launch(dispatcher) { ... }.join().await()` -- or + * null when the chain isn't built on one. + * + * The innermost such part is the head: a chain grows leftwards, so the first part carrying a + * trailing lambda is the one the selectors after it hang off, whether or not they carry trailing + * lambdas of their own. + */ +internal val KtExpression.trailingLambdaChainHead: Int? + get() { + if (this !is KtQualifiedExpression) return null + val parts = breakIntoParts(this) + // Array accesses and postfix operators (`a[0]`, `a!!`) split a chain into segments that neither + // the head nor the selectors after it can be laid out across, so those chains keep the regular + // layout. Only the parts past the innermost receiver can be one of those. + if (parts.subList(1, parts.size).any { it !is KtQualifiedExpression }) return null + // The last part is the chain itself, so a head there would leave no selectors to hang off it. + return (0 until parts.lastIndex).firstOrNull { index -> + parts[index].callExpression?.hasTrailingLambdaAfterArguments == true + } + } + +/** + * Whether this call carries both a parenthesized value argument list and a trailing lambda, e.g. + * `launch(dispatcher) { ... }`. Without the parentheses it is a scoping function instead, which + * [isLambdaOrScopingFunction] covers. + */ +private val KtCallExpression.hasTrailingLambdaAfterArguments: Boolean + get() { + if (valueArgumentList?.leftParenthesis == null) return false + var carry: KtExpression? = + lambdaArguments.firstOrNull()?.getArgumentExpression() ?: return false if (carry is KtLabeledExpression) { carry = carry.baseExpression } diff --git a/core/src/test/resources/cases/new_codestyle/CallAfterBlockLambda.input b/core/src/test/resources/cases/new_codestyle/CallAfterBlockLambda.input new file mode 100644 index 000000000..7f50bd2dc --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/CallAfterBlockLambda.input @@ -0,0 +1,4 @@ +GlobalScope.launch(Dispatchers.Main) { + expect(2) + throw TestException() +}.join() diff --git a/core/src/test/resources/cases/new_codestyle/CallAfterBlockLambda.new.output b/core/src/test/resources/cases/new_codestyle/CallAfterBlockLambda.new.output new file mode 100644 index 000000000..7f50bd2dc --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/CallAfterBlockLambda.new.output @@ -0,0 +1,4 @@ +GlobalScope.launch(Dispatchers.Main) { + expect(2) + throw TestException() +}.join() diff --git a/core/src/test/resources/cases/new_codestyle/CallAfterBlockLambda.output b/core/src/test/resources/cases/new_codestyle/CallAfterBlockLambda.output new file mode 100644 index 000000000..e2c2eb7df --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/CallAfterBlockLambda.output @@ -0,0 +1,5 @@ +GlobalScope.launch(Dispatchers.Main) { + expect(2) + throw TestException() + } + .join() From 7fa13b01c7385ea0022b0bb22f478825d7af9515 Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Thu, 6 Aug 2026 15:48:18 +0200 Subject: [PATCH 16/22] [formatter] Don't force line break after `=` --- .../facebook/ktfmt/format/KotlinInputAstVisitor.kt | 2 ++ .../BinaryExpressionInNamedArgument.input | 11 +++++++++++ .../BinaryExpressionInNamedArgument.new.output | 10 ++++++++++ .../BinaryExpressionInNamedArgument.output | 11 +++++++++++ 4 files changed, 34 insertions(+) create mode 100644 core/src/test/resources/cases/new_codestyle/BinaryExpressionInNamedArgument.input create mode 100644 core/src/test/resources/cases/new_codestyle/BinaryExpressionInNamedArgument.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/BinaryExpressionInNamedArgument.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 6ed8f6894..495deb3b6 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -1377,6 +1377,7 @@ open class KotlinInputAstVisitor( } if (hasArgName && !isLambda && !argument.isSpread && !forceLineBreakAfterNamedParameter) { if (emitCallAfterOperator(argument.getArgumentExpression())) return + if (emitExpressionAfterOperator(argument.getArgumentExpression()!!)) return } val indent = if (hasArgName && !isLambda) expressionBreakIndent else ZERO @@ -1709,6 +1710,7 @@ open class KotlinInputAstVisitor( visitChainAfterTrailingLambda(expression, emitLeadingBreak = true) -> Unit hugBlockLikeInfixCalls && expression.isInfixBlockLikeCall -> emitInfixBlockLikeCall(expression) + !forceLineBreakAfterAssignment && expression is KtBinaryExpression -> emitHugged() hugWhenExpressions && expression is KtWhenExpression && !expression.hasLeadingComment -> emitWhenExpressionAfterOperator(expression) else -> return false diff --git a/core/src/test/resources/cases/new_codestyle/BinaryExpressionInNamedArgument.input b/core/src/test/resources/cases/new_codestyle/BinaryExpressionInNamedArgument.input new file mode 100644 index 000000000..6eefeff40 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/BinaryExpressionInNamedArgument.input @@ -0,0 +1,11 @@ +@Target(AnnotationTarget.CLASS) +@RequiresOptIn( + level = RequiresOptIn.Level.WARNING, + message = + "This is a kotlinx.coroutines API that is not intended to be inherited from, " + + "as the library may handle predefined instances of this in a special manner. " + + "This will be an error in a future release. " + + "If you need to inherit from this, please describe your use case in " + + "https://github.com/Kotlin/kotlinx.coroutines/issues, so that we can provide a stable API for inheritance. ", +) +public annotation class InternalForInheritanceCoroutinesApi \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/BinaryExpressionInNamedArgument.new.output b/core/src/test/resources/cases/new_codestyle/BinaryExpressionInNamedArgument.new.output new file mode 100644 index 000000000..96dc434d6 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/BinaryExpressionInNamedArgument.new.output @@ -0,0 +1,10 @@ +@Target(AnnotationTarget.CLASS) +@RequiresOptIn( + level = RequiresOptIn.Level.WARNING, + message = "This is a kotlinx.coroutines API that is not intended to be inherited from, " + + "as the library may handle predefined instances of this in a special manner. " + + "This will be an error in a future release. " + + "If you need to inherit from this, please describe your use case in " + + "https://github.com/Kotlin/kotlinx.coroutines/issues, so that we can provide a stable API for inheritance. ", +) +public annotation class InternalForInheritanceCoroutinesApi diff --git a/core/src/test/resources/cases/new_codestyle/BinaryExpressionInNamedArgument.output b/core/src/test/resources/cases/new_codestyle/BinaryExpressionInNamedArgument.output new file mode 100644 index 000000000..071daf9c1 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/BinaryExpressionInNamedArgument.output @@ -0,0 +1,11 @@ +@Target(AnnotationTarget.CLASS) +@RequiresOptIn( + level = RequiresOptIn.Level.WARNING, + message = + "This is a kotlinx.coroutines API that is not intended to be inherited from, " + + "as the library may handle predefined instances of this in a special manner. " + + "This will be an error in a future release. " + + "If you need to inherit from this, please describe your use case in " + + "https://github.com/Kotlin/kotlinx.coroutines/issues, so that we can provide a stable API for inheritance. ", +) +public annotation class InternalForInheritanceCoroutinesApi From 639ac2d65eaeb7ae0c93fe2d62a2be22b2960c63 Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Thu, 6 Aug 2026 16:18:37 +0200 Subject: [PATCH 17/22] [formatter] Don't force line break after `=` --- .../com/facebook/ktfmt/format/KotlinInputAstVisitor.kt | 7 +++++-- core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt | 7 ++----- .../new_codestyle/ScopingFunctionWithTypeArgument.input | 7 +++++++ .../ScopingFunctionWithTypeArgument.new.output | 6 ++++++ .../new_codestyle/ScopingFunctionWithTypeArgument.output | 6 ++++++ 5 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 core/src/test/resources/cases/new_codestyle/ScopingFunctionWithTypeArgument.input create mode 100644 core/src/test/resources/cases/new_codestyle/ScopingFunctionWithTypeArgument.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/ScopingFunctionWithTypeArgument.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 495deb3b6..533c8b60a 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -1971,9 +1971,12 @@ open class KotlinInputAstVisitor( carry = carry.selectorExpression } if (carry is KtCallExpression) { - visit(carry.calleeExpression) + val call = carry + visit(call.calleeExpression) + // The extra level keeps the type arguments off the broken level the lambda body forces. + builder.block(ZERO) { visit(call.typeArgumentList) } builder.space() - carry = carry.lambdaArguments[0].getArgumentExpression() + carry = call.lambdaArguments[0].getArgumentExpression() } if (carry is KtLabeledExpression) { visit(carry.labelQualifier) diff --git a/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt b/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt index 892d58a7a..28d9e7d1e 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/PsiUtils.kt @@ -182,6 +182,7 @@ internal val KtExpression.isChainedScopingFunction: Boolean * 2. '... = Runnable { ... }' is considered a scoping function * 3. '... = scope { ... }' '... = apply { ... }' is a scoping function * 4. '... = scope.launch { ... }' is a dot-qualified scoping function + * 5. '... = async { ... }' is a scoping function with a type argument * * but not: * 1. '... = foo() { ... }' due to the empty parenthesis @@ -200,11 +201,7 @@ internal val KtExpression?.isLambdaOrScopingFunction: Boolean carry = carry.selectorExpression } if (carry is KtCallExpression) { - if ( - carry.valueArgumentList?.leftParenthesis == null && - carry.lambdaArguments.isNotEmpty() && - carry.typeArgumentList?.arguments.isNullOrEmpty() - ) { + if (carry.valueArgumentList?.leftParenthesis == null && carry.lambdaArguments.isNotEmpty()) { carry = carry.lambdaArguments[0].getArgumentExpression() } else { return false diff --git a/core/src/test/resources/cases/new_codestyle/ScopingFunctionWithTypeArgument.input b/core/src/test/resources/cases/new_codestyle/ScopingFunctionWithTypeArgument.input new file mode 100644 index 000000000..ea40e4139 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/ScopingFunctionWithTypeArgument.input @@ -0,0 +1,7 @@ +val d = + async { + expect(3) + yield() // no effect, parent waiting + finish(4) + throw TestException() + } \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/ScopingFunctionWithTypeArgument.new.output b/core/src/test/resources/cases/new_codestyle/ScopingFunctionWithTypeArgument.new.output new file mode 100644 index 000000000..0f5dad95e --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/ScopingFunctionWithTypeArgument.new.output @@ -0,0 +1,6 @@ +val d = async { + expect(3) + yield() // no effect, parent waiting + finish(4) + throw TestException() +} diff --git a/core/src/test/resources/cases/new_codestyle/ScopingFunctionWithTypeArgument.output b/core/src/test/resources/cases/new_codestyle/ScopingFunctionWithTypeArgument.output new file mode 100644 index 000000000..41eea3439 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/ScopingFunctionWithTypeArgument.output @@ -0,0 +1,6 @@ +val d = async { + expect(3) + yield() // no effect, parent waiting + finish(4) + throw TestException() +} From 68aafc7cb12d05133a1979169e7a1c33e22cd661 Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Thu, 6 Aug 2026 17:00:15 +0200 Subject: [PATCH 18/22] [formatter] More workarounds for assignment --- .../ktfmt/format/KotlinInputAstVisitor.kt | 25 +++++++-- .../new_codestyle/AssignmentStatement.input | 47 +++++++++++++++++ .../AssignmentStatement.new.output | 51 +++++++++++++++++++ .../new_codestyle/AssignmentStatement.output | 48 +++++++++++++++++ .../resources/cases/new_codestyle/Foo.input | 12 +++++ .../cases/new_codestyle/Foo.new.output | 12 +++++ .../resources/cases/new_codestyle/Foo.output | 11 ++++ 7 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 core/src/test/resources/cases/new_codestyle/AssignmentStatement.input create mode 100644 core/src/test/resources/cases/new_codestyle/AssignmentStatement.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/AssignmentStatement.output create mode 100644 core/src/test/resources/cases/new_codestyle/Foo.input create mode 100644 core/src/test/resources/cases/new_codestyle/Foo.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/Foo.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 533c8b60a..76f63f91d 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -1427,11 +1427,18 @@ open class KotlinInputAstVisitor( builder.sync(expression) val op = expression.operationToken - if (KtTokens.ALL_ASSIGNMENTS.contains(op) && expression.right.isLambdaOrScopingFunction) { + val right = expression.right + if ( + KtTokens.ALL_ASSIGNMENTS.contains(op) && + right != null && + (!forceLineBreakAfterAssignment || right.isLambdaOrScopingFunction) + ) { // Assignments are statements in Kotlin; we don't have to worry about compound assignment. visit(expression.left) builder.spaceThenToken(expression.operationReference.text) - visitLambdaOrScopingFunction(expression.right) + // The level keeps the break after the operator out of reach of any forced break the + // left-hand side brought with it -- an annotation before the statement, say. + builder.block(ZERO) { emitAssignedExpression(right) } return } @@ -1737,15 +1744,23 @@ open class KotlinInputAstVisitor( */ private fun emitInitializer(initializer: KtExpression) { builder.spaceThenToken("=") - if (emitExpressionAfterOperator(initializer)) { + emitAssignedExpression(initializer) + } + + /** + * Lays out the right-hand side of an assignment operator -- the `=` of an initializer or of an + * assignment statement -- according to the kind of expression it is. + */ + private fun emitAssignedExpression(expression: KtExpression) { + if (emitExpressionAfterOperator(expression)) { return } // A chain gets to keep its receiver on the `=` line when it fits there; everything else // breaks after the `=` and is laid out one level in. - if (!emitChainAfterOperator(initializer)) { + if (!emitChainAfterOperator(expression)) { builder.breakOpThenBlock(" ", expressionBreakIndent) { builder.fenceComments() - visit(initializer) + visit(expression) } } } diff --git a/core/src/test/resources/cases/new_codestyle/AssignmentStatement.input b/core/src/test/resources/cases/new_codestyle/AssignmentStatement.input new file mode 100644 index 000000000..5712d2820 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/AssignmentStatement.input @@ -0,0 +1,47 @@ +fun singleCallChain() { + reports { + total { + html { + report { + htmlDir = conventionProject.layout.buildDirectory.dir("kover/${project.name}/html") + } + } + } + } +} + +fun splittableChain() { + reports { + total { + html { + htmlDir = conventionProject.layout.buildDirectory.dir("kover/html").asFile.absolutePath + } + } + } +} + +fun annotatedAssignment() { + var b + @Suppress("UNCHECKED_CAST") b = f(1) as Int + @Suppress("UNCHECKED_CAST") + b = f(1) as Int +} + +fun whenAssignment() { + reports { + total { + htmlDirectoryValue = when (someLongConditionValue) { + 1 -> firstValue + else -> secondValue + } + } + } +} + +fun scopingFunctionAssignment() { + reports { + total { + htmlDirectoryValue = conventionProjectValue.apply { someLongName = otherLongName } + } + } +} diff --git a/core/src/test/resources/cases/new_codestyle/AssignmentStatement.new.output b/core/src/test/resources/cases/new_codestyle/AssignmentStatement.new.output new file mode 100644 index 000000000..634836b2b --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/AssignmentStatement.new.output @@ -0,0 +1,51 @@ +fun singleCallChain() { + reports { + total { + html { + report { + htmlDir = + conventionProject.layout.buildDirectory.dir("kover/${project.name}/html") + } + } + } + } +} + +fun splittableChain() { + reports { + total { + html { + htmlDir = conventionProject.layout.buildDirectory + .dir("kover/html") + .asFile + .absolutePath + } + } + } +} + +fun annotatedAssignment() { + var b + @Suppress("UNCHECKED_CAST") b = f(1) as Int + @Suppress("UNCHECKED_CAST") + b = f(1) as Int +} + +fun whenAssignment() { + reports { + total { + htmlDirectoryValue = when (someLongConditionValue) { + 1 -> firstValue + else -> secondValue + } + } + } +} + +fun scopingFunctionAssignment() { + reports { + total { + htmlDirectoryValue = conventionProjectValue.apply { someLongName = otherLongName } + } + } +} diff --git a/core/src/test/resources/cases/new_codestyle/AssignmentStatement.output b/core/src/test/resources/cases/new_codestyle/AssignmentStatement.output new file mode 100644 index 000000000..b1cdc814d --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/AssignmentStatement.output @@ -0,0 +1,48 @@ +fun singleCallChain() { + reports { + total { + html { + report { + htmlDir = conventionProject.layout.buildDirectory.dir("kover/${project.name}/html") + } + } + } + } +} + +fun splittableChain() { + reports { + total { + html { + htmlDir = conventionProject.layout.buildDirectory.dir("kover/html").asFile.absolutePath + } + } + } +} + +fun annotatedAssignment() { + var b + @Suppress("UNCHECKED_CAST") b = f(1) as Int + @Suppress("UNCHECKED_CAST") + b = f(1) as Int +} + +fun whenAssignment() { + reports { + total { + htmlDirectoryValue = + when (someLongConditionValue) { + 1 -> firstValue + else -> secondValue + } + } + } +} + +fun scopingFunctionAssignment() { + reports { + total { + htmlDirectoryValue = conventionProjectValue.apply { someLongName = otherLongName } + } + } +} diff --git a/core/src/test/resources/cases/new_codestyle/Foo.input b/core/src/test/resources/cases/new_codestyle/Foo.input new file mode 100644 index 000000000..5ef98cd14 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/Foo.input @@ -0,0 +1,12 @@ +fun foo() { +extensions.configure("kover") { + reports { + total { + html { + htmlDir = + conventionProject.layout.buildDirectory.dir("kover/${project.name}/html") + } + } + } + } + } \ No newline at end of file diff --git a/core/src/test/resources/cases/new_codestyle/Foo.new.output b/core/src/test/resources/cases/new_codestyle/Foo.new.output new file mode 100644 index 000000000..e5a2659e4 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/Foo.new.output @@ -0,0 +1,12 @@ +fun foo() { + extensions.configure("kover") { + reports { + total { + html { + htmlDir = + conventionProject.layout.buildDirectory.dir("kover/${project.name}/html") + } + } + } + } +} diff --git a/core/src/test/resources/cases/new_codestyle/Foo.output b/core/src/test/resources/cases/new_codestyle/Foo.output new file mode 100644 index 000000000..74d46cbcb --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/Foo.output @@ -0,0 +1,11 @@ +fun foo() { + extensions.configure("kover") { + reports { + total { + html { + htmlDir = conventionProject.layout.buildDirectory.dir("kover/${project.name}/html") + } + } + } + } +} From 690d6d3927fe67b2466d8638421260b1303a9b62 Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Fri, 7 Aug 2026 10:36:17 +0200 Subject: [PATCH 19/22] [formatter] Indent binary expressions in expression body --- .../ktfmt/format/KotlinInputAstVisitor.kt | 16 +++++++++++++--- .../BinaryExpressionExpressionBody.input | 4 ++++ .../BinaryExpressionExpressionBody.new.output | 4 ++++ .../BinaryExpressionExpressionBody.output | 5 +++++ 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 core/src/test/resources/cases/new_codestyle/BinaryExpressionExpressionBody.input create mode 100644 core/src/test/resources/cases/new_codestyle/BinaryExpressionExpressionBody.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/BinaryExpressionExpressionBody.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 76f63f91d..e72569504 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -380,7 +380,7 @@ open class KotlinInputAstVisitor( builder.block(ZERO) { builder.token("=") if ( - !emitExpressionAfterOperator(bodyExpression) && + !emitExpressionAfterOperator(bodyExpression, indentHuggedBinaryExpression = true) && !emitChainAfterOperator(bodyExpression) ) { builder.block(expressionBreakIndent) { @@ -1690,12 +1690,17 @@ open class KotlinInputAstVisitor( * @param scopingFunctionHugs whether a lambda or scoping function is emitted by [emitHugged] * instead of by [visitLambdaOrScopingFunction]. The `by` of a delegate is already followed by * its expression on the same line, so its lambda hugs it rather than breaking to it. + * @param indentHuggedBinaryExpression whether the operands of a hugged binary expression are laid + * out one level in from the line the operator is on. The `=` of a declaration or of an + * assignment indents them; an operator that is itself already indented -- the `=` of a named + * argument, say -- keeps them at its own level. * @param emitHugged emits [expression] on the operator's line, after a plain space. Callers whose * expression is wrapped in another PSI node -- the `by` of a delegate -- visit that node here. */ private fun emitExpressionAfterOperator( expression: KtExpression, scopingFunctionHugs: Boolean = false, + indentHuggedBinaryExpression: Boolean = false, emitHugged: () -> Unit = { builder.space() visit(expression) @@ -1717,7 +1722,12 @@ open class KotlinInputAstVisitor( visitChainAfterTrailingLambda(expression, emitLeadingBreak = true) -> Unit hugBlockLikeInfixCalls && expression.isInfixBlockLikeCall -> emitInfixBlockLikeCall(expression) - !forceLineBreakAfterAssignment && expression is KtBinaryExpression -> emitHugged() + !forceLineBreakAfterAssignment && expression is KtBinaryExpression -> + if (indentHuggedBinaryExpression) { + builder.block(expressionBreakIndent) { emitHugged() } + } else { + emitHugged() + } hugWhenExpressions && expression is KtWhenExpression && !expression.hasLeadingComment -> emitWhenExpressionAfterOperator(expression) else -> return false @@ -1752,7 +1762,7 @@ open class KotlinInputAstVisitor( * assignment statement -- according to the kind of expression it is. */ private fun emitAssignedExpression(expression: KtExpression) { - if (emitExpressionAfterOperator(expression)) { + if (emitExpressionAfterOperator(expression, indentHuggedBinaryExpression = true)) { return } // A chain gets to keep its receiver on the `=` line when it fits there; everything else diff --git a/core/src/test/resources/cases/new_codestyle/BinaryExpressionExpressionBody.input b/core/src/test/resources/cases/new_codestyle/BinaryExpressionExpressionBody.input new file mode 100644 index 000000000..09050eac7 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/BinaryExpressionExpressionBody.input @@ -0,0 +1,4 @@ +private fun URI.isCachedOrLocal() = scheme == "file" || +host == "cache-redirector.jetbrains.com" || +host == "teamcity.jetbrains.com" || +host == "buildserver.labs.intellij.net" diff --git a/core/src/test/resources/cases/new_codestyle/BinaryExpressionExpressionBody.new.output b/core/src/test/resources/cases/new_codestyle/BinaryExpressionExpressionBody.new.output new file mode 100644 index 000000000..5cedfaf3e --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/BinaryExpressionExpressionBody.new.output @@ -0,0 +1,4 @@ +private fun URI.isCachedOrLocal() = scheme == "file" || + host == "cache-redirector.jetbrains.com" || + host == "teamcity.jetbrains.com" || + host == "buildserver.labs.intellij.net" diff --git a/core/src/test/resources/cases/new_codestyle/BinaryExpressionExpressionBody.output b/core/src/test/resources/cases/new_codestyle/BinaryExpressionExpressionBody.output new file mode 100644 index 000000000..328a37c31 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/BinaryExpressionExpressionBody.output @@ -0,0 +1,5 @@ +private fun URI.isCachedOrLocal() = + scheme == "file" || + host == "cache-redirector.jetbrains.com" || + host == "teamcity.jetbrains.com" || + host == "buildserver.labs.intellij.net" From 1809ab7868bb1006d9e4f36b16bc6312330a1d7c Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Fri, 7 Aug 2026 11:06:15 +0200 Subject: [PATCH 20/22] [formatter] Add more cases for annotations --- .../ktfmt/format/KotlinInputAstVisitor.kt | 9 +++++- .../AnnotationsOnParameters.input | 28 +++++++++++++++++++ .../AnnotationsOnParameters.new.output | 28 +++++++++++++++++++ .../AnnotationsOnParameters.output | 28 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 core/src/test/resources/cases/new_codestyle/AnnotationsOnParameters.input create mode 100644 core/src/test/resources/cases/new_codestyle/AnnotationsOnParameters.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/AnnotationsOnParameters.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index e72569504..dc4f50a50 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -2240,7 +2240,14 @@ open class KotlinInputAstVisitor( visit(psi) } - if (onlyAnnotationsSoFar && forceAnnotationBreaks && psi is KtAnnotationEntry) { + val shouldForceBreak = forceAnnotationBreaks && + // don't force break on parameter annotations + list.parent !is KtParameter && + // don't force break on receiver type annotations + !(list.parent is KtTypeReference && list.parent.parent is KtFunction) && + // don't force break on parameter type annotations + !(list.parent is KtTypeReference && list.parent.parent is KtParameter) + if (onlyAnnotationsSoFar && shouldForceBreak) { builder.forcedBreak() } else if (onlyAnnotationsSoFar) { builder.breakOp(" ") diff --git a/core/src/test/resources/cases/new_codestyle/AnnotationsOnParameters.input b/core/src/test/resources/cases/new_codestyle/AnnotationsOnParameters.input new file mode 100644 index 000000000..229565716 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/AnnotationsOnParameters.input @@ -0,0 +1,28 @@ +private class AnnotatedConstructor( + @JvmField @Volatile private var state: Int, + @get:JvmName("ctx") val context: CoroutineContext, + @Suppress("UNUSED_PARAMETER") unused: String = "", + @property:Inject val dependency: Dependency, + @Deprecated("Use context instead", ReplaceWith("context")) val legacyContext: CoroutineContext, +) + +private class AnnotatedParameters { + fun single(@NotNull a: String) = a + + fun multiple(@NotNull @Size(min = 1) a: String, @Nullable b: String?) = a + b + + fun wrapped( + @NotNull @Size(min = 1, max = 100) someVeryLongParameterName: String, + @Nullable anotherRatherLongParameterName: String? = null, + ) = someVeryLongParameterName + anotherRatherLongParameterName + + fun varargs(@NotNull vararg values: String) = values.size + + fun higherOrder(@NotNull block: (@Nullable Int) -> Unit) = block(1) + + fun @receiver:NotNull String.extension(@NotNull other: String) = this + other + + fun withLambda(@NotNull name: String, @Nullable block: () -> Unit = {}) { + block() + } +} diff --git a/core/src/test/resources/cases/new_codestyle/AnnotationsOnParameters.new.output b/core/src/test/resources/cases/new_codestyle/AnnotationsOnParameters.new.output new file mode 100644 index 000000000..229565716 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/AnnotationsOnParameters.new.output @@ -0,0 +1,28 @@ +private class AnnotatedConstructor( + @JvmField @Volatile private var state: Int, + @get:JvmName("ctx") val context: CoroutineContext, + @Suppress("UNUSED_PARAMETER") unused: String = "", + @property:Inject val dependency: Dependency, + @Deprecated("Use context instead", ReplaceWith("context")) val legacyContext: CoroutineContext, +) + +private class AnnotatedParameters { + fun single(@NotNull a: String) = a + + fun multiple(@NotNull @Size(min = 1) a: String, @Nullable b: String?) = a + b + + fun wrapped( + @NotNull @Size(min = 1, max = 100) someVeryLongParameterName: String, + @Nullable anotherRatherLongParameterName: String? = null, + ) = someVeryLongParameterName + anotherRatherLongParameterName + + fun varargs(@NotNull vararg values: String) = values.size + + fun higherOrder(@NotNull block: (@Nullable Int) -> Unit) = block(1) + + fun @receiver:NotNull String.extension(@NotNull other: String) = this + other + + fun withLambda(@NotNull name: String, @Nullable block: () -> Unit = {}) { + block() + } +} diff --git a/core/src/test/resources/cases/new_codestyle/AnnotationsOnParameters.output b/core/src/test/resources/cases/new_codestyle/AnnotationsOnParameters.output new file mode 100644 index 000000000..7c4fdbc1b --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/AnnotationsOnParameters.output @@ -0,0 +1,28 @@ +private class AnnotatedConstructor( + @JvmField @Volatile private var state: Int, + @get:JvmName("ctx") val context: CoroutineContext, + @Suppress("UNUSED_PARAMETER") unused: String = "", + @property:Inject val dependency: Dependency, + @Deprecated("Use context instead", ReplaceWith("context")) val legacyContext: CoroutineContext, +) + +private class AnnotatedParameters { + fun single(@NotNull a: String) = a + + fun multiple(@NotNull @Size(min = 1) a: String, @Nullable b: String?) = a + b + + fun wrapped( + @NotNull @Size(min = 1, max = 100) someVeryLongParameterName: String, + @Nullable anotherRatherLongParameterName: String? = null, + ) = someVeryLongParameterName + anotherRatherLongParameterName + + fun varargs(@NotNull vararg values: String) = values.size + + fun higherOrder(@NotNull block: (@Nullable Int) -> Unit) = block(1) + + fun @receiver:NotNull String.extension(@NotNull other: String) = this + other + + fun withLambda(@NotNull name: String, @Nullable block: () -> Unit = {}) { + block() + } +} From c4f1f7c91f89ece2f40e1544a6e46ae1e5ade9bd Mon Sep 17 00:00:00 2001 From: Vsevolod Tolstopyatov Date: Fri, 7 Aug 2026 15:07:33 +0200 Subject: [PATCH 21/22] Fix formatting of KotlinInputAstVisitor --- .../com/facebook/ktfmt/format/KotlinInputAstVisitor.kt | 3 ++- .../src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt | 7 +++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index dc4f50a50..275c16e28 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -2240,7 +2240,8 @@ open class KotlinInputAstVisitor( visit(psi) } - val shouldForceBreak = forceAnnotationBreaks && + val shouldForceBreak = + forceAnnotationBreaks && // don't force break on parameter annotations list.parent !is KtParameter && // don't force break on receiver type annotations diff --git a/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt b/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt index c74010aac..f8e41735c 100644 --- a/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt +++ b/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt @@ -289,10 +289,9 @@ class ParsedArgsTest { @Test fun `processArgs use the @file option with non existing file`() { - val e = - assertThrows { - ParsedArgs.processArgs(arrayOf("@non-existing-file")) - } + val e = assertThrows { + ParsedArgs.processArgs(arrayOf("@non-existing-file")) + } assertContains(e.message, "non-existing-file") } From 216878704c08304245568949933521a7ee6bde78 Mon Sep 17 00:00:00 2001 From: Azat Abdullin Date: Fri, 7 Aug 2026 15:11:36 +0200 Subject: [PATCH 22/22] [formatter] Conditions formatting --- .../facebook/ktfmt/format/KotlinInputAstVisitor.kt | 5 +++-- .../ktfmt/format/KotlinLangInputAstVisitor.kt | 1 + .../cases/new_codestyle/BinaryExpressionInIf.input | 8 ++++++++ .../new_codestyle/BinaryExpressionInIf.new.output | 8 ++++++++ .../cases/new_codestyle/BinaryExpressionInIf.output | 8 ++++++++ .../cases/new_codestyle/BooleanConditions.new.output | 6 ++---- .../new_codestyle/DoWhileConditionWrapping.input | 5 +++++ .../DoWhileConditionWrapping.new.output | 8 ++++++++ .../new_codestyle/DoWhileConditionWrapping.output | 8 ++++++++ .../cases/new_codestyle/WhenBooleanSubject.input | 6 ++++++ .../new_codestyle/WhenBooleanSubject.new.output | 9 +++++++++ .../cases/new_codestyle/WhenBooleanSubject.output | 9 +++++++++ .../resources/cases/new_codestyle/WhenGuard.input | 7 +++++++ .../cases/new_codestyle/WhenGuard.new.output | 10 ++++++++++ .../resources/cases/new_codestyle/WhenGuard.output | 10 ++++++++++ .../cases/new_codestyle/WhenSubjectWrapping.input | 6 ++++++ .../new_codestyle/WhenSubjectWrapping.new.output | 12 ++++++++++++ .../cases/new_codestyle/WhenSubjectWrapping.output | 7 +++++++ .../cases/new_codestyle/WhileCallCondition.input | 5 +++++ .../new_codestyle/WhileCallCondition.new.output | 11 +++++++++++ .../cases/new_codestyle/WhileCallCondition.output | 6 ++++++ .../cases/new_codestyle/WhileConditionWrapping.input | 5 +++++ .../new_codestyle/WhileConditionWrapping.new.output | 8 ++++++++ .../new_codestyle/WhileConditionWrapping.output | 8 ++++++++ 24 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 core/src/test/resources/cases/new_codestyle/BinaryExpressionInIf.input create mode 100644 core/src/test/resources/cases/new_codestyle/BinaryExpressionInIf.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/BinaryExpressionInIf.output create mode 100644 core/src/test/resources/cases/new_codestyle/DoWhileConditionWrapping.input create mode 100644 core/src/test/resources/cases/new_codestyle/DoWhileConditionWrapping.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/DoWhileConditionWrapping.output create mode 100644 core/src/test/resources/cases/new_codestyle/WhenBooleanSubject.input create mode 100644 core/src/test/resources/cases/new_codestyle/WhenBooleanSubject.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/WhenBooleanSubject.output create mode 100644 core/src/test/resources/cases/new_codestyle/WhenGuard.input create mode 100644 core/src/test/resources/cases/new_codestyle/WhenGuard.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/WhenGuard.output create mode 100644 core/src/test/resources/cases/new_codestyle/WhenSubjectWrapping.input create mode 100644 core/src/test/resources/cases/new_codestyle/WhenSubjectWrapping.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/WhenSubjectWrapping.output create mode 100644 core/src/test/resources/cases/new_codestyle/WhileCallCondition.input create mode 100644 core/src/test/resources/cases/new_codestyle/WhileCallCondition.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/WhileCallCondition.output create mode 100644 core/src/test/resources/cases/new_codestyle/WhileConditionWrapping.input create mode 100644 core/src/test/resources/cases/new_codestyle/WhileConditionWrapping.new.output create mode 100644 core/src/test/resources/cases/new_codestyle/WhileConditionWrapping.output diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 275c16e28..c1ae3ab02 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -149,6 +149,7 @@ open class KotlinInputAstVisitor( internal open val hugBlockLikeInfixCalls: Boolean = false internal open val hugCallsWithTrailingLambda: Boolean = false internal open val hugChainsAfterTrailingLambda: Boolean = false + internal open val hugConditionParens: Boolean = false internal open val hugWhenExpressions: Boolean = false internal open val indentBooleanConditions: Boolean = true internal open val forceLineBreakInWhenConditionList: Boolean = true @@ -3179,14 +3180,14 @@ open class KotlinInputAstVisitor( if (surroundConditionWithParens) { builder.token("(") } - if (options.manageTrailingCommas) { + if (options.manageTrailingCommas && !hugConditionParens) { builder.block(expressionBreakIndent) { builder.breakOp() visit(condition) builder.breakOp(expressionBreakNegativeIndent) } } else { - builder.block(ZERO) { visit(condition) } + builder.block(if (hugConditionParens) expressionBreakIndent else ZERO) { visit(condition) } } } if (surroundConditionWithParens) { diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt index 284c10ea0..b0c92a0d2 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinLangInputAstVisitor.kt @@ -14,6 +14,7 @@ internal class KotlinLangInputAstVisitor( override val hugBlockLikeInfixCalls: Boolean = true override val hugCallsWithTrailingLambda: Boolean = true override val hugChainsAfterTrailingLambda: Boolean = true + override val hugConditionParens: Boolean = true override val hugWhenExpressions: Boolean = true override val indentBooleanConditions: Boolean = false override val forceLineBreakInWhenConditionList: Boolean = false diff --git a/core/src/test/resources/cases/new_codestyle/BinaryExpressionInIf.input b/core/src/test/resources/cases/new_codestyle/BinaryExpressionInIf.input new file mode 100644 index 000000000..3055f3641 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/BinaryExpressionInIf.input @@ -0,0 +1,8 @@ +fun f() { + if (aaaaa == null || + aaaaa.bbbbb[0] == null || + aaaaa.bbbbb[0].cc == null || + aaaaa.bbbbb[0].dddd == null) { + println() + } +} diff --git a/core/src/test/resources/cases/new_codestyle/BinaryExpressionInIf.new.output b/core/src/test/resources/cases/new_codestyle/BinaryExpressionInIf.new.output new file mode 100644 index 000000000..f3f706b6c --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/BinaryExpressionInIf.new.output @@ -0,0 +1,8 @@ +fun f() { + if (aaaaa == null || + aaaaa.bbbbb[0] == null || + aaaaa.bbbbb[0].cc == null || + aaaaa.bbbbb[0].dddd == null) { + println() + } +} diff --git a/core/src/test/resources/cases/new_codestyle/BinaryExpressionInIf.output b/core/src/test/resources/cases/new_codestyle/BinaryExpressionInIf.output new file mode 100644 index 000000000..3055f3641 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/BinaryExpressionInIf.output @@ -0,0 +1,8 @@ +fun f() { + if (aaaaa == null || + aaaaa.bbbbb[0] == null || + aaaaa.bbbbb[0].cc == null || + aaaaa.bbbbb[0].dddd == null) { + println() + } +} diff --git a/core/src/test/resources/cases/new_codestyle/BooleanConditions.new.output b/core/src/test/resources/cases/new_codestyle/BooleanConditions.new.output index b2d6f4734..728d8fcd3 100644 --- a/core/src/test/resources/cases/new_codestyle/BooleanConditions.new.output +++ b/core/src/test/resources/cases/new_codestyle/BooleanConditions.new.output @@ -1,10 +1,8 @@ fun f1() { - if ( - unwrapped !== rootCause && + if (unwrapped !== rootCause && unwrapped !== unwrappedCause && unwrapped !is CancellationException && - seenExceptions.add(unwrapped) - ) { + seenExceptions.add(unwrapped)) { rootCause.addSuppressed(unwrapped) } } diff --git a/core/src/test/resources/cases/new_codestyle/DoWhileConditionWrapping.input b/core/src/test/resources/cases/new_codestyle/DoWhileConditionWrapping.input new file mode 100644 index 000000000..c2220a7fe --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/DoWhileConditionWrapping.input @@ -0,0 +1,5 @@ +fun f() { + do { + println() + } while (aaaaa == null || aaaaa.bbbbb[0] == null || aaaaa.bbbbb[0].cc == null || aaaaa.bbbbb[0].dddd == null) +} diff --git a/core/src/test/resources/cases/new_codestyle/DoWhileConditionWrapping.new.output b/core/src/test/resources/cases/new_codestyle/DoWhileConditionWrapping.new.output new file mode 100644 index 000000000..a0cc67e1e --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/DoWhileConditionWrapping.new.output @@ -0,0 +1,8 @@ +fun f() { + do { + println() + } while (aaaaa == null || + aaaaa.bbbbb[0] == null || + aaaaa.bbbbb[0].cc == null || + aaaaa.bbbbb[0].dddd == null) +} diff --git a/core/src/test/resources/cases/new_codestyle/DoWhileConditionWrapping.output b/core/src/test/resources/cases/new_codestyle/DoWhileConditionWrapping.output new file mode 100644 index 000000000..d41cf4705 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/DoWhileConditionWrapping.output @@ -0,0 +1,8 @@ +fun f() { + do { + println() + } while (aaaaa == null || + aaaaa.bbbbb[0] == null || + aaaaa.bbbbb[0].cc == null || + aaaaa.bbbbb[0].dddd == null) +} diff --git a/core/src/test/resources/cases/new_codestyle/WhenBooleanSubject.input b/core/src/test/resources/cases/new_codestyle/WhenBooleanSubject.input new file mode 100644 index 000000000..b4cb2e1ef --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhenBooleanSubject.input @@ -0,0 +1,6 @@ +fun f() { + when (aaaaa == null || aaaaa.bbbbb[0] == null || aaaaa.bbbbb[0].cc == null || aaaaa.bbbbb[0].dddd == null) { + true -> println("yes") + false -> println("no") + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhenBooleanSubject.new.output b/core/src/test/resources/cases/new_codestyle/WhenBooleanSubject.new.output new file mode 100644 index 000000000..fa2602b77 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhenBooleanSubject.new.output @@ -0,0 +1,9 @@ +fun f() { + when (aaaaa == null || + aaaaa.bbbbb[0] == null || + aaaaa.bbbbb[0].cc == null || + aaaaa.bbbbb[0].dddd == null) { + true -> println("yes") + false -> println("no") + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhenBooleanSubject.output b/core/src/test/resources/cases/new_codestyle/WhenBooleanSubject.output new file mode 100644 index 000000000..dd4770601 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhenBooleanSubject.output @@ -0,0 +1,9 @@ +fun f() { + when (aaaaa == null || + aaaaa.bbbbb[0] == null || + aaaaa.bbbbb[0].cc == null || + aaaaa.bbbbb[0].dddd == null) { + true -> println("yes") + false -> println("no") + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhenGuard.input b/core/src/test/resources/cases/new_codestyle/WhenGuard.input new file mode 100644 index 000000000..acd34de52 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhenGuard.input @@ -0,0 +1,7 @@ +fun f(state: State) { + when (state) { + is Loaded if state.firstCondition && state.secondCondition && state.thirdCondition && state.fourthCondition -> handle() + is Empty if state.isReallyEmpty -> Unit + else -> Unit + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhenGuard.new.output b/core/src/test/resources/cases/new_codestyle/WhenGuard.new.output new file mode 100644 index 000000000..6972b2de6 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhenGuard.new.output @@ -0,0 +1,10 @@ +fun f(state: State) { + when (state) { + is Loaded if state.firstCondition && + state.secondCondition && + state.thirdCondition && + state.fourthCondition -> handle() + is Empty if state.isReallyEmpty -> Unit + else -> Unit + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhenGuard.output b/core/src/test/resources/cases/new_codestyle/WhenGuard.output new file mode 100644 index 000000000..7291cbd73 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhenGuard.output @@ -0,0 +1,10 @@ +fun f(state: State) { + when (state) { + is Loaded if state.firstCondition && + state.secondCondition && + state.thirdCondition && + state.fourthCondition -> handle() + is Empty if state.isReallyEmpty -> Unit + else -> Unit + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhenSubjectWrapping.input b/core/src/test/resources/cases/new_codestyle/WhenSubjectWrapping.input new file mode 100644 index 000000000..a426e2837 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhenSubjectWrapping.input @@ -0,0 +1,6 @@ +fun f() { + when (computeSomething(firstArgument, secondArgument, thirdArgument, fourthArgument, fifthArgument)) { + 0 -> println("zero") + else -> println("other") + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhenSubjectWrapping.new.output b/core/src/test/resources/cases/new_codestyle/WhenSubjectWrapping.new.output new file mode 100644 index 000000000..d2c2fb7a4 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhenSubjectWrapping.new.output @@ -0,0 +1,12 @@ +fun f() { + when (computeSomething( + firstArgument, + secondArgument, + thirdArgument, + fourthArgument, + fifthArgument, + )) { + 0 -> println("zero") + else -> println("other") + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhenSubjectWrapping.output b/core/src/test/resources/cases/new_codestyle/WhenSubjectWrapping.output new file mode 100644 index 000000000..d7624ba81 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhenSubjectWrapping.output @@ -0,0 +1,7 @@ +fun f() { + when (computeSomething( + firstArgument, secondArgument, thirdArgument, fourthArgument, fifthArgument)) { + 0 -> println("zero") + else -> println("other") + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhileCallCondition.input b/core/src/test/resources/cases/new_codestyle/WhileCallCondition.input new file mode 100644 index 000000000..d1c585260 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhileCallCondition.input @@ -0,0 +1,5 @@ +fun f() { + while (computeSomething(firstArgument, secondArgument, thirdArgument, fourthArgument, fifthArgument)) { + println() + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhileCallCondition.new.output b/core/src/test/resources/cases/new_codestyle/WhileCallCondition.new.output new file mode 100644 index 000000000..5f15a97ce --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhileCallCondition.new.output @@ -0,0 +1,11 @@ +fun f() { + while (computeSomething( + firstArgument, + secondArgument, + thirdArgument, + fourthArgument, + fifthArgument, + )) { + println() + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhileCallCondition.output b/core/src/test/resources/cases/new_codestyle/WhileCallCondition.output new file mode 100644 index 000000000..e8be5a5b0 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhileCallCondition.output @@ -0,0 +1,6 @@ +fun f() { + while (computeSomething( + firstArgument, secondArgument, thirdArgument, fourthArgument, fifthArgument)) { + println() + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhileConditionWrapping.input b/core/src/test/resources/cases/new_codestyle/WhileConditionWrapping.input new file mode 100644 index 000000000..905ef09c5 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhileConditionWrapping.input @@ -0,0 +1,5 @@ +fun f() { + while (aaaaa == null || aaaaa.bbbbb[0] == null || aaaaa.bbbbb[0].cc == null || aaaaa.bbbbb[0].dddd == null) { + println() + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhileConditionWrapping.new.output b/core/src/test/resources/cases/new_codestyle/WhileConditionWrapping.new.output new file mode 100644 index 000000000..faaba1b7b --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhileConditionWrapping.new.output @@ -0,0 +1,8 @@ +fun f() { + while (aaaaa == null || + aaaaa.bbbbb[0] == null || + aaaaa.bbbbb[0].cc == null || + aaaaa.bbbbb[0].dddd == null) { + println() + } +} diff --git a/core/src/test/resources/cases/new_codestyle/WhileConditionWrapping.output b/core/src/test/resources/cases/new_codestyle/WhileConditionWrapping.output new file mode 100644 index 000000000..093694764 --- /dev/null +++ b/core/src/test/resources/cases/new_codestyle/WhileConditionWrapping.output @@ -0,0 +1,8 @@ +fun f() { + while (aaaaa == null || + aaaaa.bbbbb[0] == null || + aaaaa.bbbbb[0].cc == null || + aaaaa.bbbbb[0].dddd == null) { + println() + } +}