All Downloads are FREE. Search and download functionalities are using the official Maven repository.

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