Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add DependenciesMutator for rewriting dependency strings. #31

Merged
merged 1 commit into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
}
Loading