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