diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 935f540..270e438 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } dependencies { - api(project((":grammar"))) + api(project(":grammar")) api(libs.antlr.runtime) api(libs.kotlinStdLib) diff --git a/core/src/main/kotlin/cash/grammar/kotlindsl/model/DependencyDeclaration.kt b/core/src/main/kotlin/cash/grammar/kotlindsl/model/DependencyDeclaration.kt index 410952b..c98cf00 100644 --- a/core/src/main/kotlin/cash/grammar/kotlindsl/model/DependencyDeclaration.kt +++ b/core/src/main/kotlin/cash/grammar/kotlindsl/model/DependencyDeclaration.kt @@ -31,16 +31,56 @@ package cash.grammar.kotlindsl.model * terms, and can be one of three kinds. See [Capability]. * * Finally we have [type], which tells us whether this dependency declaration is for an internal - * project declaration or an external module declaration. See [Type] and + * project declaration, an external module declaration, or a local file dependency. See [Type] and * [ModuleDependency](https://docs.gradle.org/current/javadoc/org/gradle/api/artifacts/ModuleDependency.html). */ public data class DependencyDeclaration( val configuration: String, - val identifier: String, + val identifier: Identifier, val capability: Capability, val type: Type, + val fullText: String, + val precedingComment: String? = null, ) { + public data class Identifier @JvmOverloads constructor( + public val path: String, + public val configuration: String? = null, + public val explicitPath: Boolean = false, + ) { + + /** + * ``` + * 1. "g:a:v" + * 2. path = "g:a:v" + * 3. path = "g:a:v", configuration = "foo" + * 4. "g:a:v", configuration = "foo" + * ``` + */ + override fun toString(): String = buildString { + if (explicitPath) { + append("path = ") + } + + append(path) + + if (configuration != null) { + append(", configuration = ") + append(configuration) + } + } + + internal companion object { + fun String?.asSimpleIdentifier(): Identifier? { + return if (this != null) { + Identifier(path = this) + } else { + null + } + } + } + } + /** * @see Component Capabilities */ @@ -71,8 +111,11 @@ public data class DependencyDeclaration( * @see ModuleDependency */ public enum class Type { - PROJECT, + FILE, + FILES, + FILE_TREE, MODULE, + PROJECT, ; } } diff --git a/core/src/main/kotlin/cash/grammar/kotlindsl/model/gradle/DependencyContainer.kt b/core/src/main/kotlin/cash/grammar/kotlindsl/model/gradle/DependencyContainer.kt new file mode 100644 index 0000000..509b743 --- /dev/null +++ b/core/src/main/kotlin/cash/grammar/kotlindsl/model/gradle/DependencyContainer.kt @@ -0,0 +1,30 @@ +package cash.grammar.kotlindsl.model.gradle + +import cash.grammar.kotlindsl.model.DependencyDeclaration + +/** + * A container for all the [Statements][com.squareup.cash.grammar.KotlinParser.StatementsContext] in + * a `dependencies` block in a Gradle build script. These statements are an ordered (not sorted!) + * list of "raw" statements and modeled + * [DependencyDeclarations][cash.grammar.kotlindsl.model.DependencyDeclaration]. + * + * Rather than attempt to model everything that might possibly be found inside a build script, we + * declare defeat on anything that isn't a standard dependency declaration and simply retain it + * as-is. + */ +public class DependencyContainer( + private val statements: List, +) { + + public fun getDependencyDeclarations(): List { + return statements.filterIsInstance() + } + + public fun getNonDeclarations(): List { + return statements.filterIsInstance() + } + + internal companion object { + val EMPTY = DependencyContainer(emptyList()) + } +} diff --git a/core/src/main/kotlin/cash/grammar/kotlindsl/parse/Rewriter.kt b/core/src/main/kotlin/cash/grammar/kotlindsl/parse/Rewriter.kt index 3d51034..555270a 100644 --- a/core/src/main/kotlin/cash/grammar/kotlindsl/parse/Rewriter.kt +++ b/core/src/main/kotlin/cash/grammar/kotlindsl/parse/Rewriter.kt @@ -46,7 +46,7 @@ public class Rewriter( } /** - * Deletes all comments "to the right of" [after], returning the list of comment tokens, if they + * Deletes all comments "to the left of" [before], returning the list of comment tokens, if they * exist. */ public fun deleteCommentsToLeft( diff --git a/core/src/main/kotlin/cash/grammar/kotlindsl/utils/Comments.kt b/core/src/main/kotlin/cash/grammar/kotlindsl/utils/Comments.kt new file mode 100644 index 0000000..53b6109 --- /dev/null +++ b/core/src/main/kotlin/cash/grammar/kotlindsl/utils/Comments.kt @@ -0,0 +1,69 @@ +package cash.grammar.kotlindsl.utils + +import com.squareup.cash.grammar.KotlinLexer +import org.antlr.v4.runtime.CommonTokenStream +import org.antlr.v4.runtime.ParserRuleContext +import org.antlr.v4.runtime.Token + +public class Comments( + private val tokens: CommonTokenStream, + private val indent: String, +) { + + private var level = 0 + + public fun onEnterBlock() { + level++ + } + + public fun onExitBlock() { + level-- + } + + public fun getCommentsToLeft(before: ParserRuleContext): String? { + return getCommentsToLeft(before.start) + } + + public fun getCommentsToLeft(before: Token): String? { + var index = before.tokenIndex - 1 + if (index <= 0) return null + + var next = tokens.get(index) + + while (next != null && next.isWhitespace()) { + next = tokens.get(--index) + } + + if (next == null) return null + + val comments = ArrayDeque() + + while (next != null) { + if (next.isComment()) { + comments.addFirst(next.text) + } else if (next.isNotWhitespace()) { + break + } + + next = tokens.get(--index) + } + + if (comments.isEmpty()) return null + + return comments.joinToString(separator = "\n") { + "${indent.repeat(level)}$it" + } + } + + private fun Token.isWhitespace(): Boolean { + return text.isBlank() + } + + private fun Token.isNotWhitespace(): Boolean { + return text.isNotBlank() + } + + private fun Token.isComment(): Boolean { + return type == KotlinLexer.LineComment || type == KotlinLexer.DelimitedComment + } +} diff --git a/core/src/main/kotlin/cash/grammar/kotlindsl/utils/DependencyExtractor.kt b/core/src/main/kotlin/cash/grammar/kotlindsl/utils/DependencyExtractor.kt index 6f4a152..2a3f6b8 100644 --- a/core/src/main/kotlin/cash/grammar/kotlindsl/utils/DependencyExtractor.kt +++ b/core/src/main/kotlin/cash/grammar/kotlindsl/utils/DependencyExtractor.kt @@ -2,14 +2,52 @@ package cash.grammar.kotlindsl.utils import cash.grammar.kotlindsl.model.DependencyDeclaration import cash.grammar.kotlindsl.model.DependencyDeclaration.Capability +import cash.grammar.kotlindsl.model.DependencyDeclaration.Identifier +import cash.grammar.kotlindsl.model.DependencyDeclaration.Identifier.Companion.asSimpleIdentifier +import cash.grammar.kotlindsl.model.gradle.DependencyContainer import cash.grammar.kotlindsl.utils.Blocks.isBuildscript import cash.grammar.kotlindsl.utils.Blocks.isDependencies +import cash.grammar.kotlindsl.utils.Context.fullText import cash.grammar.kotlindsl.utils.Context.leafRule import cash.grammar.kotlindsl.utils.Context.literalText import com.squareup.cash.grammar.KotlinParser.NamedBlockContext import com.squareup.cash.grammar.KotlinParser.PostfixUnaryExpressionContext +import org.antlr.v4.runtime.CharStream +import org.antlr.v4.runtime.CommonTokenStream +import org.antlr.v4.runtime.ParserRuleContext -public class DependencyExtractor { +public class DependencyExtractor( + private val input: CharStream, + tokens: CommonTokenStream, + indent: String, +) { + + private val comments = Comments(tokens, indent) + + public fun onEnterBlock() { + comments.onEnterBlock() + } + + public fun onExitBlock() { + comments.onExitBlock() + } + + /** + * Given that we're inside a `dependencies {}` block, collect the set of dependencies. + */ + public fun collectDependencies(ctx: NamedBlockContext): DependencyContainer { + require(ctx.isDependencies) { + "Expected dependencies block. Was '${ctx.name().text}'" + } + + val statements = ctx.statements().statement() + if (statements == null || statements.isEmpty()) return DependencyContainer.EMPTY + + return statements + .mapNotNull { it.leafRule() as? PostfixUnaryExpressionContext } + .map { parseDependencyDeclaration(it) } + .asContainer() + } /** * Given that we're inside `buildscript { dependencies { ... } }`, collect all of the `classpath` @@ -22,18 +60,21 @@ public class DependencyExtractor { public fun collectClasspathDependencies( blockStack: ArrayDeque, ctx: NamedBlockContext, - ): List { + ): DependencyContainer { // Validate we're in `buildscript { dependencies { ... } }` first - if (!isInBuildscriptDependenciesBlock(blockStack)) return emptyList() + if (!isInBuildscriptDependenciesBlock(blockStack)) return DependencyContainer.EMPTY val statements = ctx.statements().statement() - if (statements == null || statements.isEmpty()) return emptyList() + if (statements == null || statements.isEmpty()) return DependencyContainer.EMPTY return statements .mapNotNull { it.leafRule() as? PostfixUnaryExpressionContext } .map { parseDependencyDeclaration(it) } + .asContainer() } + private fun List.asContainer() = DependencyContainer(this) + private fun isInBuildscriptDependenciesBlock( blockStack: ArrayDeque, ): Boolean { @@ -44,16 +85,23 @@ public class DependencyExtractor { private fun parseDependencyDeclaration( declaration: PostfixUnaryExpressionContext, - ): DependencyDeclaration { + ): Any { // e.g., `classpath`, `implementation`, etc. val configuration = declaration.primaryExpression().text - var identifier: String? + var identifier: Identifier? var capability = Capability.DEFAULT var type = DependencyDeclaration.Type.MODULE // This is everything after the configuration, including optionally a trailing lambda val rawDependency = declaration.postfixUnarySuffix().single().callSuffix() + val args = rawDependency.valueArguments().valueArgument() + + // Not a normal declaration, but a function call + if (args.size > 1) { + return parseFunctionCall(declaration) + } + /* * This leaf includes, in order: * 1. An optional "capability" (`platform` or `testFixtures`). @@ -67,10 +115,10 @@ public class DependencyExtractor { * It's a postfix... if it is anything more complex. */ - val leaf = rawDependency.valueArguments().valueArgument().single().leafRule() + val leaf = args.single().leafRule() // 3. In case we have a simple dependency of the form `classpath("group:artifact:version")` - identifier = literalText(leaf) + identifier = quoted(leaf).asSimpleIdentifier() // 1. Find capability, if present. // 2. Determine type, if present. @@ -79,50 +127,164 @@ public class DependencyExtractor { if (Capability.isCapability(maybeCapability)) { capability = Capability.of(maybeCapability) - identifier = literalText( + identifier = quoted( leaf.postfixUnarySuffix().single() .callSuffix().valueArguments().valueArgument().single() .leafRule() - ) + ).asSimpleIdentifier() } else if (maybeCapability == "project") { type = DependencyDeclaration.Type.PROJECT - identifier = literalText( - leaf.postfixUnarySuffix().single() - .callSuffix().valueArguments().valueArgument().single() - .leafRule() - ) + // TODO(tsr): use findIdentifier everywhere? + identifier = findIdentifier(leaf) + } else if (maybeCapability == "file") { + type = DependencyDeclaration.Type.FILE + + // TODO(tsr): use findIdentifier everywhere? + identifier = findIdentifier(leaf) + } else if (maybeCapability == "files") { + type = DependencyDeclaration.Type.FILES + + // TODO(tsr): use findIdentifier everywhere? + identifier = findIdentifier(leaf) + } else if (maybeCapability == "fileTree") { + type = DependencyDeclaration.Type.FILE_TREE + + // TODO(tsr): use findIdentifier everywhere? + identifier = findIdentifier(leaf) } // 2. Determine if `PROJECT` type. // 3. Also find `identifier` at this point. - val newLeaf = leaf.postfixUnarySuffix().single() - ?.callSuffix()?.valueArguments()?.valueArgument()?.single() - ?.leafRule() - - if (newLeaf is PostfixUnaryExpressionContext) { - val maybeProjectType = newLeaf.primaryExpression().text - if (maybeProjectType == "project") { - type = DependencyDeclaration.Type.PROJECT - identifier = literalText( - newLeaf.postfixUnarySuffix().single().callSuffix().valueArguments().valueArgument() - .single() - ) - } else { - // e.g., `libs.kotlinGradleBom` ("libs" is the primaryExpression) - identifier = newLeaf.text + val suffixes = leaf.postfixUnarySuffix() + val args = suffixes.singleOrNull()?.callSuffix()?.valueArguments()?.valueArgument() + + if (args?.size == 1) { + val newLeaf = args.single().leafRule() + + if (newLeaf is PostfixUnaryExpressionContext) { + val maybeProjectType = newLeaf.primaryExpression().text + if (maybeProjectType == "project") { + type = DependencyDeclaration.Type.PROJECT + identifier = quoted( + newLeaf.postfixUnarySuffix().single() + .callSuffix() + .valueArguments().valueArgument().single() + ).asSimpleIdentifier() + } else { + // e.g., `libs.kotlinGradleBom` ("libs" is the primaryExpression) + identifier = newLeaf.text.asSimpleIdentifier() + } } - } else if (newLeaf == null) { + } else if (args == null) { // We're looking at something like `libs.kotlinGradleBom` - identifier = leaf.text + identifier = leaf.text.asSimpleIdentifier() } } + val precedingComment = comments.getCommentsToLeft(declaration) + return DependencyDeclaration( configuration = configuration, identifier = identifier!!, capability = capability, type = type, + fullText = declaration.fullText(input)!!, + precedingComment = precedingComment, + ) + } + + /** + * The quotation marks are an important part of how the dependency is declared. Is it + * ``` + * 1. "g:a:v", or + * 2. libs.gav + * ``` + * ? + */ + private fun quoted(ctx: ParserRuleContext): String? { + return literalText(ctx)?.let { "\"$it\"" } + } + + /** + * E.g., + * ``` + * add("extraImplementation", "com.foo:bar:1.0") + * ``` + */ + private fun parseFunctionCall(statement: PostfixUnaryExpressionContext): Any { + // TODO(tsr): anything more interesting? + return statement.fullText(input)!! + } + + private fun findIdentifier(ctx: PostfixUnaryExpressionContext): Identifier? { + val args = ctx.postfixUnarySuffix().single() + .callSuffix() + .valueArguments() + .valueArgument() + + // 1. possibly a simple identifier, like `g:a:v`, or + // 2. `path = "foo"` + if (args.size == 1) { + val singleArg = args.single() + + quoted(singleArg.leafRule())?.let { identifier -> + return Identifier(path = identifier) + } + + // maybe `path = "foo"` + val exprName = singleArg.simpleIdentifier()?.Identifier()?.text + if (exprName == "path") { + quoted(singleArg.expression().leafRule())?.let { identifier -> + return Identifier(path = identifier, explicitPath = true) + } + } + } + + // Unclear what this would be, bail + if (args.size > 2) { + return null + } + + // possibly a map-like expression, e.g., + // 1. `path = "foo", configuration = "bar"`, or + // 2. `"foo", configuration = "bar"` + val firstArg = args[0] + val secondArg = args[1] + + val firstKey = firstArg.simpleIdentifier()?.Identifier()?.text + val secondKey = secondArg.simpleIdentifier()?.Identifier()?.text + + val firstValue = quoted(firstArg.expression()) ?: return null + val secondValue = quoted(secondArg.expression()) ?: return null + + val path: String + val configuration: String + val explicitPath = firstKey == "path" || secondKey == "path" + + if (firstKey == "path" || firstKey == null) { + require(secondKey == "configuration") { + "Expected 'configuration', was '$secondKey'." + } + + path = firstValue + configuration = secondValue + } else { + require(firstKey == "configuration") { + "Expected 'configuration', was '$firstKey'." + } + require(secondKey == "path") { + "Expected 'path', was '$secondKey'." + } + + path = secondValue + configuration = firstValue + } + + return Identifier( + path = path, + configuration = configuration, + explicitPath = explicitPath, ) } } diff --git a/core/src/test/kotlin/cash/grammar/kotlindsl/utils/CommentsTest.kt b/core/src/test/kotlin/cash/grammar/kotlindsl/utils/CommentsTest.kt new file mode 100644 index 0000000..b21a5c4 --- /dev/null +++ b/core/src/test/kotlin/cash/grammar/kotlindsl/utils/CommentsTest.kt @@ -0,0 +1,55 @@ +package cash.grammar.kotlindsl.utils + +import cash.grammar.kotlindsl.model.DependencyDeclaration +import cash.grammar.kotlindsl.model.DependencyDeclaration.Capability +import cash.grammar.kotlindsl.model.DependencyDeclaration.Identifier.Companion.asSimpleIdentifier +import cash.grammar.kotlindsl.model.DependencyDeclaration.Type +import cash.grammar.kotlindsl.parse.Parser +import cash.grammar.kotlindsl.utils.test.TestErrorListener +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class CommentsTest { + + @Test fun `can find comments`() { + val buildScript = + """ + dependencies { + // This is a + // comment + implementation(libs.lib) + /* + * Here's a multiline comment. + */ + implementation(deps.bar) + } + """.trimIndent() + + val scriptListener = Parser( + file = buildScript, + errorListener = TestErrorListener { + throw RuntimeException("Syntax error: ${it?.message}", it) + }, + listenerFactory = { input, tokens, _ -> TestListener(input, tokens) } + ).listener() + + assertThat(scriptListener.dependencyDeclarations).containsExactly( + DependencyDeclaration( + configuration = "implementation", + identifier = "libs.lib".asSimpleIdentifier()!!, + capability = Capability.DEFAULT, + type = Type.MODULE, + fullText = "implementation(libs.lib)", + precedingComment = "// This is a\n// comment", + ), + DependencyDeclaration( + configuration = "implementation", + identifier = "deps.bar".asSimpleIdentifier()!!, + capability = Capability.DEFAULT, + type = Type.MODULE, + fullText = "implementation(deps.bar)", + precedingComment = "/*\n * Here's a multiline comment.\n */", + ), + ) + } +} diff --git a/core/src/test/kotlin/cash/grammar/kotlindsl/utils/TestListener.kt b/core/src/test/kotlin/cash/grammar/kotlindsl/utils/TestListener.kt new file mode 100644 index 0000000..78d77a5 --- /dev/null +++ b/core/src/test/kotlin/cash/grammar/kotlindsl/utils/TestListener.kt @@ -0,0 +1,40 @@ +package cash.grammar.kotlindsl.utils + +import cash.grammar.kotlindsl.model.DependencyDeclaration +import cash.grammar.kotlindsl.utils.Blocks.isBuildscript +import cash.grammar.kotlindsl.utils.Blocks.isDependencies +import cash.grammar.kotlindsl.utils.Blocks.isSubprojects +import com.squareup.cash.grammar.KotlinParser +import com.squareup.cash.grammar.KotlinParserBaseListener +import org.antlr.v4.runtime.CharStream +import org.antlr.v4.runtime.CommonTokenStream +import org.antlr.v4.runtime.Token + +internal class TestListener( + private val input: CharStream, + private val tokens: CommonTokenStream, + private val defaultIndent: String = " ", +) : KotlinParserBaseListener() { + + var newlines: List? = null + var whitespace: List? = null + val trailingBuildscriptNewlines = Whitespace.countTerminalNewlines(tokens) + val trailingKotlinFileNewlines = Whitespace.countTerminalNewlines(tokens) + val indent = Whitespace.computeIndent(tokens, input, defaultIndent) + val dependencyExtractor = DependencyExtractor(input, tokens, indent) + + val dependencyDeclarations = mutableListOf() + + override fun exitNamedBlock(ctx: KotlinParser.NamedBlockContext) { + if (ctx.isSubprojects) { + newlines = Whitespace.getBlankSpaceToLeft(tokens, ctx) + } + if (ctx.isBuildscript) { + whitespace = Whitespace.getWhitespaceToLeft(tokens, ctx) + } + if (ctx.isDependencies) { + dependencyDeclarations += dependencyExtractor.collectDependencies(ctx) + .getDependencyDeclarations() + } + } +} diff --git a/core/src/test/kotlin/cash/grammar/kotlindsl/utils/WhitespaceTest.kt b/core/src/test/kotlin/cash/grammar/kotlindsl/utils/WhitespaceTest.kt index a0799c3..a6d506f 100644 --- a/core/src/test/kotlin/cash/grammar/kotlindsl/utils/WhitespaceTest.kt +++ b/core/src/test/kotlin/cash/grammar/kotlindsl/utils/WhitespaceTest.kt @@ -1,14 +1,7 @@ package cash.grammar.kotlindsl.utils import cash.grammar.kotlindsl.parse.Parser -import cash.grammar.kotlindsl.utils.Blocks.isBuildscript -import cash.grammar.kotlindsl.utils.Blocks.isSubprojects import cash.grammar.kotlindsl.utils.test.TestErrorListener -import com.squareup.cash.grammar.KotlinParser.NamedBlockContext -import com.squareup.cash.grammar.KotlinParserBaseListener -import org.antlr.v4.runtime.CharStream -import org.antlr.v4.runtime.CommonTokenStream -import org.antlr.v4.runtime.Token import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.tuple import org.junit.jupiter.api.Test @@ -201,28 +194,6 @@ internal class WhitespaceTest { ) } - private class TestListener( - private val input: CharStream, - private val tokens: CommonTokenStream, - private val defaultIndent: String = " ", - ) : KotlinParserBaseListener() { - - var newlines: List? = null - var whitespace: List? = null - val trailingBuildscriptNewlines = Whitespace.countTerminalNewlines(tokens) - val trailingKotlinFileNewlines = Whitespace.countTerminalNewlines(tokens) - val indent = Whitespace.computeIndent(tokens, input, defaultIndent) - - override fun exitNamedBlock(ctx: NamedBlockContext) { - if (ctx.isSubprojects) { - newlines = Whitespace.getBlankSpaceToLeft(tokens, ctx) - } - if (ctx.isBuildscript) { - whitespace = Whitespace.getWhitespaceToLeft(tokens, ctx) - } - } - } - internal class TestCase( val displayName: String, val sourceText: String,