Skip to content

Commit

Permalink
feat: add DependenciesMutator for rewriting dependency strings.
Browse files Browse the repository at this point in the history
  • Loading branch information
autonomousapps committed Dec 5, 2024
1 parent c5f0e90 commit 194a811
Show file tree
Hide file tree
Showing 4 changed files with 305 additions and 0 deletions.
40 changes: 40 additions & 0 deletions core/src/main/kotlin/cash/grammar/kotlindsl/parse/Mutator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package cash.grammar.kotlindsl.parse

import cash.grammar.kotlindsl.utils.CollectingErrorListener
import cash.grammar.kotlindsl.utils.Whitespace
import cash.grammar.kotlindsl.utils.Whitespace.trimGently
import cash.grammar.utils.ifNotEmpty
import com.squareup.cash.grammar.KotlinParserBaseListener
import org.antlr.v4.runtime.CharStream
import org.antlr.v4.runtime.CommonTokenStream

/**
* Subclass of [KotlinParserBaseListener] with an additional contract.
*/
public abstract class Mutator(
protected val input: CharStream,
protected val tokens: CommonTokenStream,
protected val errorListener: CollectingErrorListener,
) : KotlinParserBaseListener() {

protected val rewriter: Rewriter = Rewriter(tokens)
protected val terminalNewlines: Int = Whitespace.countTerminalNewlines(tokens)
protected val indent: String = Whitespace.computeIndent(tokens, input)

/** Returns `true` if this mutator will make semantic changes to a file. */
public abstract fun isChanged(): Boolean

/**
* Returns the new content of the file. Will contain semantic differences if and only if [isChanged] is true.
*/
@Throws(KotlinParseException::class)
public fun rewritten(): String {
// TODO: check value of isChanged

errorListener.getErrorMessages().ifNotEmpty {
throw KotlinParseException.withErrors(it)
}

return rewriter.text.trimGently(terminalNewlines)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package cash.recipes.dependencies

import cash.grammar.kotlindsl.parse.Mutator
import cash.grammar.kotlindsl.parse.Parser
import cash.grammar.kotlindsl.utils.Blocks.isDependencies
import cash.grammar.kotlindsl.utils.CollectingErrorListener
import cash.grammar.kotlindsl.utils.Context.leafRule
import cash.grammar.kotlindsl.utils.DependencyExtractor
import cash.recipes.dependencies.transform.Transform
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 org.antlr.v4.runtime.CharStream
import org.antlr.v4.runtime.CommonTokenStream
import org.antlr.v4.runtime.Token
import java.io.InputStream
import java.nio.file.Path

/** Rewrites dependencies according to the provided [transforms]. */
public class DependenciesMutator private constructor(
private val transforms: List<Transform>,
input: CharStream,
tokens: CommonTokenStream,
errorListener: CollectingErrorListener,
) : Mutator(input, tokens, errorListener) {

private val dependencyExtractor = DependencyExtractor(input, tokens, indent)
private val usedTransforms = mutableListOf<Transform>()
private var changes = false

/**
* Returns a list of all used transforms. Might be empty. This can be used to, for example, update a version catalog.
*/
public fun usedTransforms(): List<Transform> = usedTransforms

public override fun isChanged(): Boolean = changes

override fun enterNamedBlock(ctx: NamedBlockContext) {
dependencyExtractor.onEnterBlock()
}

override fun exitNamedBlock(ctx: NamedBlockContext) {
if (isRealDependenciesBlock(ctx)) {
onExitDependenciesBlock(ctx)
}

dependencyExtractor.onExitBlock()
}

private fun isRealDependenciesBlock(ctx: NamedBlockContext): Boolean {
// parent is StatementContext. Parent of that should be ScriptContext
// In contrast, with tasks.shadowJar { dependencies { ... } }, the parent.parent is StatementsContext
if (ctx.parent.parent !is ScriptContext) return false

return ctx.isDependencies
}

private fun onExitDependenciesBlock(ctx: NamedBlockContext) {
val container = dependencyExtractor.collectDependencies(ctx)
container.getDependencyDeclarationsWithContext()
.mapNotNull { element ->
val gav = element.declaration.identifier.path
val identifier = buildString {
append(gav.substringBeforeLast(':'))
if (gav.startsWith("\"")) append("\"")
}

// E.g., "com.foo:bar:1.0" -> libs.fooBar OR
// "com.foo:bar" -> libs.fooBar
// We support the user passing in either the full GAV or just the identifier (without version)
val transform = transforms.find { t -> t.from.matches(gav) }
?: transforms.find { t -> t.from.matches(identifier) }
val newText = transform?.to?.render()

if (newText != null) {
// We'll return these entries later, so users can update their version catalogs as appropriate
usedTransforms += transform
element to newText
} else {
null
}
}
.forEach { (element, newText) ->
changes = true
// postfix with parens because we took a shortcut with getStop() and cut it off
rewriter.replace(getStart(element.statement), getStop(element.statement), "${newText})")
}
}

/** Returns the token marking the start of the identifier (after the opening parentheses). */
private fun getStart(ctx: StatementContext): Token {
// statement -> postfixUnaryExpression -> postfixUnarySuffix -> callSuffix -> valueArguments -> valueArgument -> expression -> ... -> postfixUnaryExpression -> ... -> lineStringLiteral
// statement -> postfixUnaryExpression -> postfixUnarySuffix -> callSuffix -> valueArguments -> valueArgument -> expression -> ... -> postfixUnaryExpression

// This makes a lot of assumptions that I'm not sure are always valid. We do know that our ctx is for a dependency
// declaration, though, which constrains the possibilities for the parse tree.
return (ctx.leafRule() as PostfixUnaryExpressionContext)
.postfixUnarySuffix()
.single()
.callSuffix()
.valueArguments()
.valueArgument()
.single()
.expression()
.start
}

/** Returns the token marking the end of the declaration proper (before any trailing lambda). */
private fun getStop(ctx: StatementContext): Token {
val default = ctx.stop

val leaf = ctx.leafRule()
if (leaf !is PostfixUnaryExpressionContext) return default

val postfix = leaf.postfixUnarySuffix().firstOrNull() ?: return default
val preLambda = postfix.callSuffix().valueArguments()

// we only want to replace everything BEFORE the trailing lambda (this includes the closing parentheses)
return preLambda.stop
}

public companion object {
/**
* Returns a [DependenciesMutator], which eagerly parses [buildScript].
*
* @throws IllegalStateException if [DependencyExtractor] sees an expression it doesn't understand.
* @throws IllegalArgumentException if [DependencyExtractor] sees an expression it doesn't understand.
*/
@Throws(IllegalStateException::class, IllegalArgumentException::class)
public fun of(
buildScript: Path,
transforms: List<Transform>,
): DependenciesMutator {
return of(Parser.readOnlyInputStream(buildScript), transforms)
}

/**
* Returns a [DependenciesMutator], which eagerly parses [buildScript].
*
* @throws IllegalStateException if [DependencyExtractor] sees an expression it doesn't understand.
* @throws IllegalArgumentException if [DependencyExtractor] sees an expression it doesn't understand.
*/
@Throws(IllegalStateException::class, IllegalArgumentException::class)
public fun of(
buildScript: String,
transforms: List<Transform>,
): DependenciesMutator {
return of(buildScript.byteInputStream(), transforms)
}

/**
* Returns a [DependenciesMutator], which eagerly parses [buildScript].
*
* @throws IllegalStateException if [DependencyExtractor] sees an expression it doesn't understand.
* @throws IllegalArgumentException if [DependencyExtractor] sees an expression it doesn't understand.
*/
@Throws(IllegalStateException::class, IllegalArgumentException::class)
private fun of(
buildScript: InputStream,
transforms: List<Transform>,
): DependenciesMutator {
val errorListener = CollectingErrorListener()

return Parser(
file = buildScript,
errorListener = errorListener,
listenerFactory = { input, tokens, _ ->
DependenciesMutator(
transforms = transforms,
input = input,
tokens = tokens,
errorListener = errorListener,
)
}
).listener()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package cash.recipes.dependencies.transform

public data class Transform(
public val from: Element,
public val to: Element,
) {

public fun from(): String = from.render()
public fun to(): String = to.render()

public sealed class Element {

public abstract fun render(): String

/** A "raw string" declaration, like `com.foo:bar:1.0`, with or without the version string. */
public data class StringLiteral(public val value: String) : Element() {
// wrap in quotation marks (because it's a string literal!)
override fun render(): String = "\"$value\""
}

/** A dependency accessor, like `libs.fooBar`. Doesn't need to represent a version catalog entry. */
public data class Accessor(public val value: String) : Element() {
override fun render(): String = value
}

public fun matches(other: String): Boolean = render() == other
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package cash.recipes.dependencies

import cash.recipes.dependencies.transform.Transform
import cash.recipes.dependencies.transform.Transform.Element
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

internal class DependenciesMutatorTest {

@Test fun `can remap dependency declarations`() {
// Given a build script and a map of changes
val expectedUsedTransforms = listOf(
// Can handle just the identifier (no version)
Transform(
from = Element.StringLiteral("com.foo:bar"),
to = Element.Accessor("libs.fooBar"),
),
// Can handle full GAV coordinates (including version)
Transform(
from = Element.StringLiteral("group:artifact:1.0"),
to = Element.Accessor("libs.artifact"),
),
)
val transforms = expectedUsedTransforms +
// Doesn't find this dependency, so nothing happens
Transform(
from = Element.StringLiteral("org.magic:turtles"),
to = Element.Accessor("libs.magicTurtles"),
)

val buildScript = """
dependencies {
api("com.foo:bar:1.0")
implementation(libs.foo)
runtimeOnly("group:artifact:1.0") { isTransitive = false }
}
""".trimIndent()

// When
val mutator = DependenciesMutator.of(buildScript, transforms)
val rewrittenContent = mutator.rewritten()

// Then
assertThat(rewrittenContent).isEqualTo(
"""
dependencies {
api(libs.fooBar)
implementation(libs.foo)
runtimeOnly(libs.artifact) { isTransitive = false }
}
""".trimIndent()
)

// ...and we can get the list of transforms
val usedTransforms = mutator.usedTransforms()
assertThat(usedTransforms).containsExactlyElementsOf(expectedUsedTransforms)
}
}

0 comments on commit 194a811

Please sign in to comment.