diff --git a/CHANGELOG.md b/CHANGELOG.md index b939fb6fe..f4bae929c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [13.1.0] - 2025-08-21 - Gradle 9 Support [#937](https://github.com/JLLeitschuh/ktlint-gradle/pull/937) +- Problems API Integration [#927](https://github.com/JLLeitschuh/ktlint-gradle/pull/927) ## [13.0.0] - 2025-07-07 diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index affdf53a5..c809ce12c 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -129,6 +129,9 @@ dependencies { testImplementation(libs.kotlin.reflect) testImplementation(libs.ktlint.rule.engine) testImplementation(libs.archunit.junit5) + testImplementation(gradleApi()) + // Used to test the problems API + testImplementation(libs.mockito.kotlin) } kotlin { diff --git a/plugin/gradle/libs.versions.toml b/plugin/gradle/libs.versions.toml index ba5e61bcf..f7f5ed7d3 100644 --- a/plugin/gradle/libs.versions.toml +++ b/plugin/gradle/libs.versions.toml @@ -11,6 +11,7 @@ junit5 = "5.5.2" assertJ = "3.27.4" commonsIo = "2.17.0" archUnit = "1.4.0" +mockitoKotlin = "4.1.0" [libraries] android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "androidPlugin" } @@ -23,5 +24,6 @@ kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", v kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlin-script-runtime = { module = "org.jetbrains.kotlin:kotlin-script-runtime", version.ref = "kotlin" } ktlint-rule-engine = { module = "com.pinterest.ktlint:ktlint-rule-engine", version.ref = "ktlint" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" } semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" } slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "sl4fj" } diff --git a/plugin/src/main/kotlin/org/jlleitschuh/gradle/ktlint/reporter/ProblemsApiReporter.kt b/plugin/src/main/kotlin/org/jlleitschuh/gradle/ktlint/reporter/ProblemsApiReporter.kt new file mode 100644 index 000000000..f8f96b9ac --- /dev/null +++ b/plugin/src/main/kotlin/org/jlleitschuh/gradle/ktlint/reporter/ProblemsApiReporter.kt @@ -0,0 +1,38 @@ +package org.jlleitschuh.gradle.ktlint.reporter + +import org.gradle.api.problems.ProblemGroup +import org.gradle.api.problems.ProblemId +import org.gradle.api.problems.ProblemReporter +import org.gradle.api.problems.Problems +import org.gradle.api.problems.Severity +import org.jlleitschuh.gradle.ktlint.worker.SerializableLintError +import javax.inject.Inject + +internal class ProblemsApiReporter @Inject constructor( + private val problems: Problems +) { + + companion object { + private val PROBLEM_GROUP = ProblemGroup.create("ktlint-gradle", "ktlint-gradle issue") + } + + fun reportProblems(lintErrors: Map>, ignoreFailures: Boolean) { + val severity = if (ignoreFailures) Severity.WARNING else Severity.ERROR + lintErrors.forEach { (filePath, errors) -> + errors.forEach { error -> + reportProblem(error, filePath, severity) + } + } + } + + fun reportProblem(error: SerializableLintError, filePath: String, severity: Severity) { + val reporter: ProblemReporter? = problems.reporter + val id = ProblemId.create(error.ruleId, error.detail, PROBLEM_GROUP) + reporter?.report(id) { + lineInFileLocation(filePath, error.line, error.col) + details(error.detail) + severity(severity) + solution("Run ktlintFormat to auto-fix this issue") + } + } +} diff --git a/plugin/src/main/kotlin/org/jlleitschuh/gradle/ktlint/worker/ConsoleReportWorkAction.kt b/plugin/src/main/kotlin/org/jlleitschuh/gradle/ktlint/worker/ConsoleReportWorkAction.kt index 747562094..893c31bf8 100644 --- a/plugin/src/main/kotlin/org/jlleitschuh/gradle/ktlint/worker/ConsoleReportWorkAction.kt +++ b/plugin/src/main/kotlin/org/jlleitschuh/gradle/ktlint/worker/ConsoleReportWorkAction.kt @@ -10,12 +10,17 @@ import org.gradle.api.provider.Property import org.gradle.workers.WorkAction import org.gradle.workers.WorkParameters import org.jetbrains.kotlin.util.prefixIfNot +import org.jlleitschuh.gradle.ktlint.reporter.ProblemsApiReporter import java.io.File +import javax.inject.Inject internal abstract class ConsoleReportWorkAction : WorkAction { private val logger = Logging.getLogger("ktlint-console-report-worker") + @Inject + private lateinit var problemsApiReporter: ProblemsApiReporter + override fun execute() { val errors = KtLintClassesSerializer .create() @@ -42,6 +47,17 @@ internal abstract class ConsoleReportWorkAction : WorkAction diff --git a/plugin/src/test/kotlin/org/jlleitschuh/gradle/ktlint/reporter/ProblemsApiReporterTest.kt b/plugin/src/test/kotlin/org/jlleitschuh/gradle/ktlint/reporter/ProblemsApiReporterTest.kt new file mode 100644 index 000000000..26fdc8d82 --- /dev/null +++ b/plugin/src/test/kotlin/org/jlleitschuh/gradle/ktlint/reporter/ProblemsApiReporterTest.kt @@ -0,0 +1,198 @@ +package org.jlleitschuh.gradle.ktlint.reporter + +import org.assertj.core.api.Assertions.assertThat +import org.gradle.api.Action +import org.gradle.api.problems.ProblemReporter +import org.gradle.api.problems.ProblemSpec +import org.gradle.api.problems.Problems +import org.jlleitschuh.gradle.ktlint.worker.SerializableLintError +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.gradle.api.problems.Severity as GradleSeverity + +class ProblemsApiReporterTest { + + private lateinit var problemsService: Problems + private lateinit var problemReporter: ProblemReporter + private lateinit var reporter: ProblemsApiReporter + + @BeforeEach + fun setUp() { + problemsService = mock() + problemReporter = mock() + reporter = ProblemsApiReporter(problemsService) + whenever(problemsService.reporter).thenReturn(problemReporter) + } + + @Test + fun `given a lint error, it correctly reports it to the Gradle Problems API`() { + val error = SerializableLintError( + line = 4, + col = 1, + ruleId = "no-wildcard-imports", + detail = "Wildcard import detected", + canBeAutoCorrected = true + ) + val filePath = "src/main/kotlin/TestFile.kt" + + reporter.reportProblem(error, filePath, GradleSeverity.ERROR) + + val specCaptor = argumentCaptor>() + verify(problemReporter).report(any(), specCaptor.capture()) + + val spec: ProblemSpec = mock() + specCaptor.firstValue.execute(spec) + + verify(spec).details("Wildcard import detected") + verify(spec).severity(GradleSeverity.ERROR) + verify(spec).lineInFileLocation(eq(filePath), eq(4), eq(1)) + verify(spec).solution("Run ktlintFormat to auto-fix this issue") + } + + @Test + fun `given multiple lint errors, it correctly reports all of them`() { + val errors = mapOf( + "src/main/kotlin/File1.kt" to listOf( + SerializableLintError( + line = 1, + col = 1, + ruleId = "no-wildcard-imports", + detail = "Wildcard import detected", + canBeAutoCorrected = true + ) + ), + "src/main/kotlin/File2.kt" to listOf( + SerializableLintError( + line = 5, + col = 10, + ruleId = "no-unused-imports", + detail = "Unused import", + canBeAutoCorrected = false + ), + SerializableLintError( + line = 10, + col = 5, + ruleId = "no-trailing-whitespace", + detail = "Trailing whitespace", + canBeAutoCorrected = true + ) + ) + ) + + reporter.reportProblems(errors, false) // ignoreFailures = false, so ERROR severity + + // Should report 3 total errors + verify(problemReporter, times(3)).report(any(), any()) + } + + @Test + fun `given no errors, it does not report any problems`() { + val emptyErrors = emptyMap>() + + reporter.reportProblems(emptyErrors, false) + + verify(problemReporter, never()).report(any(), any()) + } + + @Test + fun `severity is WARNING when ignoreFailures is true`() { + val errors = mapOf( + "src/main/kotlin/TestFile.kt" to listOf( + SerializableLintError( + line = 1, + col = 1, + ruleId = "test-rule", + detail = "Test error", + canBeAutoCorrected = false + ) + ) + ) + + reporter.reportProblems(errors, true) // ignoreFailures = true, so WARNING severity + + val specCaptor = argumentCaptor>() + verify(problemReporter).report(any(), specCaptor.capture()) + + val spec: ProblemSpec = mock() + specCaptor.firstValue.execute(spec) + + verify(spec).severity(GradleSeverity.WARNING) + } + + @Test + fun `reports correct problem group and id`() { + val error = SerializableLintError( + line = 1, + col = 1, + ruleId = "no-wildcard-imports", + detail = "Wildcard import detected", + canBeAutoCorrected = true + ) + val filePath = "src/main/kotlin/TestFile.kt" + + reporter.reportProblem(error, filePath, GradleSeverity.ERROR) + + val idCaptor = argumentCaptor() + verify(problemReporter).report(idCaptor.capture(), any()) + + val capturedId = idCaptor.firstValue + assertThat(capturedId.name).isEqualTo("no-wildcard-imports") + assertThat(capturedId.displayName).isEqualTo("Wildcard import detected") + assertThat(capturedId.group.name).isEqualTo("ktlint-gradle") + assertThat(capturedId.group.displayName).isEqualTo("ktlint-gradle issue") + } + + @Test + fun `handles different error types correctly`() { + val autoCorrectableError = SerializableLintError( + line = 1, + col = 1, + ruleId = "auto-correctable", + detail = "Can be fixed", + canBeAutoCorrected = true + ) + val nonAutoCorrectableError = SerializableLintError( + line = 2, + col = 1, + ruleId = "manual-fix", + detail = "Must fix manually", + canBeAutoCorrected = false + ) + + reporter.reportProblem(autoCorrectableError, "test.kt", GradleSeverity.ERROR) + reporter.reportProblem(nonAutoCorrectableError, "test.kt", GradleSeverity.ERROR) + + // Both should be reported with ERROR severity + verify(problemReporter, times(2)).report(any(), any()) + } + + @Test + fun `reports errors with correct line and column information`() { + val error = SerializableLintError( + line = 42, + col = 15, + ruleId = "test-rule", + detail = "Test error", + canBeAutoCorrected = false + ) + val filePath = "src/main/kotlin/ComplexFile.kt" + + reporter.reportProblem(error, filePath, GradleSeverity.ERROR) + + val specCaptor = argumentCaptor>() + verify(problemReporter).report(any(), specCaptor.capture()) + + val spec: ProblemSpec = mock() + specCaptor.firstValue.execute(spec) + + verify(spec).lineInFileLocation(eq(filePath), eq(42), eq(15)) + } +}