From 9acad77fb248f04bc17d51e171ba4daceb0894c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brais=20Gab=C3=ADn?= Date: Mon, 1 Aug 2022 18:18:11 +0200 Subject: [PATCH] Implement `MultilineRawStringIndentation` (#5058) * Refactor TrimMultilineRawString * Implement MultilineRawStringIndentation * Update detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/MultilineRawStringIndentation.kt Co-authored-by: marschwar * Update detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/MultilineRawStringIndentation.kt Co-authored-by: marschwar * Update detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/MultilineRawStringIndentation.kt Co-authored-by: marschwar * Update detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/MultilineRawStringIndentation.kt Co-authored-by: marschwar * Don't use Triple Co-authored-by: marschwar --- .../main/resources/default-detekt-config.yml | 3 + .../style/MultilineRawStringIndentation.kt | 186 ++++++++++ .../detekt/rules/style/StyleGuideProvider.kt | 1 + .../rules/style/TrimMultilineRawString.kt | 22 +- .../MultilineRawStringIndentationSpec.kt | 340 ++++++++++++++++++ 5 files changed, 543 insertions(+), 9 deletions(-) create mode 100644 detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/MultilineRawStringIndentation.kt create mode 100644 detekt-rules-style/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/style/MultilineRawStringIndentationSpec.kt diff --git a/detekt-core/src/main/resources/default-detekt-config.yml b/detekt-core/src/main/resources/default-detekt-config.yml index 84230e4166e..934c351e1ec 100644 --- a/detekt-core/src/main/resources/default-detekt-config.yml +++ b/detekt-core/src/main/resources/default-detekt-config.yml @@ -603,6 +603,9 @@ style: active: true MultilineLambdaItParameter: active: false + MultilineRawStringIndentation: + active: false + indentSize: 4 NestedClassesVisibility: active: true NewLineAtEndOfFile: diff --git a/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/MultilineRawStringIndentation.kt b/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/MultilineRawStringIndentation.kt new file mode 100644 index 00000000000..b7af32bf85f --- /dev/null +++ b/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/MultilineRawStringIndentation.kt @@ -0,0 +1,186 @@ +package io.gitlab.arturbosch.detekt.rules.style + +import io.github.detekt.psi.getLineAndColumnInPsiFile +import io.github.detekt.psi.toFilePath +import io.gitlab.arturbosch.detekt.api.CodeSmell +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.Debt +import io.gitlab.arturbosch.detekt.api.Entity +import io.gitlab.arturbosch.detekt.api.Issue +import io.gitlab.arturbosch.detekt.api.Location +import io.gitlab.arturbosch.detekt.api.Rule +import io.gitlab.arturbosch.detekt.api.Severity +import io.gitlab.arturbosch.detekt.api.SourceLocation +import io.gitlab.arturbosch.detekt.api.TextLocation +import io.gitlab.arturbosch.detekt.api.config +import io.gitlab.arturbosch.detekt.api.internal.Configuration +import org.jetbrains.kotlin.com.intellij.psi.PsiFile +import org.jetbrains.kotlin.psi.KtElement +import org.jetbrains.kotlin.psi.KtStringTemplateExpression + +/** + * This rule ensures that raw strings have a consistent indentation. + * + * The content of a multi line raw string should have the same indentation as the enclosing expression plus the + * configured indentSize. The closing triple-quotes (`"""`) must have the same indentation as the enclosing expression. + * + * + * val a = """ + * Hello World! + * How are you? + * """.trimMargin() + * + * val a = """ + * Hello World! + * How are you? + * """.trimMargin() + * + * + * + * val a = """ + * Hello World! + * How are you? + * """.trimMargin() + * + * val a = """ + * Hello World! + * How are you? + * """.trimMargin() + * + */ +class MultilineRawStringIndentation(config: Config) : Rule(config) { + override val issue = Issue( + javaClass.simpleName, + Severity.Style, + "The indentation of the raw String should be consistent", + Debt.FIVE_MINS + ) + + @Configuration("indentation size") + private val indentSize by config(4) + + @Suppress("ReturnCount") + override fun visitStringTemplateExpression(expression: KtStringTemplateExpression) { + super.visitStringTemplateExpression(expression) + + val text = expression.text + val lineCount = text.lines().count() + if (lineCount <= 1) return + if (!expression.isTrimmed()) return + if (!text.matches(rawStringRegex)) return + + val lineAndColumn = getLineAndColumnInPsiFile(expression.containingFile, expression.textRange) ?: return + + expression.checkIndentation( + baseIndent = lineAndColumn.lineContent?.countIndent() ?: return, + firstLineNumber = lineAndColumn.line, + lastLineNumber = lineAndColumn.line + lineCount - 1 + ) + } + + private fun KtStringTemplateExpression.checkIndentation( + baseIndent: Int, + firstLineNumber: Int, + lastLineNumber: Int, + ) { + checkContent(desiredIndent = baseIndent + indentSize, (firstLineNumber + 1)..(lastLineNumber - 1)) + checkClosing(baseIndent, lastLineNumber) + } + + private fun KtStringTemplateExpression.checkContent( + desiredIndent: Int, + lineNumberRange: IntRange, + ) { + data class LineInformation(val lineNumber: Int, val line: String, val currentIndent: Int) + + val indentation = lineNumberRange + .map { lineNumber -> + val line = containingFile.getLine(lineNumber) + LineInformation(lineNumber, line, line.countIndent()) + } + + if (indentation.isNotEmpty()) { + indentation + .filter { (_, line, currentIndent) -> line.isNotEmpty() && currentIndent < desiredIndent } + .onEach { (lineNumber, line, currentIndent) -> + val location = containingFile.getLocation( + SourceLocation(lineNumber, if (line.isBlank()) 1 else currentIndent + 1), + SourceLocation(lineNumber, line.length + 1) + ) + + report(this, location, message(desiredIndent, currentIndent)) + } + .ifEmpty { + if (indentation.none { (_, _, currentIndent) -> currentIndent == desiredIndent }) { + val location = containingFile.getLocation( + SourceLocation(lineNumberRange.first, desiredIndent + 1), + SourceLocation(lineNumberRange.last, indentation.last().line.length + 1), + ) + + report( + this, + location, + message(desiredIndent, indentation.minOf { (_, _, indent) -> indent }), + ) + } + } + } + } + + private fun KtStringTemplateExpression.checkClosing( + desiredIndent: Int, + lineNumber: Int, + ) { + val currentIndent = containingFile.getLine(lineNumber).countIndent() + if (currentIndent != desiredIndent) { + val location = if (currentIndent < desiredIndent) { + containingFile.getLocation( + SourceLocation(lineNumber, currentIndent + 1), + SourceLocation(lineNumber, currentIndent + "\"\"\"".length + 1), + ) + } else { + containingFile.getLocation( + SourceLocation(lineNumber, desiredIndent + 1), + SourceLocation(lineNumber, currentIndent + 1), + ) + } + + report(this, location, message(desiredIndent, currentIndent)) + } + } +} + +private fun Rule.report(element: KtElement, location: Location, message: String) { + report(CodeSmell(issue, Entity.from(element, location), message)) +} + +private fun message(desiredIntent: Int, currentIndent: Int): String { + return "The indentation should be $desiredIntent but it is $currentIndent." +} + +private val rawStringRegex = "\"{3}\n.*\n *\"{3}".toRegex(RegexOption.DOT_MATCHES_ALL) + +private fun String.countIndent() = this.takeWhile { it == ' ' }.count() + +private fun PsiFile.getLine(line: Int): String { + return text.lineSequence().drop(line - 1).first() +} + +private fun PsiFile.getLocation(start: SourceLocation, end: SourceLocation): Location { + val lines = this.text.lines() + var startOffset = 0 + for (i in 1 until start.line) { + startOffset += lines[i - 1].length + 1 + } + var endOffset = startOffset + for (i in start.line until end.line) { + endOffset += lines[i - 1].length + 1 + } + this.text.lines() + return Location( + start, + end, + TextLocation(startOffset + start.column - 1, endOffset + end.column - 1), + toFilePath() + ) +} diff --git a/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/StyleGuideProvider.kt b/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/StyleGuideProvider.kt index 834e95b2f7d..fac5238d873 100644 --- a/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/StyleGuideProvider.kt +++ b/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/StyleGuideProvider.kt @@ -96,6 +96,7 @@ class StyleGuideProvider : DefaultRuleSetProvider { RedundantHigherOrderMapUsage(config), UseIfEmptyOrIfBlank(config), MultilineLambdaItParameter(config), + MultilineRawStringIndentation(config), UseIsNullOrEmpty(config), UseOrEmpty(config), UseAnyOrNoneInsteadOfFind(config), diff --git a/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/TrimMultilineRawString.kt b/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/TrimMultilineRawString.kt index d2d215ee919..fbd0f0278b0 100644 --- a/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/TrimMultilineRawString.kt +++ b/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/TrimMultilineRawString.kt @@ -50,14 +50,7 @@ class TrimMultilineRawString(val config: Config) : Rule(config) { if (expression.text.lines().count() <= 1) return - val nextCall = expression.getQualifiedExpressionForSelectorOrThis() - .getQualifiedExpressionForReceiver() - ?.selectorExpression - ?.asKtCallExpression() - ?.calleeExpression - ?.text - - if (nextCall !in trimFunctions) { + if (!expression.isTrimmed()) { report( CodeSmell( issue, @@ -69,6 +62,17 @@ class TrimMultilineRawString(val config: Config) : Rule(config) { } } -private fun KtExpression.asKtCallExpression(): KtCallExpression? = this as? KtCallExpression +fun KtStringTemplateExpression.isTrimmed(): Boolean { + fun KtExpression.asKtCallExpression(): KtCallExpression? = this as? KtCallExpression + + val nextCall = getQualifiedExpressionForSelectorOrThis() + .getQualifiedExpressionForReceiver() + ?.selectorExpression + ?.asKtCallExpression() + ?.calleeExpression + ?.text + + return nextCall in trimFunctions +} private val trimFunctions = listOf("trimIndent", "trimMargin") diff --git a/detekt-rules-style/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/style/MultilineRawStringIndentationSpec.kt b/detekt-rules-style/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/style/MultilineRawStringIndentationSpec.kt new file mode 100644 index 00000000000..c309c54af73 --- /dev/null +++ b/detekt-rules-style/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/style/MultilineRawStringIndentationSpec.kt @@ -0,0 +1,340 @@ +package io.gitlab.arturbosch.detekt.rules.style + +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.test.assertThat +import io.gitlab.arturbosch.detekt.test.compileAndLint +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class MultilineRawStringIndentationSpec { + val subject = MultilineRawStringIndentation(Config.empty) + + @Nested + inner class IfTheOpeningDoesNotStartTheLine { + @Test + fun `raise multiline raw string without indentation`() { + val code = """ + val a = $TQ + Hello world! + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .hasSize(1) + .hasStartSourceLocation(2, 1) + .hasEndSourceLocation(2, 13) + .hasTextLocations("Hello world!") + } + + @Test + fun `raise multiline raw strings without indentation`() { + val code = """ + val a = $TQ + Hello world! + How are you? + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .hasSize(2) + .hasTextLocations("Hello world!", "How are you?") + } + + @Test + fun `raise multiline raw strings without right indentation`() { + val code = """ + val a = $TQ + Hello world! + How are you? + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .hasSize(1) + .hasStartSourceLocation(2, 2) + .hasEndSourceLocation(2, 14) + .hasTextLocations("Hello world!") + } + + @Test + fun `raise multiline raw strings with too much indentation`() { + val code = """ + val a = $TQ + Hello world! + How are you? + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .hasSize(1) + .hasStartSourceLocation(2, 5) + .hasEndSourceLocation(3, 18) + .hasTextLocations(" Hello world!\n How are you?") + } + + @Test + fun `don't raise multiline raw strings if one has correct indentation and the other more`() { + val code = """ + val a = $TQ + Hello world! + How are you? + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .isEmpty() + } + + @Test + fun `don't raise multiline raw strings if all have the correct indentation`() { + val code = """ + val a = $TQ + Hello world! + + How are you? + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .isEmpty() + } + + @Test + fun `don't raise multiline raw strings if all have the correct indentation or empty`() { + val code = """ + val a = $TQ + Hello world! + + How are you? + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .isEmpty() + } + + @Test + fun `raise multiline raw strings if a blank line doesn't have the minimum indentation`() { + val code = """ + val a = $TQ + Hello world! + + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .hasSize(1) + .hasStartSourceLocation(3, 1) + .hasEndSourceLocation(3, 3) + } + + @Test + fun `raise multiline raw strings with indentation on closing`() { + val code = """ + val a = $TQ + Hello world! + How are you? + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .hasSize(1) + .hasStartSourceLocation(4, 1) + .hasEndSourceLocation(4, 5) + } + } + + @Nested + inner class IfTheOpeningStartTheLine { + @Test + fun `raise multiline raw string without indentation`() { + val code = """ + val a = + $TQ + Hello world! + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .hasSize(1) + .hasStartSourceLocation(3, 5) + .hasEndSourceLocation(3, 17) + } + + @Test + fun `raise multiline raw strings without indentation`() { + val code = """ + val a = + $TQ + Hello world! + How are you? + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .hasSize(2) + } + + @Test + fun `raise multiline raw strings without right indentation`() { + val code = """ + val a = + $TQ + Hello world! + How are you? + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .hasSize(1) + .hasStartSourceLocation(3, 6) + .hasEndSourceLocation(3, 18) + } + + @Test + fun `raise multiline raw strings with too much indentation`() { + val code = """ + val a = + $TQ + Hello world! + How are you? + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .hasSize(1) + .hasStartSourceLocation(3, 9) + .hasEndSourceLocation(4, 22) + .hasTextLocations(" Hello world!\n How are you?") + } + + @Test + fun `don't raise multiline raw strings if one has correct indentation and the other more`() { + val code = """ + val a = + $TQ + Hello world! + How are you? + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .isEmpty() + } + + @Test + fun `don't raise multiline raw strings if all have the correct indentation`() { + val code = """ + val a = + $TQ + Hello world! + How are you? + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .isEmpty() + } + + @Test + fun `raise multiline raw strings with too much indentation on closing`() { + val code = """ + val a = + $TQ + Hello world! + How are you? + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .hasSize(1) + .hasStartSourceLocation(5, 5) + .hasEndSourceLocation(5, 9) + } + + @Test + fun `raise multiline raw strings with too little indentation on closing`() { + val code = """ + val a = + $TQ + Hello world! + How are you? + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .hasSize(1) + .hasStartSourceLocation(5, 3) + .hasEndSourceLocation(5, 6) + } + } + + @Nested + inner class CasesThatShouldBeIgnored { + @Test + fun `doesn't raise multiline raw strings without trim`() { + val code = """ + val a = $TQ + Hello world! + $TQ + """ + subject.compileAndLint(code) + assertThat(subject.findings).isEmpty() + } + + @Test + fun `don't raise one line raw strings`() { + val code = """ + val a = ${TQ}Hello world!$TQ + """ + subject.compileAndLint(code) + assertThat(subject.findings).isEmpty() + } + + @Test + fun `doesn't raise if it is not a raw string`() { + val code = """ + val a = "Hello world!" + """ + subject.compileAndLint(code) + assertThat(subject.findings).isEmpty() + } + + @Test + fun `don't raise if it contains content after the opening triple quote`() { + val code = """ + val a = ${TQ}Hello world! + How are you? + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .isEmpty() + } + + @Test + fun `don't raise if it contains content before the closing triple quote`() { + val code = """ + val a = $TQ + Hello world! + How are you?$TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .isEmpty() + } + + @Test + fun `don't raise if it isEmpty`() { + val code = """ + val a = $TQ + $TQ.trimIndent() + """ + subject.compileAndLint(code) + assertThat(subject.findings) + .isEmpty() + } + } +} + +private const val TQ = "\"\"\""