feat(build): replace homebrew ktfmt formatter with Spotless

Spotless has fixed their Gradle Configuration Cache woes in the past
couple months which gets rid of my primary complaint.
This commit is contained in:
Harsh Shandilya 2024-08-14 00:18:51 +05:30
parent 3af68b45c4
commit b699b4db71
15 changed files with 65 additions and 243 deletions

View file

@ -68,6 +68,16 @@
"datasourceTemplate": "maven",
"depNameTemplate": "androidx.compose.compiler:compiler",
"registryUrlTemplate": "https://maven.google.com",
}
},
{
"fileMatch": [
"build-logic/src/main/kotlin/app/passwordstore/gradle/SpotlessPlugin.kt"
],
"matchStrings": [
"KTFMT_VERSION = \"(?<currentValue>.*)\""
],
"datasourceTemplate": "maven",
"depNameTemplate": "com.facebook:ktfmt"
}
]
}

View file

@ -20,7 +20,7 @@ jobs:
- name: Check codestyle
shell: bash
run: ./gradlew ktfmtCheck
run: ./gradlew spotlessCheck
- name: Upload Kotlin build report
if: "${{ always() }}"

View file

@ -6,7 +6,7 @@
<resources>
<plurals name="delete_title">
<item quantity="one">Wybrano %d element</item>
<item quantity="few">Wybrano %d elementy
<item quantity="few">Wybrano %d elementy
(If: 2,3,4)</item>
<item quantity="many">Wybrano %d elementów</item>
<item quantity="other">Liczba wybranych elementów: %d</item>

View file

