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

cash.recipes.plugins.PluginMutator.kt Maven / Gradle / Ivy

package cash.recipes.plugins

import cash.grammar.kotlindsl.model.Plugin
import cash.grammar.kotlindsl.parse.KotlinParseException
import cash.grammar.kotlindsl.parse.Parser
import cash.grammar.kotlindsl.parse.Rewriter
import cash.grammar.kotlindsl.utils.Blocks.isPlugins
import cash.grammar.kotlindsl.utils.CollectingErrorListener
import cash.grammar.kotlindsl.utils.Context.leafRule
import cash.grammar.kotlindsl.utils.PluginExtractor
import cash.grammar.kotlindsl.utils.SmartIndent
import cash.grammar.kotlindsl.utils.Whitespace
import cash.grammar.kotlindsl.utils.Whitespace.trimGently
import cash.grammar.utils.ifNotEmpty
import cash.recipes.plugins.exception.NonNormalizedScriptException
import com.squareup.cash.grammar.KotlinParser.NamedBlockContext
import com.squareup.cash.grammar.KotlinParser.PostfixUnaryExpressionContext
import com.squareup.cash.grammar.KotlinParser.ScriptContext
import com.squareup.cash.grammar.KotlinParser.StatementContext
import com.squareup.cash.grammar.KotlinParserBaseListener
import org.antlr.v4.runtime.CommonTokenStream
import org.antlr.v4.runtime.ParserRuleContext
import org.antlr.v4.runtime.tree.TerminalNode
import java.io.InputStream
import java.nio.file.Path

/**
 * Manages the addition and removal of plugins in a Gradle build script that has been normalized by [PluginNormalizer].
 * This class ensures that plugins are applied using the standardized formats: [Plugin.Type.BLOCK_ID] or [Plugin.Type.BLOCK_SIMPLE].
 *
 * If the script is not normalized, a [NonNormalizedScriptException] is thrown.
 *
 * @param tokens the token stream that represents the entire input script, used for rewriting the script.
 * @param errorListener an error listener that collects parsing errors during the operation.
 * @param pluginsToAdd A set of plugin IDs to be added to the script. Existing plugins are ignored. Plugins are added in the [Plugin.Type.BLOCK_ID] format.
 * @param pluginsToRemove A set of plugin IDs to be removed from the script. This removal includes plugins in both [Plugin.Type.BLOCK_ID] and [Plugin.Type.BLOCK_SIMPLE] formats.
 *
 * @throws NonNormalizedScriptException if the script has not been normalized.
 * @throws IllegalArgumentException if conflicting plugins are found in both the pluginsToAdd and pluginsToRemove sets.
 * @throws KotlinParseException if the script cannot be parsed.
 */
