From fd20480f554060805acba3124cb251be7824c4d2 Mon Sep 17 00:00:00 2001 From: Harsh Shandilya Date: Sat, 29 Oct 2022 07:29:46 +0530 Subject: [PATCH] feat(build): add a homebrew ktfmt plugin The general idea of the implementation is borrowed from https://github.com/cortinico/ktfmt-gradle --- build-logic/kotlin-plugins/build.gradle.kts | 9 ++- .../app/passwordstore/gradle/KtfmtPlugin.kt | 32 +++++++++ .../gradle/ktfmt/KtfmtCheckTask.kt | 68 +++++++++++++++++++ .../gradle/ktfmt/KtfmtDiffEntry.kt | 3 + .../passwordstore/gradle/ktfmt/KtfmtDiffer.kt | 35 ++++++++++ .../gradle/ktfmt/KtfmtFormatTask.kt | 56 +++++++++++++++ build.gradle.kts | 3 +- gradle/libs.versions.toml | 2 + 8 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/KtfmtPlugin.kt create mode 100644 build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtCheckTask.kt create mode 100644 build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffEntry.kt create mode 100644 build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffer.kt create mode 100644 build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtFormatTask.kt diff --git a/build-logic/kotlin-plugins/build.gradle.kts b/build-logic/kotlin-plugins/build.gradle.kts index fc08cd15..815d6ca0 100644 --- a/build-logic/kotlin-plugins/build.gradle.kts +++ b/build-logic/kotlin-plugins/build.gradle.kts @@ -20,7 +20,7 @@ afterEvaluate { tasks.withType().configureEach { kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() - freeCompilerArgs = freeCompilerArgs + "-Xsam-conversions=class" + freeCompilerArgs = freeCompilerArgs + "-Xsam-conversions=class" + "-opt-in=kotlin.RequiresOptIn" } } } @@ -43,6 +43,10 @@ gradlePlugin { id = "com.github.android-password-store.kotlin-library" implementationClass = "app.passwordstore.gradle.KotlinLibraryPlugin" } + register("ktfmt") { + id = "com.github.android-password-store.ktfmt" + implementationClass = "app.passwordstore.gradle.KtfmtPlugin" + } register("spotless") { id = "com.github.android-password-store.spotless" implementationClass = "app.passwordstore.gradle.SpotlessPlugin" @@ -57,9 +61,12 @@ gradlePlugin { dependencies { implementation(libs.build.agp) implementation(libs.build.detekt) + implementation(libs.build.diffutils) implementation(libs.build.kotlin) + implementation(libs.build.ktfmt) implementation(libs.build.r8) implementation(libs.build.spotless) implementation(libs.build.vcu) implementation(libs.build.versions) + implementation(libs.kotlin.coroutines.core) } diff --git a/build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/KtfmtPlugin.kt b/build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/KtfmtPlugin.kt new file mode 100644 index 00000000..74e8ba95 --- /dev/null +++ b/build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/KtfmtPlugin.kt @@ -0,0 +1,32 @@ +package app.passwordstore.gradle + +import app.passwordstore.gradle.ktfmt.KtfmtCheckTask +import app.passwordstore.gradle.ktfmt.KtfmtFormatTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.register + +class KtfmtPlugin : Plugin { + + override fun apply(target: Project) { + target.tasks.register("ktfmtFormat") { + source = + project.layout.projectDirectory.asFileTree + .filter { file -> + file.extension == "kt" || + file.extension == "kts" && !file.canonicalPath.contains("build") + } + .asFileTree + } + target.tasks.register("ktfmtCheck") { + source = + project.layout.projectDirectory.asFileTree + .filter { file -> + file.extension == "kt" || + file.extension == "kts" && !file.canonicalPath.contains("build") + } + .asFileTree + projectDirectory.set(target.layout.projectDirectory) + } + } +} diff --git a/build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtCheckTask.kt b/build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtCheckTask.kt new file mode 100644 index 00000000..a50c8494 --- /dev/null +++ b/build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtCheckTask.kt @@ -0,0 +1,68 @@ +package app.passwordstore.gradle.ktfmt + +import com.facebook.ktfmt.format.Formatter +import com.facebook.ktfmt.format.FormattingOptions +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.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 }) { + results + .map { (_, diffs) -> diffs } + .forEach { diffs -> KtfmtDiffer.printDiff(diffs, logger) } + error("[ktfmt] Found unformatted files") + } + } + } + } + + private fun checkFile(input: File): Pair> { + val originCode = input.readText() + val formattedCode = + Formatter.format( + FormattingOptions( + style = FormattingOptions.Style.GOOGLE, + maxWidth = 100, + continuationIndent = 2, + ), + 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 + } +} diff --git a/build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffEntry.kt b/build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffEntry.kt new file mode 100644 index 00000000..44d1a967 --- /dev/null +++ b/build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffEntry.kt @@ -0,0 +1,3 @@ +package app.passwordstore.gradle.ktfmt + +data class KtfmtDiffEntry(val input: String, val lineNumber: Int, val message: String) diff --git a/build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffer.kt b/build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffer.kt new file mode 100644 index 00000000..936596cd --- /dev/null +++ b/build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffer.kt @@ -0,0 +1,35 @@ +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 +import org.gradle.api.logging.Logger + +object KtfmtDiffer { + fun computeDiff( + inputFile: File, + formattedCode: String, + pathNormalizer: (File) -> String + ): List { + 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) + } + } + + fun printDiff(entries: List, logger: Logger) { + entries.forEach { entry -> + logger.error("${entry.input}:${entry.lineNumber} - ${entry.message}") + } + } +} diff --git a/build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtFormatTask.kt b/build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtFormatTask.kt new file mode 100644 index 00000000..82ce4ca3 --- /dev/null +++ b/build-logic/kotlin-plugins/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtFormatTask.kt @@ -0,0 +1,56 @@ +package app.passwordstore.gradle.ktfmt + +import com.facebook.ktfmt.format.Formatter +import com.facebook.ktfmt.format.FormattingOptions +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.file.FileCollection +import org.gradle.api.tasks.IgnoreEmptyDirectories +import org.gradle.api.tasks.InputFiles +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 KtfmtFormatTask : SourceTask() { + + @get:PathSensitive(PathSensitivity.RELATIVE) + @get:InputFiles + @get:IgnoreEmptyDirectories + protected val inputFiles: FileCollection + get() = super.getSource() + + @TaskAction + fun execute() { + runBlocking(Dispatchers.IO.limitedParallelism(PARALLEL_TASK_LIMIT)) { + coroutineScope { inputFiles.map { async { formatFile(it) } }.awaitAll() } + } + } + + private fun formatFile(input: File) { + val originCode = input.readText() + val formattedCode = + Formatter.format( + FormattingOptions( + style = FormattingOptions.Style.GOOGLE, + maxWidth = 100, + continuationIndent = 2, + ), + originCode + ) + if (originCode != formattedCode) { + input.writeText(formattedCode) + } + } + + companion object { + + private const val PARALLEL_TASK_LIMIT = 4 + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 0ad75cd6..17725b88 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,8 +5,9 @@ @file:Suppress("DSL_SCOPE_VIOLATION", "UnstableApiUsage") plugins { - id("com.github.android-password-store.kotlin-common") 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") alias(libs.plugins.hilt) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 85efa1cb..d56cf327 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,8 +39,10 @@ aps-sublimeFuzzy = "com.github.android-password-store:sublime-fuzzy:2.2.1" aps-zxingAndroidEmbedded = "com.github.android-password-store:zxing-android-embedded:4.2.1" build-agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } build-detekt = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.21.0" +build-diffutils = "io.github.java-diff-utils:java-diff-utils:4.12" build-download = "de.undercouch:gradle-download-task:5.3.0" build-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +build-ktfmt = "com.facebook:ktfmt:0.41" build-mavenpublish = "com.vanniktech:gradle-maven-publish-plugin:0.22.0" build-metalava = "me.tylerbwong.gradle.metalava:plugin:0.3.2" build-okhttp = "com.squareup.okhttp3:okhttp:4.10.0"