From 6f011883f99232150d0e82a339d1d53f39b6c072 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 1 Dec 2025 16:52:19 -0500 Subject: [PATCH 1/5] Phase 1: Add cascadeNestedLambdaBreaks configuration option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new boolean configuration option to FormattingOptions and wire it through the command-line interface. This option will enable cascading lambda break decisions to nested trailing lambdas, useful for DSLs like Jetpack Compose. Changes: - Add cascadeNestedLambdaBreaks field to FormattingOptions data class - Add --cascade-nested-lambda-breaks command-line flag to ParsedArgs - Update CLI help text to document the new option - Default value is false to maintain backward compatibility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../java/com/facebook/ktfmt/cli/ParsedArgs.kt | 9 +++++-- .../ktfmt/format/FormattingOptions.kt | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt b/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt index 8eff0047..395347a6 100644 --- a/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt +++ b/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt @@ -72,15 +72,17 @@ data class ParsedArgs( |Commands options: | -h, --help Show this help message | -v, --version Show version - | -n, --dry-run Don't write to files, only report files which + | -n, --dry-run Don't write to files, only report files which | would have changed | --meta-style Use 2-space block indenting (default) | --google-style Google internal style (2 spaces) | --kotlinlang-style Kotlin language guidelines style (4 spaces) | --stdin-name= Name to report when formatting code from stdin - | --set-exit-if-changed Sets exit code to 1 if any input file was not + | --set-exit-if-changed Sets exit code to 1 if any input file was not | formatted/touched | --do-not-remove-unused-imports Leaves all imports in place, even if not used + | --cascade-nested-lambda-breaks Force nested trailing lambdas to break when parent + | breaks (useful for Compose DSLs) | |ARGFILE: | If the only argument begins with '@', the remainder of the argument is treated @@ -120,6 +122,9 @@ data class ParsedArgs( arg == "--dry-run" || arg == "-n" -> dryRun = true arg == "--set-exit-if-changed" -> setExitIfChanged = true arg == "--do-not-remove-unused-imports" -> removeUnusedImports = false + arg == "--cascade-nested-lambda-breaks" -> { + formattingOptions = formattingOptions.copy(cascadeNestedLambdaBreaks = true) + } arg.startsWith("--stdin-name=") -> stdinName = parseKeyValueArg("--stdin-name", arg) diff --git a/core/src/main/java/com/facebook/ktfmt/format/FormattingOptions.kt b/core/src/main/java/com/facebook/ktfmt/format/FormattingOptions.kt index caa6a584..c38c53fc 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/FormattingOptions.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/FormattingOptions.kt @@ -57,6 +57,31 @@ data class FormattingOptions( /** Whether ktfmt should remove imports that are not used. */ val removeUnusedImports: Boolean = true, + /** + * Whether to cascade (propagate) lambda break decisions to nested trailing lambdas. + * + * When enabled, if a parent lambda breaks to multiple lines, all nested trailing lambdas will + * also be forced to break, preserving the visual hierarchy. This is particularly useful for + * DSLs like Jetpack Compose where nesting represents semantic parent-child relationships. + * + * For example, when enabled: + * ``` + * App { + * SelectableCard { + * Button { + * Text("") // Forced to multi-line despite being simple + * } + * } + * } + * ``` + * + * When disabled (default), the same code might format as: + * ``` + * App { SelectableCard { Button { Text("") }}} + * ``` + */ + val cascadeNestedLambdaBreaks: Boolean = false, + /** * Print the Ops generated by KotlinInputAstVisitor to help reason about formatting (i.e., * newline) decisions From 13e8f28ee69166c0924c5c99cba3c446e87ed23b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 1 Dec 2025 16:53:52 -0500 Subject: [PATCH 2/5] Phase 2: Thread context through lambda visitors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add parentLambdaBroke parameter to track parent lambda break state through nested lambda expressions. Add helper method to identify trailing lambdas that should inherit hierarchy preservation. Changes: - Add parentLambdaBroke parameter to visitLambdaExpressionInternal - Add isTrailingLambda helper method to detect trailing lambdas - Update method documentation to explain new parameter - All call sites use default value (false) for now No functional changes yet - context is passed but not used. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../ktfmt/format/KotlinInputAstVisitor.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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 1cc8b11f..e9480075 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -863,10 +863,15 @@ class KotlinInputAstVisitor( * car() * } * ``` + * + * @param parentLambdaBroke used when cascadeNestedLambdaBreaks is enabled to track whether the + * parent lambda has broken to multi-line, so that nested trailing lambdas can be forced to + * break as well */ private fun visitLambdaExpressionInternal( lambdaExpression: KtLambdaExpression, brokeBeforeBrace: BreakTag?, + parentLambdaBroke: Boolean = false, ) { builder.sync(lambdaExpression) @@ -1501,6 +1506,30 @@ class KotlinInputAstVisitor( return false } + /** + * Determines if the given expression is a trailing lambda in a call expression. + * + * This is used to identify lambdas that should inherit hierarchy preservation when + * cascadeNestedLambdaBreaks is enabled. + */ + private fun isTrailingLambda(expression: KtExpression?): Boolean { + if (expression == null) return false + val parent = expression.parent + + // Check if this lambda is in the lambdaArguments list of a call expression + if (parent is KtLambdaArgument) { + val callExpression = parent.parent as? KtCallExpression + return callExpression?.lambdaArguments?.contains(parent) == true + } + + // Check if this is a labeled lambda that's a trailing lambda + if (expression is KtLabeledExpression) { + return isTrailingLambda(expression.parent as? KtExpression) + } + + return false + } + /** See [isLambdaOrScopingFunction] for examples. */ private fun visitLambdaOrScopingFunction(expr: PsiElement?) { val breakToExpr = genSym() From 11bbd0bc03c70a0054d5e8be797b817ae4162b06 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 1 Dec 2025 16:56:26 -0500 Subject: [PATCH 3/5] Phase 3: Implement break propagation logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the core cascading lambda break functionality. When cascadeNestedLambdaBreaks is enabled and a parent lambda breaks to multi-line, all nested trailing lambdas are forced to break as well, preserving visual hierarchy. Changes: - Add insideBreakingLambda member variable to track break state - Detect when current lambda will break to multi-line - Force nested trailing lambdas to multi-line when parent broke - Propagate break state through nested lambda visitor calls - Update visitArgumentInternal and visitLambdaOrScopingFunction The feature is now functional when --cascade-nested-lambda-breaks flag is enabled. Backward compatible - default behavior unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../ktfmt/format/KotlinInputAstVisitor.kt | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 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 e9480075..db381766 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -138,6 +138,12 @@ class KotlinInputAstVisitor( private val builder: OpsBuilder, ) : KtTreeVisitorVoid() { + /** + * Track whether we're currently inside a lambda that has broken to multi-line. Used when + * cascadeNestedLambdaBreaks is enabled to force nested trailing lambdas to break. + */ + private var insideBreakingLambda: Boolean = false + /** Standard indentation for a block */ private val blockIndent: Indent.Const = Indent.Const.make(options.blockIndent, 1) @@ -928,15 +934,46 @@ class KotlinInputAstVisitor( builder.breakOp(Doc.FillMode.UNIFIED, "", bracePlusBlockIndent) builder.block(bracePlusBlockIndent) { builder.blankLineWanted(OpsBuilder.BlankLineWanted.NO) + + // FORCE multi-line when parent lambda broke and option is enabled + val shouldForceMultiline = + options.cascadeNestedLambdaBreaks && + parentLambdaBroke && + isTrailingLambda(lambdaExpression) + + // Determine if this lambda will break to multi-line + // A lambda breaks when it either: + // 1. Is forced to break by the cascadeNestedLambdaBreaks option + // 2. Has multiple statements + // 3. Has a return expression + // 4. Has a comment before the first statement + val currentLambdaWillBreak = + shouldForceMultiline || + expressionStatements.size > 1 || + expressionStatements.firstOrNull() is KtReturnExpression || + bodyExpression.startsWithComment() + + // Track lambda break state for nested lambdas + val previousInsideBreakingLambda = insideBreakingLambda + if (options.cascadeNestedLambdaBreaks && isTrailingLambda(lambdaExpression)) { + insideBreakingLambda = currentLambdaWillBreak + } + if ( - expressionStatements.size == 1 && + !shouldForceMultiline && + expressionStatements.size == 1 && expressionStatements.first() !is KtReturnExpression && !bodyExpression.startsWithComment() ) { visitStatement(expressionStatements[0]) } else { + // FORCED multi-line: either naturally multi-line OR forced by parent visitStatements(expressionStatements) } + + // Restore previous state + insideBreakingLambda = previousInsideBreakingLambda + builder.breakOp(Doc.FillMode.UNIFIED, " ", bracePlusZeroIndent) } } @@ -1167,6 +1204,7 @@ class KotlinInputAstVisitor( visitLambdaExpressionInternal( argument.getArgumentExpression() as KtLambdaExpression, brokeBeforeBrace = brokeBeforeBrace, + parentLambdaBroke = insideBreakingLambda, ) } else { visit(argument.getArgumentExpression()) @@ -1546,7 +1584,11 @@ class KotlinInputAstVisitor( carry = carry.baseExpression ?: fail() } if (carry is KtLambdaExpression) { - visitLambdaExpressionInternal(carry, brokeBeforeBrace = breakToExpr) + visitLambdaExpressionInternal( + carry, + brokeBeforeBrace = breakToExpr, + parentLambdaBroke = insideBreakingLambda, + ) return } From 5eecefbd18d832cbfde3b5d58300fd2a49215e59 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 1 Dec 2025 17:21:25 -0500 Subject: [PATCH 4/5] Phase 4: Add comprehensive test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 7 comprehensive test cases covering various scenarios for the cascadeNestedLambdaBreaks feature. Tests verify basic hierarchy preservation, backward compatibility, deep nesting, edge cases, and that only trailing lambdas are affected. Changes: - Add test for forcing nested lambda hierarchy when option enabled - Add test for maintaining single line when option disabled - Add test for forcing only trailing lambdas (not map/filter) - Add test for forcing behavior when parent breaks - Add test for preserving hierarchy with multiple statements - Add test for handling deep nesting levels - Add test for no effect on non-nested lambdas - Fix containsTrailingLambdas to correctly detect nested lambdas - Update break detection logic to handle hierarchy properly All tests pass, feature fully functional. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../ktfmt/format/KotlinInputAstVisitor.kt | 37 ++- .../facebook/ktfmt/format/FormatterTest.kt | 245 ++++++++++++++++++ 2 files changed, 278 insertions(+), 4 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 db381766..bb4ec5a6 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -943,12 +943,16 @@ class KotlinInputAstVisitor( // Determine if this lambda will break to multi-line // A lambda breaks when it either: - // 1. Is forced to break by the cascadeNestedLambdaBreaks option - // 2. Has multiple statements - // 3. Has a return expression - // 4. Has a comment before the first statement + // 1. Is forced to break by the cascadeNestedLambdaBreaks option (parent broke) + // 2. Contains nested trailing lambdas and cascadeNestedLambdaBreaks is enabled + // 3. Has multiple statements + // 4. Has a return expression + // 5. Has a comment before the first statement val currentLambdaWillBreak = shouldForceMultiline || + (options.cascadeNestedLambdaBreaks && + isTrailingLambda(lambdaExpression) && + containsTrailingLambdas(lambdaExpression)) || expressionStatements.size > 1 || expressionStatements.firstOrNull() is KtReturnExpression || bodyExpression.startsWithComment() @@ -1568,6 +1572,31 @@ class KotlinInputAstVisitor( return false } + /** + * Determines if a lambda expression contains trailing lambda calls in its body. + * + * This is used to detect when a lambda should be forced to break because it contains nested + * trailing lambdas that form a hierarchy needing preservation. + */ + private fun containsTrailingLambdas(lambdaExpression: KtLambdaExpression): Boolean { + val body = lambdaExpression.bodyExpression ?: return false + + // Check if any element in the body is a call expression with trailing lambdas + fun checkElement(element: PsiElement): Boolean { + if (element is KtCallExpression && element.lambdaArguments.isNotEmpty()) { + return true + } + for (child in element.children) { + if (checkElement(child)) { + return true + } + } + return false + } + + return checkElement(body) + } + /** See [isLambdaOrScopingFunction] for examples. */ private fun visitLambdaOrScopingFunction(expr: PsiElement?) { val breakToExpr = genSym() diff --git a/core/src/test/java/com/facebook/ktfmt/format/FormatterTest.kt b/core/src/test/java/com/facebook/ktfmt/format/FormatterTest.kt index 264e32ec..4a356209 100644 --- a/core/src/test/java/com/facebook/ktfmt/format/FormatterTest.kt +++ b/core/src/test/java/com/facebook/ktfmt/format/FormatterTest.kt @@ -8610,6 +8610,251 @@ class FormatterTest { assertThatFormatting(code).isEqualTo(expected) } + @Test + fun `cascade nested lambda breaks - force nested lambda hierarchy when option enabled`() { + val code = + """ + |fun compose() { + | App { SelectableCard { Button { Text("Hello") } } } + |} + |""" + .trimMargin() + + val expected = + """ + |fun compose() { + | App { + | SelectableCard { + | Button { + | Text("Hello") + | } + | } + | } + |} + |""" + .trimMargin() + + assertThatFormatting(code) + .withOptions( + FormattingOptions( + cascadeNestedLambdaBreaks = true, + blockIndent = 2, + continuationIndent = 4, + ) + ) + .isEqualTo(expected) + } + + @Test + fun `cascade nested lambda breaks - maintain single line when option disabled`() { + val code = + """ + |fun compose() { + | App { SelectableCard { Button { Text("Hello") } } } + |} + |""" + .trimMargin() + + val expected = + """ + |fun compose() { + | App { SelectableCard { Button { Text("Hello") } } } + |} + |""" + .trimMargin() + + assertThatFormatting(code) + .withOptions( + FormattingOptions( + cascadeNestedLambdaBreaks = false, + blockIndent = 2, + continuationIndent = 4, + ) + ) + .isEqualTo(expected) + } + + @Test + fun `cascade nested lambda breaks - force hierarchy only for trailing lambdas`() { + val code = + """ + |fun test() { + | val result = listOf(1, 2, 3).map { it * 2 }.filter { it > 2 } + | Container { + | Box { + | Text("Hi") + | } + | } + |} + |""" + .trimMargin() + + val expected = + """ + |fun test() { + | val result = listOf(1, 2, 3).map { it * 2 }.filter { it > 2 } + | Container { + | Box { + | Text("Hi") + | } + | } + |} + |""" + .trimMargin() + + assertThatFormatting(code) + .withOptions( + FormattingOptions( + cascadeNestedLambdaBreaks = true, + blockIndent = 2, + continuationIndent = 4, + ) + ) + .isEqualTo(expected) + } + + @Test + fun `cascade nested lambda breaks - demonstrate forcing behavior with parent break`() { + val code = + """ + |fun test() { + | Container { + | val x = 1 + | Card { Text("x") } + | } + | Container { Card { Text("x") } } + |} + |""" + .trimMargin() + + val expected = + """ + |fun test() { + | Container { + | val x = 1 + | Card { + | Text("x") + | } + | } + | Container { + | Card { + | Text("x") + | } + | } + |} + |""" + .trimMargin() + + assertThatFormatting(code) + .withOptions( + FormattingOptions( + cascadeNestedLambdaBreaks = true, + blockIndent = 2, + continuationIndent = 4, + ) + ) + .isEqualTo(expected) + } + + @Test + fun `cascade nested lambda breaks - preserve hierarchy with multiple statements`() { + val code = + """ + |fun compose() { + | App { + | val state = remember { mutableStateOf(0) } + | SelectableCard { + | Button { + | state.value++ + | Text("Count: ${'$'}{state.value}") + | } + | } + | } + |} + |""" + .trimMargin() + + val expected = + """ + |fun compose() { + | App { + | val state = remember { + | mutableStateOf(0) + | } + | SelectableCard { + | Button { + | state.value++ + | Text("Count: ${'$'}{state.value}") + | } + | } + | } + |} + |""" + .trimMargin() + + assertThatFormatting(code) + .withOptions( + FormattingOptions( + cascadeNestedLambdaBreaks = true, + blockIndent = 2, + continuationIndent = 4, + ) + ) + .isEqualTo(expected) + } + + @Test + fun `cascade nested lambda breaks - handle deep nesting levels`() { + val code = + """ + |fun deepCompose() { + | Level1 { + | Level2 { + | Level3 { + | Level4 { + | Level5 { + | Text("Deep") + | } + | } + | } + | } + | } + |} + |""" + .trimMargin() + + assertThatFormatting(code) + .withOptions( + FormattingOptions( + cascadeNestedLambdaBreaks = true, + blockIndent = 2, + continuationIndent = 4, + ) + ) + .isEqualTo(code) + } + + @Test + fun `cascade nested lambda breaks - no effect on non-nested lambdas`() { + val code = + """ + |fun test() { + | singleCall { doSomething() } + |} + |""" + .trimMargin() + + assertThatFormatting(code) + .withOptions( + FormattingOptions( + cascadeNestedLambdaBreaks = true, + blockIndent = 2, + continuationIndent = 4, + ) + ) + .isEqualTo(code) + } + companion object { /** Triple quotes, useful to use within triple-quoted strings. */ private const val TQ = "\"\"\"" From 474ee2df2e0b50ae03155b59aeb890904e45d8f2 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 1 Dec 2025 17:23:09 -0500 Subject: [PATCH 5/5] Phase 5: Add documentation for cascadeNestedLambdaBreaks feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the new --cascade-nested-lambda-breaks command-line option in the README with examples and usage guidelines. The documentation explains how the feature helps preserve visual hierarchy in DSL code like Jetpack Compose. Changes: - Add "Cascading Lambda Breaks" section to README.md - Include usage examples showing before/after formatting - Clarify that option only affects trailing lambdas - Note that it's useful for DSLs with semantic nesting Feature is now fully implemented, tested, and documented. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 1 + README.md | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/.gitignore b/.gitignore index 5099988d..1b24da0a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ release-ktfmt-website/ .gradle **/build/ !src/**/build/ +.claude/ diff --git a/README.md b/README.md index d6381b98..c1bed148 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,32 @@ $ java -jar /path/to/ktfmt--with-dependencies.jar [--kotlinlang-style | `--kotlinlang-style` makes `ktfmt` use a block indent of 4 spaces instead of 2. See below for details. +#### Cascading Lambda Breaks + +For DSLs like Jetpack Compose where nested lambdas represent semantic hierarchies, you can use the `--cascade-nested-lambda-breaks` option to preserve visual structure: + +``` +$ java -jar /path/to/ktfmt--with-dependencies.jar --cascade-nested-lambda-breaks [files...] +``` + +When enabled, nested trailing lambdas are forced to multi-line format when their parent breaks, ensuring complete visual hierarchy: + +```kotlin +// With --cascade-nested-lambda-breaks +App { + SelectableCard { + Button { + Text("Click me") + } + } +} + +// Without (default) +App { SelectableCard { Button { Text("Click me") } } } +``` + +This option only affects trailing lambdas and does not change the formatting of lambdas in other contexts (like `map` or `filter` chains). + ***Note:*** *There is no configurability as to the formatter's algorithm for formatting (apart from the different styles). This is a deliberate design decision to unify our code formatting on a single