-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add DependenciesMutator for rewriting dependency strings.
- Loading branch information
1 parent
c5f0e90
commit 194a811
Showing
4 changed files
with
305 additions
and
0 deletions.
There are no files selected for viewing
40 changes: 40 additions & 0 deletions
40
core/src/main/kotlin/cash/grammar/kotlindsl/parse/Mutator.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
179 changes: 179 additions & 0 deletions
179
recipes/dependencies/src/main/kotlin/cash/recipes/dependencies/DependenciesMutator.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} | ||
} | ||
} |
28 changes: 28 additions & 0 deletions
28
recipes/dependencies/src/main/kotlin/cash/recipes/dependencies/transform/Transform.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
58 changes: 58 additions & 0 deletions
58
recipes/dependencies/src/test/kotlin/cash/recipes/dependencies/DependenciesMutatorTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |