From 1a0e1c039999b40beaf856be62071d201297a6a8 Mon Sep 17 00:00:00 2001 From: Tony Robalik Date: Mon, 5 Aug 2024 13:00:16 -0700 Subject: [PATCH] feat: improve support for parsing Kotlin DSL, using KotlinEditor. --- .../kotlin/com/squareup/sort/SortCommand.kt | 24 +- gradle/libs.versions.toml | 3 + settings.gradle | 2 + .../com/squareup/sort/FunctionalSpec.groovy | 2 +- sort/build.gradle | 1 + .../kotlin/com/squareup/sort/Configuration.kt | 88 +++ .../squareup/sort/ConfigurationComparator.kt | 99 --- .../com/squareup/sort/DependencyComparator.kt | 65 +- .../squareup/sort/DependencyDeclaration.kt | 100 ++- .../main/kotlin/com/squareup/sort/Sorter.kt | 259 +------ .../main/kotlin/com/squareup/sort/Texts.kt | 6 + .../groovy/GroovyConfigurationComparator.kt | 23 + .../groovy/GroovyDependencyDeclaration.kt | 93 +++ .../com/squareup/sort/groovy/GroovySorter.kt | 264 +++++++ .../kotlin/KotlinConfigurationComparator.kt | 12 + .../kotlin/KotlinDependencyDeclaration.kt | 62 ++ .../com/squareup/sort/kotlin/KotlinSorter.kt | 232 +++++++ .../kotlin/com/squareup/utils/collections.kt | 7 + .../sort/DependencyComparatorTest.groovy | 71 ++ ... GroovyConfigurationComparatorSpec.groovy} | 7 +- ...terSpec.groovy => GroovySorterSpec.groovy} | 31 +- .../com/squareup/sort/KotlinSorterSpec.groovy | 645 ++++++++++++++++++ 22 files changed, 1620 insertions(+), 476 deletions(-) create mode 100644 sort/src/main/kotlin/com/squareup/sort/Configuration.kt delete mode 100644 sort/src/main/kotlin/com/squareup/sort/ConfigurationComparator.kt create mode 100644 sort/src/main/kotlin/com/squareup/sort/Texts.kt create mode 100644 sort/src/main/kotlin/com/squareup/sort/groovy/GroovyConfigurationComparator.kt create mode 100644 sort/src/main/kotlin/com/squareup/sort/groovy/GroovyDependencyDeclaration.kt create mode 100644 sort/src/main/kotlin/com/squareup/sort/groovy/GroovySorter.kt create mode 100644 sort/src/main/kotlin/com/squareup/sort/kotlin/KotlinConfigurationComparator.kt create mode 100644 sort/src/main/kotlin/com/squareup/sort/kotlin/KotlinDependencyDeclaration.kt create mode 100644 sort/src/main/kotlin/com/squareup/sort/kotlin/KotlinSorter.kt create mode 100644 sort/src/main/kotlin/com/squareup/utils/collections.kt create mode 100644 sort/src/test/groovy/com/squareup/sort/DependencyComparatorTest.groovy rename sort/src/test/groovy/com/squareup/sort/{ConfigurationComparatorSpec.groovy => GroovyConfigurationComparatorSpec.groovy} (81%) rename sort/src/test/groovy/com/squareup/sort/{SorterSpec.groovy => GroovySorterSpec.groovy} (95%) create mode 100644 sort/src/test/groovy/com/squareup/sort/KotlinSorterSpec.groovy diff --git a/app/src/main/kotlin/com/squareup/sort/SortCommand.kt b/app/src/main/kotlin/com/squareup/sort/SortCommand.kt index 0d345a7..05b95eb 100644 --- a/app/src/main/kotlin/com/squareup/sort/SortCommand.kt +++ b/app/src/main/kotlin/com/squareup/sort/SortCommand.kt @@ -17,6 +17,7 @@ import com.squareup.parse.BuildScriptParseException import com.squareup.sort.Status.NOT_SORTED import com.squareup.sort.Status.PARSE_ERROR import com.squareup.sort.Status.SUCCESS +import com.squareup.sort.groovy.GroovySorter import org.slf4j.Logger import org.slf4j.LoggerFactory import java.nio.file.FileSystem @@ -32,14 +33,23 @@ import kotlin.io.path.writeText */ class SortCommand( private val fileSystem: FileSystem = FileSystems.getDefault(), - private val buildFileFinder: BuildDotGradleFinder.Factory = object : BuildDotGradleFinder.Factory {} + private val buildFileFinder: BuildDotGradleFinder.Factory = object : + BuildDotGradleFinder.Factory {} ) : CliktCommand( name = "sort", help = "Sorts dependencies", ) { init { - context { helpFormatter = { context -> MordantHelpFormatter(context = context, showDefaultValues = true, showRequiredTag = true) } } + context { + helpFormatter = { context -> + MordantHelpFormatter( + context = context, + showDefaultValues = true, + showRequiredTag = true + ) + } + } } private val verbose by option("-v", "--verbose", help = "Verbose mode. All logs are printed.") @@ -50,7 +60,11 @@ class SortCommand( help = "Flag to control whether file tree walking looks in build and hidden directories. True by default.", ).flag("--no-skip-hidden-and-build-dirs", default = true) - val mode by option("-m", "--mode", help = "Mode: [sort, check]. Defaults to 'sort'. Check will report if a file is already sorted") + val mode by option( + "-m", + "--mode", + help = "Mode: [sort, check]. Defaults to 'sort'. Check will report if a file is already sorted" + ) .enum().default(Mode.SORT) val paths: List by argument(help = "Path(s) to sort. Required.") @@ -102,7 +116,7 @@ class SortCommand( var alreadySortedCount = 0 filesToSort.parallelStream().forEach { file -> try { - val newContent = Sorter.sorterFor(file).rewritten() + val newContent = Sorter.of(file).rewritten() file.writeText(newContent, Charsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING) logger.trace("Successfully sorted: ${file.pathString} ") successCount++ @@ -144,7 +158,7 @@ class SortCommand( filesToSort.parallelStream().forEach { file -> try { - val sorter = Sorter.sorterFor(file) + val sorter = Sorter.of(file) if (!sorter.isSorted() && !sorter.hasParseErrors()) { logger.trace("Not ordered: ${file.pathString} ") notSorted.add(file) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index db48370..46cb55f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ dagp = "1.30.0" java = "11" junit5 = "5.7.2" kotlin = "1.9.24" +kotlinEditor = "0.3" mavenPublish = "0.28.0" moshi = "1.14.0" retrofit = "2.9.0" @@ -14,6 +15,8 @@ antlr-core = { module = "org.antlr:antlr4", version.ref = "antlr" } antlr-runtime = { module = "org.antlr:antlr4-runtime", version.ref = "antlr" } clikt = "com.github.ajalt.clikt:clikt:4.2.2" grammar = "com.autonomousapps:gradle-script-grammar:0.3" +kotlinEditor-core = { module = "app.cash.kotlin-editor:core", version.ref = "kotlinEditor" } +kotlinEditor-grammar = { module = "app.cash.kotlin-editor:grammar", version.ref = "kotlinEditor" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin"} okhttp3 = "com.squareup.okhttp3:okhttp:4.9.0" moshi-core = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } diff --git a/settings.gradle b/settings.gradle index 543dd3d..d7dc089 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,6 +7,8 @@ plugins { id 'me.champeau.includegit' version '0.1.6' } +includeBuild("../cash/kotlin-editor") + dependencyResolutionManagement { repositories { mavenCentral() diff --git a/sort-dependencies-gradle-plugin/src/test/groovy/com/squareup/sort/FunctionalSpec.groovy b/sort-dependencies-gradle-plugin/src/test/groovy/com/squareup/sort/FunctionalSpec.groovy index c72f432..1ac8bfb 100644 --- a/sort-dependencies-gradle-plugin/src/test/groovy/com/squareup/sort/FunctionalSpec.groovy +++ b/sort-dependencies-gradle-plugin/src/test/groovy/com/squareup/sort/FunctionalSpec.groovy @@ -62,7 +62,7 @@ final class FunctionalSpec extends Specification { Files.writeString(buildScript, BUILD_SCRIPT) when: 'We sort dependencies' - build(dir, 'sortDependencies') + build(dir, 'sortDependencies', '--verbose') then: 'Dependencies are sorted' buildScript.text == """\ diff --git a/sort/build.gradle b/sort/build.gradle index 693494e..b2bac38 100644 --- a/sort/build.gradle +++ b/sort/build.gradle @@ -8,6 +8,7 @@ kotlin { dependencies { api libs.grammar + api libs.kotlinEditor.core testImplementation libs.spock } diff --git a/sort/src/main/kotlin/com/squareup/sort/Configuration.kt b/sort/src/main/kotlin/com/squareup/sort/Configuration.kt new file mode 100644 index 0000000..a764c95 --- /dev/null +++ b/sort/src/main/kotlin/com/squareup/sort/Configuration.kt @@ -0,0 +1,88 @@ +package com.squareup.sort + +internal class Configuration( + private val configuration: String, + val level: Int, + /** + * Android support. A "variant" configuration looks like "debugApi", "releaseImplementation", etc. + * The variant will be "debug", "release", etc. + */ + var variant: String? = null +) { + + companion object { + val values = listOf( + "api" to { Configuration("api", 0) }, + "implementation" to { Configuration("implementation", 1) }, + "compileOnlyApi" to { Configuration("compileOnlyApi", 2) }, + "compileOnly" to { Configuration("compileOnly", 3) }, + "runtimeOnly" to { Configuration("runtimeOnly", 4) }, + "annotationProcessor" to { Configuration("annotationProcessor", 5) }, + "kapt" to { Configuration("kapt", 6) }, + "testImplementation" to { Configuration("testImplementation", 7) }, + "testCompileOnly" to { Configuration("testCompileOnly", 8) }, + "testRuntimeOnly" to { Configuration("testRuntimeOnly", 9) }, + "androidTestImplementation" to { Configuration("androidTestImplementation", 10) }, + ) + + fun of(configuration: String): Configuration? { + fun findConfiguration( + predicate: (Pair Configuration>) -> Boolean + ): Configuration? { + return values.find(predicate)?.second?.invoke() + } + + // Try to find an exact match + var matchingConfiguration = findConfiguration { it.first == configuration } + + // If that failed, look for a variant + if (matchingConfiguration == null) { + matchingConfiguration = findConfiguration { configuration.endsWith(it.first, true) } + if (matchingConfiguration != null) { + matchingConfiguration.variant = configuration.substring( + 0, + configuration.length - matchingConfiguration.configuration.length + ) + } + } + + // Look for a variant again + if (matchingConfiguration == null) { + matchingConfiguration = findConfiguration { configuration.startsWith(it.first, true) } + if (matchingConfiguration != null) { + matchingConfiguration.variant = configuration.substring( + configuration.length - matchingConfiguration.configuration.length, + configuration.length + ) + } + } + + return matchingConfiguration + } + + fun stringCompare( + left: String, + right: String + ): Int { + val leftC = of(left) + val rightC = of(right) + + // Null means they don't map to a known configuration. So, compare by String natural order. + if (leftC == null && rightC == null) return left.compareTo(right) + // Unknown configuration is "higher than" known + if (rightC == null) return 1 + if (leftC == null) return -1 + + val c = leftC.level.compareTo(rightC.level) + + // If each maps to a known configuration, and they're different, we can return that value + if (c != 0) return c + // If each maps to the same configuration, we now differentiate based on whether variants are + // involved. Non-variants are "higher than" variants. + if (leftC.variant != null && rightC.variant != null) { + return rightC.variant!!.compareTo(leftC.variant!!) + } + return if (rightC.variant != null) return -1 else 1 + } + } +} diff --git a/sort/src/main/kotlin/com/squareup/sort/ConfigurationComparator.kt b/sort/src/main/kotlin/com/squareup/sort/ConfigurationComparator.kt deleted file mode 100644 index 1a1def8..0000000 --- a/sort/src/main/kotlin/com/squareup/sort/ConfigurationComparator.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.squareup.sort - -internal object ConfigurationComparator : - Comparator>> { - - private class Configuration( - private val configuration: String, - val level: Int, - /** - * Android support. A "variant" configuration looks like "debugApi", "releaseImplementation", etc. - * The variant will be "debug", "release", etc. - */ - var variant: String? = null - ) { - - companion object { - val values = listOf( - "api" to { Configuration("api", 0) }, - "implementation" to { Configuration("implementation", 1) }, - "compileOnlyApi" to { Configuration("compileOnlyApi", 2) }, - "compileOnly" to { Configuration("compileOnly", 3) }, - "runtimeOnly" to { Configuration("runtimeOnly", 4) }, - "annotationProcessor" to { Configuration("annotationProcessor", 5) }, - "kapt" to { Configuration("kapt", 6) }, - "testImplementation" to { Configuration("testImplementation", 7) }, - "testCompileOnly" to { Configuration("testCompileOnly", 8) }, - "testRuntimeOnly" to { Configuration("testRuntimeOnly", 9) }, - "androidTestImplementation" to { Configuration("androidTestImplementation", 10) }, - ) - - fun of(configuration: String): Configuration? { - fun findConfiguration( - predicate: (Pair Configuration>) -> Boolean - ): Configuration? { - return values.find(predicate)?.second?.invoke() - } - - // Try to find an exact match - var matchingConfiguration = findConfiguration { it.first == configuration } - - // If that failed, look for a variant - if (matchingConfiguration == null) { - matchingConfiguration = findConfiguration { configuration.endsWith(it.first, true) } - if (matchingConfiguration != null) { - matchingConfiguration.variant = configuration.substring( - 0, - configuration.length - matchingConfiguration.configuration.length - ) - } - } - - // Look for a variant again - if (matchingConfiguration == null) { - matchingConfiguration = findConfiguration { configuration.startsWith(it.first, true) } - if (matchingConfiguration != null) { - matchingConfiguration.variant = configuration.substring( - configuration.length - matchingConfiguration.configuration.length, - configuration.length - ) - } - } - - return matchingConfiguration - } - } - } - - override fun compare( - left: MutableMap.MutableEntry>, - right: MutableMap.MutableEntry> - ): Int = stringCompare(left.key, right.key) - - /** Visible for testing. */ - @JvmStatic - fun stringCompare( - left: String, - right: String - ): Int { - val leftC = Configuration.of(left) - val rightC = Configuration.of(right) - - // Null means they don't map to a known configuration. So, compare by String natural order. - if (leftC == null && rightC == null) return left.compareTo(right) - // Unknown configuration is "higher than" known - if (rightC == null) return 1 - if (leftC == null) return -1 - - val c = leftC.level.compareTo(rightC.level) - - // If each maps to a known configuration, and they're different, we can return that value - if (c != 0) return c - // If each maps to the same configuration, we now differentiate based on whether variants are - // involved. Non-variants are "higher than" variants. - if (leftC.variant != null && rightC.variant != null) { - return rightC.variant!!.compareTo(leftC.variant!!) - } - return if (rightC.variant != null) return -1 else 1 - } -} diff --git a/sort/src/main/kotlin/com/squareup/sort/DependencyComparator.kt b/sort/src/main/kotlin/com/squareup/sort/DependencyComparator.kt index bdcae78..2d83748 100644 --- a/sort/src/main/kotlin/com/squareup/sort/DependencyComparator.kt +++ b/sort/src/main/kotlin/com/squareup/sort/DependencyComparator.kt @@ -1,18 +1,22 @@ package com.squareup.sort -import com.autonomousapps.grammar.gradle.GradleScript.QuoteContext - internal class DependencyComparator : Comparator { override fun compare( left: DependencyDeclaration, - right: DependencyDeclaration + right: DependencyDeclaration, ): Int { - if (left.isPlatformDeclaration() && right.isPlatformDeclaration()) return compareDeclaration(left, right) + if (left.isPlatformDeclaration() && right.isPlatformDeclaration()) return compareDeclaration( + left, + right + ) if (left.isPlatformDeclaration()) return -1 if (right.isPlatformDeclaration()) return 1 - if (left.isTestFixturesDeclaration() && right.isTestFixturesDeclaration()) return compareDeclaration(left, right) + if (left.isTestFixturesDeclaration() && right.isTestFixturesDeclaration()) return compareDeclaration( + left, + right + ) if (left.isTestFixturesDeclaration()) return -1 if (right.isTestFixturesDeclaration()) return 1 @@ -21,9 +25,12 @@ internal class DependencyComparator : Comparator { private fun compareDeclaration( left: DependencyDeclaration, - right: DependencyDeclaration + right: DependencyDeclaration, ): Int { - if (left.isProjectDependency() && right.isProjectDependency()) return compareDependencies(left, right) + if (left.isProjectDependency() && right.isProjectDependency()) return compareDependencies( + left, + right + ) if (left.isProjectDependency()) return -1 if (right.isProjectDependency()) return 1 @@ -36,7 +43,7 @@ internal class DependencyComparator : Comparator { private fun compareDependencies( left: DependencyDeclaration, - right: DependencyDeclaration + right: DependencyDeclaration, ): Int { val leftText = left.comparisonText() val rightText = right.comparisonText() @@ -54,46 +61,4 @@ internal class DependencyComparator : Comparator { // No quotes on either -> return natural sort order return c } - - /** - * Returns `true` if the dependency component is surrounded by quotation marks. Consider: - * 1. implementation deps.foo // no quotes - * 2. implementation 'com.foo:bar:1.0' // quotes - * - * We want 2 to be sorted above 1. This is arbitrary. - */ - private fun DependencyDeclaration.hasQuotes(): Boolean { - val i = declaration.children.indexOf(dependency) - return declaration.getChild(i - 1) is QuoteContext && declaration.getChild(i + 1) is QuoteContext - } - - private fun DependencyDeclaration.comparisonText(): String { - val text = when { - isProjectDependency() -> with(dependency.projectDependency()) { - // If project(path: 'foo') syntax is used, take the path value. - // Else, if project('foo') syntax is used, take the ID. - projectMapEntry().firstOrNull { it.key.text == "path:" }?.value?.text - ?: ID().text - } - - isFileDependency() -> dependency.fileDependency().ID().text - else -> dependency.externalDependency().ID().text - } - - /* - * Colons should sort "higher" than hyphens. The comma's ASCII value - * is 44, the hyphen's is 45, and the colon's is 58. We replace - * colons with commas and then rely on natural sort order from - * there. - * - * For example, consider ':foo-bar' vs. ':foo:bar'. Before this - * transformation, ':foo-bar' will appear before ':foo:bar'. But - * after it, we compare ',foo,bar' to ',foo-bar', which gives the - * desired sort ordering. - * - * Similarly, single and double quotes have different ASCII values, - * but we don't care about that for our purposes. - */ - return text.replace(':', ',').replace("'", "\"") - } } diff --git a/sort/src/main/kotlin/com/squareup/sort/DependencyDeclaration.kt b/sort/src/main/kotlin/com/squareup/sort/DependencyDeclaration.kt index 23e2623..8ae41e5 100644 --- a/sort/src/main/kotlin/com/squareup/sort/DependencyDeclaration.kt +++ b/sort/src/main/kotlin/com/squareup/sort/DependencyDeclaration.kt @@ -1,62 +1,46 @@ package com.squareup.sort -import com.autonomousapps.grammar.gradle.GradleScript.DependencyContext -import com.autonomousapps.grammar.gradle.GradleScript.NormalDeclarationContext -import com.autonomousapps.grammar.gradle.GradleScript.PlatformDeclarationContext -import com.autonomousapps.grammar.gradle.GradleScript.TestFixturesDeclarationContext -import org.antlr.v4.runtime.ParserRuleContext - -/** - * To sort a dependency declaration, we care what kind of declaration it is ("normal", "platform", "test fixtures"), as - * well as what kind of dependency it is (GAV, project, file/files, catalog-like). - */ -internal class DependencyDeclaration( - val declaration: ParserRuleContext, - val dependency: DependencyContext, - private val declarationKind: DeclarationKind, - private val dependencyKind: DependencyKind, -) { - - enum class DeclarationKind { - NORMAL, PLATFORM, TEST_FIXTURES - } - - enum class DependencyKind { - NORMAL, PROJECT, FILE; - - companion object { - fun of(dependency: DependencyContext, filePath: String): DependencyKind { - return if (dependency.externalDependency() != null) NORMAL - else if (dependency.projectDependency() != null) PROJECT - else if (dependency.fileDependency() != null) FILE - else error("Unknown dependency kind. Was <${dependency.text}> for $filePath") - } - } - } - - fun isPlatformDeclaration() = declarationKind == DeclarationKind.PLATFORM - fun isTestFixturesDeclaration() = declarationKind == DeclarationKind.TEST_FIXTURES - - fun isProjectDependency() = dependencyKind == DependencyKind.PROJECT - fun isFileDependency() = dependencyKind == DependencyKind.FILE - - companion object { - fun of(declaration: ParserRuleContext, filePath: String): DependencyDeclaration { - val (dependency, declarationKind) = when (declaration) { - is NormalDeclarationContext -> declaration.dependency() to DeclarationKind.NORMAL - is PlatformDeclarationContext -> declaration.dependency() to DeclarationKind.PLATFORM - is TestFixturesDeclarationContext -> declaration.dependency() to DeclarationKind.TEST_FIXTURES - else -> error("Unknown declaration kind. Was ${declaration.text}.") - } - - val dependencyKind = when (declaration) { - is NormalDeclarationContext -> DependencyKind.of(declaration.dependency(), filePath) - is PlatformDeclarationContext -> DependencyKind.of(declaration.dependency(), filePath) - is TestFixturesDeclarationContext -> DependencyKind.of(declaration.dependency(), filePath) - else -> error("Unknown declaration kind. Was ${declaration.text}.") - } - - return DependencyDeclaration(declaration, dependency, declarationKind, dependencyKind) - } +internal interface DependencyDeclaration { + + fun fullText(): String + fun precedingComment(): String? + + fun isPlatformDeclaration(): Boolean + fun isTestFixturesDeclaration(): Boolean + + fun isFileDependency(): Boolean + fun isProjectDependency(): Boolean + + /** + * Returns `true` if the dependency component is surrounded by quotation marks. Consider: + * 1. implementation deps.foo // no quotes + * 2. implementation 'com.foo:bar:1.0' // quotes + * + * We want 2 to be sorted above 1. This is arbitrary. + */ + fun hasQuotes(): Boolean + + /** + * TODO. + */ + fun comparisonText(): String + + /** + * Colons should sort "higher" than hyphens. The comma's ASCII value + * is 44, the hyphen's is 45, and the colon's is 58. We replace + * colons with commas and then rely on natural sort order from + * there. + * + * For example, consider ':foo-bar' vs. ':foo:bar'. Before this + * transformation, ':foo-bar' will appear before ':foo:bar'. But + * after it, we compare ',foo,bar' to ',foo-bar', which gives the + * desired sort ordering. + * + * Similarly, single and double quotes have different ASCII values, + * but we don't care about that for our purposes. + */ + fun String.replaceHyphens(): String { + // TODO maybe I should make this an ABC and this function protected. + return replace(':', ',').replace("'", "\"") } } diff --git a/sort/src/main/kotlin/com/squareup/sort/Sorter.kt b/sort/src/main/kotlin/com/squareup/sort/Sorter.kt index f1222cd..e6c1085 100644 --- a/sort/src/main/kotlin/com/squareup/sort/Sorter.kt +++ b/sort/src/main/kotlin/com/squareup/sort/Sorter.kt @@ -1,256 +1,25 @@ package com.squareup.sort -import com.autonomousapps.grammar.gradle.GradleScript -import com.autonomousapps.grammar.gradle.GradleScript.DependenciesContext -import com.autonomousapps.grammar.gradle.GradleScript.NormalDeclarationContext -import com.autonomousapps.grammar.gradle.GradleScript.PlatformDeclarationContext -import com.autonomousapps.grammar.gradle.GradleScript.TestFixturesDeclarationContext -import com.autonomousapps.grammar.gradle.GradleScriptBaseListener -import com.autonomousapps.grammar.gradle.GradleScriptLexer -import com.squareup.parse.AbstractErrorListener -import com.squareup.parse.AlreadyOrderedException import com.squareup.parse.BuildScriptParseException -import org.antlr.v4.runtime.CharStreams -import org.antlr.v4.runtime.CommonTokenStream -import org.antlr.v4.runtime.ParserRuleContext -import org.antlr.v4.runtime.RecognitionException -import org.antlr.v4.runtime.Recognizer -import org.antlr.v4.runtime.TokenStreamRewriter -import org.antlr.v4.runtime.tree.ParseTreeWalker -import java.nio.file.Files +import com.squareup.sort.groovy.GroovySorter +import com.squareup.sort.kotlin.KotlinSorter import java.nio.file.Path -import java.nio.file.StandardOpenOption -import kotlin.io.path.absolutePathString +import kotlin.io.path.pathString -public class Sorter private constructor( - private val tokens: CommonTokenStream, - private val rewriter: TokenStreamRewriter, - private val errorListener: RewriterErrorListener, - private val filePath: String, -) : GradleScriptBaseListener() { +public interface Sorter { - // We use a default of two spaces, but update it at most once later on. - private var smartIndentSet = false - private var indent = " " - - // TODO we can probably sort this block too. - private var isInBuildScriptBlock = false - - private val dependencyComparator = DependencyComparator() - private val dependenciesByConfiguration = mutableMapOf>() - private val ordering = Ordering(tokens) - - private fun collectDependency( - configuration: String, - dependencyDeclaration: DependencyDeclaration - ) { - setIndent(dependencyDeclaration.declaration) - ordering.collectDependency(dependencyDeclaration) - dependenciesByConfiguration.merge(configuration, mutableListOf(dependencyDeclaration)) { acc, inc -> - acc.apply { addAll(inc) } - } - } - - private fun setIndent(ctx: ParserRuleContext) { - if (smartIndentSet) return - - tokens.getHiddenTokensToLeft(ctx.start.tokenIndex, GradleScriptLexer.WHITESPACE) - ?.firstOrNull()?.text?.replace("\n", "")?.let { - smartIndentSet = true - indent = it - } - } - - /** - * Returns the sorted build script. - * - * Throws [BuildScriptParseException] if the script has some idiosyncrasy that impairs parsing. - * - * Throws [AlreadyOrderedException] if the script is already sorted correctly. - */ - @Throws(BuildScriptParseException::class, AlreadyOrderedException::class) - public fun rewritten(): String { - errorListener.errorMessages.ifNotEmpty { - throw BuildScriptParseException.withErrors(errorListener.errorMessages) - } - if (isSorted()) throw AlreadyOrderedException() - - return rewriter.text - } - - /** Returns `true` if this file's dependencies are already sorted correctly, or if there are no dependencies. */ - public fun isSorted(): Boolean = ordering.isAlreadyOrdered() - - /** Returns `true` if there were errors parsing the build script. */ - public fun hasParseErrors(): Boolean = errorListener.errorMessages.isNotEmpty() - - /** Returns the parse exception if there is one, otherwise null. */ - public fun getParseError(): BuildScriptParseException? { - return if (errorListener.errorMessages.isNotEmpty()) { - BuildScriptParseException.withErrors(errorListener.errorMessages) - } else { - null - } - } - - override fun enterBuildscript(ctx: GradleScript.BuildscriptContext?) { - isInBuildScriptBlock = true - } - - override fun exitBuildscript(ctx: GradleScript.BuildscriptContext?) { - isInBuildScriptBlock = false - } - - override fun enterNormalDeclaration(ctx: NormalDeclarationContext) { - if (isInBuildScriptBlock) return - collectDependency(tokens.getText(ctx.configuration()), DependencyDeclaration.of(ctx, filePath)) - } - - override fun enterPlatformDeclaration(ctx: PlatformDeclarationContext) { - if (isInBuildScriptBlock) return - collectDependency(tokens.getText(ctx.configuration()), DependencyDeclaration.of(ctx, filePath)) - } - - override fun enterTestFixturesDeclaration(ctx: TestFixturesDeclarationContext) { - if (isInBuildScriptBlock) return - collectDependency(tokens.getText(ctx.configuration()), DependencyDeclaration.of(ctx, filePath)) - } - - override fun exitDependencies(ctx: DependenciesContext) { - if (isInBuildScriptBlock) return - rewriter.replace(ctx.start, ctx.stop, dependenciesBlock()) - - // Whenever we exit a dependencies block, clear this map. Each block will be treated separately. - dependenciesByConfiguration.clear() - } - - private fun dependenciesBlock() = buildString { - val newOrder = mutableListOf() - - appendLine("dependencies {") - dependenciesByConfiguration.entries.sortedWith(ConfigurationComparator) - .forEachIndexed { i, entry -> - if (i != 0) appendLine() - entry.value.sortedWith(dependencyComparator) - .map { dependency -> - dependency to Texts( - comment = precedingComment(dependency), - declarationText = tokens.getText(dependency.declaration), - ) - } - .distinctBy { (_, texts) -> texts } - .forEach { (declaration, texts) -> - newOrder += declaration - - // Write preceding comments if there are any - if (texts.comment != null) appendLine(texts.comment) - - append(indent) - appendLine(texts.declarationText) - } - } - append("}") - - // If the new ordering matches the old ordering, we shouldn't rewrite the file. This accounts for multiple - // dependencies blocks - ordering.checkOrdering(newOrder) - } - - private fun precedingComment(dependency: DependencyDeclaration) = tokens.getHiddenTokensToLeft( - dependency.declaration.start.tokenIndex, - GradleScriptLexer.COMMENTS - )?.joinToString(separator = "") { - "$indent${it.text}" - }?.trimEnd() + public fun rewritten(): String + public fun isSorted(): Boolean + public fun hasParseErrors(): Boolean + public fun getParseError(): BuildScriptParseException? public companion object { - @JvmStatic - public fun sorterFor(file: Path): Sorter { - val input = Files.newInputStream(file, StandardOpenOption.READ).use { - CharStreams.fromStream(it) - } - val lexer = GradleScriptLexer(input) - val tokens = CommonTokenStream(lexer) - val parser = GradleScript(tokens) - - // Remove default error listeners to prevent insane console output - lexer.removeErrorListeners() - parser.removeErrorListeners() - - val errorListener = RewriterErrorListener() - parser.addErrorListener(errorListener) - lexer.addErrorListener(errorListener) - - val walker = ParseTreeWalker() - val listener = Sorter( - tokens = tokens, - rewriter = TokenStreamRewriter(tokens), - errorListener = errorListener, - filePath = file.absolutePathString() - ) - val tree = parser.script() - walker.walk(listener, tree) - - return listener - } - } -} - -internal class RewriterErrorListener : AbstractErrorListener() { - val errorMessages = mutableListOf() - - override fun syntaxError( - recognizer: Recognizer<*, *>, - offendingSymbol: Any, - line: Int, - charPositionInLine: Int, - msg: String, - e: RecognitionException? - ) { - errorMessages.add(msg) - } -} - -private class Ordering( - private val tokens: CommonTokenStream, -) { - - private val dependenciesInOrder = mutableListOf() - private val orderedBlocks = mutableListOf() - - fun isAlreadyOrdered(): Boolean = orderedBlocks.all { it } - - fun collectDependency(dependency: DependencyDeclaration) { - dependenciesInOrder += dependency - } - - /** - * Checks ordering as we leave a dependencies block. Clears the list of dependencies to prepare for the potential next - * block. - */ - fun checkOrdering(newOrder: List) { - orderedBlocks += isSameOrder(dependenciesInOrder, newOrder) - dependenciesInOrder.clear() - } - - private fun isSameOrder( - first: List, - second: List - ): Boolean { - if (first.size != second.size) return false - return first.zip(second).all { (l, r) -> - tokens.getText(l.declaration) == tokens.getText(r.declaration) + public fun of(file: Path): Sorter = if (file.pathString.endsWith(".gradle")) { + GroovySorter.of(file) + } else if (file.pathString.endsWith(".gradle.kts")) { + KotlinSorter.of(file) + } else { + error("Expected '.gradle' or '.gradle.kts' extension. Was ${file.pathString}") } } } - -private data class Texts( - val comment: String?, - val declarationText: String -) - -private inline fun C.ifNotEmpty(block: (C) -> Unit) where C : Collection<*> { - if (isNotEmpty()) { - block(this) - } -} diff --git a/sort/src/main/kotlin/com/squareup/sort/Texts.kt b/sort/src/main/kotlin/com/squareup/sort/Texts.kt new file mode 100644 index 0000000..fbb593c --- /dev/null +++ b/sort/src/main/kotlin/com/squareup/sort/Texts.kt @@ -0,0 +1,6 @@ +package com.squareup.sort + +internal data class Texts( + val comment: String?, + val declarationText: String, +) diff --git a/sort/src/main/kotlin/com/squareup/sort/groovy/GroovyConfigurationComparator.kt b/sort/src/main/kotlin/com/squareup/sort/groovy/GroovyConfigurationComparator.kt new file mode 100644 index 0000000..50ad020 --- /dev/null +++ b/sort/src/main/kotlin/com/squareup/sort/groovy/GroovyConfigurationComparator.kt @@ -0,0 +1,23 @@ +package com.squareup.sort.groovy + +import com.squareup.sort.Configuration + +internal object GroovyConfigurationComparator : + Comparator>> { + + override fun compare( + left: MutableMap.MutableEntry>, + right: MutableMap.MutableEntry> + ): Int = stringCompare(left.key, right.key) + + /** + * Visible for testing. + * + * TODO: delete and inline. Fix tests. + */ + @JvmStatic + fun stringCompare( + left: String, + right: String + ): Int = Configuration.stringCompare(left, right) +} diff --git a/sort/src/main/kotlin/com/squareup/sort/groovy/GroovyDependencyDeclaration.kt b/sort/src/main/kotlin/com/squareup/sort/groovy/GroovyDependencyDeclaration.kt new file mode 100644 index 0000000..e72e54c --- /dev/null +++ b/sort/src/main/kotlin/com/squareup/sort/groovy/GroovyDependencyDeclaration.kt @@ -0,0 +1,93 @@ +package com.squareup.sort.groovy + +import com.autonomousapps.grammar.gradle.GradleScript.DependencyContext +import com.autonomousapps.grammar.gradle.GradleScript.NormalDeclarationContext +import com.autonomousapps.grammar.gradle.GradleScript.PlatformDeclarationContext +import com.autonomousapps.grammar.gradle.GradleScript.QuoteContext +import com.autonomousapps.grammar.gradle.GradleScript.TestFixturesDeclarationContext +import com.squareup.sort.DependencyDeclaration +import org.antlr.v4.runtime.ParserRuleContext + +/** + * To sort a dependency declaration, we care what kind of declaration it is ("normal", "platform", "test fixtures"), as + * well as what kind of dependency it is (GAV, project, file/files, catalog-like). + */ +internal class GroovyDependencyDeclaration( + val declaration: ParserRuleContext, + private val dependency: DependencyContext, + private val declarationKind: DeclarationKind, + private val dependencyKind: DependencyKind, +) : DependencyDeclaration { + + enum class DeclarationKind { + NORMAL, PLATFORM, TEST_FIXTURES + } + + enum class DependencyKind { + NORMAL, PROJECT, FILE; + + companion object { + fun of(dependency: DependencyContext, filePath: String): DependencyKind { + return if (dependency.externalDependency() != null) NORMAL + else if (dependency.projectDependency() != null) PROJECT + else if (dependency.fileDependency() != null) FILE + else error("Unknown dependency kind. Was <${dependency.text}> for $filePath") + } + } + } + + override fun fullText(): String { + TODO("Use tokens.getText(dependency.declaration) instead") + } + + override fun precedingComment(): String? { + TODO("Use precedingComment() instead") + } + + override fun isPlatformDeclaration() = declarationKind == DeclarationKind.PLATFORM + override fun isTestFixturesDeclaration() = declarationKind == DeclarationKind.TEST_FIXTURES + + override fun isProjectDependency() = dependencyKind == DependencyKind.PROJECT + override fun isFileDependency() = dependencyKind == DependencyKind.FILE + + override fun hasQuotes(): Boolean { + val i = declaration.children.indexOf(dependency) + return declaration.getChild(i - 1) is QuoteContext && declaration.getChild(i + 1) is QuoteContext + } + + override fun comparisonText(): String { + val text = when { + isProjectDependency() -> with(dependency.projectDependency()) { + // If project(path: 'foo') syntax is used, take the path value. + // Else, if project('foo') syntax is used, take the ID. + projectMapEntry().firstOrNull { it.key.text == "path:" }?.value?.text + ?: ID().text + } + + isFileDependency() -> dependency.fileDependency().ID().text + else -> dependency.externalDependency().ID().text + } + + return text.replaceHyphens() + } + + companion object { + fun of(declaration: ParserRuleContext, filePath: String): GroovyDependencyDeclaration { + val (dependency, declarationKind) = when (declaration) { + is NormalDeclarationContext -> declaration.dependency() to DeclarationKind.NORMAL + is PlatformDeclarationContext -> declaration.dependency() to DeclarationKind.PLATFORM + is TestFixturesDeclarationContext -> declaration.dependency() to DeclarationKind.TEST_FIXTURES + else -> error("Unknown declaration kind. Was ${declaration.text}.") + } + + val dependencyKind = when (declaration) { + is NormalDeclarationContext -> DependencyKind.of(declaration.dependency(), filePath) + is PlatformDeclarationContext -> DependencyKind.of(declaration.dependency(), filePath) + is TestFixturesDeclarationContext -> DependencyKind.of(declaration.dependency(), filePath) + else -> error("Unknown declaration kind. Was ${declaration.text}.") + } + + return GroovyDependencyDeclaration(declaration, dependency, declarationKind, dependencyKind) + } + } +} diff --git a/sort/src/main/kotlin/com/squareup/sort/groovy/GroovySorter.kt b/sort/src/main/kotlin/com/squareup/sort/groovy/GroovySorter.kt new file mode 100644 index 0000000..7d88896 --- /dev/null +++ b/sort/src/main/kotlin/com/squareup/sort/groovy/GroovySorter.kt @@ -0,0 +1,264 @@ +package com.squareup.sort.groovy + +import com.autonomousapps.grammar.gradle.GradleScript +import com.autonomousapps.grammar.gradle.GradleScript.BuildscriptContext +import com.autonomousapps.grammar.gradle.GradleScript.DependenciesContext +import com.autonomousapps.grammar.gradle.GradleScript.NormalDeclarationContext +import com.autonomousapps.grammar.gradle.GradleScript.PlatformDeclarationContext +import com.autonomousapps.grammar.gradle.GradleScript.TestFixturesDeclarationContext +import com.autonomousapps.grammar.gradle.GradleScriptBaseListener +import com.autonomousapps.grammar.gradle.GradleScriptLexer +import com.squareup.parse.AbstractErrorListener +import com.squareup.parse.AlreadyOrderedException +import com.squareup.parse.BuildScriptParseException +import com.squareup.sort.DependencyComparator +import com.squareup.sort.Sorter +import com.squareup.sort.Texts +import com.squareup.utils.ifNotEmpty +import org.antlr.v4.runtime.CharStreams +import org.antlr.v4.runtime.CommonTokenStream +import org.antlr.v4.runtime.ParserRuleContext +import org.antlr.v4.runtime.RecognitionException +import org.antlr.v4.runtime.Recognizer +import org.antlr.v4.runtime.TokenStreamRewriter +import org.antlr.v4.runtime.tree.ParseTreeWalker +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import kotlin.io.path.absolutePathString + +public class GroovySorter private constructor( + private val tokens: CommonTokenStream, + private val rewriter: TokenStreamRewriter, + private val errorListener: RewriterErrorListener, + private val filePath: String, +) : Sorter, GradleScriptBaseListener() { + + // We use a default of two spaces, but update it at most once later on. + private var smartIndentSet = false + private var indent = " " + + // TODO we can probably sort this block too. + private var isInBuildScriptBlock = false + + private val dependencyComparator = DependencyComparator() + private val dependenciesByConfiguration = + mutableMapOf>() + private val ordering = Ordering(tokens) + + private fun collectDependency( + configuration: String, + dependencyDeclaration: GroovyDependencyDeclaration + ) { + setIndent(dependencyDeclaration.declaration) + ordering.collectDependency(dependencyDeclaration) + dependenciesByConfiguration.merge( + configuration, + mutableListOf(dependencyDeclaration) + ) { acc, inc -> + acc.apply { addAll(inc) } + } + } + + private fun setIndent(ctx: ParserRuleContext) { + if (smartIndentSet) return + + tokens.getHiddenTokensToLeft(ctx.start.tokenIndex, GradleScriptLexer.WHITESPACE) + ?.firstOrNull()?.text?.replace("\n", "")?.let { + smartIndentSet = true + indent = it + } + } + + /** + * Returns the sorted build script. + * + * Throws [BuildScriptParseException] if the script has some idiosyncrasy that impairs parsing. + * + * Throws [AlreadyOrderedException] if the script is already sorted correctly. + */ + @Throws(BuildScriptParseException::class, AlreadyOrderedException::class) + override fun rewritten(): String { + errorListener.errorMessages.ifNotEmpty { + throw BuildScriptParseException.withErrors(errorListener.errorMessages) + } + if (isSorted()) throw AlreadyOrderedException() + + return rewriter.text + } + + /** Returns `true` if this file's dependencies are already sorted correctly, or if there are no dependencies. */ + override fun isSorted(): Boolean = ordering.isAlreadyOrdered() + + /** Returns `true` if there were errors parsing the build script. */ + override fun hasParseErrors(): Boolean = errorListener.errorMessages.isNotEmpty() + + /** Returns the parse exception if there is one, otherwise null. */ + override fun getParseError(): BuildScriptParseException? { + return if (errorListener.errorMessages.isNotEmpty()) { + BuildScriptParseException.withErrors(errorListener.errorMessages) + } else { + null + } + } + + override fun enterBuildscript(ctx: BuildscriptContext) { + isInBuildScriptBlock = true + } + + override fun exitBuildscript(ctx: BuildscriptContext) { + isInBuildScriptBlock = false + } + + override fun enterNormalDeclaration(ctx: NormalDeclarationContext) { + if (isInBuildScriptBlock) return + collectDependency( + tokens.getText(ctx.configuration()), + GroovyDependencyDeclaration.of(ctx, filePath) + ) + } + + override fun enterPlatformDeclaration(ctx: PlatformDeclarationContext) { + if (isInBuildScriptBlock) return + collectDependency( + tokens.getText(ctx.configuration()), + GroovyDependencyDeclaration.of(ctx, filePath) + ) + } + + override fun enterTestFixturesDeclaration(ctx: TestFixturesDeclarationContext) { + if (isInBuildScriptBlock) return + collectDependency( + tokens.getText(ctx.configuration()), + GroovyDependencyDeclaration.of(ctx, filePath) + ) + } + + override fun exitDependencies(ctx: DependenciesContext) { + if (isInBuildScriptBlock) return + rewriter.replace(ctx.start, ctx.stop, dependenciesBlock()) + + // Whenever we exit a dependencies block, clear this map. Each block will be treated separately. + dependenciesByConfiguration.clear() + } + + private fun dependenciesBlock() = buildString { + val newOrder = mutableListOf() + + appendLine("dependencies {") + dependenciesByConfiguration.entries.sortedWith(GroovyConfigurationComparator) + .forEachIndexed { i, entry -> + if (i != 0) appendLine() + entry.value.sortedWith(dependencyComparator) + .map { dependency -> + dependency to Texts( + comment = precedingComment(dependency), + declarationText = tokens.getText(dependency.declaration), + ) + } + .distinctBy { (_, texts) -> texts } + .forEach { (declaration, texts) -> + newOrder += declaration + + // Write preceding comments if there are any + if (texts.comment != null) appendLine(texts.comment) + + append(indent) + appendLine(texts.declarationText) + } + } + append("}") + + // If the new ordering matches the old ordering, we shouldn't rewrite the file. This accounts for multiple + // dependencies blocks + ordering.checkOrdering(newOrder) + } + + private fun precedingComment(dependency: GroovyDependencyDeclaration) = + tokens.getHiddenTokensToLeft( + dependency.declaration.start.tokenIndex, + GradleScriptLexer.COMMENTS + )?.joinToString(separator = "") { + "$indent${it.text}" + }?.trimEnd() + + public companion object { + @JvmStatic + public fun of(file: Path): GroovySorter { + val input = Files.newInputStream(file, StandardOpenOption.READ).use { + CharStreams.fromStream(it) + } + val lexer = GradleScriptLexer(input) + val tokens = CommonTokenStream(lexer) + val parser = GradleScript(tokens) + + // Remove default error listeners to prevent insane console output + lexer.removeErrorListeners() + parser.removeErrorListeners() + + val errorListener = RewriterErrorListener() + parser.addErrorListener(errorListener) + lexer.addErrorListener(errorListener) + + val walker = ParseTreeWalker() + val listener = GroovySorter( + tokens = tokens, + rewriter = TokenStreamRewriter(tokens), + errorListener = errorListener, + filePath = file.absolutePathString() + ) + val tree = parser.script() + walker.walk(listener, tree) + + return listener + } + } +} + +internal class RewriterErrorListener : AbstractErrorListener() { + val errorMessages = mutableListOf() + + override fun syntaxError( + recognizer: Recognizer<*, *>, + offendingSymbol: Any, + line: Int, + charPositionInLine: Int, + msg: String, + e: RecognitionException? + ) { + errorMessages.add(msg) + } +} + +private class Ordering( + private val tokens: CommonTokenStream, +) { + + private val dependenciesInOrder = mutableListOf() + private val orderedBlocks = mutableListOf() + + fun isAlreadyOrdered(): Boolean = orderedBlocks.all { it } + + fun collectDependency(dependency: GroovyDependencyDeclaration) { + dependenciesInOrder += dependency + } + + /** + * Checks ordering as we leave a dependencies block. Clears the list of dependencies to prepare for the potential next + * block. + */ + fun checkOrdering(newOrder: List) { + orderedBlocks += isSameOrder(dependenciesInOrder, newOrder) + dependenciesInOrder.clear() + } + + private fun isSameOrder( + first: List, + second: List + ): Boolean { + if (first.size != second.size) return false + return first.zip(second).all { (l, r) -> + tokens.getText(l.declaration) == tokens.getText(r.declaration) + } + } +} diff --git a/sort/src/main/kotlin/com/squareup/sort/kotlin/KotlinConfigurationComparator.kt b/sort/src/main/kotlin/com/squareup/sort/kotlin/KotlinConfigurationComparator.kt new file mode 100644 index 0000000..e8eed46 --- /dev/null +++ b/sort/src/main/kotlin/com/squareup/sort/kotlin/KotlinConfigurationComparator.kt @@ -0,0 +1,12 @@ +package com.squareup.sort.kotlin + +import com.squareup.sort.Configuration + +internal object KotlinConfigurationComparator : + Comparator>> { + + override fun compare( + left: MutableMap.MutableEntry>, + right: MutableMap.MutableEntry> + ): Int = Configuration.stringCompare(left.key, right.key) +} diff --git a/sort/src/main/kotlin/com/squareup/sort/kotlin/KotlinDependencyDeclaration.kt b/sort/src/main/kotlin/com/squareup/sort/kotlin/KotlinDependencyDeclaration.kt new file mode 100644 index 0000000..946cb18 --- /dev/null +++ b/sort/src/main/kotlin/com/squareup/sort/kotlin/KotlinDependencyDeclaration.kt @@ -0,0 +1,62 @@ +package com.squareup.sort.kotlin + +import cash.grammar.kotlindsl.model.DependencyDeclaration.Capability +import cash.grammar.kotlindsl.model.DependencyDeclaration.Type +import com.squareup.sort.DependencyDeclaration +import cash.grammar.kotlindsl.model.DependencyDeclaration as ModelDeclaration + +internal class KotlinDependencyDeclaration( + private val base: ModelDeclaration, +) : DependencyDeclaration { + + val configuration = base.configuration + + override fun fullText(): String = base.fullText + + override fun precedingComment(): String? = base.precedingComment + + override fun isPlatformDeclaration(): Boolean { + return base.capability == Capability.PLATFORM + } + + override fun isTestFixturesDeclaration(): Boolean { + return base.capability == Capability.TEST_FIXTURES + } + + override fun isFileDependency(): Boolean { + return base.type == Type.FILE + } + + override fun isProjectDependency(): Boolean { + return base.type == Type.PROJECT + } + + override fun hasQuotes(): Boolean { + return base.identifier.path.startsWith("'") || base.identifier.path.startsWith("\"") + } + + override fun comparisonText(): String { + // TODO: this may not exactly match the Groovy DSL behavior + return base.identifier.path.replaceHyphens() + } + + override fun toString(): String { + return fullText() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + if (!super.equals(other)) return false + + other as KotlinDependencyDeclaration + + return base == other.base + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + base.hashCode() + return result + } +} diff --git a/sort/src/main/kotlin/com/squareup/sort/kotlin/KotlinSorter.kt b/sort/src/main/kotlin/com/squareup/sort/kotlin/KotlinSorter.kt new file mode 100644 index 0000000..d3ca6bc --- /dev/null +++ b/sort/src/main/kotlin/com/squareup/sort/kotlin/KotlinSorter.kt @@ -0,0 +1,232 @@ +package com.squareup.sort.kotlin + +import cash.grammar.kotlindsl.model.gradle.DependencyContainer +import cash.grammar.kotlindsl.parse.Parser +import cash.grammar.kotlindsl.utils.Blocks.isDependencies +import cash.grammar.kotlindsl.utils.CollectingErrorListener +import cash.grammar.kotlindsl.utils.DependencyExtractor +import cash.grammar.kotlindsl.utils.Whitespace +import com.squareup.cash.grammar.KotlinParser.NamedBlockContext +import com.squareup.cash.grammar.KotlinParserBaseListener +import com.squareup.parse.AlreadyOrderedException +import com.squareup.parse.BuildScriptParseException +import com.squareup.sort.DependencyComparator +import com.squareup.sort.Sorter +import com.squareup.sort.Texts +import com.squareup.utils.ifNotEmpty +import org.antlr.v4.runtime.CharStream +import org.antlr.v4.runtime.CommonTokenStream +import org.antlr.v4.runtime.TokenStreamRewriter +import java.nio.file.Path +import kotlin.io.path.absolutePathString + +public class KotlinSorter private constructor( + private val input: CharStream, + private val tokens: CommonTokenStream, + private val errorListener: CollectingErrorListener, + private val filePath: String, +) : Sorter, KotlinParserBaseListener() { + + private val rewriter = TokenStreamRewriter(tokens) + + private val indent = Whitespace.computeIndent(tokens, input) + private val dependencyExtractor = DependencyExtractor( + input = input, + tokens = tokens, + indent = indent, + ) + + private val dependencyComparator = DependencyComparator() + private val mutableDependencies = MutableDependencies( + mutableMapOf(), + mutableListOf() + ) + private val ordering = Ordering() + + private var level = 0 + + // TODO move + private class MutableDependencies( + val dependenciesByConfiguration: MutableMap>, + val nonDeclarations: MutableList, + ) { + + fun declarations() = dependenciesByConfiguration.entries + + fun clear() { + dependenciesByConfiguration.clear() + nonDeclarations.clear() + } + } + + /** + * Returns the sorted build script. + * + * Throws [BuildScriptParseException] if the script has some idiosyncrasy that impairs parsing. + * + * Throws [AlreadyOrderedException] if the script is already sorted correctly. + */ + @Throws(BuildScriptParseException::class, AlreadyOrderedException::class) + override fun rewritten(): String { + errorListener.getErrorMessages().ifNotEmpty { + throw BuildScriptParseException.withErrors(it) + } + if (isSorted()) throw AlreadyOrderedException() + + return rewriter.text + } + + /** Returns `true` if this file's dependencies are already sorted correctly, or if there are no dependencies. */ + override fun isSorted(): Boolean = ordering.isAlreadyOrdered() + + /** Returns `true` if there were errors parsing the build script. */ + override fun hasParseErrors(): Boolean = errorListener.getErrorMessages().isNotEmpty() + + /** Returns the parse exception if there is one, otherwise null. */ + override fun getParseError(): BuildScriptParseException? { + return if (errorListener.getErrorMessages().isNotEmpty()) { + BuildScriptParseException.withErrors(errorListener.getErrorMessages()) + } else { + null + } + } + + override fun enterNamedBlock(ctx: NamedBlockContext) { + dependencyExtractor.onEnterBlock() + level++ + + if (ctx.isDependencies) { + collectDependencies(dependencyExtractor.collectDependencies(ctx)) + } + } + + override fun exitNamedBlock(ctx: NamedBlockContext) { + if (ctx.isDependencies) { + rewriter.replace(ctx.start, ctx.stop, dependenciesBlock()) + + // Whenever we exit a dependencies block, clear this map. Each block will be treated separately. + mutableDependencies.clear() + } + + dependencyExtractor.onExitBlock() + level-- + } + + private fun collectDependencies(container: DependencyContainer) { + val declarations = container.getDependencyDeclarations().map { KotlinDependencyDeclaration(it) } + mutableDependencies.nonDeclarations += container.getNonDeclarations() + + ordering.collectDependencies(declarations) + + declarations.forEach { decl -> + mutableDependencies.dependenciesByConfiguration.merge( + decl.configuration, + mutableListOf(decl) + ) { acc, inc -> + acc.apply { addAll(inc) } + } + } + } + + private fun dependenciesBlock() = buildString { + val newOrder = mutableListOf() + var didWrite = false + + appendLine("dependencies {") + + // not-easily-modelable elements + mutableDependencies.nonDeclarations.forEach { + append(indent.repeat(level)) + appendLine(it) + + didWrite = true + } + + if (didWrite && mutableDependencies.declarations().isNotEmpty()) { + appendLine() + } + + // declarations + mutableDependencies.declarations().sortedWith(KotlinConfigurationComparator) + .forEachIndexed { i, entry -> + if (i != 0) appendLine() + + entry.value.sortedWith(dependencyComparator) + .map { dependency -> + dependency to Texts( + comment = dependency.precedingComment(), + declarationText = dependency.fullText(), + ) + } + .distinctBy { (_, texts) -> texts } + .forEach { (declaration, texts) -> + newOrder += declaration + + // Write preceding comments if there are any + if (texts.comment != null) appendLine(texts.comment) + + append(indent.repeat(level)) + appendLine(texts.declarationText) + } + } + + append(indent.repeat(level - 1)) + append("}") + + // If the new ordering matches the old ordering, we shouldn't rewrite the file. This accounts for multiple + // dependencies blocks + ordering.checkOrdering(newOrder) + } + + public companion object { + @JvmStatic + public fun of(file: Path): KotlinSorter { + val errorListener = CollectingErrorListener() + + return Parser( + file = Parser.readOnlyInputStream(file), + errorListener = errorListener, + startRule = { it.script() }, + listenerFactory = { input, tokens, parser -> + KotlinSorter( + input = input, + tokens = tokens, + errorListener = errorListener, + filePath = file.absolutePathString(), + ) + } + ).listener() + } + } +} + +private class Ordering { + + private val dependenciesInOrder = mutableListOf() + private val orderedBlocks = mutableListOf() + + fun isAlreadyOrdered(): Boolean = orderedBlocks.all { it } + + fun collectDependencies(dependencies: List) { + dependenciesInOrder += dependencies + } + + /** + * Checks ordering as we leave a dependencies block. Clears the list of dependencies to prepare for the potential next + * block. + */ + fun checkOrdering(newOrder: List) { + orderedBlocks += isSameOrder(dependenciesInOrder, newOrder) + dependenciesInOrder.clear() + } + + private fun isSameOrder( + first: List, + second: List, + ): Boolean { + if (first.size != second.size) return false + return first.zip(second).all { (l, r) -> + l == r + } + } +} diff --git a/sort/src/main/kotlin/com/squareup/utils/collections.kt b/sort/src/main/kotlin/com/squareup/utils/collections.kt new file mode 100644 index 0000000..e240be1 --- /dev/null +++ b/sort/src/main/kotlin/com/squareup/utils/collections.kt @@ -0,0 +1,7 @@ +package com.squareup.utils + +internal inline fun C.ifNotEmpty(block: (C) -> Unit) where C : Collection<*> { + if (isNotEmpty()) { + block(this) + } +} diff --git a/sort/src/test/groovy/com/squareup/sort/DependencyComparatorTest.groovy b/sort/src/test/groovy/com/squareup/sort/DependencyComparatorTest.groovy new file mode 100644 index 0000000..838366d --- /dev/null +++ b/sort/src/test/groovy/com/squareup/sort/DependencyComparatorTest.groovy @@ -0,0 +1,71 @@ +package com.squareup.sort + +import cash.grammar.kotlindsl.model.DependencyDeclaration.Capability +import cash.grammar.kotlindsl.model.DependencyDeclaration.Type +import com.squareup.sort.kotlin.KotlinDependencyDeclaration +import spock.lang.Specification +import cash.grammar.kotlindsl.model.DependencyDeclaration.Identifier + +class DependencyComparatorTest extends Specification { + + def "can sort dependency declarations"() { + given: + def deps = [ + moduleDependency('implementation', '"heart:of-gold:1.0"', 'implementation("heart:of-gold:1.0")'), + moduleDependency('implementation', '"b:1.0"', 'implementation("b:1.0")'), + moduleDependency('implementation', '"a:1.0"', 'implementation("a:1.0")'), + moduleDependency('implementation', 'deps.foo', 'implementation(deps.foo)'), + moduleDependency('implementation', 'deps.bar', 'implementation(deps.bar)', ' /*\n * Here\'s a multiline comment.\n */'), + projectDependency('implementation', '":milliways"', 'implementation(project(":milliways"))'), + ] + + when: + Collections.sort(deps, new DependencyComparator()) + + then: + deps[0].base.identifier.path == '":milliways"' + deps[1].base.identifier.path == '"a:1.0"' + deps[2].base.identifier.path == '"b:1.0"' + deps[3].base.identifier.path == '"heart:of-gold:1.0"' + deps[4].base.identifier.path == 'deps.bar' + deps[5].base.identifier.path == 'deps.foo' + } + + private static KotlinDependencyDeclaration moduleDependency( + String configuration, + String identifier, + String fullText, + String comment = null, + Capability capability = Capability.DEFAULT + ) { + def base = new cash.grammar.kotlindsl.model.DependencyDeclaration( + configuration, + new Identifier(identifier), + capability, + Type.MODULE, + fullText, + comment + ) + + return new KotlinDependencyDeclaration(base) + } + + private static KotlinDependencyDeclaration projectDependency( + String configuration, + String identifier, + String fullText, + String comment = null, + Capability capability = Capability.DEFAULT + ) { + def base = new cash.grammar.kotlindsl.model.DependencyDeclaration( + configuration, + new Identifier(identifier), + capability, + Type.PROJECT, + fullText, + comment + ) + + return new KotlinDependencyDeclaration(base) + } +} diff --git a/sort/src/test/groovy/com/squareup/sort/ConfigurationComparatorSpec.groovy b/sort/src/test/groovy/com/squareup/sort/GroovyConfigurationComparatorSpec.groovy similarity index 81% rename from sort/src/test/groovy/com/squareup/sort/ConfigurationComparatorSpec.groovy rename to sort/src/test/groovy/com/squareup/sort/GroovyConfigurationComparatorSpec.groovy index 7694de8..c6d2944 100644 --- a/sort/src/test/groovy/com/squareup/sort/ConfigurationComparatorSpec.groovy +++ b/sort/src/test/groovy/com/squareup/sort/GroovyConfigurationComparatorSpec.groovy @@ -1,11 +1,12 @@ package com.squareup.sort - +import com.squareup.sort.groovy.GroovyConfigurationComparator import spock.lang.Specification import static com.google.common.truth.Truth.assertThat -final class ConfigurationComparatorSpec extends Specification { +// TODO need KotlinConfigurationComparatorSpec +final class GroovyConfigurationComparatorSpec extends Specification { def "comparisons work"() { given: @@ -18,7 +19,7 @@ final class ConfigurationComparatorSpec extends Specification { when: configurations.sort(true) { left, right -> - ConfigurationComparator.stringCompare(left, right) + GroovyConfigurationComparator.stringCompare(left, right) } then: diff --git a/sort/src/test/groovy/com/squareup/sort/SorterSpec.groovy b/sort/src/test/groovy/com/squareup/sort/GroovySorterSpec.groovy similarity index 95% rename from sort/src/test/groovy/com/squareup/sort/SorterSpec.groovy rename to sort/src/test/groovy/com/squareup/sort/GroovySorterSpec.groovy index e93e9c1..a45d011 100644 --- a/sort/src/test/groovy/com/squareup/sort/SorterSpec.groovy +++ b/sort/src/test/groovy/com/squareup/sort/GroovySorterSpec.groovy @@ -2,6 +2,7 @@ package com.squareup.sort import com.squareup.parse.AlreadyOrderedException import com.squareup.parse.BuildScriptParseException +import com.squareup.sort.groovy.GroovySorter import spock.lang.Specification import spock.lang.TempDir @@ -10,7 +11,7 @@ import java.nio.file.Path import static com.google.common.truth.Truth.assertThat -final class SorterSpec extends Specification { +final class GroovySorterSpec extends Specification { @TempDir Path dir @@ -64,7 +65,7 @@ final class SorterSpec extends Specification { println 'hello, world' '''.stripIndent()) - def sorter = Sorter.sorterFor(buildScript) + def sorter = GroovySorter.of(buildScript) expect: assertThat(trimmedLinesOf(sorter.rewritten())).containsExactlyElementsIn(trimmedLinesOf( @@ -142,7 +143,7 @@ final class SorterSpec extends Specification { api 'zzz:yyy:1.0' } '''.stripIndent()) - def sorter = Sorter.sorterFor(buildScript) + def sorter = GroovySorter.of(buildScript) expect: assertThat(trimmedLinesOf(sorter.rewritten())).containsExactlyElementsIn(trimmedLinesOf( @@ -181,7 +182,7 @@ final class SorterSpec extends Specification { api project(":marvin:robot:so-sad") } '''.stripIndent()) - def sorter = Sorter.sorterFor(buildScript) + def sorter = GroovySorter.of(buildScript) expect: assertThat(trimmedLinesOf(sorter.rewritten())).containsExactlyElementsIn(trimmedLinesOf( @@ -204,7 +205,7 @@ final class SorterSpec extends Specification { api project(":b") } '''.stripIndent()) - def sorter = Sorter.sorterFor(buildScript) + def sorter = GroovySorter.of(buildScript) when: sorter.rewritten() @@ -228,7 +229,7 @@ final class SorterSpec extends Specification { '''.stripIndent()) when: - def newScript = Sorter.sorterFor(buildScript).rewritten() + def newScript = GroovySorter.of(buildScript).rewritten() then: notThrown(BuildScriptParseException) @@ -286,7 +287,7 @@ final class SorterSpec extends Specification { println 'hello, world' '''.stripIndent()) - def sorter = Sorter.sorterFor(buildScript) + def sorter = GroovySorter.of(buildScript) when: sorter.rewritten() @@ -311,7 +312,7 @@ final class SorterSpec extends Specification { api project(path: ":trillian") } '''.stripIndent()) - def sorter = Sorter.sorterFor(buildScript) + def sorter = GroovySorter.of(buildScript) expect: assertThat(trimmedLinesOf(sorter.rewritten())).containsExactlyElementsIn(trimmedLinesOf( @@ -338,7 +339,7 @@ final class SorterSpec extends Specification { id 'foo' } '''.stripIndent()) - def sorter = Sorter.sorterFor(buildScript) + def sorter = GroovySorter.of(buildScript) expect: sorter.isSorted() @@ -352,7 +353,7 @@ final class SorterSpec extends Specification { dependencies { } '''.stripIndent()) - def sorter = Sorter.sorterFor(buildScript) + def sorter = GroovySorter.of(buildScript) expect: sorter.isSorted() @@ -375,7 +376,7 @@ final class SorterSpec extends Specification { '''.stripIndent()) when: - def newScript = Sorter.sorterFor(buildScript).rewritten() + def newScript = GroovySorter.of(buildScript).rewritten() then: notThrown(BuildScriptParseException) @@ -415,7 +416,7 @@ final class SorterSpec extends Specification { '''.stripIndent()) when: - def newScript = Sorter.sorterFor(buildScript).rewritten() + def newScript = GroovySorter.of(buildScript).rewritten() then: notThrown(BuildScriptParseException) @@ -456,7 +457,7 @@ final class SorterSpec extends Specification { '''.stripIndent()) when: - def newScript = Sorter.sorterFor(buildScript).rewritten() + def newScript = GroovySorter.of(buildScript).rewritten() then: notThrown(BuildScriptParseException) @@ -495,7 +496,7 @@ final class SorterSpec extends Specification { } '''.stripIndent()) when: - def newScript = Sorter.sorterFor(buildScript).rewritten() + def newScript = GroovySorter.of(buildScript).rewritten() then: notThrown(BuildScriptParseException) @@ -574,7 +575,7 @@ final class SorterSpec extends Specification { ) when: - def newScript = Sorter.sorterFor(buildScript).rewritten() + def newScript = GroovySorter.of(buildScript).rewritten() then: assertThat(newScript).isEqualTo( diff --git a/sort/src/test/groovy/com/squareup/sort/KotlinSorterSpec.groovy b/sort/src/test/groovy/com/squareup/sort/KotlinSorterSpec.groovy new file mode 100644 index 0000000..099589f --- /dev/null +++ b/sort/src/test/groovy/com/squareup/sort/KotlinSorterSpec.groovy @@ -0,0 +1,645 @@ +package com.squareup.sort + +import com.squareup.parse.AlreadyOrderedException +import com.squareup.parse.BuildScriptParseException +import com.squareup.sort.kotlin.KotlinSorter +import spock.lang.Specification +import spock.lang.TempDir + +import java.nio.file.Files +import java.nio.file.Path + +import static com.google.common.truth.Truth.assertThat + +class KotlinSorterSpec extends Specification { + + @TempDir + Path dir + + def "can sort build script"() { + given: + def buildScript = dir.resolve('build.gradle.kts') + Files.writeString(buildScript, + '''\ + import foo + import static bar; + + plugins { + id("foo") + } + + repositories { + google() + mavenCentral() + } + + apply(plugin = "bar") + val magic by extra(42) + + android { + whatever = "" + } + + dependencies { + implementation("heart:of-gold:1.0") + api(project(":marvin")) + + implementation("b:1.0") + implementation("a:1.0") + // Here's a multi-line comment + // Here's the second line of the comment + implementation(deps.foo) + + /* + * Here's a multiline comment. + */ + implementation(deps.bar) + + testImplementation("pan-galactic:gargle-blaster:2.0-SNAPSHOT") { + because = "life's too short not to" + } + + implementation(project(":milliways")) + api("zzz:yyy:1.0") + } + + println("hello, world") + '''.stripIndent()) + def sorter = KotlinSorter.of(buildScript) + + expect: + assertThat(trimmedLinesOf(sorter.rewritten())).containsExactlyElementsIn(trimmedLinesOf( + '''\ + import foo + import static bar; + + plugins { + id("foo") + } + + repositories { + google() + mavenCentral() + } + + apply(plugin = "bar") + val magic by extra(42) + + android { + whatever = "" + } + + dependencies { + api(project(":marvin")) + api("zzz:yyy:1.0") + + implementation(project(":milliways")) + implementation("a:1.0") + implementation("b:1.0") + implementation("heart:of-gold:1.0") + /* + * Here's a multiline comment. + */ + implementation(deps.bar) + // Here's a multi-line comment + // Here's the second line of the comment + implementation(deps.foo) + + testImplementation("pan-galactic:gargle-blaster:2.0-SNAPSHOT") { + because = "life's too short not to" + } + } + + println("hello, world") + '''.stripIndent() + )).inOrder() + } + + def "can sort build script with four-space tabs"() { + given: + def buildScript = dir.resolve('build.gradle.kts') + Files.writeString(buildScript, + '''\ + dependencies { + implementation("heart:of-gold:1.0") + api(project(":marvin")) + + implementation("b:1.0") + implementation("a:1.0") + // Here's a multi-line comment + // Here's the second line of the comment + implementation(deps.foo) + + /* + * Here's a multiline comment. + */ + implementation(deps.bar) + + testImplementation("pan-galactic:gargle-blaster:2.0-SNAPSHOT") { + because = "life's too short not to" + } + + implementation(project(":milliways")) + api("zzz:yyy:1.0") + } + '''.stripIndent()) + def sorter = KotlinSorter.of(buildScript) + + expect: + assertThat(trimmedLinesOf(sorter.rewritten())).containsExactlyElementsIn(trimmedLinesOf( + '''\ + dependencies { + api(project(":marvin")) + api("zzz:yyy:1.0") + + implementation(project(":milliways")) + implementation("a:1.0") + implementation("b:1.0") + implementation("heart:of-gold:1.0") + /* + * Here's a multiline comment. + */ + implementation(deps.bar) + // Here's a multi-line comment + // Here's the second line of the comment + implementation(deps.foo) + + testImplementation("pan-galactic:gargle-blaster:2.0-SNAPSHOT") { + because = "life's too short not to" + } + } + '''.stripIndent() + )).inOrder() + } + + def "colons have higher precedence than hyphen"() { + given: + def buildScript = dir.resolve('build.gradle.kts') + Files.writeString(buildScript, + '''\ + dependencies { + api(project(":marvin-robot:so-sad")) + api(project(":marvin:robot:so-sad")) + } + '''.stripIndent()) + def sorter = KotlinSorter.of(buildScript) + + expect: + assertThat(trimmedLinesOf(sorter.rewritten())).containsExactlyElementsIn(trimmedLinesOf( + '''\ + dependencies { + api(project(":marvin:robot:so-sad")) + api(project(":marvin-robot:so-sad")) + } + '''.stripIndent() + )).inOrder() + } + + // We have observed that, given the start "dependencies{" (no space), and a project dependency, the + // parser fails. For some reason this combination was confusing the lexer, which treated + // "dependencies{" as if it matched the 'text' rule, rather than the 'dependencies' rule. + def "can sort a dependencies{ block"() { + given: + def buildScript = dir.resolve('build.gradle.kts') + Files.writeString(buildScript, + '''\ + dependencies{ + api(project(":nu-metal")) + api(project(":magic")) + } + '''.stripIndent()) + + when: + def newScript = KotlinSorter.of(buildScript).rewritten() + + then: + notThrown(BuildScriptParseException) + + and: + assertThat(trimmedLinesOf(newScript)).containsExactlyElementsIn(trimmedLinesOf( + '''\ + dependencies { + api(project(":magic")) + api(project(":nu-metal")) + } + '''.stripIndent() + )).inOrder() + } + + def "will not sort already sorted build script"() { + given: + def buildScript = dir.resolve('build.gradle.kts') + Files.writeString(buildScript, + '''\ + import foo + import static bar; + + plugins { + id("foo") + } + + repositories { + google() + mavenCentral() + } + + apply(plugin = "bar") + val magic by extra(42) + + android { + whatever = "" + } + + dependencies { + api(project(":marvin")) + api("zzz:yyy:1.0") + + implementation(project(":milliways")) + implementation("a:1.0") + implementation("b:1.0") + implementation("heart:of-gold:1.0") + implementation(deps.bar) + implementation(deps.foo) + + testImplementation("pan-galactic:gargle-blaster:2.0-SNAPSHOT") { + because = "life's too short not to" + } + } + + println("hello, world") + '''.stripIndent()) + def sorter = KotlinSorter.of(buildScript) + + when: + sorter.rewritten() + + then: + thrown(AlreadyOrderedException) + } + + def "sort can handle 'path:' notation"() { + given: + def buildScript = dir.resolve('build.gradle.kts') + Files.writeString(buildScript, + '''\ + dependencies { + api(project(":path:path")) + api(project(":zaphod")) + api(project(path = ":beeblebrox", configuration = "solipsism")) + api(project(path = ":path")) + + api(project(":eddie")) + api(project(path = ":trillian")) + api(project(":eddie:eddie")) + } + '''.stripIndent()) + def sorter = KotlinSorter.of(buildScript) + + expect: + assertThat(trimmedLinesOf(sorter.rewritten())).containsExactlyElementsIn(trimmedLinesOf( + '''\ + dependencies { + api(project(path = ":beeblebrox", configuration = "solipsism")) + api(project(":eddie")) + api(project(":eddie:eddie")) + api(project(path = ":path")) + api(project(":path:path")) + api(project(path = ":trillian")) + api(project(":zaphod")) + } + '''.stripIndent() + )).inOrder() + } + + def "sort can handle 'files'-like notation"() { + given: + def buildScript = dir.resolve('build.gradle.kts') + Files.writeString(buildScript, + '''\ + dependencies { + implementation(files("a.jar")) + api(file("another.jar")) + compileOnly(fileTree("libs") { include("*.jar") }) + } + '''.stripIndent()) + def sorter = KotlinSorter.of(buildScript) + + expect: + assertThat(trimmedLinesOf(sorter.rewritten())).containsExactlyElementsIn(trimmedLinesOf( + '''\ + dependencies { + api(file("another.jar")) + + implementation(files("a.jar")) + + compileOnly(fileTree("libs") { include("*.jar") }) + } + '''.stripIndent() + )).inOrder() + } + + def "a script without dependencies is already sorted"() { + given: + def buildScript = dir.resolve('build.gradle.kts') + Files.writeString(buildScript, + '''\ + plugins { + id("foo") + } + '''.stripIndent()) + def sorter = KotlinSorter.of(buildScript) + + expect: + sorter.isSorted() + } + + def "a script with an empty dependencies is already sorted"() { + given: + def buildScript = dir.resolve('build.gradle.kts') + Files.writeString(buildScript, + '''\ + dependencies { + } + '''.stripIndent()) + def sorter = KotlinSorter.of(buildScript) + + expect: + sorter.isSorted() + } + + def "dedupe identical dependencies"() { + given: + def buildScript = dir.resolve('build.gradle.kts') + Files.writeString(buildScript, + '''\ + dependencies { + implementation(projects.foo) + implementation(projects.bar) + implementation(projects.foo) + + api(projects.foo) + api(projects.bar) + api(projects.foo) + } + '''.stripIndent()) + + when: + def newScript = KotlinSorter.of(buildScript).rewritten() + + then: + notThrown(BuildScriptParseException) + + and: + assertThat(trimmedLinesOf(newScript)).containsExactlyElementsIn(trimmedLinesOf( + '''\ + dependencies { + api(projects.bar) + api(projects.foo) + + implementation(projects.bar) + implementation(projects.foo) + } + '''.stripIndent() + )).inOrder() + } + + def "keep identical dependencies that have non-identical comments"() { + given: + def buildScript = dir.resolve('build.gradle.kts') + Files.writeString(buildScript, + '''\ + dependencies { + // Foo implementation + implementation(projects.foo) + implementation(projects.bar) + // Foo implementation + implementation(projects.foo) + + // Foo api 1st + api(projects.foo) + api(projects.bar) + // Foo api 2nd + api(projects.foo) + } + '''.stripIndent()) + + when: + def newScript = KotlinSorter.of(buildScript).rewritten() + + then: + notThrown(BuildScriptParseException) + + and: + assertThat(trimmedLinesOf(newScript)).containsExactlyElementsIn(trimmedLinesOf( + '''\ + dependencies { + api(projects.bar) + // Foo api 1st + api(projects.foo) + // Foo api 2nd + api(projects.foo) + + implementation(projects.bar) + // Foo implementation + implementation(projects.foo) + } + '''.stripIndent() + )).inOrder() + } + + def "sort add function call in dependencies"() { + given: + def buildScript = dir.resolve('build.gradle.kts') + Files.writeString(buildScript, + '''\ + dependencies { + implementation(projects.foo) + implementation(projects.bar) + + api(projects.foo) + api(projects.bar) + + add("debugImplementation", projects.foo) + add(releaseImplementation, projects.foo) + } + '''.stripIndent()) + + when: + def newScript = KotlinSorter.of(buildScript).rewritten() + + then: + notThrown(BuildScriptParseException) + + and: + assertThat(trimmedLinesOf(newScript)).containsExactlyElementsIn(trimmedLinesOf('''\ + dependencies { + add("debugImplementation", projects.foo) + add(releaseImplementation, projects.foo) + + api(projects.bar) + api(projects.foo) + + implementation(projects.bar) + implementation(projects.foo) + } + '''.stripIndent())).inOrder() + } + + def "can sort dependencies with artifact type specified"() { + given: + def buildScript = dir.resolve('build.gradle.kts') + Files.writeString(buildScript, + '''\ + dependencies { + implementation(projects.foo.internal) + implementation(projects.bar.public) + implementation(libs.baz.ui) { + artifact { + type = "aar" + } + } + implementation(libs.androidx.constraintLayout) + implementation(libs.common.view) + implementation(projects.core) + } + '''.stripIndent()) + when: + def newScript = KotlinSorter.of(buildScript).rewritten() + + then: + notThrown(BuildScriptParseException) + + and: + assertThat(trimmedLinesOf(newScript)).containsExactlyElementsIn(trimmedLinesOf( + '''\ + dependencies { + implementation(libs.androidx.constraintLayout) + implementation(libs.baz.ui) { + artifact { + type = "aar" + } + } + implementation(libs.common.view) + implementation(projects.bar.public) + implementation(projects.core) + implementation(projects.foo.internal) + } + '''.stripIndent() + )).inOrder() + } + + // https://github.com/square/gradle-dependencies-sorter/issues/59 + def "can sort multiple semantically different dependencies blocks"() { + given: + def buildScript = dir.resolve('build.gradle.kts') + Files.writeString(buildScript, + """\ + import app.cash.redwood.buildsupport.FlexboxHelpers + + apply(plugin = "com.android.library") + apply(plugin = "org.jetbrains.kotlin.multiplatform") + apply(plugin = "org.jetbrains.kotlin.plugin.serialization") + apply(plugin = "app.cash.paparazzi") + apply(plugin = "com.vanniktech.maven.publish") + apply(plugin = "org.jetbrains.dokka") // Must be applied here for publish plugin. + apply(plugin = "app.cash.redwood.build.compose") + + kotlin { + android { + publishLibraryVariants("release") + } + + iosArm64() + iosX64() + iosSimulatorArm64() + + jvm() + + macosArm64() + macosX64() + + sourceSets { + commonMain { + kotlin.srcDir(FlexboxHelpers.get(tasks, "app.cash.redwood.layout.composeui").get()) + dependencies { + api(projects.redwoodLayoutWidget) + implementation(projects.redwoodFlexbox) + implementation(projects.redwoodWidgetCompose) + implementation(libs.jetbrains.compose.foundation) + } + } + + androidUnitTest { + dependencies { + implementation(projects.redwoodLayoutSharedTest) + } + } + } + } + + android { + namespace = "app.cash.redwood.layout.composeui" + }""".stripIndent() + ) + + when: + def newScript = KotlinSorter.of(buildScript).rewritten() + + then: + assertThat(newScript).isEqualTo( + """\ + import app.cash.redwood.buildsupport.FlexboxHelpers + + apply(plugin = "com.android.library") + apply(plugin = "org.jetbrains.kotlin.multiplatform") + apply(plugin = "org.jetbrains.kotlin.plugin.serialization") + apply(plugin = "app.cash.paparazzi") + apply(plugin = "com.vanniktech.maven.publish") + apply(plugin = "org.jetbrains.dokka") // Must be applied here for publish plugin. + apply(plugin = "app.cash.redwood.build.compose") + + kotlin { + android { + publishLibraryVariants("release") + } + + iosArm64() + iosX64() + iosSimulatorArm64() + + jvm() + + macosArm64() + macosX64() + + sourceSets { + commonMain { + kotlin.srcDir(FlexboxHelpers.get(tasks, "app.cash.redwood.layout.composeui").get()) + dependencies { + api(projects.redwoodLayoutWidget) + + implementation(libs.jetbrains.compose.foundation) + implementation(projects.redwoodFlexbox) + implementation(projects.redwoodWidgetCompose) + } + } + + androidUnitTest { + dependencies { + implementation(projects.redwoodLayoutSharedTest) + } + } + } + } + + android { + namespace = "app.cash.redwood.layout.composeui" + }""".stripIndent() + ) + } + + private static List trimmedLinesOf(CharSequence content) { + // to lines and trim whitespace off end + return content.readLines().collect { it.replaceFirst('\\s+\$', '') } + } +}