foundry.gradle.dependencyrake.DependencyRake.kt Maven / Gradle / Ivy
/*
* Copyright (C) 2022 Slack Technologies, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package foundry.gradle.dependencyrake
import com.autonomousapps.AbstractPostProcessingTask
import com.autonomousapps.model.Advice
import com.autonomousapps.model.Coordinates
import com.autonomousapps.model.FlatCoordinates
import com.autonomousapps.model.IncludedBuildCoordinates
import com.autonomousapps.model.ModuleCoordinates
import com.autonomousapps.model.PluginAdvice
import com.autonomousapps.model.ProjectCoordinates
import foundry.gradle.artifacts.FoundryArtifact
import foundry.gradle.artifacts.Resolver
import foundry.gradle.convertProjectPathToAccessor
import foundry.gradle.properties.mapToBoolean
import foundry.gradle.property
import java.io.File
import javax.inject.Inject
import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.MapProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.ProviderFactory
import org.gradle.api.provider.SetProperty
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.TaskProvider
import org.gradle.api.tasks.UntrackedTask
import org.gradle.work.DisableCachingByDefault
private const val IGNORE_COMMENT = "// dependency-rake=ignore"
private val PREFERRED_BUNDLE_IDENTIFIERS =
mapOf("com.google.android.play:core" to "com.google.android.play:core-ktx")
// These are dependencies we manage directly in SlackExtension or other plugins
private val MANAGED_DEPENDENCIES =
setOf(
// Managed by AGP
"androidx.databinding:viewbinding",
// Managed by KGP
"org.jetbrains.kotlin:kotlin-stdlib",
// Managed by the SGP robolectric DSL feature
"org.robolectric:shadowapi",
"org.robolectric:shadows-framework",
)
/**
* Task that consumes the generated advice report json generated by `AdviceTask` and applies its
* advice to the project build file. This is usually not run directly, but rather added as a
* finalizer to the `AdviceTask` it reads from.
*/
@DisableCachingByDefault
internal abstract class RakeDependencies
@Inject
constructor(objects: ObjectFactory, providers: ProviderFactory) : AbstractPostProcessingTask() {
@get:Input abstract val identifierMap: MapProperty
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputFile
abstract val buildFileProperty: RegularFileProperty
init {
@Suppress("LeakingThis") doNotTrackState("This task modifies build scripts in place.")
}
@get:Input
val modes: SetProperty =
objects
.setProperty(AnalysisMode::class.java)
.convention(
providers
.gradleProperty("foundry.dependencyrake.modes")
.map { it.splitToSequence(",").map(AnalysisMode::valueOf).toSet() }
.orElse(
setOf(
AnalysisMode.COMPILE_ONLY,
AnalysisMode.UNUSED,
AnalysisMode.MISUSED,
AnalysisMode.PLUGINS,
AnalysisMode.ABI,
)
)
)
@get:Input
val dryRun: Property =
objects
.property()
.convention(
providers.gradleProperty("foundry.dependencyrake.dryRun").mapToBoolean().orElse(false)
)
@get:Input abstract val noApi: Property
@get:OutputFile abstract val missingIdentifiersFile: RegularFileProperty
init {
group = "rake"
}
@TaskAction
fun rake() {
if (identifierMap.get().isEmpty()) {
logger.warn("No identifier map found. Skipping rake.")
return
}
val noApi = noApi.get()
val projectAdvice = projectAdvice()
val redundantPlugins = projectAdvice.pluginAdvice
val advices: Set = projectAdvice.dependencyAdvice
val buildFile = buildFileProperty.asFile.get()
val missingIdentifiers = mutableSetOf()
logger.lifecycle("🌲 Raking $buildFile ")
rakeProject(buildFile, advices, redundantPlugins, noApi, missingIdentifiers)
val identifiersFile = missingIdentifiersFile.asFile.get()
if (missingIdentifiers.isNotEmpty()) {
logger.lifecycle("⚠️ Missing identifiers found, written to $identifiersFile")
}
identifiersFile.writeText(missingIdentifiers.sorted().joinToString("\n"))
}
@Suppress("LongMethod", "ComplexMethod")
private fun rakeProject(
buildFile: File,
advices: Set,
redundantPlugins: Set,
noApi: Boolean,
missingIdentifiers: MutableSet,
) {
val resolvedModes = modes.get()
val abiModeEnabled = AnalysisMode.ABI in resolvedModes
val unusedDepsToRemove =
if (AnalysisMode.UNUSED in resolvedModes) {
advices
.filter { it.isRemove() }
.filterNot { it.coordinates.identifier in MANAGED_DEPENDENCIES }
.associateBy { it.toDependencyString("UNUSED", missingIdentifiers) }
} else {
emptyMap()
}
val misusedDepsToRemove =
if (AnalysisMode.MISUSED in resolvedModes) {
advices
.filter { it.isRemove() }
.filterNot { it.coordinates.identifier in MANAGED_DEPENDENCIES }
.associateBy { it.toDependencyString("MISUSED", missingIdentifiers) }
} else {
emptyMap()
}
val depsToRemove = (unusedDepsToRemove + misusedDepsToRemove)
val depsToChange =
if (AnalysisMode.ABI in resolvedModes) {
advices
.filter { it.isChange() }
.filterNot { it.coordinates.identifier in MANAGED_DEPENDENCIES }
.associateBy { it.toDependencyString("CHANGE", missingIdentifiers) }
} else {
emptyMap()
}
val depsToAdd =
if (AnalysisMode.MISUSED in resolvedModes) {
advices
.filter { it.isAdd() }
.filterNot { it.coordinates.identifier in MANAGED_DEPENDENCIES }
.associateBy { it.coordinates.identifier }
.toMutableMap()
} else {
mutableMapOf()
}
val compileOnlyDeps =
if (AnalysisMode.COMPILE_ONLY in resolvedModes) {
advices
.filter { it.isCompileOnly() }
.associateBy { it.toDependencyString("ADD-COMPILE-ONLY", missingIdentifiers) }
} else {
emptyMap()
}
// Now start rewriting the build file
val newLines = mutableListOf()
buildFile.useLines { lines ->
var inDependenciesBlock = false
var done = false
var ignoreNext = false
lines.forEach { line ->
if (done) {
newLines += line
return@forEach
}
if (!inDependenciesBlock) {
if (line.trimStart().startsWith("dependencies {")) {
inDependenciesBlock = true
}
newLines += line
return@forEach
} else {
when {
line.trimEnd() == "}" -> {
done = true
// Emit any remaining new dependencies to add
depsToAdd.entries
.mapNotNull { (_, advice) ->
advice.coordinates.toDependencyNotation("ADD-NEW", missingIdentifiers)?.let {
newNotation ->
var newConfiguration = advice.toConfiguration!!
if (noApi && newConfiguration == "api") {
newConfiguration = "implementation"
}
" $newConfiguration($newNotation)"
}
}
.sorted()
.forEach {
logger.lifecycle(" ➕ Adding '${it.trimStart()}'")
newLines += it
}
newLines += line
return@forEach
}
IGNORE_COMMENT in line -> {
ignoreNext = true
newLines += line
return@forEach
}
ignoreNext -> {
ignoreNext = false
newLines += line
return@forEach
}
depsToRemove.keys.any { it in line } -> {
if (" {" in line) {
logger.lifecycle(" 🤔 Could not remove '$line'")
newLines += line
return@forEach
}
logger.lifecycle(" ⛔ Removing '${line.trimStart()}'")
// If this is being swapped with used transitives, inline them here
// Note we remove from the depsToAdd list on a first-come-first-serve bases (in case
// multiple deps pull the same transitives).
advices
.filter { it.isAdd() }
.mapNotNull { depsToAdd.remove(it.coordinates.identifier) }
.mapNotNull { advice ->
advice.coordinates.toDependencyNotation("ADD", missingIdentifiers)?.let {
newNotation ->
val newConfiguration =
if (!abiModeEnabled) {
"implementation"
} else {
advice.toConfiguration
}
" $newConfiguration($newNotation)"
}
}
.sorted()
.forEach { newLines += it }
return@forEach
}
depsToChange.keys.any { it in line } || compileOnlyDeps.keys.any { it in line } -> {
if (" {" in line) {
logger.lifecycle(" 🤔 Could not modify '$line'")
newLines += line
return@forEach
}
val which =
if (depsToChange.keys.any { it in line }) depsToChange else compileOnlyDeps
val (_, abiDep) =
which.entries.first { (_, v) ->
v.coordinates.toDependencyNotation("ABI", missingIdentifiers)?.let { it in line }
?: false
}
val oldConfiguration = abiDep.fromConfiguration!!
var newConfiguration = abiDep.toConfiguration!!
if (noApi && newConfiguration == "api") {
newConfiguration = "implementation"
}
// Replace the oldConfiguration name with API
val newLine = line.replace("$oldConfiguration(", "$newConfiguration(")
logger.lifecycle(" ✏️ Modifying configuration")
logger.lifecycle(" -${line.trimStart()}")
logger.lifecycle(" +${newLine.trimStart()}")
newLines += newLine
return@forEach
}
else -> {
newLines += line
return@forEach
}
}
}
}
}
if (AnalysisMode.PLUGINS in resolvedModes) {
redundantPlugins.forEach { (id, reason) ->
val lookFor =
if (id.startsWith("org.jetbrains.kotlin.")) {
"kotlin(\"${id.removePrefix("org.jetbrains.kotlin.")}\")"
} else {
"id(\"$id\")"
}
val pluginLine = newLines.indexOfFirst { lookFor == it.trim() }
if (pluginLine != -1) {
logger.lifecycle("Removing unused plugin \'$id\' in $buildFile. $reason")
newLines.removeAt(pluginLine)
}
}
}
val fileToWrite =
if (!dryRun.get()) {
buildFile
} else {
buildFile.parentFile.resolve("new-build.gradle.kts").apply {
if (exists()) {
delete()
}
createNewFile()
}
}
fileToWrite.writeText(newLines.cleanLineFormatting().joinToString("\n"))
}
/** Remaps a given [Coordinates] to a known toml lib reference or error if [error] is true. */
private fun Coordinates.mapIdentifier(
context: String,
missingIdentifiers: MutableSet,
): Coordinates? {
return when (this) {
is ModuleCoordinates -> {
val preferredIdentifier = PREFERRED_BUNDLE_IDENTIFIERS.getOrDefault(identifier, identifier)
val newIdentifier =
identifierMap.get()[preferredIdentifier]
?: run {
logger.lifecycle("($context) Unknown identifier: $identifier")
missingIdentifiers += identifier
return null
}
ModuleCoordinates(newIdentifier, resolvedVersion, gradleVariantIdentification)
}
is FlatCoordinates,
is IncludedBuildCoordinates,
is ProjectCoordinates -> this
}
}
private fun Advice.toDependencyString(
context: String,
missingIdentifiers: MutableSet,
): String {
return "${fromConfiguration ?: error("Transitive dep $this")}(${coordinates.toDependencyNotation(context, missingIdentifiers)})"
}
private fun Coordinates.toDependencyNotation(
context: String,
missingIdentifiers: MutableSet,
): String? {
return when (this) {
is ProjectCoordinates -> "projects.${convertProjectPathToAccessor(identifier)}"
is ModuleCoordinates -> mapIdentifier(context, missingIdentifiers)?.identifier
is FlatCoordinates -> gav()
is IncludedBuildCoordinates -> gav()
}
}
enum class AnalysisMode {
/** Remove unused dependencies. */
UNUSED,
/** Modify dependencies that could be `compileOnly`. */
COMPILE_ONLY,
/** Fix dependencies that should be `api`. */
ABI,
/** Replace misused dependencies with their transitively-used dependencies. */
MISUSED,
/** Remove unused or redundant plugins. */
PLUGINS,
}
}
private fun List.cleanLineFormatting(): List {
val cleanedBlankLines = mutableListOf()
var blankLineCount = 0
for (newLine in this) {
if (newLine.isBlank()) {
if (blankLineCount == 1) {
// Skip this line
} else {
blankLineCount++
cleanedBlankLines += newLine
}
} else {
blankLineCount = 0
cleanedBlankLines += newLine
}
}
return cleanedBlankLines.padNewline()
}
private fun List.padNewline(): List {
val noEmpties = dropLastWhile { it.isBlank() }
return noEmpties + ""
}
@UntrackedTask(because = "Dependency Rake tasks modify build files")
internal abstract class MissingIdentifiersAggregatorTask : DefaultTask() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val inputFiles: ConfigurableFileCollection
@get:OutputFile abstract val outputFile: RegularFileProperty
init {
group = "rake"
description = "Aggregates missing identifiers from all upstream dependency rake tasks."
}
@TaskAction
fun aggregate() {
val aggregated = inputFiles.flatMap { it.readLines() }.toSortedSet()
val output = outputFile.asFile.get()
logger.lifecycle("Writing aggregated missing identifiers to $output")
output.writeText(aggregated.joinToString("\n"))
}
companion object {
const val NAME = "aggregateMissingIdentifiers"
fun register(rootProject: Project): TaskProvider {
val resolver =
Resolver.interProjectResolver(rootProject, FoundryArtifact.DAGP_MISSING_IDENTIFIERS)
return rootProject.tasks.register(NAME, MissingIdentifiersAggregatorTask::class.java) {
inputFiles.from(resolver.artifactView())
outputFile.set(
rootProject.layout.buildDirectory.file("rake/aggregated_missing_identifiers.txt")
)
}
}
}
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy