diff --git a/README.md b/README.md index 3d62edf7d2..32bd5febea 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,9 @@ $ ktlint --reporter=plain?group_by_file # print style violations as usual + create report in checkstyle format $ ktlint --reporter=plain --reporter=checkstyle,output=ktlint-report-in-checkstyle-format.xml +# check against a baseline file +$ ktlint --baseline=ktlint-baseline.xml + # install git hook to automatically check files for style violations on commit # Run "ktlint installGitPrePushHook" if you wish to run ktlint on push instead $ ktlint installGitPreCommitHook @@ -288,6 +291,8 @@ task ktlint(type: JavaExec, group: "verification") { args "src/**/*.kt" // to generate report in checkstyle format prepend following args: // "--reporter=plain", "--reporter=checkstyle,output=${buildDir}/ktlint.xml" + // to add a baseline to check against prepend following args: + // "--baseline=ktlint-baseline.xml" // see https://github.com/pinterest/ktlint#usage for more } check.dependsOn ktlint diff --git a/build.gradle b/build.gradle index 9dcfb8ea1a..fb7b5b5e93 100644 --- a/build.gradle +++ b/build.gradle @@ -42,7 +42,7 @@ task ktlint(type: JavaExec, group: LifecycleBasePlugin.VERIFICATION_GROUP) { description = "Check Kotlin code style." classpath = configurations.ktlint main = 'com.pinterest.ktlint.Main' - args '*/src/**/*.kt' + args '*/src/**/*.kt', '--baseline=ktlint-baseline.xml' } allprojects { diff --git a/ktlint-baseline.xml b/ktlint-baseline.xml new file mode 100644 index 0000000000..4327ef2957 --- /dev/null +++ b/ktlint-baseline.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ktlint-reporter-baseline/build.gradle b/ktlint-reporter-baseline/build.gradle new file mode 100644 index 0000000000..437ed77722 --- /dev/null +++ b/ktlint-reporter-baseline/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' + id 'ktlint-publication' +} + +dependencies { + implementation project(':ktlint-core') + implementation deps.kotlin.stdlib + + testImplementation deps.junit + testImplementation deps.assertj +} diff --git a/ktlint-reporter-baseline/gradle.properties b/ktlint-reporter-baseline/gradle.properties new file mode 100644 index 0000000000..3f601372ea --- /dev/null +++ b/ktlint-reporter-baseline/gradle.properties @@ -0,0 +1,4 @@ +GROUP=com.pinterest.ktlint +POM_NAME=ktlint-reporter-baseline +POM_ARTIFACT_ID=ktlint-reporter-baseline +POM_PACKAGING=jar diff --git a/ktlint-reporter-baseline/src/main/kotlin/com/pinterest/ktlint/reporter/baseline/BaselineReporter.kt b/ktlint-reporter-baseline/src/main/kotlin/com/pinterest/ktlint/reporter/baseline/BaselineReporter.kt new file mode 100644 index 0000000000..a99a191aed --- /dev/null +++ b/ktlint-reporter-baseline/src/main/kotlin/com/pinterest/ktlint/reporter/baseline/BaselineReporter.kt @@ -0,0 +1,46 @@ +package com.pinterest.ktlint.reporter.baseline + +import com.pinterest.ktlint.core.LintError +import com.pinterest.ktlint.core.Reporter +import java.io.File +import java.io.PrintStream +import java.nio.file.Paths +import java.util.ArrayList +import java.util.concurrent.ConcurrentHashMap + +class BaselineReporter(val out: PrintStream) : Reporter { + + private val acc = ConcurrentHashMap>() + + override fun onLintError(file: String, err: LintError, corrected: Boolean) { + if (!corrected) { + acc.getOrPut(file) { ArrayList() }.add(err) + } + } + + override fun afterAll() { + out.println("""""") + out.println("""""") + for ((file, errList) in acc.entries.sortedBy { it.key }) { + val fileName = try { + val rootPath = Paths.get("").toAbsolutePath() + val filePath = Paths.get(file) + rootPath.relativize(filePath).toString().replace(File.separatorChar, '/') + } catch (e: IllegalArgumentException) { + file + } + out.println(""" """) + for ((line, col, ruleId, _) in errList) { + out.println( + """ """ + ) + } + out.println(""" """) + } + out.println("""""") + } + + private fun String.escapeXMLAttrValue() = + this.replace("&", "&").replace("\"", """).replace("'", "'") + .replace("<", "<").replace(">", ">") +} diff --git a/ktlint-reporter-baseline/src/main/kotlin/com/pinterest/ktlint/reporter/baseline/BaselineReporterProvider.kt b/ktlint-reporter-baseline/src/main/kotlin/com/pinterest/ktlint/reporter/baseline/BaselineReporterProvider.kt new file mode 100644 index 0000000000..29394314d9 --- /dev/null +++ b/ktlint-reporter-baseline/src/main/kotlin/com/pinterest/ktlint/reporter/baseline/BaselineReporterProvider.kt @@ -0,0 +1,10 @@ +package com.pinterest.ktlint.reporter.baseline + +import com.pinterest.ktlint.core.Reporter +import com.pinterest.ktlint.core.ReporterProvider +import java.io.PrintStream + +class BaselineReporterProvider : ReporterProvider { + override val id: String = "baseline" + override fun get(out: PrintStream, opt: Map): Reporter = BaselineReporter(out) +} diff --git a/ktlint-reporter-baseline/src/main/resources/META-INF/services/com.pinterest.ktlint.core.ReporterProvider b/ktlint-reporter-baseline/src/main/resources/META-INF/services/com.pinterest.ktlint.core.ReporterProvider new file mode 100644 index 0000000000..a8e7a91477 --- /dev/null +++ b/ktlint-reporter-baseline/src/main/resources/META-INF/services/com.pinterest.ktlint.core.ReporterProvider @@ -0,0 +1 @@ +com.pinterest.ktlint.reporter.baseline.BaselineReporterProvider diff --git a/ktlint-reporter-baseline/src/test/kotlin/com/pinterest/ktlint/reporter/baseline/BaselineReporterTest.kt b/ktlint-reporter-baseline/src/test/kotlin/com/pinterest/ktlint/reporter/baseline/BaselineReporterTest.kt new file mode 100644 index 0000000000..edcece007c --- /dev/null +++ b/ktlint-reporter-baseline/src/test/kotlin/com/pinterest/ktlint/reporter/baseline/BaselineReporterTest.kt @@ -0,0 +1,75 @@ +package com.pinterest.ktlint.reporter.baseline + +import com.pinterest.ktlint.core.LintError +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import java.nio.file.Paths +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class BaselineReporterTest { + + @Test + fun testReportGeneration() { + val basePath = Paths.get("").toAbsolutePath() + val out = ByteArrayOutputStream() + val reporter = BaselineReporter(PrintStream(out, true)) + reporter.onLintError( + "$basePath/one-fixed-and-one-not.kt", + LintError( + 1, 1, "rule-1", + "<\"&'>" + ), + false + ) + reporter.onLintError( + "$basePath/one-fixed-and-one-not.kt", + LintError( + 2, 1, "rule-2", + "And if you see my friend" + ), + true + ) + + reporter.onLintError( + "$basePath/two-not-fixed.kt", + LintError( + 1, 10, "rule-1", + "I thought I would again" + ), + false + ) + reporter.onLintError( + "$basePath/two-not-fixed.kt", + LintError( + 2, 20, "rule-2", + "A single thin straight line" + ), + false + ) + + reporter.onLintError( + "$basePath/all-corrected.kt", + LintError( + 1, 1, "rule-1", + "I thought we had more time" + ), + true + ) + reporter.afterAll() + assertThat(String(out.toByteArray())).isEqualTo( +""" + + + + + + + + + + +""".trimStart().replace("\n", System.lineSeparator()) + ) + } +} diff --git a/ktlint/build.gradle b/ktlint/build.gradle index e87fb87fe8..fb8fce317b 100644 --- a/ktlint/build.gradle +++ b/ktlint/build.gradle @@ -25,6 +25,7 @@ publishing.publications.named("maven").configure { dependencies { implementation project(':ktlint-core') + implementation project(':ktlint-reporter-baseline') implementation project(':ktlint-reporter-checkstyle') implementation project(':ktlint-reporter-json') implementation project(':ktlint-reporter-html') diff --git a/ktlint/src/main/kotlin/com/pinterest/ktlint/Main.kt b/ktlint/src/main/kotlin/com/pinterest/ktlint/Main.kt index 2f013c2ba1..ccee4eace0 100644 --- a/ktlint/src/main/kotlin/com/pinterest/ktlint/Main.kt +++ b/ktlint/src/main/kotlin/com/pinterest/ktlint/Main.kt @@ -17,12 +17,15 @@ import com.pinterest.ktlint.internal.GitPrePushHookSubCommand import com.pinterest.ktlint.internal.JarFiles import com.pinterest.ktlint.internal.KtlintVersionProvider import com.pinterest.ktlint.internal.PrintASTSubCommand +import com.pinterest.ktlint.internal.containsLintError import com.pinterest.ktlint.internal.fileSequence import com.pinterest.ktlint.internal.formatFile import com.pinterest.ktlint.internal.lintFile +import com.pinterest.ktlint.internal.loadBaseline import com.pinterest.ktlint.internal.loadRulesets import com.pinterest.ktlint.internal.location import com.pinterest.ktlint.internal.printHelpOrVersionUsage +import com.pinterest.ktlint.internal.relativeRoute import com.pinterest.ktlint.internal.toFilesURIList import com.pinterest.ktlint.reporter.plain.internal.Color import java.io.File @@ -212,6 +215,12 @@ class KtlintCommandLine { ) var experimental: Boolean = false + @Option( + names = ["--baseline"], + description = ["Defines a baseline file to check against"] + ) + private var baseline: String = "" + @Parameters(hidden = true) private var patterns = ArrayList() @@ -224,8 +233,14 @@ class KtlintCommandLine { val start = System.currentTimeMillis() + val baselineResults = loadBaseline(baseline) val ruleSetProviders = rulesets.loadRulesets(experimental, debug) - val reporter = loadReporter() + var reporter = loadReporter() + if (baselineResults.baselineGenerationNeeded) { + val baselineReporter = ReporterTemplate("baseline", null, emptyMap(), baseline) + val reporterProviderById = loadReporters(emptyList()) + reporter = Reporter.from(reporter, baselineReporter.toReporter(reporterProviderById)) + } val userData = listOfNotNull( "android" to android.toString(), if (disabledRules.isNotBlank()) "disabled_rules" to disabledRules else null @@ -235,7 +250,7 @@ class KtlintCommandLine { if (stdin) { lintStdin(ruleSetProviders, userData, reporter) } else { - lintFiles(ruleSetProviders, userData, reporter) + lintFiles(ruleSetProviders, userData, baselineResults.baselineRules, reporter) } reporter.afterAll() if (debug) { @@ -253,6 +268,7 @@ class KtlintCommandLine { private fun lintFiles( ruleSetProviders: Map, userData: Map, + baseline: Map>?, reporter: Reporter ) { patterns.fileSequence() @@ -263,7 +279,8 @@ class KtlintCommandLine { file.path, file.readText(), ruleSetProviders, - userData + userData, + baseline?.get(file.relativeRoute) ) } } @@ -281,7 +298,8 @@ class KtlintCommandLine { KtLint.STDIN_FILE, String(System.`in`.readBytes()), ruleSetProviders, - userData + userData, + null ), reporter ) @@ -324,7 +342,8 @@ class KtlintCommandLine { fileName: String, fileContent: String, ruleSetProviders: Map, - userData: Map + userData: Map, + baselineErrors: List? ): List { if (debug) { val fileLocation = if (fileName != KtLint.STDIN_FILE) File(fileName).location(relative) else fileName @@ -342,8 +361,10 @@ class KtlintCommandLine { debug ) { err, corrected -> if (!corrected) { - result.add(LintErrorWithCorrectionInfo(err, corrected)) - tripped.set(true) + if (baselineErrors == null || !baselineErrors.containsLintError(err)) { + result.add(LintErrorWithCorrectionInfo(err, corrected)) + tripped.set(true) + } } } } catch (e: Exception) { @@ -368,8 +389,10 @@ class KtlintCommandLine { editorConfigPath, debug ) { err -> - result.add(LintErrorWithCorrectionInfo(err, false)) - tripped.set(true) + if (baselineErrors == null || !baselineErrors.containsLintError(err)) { + result.add(LintErrorWithCorrectionInfo(err, false)) + tripped.set(true) + } } } catch (e: Exception) { result.add(LintErrorWithCorrectionInfo(e.toLintError(), false)) diff --git a/ktlint/src/main/kotlin/com/pinterest/ktlint/internal/BaselineUtils.kt b/ktlint/src/main/kotlin/com/pinterest/ktlint/internal/BaselineUtils.kt new file mode 100644 index 0000000000..1bd58f6107 --- /dev/null +++ b/ktlint/src/main/kotlin/com/pinterest/ktlint/internal/BaselineUtils.kt @@ -0,0 +1,122 @@ +package com.pinterest.ktlint.internal + +import com.pinterest.ktlint.core.LintError +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.nio.file.Paths +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.ParserConfigurationException +import org.w3c.dom.Element +import org.xml.sax.SAXException + +/** + * Loads the baseline file if one is provided. + * + * @param baselineFilePath the path to the xml baseline file + * @return a [CurrentBaseline] with the file details + */ +internal fun loadBaseline(baselineFilePath: String): CurrentBaseline { + if (baselineFilePath.isBlank()) { + return CurrentBaseline(null, false) + } + + var baselineRules: Map>? = null + var baselineGenerationNeeded = true + val baselineFile = Paths.get(baselineFilePath).toFile() + if (baselineFile.exists()) { + try { + baselineRules = parseBaseline(baselineFile.inputStream()) + baselineGenerationNeeded = false + } catch (e: IOException) { + System.err.println("Unable to parse baseline file: $baselineFilePath") + baselineGenerationNeeded = true + } catch (e: ParserConfigurationException) { + System.err.println("Unable to parse baseline file: $baselineFilePath") + baselineGenerationNeeded = true + } catch (e: SAXException) { + System.err.println("Unable to parse baseline file: $baselineFilePath") + baselineGenerationNeeded = true + } + } + + // delete the old file if one exists + if (baselineGenerationNeeded && baselineFile.exists()) { + baselineFile.delete() + } + + return CurrentBaseline(baselineRules, baselineGenerationNeeded) +} + +/** + * Parses the file to generate a mapping of [LintError] + * + * @param baselineFile the file containing the current baseline + * @return a mapping of file names to a list of all [LintError] in that file + */ +internal fun parseBaseline(baselineFile: InputStream): Map> { + val baselineRules = HashMap>() + val builderFactory = DocumentBuilderFactory.newInstance() + val docBuilder = builderFactory.newDocumentBuilder() + val doc = docBuilder.parse(baselineFile) + val filesList = doc.getElementsByTagName("file") + for (i in 0 until filesList.length) { + val fileElement = filesList.item(i) as Element + val fileName = fileElement.getAttribute("name") + val baselineErrors = parseBaselineErrorsByFile(fileElement) + baselineRules[fileName] = baselineErrors + } + return baselineRules +} + +/** + * Parses the errors inside each file tag in the xml + * + * @param element the xml "file" element + * @return a list of [LintError] for that file + */ +private fun parseBaselineErrorsByFile(element: Element): MutableList { + val errors = mutableListOf() + val errorsList = element.getElementsByTagName("error") + for (i in 0 until errorsList.length) { + val errorElement = errorsList.item(i) as Element + errors.add( + LintError( + line = errorElement.getAttribute("line").toInt(), + col = errorElement.getAttribute("column").toInt(), + ruleId = errorElement.getAttribute("source"), + detail = "" // we don't have details in the baseline file + ) + ) + } + return errors +} + +internal class CurrentBaseline( + val baselineRules: Map>?, + val baselineGenerationNeeded: Boolean +) + +/** + * Checks if the list contains the lint error. We cannot use the contains function + * as the `checkstyle` reporter formats the details string and hence the comparison + * normally fails + */ +internal fun List.containsLintError(error: LintError): Boolean { + return firstOrNull { lintError -> + lintError.col == error.col && + lintError.line == error.line && + lintError.ruleId == error.ruleId + } != null +} + +/** + * Gets the relative route of the file for baselines + * Also adjusts the slashes for uniformity between file systems + */ +internal val File.relativeRoute: String + get() { + val rootPath = Paths.get("").toAbsolutePath() + val filePath = this.toPath() + return rootPath.relativize(filePath).toString().replace(File.separatorChar, '/') + } diff --git a/ktlint/src/test/kotlin/com/pinterest/ktlint/BaselineTests.kt b/ktlint/src/test/kotlin/com/pinterest/ktlint/BaselineTests.kt new file mode 100644 index 0000000000..1a7a14b7e1 --- /dev/null +++ b/ktlint/src/test/kotlin/com/pinterest/ktlint/BaselineTests.kt @@ -0,0 +1,81 @@ +package com.pinterest.ktlint + +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import java.security.Permission +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class BaselineTests { + + @Before + fun setup() { + System.setSecurityManager(object : SecurityManager() { + override fun checkPermission(perm: Permission?) { // allow anything. + } + + override fun checkPermission(perm: Permission?, context: Any?) { // allow anything. + } + + override fun checkExit(status: Int) { + super.checkExit(status) + throw ExitException(status) + } + }) + } + + @Test + fun testNoBaseline() { + val stream = ByteArrayOutputStream() + val ps = PrintStream(stream) + System.setOut(ps) + + try { + main(arrayOf("src/test/resources/TestBaselineFile.kt")) + } catch (e: ExitException) { + // handle System.exit + } + + val output = String(stream.toByteArray()) + assertTrue(output.contains(".*:1:24: Unnecessary block".toRegex())) + assertTrue(output.contains(".*:2:1: Unexpected blank line\\(s\\) before \"}\"".toRegex())) + } + + @Test + fun testBaselineReturnsNoErrors() { + val stream = ByteArrayOutputStream() + val ps = PrintStream(stream) + System.setOut(ps) + + try { + main(arrayOf("src/test/resources/TestBaselineFile.kt", "--baseline=src/test/resources/test-baseline.xml")) + } catch (e: ExitException) { + // handle System.exit + } + + val output = String(stream.toByteArray()) + assertFalse(output.contains(".*:1:24: Unnecessary block".toRegex())) + assertFalse(output.contains(".*:2:1: Unexpected blank line\\(s\\) before \"}\"".toRegex())) + } + + @Test + fun testExtraErrorNotInBaseline() { + val stream = ByteArrayOutputStream() + val ps = PrintStream(stream) + System.setOut(ps) + + try { + main(arrayOf("src/test/resources/TestBaselineExtraErrorFile.kt", "--baseline=src/test/resources/test-baseline.xml")) + } catch (e: ExitException) { + // handle System.exit + } + + val output = String(stream.toByteArray()) + assertFalse(output.contains(".*:1:24: Unnecessary block".toRegex())) + assertTrue(output.contains(".*:2:1: Unexpected blank line\\(s\\) before \"}\"".toRegex())) + } + + private class ExitException(val status: Int) : SecurityException("Should not exit in tests") +} diff --git a/ktlint/src/test/kotlin/com/pinterest/ktlint/internal/BaselineUtilsKtTest.kt b/ktlint/src/test/kotlin/com/pinterest/ktlint/internal/BaselineUtilsKtTest.kt new file mode 100644 index 0000000000..5e7319972e --- /dev/null +++ b/ktlint/src/test/kotlin/com/pinterest/ktlint/internal/BaselineUtilsKtTest.kt @@ -0,0 +1,44 @@ +package com.pinterest.ktlint.internal + +import com.pinterest.ktlint.core.LintError +import java.io.ByteArrayInputStream +import java.io.InputStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BaselineUtilsKtTest { + + @Test + fun testParseBaselineFile() { + val filename = "TestBaselineFile.kt" + val errorOne = LintError( + line = 1, + col = 1, + ruleId = "final-new-line", + detail = "" + ) + val errorTwo = LintError( + line = 62, + col = 1, + ruleId = "no-blank-line-before-rbrace", + detail = "" + ) + + val baseline: InputStream = ByteArrayInputStream( + """ + + + + + """.toByteArray() + ) + + val baselineFiles = parseBaseline(baseline) + + assertTrue(baselineFiles.containsKey(filename)) + assertEquals(2, baselineFiles[filename]?.size) + assertTrue(true == baselineFiles[filename]?.containsLintError(errorOne)) + assertTrue(true == baselineFiles[filename]?.containsLintError(errorTwo)) + } +} diff --git a/ktlint/src/test/resources/TestBaselineExtraErrorFile.kt b/ktlint/src/test/resources/TestBaselineExtraErrorFile.kt new file mode 100644 index 0000000000..0beb3316d4 --- /dev/null +++ b/ktlint/src/test/resources/TestBaselineExtraErrorFile.kt @@ -0,0 +1,3 @@ +class TestBaselineExtraErrorFile { + +} diff --git a/ktlint/src/test/resources/TestBaselineFile.kt b/ktlint/src/test/resources/TestBaselineFile.kt new file mode 100644 index 0000000000..9f6b6e33b3 --- /dev/null +++ b/ktlint/src/test/resources/TestBaselineFile.kt @@ -0,0 +1,3 @@ +class TestBaselineFile { + +} diff --git a/ktlint/src/test/resources/test-baseline.xml b/ktlint/src/test/resources/test-baseline.xml new file mode 100644 index 0000000000..e4eff23900 --- /dev/null +++ b/ktlint/src/test/resources/test-baseline.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/settings.gradle b/settings.gradle index d734fac7dd..b10e316ed9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -27,6 +27,7 @@ rootProject.name = 'ktlint' include ':ktlint' include ':ktlint-core' +include ':ktlint-reporter-baseline' include ':ktlint-reporter-checkstyle' include ':ktlint-reporter-json' include ':ktlint-reporter-html'