From 05aa8a0bfcba22d034dd8650c872492ee640d639 Mon Sep 17 00:00:00 2001 From: Hendra Anggrian Date: Thu, 16 Nov 2023 11:03:59 -0600 Subject: [PATCH] Add multiline-loop to complement multiline-if-else (#2298) * Add multiline-loop to complement multiline-if-else --- CHANGELOG.md | 5 +- .../snapshot/docs/rules/experimental.md | 21 ++ .../api/ktlint-ruleset-standard.api | 10 + .../standard/StandardRuleSetProvider.kt | 2 + .../standard/rules/MultilineLoopRule.kt | 116 +++++++++ .../standard/rules/MultilineLoopRuleTest.kt | 226 ++++++++++++++++++ 6 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/rules/MultilineLoopRule.kt create mode 100644 ktlint-ruleset-standard/src/test/kotlin/com/pinterest/ktlint/ruleset/standard/rules/MultilineLoopRuleTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf5881a0d..18cac21ca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ This project adheres to [Semantic Versioning](https://semver.org/). ### Added +* Add `.editorconfig` property `ktlint_function_naming_ignore_when_annotated_with` so that rule `function-naming` can be ignored based on annotations on that rule. See [function-naming](https://pinterest.github.io/ktlint/1.0.1/rules/standard/#function-naming). +* Add new experimental rule `multiline-loop` rule - [#2298](https://github.com/pinterest/ktlint/pull/2298), by @hendraanggrian + ### Removed * Remove obsolete idea configuration files [#2249](https://github.com/pinterest/ktlint/issues/2249) @@ -146,7 +149,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). * Add new experimental rule `function-type-modifier-spacing` rule - [#2216](https://github.com/pinterest/ktlint/pull/2216), by @t-kameyama -* Define `EditorConfigOverride` for dynamically loaded ruleset - [#2194](https://github.com/pinterest/ktlint/pull/2194), by @paul-dingemans +* Define `EditorConfigOverride` for dynamically loaded ruleset - [#2194](https://github.com/pinterest/ktlint/pull/2194), by @paul-dingemans The `EditorConfigOverride` parameter of the `KtlintRuleEngine` can be defined using the factory method `EditorConfigOverride.from(vararg properties: Pair, *>)`. This requires the `EditorConfigProperty`'s to be available at compile time. Some common `EditorConfigProperty`'s are defined in `ktlint-rule-engine-core` which is loaded as transitive dependency of `ktlint-rule-engine` and as of that are available at compile. If an `EditorConfigProperty` is defined in a `Rule` that is only provided via a runtime dependency, it gets a bit more complicated. The `ktlint-api-consumer` example has now been updated to show how the `EditorConfigProperty` can be retrieved from the `Rule`. diff --git a/documentation/snapshot/docs/rules/experimental.md b/documentation/snapshot/docs/rules/experimental.md index 6b084dd85b..8f60deb326 100644 --- a/documentation/snapshot/docs/rules/experimental.md +++ b/documentation/snapshot/docs/rules/experimental.md @@ -480,3 +480,24 @@ Enforce a single whitespace between the modifier list and the function type. ``` Rule id: `function-type-modifier-spacing` (`standard` rule set) + +## Multiline loop + +Braces required for multiline for, while, and do statements. + +=== "[:material-heart:](#) Ktlint" + + ```kotlin + for (i in 1..10) { + println(i) + } + ``` + +=== "[:material-heart-off-outline:](#) Disallowed" + + ```kotlin + for (i in 1..10) + println(i) + ``` + +Rule id: `multiline-loop` (`standard` rule set) diff --git a/ktlint-ruleset-standard/api/ktlint-ruleset-standard.api b/ktlint-ruleset-standard/api/ktlint-ruleset-standard.api index e417dad28f..85d22eaada 100644 --- a/ktlint-ruleset-standard/api/ktlint-ruleset-standard.api +++ b/ktlint-ruleset-standard/api/ktlint-ruleset-standard.api @@ -407,6 +407,16 @@ public final class com/pinterest/ktlint/ruleset/standard/rules/MultilineExpressi public static final fun getMULTILINE_EXPRESSION_WRAPPING_RULE_ID ()Lcom/pinterest/ktlint/rule/engine/core/api/RuleId; } +public final class com/pinterest/ktlint/ruleset/standard/rules/MultilineLoopRule : com/pinterest/ktlint/ruleset/standard/StandardRule, com/pinterest/ktlint/rule/engine/core/api/Rule$Experimental { + public fun ()V + public fun beforeFirstNode (Lcom/pinterest/ktlint/rule/engine/core/api/editorconfig/EditorConfig;)V + public fun beforeVisitChildNodes (Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;ZLkotlin/jvm/functions/Function3;)V +} + +public final class com/pinterest/ktlint/ruleset/standard/rules/MultilineLoopRuleKt { + public static final fun getMULTILINE_LOOP_RULE_ID ()Lcom/pinterest/ktlint/rule/engine/core/api/RuleId; +} + public final class com/pinterest/ktlint/ruleset/standard/rules/NoBlankLineBeforeRbraceRule : com/pinterest/ktlint/ruleset/standard/StandardRule { public fun ()V public fun beforeVisitChildNodes (Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;ZLkotlin/jvm/functions/Function3;)V diff --git a/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/StandardRuleSetProvider.kt b/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/StandardRuleSetProvider.kt index 30f8ef18de..2ee08d6fa8 100644 --- a/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/StandardRuleSetProvider.kt +++ b/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/StandardRuleSetProvider.kt @@ -40,6 +40,7 @@ import com.pinterest.ktlint.ruleset.standard.rules.ModifierListSpacingRule import com.pinterest.ktlint.ruleset.standard.rules.ModifierOrderRule import com.pinterest.ktlint.ruleset.standard.rules.MultiLineIfElseRule import com.pinterest.ktlint.ruleset.standard.rules.MultilineExpressionWrappingRule +import com.pinterest.ktlint.ruleset.standard.rules.MultilineLoopRule import com.pinterest.ktlint.ruleset.standard.rules.NoBlankLineBeforeRbraceRule import com.pinterest.ktlint.ruleset.standard.rules.NoBlankLineInListRule import com.pinterest.ktlint.ruleset.standard.rules.NoBlankLinesInChainedMethodCallsRule @@ -130,6 +131,7 @@ public class StandardRuleSetProvider : RuleSetProviderV3(RuleSetId.STANDARD) { RuleProvider { ModifierOrderRule() }, RuleProvider { MultiLineIfElseRule() }, RuleProvider { MultilineExpressionWrappingRule() }, + RuleProvider { MultilineLoopRule() }, RuleProvider { NoBlankLineBeforeRbraceRule() }, RuleProvider { NoBlankLineInListRule() }, RuleProvider { NoBlankLinesInChainedMethodCallsRule() }, diff --git a/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/rules/MultilineLoopRule.kt b/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/rules/MultilineLoopRule.kt new file mode 100644 index 0000000000..ef1d87965d --- /dev/null +++ b/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/rules/MultilineLoopRule.kt @@ -0,0 +1,116 @@ +package com.pinterest.ktlint.ruleset.standard.rules + +import com.pinterest.ktlint.rule.engine.core.api.ElementType.BLOCK +import com.pinterest.ktlint.rule.engine.core.api.ElementType.BODY +import com.pinterest.ktlint.rule.engine.core.api.ElementType.DO_KEYWORD +import com.pinterest.ktlint.rule.engine.core.api.ElementType.LBRACE +import com.pinterest.ktlint.rule.engine.core.api.ElementType.RBRACE +import com.pinterest.ktlint.rule.engine.core.api.ElementType.RPAR +import com.pinterest.ktlint.rule.engine.core.api.IndentConfig +import com.pinterest.ktlint.rule.engine.core.api.Rule +import com.pinterest.ktlint.rule.engine.core.api.RuleId +import com.pinterest.ktlint.rule.engine.core.api.SinceKtlint +import com.pinterest.ktlint.rule.engine.core.api.SinceKtlint.Status.EXPERIMENTAL +import com.pinterest.ktlint.rule.engine.core.api.editorconfig.EditorConfig +import com.pinterest.ktlint.rule.engine.core.api.editorconfig.INDENT_SIZE_PROPERTY +import com.pinterest.ktlint.rule.engine.core.api.editorconfig.INDENT_STYLE_PROPERTY +import com.pinterest.ktlint.rule.engine.core.api.indent +import com.pinterest.ktlint.rule.engine.core.api.isPartOfComment +import com.pinterest.ktlint.rule.engine.core.api.isWhiteSpace +import com.pinterest.ktlint.rule.engine.core.api.isWhiteSpaceWithoutNewline +import com.pinterest.ktlint.rule.engine.core.api.nextSibling +import com.pinterest.ktlint.rule.engine.core.api.upsertWhitespaceBeforeMe +import com.pinterest.ktlint.ruleset.standard.StandardRule +import org.jetbrains.kotlin.com.intellij.lang.ASTNode +import org.jetbrains.kotlin.com.intellij.psi.impl.source.tree.LeafPsiElement +import org.jetbrains.kotlin.com.intellij.psi.impl.source.tree.PsiWhiteSpaceImpl +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.psiUtil.leaves + +/** + * https://developer.android.com/kotlin/style-guide#braces + */ +@SinceKtlint("1.0", EXPERIMENTAL) +public class MultilineLoopRule : + StandardRule( + id = "multiline-loop", + usesEditorConfigProperties = + setOf( + INDENT_SIZE_PROPERTY, + INDENT_STYLE_PROPERTY, + ), + ), + Rule.Experimental { + private var indentConfig = IndentConfig.DEFAULT_INDENT_CONFIG + + override fun beforeFirstNode(editorConfig: EditorConfig) { + indentConfig = + IndentConfig( + indentStyle = editorConfig[INDENT_STYLE_PROPERTY], + tabWidth = editorConfig[INDENT_SIZE_PROPERTY], + ) + } + + override fun beforeVisitChildNodes( + node: ASTNode, + autoCorrect: Boolean, + emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit, + ) { + node + .takeIf { it.elementType == BODY } + ?.takeUnless { it.firstChildNode.elementType == BLOCK } + ?.takeUnless { + // Allow single line loop statements as long as they are really simple (e.g. do not contain newlines) + // for (...) + // while (...) + // do while (...) + !it.treeParent.textContains('\n') + } ?: return + emit(node.firstChildNode.startOffset, "Missing { ... }", true) + + if (autoCorrect) { + autocorrect(node) + } + } + + private fun autocorrect(node: ASTNode) { + val prevLeaves = + node + .leaves(forward = false) + .takeWhile { it.elementType !in listOf(RPAR, DO_KEYWORD) } + .toList() + .reversed() + val nextLeaves = + node + .leaves(forward = true) + .takeWhile { it.isWhiteSpaceWithoutNewline() || it.isPartOfComment() } + .toList() + .dropLastWhile { it.isWhiteSpaceWithoutNewline() } + + prevLeaves + .firstOrNull() + .takeIf { it.isWhiteSpace() } + ?.let { + (it as LeafPsiElement).rawReplaceWithText(" ") + } + KtBlockExpression(null).apply { + val previousChild = node.firstChildNode + node.replaceChild(node.firstChildNode, this) + addChild(LeafPsiElement(LBRACE, "{")) + addChild(PsiWhiteSpaceImpl(indentConfig.childIndentOf(node))) + prevLeaves + .dropWhile { it.isWhiteSpace() } + .forEach(::addChild) + addChild(previousChild) + nextLeaves.forEach(::addChild) + addChild(PsiWhiteSpaceImpl(node.indent())) + addChild(LeafPsiElement(RBRACE, "}")) + } + + node + .nextSibling { !it.isPartOfComment() } + ?.upsertWhitespaceBeforeMe(" ") + } +} + +public val MULTILINE_LOOP_RULE_ID: RuleId = MultilineLoopRule().ruleId diff --git a/ktlint-ruleset-standard/src/test/kotlin/com/pinterest/ktlint/ruleset/standard/rules/MultilineLoopRuleTest.kt b/ktlint-ruleset-standard/src/test/kotlin/com/pinterest/ktlint/ruleset/standard/rules/MultilineLoopRuleTest.kt new file mode 100644 index 0000000000..3bc5e3f915 --- /dev/null +++ b/ktlint-ruleset-standard/src/test/kotlin/com/pinterest/ktlint/ruleset/standard/rules/MultilineLoopRuleTest.kt @@ -0,0 +1,226 @@ +package com.pinterest.ktlint.ruleset.standard.rules + +import com.pinterest.ktlint.test.KtLintAssertThat.Companion.assertThatRule +import com.pinterest.ktlint.test.LintViolation +import org.junit.jupiter.api.Test + +class MultilineLoopRuleTest { + private val multilineLoopRuleAssertThat = assertThatRule { MultilineLoopRule() } + + @Test + fun `Given loop statements with curly braces on single line`() { + val code = + """ + fun foo() { + for (i in 1..10) { bar() } + while (true) { bar() } + do { bar() } while (true) + } + """.trimIndent() + multilineLoopRuleAssertThat(code).hasNoLintViolations() + } + + @Test + fun `Given loop statements without curly braces on single line`() { + val code = + """ + fun foo() { + for (i in 1..10) bar() + while (true) bar() + do bar() while (true) + } + """.trimIndent() + multilineLoopRuleAssertThat(code).hasNoLintViolations() + } + + @Test + fun `Given multiline loop statements with curly braces`() { + val code = + """ + fun foo() { + for (i in 1..10) { + bar() + } + while (true) { + bar() + } + do { + bar() + } while (true) + } + """.trimIndent() + multilineLoopRuleAssertThat(code).hasNoLintViolations() + } + + @Test + fun `Given multiline loop statements without curly braces`() { + val code = + """ + fun foo() { + for (i in 1..10) + bar() + while (true) + bar() + do + bar() + while (true) + } + """.trimIndent() + val formattedCode = + """ + fun foo() { + for (i in 1..10) { + bar() + } + while (true) { + bar() + } + do { + bar() + } while (true) + } + """.trimIndent() + multilineLoopRuleAssertThat(code) + .hasLintViolations( + LintViolation(3, 9, "Missing { ... }"), + LintViolation(5, 9, "Missing { ... }"), + LintViolation(7, 9, "Missing { ... }"), + ).isFormattedAs(formattedCode) + } + + @Test + fun `Given deep nested loop statements without curly braces`() { + val code = + """ + fun main() { + for (i in 1..10) + while (true) + do + bar() + while (true) + } + """.trimIndent() + val formattedCode = + """ + fun main() { + for (i in 1..10) { + while (true) { + do { + bar() + } while (true) + } + } + } + """.trimIndent() + multilineLoopRuleAssertThat(code) + .hasLintViolations( + LintViolation(3, 9, "Missing { ... }"), + LintViolation(4, 13, "Missing { ... }"), + LintViolation(5, 17, "Missing { ... }"), + ).isFormattedAs(formattedCode) + } + + @Test + fun `Given loop statements inside a lambda`() { + val code = + """ + fun test(s: String?): Int { + val i = s.let { + for (i in 1..10) + 1 + while (true) + 2 + do + 3 + while (true) + } ?: 0 + return i + } + """.trimIndent() + val formattedCode = + """ + fun test(s: String?): Int { + val i = s.let { + for (i in 1..10) { + 1 + } + while (true) { + 2 + } + do { + 3 + } while (true) + } ?: 0 + return i + } + """.trimIndent() + multilineLoopRuleAssertThat(code) + .hasLintViolations( + LintViolation(4, 13, "Missing { ... }"), + LintViolation(6, 13, "Missing { ... }"), + LintViolation(8, 13, "Missing { ... }"), + ).isFormattedAs(formattedCode) + } + + @Test + fun `Given a do-while-statement with do keyword on same line as body statement and while keyword on separate line`() { + val code = + """ + fun foo() { + do bar() + while (true) + } + """.trimIndent() + val formattedCode = + """ + fun foo() { + do { + bar() + } while (true) + } + """.trimIndent() + multilineLoopRuleAssertThat(code) + .hasLintViolation(2, 8, "Missing { ... }") + .isFormattedAs(formattedCode) + } + + @Test + fun `Given loop statements with multiline statement starting on same line as loop`() { + val code = + """ + fun foo() { + for (i in 1..10) 25 + .toString() + while (true) 50 + .toString() + do 75 + .toString() + while (true) + } + """.trimIndent() + val formattedCode = + """ + fun foo() { + for (i in 1..10) { + 25 + .toString() + } + while (true) { + 50 + .toString() + } + do { + 75 + .toString() + } while (true) + } + """.trimIndent() + multilineLoopRuleAssertThat(code) + .addAdditionalRuleProvider { IndentationRule() } + .hasLintViolations( + LintViolation(2, 22, "Missing { ... }"), + LintViolation(4, 18, "Missing { ... }"), + LintViolation(6, 8, "Missing { ... }"), + ).isFormattedAs(formattedCode) + } +}