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,