@ -41,10 +41,6 @@ gradlePlugin {
id = "com.github.android-password-store.kotlin-jvm-library"
implementationClass = "app.passwordstore.gradle.KotlinJVMLibrary"
}
register("ktfmt") {
id = "com.github.android-password-store.ktfmt"
implementationClass = "app.passwordstore.gradle.KtfmtPlugin"
}
register("published-android-library") {
id = "com.github.android-password-store.published-android-library"
implementationClass = "app.passwordstore.gradle.PublishedAndroidLibraryPlugin"
@ -61,6 +57,10 @@ gradlePlugin {
id = "com.github.android-password-store.sentry"
implementationClass = "app.passwordstore.gradle.SentryPlugin"
}
register("spotless") {
id = "com.github.android-password-store.spotless"
implementationClass = "app.passwordstore.gradle.SpotlessPlugin"
}
register("versioning") {
id = "com.github.android-password-store.versioning-plugin"
implementationClass = "app.passwordstore.gradle.versioning.VersioningPlugin"
@ -79,7 +79,6 @@ dependencies {
implementation(libs.build.download)
implementation(libs.build.javapoet)
implementation(libs.build.kotlin)
implementation(libs.build.ktfmt)
implementation(libs.build.mavenpublish)
implementation(libs.build.metalava)
implementation(libs.build.moshi)
@ -88,6 +87,7 @@ dependencies {
implementation(libs.build.r8)
implementation(libs.build.semver)
implementation(libs.build.sentry)
implementation(libs.build.spotless)
implementation(libs.build.vcu)
implementation(libs.kotlinx.coroutines.core)

View file

@ -1,37 +0,0 @@
package app.passwordstore.gradle
import app.passwordstore.gradle.ktfmt.KtfmtCheckTask
import app.passwordstore.gradle.ktfmt.KtfmtFormatTask
import com.facebook.ktfmt.format.FormattingOptions
import java.util.concurrent.Callable
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.register
class KtfmtPlugin : Plugin<Project> {
override fun apply(target: Project) {
val input = Callable {
target.layout.projectDirectory.asFileTree.filter { file ->
file.extension == "kt" || file.extension == "kts" && !file.canonicalPath.contains("build/")
}
}
target.tasks.register<KtfmtFormatTask>("ktfmtFormat") { source(input) }
target.tasks.register<KtfmtCheckTask>("ktfmtCheck") {
source(input)
projectDirectory.set(target.layout.projectDirectory)
}
}
companion object {
val DEFAULT_FORMATTING_OPTIONS =
FormattingOptions(
maxWidth = FormattingOptions.DEFAULT_MAX_WIDTH,
blockIndent = 2,
continuationIndent = 2,
removeUnusedImports = true,
debuggingPrintOpsAfterFormatting = false,
manageTrailingCommas = true,
)
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package app.passwordstore.gradle
import com.diffplug.gradle.spotless.SpotlessExtension
import com.diffplug.gradle.spotless.SpotlessPlugin
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType
@Suppress("Unused")
class SpotlessPlugin : Plugin<Project> {
override fun apply(project: Project) {
project.pluginManager.apply(SpotlessPlugin::class)
project.extensions.getByType<SpotlessExtension>().run {
kotlin {
ktfmt(KTFMT_VERSION).googleStyle()
target("**/*.kt")
targetExclude("**/build/")
}
kotlinGradle {
ktfmt(KTFMT_VERSION).googleStyle()
target("**/*.kts")
targetExclude("**/build/")
}
format("xml") {
target("**/*.xml")
targetExclude("**/build/", ".idea/")
trimTrailingWhitespace()
indentWithSpaces()
endWithNewline()
}
}
}
private companion object {
private const val KTFMT_VERSION = "0.51"
}
}

View file

@ -1,63 +0,0 @@
package app.passwordstore.gradle.ktfmt
import app.passwordstore.gradle.KtfmtPlugin
import com.facebook.ktfmt.format.Formatter
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.FileCollection
import org.gradle.api.tasks.IgnoreEmptyDirectories
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.SourceTask
import org.gradle.api.tasks.TaskAction
@OptIn(ExperimentalCoroutinesApi::class)
abstract class KtfmtCheckTask : SourceTask() {
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputFiles
@get:IgnoreEmptyDirectories
protected val inputFiles: FileCollection
get() = super.getSource()
@get:Internal abstract val projectDirectory: DirectoryProperty
@TaskAction
fun execute() {
runBlocking(Dispatchers.IO.limitedParallelism(PARALLEL_TASK_LIMIT)) {
coroutineScope {
val results = inputFiles.map { async { checkFile(it) } }.awaitAll()
if (results.any { (notFormatted, _) -> notFormatted }) {
val prettyDiff =
results
.map { (_, diffs) -> diffs }
.flatten()
.joinToString(separator = "\n") { diff -> diff.toString() }
throw GradleException("[ktfmt] Found unformatted files\n${prettyDiff}")
}
}
}
}
private fun checkFile(input: File): Pair<Boolean, List<KtfmtDiffEntry>> {
val originCode = input.readText()
val formattedCode = Formatter.format(KtfmtPlugin.DEFAULT_FORMATTING_OPTIONS, originCode)
val pathNormalizer = { file: File -> file.toRelativeString(projectDirectory.asFile.get()) }
return (originCode != formattedCode) to
KtfmtDiffer.computeDiff(input, formattedCode, pathNormalizer)
}
companion object {
private const val PARALLEL_TASK_LIMIT = 4
}
}

View file

@ -1,8 +0,0 @@
package app.passwordstore.gradle.ktfmt
data class KtfmtDiffEntry(val input: String, val lineNumber: Int, val message: String) {
override fun toString(): String {
return "$input:$lineNumber - $message"
}
}

View file

@ -1,28 +0,0 @@
package app.passwordstore.gradle.ktfmt
import com.github.difflib.DiffUtils
import com.github.difflib.patch.ChangeDelta
import com.github.difflib.patch.DeleteDelta
import com.github.difflib.patch.InsertDelta
import java.io.File
object KtfmtDiffer {
fun computeDiff(
inputFile: File,
formattedCode: String,
pathNormalizer: (File) -> String,
): List<KtfmtDiffEntry> {
val originCode = inputFile.readText()
return DiffUtils.diff(originCode, formattedCode, null).deltas.map {
val line = it.source.position + 1
val message: String =
when (it) {
is ChangeDelta -> "Line changed: ${it.source.lines.first()}"
is DeleteDelta -> "Line deleted"
is InsertDelta -> "Line added"
else -> ""
}
KtfmtDiffEntry(pathNormalizer(inputFile), line, message)
}
}
}

View file

@ -1,46 +0,0 @@
package app.passwordstore.gradle.ktfmt
import javax.inject.Inject
import org.gradle.api.GradleException
import org.gradle.api.file.ProjectLayout
import org.gradle.api.tasks.SourceTask
import org.gradle.api.tasks.TaskAction
import org.gradle.internal.exceptions.MultiCauseException
import org.gradle.workers.WorkerExecutor
import org.jetbrains.kotlin.utils.addToStdlib.ifNotEmpty
abstract class KtfmtFormatTask
@Inject
constructor(private val workerExecutor: WorkerExecutor, private val projectLayout: ProjectLayout) :
SourceTask() {
@TaskAction
fun execute() {
val result =
with(workerExecutor.noIsolation()) {
submit(KtfmtWorkerAction::class.java) {
name.set("ktfmt-worker")
files.from(source)
projectDirectory.set(projectLayout.projectDirectory.asFile)
}
runCatching { await() }
}
result.exceptionOrNull()?.workErrorCauses<Exception>()?.ifNotEmpty {
forEach { logger.error(it.message, it.cause) }
throw GradleException("error formatting sources for $name")
}
}
private inline fun <reified T : Throwable> Throwable.workErrorCauses(): List<Throwable> {
return when (this) {
is MultiCauseException -> this.causes.map { it.cause }
else -> listOf(this.cause)
}
.filter {
// class instance comparison doesn't work due to different classloaders
it?.javaClass?.canonicalName == T::class.java.canonicalName
}
.filterNotNull()
}
}

View file

@ -1,38 +0,0 @@
package app.passwordstore.gradle.ktfmt
import app.passwordstore.gradle.KtfmtPlugin
import com.facebook.ktfmt.format.Formatter
import java.io.File
import org.gradle.api.logging.LogLevel
import org.gradle.api.logging.Logger
import org.gradle.api.logging.Logging
import org.gradle.internal.logging.slf4j.DefaultContextAwareTaskLogger
import org.gradle.workers.WorkAction
abstract class KtfmtWorkerAction : WorkAction<KtfmtWorkerParameters> {
private val logger: Logger =
DefaultContextAwareTaskLogger(Logging.getLogger(KtfmtFormatTask::class.java))
private val files: List<File> = parameters.files.toList()
private val projectDirectory: File = parameters.projectDirectory.asFile.get()
private val name: String = parameters.name.get()
override fun execute() {
try {
files.forEach { file ->
val sourceText = file.readText()
val relativePath = file.toRelativeString(projectDirectory)
logger.log(LogLevel.DEBUG, "$name checking format: $relativePath")
val formattedText = Formatter.format(KtfmtPlugin.DEFAULT_FORMATTING_OPTIONS, sourceText)
if (!formattedText.contentEquals(sourceText)) {
logger.log(LogLevel.QUIET, "${file.toRelativeString(projectDirectory)}: Format fixed")
file.writeText(formattedText)
}
}
} catch (t: Throwable) {
throw Exception("format worker execution error", t)
}
}
}

View file

@ -1,12 +0,0 @@
package app.passwordstore.gradle.ktfmt
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.workers.WorkParameters
interface KtfmtWorkerParameters : WorkParameters {
val name: Property<String>
val files: ConfigurableFileCollection
val projectDirectory: RegularFileProperty
}

View file

@ -5,6 +5,6 @@
plugins {
id("com.github.android-password-store.git-hooks")
id("com.github.android-password-store.kotlin-common")
id("com.github.android-password-store.ktfmt")
id("com.github.android-password-store.spotless")
id("com.github.android-password-store.versions")
}

View file

@ -39,7 +39,6 @@ build-diffutils = "io.github.java-diff-utils:java-diff-utils:4.12"
build-download = "de.undercouch:gradle-download-task:5.6.0"
build-javapoet = "com.squareup:javapoet:1.13.0"
build-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
build-ktfmt = "com.facebook:ktfmt:0.51"
build-mavenpublish = "com.vanniktech:gradle-maven-publish-plugin:0.29.0"
build-metalava = "me.tylerbwong.gradle.metalava:plugin:0.3.5"
build-moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
@ -48,6 +47,7 @@ build-okhttp = "com.squareup.okhttp3:okhttp:5.0.0-alpha.14"
build-r8 = "com.android.tools:r8:8.4.6-dev"
build-semver = "com.github.zafarkhaja:java-semver:0.10.2"
build-sentry = "io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin:4.11.0"
build-spotless = "com.diffplug.spotless:spotless-plugin-gradle:6.25.0"
build-vcu = "nl.littlerobots.version-catalog-update:nl.littlerobots.version-catalog-update.gradle.plugin:0.8.4"
compose-bom = "androidx.compose:compose-bom:2024.06.00"
compose-foundation-core = { module = "androidx.compose.foundation:foundation" }

View file

@ -13,6 +13,6 @@ while read -r local_ref local_oid remote_ref remote_oid; do
_=$remote_ref
_=$remote_oid
if [ "${local_oid}" != "${ZERO}" ]; then
CI=true "${GRADLE_EXEC}" metalavaCheckCompatibilityRelease lint ktfmtCheck test -PslimTests
CI=true "${GRADLE_EXEC}" metalavaCheckCompatibilityRelease lint spotlessCheck test -PslimTests
fi
done