1 /*
<lambda>null2 * Copyright 2023 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.build
18
19 import androidx.build.logging.TERMINAL_RED
20 import androidx.build.logging.TERMINAL_RESET
21 import androidx.build.uptodatedness.cacheEvenIfNoOutputs
22 import java.io.ByteArrayOutputStream
23 import java.io.File
24 import java.nio.file.Paths
25 import javax.inject.Inject
26 import org.gradle.api.DefaultTask
27 import org.gradle.api.Project
28 import org.gradle.api.attributes.java.TargetJvmEnvironment
29 import org.gradle.api.attributes.java.TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE
30 import org.gradle.api.file.ConfigurableFileCollection
31 import org.gradle.api.file.FileCollection
32 import org.gradle.api.file.FileTree
33 import org.gradle.api.model.ObjectFactory
34 import org.gradle.api.tasks.CacheableTask
35 import org.gradle.api.tasks.Classpath
36 import org.gradle.api.tasks.Input
37 import org.gradle.api.tasks.InputFiles
38 import org.gradle.api.tasks.Internal
39 import org.gradle.api.tasks.OutputFiles
40 import org.gradle.api.tasks.PathSensitive
41 import org.gradle.api.tasks.PathSensitivity
42 import org.gradle.api.tasks.SkipWhenEmpty
43 import org.gradle.api.tasks.TaskAction
44 import org.gradle.api.tasks.options.Option
45 import org.gradle.kotlin.dsl.named
46 import org.gradle.process.ExecOperations
47
48 fun Project.configureKtfmt() {
49 val ktfmtClasspath = getKtfmtConfiguration()
50 tasks.register("ktFormat", KtfmtFormatTask::class.java) { task ->
51 task.ktfmtClasspath.from(ktfmtClasspath)
52 }
53
54 val ktCheckTask =
55 tasks.register("ktCheck", KtfmtCheckTask::class.java) { task ->
56 task.ktfmtClasspath.from(ktfmtClasspath)
57 task.cacheEvenIfNoOutputs()
58 }
59
60 // afterEvaluate because Gradle's default "check" task doesn't exist yet
61 afterEvaluate {
62 // multiplatform projects with no enabled platforms do not actually apply the kotlin plugin
63 // and therefore do not have the check task. They are skipped unless a platform is enabled.
64 if (tasks.findByName("check") != null) {
65 addToCheckTask(ktCheckTask)
66 addToBuildOnServer(ktCheckTask)
67 }
68 }
69 }
70
71 private val ExcludedDirectories =
72 listOf(
73 "test-data",
74 "external",
75 )
76
<lambda>null77 private val ExcludedDirectoryGlobs = ExcludedDirectories.map { "**/$it/**/*.kt" }
78 private const val MainClass = "com.facebook.ktfmt.cli.Main"
79 private const val InputDir = "src"
80 private const val IncludedFiles = "**/*.kt"
81
Projectnull82 private fun Project.getKtfmtConfiguration(): FileCollection {
83 val conf = configurations.detachedConfiguration(dependencies.create(getLibraryByName("ktfmt")))
84 conf.attributes {
85 it.attribute(
86 TARGET_JVM_ENVIRONMENT_ATTRIBUTE,
87 project.objects.named(TargetJvmEnvironment.STANDARD_JVM)
88 )
89 }
90 return files(conf)
91 }
92
93 @CacheableTask
94 abstract class BaseKtfmtTask : DefaultTask() {
95 @get:Inject abstract val execOperations: ExecOperations
96
97 @get:Classpath abstract val ktfmtClasspath: ConfigurableFileCollection
98
99 @get:Inject abstract val objects: ObjectFactory
100
101 @get:Internal val projectPath: String = project.path
102
103 @[InputFiles PathSensitive(PathSensitivity.RELATIVE) SkipWhenEmpty]
getInputFilesnull104 open fun getInputFiles(): FileTree {
105 val projectDirectory = overrideDirectory
106 val subdirectories = overrideSubdirectories
107 if (projectDirectory == null || subdirectories.isNullOrEmpty()) {
108 // If we have a valid override, use that as the default fileTree
109 return objects.fileTree().setDir(InputDir).apply {
110 include(IncludedFiles)
111 exclude(ExcludedDirectoryGlobs)
112 }
113 }
114 return objects.fileTree().setDir(projectDirectory).apply {
115 subdirectories.forEach { include("$it/src/**/*.kt") }
116 }
117 }
118
119 /** Allows overriding to use a custom directory instead of default [Project.getProjectDir]. */
120 @get:Internal var overrideDirectory: File? = null
121
122 /**
123 * Used together with [overrideDirectory] to specify which specific subdirectories should be
124 * analyzed.
125 */
126 @get:Internal var overrideSubdirectories: List<String>? = null
127
runKtfmtnull128 protected fun runKtfmt(format: Boolean) {
129 if (getInputFiles().files.isEmpty()) return
130 val outputStream = ByteArrayOutputStream()
131 val errorStream = ByteArrayOutputStream()
132 execOperations.javaexec { javaExecSpec ->
133 javaExecSpec.standardOutput = outputStream
134 javaExecSpec.errorOutput = errorStream
135 javaExecSpec.mainClass.set(MainClass)
136 javaExecSpec.classpath = ktfmtClasspath
137 javaExecSpec.args = getArgsList(format = format)
138 javaExecSpec.jvmArgs("--add-opens=java.base/java.lang=ALL-UNNAMED")
139 overrideDirectory?.let { javaExecSpec.workingDir = it }
140 }
141
142 // https://github.com/facebook/ktfmt/blob/9830466327b72879808b0d6266d2cc69ef0197b2/core/src/main/java/com/facebook/ktfmt/cli/Main.kt#L168
143 // Info messages are printed to error, filter these out to avoid stderr clutter.
144 val error =
145 errorStream
146 .toString()
147 .lines()
148 .filterNot { it.startsWith("Done formatting ") }
149 .joinToString(separator = "\n")
150
151 if (error.isNotBlank()) {
152 System.err.println(error)
153 }
154
155 val output = outputStream.toString()
156 if (output.isNotEmpty()) {
157 error(processOutput(output))
158 }
159 }
160
processOutputnull161 open fun processOutput(output: String): String =
162 """
163 Failed check for the following files:
164 $output
165 """
166 .trimIndent()
167
168 private fun getArgsList(format: Boolean): List<String> {
169 val arguments = mutableListOf("--kotlinlang-style")
170 if (!format) arguments.add("--dry-run")
171 arguments.addAll(getInputFiles().files.map { it.absolutePath })
172 return arguments
173 }
174 }
175
176 @CacheableTask
177 abstract class KtfmtFormatTask : BaseKtfmtTask() {
178 init {
179 description = "Fix Kotlin code style deviations."
180 group = "formatting"
181 }
182
183 // Format task rewrites inputs, so the outputs are the same as inputs.
getRewrittenFilesnull184 @OutputFiles fun getRewrittenFiles(): FileTree = getInputFiles()
185
186 @TaskAction
187 fun runFormat() {
188 runKtfmt(format = true)
189 }
190 }
191
192 @CacheableTask
193 abstract class KtfmtCheckTask : BaseKtfmtTask() {
194 init {
195 description = "Check Kotlin code style."
196 group = "Verification"
197 }
198
199 @TaskAction
runChecknull200 fun runCheck() {
201 runKtfmt(format = false)
202 }
203
processOutputnull204 override fun processOutput(output: String): String =
205 """
206 Failed check for the following files:
207 $output
208
209 ********************************************************************************
210 ${TERMINAL_RED}You can automatically fix these issues with:
211 ./gradlew $projectPath:ktFormat$TERMINAL_RESET
212 ********************************************************************************
213 """
214 .trimIndent()
215 }
216
217 @CacheableTask
218 abstract class KtfmtCheckFileTask : BaseKtfmtTask() {
219 init {
220 description = "Check Kotlin code style."
221 group = "Verification"
222 }
223
224 @get:Internal val projectDir = project.projectDir
225
226 @get:Input
227 @set:Option(
228 option = "file",
229 description =
230 "File to check. This option can be used multiple times: --file file1.kt " +
231 "--file file2.kt"
232 )
233 var files: List<String> = emptyList()
234
235 @get:Input
236 @set:Option(
237 option = "format",
238 description =
239 "Use --format to auto-correct style violations (if some errors cannot be " +
240 "fixed automatically they will be printed to stderr)"
241 )
242 var format = false
243
244 override fun getInputFiles(): FileTree {
245 if (files.isEmpty()) {
246 return objects.fileTree().setDir(projectDir).apply { exclude("**") }
247 }
248 val kotlinFiles =
249 files
250 .filter { file ->
251 val isKotlinFile = file.endsWith(".kt") || file.endsWith(".ktx")
252 val inExcludedDir =
253 Paths.get(file).any { subPath ->
254 ExcludedDirectories.contains(subPath.toString())
255 }
256
257 isKotlinFile && !inExcludedDir
258 }
259 .map { it.replace("./", "**/") }
260
261 if (kotlinFiles.isEmpty()) {
262 return objects.fileTree().setDir(projectDir).apply { exclude("**") }
263 }
264 return objects.fileTree().setDir(projectDir).apply { include(kotlinFiles) }
265 }
266
267 @TaskAction
268 fun runCheck() {
269 runKtfmt(format = format)
270 }
271
272 override fun processOutput(output: String): String {
273 val kotlinFiles =
274 files.filter { file ->
275 val isKotlinFile = file.endsWith(".kt") || file.endsWith(".ktx")
276 val inExcludedDir =
277 Paths.get(file).any { subPath ->
278 ExcludedDirectories.contains(subPath.toString())
279 }
280
281 isKotlinFile && !inExcludedDir
282 }
283 return """
284 Failed check for the following files:
285 $output
286
287 ********************************************************************************
288 ${TERMINAL_RED}You can attempt to automatically fix these issues with:
289 ./gradlew :ktCheckFile --format ${kotlinFiles.joinToString(separator = " "){ "--file $it" }}$TERMINAL_RESET
290 ********************************************************************************
291 """
292 .trimIndent()
293 }
294 }
295
Projectnull296 fun Project.configureKtfmtCheckFile() {
297 tasks.register("ktCheckFile", KtfmtCheckFileTask::class.java) { task ->
298 task.ktfmtClasspath.from(getKtfmtConfiguration())
299 }
300 }
301