From 37ac386d3f6e2cd78f191f0ff6bc68f01b4cc922 Mon Sep 17 00:00:00 2001 From: Brais Date: Sat, 9 Jul 2022 15:06:49 +0200 Subject: [PATCH] Implement MultilineRawStringIndentation --- .../main/resources/default-detekt-config.yml | 3 + .../style/MultilineRawStringIndentation.kt | 185 ++++++++++ .../detekt/rules/style/StyleGuideProvider.kt | 1 + .../MultilineRawStringIndentationSpec.kt | 330 ++++++++++++++++++ 4 files changed, 519 insertions(+) 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 7574e161edb8..d07a2c1613a9 100644 --- a/detekt-core/src/main/resources/default-detekt-config.yml +++ b/detekt-core/src/main/resources/default-detekt-config.yml @@ -597,6 +597,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 000000000000..cea3b690c538 --- /dev/null +++ b/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/MultilineRawStringIndentation.kt @@ -0,0 +1,185 @@ +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 ensure that the raw strings have a consistent indentation. + * + * The baseIndentation is the indentation that has the line where the raw string started. The content of the + * raw string should have baseIndent plus one identation extra. And the closing raw string (`"""`) should have + * baseIndentation. + * + * + * 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(val 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, + ) { + val indentation = lineNumberRange + .map { lineNumber -> + val line = containingFile.getLine(lineNumber) + Triple(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().second.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 = "\"\"\"\n(.|\n)*\n *\"\"\"".toRegex() + +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 834e95b2f7d7..fac5238d873e 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/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 000000000000..6480c16b6fd1 --- /dev/null +++ b/detekt-rules-style/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/style/MultilineRawStringIndentationSpec.kt @@ -0,0 +1,330 @@ +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) + .hasSourceLocation(2 to 1, 2 to 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) + .hasSourceLocation(2 to 2, 2 to 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) + .hasSourceLocation(2 to 5, 3 to 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) + .hasSourceLocation(3 to 1, 3 to 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) + .hasSourceLocation(4 to 1, 4 to 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) + .hasSourceLocation(3 to 5, 3 to 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) + .hasSourceLocation(3 to 6, 3 to 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) + .hasSourceLocation(3 to 9, 4 to 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) + .hasSourceLocation(5 to 5, 5 to 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) + .hasSourceLocation(5 to 3, 5 to 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 = "\"\"\""