public class PluginMutator private constructor(
  private val tokens: CommonTokenStream,
  private val errorListener: CollectingErrorListener,
  private val pluginsToAdd: Set,
  private val pluginsToRemove: Set,
) : KotlinParserBaseListener() {

  private val rewriter = Rewriter(tokens)
  private val smartIndent = SmartIndent(tokens)
  private val terminalNewlines = Whitespace.countTerminalNewlines(tokens)

  private val blockStack = ArrayDeque()
  private var pluginsBlock: NamedBlockContext? = null

  private val pluginIds = mutableSetOf()

  public fun getPluginIds(): Set = pluginIds

  @Throws(KotlinParseException::class, NonNormalizedScriptException::class)
  public fun rewritten(): String {
    errorListener.getErrorMessages().ifNotEmpty {
      throw KotlinParseException.withErrors(it)
    }

    return rewriter.text.trimGently(terminalNewlines)
  }

  override fun enterNamedBlock(ctx: NamedBlockContext) {
    blockStack.addFirst(ctx)
  }

  @Throws(NonNormalizedScriptException::class)
  override fun exitNamedBlock(ctx: NamedBlockContext) {
    if (ctx.isPlugins) {
      pluginsBlock = ctx

      val blockPluginsContext = ctx.statements().statement()
        .filterNot { it is TerminalNode }
        .map { it.leafRule() }

      val appliedPluginsWithContext = blockPluginsContext.mapNotNull { context ->
        PluginExtractor.extractFromBlock(context)?.let { plugin ->
          pluginIds += plugin.id
          plugin to context
        }
      }.toMap()

      val appliedPlugins = appliedPluginsWithContext.keys.toList()

      // Error if the script is not normalized
      appliedPlugins.forEach { errorIfScriptNotNormalized(it) }

      // Add and remove plugins
      addBlockIdPlugins(ctx, appliedPluginsWithContext)
      removeBlockIdPlugins(blockPluginsContext)
    }

    blockStack.removeFirst()
  }

  @Throws(NonNormalizedScriptException::class)
  override fun exitPostfixUnaryExpression(ctx: PostfixUnaryExpressionContext) {
    if (blockStack.isNotEmpty()) return
    // Check if the script has been normalized
    PluginExtractor.extractFromScript(ctx)?.let { errorIfScriptNotNormalized(it) }
  }

  override fun enterStatement(ctx: StatementContext) {
    smartIndent.setIndent(ctx)
  }

  override fun exitScript(ctx: ScriptContext) {
    if (pluginsBlock == null) {
      addNewPluginBlock(ctx)
    }
  }

  /**
   * Add BlockId plugins to the existing plugin block
   */
  private fun addBlockIdPlugins(
    pluginsBlock: NamedBlockContext,
    appliedPluginsWithContext: Map
  ) {
    // If there are no plugins to add or the block is not a plugins block, exit early
    if (pluginsToAdd.isEmpty() || !pluginsBlock.isPlugins) return

    // Generate the content to be added for the plugins
    val contentToAdd = pluginContentToAdd(appliedPluginsWithContext.keys.toList())

    // If no content needs to be added, exit early
    if (contentToAdd.isEmpty()) return

    val firstAppliedPlugin = appliedPluginsWithContext.values.takeIf { it.isNotEmpty() }?.first()
    val tokenToInsertBefore = if (firstAppliedPlugin != null) {
      // Insert before the first applied plugin
      Whitespace.getWhitespaceToLeft(tokens, firstAppliedPlugin.start)?.first()
    } else {
      // If plugin is empty, insert before the closing brace
      pluginsBlock.stop
    }

    rewriter.insertBefore(
      tokenToInsertBefore,
      contentToAdd.joinToString(separator = "\n") + "\n"
    )
  }

  private fun addNewPluginBlock(script: ScriptContext) {
    if (pluginsToAdd.isEmpty()) return

    val pluginsContentToAdd: List = pluginContentToAdd(emptyList())
    val pluginBlockContent = buildString {
      appendLine("plugins {")
      pluginsContentToAdd.forEach { appendLine(it) }
      append("}")
    }

    rewriter.insertBefore(script.start, "$pluginBlockContent\n\n")
  }

  private fun removeBlockIdPlugins(ctx: List) {
    val blockIdPluginsToRemove = pluginsToRemove.map { pluginId ->
      Plugin(Plugin.Type.BLOCK_ID, pluginId)
    }
    val blockSimplePluginsToRemove = pluginsToRemove.map { pluginId ->
      Plugin(Plugin.Type.BLOCK_SIMPLE, pluginId)
    }
    val blockPluginsContextToRemove = ctx.filter {
      PluginExtractor.extractFromBlock(it) in blockIdPluginsToRemove + blockSimplePluginsToRemove
    }

    blockPluginsContextToRemove.forEach { line ->
      rewriter.delete(line.start, line.stop)

      rewriter.deleteWhitespaceToLeft(line.start)
      rewriter.deleteNewlineToRight(line.stop)
    }
  }

  private fun pluginContentToAdd(appliedPlugins: List): List {
    return pluginsToAdd
      .map { pluginId -> Plugin(Plugin.Type.BLOCK_ID, pluginId) }
      .filter { pluginToAdd -> pluginToAdd !in appliedPlugins }
      .mapNotNull { plugin -> plugin.asIdString() }
      .map { "${smartIndent.getSmartIndent()}$it" }
  }

  private fun errorIfScriptNotNormalized(plugin: Plugin) {
    if (plugin.type != Plugin.Type.BLOCK_ID && plugin.type != Plugin.Type.BLOCK_SIMPLE) {
      throw NonNormalizedScriptException(
        "Unexpected plugin of type '${plugin.type}' and id ${plugin.id} detected. " +
          "Please normalize the script with PluginNormalizer.kt before attempting modifications."
      )
    }
  }

  public companion object {
    @Throws(IllegalArgumentException::class)
    public fun of(
      buildScript: Path,
      pluginsToAdd: Set,
      pluginsToRemove: Set
    ): PluginMutator {
      return of(Parser.readOnlyInputStream(buildScript), pluginsToAdd, pluginsToRemove)
    }

    @Throws(IllegalArgumentException::class)
    public fun of(
      buildScript: String,
      pluginsToAdd: Set,
      pluginsToRemove: Set
    ): PluginMutator {
      return of(buildScript.byteInputStream(), pluginsToAdd, pluginsToRemove)
    }

    @Throws(IllegalArgumentException::class)
    private fun of(
      buildScript: InputStream,
      pluginsToAdd: Set,
      pluginsToRemove: Set
    ): PluginMutator {
      val errorListener = CollectingErrorListener()

      val conflictingPlugins = pluginsToAdd.intersect(pluginsToRemove)
      if (conflictingPlugins.isNotEmpty()) {
        throw IllegalArgumentException("Conflicting plugins found: $conflictingPlugins")
      }

      return Parser(
        file = buildScript,
        errorListener = errorListener,
        listenerFactory = { _, tokens, _ ->
          PluginMutator(
            tokens = tokens,
            errorListener = errorListener,
            pluginsToAdd = pluginsToAdd,
            pluginsToRemove = pluginsToRemove
          )
        }
      ).listener()
    }
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy