├── .gitattributes ├── .github └── workflows │ ├── pr.yml │ ├── publish.yml │ └── push.yml ├── .gitignore ├── CHANGELOG.md ├── README.md ├── RELEASING.md ├── build-logic ├── build.gradle.kts ├── conventions │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── kotlin │ │ └── com │ │ └── squareup │ │ └── gradle │ │ ├── BasePlugin.kt │ │ ├── GrammarConventionPlugin.kt │ │ ├── LibraryConventionPlugin.kt │ │ ├── SettingsPlugin.kt │ │ └── utils │ │ └── DependencyCatalog.kt ├── gradle.properties └── settings.gradle.kts ├── build.gradle.kts ├── core ├── build.gradle.kts └── src │ ├── main │ └── kotlin │ │ └── cash │ │ └── grammar │ │ ├── kotlindsl │ │ ├── model │ │ │ ├── Assignment.kt │ │ │ ├── DependencyDeclaration.kt │ │ │ ├── DependencyElement.kt │ │ │ ├── Plugin.kt │ │ │ ├── RemovableBlock.kt │ │ │ └── gradle │ │ │ │ └── DependencyContainer.kt │ │ ├── parse │ │ │ ├── KotlinParseException.kt │ │ │ ├── Mutator.kt │ │ │ ├── Parser.kt │ │ │ ├── Rewriter.kt │ │ │ ├── SimpleErrorListener.kt │ │ │ └── StringExtensions.kt │ │ └── utils │ │ │ ├── BlockRemover.kt │ │ │ ├── Blocks.kt │ │ │ ├── CollectingErrorListener.kt │ │ │ ├── Comments.kt │ │ │ ├── CommentsInBlockRemover.kt │ │ │ ├── Context.kt │ │ │ ├── DependencyExtractor.kt │ │ │ ├── PluginConfigFinder.kt │ │ │ ├── PluginExtractor.kt │ │ │ ├── PluginFinder.kt │ │ │ ├── SmartIndent.kt │ │ │ └── Whitespace.kt │ │ └── utils │ │ └── collections.kt │ └── test │ └── kotlin │ └── cash │ └── grammar │ └── kotlindsl │ └── utils │ ├── BlockRemoverTest.kt │ ├── BlocksTest.kt │ ├── CommentsInBlockRemoverTest.kt │ ├── CommentsTest.kt │ ├── ContextTest.kt │ ├── DependencyExtractorTest.kt │ ├── PluginExtractorTest.kt │ ├── PluginFinderTest.kt │ ├── TestListener.kt │ ├── WhitespaceTest.kt │ └── test │ └── TestErrorListener.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── grammar ├── build.gradle.kts └── src │ └── main │ └── antlr │ └── com │ └── squareup │ └── cash │ └── grammar │ ├── KotlinLexer.g4 │ ├── KotlinLexer.tokens │ ├── KotlinParser.g4 │ ├── KotlinParser.tokens │ ├── UnicodeClasses.g4 │ └── UnicodeClasses.tokens ├── recipes ├── dependencies │ ├── build.gradle.kts │ └── src │ │ ├── main │ │ └── kotlin │ │ │ └── cash │ │ │ └── recipes │ │ │ └── dependencies │ │ │ ├── DependenciesMutator.kt │ │ │ ├── DependenciesSimplifier.kt │ │ │ └── transform │ │ │ └── Transform.kt │ │ └── test │ │ └── kotlin │ │ └── cash │ │ └── recipes │ │ └── dependencies │ │ ├── DependenciesMutatorTest.kt │ │ └── DependenciesSimplifierTest.kt ├── plugins │ ├── build.gradle.kts │ └── src │ │ ├── main │ │ └── kotlin │ │ │ └── cash │ │ │ └── recipes │ │ │ └── plugins │ │ │ ├── PluginMutator.kt │ │ │ ├── PluginNormalizer.kt │ │ │ └── exception │ │ │ └── NonNormalizedScriptException.kt │ │ └── test │ │ └── kotlin │ │ └── cash │ │ └── recipes │ │ └── plugins │ │ ├── PluginMutatorTest.kt │ │ └── PluginNormalizerTest.kt └── repos │ ├── build.gradle.kts │ └── src │ ├── main │ └── kotlin │ │ └── cash │ │ └── recipes │ │ └── repos │ │ ├── DependencyResolutionManagementAdder.kt │ │ ├── RepositoriesDeleter.kt │ │ └── exception │ │ └── AlreadyHasBlockException.kt │ └── test │ └── kotlin │ └── cash │ └── recipes │ └── repos │ ├── DependencyResolutionManagementAdderTest.kt │ └── RepositoriesDeleterTest.kt └── settings.gradle.kts /.gitattributes: -------------------------------------------------------------------------------- 1 | # 2 | # https://help.github.com/articles/dealing-with-line-endings/ 3 | # 4 | # Linux start script should use lf 5 | /gradlew text eol=lf 6 | 7 | # These are Windows script files and should use crlf 8 | *.bat text eol=crlf 9 | 10 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - '*.md' 7 | workflow_dispatch: 8 | inputs: 9 | reason: 10 | description: 'Reason for manual run' 11 | required: false 12 | 13 | concurrency: 14 | group: build-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | gradle: 19 | strategy: 20 | matrix: 21 | os: [ ubuntu-latest ] 22 | runs-on: ${{ matrix.os }} 23 | steps: 24 | - name: Checkout the repo 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup java 28 | uses: actions/setup-java@v4 29 | with: 30 | distribution: 'zulu' 31 | java-version: 17 32 | 33 | - name: Validate Gradle Wrapper 34 | uses: gradle/actions/wrapper-validation@v3 35 | 36 | - name: Setup Gradle 37 | uses: gradle/actions/setup-gradle@v3 38 | 39 | - name: Execute check 40 | run: './gradlew check -s' 41 | 42 | - name: Execute buildHealth for main project 43 | run: './gradlew buildHealth -s' 44 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | reason: 7 | description: 'Reason for manual run' 8 | required: false 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | publish: 15 | env: 16 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} 17 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} 18 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.ARTIFACT_SIGNING_PRIVATE_KEY }} 19 | runs-on: ubuntu-latest 20 | if: github.repository == 'cashapp/kotlin-editor' && github.ref == 'refs/heads/main' 21 | 22 | steps: 23 | - name: Checkout the repo 24 | uses: actions/checkout@v4 25 | 26 | - name: Setup java 27 | uses: actions/setup-java@v4 28 | with: 29 | distribution: 'zulu' 30 | java-version: 17 31 | 32 | - name: Validate Gradle Wrapper 33 | uses: gradle/actions/wrapper-validation@v3 34 | 35 | - name: Setup Gradle 36 | uses: gradle/actions/setup-gradle@v3 37 | 38 | - name: Execute check 39 | run: './gradlew check -s' 40 | 41 | - name: Execute buildHealth 42 | run: './gradlew buildHealth -s' 43 | 44 | - name: Publish artifacts 45 | run: './gradlew publishToMavenCentral -s --no-configuration-cache' 46 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '*.md' 9 | workflow_dispatch: 10 | inputs: 11 | reason: 12 | description: 'Reason for manual run' 13 | required: false 14 | 15 | concurrency: 16 | group: build-${{ github.ref }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | gradle: 21 | strategy: 22 | matrix: 23 | os: [ ubuntu-latest ] 24 | runs-on: ${{ matrix.os }} 25 | steps: 26 | - name: Checkout the repo 27 | uses: actions/checkout@v4 28 | 29 | - name: Setup java 30 | uses: actions/setup-java@v4 31 | with: 32 | distribution: 'zulu' 33 | java-version: 17 34 | 35 | - name: Validate Gradle Wrapper 36 | uses: gradle/actions/wrapper-validation@v3 37 | 38 | - name: Setup Gradle 39 | uses: gradle/actions/setup-gradle@v3 40 | 41 | - name: Execute check 42 | run: './gradlew check -s' 43 | 44 | - name: Execute buildHealth 45 | run: './gradlew buildHealth -s' 46 | 47 | # - name: Publish snapshot 48 | # uses: eskatos/gradle-command-action@v1 49 | # env: 50 | # sonatypeUsername: ${{ secrets.sonatypeUsername }} 51 | # sonatypePassword: ${{ secrets.sonatypePassword }} 52 | # with: 53 | # arguments: ':publishToMavenCentral' 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore Gradle project-specific cache directory 2 | .gradle 3 | 4 | # Ignore Gradle build output directory 5 | build 6 | 7 | grammar/src/main/gen/ 8 | 9 | # Ignore IDE files 10 | .idea/ 11 | 12 | local.properties 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # KotlinEditor 2 | 3 | ## Version 0.18 4 | * [chore]: update antlr to 4.13.2 (latest). 5 | 6 | ## Version 0.17 7 | * [fix]: add plugins block below imports, if any. 8 | 9 | ## Version 0.16 10 | * [feat]: add DependenciesMutator for rewriting dependency strings. 11 | 12 | ## Version 0.15 13 | * [feat]: add `isChanged()` to avoid rewriting unchanged files. 14 | * [fix]: parameter can be null. 15 | * [fix]: add `@Throws(IllegalStateException::class)` on `DependenciesSimplifier` factories. 16 | * [fix]: check size before calling `single()`. Declare `IllegalArgumentException` too. 17 | * [fix]: be stricter about what is a dependencies block we can parse. 18 | * [fix]: handle project declaration without named arguments. 19 | 20 | ## Version 0.14 21 | * [feat]: support parsing more complex dependency declarations. 22 | * [feat]: add `DependenciesSimplifier` recipe. 23 | 24 | ## Version 0.13 25 | * [fix]: maintain terminal newline in `CommentsInBlockRemover`. 26 | 27 | ## Version 0.12 28 | * [fix]: make `CommentsInBlockRemover` expose found removable comments. 29 | 30 | ## Version 0.11 31 | * [feat]: add a utility class to remove comments from a block in a build script. 32 | 33 | ## Version 0.10 34 | * [feat]: expose `StatementContext` with parsed dependency declarations 35 | 36 | ## Version 0.9 37 | * [feat]: DependencyExtractor includes all statements in DependencyContainer. 38 | 39 | ## Version 0.8 40 | * [fix]: handle more kinds of dependency declarations on properties. 41 | 42 | ## Version 0.7 43 | * [fix]: handle dependency declarations on properties. 44 | 45 | ## Version 0.6q 46 | * [feat] support enforcedPlatform as a dependency capability. 47 | 48 | ## Version 0.5 49 | * [feat] support parsing `gradleApi()`-like dependency declarations. 50 | * [fix] improve error messages during parse errors. 51 | 52 | ## Version 0.4 53 | * [feat] improve support for parsing dependencies. 54 | 55 | ## Version 0.3 56 | * [feat] smarter indentation detection. 57 | * [feat] simplify terminal newline calculation. 58 | * [fix] fix trailing newline issue for kotlinFile. 59 | * [fix] rename exception to be more generic. 60 | 61 | ## Version 0.2 62 | * [feat] add support for terminal new lines in KotlinFileContext. 63 | 64 | ## Version 0.1 65 | 66 | First OSS release. 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![release](https://img.shields.io/maven-central/v/app.cash.kotlin-editor/core?label=release&color=blue)](https://central.sonatype.com/namespace/app.cash.kotlin-editor) 2 | [![snapshot](https://img.shields.io/nexus/s/app.cash.kotlin-editor/core?server=https%3A%2F%2Foss.sonatype.org&label=snapshot)](https://oss.sonatype.org/content/repositories/snapshots/app/cash/kotlin-editor/) 3 | [![main](https://github.com/cashapp/kotlin-editor/actions/workflows/push.yml/badge.svg)](https://github.com/cashapp/kotlin-editor/actions/workflows/push.yml) 4 | 5 | # KotlinEditor 6 | 7 | A library for parsing Kotlin source code into 8 | a [parse tree](https://en.wikipedia.org/wiki/Parse_tree) 9 | for semantic analysis, linting, and rewriting in-place. Based on the official 10 | [Kotlin grammar](https://kotlinlang.org/docs/reference/grammar.html). Supports normal Kotlin source, 11 | Kotlin scripts, and Gradle Kotlin DSL. 12 | 13 | ## Quick start 14 | 15 | Add the necessary dependencies: 16 | 17 | ```kotlin 18 | // build.gradle(.kts) 19 | dependencies { 20 | // Just the generated antlr Listener and Visitor implementations, based on the grammar 21 | implementation("app.cash.kotlin-editor:grammar:<>") 22 | // A set of models and utilities that make it easier to interact with parse trees 23 | implementation("app.cash.kotlin-editor:core:<>") 24 | } 25 | ``` 26 | 27 | Write a listener implementation that extends `KotlinParserBaseListener`. Here's a partial example 28 | from this repo that parses a Gradle Kotlin DSL build script and "normalizes" the plugin 29 | applications: 30 | 31 | ```kotlin 32 | // PluginNormalizer.kt 33 | class PluginNormalizer private constructor( 34 | private val input: CharStream, 35 | private val tokens: CommonTokenStream, 36 | private val parser: KotlinParser, 37 | private val errorListener: CollectingErrorListener, 38 | ) : KotlinParserBaseListener() { 39 | 40 | // TODO: implement various listener methods. See full implementation in `recipes/plugins/` 41 | 42 | companion object { 43 | fun of(buildScript: Path): PluginNormalizer { 44 | return of(Parser.readOnlyInputStream(buildScript)) 45 | } 46 | 47 | fun of(buildScript: InputStream): PluginNormalizer { 48 | val errorListener = CollectingErrorListener() 49 | 50 | return Parser( 51 | file = buildScript, 52 | errorListener = errorListener, 53 | listenerFactory = { input, tokens, parser -> 54 | PluginNormalizer( 55 | input = input, 56 | tokens = tokens, 57 | parser = parser, 58 | errorListener = errorListener, 59 | ) 60 | } 61 | ).listener() 62 | } 63 | } 64 | } 65 | ``` 66 | 67 | ## Making sense of parse trees 68 | 69 | Implementing anything interesting on top of the tools in this repo requires interacting with the 70 | parse trees that [antlr](https://www.antlr.org/) generates during a parse. The quickest way to get 71 | started with that is to visualize that parse tree. The instructions below explain how to do that. 72 | 73 | First, for IDEA users, ensure 74 | the [ANTLR v4 IDEA plugin](https://plugins.jetbrains.com/plugin/7358-antlr-v4) is installed. 75 | 76 | Next, with the plugin installed, there should be a little **ANTLR Preview** icon on your left 77 | sidebar. If it's not there, tap `shift-cmd-a` (on macOS) and type "antlr preview" and access it that 78 | way. Navigate to one of the grammar (`.g4`) files in `grammar/src/main/antlr`, such as 79 | `KotlinParser.g4`. Under "Input" on the left side of the tool window, paste in some Kotlin code (as 80 | simple or complex as you like). On the right, you can switch between the "Parse tree" and 81 | "Hierarchy" views, each of which are useful. "Profiler" is for profiling performance issues with 82 | your grammar, which should not be necessary. 83 | 84 | The **ANTLR Preview** tool seems to prefer to parse source by starting with the `kotlinFile` start 85 | rule. If you want it to parse your source as `script` instead, navigate to `KotlinParser.g4`, 86 | right-click on `script`, and select **Test Rule script** from the context menu. 87 | 88 | ## Project overview 89 | 90 | The project is split between three main components: 91 | 92 | 1. A grammar and the generated parser code, from the [ANTLR](https://www.antlr.org/) tool; 93 | 2. A "core" library with some high-level concepts build on the parse tree; and 94 | 3. A set of "recipes," that do something interesting to or with Kotlin source. These are meant to be 95 | used, and should also serve as examples. 96 | 97 | ### The grammar 98 | 99 | The grammar itself is broken into three components, one parser and two lexers: 100 | 101 | 1. The parser, `KotlinParser.g4`. 102 | 2. A lexer, `KotlinLexer.g4`. 103 | 3. Another lexer, `UnicodeClasses.g4`. 104 | 105 | These files were all originally borrowed from the 106 | [official Kotlin Grammar](https://kotlinlang.org/docs/reference/grammar.html). 107 | 108 | Note that the `.tokens` files are all generated by the `antlr` tool, but are checked into the main 109 | source set to make the IDE experience better. 110 | 111 | ### The recipes 112 | 113 | Some sample recipes are in the `recipes` directory. Feel free to contribute new recipes if you 114 | believe them to be generally useful; otherwise, simply treat this library as a normal dependency for 115 | your own projects. 116 | 117 | ### Gradle build scans 118 | 119 | This project is configured to publish build scans to the public 120 | [build scan service](https://scans.gradle.com/). Publication is disabled by default but can be 121 | enabled by creating a `local.properties` file with the following contents: 122 | 123 | ```properties 124 | kotlin.editor.build.scans.enable=true 125 | ``` 126 | 127 | This file should not be checked into version control. 128 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | Release procedure for KotlinEditor 2 | 3 | 1. Update CHANGELOG 4 | 1. Update README if needed 5 | 1. Bump version number in `gradle.properties` to next stable version (removing the `-SNAPSHOT` 6 | suffix). 7 | 1. `git commit -am "chore: prepare for release x.y." && git push` 8 | 1. Publish the snapshot to Maven Central by invoking the `publish` action on github. 9 | 1. `git tag -a vx.y -m "Version x.y."` 10 | 1. Update version number `gradle.properties` to next snapshot version (x.y-SNAPSHOT) 11 | 1. `git commit -am "chore: prepare next development version."` 12 | 1. `git push && git push --tags` 13 | -------------------------------------------------------------------------------- /build-logic/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.jvm) apply false 3 | alias(libs.plugins.dependencyAnalysis) 4 | } 5 | -------------------------------------------------------------------------------- /build-logic/conventions/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | id("java-gradle-plugin") 5 | alias(libs.plugins.kotlin.jvm) 6 | alias(libs.plugins.dependencyAnalysis) 7 | } 8 | 9 | gradlePlugin { 10 | plugins { 11 | create("lib") { 12 | id = "cash.lib" 13 | implementationClass = "com.squareup.gradle.LibraryConventionPlugin" 14 | } 15 | create("grammar") { 16 | id = "cash.grammar" 17 | implementationClass = "com.squareup.gradle.GrammarConventionPlugin" 18 | } 19 | create("settings") { 20 | id = "cash.settings" 21 | implementationClass = "com.squareup.gradle.SettingsPlugin" 22 | } 23 | } 24 | } 25 | 26 | kotlin { 27 | explicitApi() 28 | } 29 | 30 | dependencies { 31 | api(libs.kotlinStdLib) 32 | 33 | implementation(libs.dependencyAnalysisPlugin) 34 | implementation(libs.develocityPlugin) 35 | implementation(libs.kotlinGradlePlugin) 36 | implementation(libs.kotlinGradlePluginApi) 37 | implementation(libs.mavenPublish) 38 | } 39 | 40 | val javaTarget = JavaLanguageVersion.of(libs.versions.java.get()) 41 | 42 | java { 43 | toolchain { 44 | languageVersion = javaTarget 45 | } 46 | } 47 | 48 | tasks.withType { 49 | options.release.set(javaTarget.asInt()) 50 | } 51 | 52 | tasks.withType { 53 | kotlinOptions { 54 | jvmTarget = javaTarget.toString() 55 | } 56 | } 57 | 58 | tasks.named("test") { 59 | useJUnitPlatform() 60 | } 61 | -------------------------------------------------------------------------------- /build-logic/conventions/src/main/kotlin/com/squareup/gradle/BasePlugin.kt: -------------------------------------------------------------------------------- 1 | package com.squareup.gradle 2 | 3 | import com.squareup.gradle.utils.DependencyCatalog 4 | import com.vanniktech.maven.publish.MavenPublishBaseExtension 5 | import com.vanniktech.maven.publish.SonatypeHost 6 | import org.gradle.api.Project 7 | import org.gradle.api.tasks.compile.JavaCompile 8 | import org.gradle.jvm.toolchain.JavaLanguageVersion 9 | import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile 10 | 11 | internal class BasePlugin(private val project: Project) { 12 | 13 | private val versionCatalog = DependencyCatalog(project).catalog 14 | 15 | fun apply(): Unit = project.run { 16 | pluginManager.run { 17 | apply("java-library") 18 | apply("com.vanniktech.maven.publish") 19 | apply("com.autonomousapps.dependency-analysis") 20 | } 21 | 22 | // See gradle.properties 23 | group = providers.gradleProperty("GROUP").get() 24 | version = providers.gradleProperty("VERSION").get() 25 | 26 | configureJvmTarget() 27 | configurePublishing() 28 | } 29 | 30 | private fun Project.configureJvmTarget() { 31 | val javaVersion = JavaLanguageVersion.of( 32 | versionCatalog.findVersion("java").orElseThrow().requiredVersion 33 | ) 34 | 35 | tasks.withType(JavaCompile::class.java).configureEach { 36 | it.options.release.set(javaVersion.asInt()) 37 | } 38 | 39 | pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { 40 | tasks.withType(KotlinJvmCompile::class.java).configureEach { 41 | it.kotlinOptions { 42 | jvmTarget = javaVersion.toString() 43 | } 44 | } 45 | } 46 | } 47 | 48 | private fun Project.configurePublishing() { 49 | extensions.getByType(MavenPublishBaseExtension::class.java).run { 50 | publishToMavenCentral(SonatypeHost.DEFAULT, automaticRelease = true) 51 | signAllPublications() 52 | 53 | pom { p -> 54 | p.name.set(project.name) 55 | p.description.set("A library for parsing, rewriting, and linting Kotlin source code") 56 | p.inceptionYear.set("2024") 57 | p.url.set("https://github.com/cashapp/kotlin-editor") 58 | 59 | p.licenses { licenses -> 60 | licenses.license { l -> 61 | l.name.set("The Apache Software License, Version 2.0") 62 | l.url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") 63 | l.distribution.set("repo") 64 | } 65 | } 66 | p.developers { devs -> 67 | devs.developer { d -> 68 | d.id.set("cashapp") 69 | d.name.set("Cash App") 70 | d.url.set("https://github.com/cashapp") 71 | } 72 | } 73 | p.scm { scm -> 74 | scm.url.set("https://github.com/cashapp/kotlin-editor") 75 | scm.connection.set("scm:git:git://github.com/cashapp/kotlin-editor.git") 76 | scm.developerConnection.set("scm:git:ssh://git@github.com/cashapp/kotlin-editor.git") 77 | } 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /build-logic/conventions/src/main/kotlin/com/squareup/gradle/GrammarConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | package com.squareup.gradle 2 | 3 | import com.squareup.gradle.utils.DependencyCatalog 4 | import org.gradle.api.Plugin 5 | import org.gradle.api.Project 6 | import org.gradle.api.artifacts.VersionCatalog 7 | import org.gradle.api.plugins.antlr.AntlrTask 8 | import org.gradle.api.tasks.Copy 9 | import org.gradle.api.tasks.SourceSetContainer 10 | import org.gradle.jvm.tasks.Jar 11 | 12 | @Suppress("unused") 13 | public class GrammarConventionPlugin : Plugin { 14 | 15 | private lateinit var versionCatalog: VersionCatalog 16 | 17 | override fun apply(target: Project): Unit = target.run { 18 | pluginManager.run { 19 | apply("antlr") 20 | } 21 | BasePlugin(this).apply() 22 | 23 | versionCatalog = DependencyCatalog(this).catalog 24 | 25 | configureAntlr() 26 | } 27 | 28 | private fun Project.configureAntlr() { 29 | val taskGroup = "grammar" 30 | 31 | val pkg = "com.squareup.cash.grammar" 32 | val dir = pkg.replace('.', '/') 33 | val antlrOutput = layout.buildDirectory.dir("generated-src/antlr/main/$dir") 34 | val antlrSrc = "src/main/antlr/$dir" 35 | 36 | val copyAntlrTokens = tasks.register("copyAntlrTokens", Copy::class.java) { t -> 37 | t.group = taskGroup 38 | t.description = 39 | "Copies grammar tokens from build dir into main source for better IDE experience" 40 | 41 | t.from(antlrOutput) { 42 | it.include("*.tokens") 43 | } 44 | t.into(antlrSrc) 45 | } 46 | 47 | val generateGrammarSource = tasks.named("generateGrammarSource", AntlrTask::class.java) { t -> 48 | t.group = taskGroup 49 | t.description = "Generates Java listener and visitor source from .g4 files" 50 | 51 | // the IDE complains if the .tokens files aren't in the main source dir. This isn't necessary for 52 | // builds to succeed, but it is necessary for a good IDE experience. 53 | t.finalizedBy(copyAntlrTokens) 54 | 55 | t.outputDirectory = file(layout.buildDirectory.dir("generated-src/antlr/main/$dir")) 56 | t.arguments = t.arguments + listOf( 57 | // Specify the package declaration for generated Java source 58 | "-package", pkg, 59 | // Specify that generated Java source should go into the outputDirectory, regardless of package structure 60 | "-Xexact-output-dir", 61 | // Specify the location of "libs"; i.e., for grammars composed of multiple files 62 | "-lib", antlrSrc, 63 | // We want visitors alongside listeners 64 | "-visitor", 65 | ) 66 | } 67 | 68 | val generateTestGrammarSource = tasks.named("generateTestGrammarSource") 69 | 70 | // TODO(tsr): There is probably a better way to do this. 71 | // Even though we're excluding the token files from the source jar, we still need to specify the 72 | // task dependency to satisfy Gradle. 73 | // nb: the sourcesJar task must be getting added in an afterEvaluate from the mavenPublish 74 | // plugin, so I have to use this lazy approach to configure it. 75 | tasks.withType(Jar::class.java).named { it == "sourcesJar" }.configureEach { t -> 76 | t.dependsOn(copyAntlrTokens) 77 | t.exclude("**/*.tokens") 78 | } 79 | 80 | // Workaround for https://github.com/gradle/gradle/issues/19555#issuecomment-1593252653 81 | extensions.getByType(SourceSetContainer::class.java).run { 82 | getAt("main").java.srcDir(generateGrammarSource.map { files() }) 83 | getAt("test").java.srcDir(generateTestGrammarSource.map { files() }) 84 | } 85 | 86 | // Excluding icu4j because it bloats artifact size significantly 87 | configurations 88 | .getByName("runtimeClasspath") 89 | .exclude(mapOf("group" to "com.ibm.icu", "module" to "icu4j")) 90 | 91 | dependencies.run { 92 | add("antlr", versionCatalog.findLibrary("antlr.core").orElseThrow()) 93 | add("runtimeOnly", versionCatalog.findLibrary("antlr.runtime").orElseThrow()) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /build-logic/conventions/src/main/kotlin/com/squareup/gradle/LibraryConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | package com.squareup.gradle 2 | 3 | import com.autonomousapps.DependencyAnalysisSubExtension 4 | import com.squareup.gradle.utils.DependencyCatalog 5 | import org.gradle.api.Plugin 6 | import org.gradle.api.Project 7 | import org.gradle.api.artifacts.VersionCatalog 8 | import org.gradle.api.tasks.testing.Test 9 | import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension 10 | 11 | @Suppress("unused") 12 | public class LibraryConventionPlugin : Plugin { 13 | 14 | private lateinit var versionCatalog: VersionCatalog 15 | 16 | override fun apply(target: Project): Unit = target.run { 17 | pluginManager.run { 18 | apply("org.jetbrains.kotlin.jvm") 19 | } 20 | BasePlugin(this).apply() 21 | 22 | versionCatalog = DependencyCatalog(this).catalog 23 | 24 | configureTests() 25 | configureKotlin() 26 | } 27 | 28 | private fun Project.configureKotlin() { 29 | extensions.getByType(KotlinJvmProjectExtension::class.java).run { 30 | explicitApi() 31 | } 32 | } 33 | 34 | private fun Project.configureTests() { 35 | tasks.withType(Test::class.java).configureEach { 36 | it.useJUnitPlatform() 37 | } 38 | 39 | dependencies.run { 40 | add("testImplementation", versionCatalog.findLibrary("assertj").orElseThrow()) 41 | add("testImplementation", versionCatalog.findLibrary("junit.jupiter.api").orElseThrow()) 42 | add("testRuntimeOnly", versionCatalog.findLibrary("junit.jupiter.engine").orElseThrow()) 43 | } 44 | 45 | // These default dependencies are added to all library modules. Don't warn/fail if they're not 46 | // used. 47 | extensions.getByType(DependencyAnalysisSubExtension::class.java).run { 48 | issues { issueHandler -> 49 | issueHandler.onUnusedDependencies { issue -> 50 | issue.exclude( 51 | versionCatalog.findLibrary("assertj").orElseThrow(), 52 | versionCatalog.findLibrary("junit.jupiter.api").orElseThrow(), 53 | ) 54 | } 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /build-logic/conventions/src/main/kotlin/com/squareup/gradle/SettingsPlugin.kt: -------------------------------------------------------------------------------- 1 | package com.squareup.gradle 2 | 3 | import org.gradle.api.Plugin 4 | import org.gradle.api.initialization.Settings 5 | import org.gradle.kotlin.dsl.develocity 6 | import java.util.Properties 7 | 8 | @Suppress("UnstableApiUsage") 9 | public abstract class SettingsPlugin : Plugin { 10 | 11 | override fun apply(target: Settings): Unit = target.run { 12 | pluginManager.apply("com.gradle.develocity") 13 | 14 | val shouldPublish = shouldPublishBuildScans() 15 | 16 | develocity { 17 | buildScan { 18 | it.termsOfUseUrl.set("https://gradle.com/help/legal-terms-of-use") 19 | it.termsOfUseAgree.set("yes") 20 | it.publishing.onlyIf { shouldPublish } 21 | } 22 | } 23 | } 24 | 25 | private fun Settings.shouldPublishBuildScans(): Boolean { 26 | val localProperties = Properties() 27 | var publishBuildScans = false 28 | 29 | val localPropertiesFile = layout.settingsDirectory.file("local.properties").asFile 30 | if (localPropertiesFile.exists()) { 31 | localPropertiesFile.inputStream().use { 32 | localProperties.load(it) 33 | } 34 | 35 | publishBuildScans = localProperties.getProperty("kotlin.editor.build.scans.enable") 36 | ?.toBoolean() 37 | ?: false 38 | } 39 | 40 | return publishBuildScans 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /build-logic/conventions/src/main/kotlin/com/squareup/gradle/utils/DependencyCatalog.kt: -------------------------------------------------------------------------------- 1 | package com.squareup.gradle.utils 2 | 3 | import org.gradle.api.Project 4 | import org.gradle.api.artifacts.VersionCatalog 5 | import org.gradle.api.artifacts.VersionCatalogsExtension 6 | 7 | internal class DependencyCatalog(project: Project) { 8 | 9 | val catalog: VersionCatalog = project 10 | .extensions 11 | .getByType(VersionCatalogsExtension::class.java) 12 | .named("libs") 13 | } 14 | -------------------------------------------------------------------------------- /build-logic/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:MaxMetaspaceSize=1024m 2 | 3 | org.gradle.caching=true 4 | org.gradle.parallel=true 5 | org.gradle.configuration-cache=true 6 | 7 | dependency.analysis.print.build.health=true 8 | 9 | # https://kotlinlang.org/docs/gradle-configure-project.html#dependency-on-the-standard-library 10 | kotlin.stdlib.default.dependency=false 11 | -------------------------------------------------------------------------------- /build-logic/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "build-logic" 2 | 3 | pluginManagement { 4 | repositories { 5 | mavenCentral() 6 | gradlePluginPortal() 7 | } 8 | } 9 | 10 | plugins { 11 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" 12 | } 13 | 14 | dependencyResolutionManagement { 15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 16 | repositories { 17 | mavenCentral() 18 | gradlePluginPortal() 19 | } 20 | 21 | versionCatalogs { 22 | create("libs") { 23 | from(files("../gradle/libs.versions.toml")) 24 | } 25 | } 26 | } 27 | 28 | include("conventions") 29 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.autonomousapps.dependency-analysis") 3 | } 4 | 5 | // https://github.com/autonomousapps/dependency-analysis-gradle-plugin/wiki 6 | dependencyAnalysis { 7 | issues { 8 | all { 9 | onAny { 10 | severity("fail") 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("cash.lib") 3 | } 4 | 5 | dependencies { 6 | api(project(":grammar")) 7 | 8 | api(libs.antlr.runtime) 9 | api(libs.kotlinStdLib) 10 | 11 | testImplementation(libs.junit.jupiter.params) 12 | } 13 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/model/Assignment.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.model 2 | 3 | import com.squareup.cash.grammar.KotlinParser.AssignmentContext 4 | import org.antlr.v4.runtime.ParserRuleContext 5 | 6 | /** 7 | * Represents an assignment statement within a Gradle build script. 8 | * An assignment statement is any statement that includes an "=" operator to set a value. 9 | * 10 | * ## Examples: 11 | * - Top-level assignment: 12 | * ``` 13 | * projectName = "project-a" 14 | * ``` 15 | * 16 | * - Assignment within [cash.grammar.kotlindsl.utils.Blocks]: 17 | * ``` 18 | * foo { 19 | * projectName = "project-a" 20 | * } 21 | * ``` 22 | * 23 | * @property id the identifier on the left side of the "=" operator. 24 | * @property value the value being assigned to the identifier. 25 | */ 26 | public data class Assignment( 27 | public val id: String, 28 | public val value: String 29 | ) { 30 | 31 | /** 32 | * For example, `publishToArtifactory = true` 33 | */ 34 | public fun asString(): String = buildString { 35 | append(id) 36 | append(" = ") 37 | append(value) 38 | } 39 | 40 | public companion object { 41 | /** 42 | * Extracts an [Assignment] from the specified [line] if it represents an assignment within a block. 43 | * 44 | * ## Example: 45 | * ``` 46 | * foo { 47 | * projectName = "project-a" 48 | * } 49 | * ``` 50 | */ 51 | public fun extractFromBlock(line: ParserRuleContext): Assignment? { 52 | when (line) { 53 | is AssignmentContext -> { 54 | val identifierId = line.directlyAssignableExpression().simpleIdentifier().Identifier().text 55 | val identifierValue = line.expression().text 56 | 57 | return Assignment(identifierId, identifierValue) 58 | } 59 | else -> return null 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/model/DependencyDeclaration.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.model 2 | 3 | /** 4 | * Example dependency declarations: 5 | * ``` 6 | * // External module dependencies 7 | * classpath(libs.foo) 8 | * classpath(platform(libs.foo)) 9 | * classpath(testFixtures(libs.foo)) 10 | * 11 | * // Project dependencies 12 | * classpath(project(":path")) 13 | * classpath(platform(project(":path"))) 14 | * classpath(testFixtures(project(":path"))) 15 | * ``` 16 | * 17 | * Where "classpath" could be any [configuration] name whatsoever, including (for example; this is not 18 | * exhaustive): 19 | * 1. `classpath` 20 | * 2. `implementation` 21 | * 3. `api` 22 | * 4. `testRuntimeOnly` 23 | * 5. etc. etc. 24 | * 25 | * [identifier] corresponds to the name or base coordinates of the dependency, e.g.: 26 | * 1. libs.foo 27 | * 2. ":path" 28 | * 29 | * [capability] corresponds to the dependency's 30 | * [capability](https://docs.gradle.org/current/userguide/component_capabilities.html), in Gradle 31 | * terms, and can be one of three kinds. See [Capability]. 32 | * 33 | * Finally we have [type], which tells us whether this dependency declaration is for an internal 34 | * project declaration, an external module declaration, or a local file dependency. See [Type] and 35 | * [ModuleDependency](https://docs.gradle.org/current/javadoc/org/gradle/api/artifacts/ModuleDependency.html). 36 | */ 37 | public data class DependencyDeclaration( 38 | // This is the configuration (required) this dependency is declared on 39 | val configuration: String, 40 | val identifier: Identifier, 41 | val capability: Capability, 42 | val type: Type, 43 | val fullText: String, 44 | // This is the configuration (optional) that the producer publishes on 45 | val producerConfiguration: String? = null, 46 | val classifier: String? = null, 47 | val ext: String? = null, 48 | val precedingComment: String? = null, 49 | // A complex declaration will use the `implementation(name = ..., group = ..., [etc])` form 50 | val isComplex: Boolean = false, 51 | ) { 52 | 53 | public data class Identifier @JvmOverloads constructor( 54 | public val path: String, 55 | public val configuration: String? = null, 56 | public val explicitPath: Boolean = false, 57 | ) { 58 | 59 | // A helper class for use during parsing 60 | internal class IdentifierElement( 61 | val value: String, 62 | val isStringLiteral: Boolean, 63 | ) 64 | 65 | /** 66 | * ``` 67 | * 1. "g:a:v" 68 | * 2. path = "g:a:v" 69 | * 3. path = "g:a:v", configuration = "foo" 70 | * 4. "g:a:v", configuration = "foo" 71 | * ``` 72 | */ 73 | override fun toString(): String = buildString { 74 | if (explicitPath) { 75 | append("path = ") 76 | } 77 | 78 | append(path) 79 | 80 | if (configuration != null) { 81 | append(", configuration = ") 82 | append(configuration) 83 | } 84 | } 85 | 86 | internal companion object { 87 | fun String?.asSimpleIdentifier(): Identifier? { 88 | return if (this != null) { 89 | Identifier(path = this) 90 | } else { 91 | null 92 | } 93 | } 94 | } 95 | } 96 | 97 | /** 98 | * @see Component Capabilities 99 | */ 100 | public enum class Capability { 101 | DEFAULT, 102 | ENFORCED_PLATFORM, 103 | PLATFORM, 104 | TEST_FIXTURES, 105 | ; 106 | 107 | public companion object { 108 | private val capabilities = listOf("testFixtures", "enforcedPlatform", "platform") 109 | 110 | public fun isCapability(value: String): Boolean = value in capabilities 111 | 112 | public fun of(value: String): Capability { 113 | return when (value) { 114 | "testFixtures" -> TEST_FIXTURES 115 | "enforcedPlatform" -> ENFORCED_PLATFORM 116 | "platform" -> PLATFORM 117 | else -> error("Unrecognized capability: '$value'. Expected one of '$capabilities'.") 118 | } 119 | } 120 | } 121 | } 122 | 123 | /** 124 | * @see ModuleDependency 125 | */ 126 | public enum class Type { 127 | FILE, 128 | FILES, 129 | FILE_TREE, 130 | GRADLE_DISTRIBUTION, 131 | MODULE, 132 | PROJECT, 133 | ; 134 | 135 | public fun or(identifier: Identifier): Type { 136 | return if (identifier.path in GRADLE_DISTRIBUTIONS) { 137 | GRADLE_DISTRIBUTION 138 | } else { 139 | // In this case, might just be a user-supplied function that returns a dependency declaration 140 | this 141 | } 142 | } 143 | 144 | private companion object { 145 | /** Well-known dependencies available directly from the local Gradle distribution. */ 146 | val GRADLE_DISTRIBUTIONS = listOf("gradleApi()", "gradleTestKit()", "localGroovy()") 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/model/DependencyElement.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.model 2 | 3 | import com.squareup.cash.grammar.KotlinParser.StatementContext 4 | 5 | /** 6 | * Base class representing an element within the `dependencies` block. 7 | * 8 | * @property statement The context of the statement in the dependencies block. 9 | */ 10 | public sealed class DependencyElement(public open val statement: StatementContext) 11 | 12 | /** 13 | * Represents a dependency declaration within the `dependencies` block. 14 | * 15 | * @property declaration The parsed dependency declaration. 16 | * @property statement The context of the statement in the dependencies block. 17 | */ 18 | public data class DependencyDeclarationElement( 19 | val declaration: DependencyDeclaration, 20 | override val statement: StatementContext 21 | ) : DependencyElement(statement) 22 | 23 | /** 24 | * Represents a statement within the `dependencies` block that is **not** a dependency declaration. 25 | * 26 | * @property statement The context of the statement within the dependencies block. 27 | */ 28 | public data class NonDependencyDeclarationElement(override val statement: StatementContext) : DependencyElement(statement) 29 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/model/Plugin.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.model 2 | 3 | public data class Plugin( 4 | public val type: Type, 5 | public val id: String, 6 | public val version: String? = null, 7 | public val applied: Boolean = true, 8 | ) { 9 | 10 | public fun asIdString(): String? { 11 | return when (type) { 12 | Type.BLOCK_SIMPLE -> id 13 | Type.BLOCK_BACKTICK -> "`$id`" 14 | Type.BLOCK_KOTLIN -> "kotlin(\"$id\")" 15 | Type.BLOCK_ID -> "id(\"$id\")" 16 | Type.BLOCK_ALIAS -> "alias($id)" 17 | Type.APPLY -> null // Not part of plugin block 18 | } 19 | } 20 | 21 | public enum class Type { 22 | /** 23 | * Plugin was applied to a script like 24 | * ``` 25 | * apply(plugin = "a-plugin") 26 | * ``` 27 | * or 28 | * ``` 29 | * apply(mapOf("plugin" to "a-plugin")) 30 | * ``` 31 | * 32 | * It's [Plugin.id] in either case would be `"a-plugin"`. 33 | */ 34 | APPLY, 35 | 36 | /** 37 | * Plugin was applied to a script like 38 | * ``` 39 | * plugins { 40 | * alias(libs.plugins.by.alias) 41 | * } 42 | * ``` 43 | * It's [Plugin.id] in this case would be `"libs.plugins.by.alias"`. 44 | */ 45 | BLOCK_ALIAS, 46 | 47 | /** 48 | * Plugin was applied to a script like 49 | * ``` 50 | * plugins { 51 | * `kotlin-dsl` 52 | * } 53 | * ``` 54 | * It's [Plugin.id] in this case would be `kotlin-dsl`. 55 | */ 56 | BLOCK_BACKTICK, 57 | 58 | /** 59 | * Plugin was applied to a script like 60 | * ``` 61 | * plugins { 62 | * id("a-plugin") 63 | * } 64 | * ``` 65 | * It's [Plugin.id] in this case would be `"a-plugin"`. 66 | */ 67 | BLOCK_ID, 68 | 69 | /** 70 | * Plugin was applied to a script like 71 | * ``` 72 | * plugins { 73 | * kotlin("jvm") 74 | * } 75 | * ``` 76 | * It's [Plugin.id] in this case would be `"jvm"`. 77 | */ 78 | BLOCK_KOTLIN, 79 | 80 | /** 81 | * Plugin was applied to a script like 82 | * ``` 83 | * plugins { 84 | * application 85 | * } 86 | * ``` 87 | * It's [Plugin.id] in this case would be `application`. 88 | */ 89 | BLOCK_SIMPLE, 90 | ; 91 | 92 | public companion object { 93 | /** 94 | * Returns the plugin "type" from [value]. Available types are: 95 | * * [APPLY] -> Plugins that are applied via `apply(plugin = "...")` 96 | * * [BLOCK_ID] -> Plugins that are applied like `plugins { id("...") }` 97 | * * [BLOCK_KOTLIN] -> Kotlin plugins that are applied like `plugins { kotlin("jvm") }` 98 | * 99 | * Note that the following cannot be produced via this method: 100 | * * [BLOCK_BACKTICK] -> Plugins that are applied like `plugins { `kotlin-dsl` }` 101 | * * [BLOCK_SIMPLE] -> Core plugins that are applied like `plugins { application }` 102 | * 103 | * [PluginExtractor][cash.grammar.kotlindsl.utils.PluginExtractor] can create valid types 104 | * in the right parser rule contexts. 105 | */ 106 | public fun of(value: String): Type { 107 | return when (value) { 108 | "apply" -> APPLY 109 | "alias" -> BLOCK_ALIAS 110 | "id" -> BLOCK_ID 111 | "kotlin" -> BLOCK_KOTLIN 112 | // TODO BLOCK_BACKTICK, BLOCK_SIMPLE 113 | else -> { 114 | val supportedTypes = listOf("apply", "alias", "id", "kotlin") 115 | error("Unknown plugin type. Was '$value'. Expected one of $supportedTypes") 116 | } 117 | } 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/model/RemovableBlock.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.model 2 | 3 | /** 4 | * Represents a block that can be removed. 5 | */ 6 | public sealed class RemovableBlock { 7 | /** 8 | * Represents a simple block with a name. 9 | * 10 | * Usage example: 11 | * ``` 12 | * blockName { 13 | * // Block content 14 | * } 15 | * ``` 16 | * 17 | * @property name The name of the simple block. 18 | */ 19 | public data class SimpleBlock(val name: String) : RemovableBlock() 20 | 21 | /** 22 | * Represents a task configuration block that supports both Kotlin DSL and Groovy DSL. 23 | * 24 | * This block can be used to exclude specific tasks from the build process. 25 | * 26 | * Usage example for Kotlin DSL: 27 | * ``` 28 | * tasks.withType { 29 | * exclude { it.file.path.contains("/build/") } 30 | * } 31 | * ``` 32 | * 33 | * Usage example for Groovy DSL: 34 | * ``` 35 | * tasks.withType(org.jmailen.gradle.kotlinter.tasks.LintTask) { 36 | * exclude { it.file.path.contains("/build/") } 37 | * } 38 | * ``` 39 | * 40 | * @property type The type of task to be configured. This should be the value between `<>` in Kotlin DSL and `()` in Groovy DSL. 41 | */ 42 | public data class TaskWithTypeBlock(val type: String): RemovableBlock() 43 | } 44 | 45 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/model/gradle/DependencyContainer.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.model.gradle 2 | 3 | import cash.grammar.kotlindsl.model.* 4 | import com.squareup.cash.grammar.KotlinParser.StatementContext 5 | 6 | /** 7 | * A container for all the [Statements][com.squareup.cash.grammar.KotlinParser.StatementsContext] in 8 | * a `dependencies` block in a Gradle build script. These statements are an ordered (not sorted!) 9 | * list of statements, each classified as a [DependencyElement] 10 | * 11 | * Each statement in this container is classified as a [DependencyElement], which can represent either: 12 | * - A parsed [DependencyDeclaration][cash.grammar.kotlindsl.model.DependencyDeclaration] element, or 13 | * - A non-dependency declaration statement, retained as-is. 14 | */ 15 | public class DependencyContainer( 16 | /** The ordered list of [DependencyElement] instances, representing each classified statement within the `dependencies` block. */ 17 | public val elements: List, 18 | ) { 19 | 20 | public fun getDependencyDeclarationsWithContext(): List { 21 | return elements.filterIsInstance() 22 | } 23 | 24 | public fun getDependencyDeclarations(): List { 25 | return getDependencyDeclarationsWithContext().map { it.declaration } 26 | } 27 | 28 | /** 29 | * Get non-dependency declaration statements. 30 | * 31 | * Might include an [if-expression][com.squareup.cash.grammar.KotlinParser.IfExpressionContext] like 32 | * ``` 33 | * if (functionReturningABoolean()) { ... } 34 | * ``` 35 | * or a [property declaration][com.squareup.cash.grammar.KotlinParser.PropertyDeclarationContext] like 36 | * ``` 37 | * val string = "a:complex:$value" 38 | * ``` 39 | * or a common example of an expression in a dependencies block like 40 | * ``` 41 | * add("extraImplementation", "com.foo:bar:1.0") 42 | * ``` 43 | */ 44 | public fun getStatements(): List { 45 | return elements.filterIsInstance().map { it.statement } 46 | } 47 | 48 | internal companion object { 49 | val EMPTY = DependencyContainer(emptyList()) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/parse/KotlinParseException.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.parse 2 | 3 | public class KotlinParseException private constructor(msg: String) : RuntimeException(msg) { 4 | 5 | public companion object { 6 | public fun withErrors(messages: List): KotlinParseException { 7 | var i = 1 8 | val msg = messages.joinToString { "${i++}: $it" } 9 | return KotlinParseException(msg) 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/parse/Mutator.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.parse 2 | 3 | import cash.grammar.kotlindsl.utils.CollectingErrorListener 4 | import cash.grammar.kotlindsl.utils.Whitespace 5 | import cash.grammar.kotlindsl.utils.Whitespace.trimGently 6 | import cash.grammar.utils.ifNotEmpty 7 | import com.squareup.cash.grammar.KotlinParserBaseListener 8 | import org.antlr.v4.runtime.CharStream 9 | import org.antlr.v4.runtime.CommonTokenStream 10 | 11 | /** 12 | * Subclass of [KotlinParserBaseListener] with an additional contract. 13 | */ 14 | public abstract class Mutator( 15 | protected val input: CharStream, 16 | protected val tokens: CommonTokenStream, 17 | protected val errorListener: CollectingErrorListener, 18 | ) : KotlinParserBaseListener() { 19 | 20 | protected val rewriter: Rewriter = Rewriter(tokens) 21 | protected val terminalNewlines: Int = Whitespace.countTerminalNewlines(tokens) 22 | protected val indent: String = Whitespace.computeIndent(tokens, input) 23 | 24 | /** Returns `true` if this mutator will make semantic changes to a file. */ 25 | public abstract fun isChanged(): Boolean 26 | 27 | /** 28 | * Returns the new content of the file. Will contain semantic differences if and only if [isChanged] is true. 29 | */ 30 | @Throws(KotlinParseException::class) 31 | public fun rewritten(): String { 32 | // TODO: check value of isChanged 33 | 34 | errorListener.getErrorMessages().ifNotEmpty { 35 | throw KotlinParseException.withErrors(it) 36 | } 37 | 38 | return rewriter.text.trimGently(terminalNewlines) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/parse/Parser.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.parse 2 | 3 | import com.squareup.cash.grammar.KotlinLexer 4 | import com.squareup.cash.grammar.KotlinParser 5 | import com.squareup.cash.grammar.KotlinParserBaseListener 6 | import com.squareup.cash.grammar.KotlinParserListener 7 | import org.antlr.v4.runtime.CharStream 8 | import org.antlr.v4.runtime.CharStreams 9 | import org.antlr.v4.runtime.CommonTokenStream 10 | import org.antlr.v4.runtime.ParserRuleContext 11 | import org.antlr.v4.runtime.tree.ParseTreeWalker 12 | import java.io.InputStream 13 | import java.nio.file.Files 14 | import java.nio.file.Path 15 | import java.nio.file.StandardOpenOption 16 | 17 | /** 18 | * Create from an [InputStream], which is the primary type this parser operates on. 19 | */ 20 | public class Parser( 21 | /** 22 | * The source file to parse. This stream is immediately read and closed. 23 | */ 24 | file: InputStream, 25 | 26 | /** 27 | * An error listener for use during source parsing. 28 | */ 29 | private val errorListener: SimpleErrorListener = SimpleErrorListener(), 30 | 31 | /** 32 | * Specify the start rule. [KotlinParser.script] is used by default. The most common alternative 33 | * would be [KotlinParser.kotlinFile]. 34 | */ 35 | private val startRule: (KotlinParser) -> ParserRuleContext = { it.script() }, 36 | 37 | /** 38 | * A factory for generating your custom [KotlinParserListener], typically a 39 | * [KotlinParserBaseListener]. 40 | */ 41 | private val listenerFactory: (CharStream, CommonTokenStream, KotlinParser) -> T, 42 | ) { 43 | 44 | /** 45 | * Create from a [Path], which is immediately converted into an [InputStream]. 46 | */ 47 | public constructor( 48 | file: Path, 49 | errorListener: SimpleErrorListener, 50 | listenerFactory: (CharStream, CommonTokenStream, KotlinParser) -> T, 51 | ) : this( 52 | file = Files.newInputStream(file, StandardOpenOption.READ), 53 | errorListener = errorListener, 54 | listenerFactory = listenerFactory, 55 | ) 56 | 57 | /** 58 | * Create from a [String], which is immediately converted into an [InputStream]. 59 | */ 60 | public constructor( 61 | file: String, 62 | errorListener: SimpleErrorListener, 63 | listenerFactory: (CharStream, CommonTokenStream, KotlinParser) -> T, 64 | ) : this( 65 | file = file.byteInputStream(), 66 | errorListener = errorListener, 67 | listenerFactory = listenerFactory, 68 | ) 69 | 70 | // Immediately read and close the stream 71 | private val input: CharStream = file.use { 72 | CharStreams.fromStream(it) 73 | } 74 | 75 | /** 76 | * Returns a new [KotlinParserListener], a subtype of 77 | * [ParseTreeListener][org.antlr.v4.runtime.tree.ParseTreeListener]. 78 | * 79 | * @see ANTLR Listeners. 80 | */ 81 | public fun listener(): T { 82 | val lexer = KotlinLexer(input) 83 | val tokens = CommonTokenStream(lexer) 84 | val parser = KotlinParser(tokens) 85 | 86 | // Remove default error listeners to prevent insane console output 87 | lexer.removeErrorListeners() 88 | parser.removeErrorListeners() 89 | 90 | lexer.addErrorListener(errorListener) 91 | parser.addErrorListener(errorListener) 92 | 93 | val walker = ParseTreeWalker() 94 | val tree = startRule(parser) 95 | val listener = listenerFactory(input, tokens, parser) 96 | walker.walk(listener, tree) 97 | 98 | return listener 99 | } 100 | 101 | public companion object { 102 | /** Creates an [InputStream] from [file] using [Files.newInputStream]. */ 103 | public fun readOnlyInputStream(file: Path): InputStream { 104 | return Files.newInputStream(file, StandardOpenOption.READ) 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/parse/Rewriter.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.parse 2 | 3 | import cash.grammar.kotlindsl.utils.Whitespace 4 | import com.squareup.cash.grammar.KotlinLexer 5 | import org.antlr.v4.runtime.CommonTokenStream 6 | import org.antlr.v4.runtime.Token 7 | import org.antlr.v4.runtime.TokenStreamRewriter 8 | 9 | /** 10 | * A subclass of ANTLR's TokenStreamRewriter that provides additional functionality. 11 | * 12 | * Note that as with the [Whitespace] helper class, this class makes a distinction between "white space" 13 | * and "blank space". Refer to the documentation on [Whitespace] for more details. 14 | */ 15 | public class Rewriter( 16 | private val commonTokens: CommonTokenStream 17 | ) : TokenStreamRewriter(commonTokens) { 18 | 19 | /** 20 | * Deletes all comments and whitespace (including newlines) "to the left of" [before]. 21 | * 22 | * This is a complicated process because there can be a mix of whitespace, newlines (not 23 | * considered "whitespace" in this context), and comments, and we want to delete exactly as much 24 | * as necessary to "delete the line" -- nothing more, nothing less. 25 | * 26 | * @return deleted comment tokens 27 | */ 28 | public fun deleteCommentsAndBlankSpaceToLeft( 29 | before: Token, 30 | ): List? { 31 | var comments = deleteCommentsToLeft(before) 32 | 33 | val ws = Whitespace.getBlankSpaceToLeft(commonTokens, before).onEach { 34 | delete(it) 35 | } 36 | 37 | if (comments == null && ws.isNotEmpty()) { 38 | comments = deleteCommentsToLeft(ws.last()) 39 | } 40 | 41 | // TODO(tsr): it's unclear when to use `last()` vs `first()`. Sometimes the List seems 42 | // like it is returned in reverse-order. We should resolve this once and for all. 43 | comments?.last()?.let { firstComment -> 44 | Whitespace.getWhitespaceToLeft(commonTokens, firstComment) 45 | ?.onEach { ws -> delete(ws) } 46 | ?.first()?.let { deleteNewlineToLeft(it) } 47 | } 48 | 49 | return comments 50 | } 51 | 52 | /** 53 | * Deletes all comments "to the left of" [before], returning the list of comment tokens, if they 54 | * exist. 55 | * 56 | * @return deleted comment tokens 57 | */ 58 | public fun deleteCommentsToLeft( 59 | before: Token, 60 | ): List? { 61 | // line or block comments 62 | return commonTokens 63 | .getHiddenTokensToLeft(before.tokenIndex, KotlinLexer.COMMENTS) 64 | ?.onEach { token -> 65 | delete(token) 66 | } 67 | } 68 | 69 | /** 70 | * Deletes all comments and whitespace (including newlines) "to the right of" [after]. Such 71 | * comments are assumed to start on the same line. 72 | * 73 | * This is a complicated process because there can be a mix of whitespace, newlines (not 74 | * considered "whitespace" in this context), and comments, and we want to delete exactly as much 75 | * as necessary to "delete the line" -- nothing more, nothing less. 76 | * 77 | * Note that this algorithm differs from [deleteCommentsAndBlankSpaceToLeft] because comments "to 78 | * the right of" a statement must start on the same line (no intervening newline characters). 79 | * 80 | * @return deleted comment tokens 81 | */ 82 | public fun deleteCommentsAndBlankSpaceToRight( 83 | after: Token 84 | ): List? { 85 | val comments = deleteCommentsToRight(after) 86 | 87 | Whitespace.getWhitespaceToRight(commonTokens, after)?.forEach { 88 | delete(it) 89 | } 90 | 91 | return comments 92 | } 93 | 94 | /** 95 | * Deletes all comments "to the right of" [after], returning the list of comment tokens, if they 96 | * exist. 97 | * 98 | * @return deleted comment tokens 99 | */ 100 | public fun deleteCommentsToRight( 101 | after: Token, 102 | ): List? { 103 | // line or block comments 104 | return commonTokens 105 | .getHiddenTokensToRight(after.tokenIndex, KotlinLexer.COMMENTS) 106 | ?.onEach { token -> 107 | delete(token) 108 | } 109 | } 110 | 111 | /** 112 | * Delete _blank_ (spaces, tabs, and line breaks) "to the left of" [before], from the rewriter's 113 | * token stream. 114 | */ 115 | public fun deleteBlankSpaceToLeft(before: Token) { 116 | Whitespace.getBlankSpaceToLeft(commonTokens, before).forEach { delete(it) } 117 | } 118 | 119 | /** 120 | * Delete _blank_ (spaces, tabs, and line breaks) "to the right of" [after], from the rewriter's 121 | * token stream. 122 | */ 123 | public fun deleteBlankSpaceToRight(after: Token) { 124 | // TODO(tsr): this is problematic. Sometimes it deletes too much, other times not enough. 125 | // we will suffer some extra whitespace to avoid syntax issues, which are worse. I will return 126 | // to this when I have time. 127 | Whitespace.getBlankSpaceToRight(commonTokens, after) 128 | .drop(1) 129 | .forEach { delete(it) } 130 | } 131 | 132 | /** 133 | * Delete all whitespaces "to the left of" [before], from the rewriter's token stream. 134 | */ 135 | public fun deleteWhitespaceToLeft(before: Token) { 136 | Whitespace.getWhitespaceToLeft(commonTokens, before)?.forEach { delete(it) } 137 | } 138 | 139 | /** 140 | * Delete newline "to the left of" [before], from the rewriter's token stream. 141 | */ 142 | public fun deleteNewlineToLeft(before: Token) { 143 | if (before.tokenIndex - 1 > 0) { 144 | val previousToken = tokenStream.get(before.tokenIndex - 1) 145 | 146 | if (previousToken.type == KotlinLexer.NL) { 147 | delete(previousToken.tokenIndex) 148 | } 149 | } 150 | } 151 | 152 | /** 153 | * Delete newline "to the right of" [after], from the rewriter's token stream. 154 | */ 155 | public fun deleteNewlineToRight(after: Token) { 156 | if (after.tokenIndex + 1 < tokenStream.size()) { 157 | val nextToken = tokenStream.get(after.tokenIndex + 1) 158 | 159 | if (nextToken.type == KotlinLexer.NL) { 160 | delete(nextToken.tokenIndex) 161 | } 162 | } 163 | } 164 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/parse/SimpleErrorListener.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.parse 2 | 3 | import org.antlr.v4.runtime.ANTLRErrorListener 4 | import org.antlr.v4.runtime.Parser 5 | import org.antlr.v4.runtime.RecognitionException 6 | import org.antlr.v4.runtime.Recognizer 7 | import org.antlr.v4.runtime.atn.ATNConfigSet 8 | import org.antlr.v4.runtime.dfa.DFA 9 | import java.util.BitSet 10 | 11 | /** 12 | * An [ANTLRErrorListener] that ignores all errors. See also 13 | * [CollectingErrorListener][cash.grammar.kotlindsl.utils.CollectingErrorListener]. 14 | */ 15 | public open class SimpleErrorListener : ANTLRErrorListener { 16 | 17 | override fun syntaxError( 18 | recognizer: Recognizer<*, *>?, 19 | offendingSymbol: Any?, 20 | line: Int, 21 | charPositionInLine: Int, 22 | msg: String, 23 | e: RecognitionException? 24 | ) { 25 | } 26 | 27 | override fun reportAmbiguity( 28 | recognizer: Parser, 29 | dfa: DFA, 30 | startIndex: Int, 31 | stopIndex: Int, 32 | exact: Boolean, 33 | ambigAlts: BitSet, 34 | configs: ATNConfigSet 35 | ) { 36 | } 37 | 38 | override fun reportAttemptingFullContext( 39 | recognizer: Parser, 40 | dfa: DFA?, 41 | startIndex: Int, 42 | stopIndex: Int, 43 | conflictingAlts: BitSet, 44 | configs: ATNConfigSet 45 | ) { 46 | } 47 | 48 | override fun reportContextSensitivity( 49 | recognizer: Parser, 50 | dfa: DFA?, 51 | startIndex: Int, 52 | stopIndex: Int, 53 | prediction: Int, 54 | configs: ATNConfigSet 55 | ) { 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/parse/StringExtensions.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.parse 2 | 3 | import com.squareup.cash.grammar.KotlinLexer 4 | import com.squareup.cash.grammar.KotlinParser 5 | import org.antlr.v4.runtime.CharStreams 6 | import org.antlr.v4.runtime.CommonTokenStream 7 | 8 | /** 9 | * Parses the specified [String] and rewrites it using the specified [action]. Returns the 10 | * String object from the rewriter after the action has been applied. 11 | */ 12 | public fun String.rewrite(action: (KotlinParser.ScriptContext, Rewriter) -> Unit): String { 13 | val lexer = KotlinLexer(CharStreams.fromString(this)) 14 | val tokens = CommonTokenStream(lexer) 15 | val parser = KotlinParser(tokens) 16 | val rewriter = Rewriter(tokens) 17 | 18 | action(parser.script(), rewriter) 19 | 20 | return rewriter.text 21 | } 22 | 23 | /** 24 | * Parses the specified [String] into a ScriptContext and processes it using the specified [action]. Returns the 25 | * result of the [action]. This is a convenience function for parsing and processing a script in one go. 26 | */ 27 | public fun String.process(action: (KotlinParser.ScriptContext) -> T): T { 28 | val lexer = KotlinLexer(CharStreams.fromString(this)) 29 | val tokens = CommonTokenStream(lexer) 30 | val parser = KotlinParser(tokens) 31 | 32 | return action(parser.script()) 33 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/utils/BlockRemover.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import cash.grammar.kotlindsl.model.RemovableBlock 4 | import cash.grammar.kotlindsl.parse.KotlinParseException 5 | import cash.grammar.kotlindsl.parse.Parser 6 | import cash.grammar.kotlindsl.parse.Rewriter 7 | import cash.grammar.kotlindsl.utils.Whitespace.trimGently 8 | import cash.grammar.utils.ifNotEmpty 9 | import com.squareup.cash.grammar.KotlinParser 10 | import com.squareup.cash.grammar.KotlinParserBaseListener 11 | import org.antlr.v4.runtime.CommonTokenStream 12 | import java.io.InputStream 13 | import java.nio.file.Path 14 | 15 | /** 16 | * A utility class for removing specified blocks from a Gradle Kotlin build script. 17 | * 18 | * @property tokens The token stream for the parser. 19 | * @property errorListener The listener that collects parsing errors. 20 | * @property blocksToRemove The set of [RemovableBlock]s to be removed from the build script 21 | */ 22 | public class BlockRemover private constructor( 23 | private val tokens: CommonTokenStream, 24 | private val errorListener: CollectingErrorListener, 25 | private val blocksToRemove: Set 26 | ) : KotlinParserBaseListener() { 27 | 28 | private val rewriter = Rewriter(tokens) 29 | private val terminalNewlines = Whitespace.countTerminalNewlines(tokens) 30 | 31 | @Throws(KotlinParseException::class) 32 | public fun rewritten(): String { 33 | errorListener.getErrorMessages().ifNotEmpty { 34 | throw KotlinParseException.withErrors(it) 35 | } 36 | 37 | return rewriter.text.trimGently(terminalNewlines) 38 | } 39 | 40 | override fun exitNamedBlock(ctx: KotlinParser.NamedBlockContext) { 41 | val simpleBlocks = blocksToRemove.filterIsInstance() 42 | 43 | if (ctx.name().text in simpleBlocks.map { it.name }) { 44 | // delete whole block and spaces around it 45 | rewriter.delete(ctx.start, ctx.stop) 46 | rewriter.deleteWhitespaceToLeft(ctx.start) 47 | rewriter.deleteNewlineToRight(ctx.stop) 48 | } 49 | } 50 | 51 | override fun exitPostfixUnaryExpression(ctx: KotlinParser.PostfixUnaryExpressionContext) { 52 | val tasksWithTypeToRemove = 53 | blocksToRemove.filterIsInstance().map { it.type }.toSet() 54 | val inTasksWithType = 55 | ctx.primaryExpression()?.simpleIdentifier()?.text == "tasks" && ctx.postfixUnarySuffix(0) 56 | ?.navigationSuffix()?.simpleIdentifier()?.text == "withType" 57 | 58 | if (inTasksWithType) { 59 | removeWithType(ctx, tasksWithTypeToRemove) 60 | } 61 | } 62 | 63 | private fun removeWithType( 64 | ctx: KotlinParser.PostfixUnaryExpressionContext, 65 | typeNames: Set 66 | ) { 67 | // tasks.withType 68 | val kotlinDSLType = 69 | ctx.postfixUnarySuffix(1)?.typeArguments()?.typeProjection()?.singleOrNull()?.type()?.text 70 | // tasks.withType(TypeName) 71 | val groovyDSLType = ctx.postfixUnarySuffix(1)?.callSuffix()?.valueArguments()?.valueArgument() 72 | ?.singleOrNull()?.text 73 | 74 | if (kotlinDSLType in typeNames || groovyDSLType in typeNames) { 75 | rewriter.delete(ctx.start, ctx.stop) 76 | rewriter.deleteWhitespaceToLeft(ctx.start) 77 | rewriter.deleteNewlineToRight(ctx.stop) 78 | } 79 | } 80 | 81 | public companion object { 82 | public fun of( 83 | buildScript: Path, 84 | blocksToRemove: Set 85 | ): BlockRemover { 86 | return of(Parser.readOnlyInputStream(buildScript), blocksToRemove) 87 | } 88 | 89 | public fun of( 90 | buildScript: String, 91 | blocksToRemove: Set 92 | ): BlockRemover { 93 | return of(buildScript.byteInputStream(), blocksToRemove) 94 | } 95 | 96 | private fun of( 97 | buildScript: InputStream, 98 | blocksToRemove: Set 99 | ): BlockRemover { 100 | val errorListener = CollectingErrorListener() 101 | 102 | return Parser( 103 | file = buildScript, 104 | errorListener = errorListener, 105 | listenerFactory = { _, tokens, _ -> 106 | BlockRemover( 107 | tokens = tokens, 108 | errorListener = errorListener, 109 | blocksToRemove = blocksToRemove 110 | ) 111 | } 112 | ).listener() 113 | } 114 | } 115 | } 116 | 117 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/utils/Blocks.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import com.squareup.cash.grammar.KotlinParser.NamedBlockContext 4 | import com.squareup.cash.grammar.KotlinParser.ScriptContext 5 | import com.squareup.cash.grammar.KotlinParser.StatementContext 6 | import org.antlr.v4.runtime.ParserRuleContext 7 | import org.antlr.v4.runtime.tree.TerminalNode 8 | 9 | public object Blocks { 10 | 11 | public const val ALLPROJECTS: String = "allprojects" 12 | public const val BUILDSCRIPT: String = "buildscript" 13 | public const val DEPENDENCIES: String = "dependencies" 14 | public const val DEPENDENCY_RESOLUTION_MANAGEMENT: String = "dependencyResolutionManagement" 15 | public const val PLUGINS: String = "plugins" 16 | public const val REPOSITORIES: String = "repositories" 17 | public const val SUBPROJECTS: String = "subprojects" 18 | 19 | public val NamedBlockContext.isAllprojects: Boolean 20 | get() = name().text == ALLPROJECTS 21 | 22 | public val NamedBlockContext.isBuildscript: Boolean 23 | get() = name().text == BUILDSCRIPT 24 | 25 | public val NamedBlockContext.isDependencies: Boolean 26 | get() = name().text == DEPENDENCIES 27 | 28 | public val NamedBlockContext.isDependencyResolutionManagement: Boolean 29 | get() = name().text == DEPENDENCY_RESOLUTION_MANAGEMENT 30 | 31 | /** 32 | * Returns true if this is the top-level "plugins" block. False otherwise. In particular, will 33 | * return false in this case: 34 | * ``` 35 | * gradlePlugin { 36 | * plugins { ... } // not the "plugins" block 37 | * } 38 | * ``` 39 | */ 40 | public val NamedBlockContext.isPlugins: Boolean 41 | get() = name().text == PLUGINS && isTopLevel(this) 42 | 43 | public val NamedBlockContext.isRepositories: Boolean 44 | get() = name().text == REPOSITORIES 45 | 46 | public val NamedBlockContext.isSubprojects: Boolean 47 | get() = name().text == SUBPROJECTS 48 | 49 | /** 50 | * Returns the outermost block relative to the current block, represented by the block at the top 51 | * of the [stack]. This outermost block must only contain a single block (which itself can contain 52 | * a single block...), and nothing else. For example, given this (where `repositories` is at the 53 | * top of the stack): 54 | * 55 | * ``` 56 | * subprojects { 57 | * buildscript { 58 | * repositories { ... } 59 | * } 60 | * } 61 | * ``` 62 | * This function will return the `subprojects {}` block. 63 | * 64 | * However, given this (again, where `repositories` is at the top of the stack): 65 | * ``` 66 | * subprojects { 67 | * apply(plugin = "...") 68 | * 69 | * buildscript { 70 | * repositories { ... } 71 | * } 72 | * } 73 | * ``` 74 | * This function will return the `buildscript {}` block. 75 | */ 76 | public fun getOutermostBlock( 77 | stack: ArrayDeque 78 | ): NamedBlockContext? { 79 | if (stack.isEmpty()) return null 80 | if (stack.size == 1) return stack.first() 81 | 82 | // Current, innermost block 83 | var outermost = stack[0] 84 | var index = 1 85 | var parent = stack[index] 86 | 87 | // terminal nodes are lexical tokens, not parse rule contexts 88 | var realNodes = parent.children.filterNot { it is TerminalNode } 89 | 90 | // Direct parent has more than just _this_ child, so return this child (current block) 91 | if (realNodes.size != 2 || realNodes[1].childCount != 2) return outermost 92 | 93 | outermost = parent 94 | 95 | while (realNodes.size == 2 && ++index < stack.size) { 96 | parent = stack[index] 97 | realNodes = parent.children.filterNot { it is TerminalNode } 98 | 99 | if (realNodes.size == 2 && realNodes[1].childCount == 2) { 100 | outermost = parent 101 | } else { 102 | break 103 | } 104 | } 105 | 106 | return outermost 107 | } 108 | 109 | /** 110 | * If [name] is null (default), returns true if [ctx] is within _any_ named block. Otherwise, only 111 | * returns true if [ctx] is within block whose name equals [name]. 112 | * 113 | * Given 114 | * ``` 115 | * foo = bar 116 | * ``` 117 | * This method would return `false`. 118 | * 119 | * Given 120 | * ``` 121 | * foo { 122 | * bar = baz 123 | * } 124 | * ``` 125 | * This method would return `true`. 126 | */ 127 | public fun isInNamedBlock(ctx: ParserRuleContext, name: String? = null): Boolean { 128 | return enclosingNamedBlock(ctx, name) != null 129 | } 130 | 131 | /** 132 | * The inverse of [isInNamedBlock]. 133 | */ 134 | public fun isNotInNamedBlock(ctx: ParserRuleContext, name: String? = null): Boolean { 135 | return !isInNamedBlock(ctx, name) 136 | } 137 | 138 | /** 139 | * Essentially an alias for [isNotInNamedBlock]. Returns true if [ctx] is at the top level of a 140 | * build script (not nested in another block). 141 | */ 142 | public fun isTopLevel(ctx: ParserRuleContext): Boolean { 143 | return isNotInNamedBlock(ctx, null) 144 | } 145 | 146 | /** 147 | * If [name] is null (default), returns name of enclosing named block if [ctx] is within _any_ 148 | * named block. Otherwise, returns [name] if [ctx] is within block whose name equals [name]. 149 | * 150 | * Given 151 | * ``` 152 | * foo = bar 153 | * ``` 154 | * This method would return `null`. 155 | * 156 | * Given 157 | * ``` 158 | * foo { 159 | * bar = baz 160 | * } 161 | * ``` 162 | * This method would return `"foo"`. 163 | */ 164 | public fun enclosingNamedBlock(ctx: ParserRuleContext, name: String? = null): String? { 165 | var parent = ctx.parent 166 | while (parent !is ScriptContext) { 167 | if (parent is NamedBlockContext) { 168 | val parentName = parent.name().text 169 | if (name == null || parentName == name) { 170 | return parentName 171 | } 172 | } 173 | 174 | parent = parent.parent 175 | } 176 | 177 | return null 178 | } 179 | 180 | /** 181 | * Iterates over all named blocks in [iter], filtering by [name] if it is not null. 182 | * For each named block, calls [action] with the block as the argument. Note that this is not a recursive 183 | * iteration; only direct elements of [iter] are considered. 184 | */ 185 | public fun forEachNamedBlock( 186 | iter: Iterable, 187 | name: String? = null, 188 | action: (NamedBlockContext) -> Unit 189 | ) { 190 | iter.mapNotNull { it.namedBlock() } 191 | .filter { name == null || it.name().text == name } 192 | .forEach { action(it) } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/utils/CollectingErrorListener.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import cash.grammar.kotlindsl.parse.SimpleErrorListener 4 | import org.antlr.v4.runtime.RecognitionException 5 | import org.antlr.v4.runtime.Recognizer 6 | 7 | public class CollectingErrorListener : SimpleErrorListener() { 8 | 9 | private val errorMessages = mutableListOf() 10 | 11 | public fun getErrorMessages(): List = errorMessages.toList() 12 | 13 | override fun syntaxError( 14 | recognizer: Recognizer<*, *>?, 15 | offendingSymbol: Any?, 16 | line: Int, 17 | charPositionInLine: Int, 18 | msg: String, 19 | e: RecognitionException? 20 | ) { 21 | errorMessages.add(msg) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/utils/Comments.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import com.squareup.cash.grammar.KotlinLexer 4 | import com.squareup.cash.grammar.KotlinParser 5 | import org.antlr.v4.runtime.CommonTokenStream 6 | import org.antlr.v4.runtime.ParserRuleContext 7 | import org.antlr.v4.runtime.Token 8 | 9 | public class Comments( 10 | private val tokens: CommonTokenStream, 11 | private val indent: String, 12 | ) { 13 | 14 | private var level = 0 15 | 16 | public fun onEnterBlock() { 17 | level++ 18 | } 19 | 20 | public fun onExitBlock() { 21 | level-- 22 | } 23 | 24 | public fun getCommentsToLeft(before: ParserRuleContext): String? { 25 | return getCommentsToLeft(before.start) 26 | } 27 | 28 | public fun getCommentsToLeft(before: Token): String? { 29 | var index = before.tokenIndex - 1 30 | if (index <= 0) return null 31 | 32 | var next = tokens.get(index) 33 | 34 | while (next != null && next.isWhitespace()) { 35 | next = tokens.get(--index) 36 | } 37 | 38 | if (next == null) return null 39 | 40 | val comments = ArrayDeque() 41 | 42 | while (next != null) { 43 | if (next.isComment()) { 44 | comments.addFirst(next.text) 45 | } else if (next.isNotWhitespace()) { 46 | break 47 | } 48 | 49 | next = tokens.get(--index) 50 | } 51 | 52 | if (comments.isEmpty()) return null 53 | 54 | return comments.joinToString(separator = "\n") { 55 | "${indent.repeat(level)}$it" 56 | } 57 | } 58 | 59 | public fun getCommentsInBlock(ctx: KotlinParser.NamedBlockContext): List { 60 | val comments = mutableListOf() 61 | var index = ctx.start.tokenIndex 62 | 63 | while (index <= ctx.stop.tokenIndex) { 64 | val token = tokens.get(index) 65 | 66 | if (token.isComment()) { 67 | comments.add(token) 68 | } 69 | ++index 70 | } 71 | 72 | return comments 73 | } 74 | 75 | private fun Token.isWhitespace(): Boolean { 76 | return text.isBlank() 77 | } 78 | 79 | private fun Token.isNotWhitespace(): Boolean { 80 | return text.isNotBlank() 81 | } 82 | 83 | private fun Token.isComment(): Boolean { 84 | return type == KotlinLexer.LineComment || type == KotlinLexer.DelimitedComment 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/utils/CommentsInBlockRemover.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import cash.grammar.kotlindsl.model.Assignment 4 | import cash.grammar.kotlindsl.parse.KotlinParseException 5 | import cash.grammar.kotlindsl.parse.Parser 6 | import cash.grammar.kotlindsl.parse.Rewriter 7 | import cash.grammar.kotlindsl.utils.Context.leafRule 8 | import cash.grammar.kotlindsl.utils.Whitespace.trimGently 9 | import cash.grammar.utils.ifNotEmpty 10 | import com.squareup.cash.grammar.KotlinParser 11 | import com.squareup.cash.grammar.KotlinParserBaseListener 12 | import org.antlr.v4.runtime.CharStream 13 | import org.antlr.v4.runtime.CommonTokenStream 14 | import org.antlr.v4.runtime.Token 15 | import java.io.InputStream 16 | import java.nio.file.Path 17 | 18 | /** 19 | * Removes comments from a specified block in a build script. 20 | * 21 | * Example: 22 | * ``` 23 | * dependencies { 24 | * /* This is a block comment 25 | * that spans multiple lines */ 26 | * implementation("org.jetbrains.kotlin:kotlin-stdlib") // This is an inline comment 27 | * // This is a single-line comment 28 | * testImplementation("org.junit.jupiter:junit-jupiter") 29 | * } 30 | * ``` 31 | * 32 | * The above script would be rewritten to: 33 | * ``` 34 | * dependencies { 35 | * implementation("org.jetbrains.kotlin:kotlin-stdlib") 36 | * testImplementation("org.junit.jupiter:junit-jupiter") 37 | * } 38 | * ``` 39 | */ 40 | public class CommentsInBlockRemover private constructor( 41 | private val input: CharStream, 42 | private val tokens: CommonTokenStream, 43 | private val errorListener: CollectingErrorListener, 44 | private val blockName: String, 45 | ) : KotlinParserBaseListener() { 46 | private var terminalNewlines = Whitespace.countTerminalNewlines(tokens) 47 | private val rewriter = Rewriter(tokens) 48 | private val indent = Whitespace.computeIndent(tokens, input) 49 | private val comments = Comments(tokens, indent) 50 | private val foundRemovableComments = mutableListOf() 51 | 52 | /** 53 | * Retrieves the list of comments found in the block and can be removed from the script. 54 | * 55 | * @return A list of comments that are eligible for removal. 56 | */ 57 | public fun getFoundRemovableComments(): List = foundRemovableComments 58 | 59 | @Throws(KotlinParseException::class) 60 | public fun rewritten(): String { 61 | errorListener.getErrorMessages().ifNotEmpty { 62 | throw KotlinParseException.withErrors(it) 63 | } 64 | 65 | return rewriter.text.trimGently(terminalNewlines) 66 | } 67 | 68 | override fun exitNamedBlock(ctx: KotlinParser.NamedBlockContext) { 69 | if (ctx.name().text == blockName) { 70 | // Delete inline comments (a comment after a statement) 71 | val allInlineComments = mutableListOf() 72 | ctx.statements().statement().forEach { 73 | val leafRule = it.leafRule() 74 | val inlineComments = rewriter.deleteCommentsAndBlankSpaceToRight(leafRule.stop).orEmpty() 75 | allInlineComments += inlineComments 76 | } 77 | 78 | val allComments = comments.getCommentsInBlock(ctx) 79 | foundRemovableComments.addAll(allComments.map { it.text }) 80 | 81 | // Delete non-inline comments 82 | val nonInlineComments = allComments.subtract(allInlineComments) 83 | nonInlineComments.forEach { token -> 84 | rewriter.deleteWhitespaceToLeft(token) 85 | rewriter.deleteNewlineToRight(token) 86 | rewriter.delete(token) 87 | } 88 | } 89 | } 90 | 91 | public companion object { 92 | public fun of( 93 | buildScript: Path, 94 | blockName: String, 95 | ): CommentsInBlockRemover { 96 | return of(Parser.readOnlyInputStream(buildScript), blockName) 97 | } 98 | 99 | public fun of( 100 | buildScript: String, 101 | blockName: String, 102 | ): CommentsInBlockRemover { 103 | return of(buildScript.byteInputStream(), blockName) 104 | } 105 | 106 | private fun of( 107 | buildScript: InputStream, 108 | blockName: String, 109 | ): CommentsInBlockRemover { 110 | val errorListener = CollectingErrorListener() 111 | 112 | return Parser( 113 | file = buildScript, 114 | errorListener = errorListener, 115 | listenerFactory = { input, tokens, _ -> 116 | CommentsInBlockRemover( 117 | input = input, 118 | tokens = tokens, 119 | errorListener = errorListener, 120 | blockName = blockName, 121 | ) 122 | }, 123 | ).listener() 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/utils/Context.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import com.squareup.cash.grammar.KotlinParser.LineStringLiteralContext 4 | import com.squareup.cash.grammar.KotlinParser.LiteralConstantContext 5 | import com.squareup.cash.grammar.KotlinParser.PostfixUnaryExpressionContext 6 | import org.antlr.v4.runtime.CharStream 7 | import org.antlr.v4.runtime.ParserRuleContext 8 | import org.antlr.v4.runtime.misc.Interval 9 | 10 | public object Context { 11 | 12 | /** 13 | * Returns the "full text", from [input], represented by [this][ParserRuleContext]. The full text includes tokens that 14 | * are sent to hidden channels by the lexer. cf. [ParserRuleContext.text][ParserRuleContext.getText], which only 15 | * considers tokens which have been added to the parse tree (i.e., not comments or whitespace). 16 | * 17 | * Returns null if `this` has a null [ParserRuleContext.start] or [ParserRuleContext.stop], which can happen when, 18 | * e.g., `this` is a [ScriptContext][com.squareup.cash.grammar.KotlinParser.ScriptContext]. (I don't fully understand 19 | * why those tokens might be null.) 20 | */ 21 | public fun ParserRuleContext.fullText(input: CharStream): String? { 22 | val a = start?.startIndex ?: return null 23 | val b = stop?.stopIndex ?: return null 24 | 25 | return input.getText(Interval.of(a, b)) 26 | } 27 | 28 | /** 29 | * Given a [ParserRuleContext] that has a single child that has a single child... return the leaf 30 | * node terminal [ParserRuleContext]. 31 | */ 32 | public fun ParserRuleContext.leafRule(): ParserRuleContext { 33 | var tree = this 34 | while (tree.childCount == 1) { 35 | val child = tree.getChild(0) 36 | 37 | // Might also be a TerminalNode, which is essentially a lexer rule token 38 | // (we want a parser rule token). 39 | if (child !is ParserRuleContext) { 40 | break 41 | } 42 | 43 | tree = child 44 | } 45 | 46 | return tree 47 | } 48 | 49 | /** 50 | * Returns the literal text this [ctx] refers to, iff it is a [LineStringLiteralContext] 51 | * (potentially via many intermediate parser rules). 52 | */ 53 | public fun literalText(ctx: ParserRuleContext): String? { 54 | return (ctx.leafRule() as? LineStringLiteralContext) 55 | ?.lineStringContent() 56 | ?.get(0) 57 | ?.LineStrText() 58 | ?.text 59 | } 60 | 61 | /** 62 | * Returns the literal boolean this [ctx] refers to, iff it is a [LiteralConstantContext] 63 | * (potentially via many intermediate parser rules). 64 | */ 65 | public fun literalBoolean(ctx: ParserRuleContext): Boolean? { 66 | return (ctx.leafRule() as? LiteralConstantContext) 67 | ?.BooleanLiteral() 68 | ?.text 69 | ?.toBoolean() 70 | } 71 | 72 | 73 | public fun aliasText(ctx: ParserRuleContext): String? { 74 | return (ctx.leafRule() as? PostfixUnaryExpressionContext)?.text 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/utils/PluginConfigFinder.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import cash.grammar.kotlindsl.utils.Context.literalBoolean 4 | import cash.grammar.kotlindsl.utils.Context.literalText 5 | import com.squareup.cash.grammar.KotlinParser.InfixFunctionCallContext 6 | import com.squareup.cash.grammar.KotlinParser.PostfixUnaryExpressionContext 7 | import com.squareup.cash.grammar.KotlinParser.PostfixUnarySuffixContext 8 | import com.squareup.cash.grammar.KotlinParser.RangeExpressionContext 9 | import com.squareup.cash.grammar.KotlinParser.SimpleIdentifierContext 10 | import org.antlr.v4.runtime.ParserRuleContext 11 | 12 | /** 13 | * Extracts plugin configuration. For example: 14 | * ``` 15 | * plugins { 16 | * id("foo").version("x").apply(false) 17 | * // or infix form 18 | * id("foo") version "x" apply false 19 | * } 20 | * ``` 21 | * Will provide access to `"x"` and `false` via [version] and [apply]. 22 | */ 23 | internal class PluginConfigFinder private constructor(config: Config) { 24 | 25 | private interface Config { 26 | val version: String? 27 | val apply: Boolean 28 | } 29 | 30 | private class PostfixConfig(line: PostfixUnaryExpressionContext) : Config { 31 | 32 | private val config = line.postfixUnarySuffix() 33 | // this is the plugin ID, which we already have 34 | .drop(1) 35 | // the plugin config is chunked parts that each have two elements, (version, "v") 36 | // and (apply, ) 37 | .chunked(2) 38 | 39 | init { 40 | require(config.size <= 2) { 41 | "plugins can't have more than two sets of config (`version` and `apply`)" 42 | } 43 | } 44 | 45 | override val version: String? = configForName("version")?.let { c -> 46 | val arg = c[1] 47 | .callSuffix() 48 | ?.valueArguments() 49 | ?.getChild(1) as? ParserRuleContext 50 | ?: return@let null 51 | 52 | // if it's a string literal, wrap it in quotes. Otherwise, return the raw string 53 | // e.g. if the version is `libs.foo.get().version`. 54 | literalText(arg)?.let { 55 | "\"$it\"" 56 | } ?: arg.text 57 | } 58 | 59 | override val apply: Boolean = configForName("apply")?.let { c -> 60 | val arg = c[1] 61 | .callSuffix() 62 | ?.valueArguments() 63 | ?.getChild(1) as? ParserRuleContext 64 | ?: return@let null 65 | 66 | literalBoolean(arg) 67 | } ?: true 68 | 69 | private fun configForName(name: String): List? { 70 | return config.find { c -> 71 | if (c.size != 2) { 72 | false 73 | } else { 74 | name == c.first()?.navigationSuffix()?.simpleIdentifier()?.Identifier()?.text 75 | } 76 | } 77 | } 78 | } 79 | 80 | private class InfixConfig( 81 | private val line: InfixFunctionCallContext 82 | ) : Config { 83 | 84 | private val config = line.children.filterIsInstance() 85 | private val versionConfig = config.firstOrNull { it.text == "version" } 86 | private val applyConfig = config.firstOrNull { it.text == "apply" } 87 | 88 | init { 89 | require(config.size <= 2) { 90 | "plugins can't have more than two sets of config (`version` and `apply`)" 91 | } 92 | } 93 | 94 | override val version: String? = versionConfig?.let { c -> 95 | val i = line.children.indexOf(c) + 1 96 | val value = line.getChild(i) as? RangeExpressionContext ?: return@let null 97 | literalText(value)?.let { "\"$it\"" } ?: value.text 98 | } 99 | 100 | override val apply: Boolean = applyConfig?.let { c -> 101 | val i = line.children.indexOf(c) + 1 102 | val value = line.getChild(i) as? RangeExpressionContext ?: return@let null 103 | literalBoolean(value) ?: return@let null 104 | } ?: true 105 | } 106 | 107 | companion object { 108 | fun of(line: PostfixUnaryExpressionContext): PluginConfigFinder { 109 | return PluginConfigFinder(PostfixConfig(line)) 110 | } 111 | 112 | fun of(line: InfixFunctionCallContext): PluginConfigFinder { 113 | return PluginConfigFinder(InfixConfig(line)) 114 | } 115 | } 116 | 117 | /** The plugin version. May be null. */ 118 | val version: String? = config.version 119 | 120 | /** Whether the plugin is applied. Defaults to true. */ 121 | val apply: Boolean = config.apply 122 | } 123 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/utils/PluginExtractor.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import cash.grammar.kotlindsl.model.Plugin 4 | import cash.grammar.kotlindsl.model.Plugin.Type 5 | import cash.grammar.kotlindsl.utils.Context.aliasText 6 | import cash.grammar.kotlindsl.utils.Context.leafRule 7 | import cash.grammar.kotlindsl.utils.Context.literalText 8 | import com.squareup.cash.grammar.KotlinParser 9 | import com.squareup.cash.grammar.KotlinParser.ExpressionContext 10 | import com.squareup.cash.grammar.KotlinParser.InfixFunctionCallContext 11 | import com.squareup.cash.grammar.KotlinParser.NamedBlockContext 12 | import com.squareup.cash.grammar.KotlinParser.PostfixUnaryExpressionContext 13 | import com.squareup.cash.grammar.KotlinParser.RangeExpressionContext 14 | import com.squareup.cash.grammar.KotlinParser.SimpleIdentifierContext 15 | import com.squareup.cash.grammar.KotlinParser.ValueArgumentContext 16 | import org.antlr.v4.runtime.ParserRuleContext 17 | import org.antlr.v4.runtime.tree.TerminalNode 18 | 19 | /** 20 | * Extracts plugin from build scripts. 21 | * 22 | * nb for maintainers: I got to this by staring at the "Parse Tree" view in the ANTLR Preview tool 23 | * window until my eyes bled. Also note it's best to just assume everything is nullable, and also 24 | * that any list might be empty. 25 | */ 26 | public object PluginExtractor { 27 | 28 | private val pluginScriptBlocks = setOf(Blocks.ALLPROJECTS) 29 | 30 | /** 31 | * We can be in either the top level, or in an allprojects block at the top level. 32 | */ 33 | public fun scriptLikeContext(blockStack: ArrayDeque): Boolean { 34 | val isAllprojectsBlock = blockStack.firstOrNull { namedBlock -> 35 | namedBlock.name().text in pluginScriptBlocks 36 | } != null 37 | 38 | return !(blockStack.size > 1 || (blockStack.size == 1 && !isAllprojectsBlock)) 39 | } 40 | 41 | /** 42 | * Extracts a plugin ID and apply config from [line], from a `plugins` block, if there is one. May return null. 43 | * Must be called from inside a plugins block ([KotlinParser.NamedBlockContext]). 44 | * 45 | * Examples include 46 | * ``` 47 | * plugins { 48 | * id("foo") 49 | * kotlin("jvm") 50 | * application 51 | * `kotlin-dsl` 52 | * id("my-plugin") apply false 53 | * id("my-other-plugin") version "x" apply false 54 | * } 55 | * ``` 56 | */ 57 | public fun extractFromBlock(line: ParserRuleContext): Plugin? { 58 | return when (line) { 59 | is SimpleIdentifierContext -> extractFromBlockSimple(line) 60 | 61 | is PostfixUnaryExpressionContext -> extractFromBlock(line) 62 | 63 | // For plugin with configuration e.g. version, apply 64 | is InfixFunctionCallContext -> extractFromBlockInfix(line) 65 | 66 | else -> null 67 | } 68 | } 69 | 70 | private fun extractFromBlockSimple(line: SimpleIdentifierContext): Plugin? { 71 | return if (line.text.startsWith("`")) { 72 | Plugin( 73 | type = Type.BLOCK_BACKTICK, 74 | id = line.text.removePrefix("`").removeSuffix("`") 75 | ) 76 | } else { 77 | Plugin( 78 | type = Type.BLOCK_SIMPLE, 79 | id = line.text 80 | ) 81 | } 82 | } 83 | 84 | private fun extractFromBlock(line: PostfixUnaryExpressionContext): Plugin? { 85 | // TODO(tsr): replace this with something? 86 | //if (line.postfixUnarySuffix().size != 1) return null 87 | 88 | // e.g., "id", "kotlin", or "alias" 89 | val type = line.primaryExpression()?.simpleIdentifier()?.text ?: return null 90 | 91 | val valueArgumentsCtx = line.postfixUnarySuffix(0) 92 | ?.callSuffix() 93 | ?.valueArguments() 94 | 95 | if (valueArgumentsCtx?.childCount != 3) return null 96 | val pluginIdRule = valueArgumentsCtx.getChild(1) as? ParserRuleContext ?: return null 97 | val pluginId = literalText(pluginIdRule) 98 | ?: aliasText(pluginIdRule) 99 | ?: return null 100 | 101 | // optional plugin config 102 | val config = PluginConfigFinder.of(line) 103 | 104 | return Plugin( 105 | type = Type.of(type), 106 | id = pluginId, 107 | version = config.version, 108 | applied = config.apply, 109 | ) 110 | } 111 | 112 | private fun extractFromBlockInfix(line: InfixFunctionCallContext): Plugin? { 113 | val pluginArg = line.getChild(0) as? RangeExpressionContext ?: return null 114 | val plugin = when (val pluginArgLeaf = pluginArg.leafRule()) { 115 | is SimpleIdentifierContext -> extractFromBlockSimple(pluginArgLeaf) 116 | is PostfixUnaryExpressionContext -> extractFromBlock(pluginArgLeaf) 117 | else -> null 118 | } ?: return null 119 | 120 | // optional plugin config 121 | val config = PluginConfigFinder.of(line) 122 | 123 | return plugin.copy( 124 | version = config.version, 125 | applied = config.apply, 126 | ) 127 | } 128 | 129 | /** 130 | * Extracts a plugin ID from [ctx] at the top level of a build script, if there is one. May return 131 | * null. 132 | * 133 | * Examples include 134 | * ``` 135 | * apply(plugin = "kotlin") 136 | * apply(plugin = "com.github.johnrengelman.shadow") 137 | * apply(mapOf("plugin" to "foo")) 138 | * ``` 139 | */ 140 | public fun extractFromScript(ctx: PostfixUnaryExpressionContext): Plugin? { 141 | val enclosingBlockName = Blocks.enclosingNamedBlock(ctx) 142 | require(enclosingBlockName == null || enclosingBlockName in pluginScriptBlocks) { 143 | "Expected to be in the script context. Was in block named '$enclosingBlockName'" 144 | } 145 | 146 | if (ctx.primaryExpression().simpleIdentifier()?.text != "apply") return null 147 | if (ctx.postfixUnarySuffix().size != 1) return null 148 | 149 | // we might have an `apply(plugin = "...")` or an `apply(mapOf("plugin" to "..."))` 150 | val valueArgumentsCtx = ctx.postfixUnarySuffix(0) 151 | ?.callSuffix() 152 | ?.valueArguments() 153 | ?.valueArgument() 154 | ?: return null 155 | 156 | if (valueArgumentsCtx.size != 1) return null 157 | 158 | // seam: can be `apply(plugin = "...")` or `apply(mapOf("plugin" to "..."))` 159 | // example: 3 children: ["(", ["plugin", "=", "..."], ")"] 160 | // Note the 2nd child itself has three children 161 | if (valueArgumentsCtx[0].childCount == 3) { 162 | return findPluginFromAssignment(valueArgumentsCtx) 163 | } else if (valueArgumentsCtx[0].childCount == 1) { 164 | return findPluginFromMap(valueArgumentsCtx) 165 | } 166 | 167 | return null 168 | } 169 | 170 | /** 171 | * Returns [Plugin] from [valueArgumentsCtx], assuming 172 | * [argument.text][ExpressionContext.getText] == `plugin="foo"`. Returns `null` otherwise. 173 | */ 174 | private fun findPluginFromAssignment(valueArgumentsCtx: List): Plugin? { 175 | val arguments = valueArgumentsCtx[0] 176 | 177 | if ((arguments.getChild(0) as? SimpleIdentifierContext)?.text != "plugin") return null 178 | if ((arguments.getChild(1) as? TerminalNode)?.text != "=") return null 179 | 180 | val arg = arguments.getChild(2) as? ExpressionContext ?: return null 181 | val pluginId = literalText(arg) ?: return null 182 | 183 | return Plugin( 184 | type = Type.of("apply"), 185 | id = pluginId, 186 | ) 187 | } 188 | 189 | /** 190 | * Returns [Plugin] from [valueArgumentsCtx], assuming 191 | * [argument.text][ExpressionContext.getText] == `"mapOf("plugin"to"foo")"`. Returns `null` 192 | * otherwise. 193 | */ 194 | private fun findPluginFromMap(valueArgumentsCtx: List): Plugin? { 195 | val arg = valueArgumentsCtx[0].getChild(0) as? ExpressionContext ?: return null 196 | val leaf = arg.leafRule() as? PostfixUnaryExpressionContext ?: return null 197 | 198 | if (leaf.primaryExpression()?.simpleIdentifier()?.text != "mapOf") return null 199 | if (leaf.postfixUnarySuffix().size != 1) return null 200 | 201 | val arguments = leaf.postfixUnarySuffix(0) 202 | ?.callSuffix() 203 | ?.valueArguments() 204 | 205 | if (arguments?.childCount != 3) return null 206 | 207 | val mapOf = ((arguments.getChild(1) as? ValueArgumentContext) 208 | ?.leafRule() as? InfixFunctionCallContext) 209 | ?: return null 210 | 211 | if (literalText(mapOf.rangeExpression(0)) != "plugin") return null 212 | val pluginId = literalText(mapOf.rangeExpression(1)) ?: return null 213 | 214 | return Plugin( 215 | type = Type.APPLY, 216 | id = pluginId, 217 | ) 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/utils/PluginFinder.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import cash.grammar.kotlindsl.model.Plugin 4 | import cash.grammar.kotlindsl.parse.Parser 5 | import cash.grammar.kotlindsl.utils.Blocks.isPlugins 6 | import cash.grammar.kotlindsl.utils.Context.leafRule 7 | import com.squareup.cash.grammar.KotlinParser 8 | import com.squareup.cash.grammar.KotlinParser.NamedBlockContext 9 | import com.squareup.cash.grammar.KotlinParser.PostfixUnaryExpressionContext 10 | import com.squareup.cash.grammar.KotlinParserBaseListener 11 | import org.antlr.v4.runtime.CharStream 12 | import org.antlr.v4.runtime.CommonTokenStream 13 | import org.antlr.v4.runtime.tree.TerminalNode 14 | import java.io.InputStream 15 | import java.nio.file.Path 16 | 17 | /** 18 | * Scans Gradle Kotlin DSL scripts to find all plugin declarations. 19 | */ 20 | public class PluginFinder( 21 | private val input: CharStream, 22 | private val tokens: CommonTokenStream, 23 | private val parser: KotlinParser, 24 | private val errorListener: CollectingErrorListener 25 | ) : KotlinParserBaseListener() { 26 | 27 | /** Read-only view of the discovered [Plugin]s. */ 28 | public val plugins: Set 29 | get() = discoveredPlugins.toSet() 30 | 31 | private val discoveredPlugins = mutableSetOf() 32 | 33 | private val blockStack = ArrayDeque() 34 | 35 | override fun enterNamedBlock(ctx: NamedBlockContext) { 36 | blockStack.addFirst(ctx) 37 | } 38 | 39 | /** 40 | * Finds plugins in plugins block 41 | */ 42 | override fun exitNamedBlock(ctx: NamedBlockContext) { 43 | if (ctx.isPlugins) { 44 | ctx.statements().statement() 45 | .filterNot { it is TerminalNode } 46 | .map { it.leafRule() } 47 | .forEach { line -> 48 | PluginExtractor.extractFromBlock(line)?.let { plugin -> 49 | discoveredPlugins.add(plugin) 50 | } 51 | } 52 | } 53 | 54 | blockStack.removeFirst() 55 | } 56 | 57 | override fun exitPostfixUnaryExpression(ctx: PostfixUnaryExpressionContext) { 58 | if (!PluginExtractor.scriptLikeContext(blockStack)) return 59 | 60 | PluginExtractor.extractFromScript(ctx)?.let { plugin -> 61 | discoveredPlugins.add(plugin) 62 | } 63 | } 64 | 65 | public companion object { 66 | public fun of(buildScript: Path): PluginFinder { 67 | return of(Parser.readOnlyInputStream(buildScript)) 68 | } 69 | 70 | public fun of(buildScript: String): PluginFinder { 71 | return of(buildScript.byteInputStream()) 72 | } 73 | 74 | public fun of(buildScript: InputStream): PluginFinder { 75 | val errorListener = CollectingErrorListener() 76 | 77 | return Parser( 78 | file = buildScript, 79 | errorListener = errorListener, 80 | listenerFactory = { input, tokens, parser -> 81 | PluginFinder( 82 | input = input, 83 | tokens = tokens, 84 | parser = parser, 85 | errorListener = errorListener, 86 | ) 87 | } 88 | ).listener() 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/utils/SmartIndent.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import com.squareup.cash.grammar.KotlinParserBaseListener 4 | import org.antlr.v4.runtime.CommonTokenStream 5 | import org.antlr.v4.runtime.ParserRuleContext 6 | 7 | /** 8 | * Helper class for managing smart indents. Defaults to two spaces. Attempts to match whatever 9 | * indent is used by the source file from which [tokens] was taken. 10 | */ 11 | public class SmartIndent(private val tokens: CommonTokenStream) { 12 | 13 | // We use a default of two spaces, but update it at most once later on. 14 | private var smartIndentSet = false 15 | private var indent = " " 16 | 17 | /** Call this sometime after [setIndent] has been called. */ 18 | public fun getSmartIndent(): String = indent 19 | 20 | /** Call this from [KotlinParserBaseListener.enterStatement]. */ 21 | public fun setIndent(ctx: ParserRuleContext) { 22 | if (smartIndentSet) return 23 | 24 | Whitespace.getWhitespaceToLeft(tokens, ctx.start) 25 | ?.joinToString(separator = "") { token -> token.text } 26 | ?.let { ws -> 27 | smartIndentSet = true 28 | indent = ws 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/kotlindsl/utils/Whitespace.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import com.squareup.cash.grammar.KotlinLexer 4 | import org.antlr.v4.runtime.CharStream 5 | import org.antlr.v4.runtime.CommonTokenStream 6 | import org.antlr.v4.runtime.ParserRuleContext 7 | import org.antlr.v4.runtime.Token 8 | import org.antlr.v4.runtime.misc.Interval 9 | 10 | /** 11 | * Utilities for working with whitespace, including newlines, carriage returns, etc. 12 | * 13 | * Note that this class distinguishes between "blank space" and "white space". The former includes 14 | * newlines while the latter does not. This is important because newlines are significant syntactic 15 | * elements in Kotlin; they are treated as statement terminators similar to semicolons in Java. 16 | * In some cases removal of newlines can change the semantics of the code so care must be taken to 17 | * use the appropriate method. 18 | */ 19 | public object Whitespace { 20 | 21 | /** 22 | * Returns a list of [Token]s, "to the left of" [before], from [tokens], that are _blank_ 23 | * according to [String.isBlank]. 24 | */ 25 | public fun getBlankSpaceToLeft( 26 | tokens: CommonTokenStream, 27 | before: ParserRuleContext 28 | ): List = getBlankSpaceToLeft(tokens, before.start) 29 | 30 | /** 31 | * Returns a list of [Token]s, "to the left of" [before], from [tokens], that are _blank_ 32 | * according to [String.isBlank]. 33 | */ 34 | public fun getBlankSpaceToLeft(tokens: CommonTokenStream, before: Token): List { 35 | return buildList { 36 | var index = before.tokenIndex - 1 37 | 38 | if (index <= 0) return@buildList 39 | 40 | var next = tokens.get(index) 41 | 42 | while (next.text.isBlank()) { 43 | add(next) 44 | next = tokens.get(--index) 45 | } 46 | } 47 | } 48 | 49 | /** 50 | * Returns a list of [Token]s, "to the right of" [after], from [tokens], that are _blank_ 51 | * according to [String.isBlank]. 52 | */ 53 | public fun getBlankSpaceToRight(tokens: CommonTokenStream, after: Token): List { 54 | return buildList { 55 | var index = after.tokenIndex + 1 56 | 57 | if (index >= tokens.size()) return@buildList 58 | 59 | var next = tokens.get(index) 60 | 61 | while (next.text.isBlank()) { 62 | add(next) 63 | next = tokens.get(++index) 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * Returns a list of [Token]s, "to the left of" [before], from [tokens], that are "whitespace". 70 | * Whitespace characters match the following lexer rule, from `KotlinLexer`: 71 | * 72 | * ``` 73 | * [\u0020\u0009\u000C] 74 | * ``` 75 | * 76 | * Returns `null` if there is no whitespace to the left of [before]. 77 | * 78 | * nb to maintainers: do _not_ change this to an empty list instead of null. There's a difference! 79 | */ 80 | public fun getWhitespaceToLeft( 81 | tokens: CommonTokenStream, 82 | before: ParserRuleContext 83 | ): List? = getWhitespaceToLeft(tokens, before.start) 84 | 85 | /** 86 | * Returns a list of [Token]s, "to the left of" [before], from [tokens], that are "whitespace". 87 | * Whitespace characters match the following lexer rule, from `KotlinLexer`: 88 | * 89 | * ``` 90 | * [\u0020\u0009\u000C] 91 | * ``` 92 | * 93 | * Returns `null` if there is no whitespace to the left of [before]. 94 | * 95 | * nb to maintainers: do _not_ change this to an empty list instead of null. There's a difference! 96 | */ 97 | public fun getWhitespaceToLeft(tokens: CommonTokenStream, before: Token): List? { 98 | return tokens.getHiddenTokensToLeft(before.tokenIndex, KotlinLexer.WHITESPACE) 99 | } 100 | 101 | /** 102 | * Returns a list of [Token]s, "to the left of" [before], from [tokens], that are "whitespace". 103 | * Whitespace characters match the following lexer rule, from `KotlinLexer`: 104 | * 105 | * ``` 106 | * [\u0020\u0009\u000C] 107 | * ``` 108 | * 109 | * Returns `null` if there is no whitespace to the left of [before]. 110 | * 111 | * nb to maintainers: do _not_ change this to an empty list instead of null. There's a difference! 112 | */ 113 | public fun getWhitespaceToRight(tokens: CommonTokenStream, before: Token): List? { 114 | return tokens.getHiddenTokensToRight(before.tokenIndex, KotlinLexer.WHITESPACE) 115 | } 116 | 117 | /** 118 | * Returns the indentation in this input, based on the assumption that the first indentation 119 | * discovered is the common indent level. If no indent discovered (which could happen if this 120 | * input contains only top-level statements), defaults to [min]. 121 | */ 122 | public fun computeIndent( 123 | tokens: CommonTokenStream, 124 | input: CharStream, 125 | min: String = " ", 126 | ): String { 127 | // We need at least two tokens for this to make sense. 128 | if (tokens.size() < 2) return min 129 | 130 | val start = tokens.get(0).startIndex 131 | val stop = tokens.get(tokens.size() - 1).stopIndex 132 | 133 | if (start == -1 || stop == -1) return min 134 | 135 | // Kind of a gross implementation, but a starting point that works -- can optimize later. 136 | input.getText(Interval.of(start, stop)).lineSequence().forEach { line -> 137 | var indent = "" 138 | // a line might contain JUST whitespace -- we don't want to count these. 139 | var nonEmptyLine = false 140 | 141 | for (c in line.toCharArray()) { 142 | if (c == ' ' || c == '\t') { 143 | indent += c 144 | } else { 145 | nonEmptyLine = true 146 | break 147 | } 148 | } 149 | 150 | if (nonEmptyLine && indent.isNotEmpty()) return indent 151 | } 152 | 153 | return min 154 | } 155 | 156 | /** 157 | * Use this in conjunction with [trimGently] to maintain original end-of-file formatting. 158 | */ 159 | public fun countTerminalNewlines(tokens: CommonTokenStream): Int { 160 | var count = 0 161 | 162 | // We use size - 2 because we skip the EOF token, which always exists. 163 | for (i in tokens.size() - 2 downTo 0) { 164 | val t = tokens.get(i) 165 | if (t.type == KotlinLexer.NL) count++ 166 | else break 167 | } 168 | return count 169 | } 170 | 171 | /** 172 | * Use this in conjunction with [countTerminalNewlines] to maintain original end-of-file 173 | * formatting. 174 | */ 175 | public fun String.trimGently(terminalNewlines: Int = 0): String { 176 | return trim() + "\n".repeat(terminalNewlines) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /core/src/main/kotlin/cash/grammar/utils/collections.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.utils 2 | 3 | public inline fun C.ifNotEmpty(block: (C) -> Unit) where C : Collection<*> { 4 | if (isNotEmpty()) { 5 | block(this) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /core/src/test/kotlin/cash/grammar/kotlindsl/utils/BlockRemoverTest.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import cash.grammar.kotlindsl.model.RemovableBlock.SimpleBlock 4 | import cash.grammar.kotlindsl.model.RemovableBlock.TaskWithTypeBlock 5 | import org.junit.jupiter.api.Assertions.assertEquals 6 | import org.junit.jupiter.api.Test 7 | 8 | class BlockRemoverTest { 9 | @Test 10 | fun `can remove SimpleBlock`() { 11 | val buildScript = 12 | """ 13 | |plugins { 14 | | application 15 | |} 16 | | 17 | |kotlinter { 18 | | disabledRules = arrayOf("max-line-length") // Too many failures at the moment that would need manual intervention 19 | |} 20 | | 21 | |polyrepo { 22 | | publishToArtifactory = true // cash.server.protos does not publish by default 23 | | shortName = "foo" 24 | |} 25 | | 26 | """.trimMargin() 27 | 28 | // When... 29 | val blockRemover = BlockRemover.of(buildScript, setOf(SimpleBlock("kotlinter"), SimpleBlock("polyrepo"))) 30 | val rewrittenContent = blockRemover.rewritten() 31 | val expectedContent = 32 | """ 33 | |plugins { 34 | | application 35 | |} 36 | | 37 | """.trimMargin() 38 | 39 | // Then... 40 | assertEquals(expectedContent, rewrittenContent) 41 | } 42 | 43 | @Test 44 | fun `can remove nested SimpleBlock`() { 45 | val buildScript = 46 | """ 47 | |plugins { 48 | | application 49 | |} 50 | | 51 | |java { 52 | | toolchain { 53 | | languageVersion = javaTarget 54 | | } 55 | | sourceCompatibility = javaTarget 56 | |} 57 | | 58 | |polyrepo { 59 | | publishToArtifactory = true // cash.server.protos does not publish by default 60 | | shortName = "foo" 61 | |} 62 | | 63 | """.trimMargin() 64 | 65 | // When... 66 | val blockRemover = BlockRemover.of(buildScript, setOf(SimpleBlock("toolchain"), SimpleBlock("polyrepo"))) 67 | val rewrittenContent = blockRemover.rewritten() 68 | val expectedContent = 69 | """ 70 | |plugins { 71 | | application 72 | |} 73 | | 74 | |java { 75 | | sourceCompatibility = javaTarget 76 | |} 77 | | 78 | """.trimMargin() 79 | 80 | // Then... 81 | assertEquals(expectedContent, rewrittenContent) 82 | } 83 | 84 | @Test 85 | fun `can remove TaskWithTypeBlock`() { 86 | val buildScript = 87 | """ 88 | |plugins { 89 | | application 90 | |} 91 | | 92 | |tasks.withType { 93 | | dependsOn("generateMainProtos") 94 | | exclude { it.file.path.contains("/build/") } 95 | |} 96 | | 97 | |tasks.withType { 98 | | exclude { it.file.path.contains("/build/") } 99 | |} 100 | | 101 | """.trimMargin() 102 | 103 | // When... 104 | val blockRemover = BlockRemover.of(buildScript, setOf( 105 | TaskWithTypeBlock("org.jmailen.gradle.kotlinter.tasks.LintTask"), 106 | TaskWithTypeBlock("org.jmailen.gradle.kotlinter.tasks.FormatTask"), 107 | TaskWithTypeBlock("org.jmailen.gradle.kotlinter.tasks.LintTask::class") 108 | )) 109 | 110 | val rewrittenContent = blockRemover.rewritten() 111 | val expectedContent = 112 | """ 113 | |plugins { 114 | | application 115 | |} 116 | | 117 | """.trimMargin() 118 | 119 | // Then... 120 | assertEquals(expectedContent, rewrittenContent) 121 | } 122 | 123 | @Test 124 | fun `can remove nested TaskWithTypeBlock`() { 125 | val buildScript = 126 | """ 127 | |plugins { 128 | | application 129 | |} 130 | | 131 | |allprojects { 132 | | tasks.withType(org.jmailen.gradle.kotlinter.tasks.LintTask::class).configureEach { 133 | | exclude { it.file.path.contains("/build/") } 134 | | } 135 | | dependencies { 136 | | implementation("org.jetbrains.kotlin:kotlin-stdlib:1.5.31") 137 | | } 138 | |} 139 | | 140 | """.trimMargin() 141 | 142 | // When... 143 | val blockRemover = BlockRemover.of(buildScript, setOf( 144 | TaskWithTypeBlock("org.jmailen.gradle.kotlinter.tasks.LintTask::class") 145 | )) 146 | 147 | val rewrittenContent = blockRemover.rewritten() 148 | val expectedContent = 149 | """ 150 | |plugins { 151 | | application 152 | |} 153 | | 154 | |allprojects { 155 | | dependencies { 156 | | implementation("org.jetbrains.kotlin:kotlin-stdlib:1.5.31") 157 | | } 158 | |} 159 | | 160 | """.trimMargin() 161 | 162 | // Then... 163 | assertEquals(expectedContent, rewrittenContent) 164 | } 165 | } -------------------------------------------------------------------------------- /core/src/test/kotlin/cash/grammar/kotlindsl/utils/BlocksTest.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import cash.grammar.kotlindsl.parse.Parser 4 | import cash.grammar.kotlindsl.parse.process 5 | import cash.grammar.kotlindsl.utils.Blocks.isRepositories 6 | import cash.grammar.kotlindsl.utils.test.TestErrorListener 7 | import com.squareup.cash.grammar.KotlinParser.NamedBlockContext 8 | import com.squareup.cash.grammar.KotlinParser.SimpleIdentifierContext 9 | import com.squareup.cash.grammar.KotlinParserBaseListener 10 | import org.assertj.core.api.Assertions.assertThat 11 | import org.junit.jupiter.api.Test 12 | 13 | internal class BlocksTest { 14 | 15 | @Test fun `outermost block containing 'repositories' is 'subprojects'`() { 16 | val buildScript = 17 | """ 18 | subprojects { 19 | // comments are ignored 20 | buildscript { 21 | repositories {} 22 | } 23 | } 24 | """.trimIndent() 25 | 26 | val scriptListener = Parser( 27 | file = buildScript, 28 | errorListener = TestErrorListener { 29 | throw RuntimeException("Syntax error: ${it?.message}", it) 30 | }, 31 | listenerFactory = { _, _, _ -> TestListener() } 32 | ).listener() 33 | 34 | assertThat(scriptListener.outermostBlock).isNotNull() 35 | assertThat(scriptListener.outermostBlock!!.name().text).isEqualTo(Blocks.SUBPROJECTS) 36 | } 37 | 38 | @Test fun `outermost block containing 'repositories' is 'buildscript'`() { 39 | val buildScript = 40 | """ 41 | subprojects { 42 | apply(plugin = "kotlin") 43 | buildscript { 44 | repositories {} 45 | } 46 | } 47 | """.trimIndent() 48 | 49 | val scriptListener = Parser( 50 | file = buildScript, 51 | errorListener = TestErrorListener { 52 | throw RuntimeException("Syntax error: ${it?.message}", it) 53 | }, 54 | listenerFactory = { _, _, _ -> TestListener() } 55 | ).listener() 56 | 57 | assertThat(scriptListener.outermostBlock).isNotNull() 58 | assertThat(scriptListener.outermostBlock!!.name().text).isEqualTo(Blocks.BUILDSCRIPT) 59 | } 60 | 61 | @Test fun `can get enclosing blocks`() { 62 | val buildScript = 63 | """ 64 | subprojects { 65 | apply(plugin = "kotlin") 66 | buildscript { 67 | repositories {} 68 | } 69 | } 70 | 71 | apply(mapOf("plugin" to "foo")) 72 | """.trimIndent() 73 | 74 | val scriptListener = Parser( 75 | file = buildScript, 76 | errorListener = TestErrorListener { 77 | throw RuntimeException("Syntax error: ${it?.message}", it) 78 | }, 79 | listenerFactory = { _, _, _ -> TestListener() } 80 | ).listener() 81 | 82 | assertThat(scriptListener.applyByMapParentBlock).isNull() 83 | assertThat(scriptListener.repositoriesGrandParentBlock).isEqualTo(Blocks.SUBPROJECTS) 84 | assertThat(scriptListener.repositoriesParentBlock).isEqualTo(Blocks.BUILDSCRIPT) 85 | } 86 | 87 | @Test fun `iterating named blocks`() { 88 | val buildScript = 89 | """ 90 | subprojects { 91 | buildscript { 92 | } 93 | } 94 | dependencies { 95 | } 96 | apply(mapOf("plugin" to "foo")) 97 | someOtherBlock { 98 | } 99 | """.trimIndent() 100 | 101 | val blocks = mutableListOf() 102 | buildScript.process { script -> 103 | Blocks.forEachNamedBlock(script.statement()) { block -> 104 | blocks.add(block.name().text) 105 | } 106 | } 107 | 108 | assertThat(blocks).containsExactly(Blocks.SUBPROJECTS, Blocks.DEPENDENCIES, "someOtherBlock") 109 | } 110 | 111 | private class TestListener : KotlinParserBaseListener() { 112 | 113 | private val blockStack = ArrayDeque() 114 | var outermostBlock: NamedBlockContext? = null 115 | 116 | var repositoriesGrandParentBlock: String? = null 117 | var repositoriesParentBlock: String? = null 118 | var applyByMapParentBlock: String? = null 119 | 120 | override fun enterNamedBlock(ctx: NamedBlockContext) { 121 | blockStack.addFirst(ctx) 122 | } 123 | 124 | override fun exitNamedBlock(ctx: NamedBlockContext) { 125 | if (ctx.isRepositories) { 126 | outermostBlock = Blocks.getOutermostBlock(blockStack) 127 | repositoriesParentBlock = Blocks.enclosingNamedBlock(ctx) // buildscript 128 | repositoriesGrandParentBlock = Blocks.enclosingNamedBlock( 129 | ctx, 130 | Blocks.SUBPROJECTS, 131 | ) // subprojects 132 | } 133 | 134 | blockStack.removeFirst() 135 | } 136 | 137 | override fun enterSimpleIdentifier(ctx: SimpleIdentifierContext) { 138 | if (ctx.text == "mapOf") { 139 | applyByMapParentBlock = Blocks.enclosingNamedBlock(ctx) // null 140 | } 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /core/src/test/kotlin/cash/grammar/kotlindsl/utils/CommentsInBlockRemoverTest.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.junit.jupiter.api.Test 5 | 6 | class CommentsInBlockRemoverTest { 7 | @Test 8 | fun `remove all comments in the given block`() { 9 | // Given 10 | val buildScript = """ 11 | |dependencies { 12 | | /* This is a block comment 13 | | that spans multiple lines */ 14 | | implementation("org.jetbrains.kotlin:kotlin-stdlib") // This is an line comment 15 | | // This is a single-line comment 16 | | testImplementation("org.junit.jupiter:junit-jupiter") 17 | | // This is another single-line comment 18 | |} 19 | | 20 | |// This is project bar 21 | |project.name = bar 22 | | 23 | |otherBlock { 24 | | // More comments 25 | |} 26 | | 27 | """.trimMargin() 28 | 29 | // When 30 | val commentsInBlockRemover = CommentsInBlockRemover.of(buildScript, "dependencies") 31 | val rewrittenBuildScript = commentsInBlockRemover.rewritten() 32 | 33 | // Then 34 | assertThat(rewrittenBuildScript).isEqualTo(""" 35 | |dependencies { 36 | | implementation("org.jetbrains.kotlin:kotlin-stdlib") 37 | | testImplementation("org.junit.jupiter:junit-jupiter") 38 | |} 39 | | 40 | |// This is project bar 41 | |project.name = bar 42 | | 43 | |otherBlock { 44 | | // More comments 45 | |} 46 | | 47 | """.trimMargin()) 48 | assertThat(commentsInBlockRemover.getFoundRemovableComments()).containsOnly( 49 | "/* This is a block comment\n that spans multiple lines */", 50 | "// This is an line comment", 51 | "// This is a single-line comment", 52 | "// This is another single-line comment", 53 | ) 54 | } 55 | } -------------------------------------------------------------------------------- /core/src/test/kotlin/cash/grammar/kotlindsl/utils/CommentsTest.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import cash.grammar.kotlindsl.model.DependencyDeclaration 4 | import cash.grammar.kotlindsl.model.DependencyDeclaration.Capability 5 | import cash.grammar.kotlindsl.model.DependencyDeclaration.Identifier.Companion.asSimpleIdentifier 6 | import cash.grammar.kotlindsl.model.DependencyDeclaration.Type 7 | import cash.grammar.kotlindsl.parse.Parser 8 | import cash.grammar.kotlindsl.utils.test.TestErrorListener 9 | import org.assertj.core.api.Assertions.assertThat 10 | import org.junit.jupiter.api.Test 11 | 12 | internal class CommentsTest { 13 | 14 | @Test fun `can find comments`() { 15 | val buildScript = 16 | """ 17 | dependencies { 18 | // This is a 19 | // comment 20 | implementation(libs.lib) 21 | /* 22 | * Here's a multiline comment. 23 | */ 24 | implementation(deps.bar) 25 | } 26 | """.trimIndent() 27 | 28 | val scriptListener = Parser( 29 | file = buildScript, 30 | errorListener = TestErrorListener { 31 | throw RuntimeException("Syntax error: ${it?.message}", it) 32 | }, 33 | listenerFactory = { input, tokens, _ -> TestListener(input, tokens) } 34 | ).listener() 35 | 36 | assertThat(scriptListener.dependencyDeclarations).containsExactly( 37 | DependencyDeclaration( 38 | configuration = "implementation", 39 | identifier = "libs.lib".asSimpleIdentifier()!!, 40 | capability = Capability.DEFAULT, 41 | type = Type.MODULE, 42 | fullText = "implementation(libs.lib)", 43 | precedingComment = "// This is a\n// comment", 44 | ), 45 | DependencyDeclaration( 46 | configuration = "implementation", 47 | identifier = "deps.bar".asSimpleIdentifier()!!, 48 | capability = Capability.DEFAULT, 49 | type = Type.MODULE, 50 | fullText = "implementation(deps.bar)", 51 | precedingComment = "/*\n * Here's a multiline comment.\n */", 52 | ), 53 | ) 54 | } 55 | 56 | @Test fun `can find all comments in blocks`() { 57 | val buildScript = 58 | """ 59 | dependencies { 60 | // This is a 61 | // comment 62 | implementation(libs.lib) // Inline comments 63 | /* 64 | * Here's a multiline comment. 65 | */ 66 | implementation(deps.bar) 67 | } 68 | 69 | emptyBlock { } 70 | """.trimIndent() 71 | 72 | val scriptListener = Parser( 73 | file = buildScript, 74 | errorListener = TestErrorListener { 75 | throw RuntimeException("Syntax error: ${it?.message}", it) 76 | }, 77 | listenerFactory = { input, tokens, _ -> TestListener(input, tokens) } 78 | ).listener() 79 | 80 | assertThat(scriptListener.commentTokens["dependencies"]?.map { it.text }).containsExactly( 81 | "// This is a", 82 | "// comment", 83 | "// Inline comments", 84 | "/*\n * Here's a multiline comment.\n */" 85 | ) 86 | assertThat(scriptListener.commentTokens["emptyBlock"]?.map { it.text }).isEmpty() 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /core/src/test/kotlin/cash/grammar/kotlindsl/utils/ContextTest.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import cash.grammar.kotlindsl.parse.Parser 4 | import cash.grammar.kotlindsl.utils.Blocks.isSubprojects 5 | import cash.grammar.kotlindsl.utils.Context.fullText 6 | import cash.grammar.kotlindsl.utils.test.TestErrorListener 7 | import com.squareup.cash.grammar.KotlinParser.NamedBlockContext 8 | import com.squareup.cash.grammar.KotlinParserBaseListener 9 | import org.antlr.v4.runtime.CharStream 10 | import org.assertj.core.api.Assertions.assertThat 11 | import org.junit.jupiter.api.Test 12 | 13 | internal class ContextTest { 14 | 15 | /** 16 | * This test demonstrates the difference between 17 | * [ParserRuleContext.text][org.antlr.v4.runtime.ParserRuleContext.getText] and 18 | * [ctx.fullText(input)][Context.fullText]. 19 | */ 20 | @Test fun `fullText contains actual complete user text`() { 21 | val buildScript = 22 | """ 23 | subprojects { 24 | // comments and whitespace are ignored by `ParseRuleContext.text` 25 | buildscript { 26 | repositories {} 27 | } 28 | } 29 | """.trimIndent() 30 | 31 | val scriptListener = Parser( 32 | file = buildScript, 33 | errorListener = TestErrorListener { 34 | throw RuntimeException("Syntax error: ${it?.message}", it) 35 | }, 36 | listenerFactory = { input, _, _ -> TestListener(input) } 37 | ).listener() 38 | 39 | assertThat(scriptListener.text).isEqualTo( 40 | """ 41 | subprojects{ 42 | 43 | buildscript{ 44 | repositories{} 45 | } 46 | } 47 | """.trimIndent() 48 | ) 49 | assertThat(scriptListener.fullText).isEqualTo( 50 | """ 51 | subprojects { 52 | // comments and whitespace are ignored by `ParseRuleContext.text` 53 | buildscript { 54 | repositories {} 55 | } 56 | } 57 | """.trimIndent() 58 | ) 59 | } 60 | 61 | private class TestListener( 62 | private val input: CharStream 63 | ) : KotlinParserBaseListener() { 64 | 65 | var text = "" 66 | var fullText = "" 67 | 68 | override fun exitNamedBlock(ctx: NamedBlockContext) { 69 | if (ctx.isSubprojects) { 70 | text = ctx.text 71 | fullText = ctx.fullText(input)!! 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /core/src/test/kotlin/cash/grammar/kotlindsl/utils/PluginExtractorTest.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import cash.grammar.kotlindsl.model.Plugin 4 | import cash.grammar.kotlindsl.parse.Parser 5 | import cash.grammar.kotlindsl.utils.Blocks.isPlugins 6 | import cash.grammar.kotlindsl.utils.Context.leafRule 7 | import cash.grammar.kotlindsl.utils.test.TestErrorListener 8 | import com.squareup.cash.grammar.KotlinParser.NamedBlockContext 9 | import com.squareup.cash.grammar.KotlinParser.PostfixUnaryExpressionContext 10 | import com.squareup.cash.grammar.KotlinParserBaseListener 11 | import org.antlr.v4.runtime.tree.TerminalNode 12 | import org.assertj.core.api.Assertions.assertThat 13 | import org.junit.jupiter.api.Test 14 | 15 | internal class PluginExtractorTest { 16 | @Test fun `can extract all plugin types`() { 17 | val buildScript = """ 18 | plugins { 19 | id("by-id") 20 | kotlin("jvm") 21 | application 22 | `kotlin-dsl` 23 | alias(libs.plugins.by.alias) 24 | } 25 | 26 | apply(plugin = "by-apply") 27 | apply(mapOf("plugin" to "by-map")) 28 | """.trimIndent() 29 | 30 | val scriptListener = Parser( 31 | file = buildScript, 32 | errorListener = TestErrorListener { 33 | throw RuntimeException("Syntax error: ${it?.message}", it) 34 | }, 35 | listenerFactory = { _, _, _ -> TestListener() } 36 | ).listener() 37 | 38 | assertThat(scriptListener.plugins).containsExactly( 39 | Plugin(Plugin.Type.BLOCK_ID, "by-id"), 40 | Plugin(Plugin.Type.BLOCK_KOTLIN, "jvm"), 41 | Plugin(Plugin.Type.BLOCK_SIMPLE, "application"), 42 | Plugin(Plugin.Type.BLOCK_BACKTICK, "kotlin-dsl"), 43 | Plugin(Plugin.Type.BLOCK_ALIAS, "libs.plugins.by.alias"), 44 | Plugin(Plugin.Type.APPLY, "by-apply"), 45 | Plugin(Plugin.Type.APPLY, "by-map"), 46 | ) 47 | } 48 | 49 | @Test fun `can extract all plugin types with configurations`() { 50 | val buildScript = """ 51 | plugins { 52 | id("by-id") apply false 53 | kotlin("jvm") apply false 54 | application apply false 55 | `kotlin-dsl` apply false 56 | alias(libs.plugins.by.alias) apply false 57 | 58 | id("by-id-with-version") version libs.byId.get().version 59 | id("by-id-with-version-and-applied") version libs.byId.get().version apply false 60 | } 61 | """.trimIndent() 62 | 63 | val scriptListener = Parser( 64 | file = buildScript, 65 | errorListener = TestErrorListener { 66 | throw RuntimeException("Syntax error: ${it?.message}", it) 67 | }, 68 | listenerFactory = { _, _, _ -> TestListener() } 69 | ).listener() 70 | 71 | assertThat(scriptListener.plugins).containsExactly( 72 | Plugin(Plugin.Type.BLOCK_ID, "by-id", applied = false), 73 | Plugin(Plugin.Type.BLOCK_KOTLIN, "jvm", applied = false), 74 | Plugin(Plugin.Type.BLOCK_SIMPLE, "application", applied = false), 75 | Plugin(Plugin.Type.BLOCK_BACKTICK, "kotlin-dsl", applied = false), 76 | Plugin(Plugin.Type.BLOCK_ALIAS, "libs.plugins.by.alias", applied = false), 77 | Plugin(Plugin.Type.BLOCK_ID, "by-id-with-version", version = "libs.byId.get().version"), 78 | Plugin(Plugin.Type.BLOCK_ID, "by-id-with-version-and-applied", version = "libs.byId.get().version", applied = false), 79 | ) 80 | } 81 | 82 | private class TestListener : KotlinParserBaseListener() { 83 | 84 | private val blockStack = ArrayDeque() 85 | 86 | val plugins = mutableListOf() 87 | 88 | override fun enterNamedBlock(ctx: NamedBlockContext) { 89 | blockStack.addFirst(ctx) 90 | } 91 | 92 | override fun exitNamedBlock(ctx: NamedBlockContext) { 93 | if (ctx.isPlugins) { 94 | ctx.statements().statement() 95 | .filterNot { it is TerminalNode } 96 | .map { it.leafRule() } 97 | .forEach { line -> 98 | PluginExtractor.extractFromBlock(line)?.let { plugin -> 99 | plugins.add(plugin) 100 | } 101 | } 102 | } 103 | 104 | blockStack.removeFirst() 105 | } 106 | 107 | override fun exitPostfixUnaryExpression(ctx: PostfixUnaryExpressionContext) { 108 | if (!PluginExtractor.scriptLikeContext(blockStack)) return 109 | 110 | PluginExtractor.extractFromScript(ctx)?.let { plugin -> 111 | plugins.add(plugin) 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /core/src/test/kotlin/cash/grammar/kotlindsl/utils/PluginFinderTest.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import cash.grammar.kotlindsl.model.Plugin 4 | import org.assertj.core.api.Assertions.assertThat 5 | import org.junit.jupiter.api.Test 6 | 7 | class PluginFinderTest { 8 | 9 | @Test fun `should find all plugins`() { 10 | val buildScript = """ 11 | buildscript { 12 | repositories { 13 | maven(url = "https://foo") 14 | } 15 | } 16 | 17 | plugins { 18 | id("cash.server.artifact-group") version libs.cashServerConventionPlugins.get().version 19 | application 20 | alias(libs.plugins.cashServerArtifactGroup) 21 | alias(libs.plugins.dockerCompose) apply false 22 | kotlin("plugin.serialization") 23 | } 24 | 25 | apply(plugin = "kotlin") 26 | apply(plugin = "com.github.johnrengelman.shadow") 27 | apply(mapOf("plugin" to "foo")) 28 | 29 | """.trimIndent() 30 | 31 | val pluginFinder = PluginFinder.of(buildScript) 32 | 33 | assertThat(pluginFinder.plugins).isEqualTo( 34 | setOf( 35 | Plugin(Plugin.Type.BLOCK_ID, "cash.server.artifact-group", version = "libs.cashServerConventionPlugins.get().version"), 36 | Plugin(Plugin.Type.BLOCK_SIMPLE, "application"), 37 | Plugin(Plugin.Type.BLOCK_ALIAS, "libs.plugins.cashServerArtifactGroup"), 38 | Plugin(Plugin.Type.BLOCK_ALIAS, "libs.plugins.dockerCompose", applied = false), 39 | Plugin(Plugin.Type.BLOCK_KOTLIN, "plugin.serialization"), 40 | Plugin(Plugin.Type.APPLY, "kotlin"), 41 | Plugin(Plugin.Type.APPLY, "com.github.johnrengelman.shadow"), 42 | Plugin(Plugin.Type.APPLY, "foo"), 43 | ) 44 | ) 45 | } 46 | 47 | @Test fun `finds plugins, ignoring enclosing blocks`() { 48 | val buildScript = """ 49 | plugins { 50 | kotlin("jvm") 51 | `java-gradle-plugin` 52 | `maven-publish` 53 | } 54 | 55 | gradlePlugin { 56 | plugins { 57 | create("my-plugin") { 58 | id = "com.test.example" 59 | displayName = "Example" 60 | implementationClass = "com.test.example.ExamplePlugin" 61 | } 62 | } 63 | } 64 | """.trimIndent() 65 | 66 | val pluginFinder = PluginFinder.of(buildScript) 67 | 68 | assertThat(pluginFinder.plugins).isEqualTo( 69 | setOf( 70 | Plugin(Plugin.Type.BLOCK_KOTLIN, "jvm"), 71 | Plugin(Plugin.Type.BLOCK_BACKTICK, "java-gradle-plugin"), 72 | Plugin(Plugin.Type.BLOCK_BACKTICK, "maven-publish"), 73 | ) 74 | ) 75 | } 76 | 77 | @Test fun `can extract plugin IDs from allprojects blocks`() { 78 | // ads-advertiser/build.gradle.kts 79 | val buildScript = """ 80 | import org.jmailen.gradle.kotlinter.tasks.LintTask 81 | 82 | buildscript { 83 | repositories { 84 | maven(url = "https://maven.global.square/artifactory/square-public") 85 | } 86 | 87 | dependencies { 88 | classpath(libs.kotlinxKover) 89 | classpath(platform(libs.kotlinGradleBom)) 90 | classpath(libs.kotlinGradlePlugin) 91 | classpath(libs.cashDevSourcesPlugin) 92 | classpath(libs.kotlinter) 93 | classpath(libs.cashPolyrepoPlugin) 94 | classpath(libs.protobufGradlePlugin) 95 | classpath(libs.schemaRegistryPlugin) 96 | classpath(libs.shadowJarPlugin) 97 | } 98 | } 99 | 100 | allprojects { 101 | apply(plugin = "polyrepo-plugin") 102 | apply(plugin = "org.jmailen.kotlinter") 103 | 104 | tasks { 105 | withType().configureEach { 106 | // The linter checks all sources, which include ones that rely on generated classes. 107 | when (project.name) { 108 | // "client" -> dependsOn("generateMainProtos") 109 | "service" -> dependsOn("generateProto") 110 | else -> {} 111 | } 112 | // Excludes generated sources. Add other source paths here as necessary. 113 | 114 | // Exclude commonly used location for generated sources 115 | exclude { 116 | it.file.absolutePath.contains("/generated/source/") 117 | } 118 | } 119 | } 120 | } 121 | 122 | subprojects { 123 | } 124 | """.trimIndent() 125 | 126 | val pluginFinder = PluginFinder.of(buildScript) 127 | 128 | assertThat(pluginFinder.plugins).isEqualTo( 129 | setOf( 130 | Plugin(Plugin.Type.APPLY, "polyrepo-plugin"), 131 | Plugin(Plugin.Type.APPLY, "org.jmailen.kotlinter"), 132 | ) 133 | ) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /core/src/test/kotlin/cash/grammar/kotlindsl/utils/TestListener.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import cash.grammar.kotlindsl.model.DependencyDeclaration 4 | import cash.grammar.kotlindsl.utils.Blocks.isBuildscript 5 | import cash.grammar.kotlindsl.utils.Blocks.isDependencies 6 | import cash.grammar.kotlindsl.utils.Blocks.isSubprojects 7 | import cash.grammar.kotlindsl.utils.Context.fullText 8 | import com.squareup.cash.grammar.KotlinParser 9 | import com.squareup.cash.grammar.KotlinParserBaseListener 10 | import org.antlr.v4.runtime.CharStream 11 | import org.antlr.v4.runtime.CommonTokenStream 12 | import org.antlr.v4.runtime.Token 13 | 14 | internal class TestListener( 15 | private val input: CharStream, 16 | private val tokens: CommonTokenStream, 17 | private val defaultIndent: String = " ", 18 | ) : KotlinParserBaseListener() { 19 | 20 | var newlines: List? = null 21 | var whitespace: List? = null 22 | val trailingBuildscriptNewlines = Whitespace.countTerminalNewlines(tokens) 23 | val trailingKotlinFileNewlines = Whitespace.countTerminalNewlines(tokens) 24 | val indent = Whitespace.computeIndent(tokens, input, defaultIndent) 25 | val dependencyExtractor = DependencyExtractor(input, tokens, indent) 26 | val comments = Comments(tokens, indent) 27 | 28 | val dependencyDeclarations = mutableListOf() 29 | val dependencyDeclarationsStatements = mutableListOf() 30 | val statements = mutableListOf() 31 | val commentTokens = mutableMapOf>() 32 | 33 | override fun exitNamedBlock(ctx: KotlinParser.NamedBlockContext) { 34 | if (ctx.isSubprojects) { 35 | newlines = Whitespace.getBlankSpaceToLeft(tokens, ctx) 36 | } 37 | if (ctx.isBuildscript) { 38 | whitespace = Whitespace.getWhitespaceToLeft(tokens, ctx) 39 | } 40 | if (ctx.isDependencies) { 41 | val dependencyContainer = dependencyExtractor.collectDependencies(ctx) 42 | 43 | dependencyDeclarations += dependencyContainer.getDependencyDeclarations() 44 | dependencyDeclarationsStatements += dependencyContainer.getDependencyDeclarationsWithContext().map { it.statement.fullText(input)!!.trim() } 45 | statements += dependencyContainer.getStatements().map { it.fullText(input)!!.trim() } 46 | } 47 | 48 | commentTokens[ctx.name().text] = comments.getCommentsInBlock(ctx) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /core/src/test/kotlin/cash/grammar/kotlindsl/utils/WhitespaceTest.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils 2 | 3 | import cash.grammar.kotlindsl.parse.Parser 4 | import cash.grammar.kotlindsl.utils.test.TestErrorListener 5 | import org.assertj.core.api.Assertions.assertThat 6 | import org.assertj.core.api.Assertions.tuple 7 | import org.junit.jupiter.api.Test 8 | import org.junit.jupiter.params.ParameterizedTest 9 | import org.junit.jupiter.params.provider.MethodSource 10 | 11 | internal class WhitespaceTest { 12 | 13 | @Test fun `can extract newlines`() { 14 | val buildScript = 15 | """ 16 | plugins { 17 | id("kotlin") 18 | } 19 | 20 | subprojects { 21 | buildscript { 22 | repositories {} 23 | } 24 | } 25 | """.trimIndent() 26 | 27 | val scriptListener = Parser( 28 | file = buildScript, 29 | errorListener = TestErrorListener { 30 | throw RuntimeException("Syntax error: ${it?.message}", it) 31 | }, 32 | listenerFactory = { input, tokens, _ -> TestListener(input, tokens) } 33 | ).listener() 34 | 35 | // There are two newlines preceding the 'subprojects' block 36 | assertThat(scriptListener.newlines).isNotNull() 37 | assertThat(scriptListener.newlines!!.size).isEqualTo(2) 38 | assertThat(scriptListener.newlines!!) 39 | .extracting({ it.text }) 40 | .allSatisfy { assertThat(it).isEqualTo(tuple("\n")) } 41 | } 42 | 43 | @Test fun `can extract whitespace`() { 44 | val buildScript = 45 | """ 46 | plugins { 47 | id("kotlin") 48 | } 49 | 50 | subprojects { 51 | buildscript { 52 | repositories {} 53 | } 54 | } 55 | """.trimIndent() 56 | 57 | val scriptListener = Parser( 58 | file = buildScript, 59 | errorListener = TestErrorListener { 60 | throw RuntimeException("Syntax error: ${it?.message}", it) 61 | }, 62 | listenerFactory = { input, tokens, _ -> TestListener(input, tokens) } 63 | ).listener() 64 | 65 | // There are two spaces preceding the 'buildscript' block (on the same line) 66 | assertThat(scriptListener.whitespace).isNotNull() 67 | assertThat(scriptListener.whitespace!!.size).isEqualTo(2) 68 | assertThat(scriptListener.whitespace!!) 69 | .extracting({ it.text }) 70 | .allSatisfy { assertThat(it).isEqualTo(tuple(" ")) } 71 | } 72 | 73 | @Test fun `gets trailing newlines for buildscript`() { 74 | val buildScript = 75 | """ 76 | plugins { 77 | id("kotlin") 78 | } 79 | 80 | subprojects { 81 | buildscript { 82 | repositories {} 83 | } 84 | } 85 | 86 | """.trimIndent() 87 | 88 | val scriptListener = Parser( 89 | file = buildScript, 90 | errorListener = TestErrorListener { 91 | throw RuntimeException("Syntax error: ${it?.message}", it) 92 | }, 93 | listenerFactory = { input, tokens, _ -> TestListener(input, tokens) } 94 | ).listener() 95 | 96 | assertThat(scriptListener.trailingBuildscriptNewlines).isEqualTo(1) 97 | } 98 | 99 | @Test fun `gets trailing newlines for kotlin file`() { 100 | val file = 101 | """ 102 | class Foo { 103 | } 104 | 105 | """.trimIndent() 106 | 107 | val scriptListener = Parser( 108 | file = file.byteInputStream(), 109 | errorListener = TestErrorListener { 110 | throw RuntimeException("Syntax error: ${it?.message}", it) 111 | }, 112 | startRule = { parser -> parser.kotlinFile() }, 113 | listenerFactory = { input, tokens, _ -> TestListener(input, tokens) } 114 | ).listener() 115 | 116 | assertThat(scriptListener.trailingKotlinFileNewlines).isEqualTo(1) 117 | } 118 | 119 | @ParameterizedTest(name = "{0}") 120 | @MethodSource("indentationCases") 121 | fun `can discover indentation`(testCase: TestCase) { 122 | val parser = Parser( 123 | file = testCase.sourceText, 124 | errorListener = TestErrorListener { 125 | throw RuntimeException("Syntax error: ${it?.message}", it) 126 | }, 127 | listenerFactory = { input, tokens, _ -> 128 | TestListener( 129 | input = input, 130 | tokens = tokens, 131 | defaultIndent = testCase.defaultIndent, 132 | ) 133 | } 134 | ).listener() 135 | 136 | assertThat(parser.indent).isEqualTo(testCase.expectedIndent) 137 | } 138 | 139 | private companion object { 140 | @JvmStatic fun indentationCases() = listOf( 141 | TestCase( 142 | displayName = "two spaces", 143 | sourceText = """ 144 | plugins { 145 | id("kotlin") 146 | } 147 | """.trimIndent(), 148 | expectedIndent = " ", 149 | ), 150 | TestCase( 151 | displayName = "four spaces", 152 | sourceText = """ 153 | plugins { 154 | id("kotlin") 155 | } 156 | """.trimIndent(), 157 | expectedIndent = " ", 158 | ), 159 | TestCase( 160 | displayName = "tab", 161 | sourceText = "plugins {\n\tid(\"kotlin\")\n}", 162 | expectedIndent = "\t", 163 | ), 164 | TestCase( 165 | displayName = "mixed spaces and tab", 166 | sourceText = "plugins {\n\t id(\"kotlin\")\n}", 167 | expectedIndent = "\t ", 168 | ), 169 | TestCase( 170 | displayName = "defaults to two spaces", 171 | sourceText = """ 172 | package com.example 173 | 174 | class Foo 175 | """.trimIndent(), 176 | expectedIndent = " ", 177 | ), 178 | TestCase( 179 | displayName = "ignores empty lines", 180 | // the line between `package...` and `class...` contains a single space -- don't count this 181 | sourceText = "package com.example\n \nclass Foo", 182 | expectedIndent = " ", 183 | ), 184 | TestCase( 185 | displayName = "can change default to tab", 186 | sourceText = """ 187 | package com.example 188 | 189 | class Foo 190 | """.trimIndent(), 191 | defaultIndent = "\t", 192 | expectedIndent = "\t", 193 | ), 194 | ) 195 | } 196 | 197 | internal class TestCase( 198 | val displayName: String, 199 | val sourceText: String, 200 | val defaultIndent: String = " ", 201 | val expectedIndent: String, 202 | ) { 203 | override fun toString(): String = displayName 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /core/src/test/kotlin/cash/grammar/kotlindsl/utils/test/TestErrorListener.kt: -------------------------------------------------------------------------------- 1 | package cash.grammar.kotlindsl.utils.test 2 | 3 | import cash.grammar.kotlindsl.parse.SimpleErrorListener 4 | import org.antlr.v4.runtime.RecognitionException 5 | import org.antlr.v4.runtime.Recognizer 6 | 7 | internal class TestErrorListener( 8 | private val onError: (RuntimeException?) -> Unit 9 | ) : SimpleErrorListener() { 10 | 11 | override fun syntaxError( 12 | recognizer: Recognizer<*, *>?, 13 | offendingSymbol: Any?, 14 | line: Int, 15 | charPositionInLine: Int, 16 | msg: String, 17 | e: RecognitionException? 18 | ) { 19 | onError.invoke(e) 20 | } 21 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:MaxMetaspaceSize=1024m 2 | 3 | org.gradle.caching=true 4 | org.gradle.parallel=true 5 | org.gradle.configuration-cache=true 6 | 7 | dependency.analysis.print.build.health=true 8 | 9 | # https://kotlinlang.org/docs/gradle-configure-project.html#dependency-on-the-standard-library 10 | kotlin.stdlib.default.dependency=false 11 | 12 | # See BasePlugin 13 | GROUP=app.cash.kotlin-editor 14 | VERSION=0.19-SNAPSHOT 15 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | antlr = "4.13.2" 3 | dependencyAnalysis = "2.6.1" 4 | develocity = "3.19" 5 | java = "11" 6 | junit-jupiter = "5.11.4" 7 | kotlin = "1.9.25" 8 | mavenPublish = "0.30.0" 9 | 10 | [libraries] 11 | # antlr-core is referenced from build-logic 12 | antlr-core = { module = "org.antlr:antlr4", version.ref = "antlr" } 13 | antlr-runtime = { module = "org.antlr:antlr4-runtime", version.ref = "antlr" } 14 | # assertj is referenced from build-logic 15 | assertj = "org.assertj:assertj-core:3.27.2" 16 | dependencyAnalysisPlugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" } 17 | develocityPlugin = { module = "com.gradle:develocity-gradle-plugin", version.ref = "develocity" } 18 | # junit-jupiter-api and junit-jupiter-engine are referenced from build-logic 19 | # note that the junit-jupiter-engine dependency depends on junit-bom 20 | junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit-jupiter" } 21 | junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit-jupiter" } 22 | junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version = "junit-jupiter" } 23 | kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } 24 | kotlinGradlePluginApi = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin-api", version.ref = "kotlin" } 25 | kotlinStdLib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } 26 | mavenPublish = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "mavenPublish" } 27 | 28 | [plugins] 29 | dependencyAnalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" } 30 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 31 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/kotlin-editor/294855c6a208a791834c5946cd2e1b8001466311/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s 90 | ' "$PWD" ) || exit 91 | 92 | # Use the maximum available, or set MAX_FD != -1 to use that value. 93 | MAX_FD=maximum 94 | 95 | warn () { 96 | echo "$*" 97 | } >&2 98 | 99 | die () { 100 | echo 101 | echo "$*" 102 | echo 103 | exit 1 104 | } >&2 105 | 106 | # OS specific support (must be 'true' or 'false'). 107 | cygwin=false 108 | msys=false 109 | darwin=false 110 | nonstop=false 111 | case "$( uname )" in #( 112 | CYGWIN* ) cygwin=true ;; #( 113 | Darwin* ) darwin=true ;; #( 114 | MSYS* | MINGW* ) msys=true ;; #( 115 | NONSTOP* ) nonstop=true ;; 116 | esac 117 | 118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 119 | 120 | 121 | # Determine the Java command to use to start the JVM. 122 | if [ -n "$JAVA_HOME" ] ; then 123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 124 | # IBM's JDK on AIX uses strange locations for the executables 125 | JAVACMD=$JAVA_HOME/jre/sh/java 126 | else 127 | JAVACMD=$JAVA_HOME/bin/java 128 | fi 129 | if [ ! -x "$JAVACMD" ] ; then 130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 131 | 132 | Please set the JAVA_HOME variable in your environment to match the 133 | location of your Java installation." 134 | fi 135 | else 136 | JAVACMD=java 137 | if ! command -v java >/dev/null 2>&1 138 | then 139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 140 | 141 | Please set the JAVA_HOME variable in your environment to match the 142 | location of your Java installation." 143 | fi 144 | fi 145 | 146 | # Increase the maximum file descriptors if we can. 147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 148 | case $MAX_FD in #( 149 | max*) 150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 151 | # shellcheck disable=SC2039,SC3045 152 | MAX_FD=$( ulimit -H -n ) || 153 | warn "Could not query maximum file descriptor limit" 154 | esac 155 | case $MAX_FD in #( 156 | '' | soft) :;; #( 157 | *) 158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 159 | # shellcheck disable=SC2039,SC3045 160 | ulimit -n "$MAX_FD" || 161 | warn "Could not set maximum file descriptor limit to $MAX_FD" 162 | esac 163 | fi 164 | 165 | # Collect all arguments for the java command, stacking in reverse order: 166 | # * args from the command line 167 | # * the main class name 168 | # * -classpath 169 | # * -D...appname settings 170 | # * --module-path (only if needed) 171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 172 | 173 | # For Cygwin or MSYS, switch paths to Windows format before running java 174 | if "$cygwin" || "$msys" ; then 175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 177 | 178 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 179 | 180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 181 | for arg do 182 | if 183 | case $arg in #( 184 | -*) false ;; # don't mess with options #( 185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 186 | [ -e "$t" ] ;; #( 187 | *) false ;; 188 | esac 189 | then 190 | arg=$( cygpath --path --ignore --mixed "$arg" ) 191 | fi 192 | # Roll the args list around exactly as many times as the number of 193 | # args, so each arg winds up back in the position where it started, but 194 | # possibly modified. 195 | # 196 | # NB: a `for` loop captures its iteration list before it begins, so 197 | # changing the positional parameters here affects neither the number of 198 | # iterations, nor the values presented in `arg`. 199 | shift # remove old arg 200 | set -- "$@" "$arg" # push replacement arg 201 | done 202 | fi 203 | 204 | 205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 207 | 208 | # Collect all arguments for the java command: 209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 210 | # and any embedded shellness will be escaped. 211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 212 | # treated as '${Hostname}' itself on the command line. 213 | 214 | set -- \ 215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 216 | -classpath "$CLASSPATH" \ 217 | org.gradle.wrapper.GradleWrapperMain \ 218 | "$@" 219 | 220 | # Stop when "xargs" is not available. 221 | if ! command -v xargs >/dev/null 2>&1 222 | then 223 | die "xargs is not available" 224 | fi 225 | 226 | # Use "xargs" to parse quoted args. 227 | # 228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 229 | # 230 | # In Bash we could simply go: 231 | # 232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 233 | # set -- "${ARGS[@]}" "$@" 234 | # 235 | # but POSIX shell has neither arrays nor command substitution, so instead we 236 | # post-process each arg (as a line of input to sed) to backslash-escape any 237 | # character that might be a shell metacharacter, then use eval to reverse 238 | # that process (while maintaining the separation between arguments), and wrap 239 | # the whole thing up as a single "set" statement. 240 | # 241 | # This will of course break if any of these variables contains a newline or 242 | # an unmatched quote. 243 | # 244 | 245 | eval "set -- $( 246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 247 | xargs -n1 | 248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 249 | tr '\n' ' ' 250 | )" '"$@"' 251 | 252 | exec "$JAVACMD" "$@" 253 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /grammar/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("cash.grammar") 3 | } 4 | -------------------------------------------------------------------------------- /grammar/src/main/antlr/com/squareup/cash/grammar/KotlinLexer.tokens: -------------------------------------------------------------------------------- 1 | ShebangLine=1 2 | DelimitedComment=2 3 | LineComment=3 4 | WS=4 5 | NL=5 6 | RESERVED=6 7 | DOT=7 8 | COMMA=8 9 | LPAREN=9 10 | RPAREN=10 11 | LSQUARE=11 12 | RSQUARE=12 13 | LCURL=13 14 | RCURL=14 15 | MULT=15 16 | MOD=16 17 | DIV=17 18 | ADD=18 19 | SUB=19 20 | INCR=20 21 | DECR=21 22 | CONJ=22 23 | DISJ=23 24 | EXCL_WS=24 25 | EXCL_NO_WS=25 26 | COLON=26 27 | SEMICOLON=27 28 | ASSIGNMENT=28 29 | ADD_ASSIGNMENT=29 30 | SUB_ASSIGNMENT=30 31 | MULT_ASSIGNMENT=31 32 | DIV_ASSIGNMENT=32 33 | MOD_ASSIGNMENT=33 34 | ARROW=34 35 | DOUBLE_ARROW=35 36 | RANGE=36 37 | RANGE_UNTIL=37 38 | COLONCOLON=38 39 | DOUBLE_SEMICOLON=39 40 | HASH=40 41 | AT_NO_WS=41 42 | AT_POST_WS=42 43 | AT_PRE_WS=43 44 | AT_BOTH_WS=44 45 | QUEST_WS=45 46 | QUEST_NO_WS=46 47 | LANGLE=47 48 | RANGLE=48 49 | LE=49 50 | GE=50 51 | EXCL_EQ=51 52 | EXCL_EQEQ=52 53 | AS_SAFE=53 54 | EQEQ=54 55 | EQEQEQ=55 56 | SINGLE_QUOTE=56 57 | AMP=57 58 | RETURN_AT=58 59 | CONTINUE_AT=59 60 | BREAK_AT=60 61 | THIS_AT=61 62 | SUPER_AT=62 63 | FILE=63 64 | FIELD=64 65 | PROPERTY=65 66 | GET=66 67 | SET=67 68 | RECEIVER=68 69 | PARAM=69 70 | SETPARAM=70 71 | DELEGATE=71 72 | PACKAGE=72 73 | IMPORT=73 74 | CLASS=74 75 | INTERFACE=75 76 | FUN=76 77 | OBJECT=77 78 | VAL=78 79 | VAR=79 80 | TYPE_ALIAS=80 81 | CONSTRUCTOR=81 82 | BY=82 83 | COMPANION=83 84 | INIT=84 85 | THIS=85 86 | SUPER=86 87 | TYPEOF=87 88 | WHERE=88 89 | IF=89 90 | ELSE=90 91 | WHEN=91 92 | TRY=92 93 | CATCH=93 94 | FINALLY=94 95 | FOR=95 96 | DO=96 97 | WHILE=97 98 | THROW=98 99 | RETURN=99 100 | CONTINUE=100 101 | BREAK=101 102 | AS=102 103 | IS=103 104 | IN=104 105 | NOT_IS=105 106 | NOT_IN=106 107 | OUT=107 108 | DYNAMIC=108 109 | PUBLIC=109 110 | PRIVATE=110 111 | PROTECTED=111 112 | INTERNAL=112 113 | ENUM=113 114 | SEALED=114 115 | ANNOTATION=115 116 | DATA=116 117 | INNER=117 118 | VALUE=118 119 | TAILREC=119 120 | OPERATOR=120 121 | INLINE=121 122 | INFIX=122 123 | EXTERNAL=123 124 | SUSPEND=124 125 | OVERRIDE=125 126 | ABSTRACT=126 127 | FINAL=127 128 | OPEN=128 129 | CONST=129 130 | LATEINIT=130 131 | VARARG=131 132 | NOINLINE=132 133 | CROSSINLINE=133 134 | REIFIED=134 135 | EXPECT=135 136 | ACTUAL=136 137 | RealLiteral=137 138 | FloatLiteral=138 139 | DoubleLiteral=139 140 | IntegerLiteral=140 141 | HexLiteral=141 142 | BinLiteral=142 143 | UnsignedLiteral=143 144 | LongLiteral=144 145 | BooleanLiteral=145 146 | NullLiteral=146 147 | CharacterLiteral=147 148 | Identifier=148 149 | IdentifierOrSoftKey=149 150 | FieldIdentifier=150 151 | QUOTE_OPEN=151 152 | TRIPLE_QUOTE_OPEN=152 153 | UNICODE_CLASS_LL=153 154 | UNICODE_CLASS_LM=154 155 | UNICODE_CLASS_LO=155 156 | UNICODE_CLASS_LT=156 157 | UNICODE_CLASS_LU=157 158 | UNICODE_CLASS_ND=158 159 | UNICODE_CLASS_NL=159 160 | QUOTE_CLOSE=160 161 | LineStrRef=161 162 | LineStrText=162 163 | LineStrEscapedChar=163 164 | LineStrExprStart=164 165 | TRIPLE_QUOTE_CLOSE=165 166 | MultiLineStringQuote=166 167 | MultiLineStrRef=167 168 | MultiLineStrText=168 169 | MultiLineStrExprStart=169 170 | Inside_Comment=170 171 | Inside_WS=171 172 | Inside_NL=172 173 | ErrorCharacter=173 174 | '...'=6 175 | '.'=7 176 | ','=8 177 | '('=9 178 | ')'=10 179 | '['=11 180 | ']'=12 181 | '{'=13 182 | '}'=14 183 | '*'=15 184 | '%'=16 185 | '/'=17 186 | '+'=18 187 | '-'=19 188 | '++'=20 189 | '--'=21 190 | '&&'=22 191 | '||'=23 192 | '!'=25 193 | ':'=26 194 | ';'=27 195 | '='=28 196 | '+='=29 197 | '-='=30 198 | '*='=31 199 | '/='=32 200 | '%='=33 201 | '->'=34 202 | '=>'=35 203 | '..'=36 204 | '..<'=37 205 | '::'=38 206 | ';;'=39 207 | '#'=40 208 | '@'=41 209 | '?'=46 210 | '<'=47 211 | '>'=48 212 | '<='=49 213 | '>='=50 214 | '!='=51 215 | '!=='=52 216 | 'as?'=53 217 | '=='=54 218 | '==='=55 219 | '\''=56 220 | '&'=57 221 | 'file'=63 222 | 'field'=64 223 | 'property'=65 224 | 'get'=66 225 | 'set'=67 226 | 'receiver'=68 227 | 'param'=69 228 | 'setparam'=70 229 | 'delegate'=71 230 | 'package'=72 231 | 'import'=73 232 | 'class'=74 233 | 'interface'=75 234 | 'fun'=76 235 | 'object'=77 236 | 'val'=78 237 | 'var'=79 238 | 'typealias'=80 239 | 'constructor'=81 240 | 'by'=82 241 | 'companion'=83 242 | 'init'=84 243 | 'this'=85 244 | 'super'=86 245 | 'typeof'=87 246 | 'where'=88 247 | 'if'=89 248 | 'else'=90 249 | 'when'=91 250 | 'try'=92 251 | 'catch'=93 252 | 'finally'=94 253 | 'for'=95 254 | 'do'=96 255 | 'while'=97 256 | 'throw'=98 257 | 'return'=99 258 | 'continue'=100 259 | 'break'=101 260 | 'as'=102 261 | 'is'=103 262 | 'in'=104 263 | 'out'=107 264 | 'dynamic'=108 265 | 'public'=109 266 | 'private'=110 267 | 'protected'=111 268 | 'internal'=112 269 | 'enum'=113 270 | 'sealed'=114 271 | 'annotation'=115 272 | 'data'=116 273 | 'inner'=117 274 | 'value'=118 275 | 'tailrec'=119 276 | 'operator'=120 277 | 'inline'=121 278 | 'infix'=122 279 | 'external'=123 280 | 'suspend'=124 281 | 'override'=125 282 | 'abstract'=126 283 | 'final'=127 284 | 'open'=128 285 | 'const'=129 286 | 'lateinit'=130 287 | 'vararg'=131 288 | 'noinline'=132 289 | 'crossinline'=133 290 | 'reified'=134 291 | 'expect'=135 292 | 'actual'=136 293 | 'null'=146 294 | '"""'=152 295 | -------------------------------------------------------------------------------- /grammar/src/main/antlr/com/squareup/cash/grammar/KotlinParser.tokens: -------------------------------------------------------------------------------- 1 | ShebangLine=1 2 | DelimitedComment=2 3 | LineComment=3 4 | WS=4 5 | NL=5 6 | RESERVED=6 7 | DOT=7 8 | COMMA=8 9 | LPAREN=9 10 | RPAREN=10 11 | LSQUARE=11 12 | RSQUARE=12 13 | LCURL=13 14 | RCURL=14 15 | MULT=15 16 | MOD=16 17 | DIV=17 18 | ADD=18 19 | SUB=19 20 | INCR=20 21 | DECR=21 22 | CONJ=22 23 | DISJ=23 24 | EXCL_WS=24 25 | EXCL_NO_WS=25 26 | COLON=26 27 | SEMICOLON=27 28 | ASSIGNMENT=28 29 | ADD_ASSIGNMENT=29 30 | SUB_ASSIGNMENT=30 31 | MULT_ASSIGNMENT=31 32 | DIV_ASSIGNMENT=32 33 | MOD_ASSIGNMENT=33 34 | ARROW=34 35 | DOUBLE_ARROW=35 36 | RANGE=36 37 | RANGE_UNTIL=37 38 | COLONCOLON=38 39 | DOUBLE_SEMICOLON=39 40 | HASH=40 41 | AT_NO_WS=41 42 | AT_POST_WS=42 43 | AT_PRE_WS=43 44 | AT_BOTH_WS=44 45 | QUEST_WS=45 46 | QUEST_NO_WS=46 47 | LANGLE=47 48 | RANGLE=48 49 | LE=49 50 | GE=50 51 | EXCL_EQ=51 52 | EXCL_EQEQ=52 53 | AS_SAFE=53 54 | EQEQ=54 55 | EQEQEQ=55 56 | SINGLE_QUOTE=56 57 | AMP=57 58 | RETURN_AT=58 59 | CONTINUE_AT=59 60 | BREAK_AT=60 61 | THIS_AT=61 62 | SUPER_AT=62 63 | FILE=63 64 | FIELD=64 65 | PROPERTY=65 66 | GET=66 67 | SET=67 68 | RECEIVER=68 69 | PARAM=69 70 | SETPARAM=70 71 | DELEGATE=71 72 | PACKAGE=72 73 | IMPORT=73 74 | CLASS=74 75 | INTERFACE=75 76 | FUN=76 77 | OBJECT=77 78 | VAL=78 79 | VAR=79 80 | TYPE_ALIAS=80 81 | CONSTRUCTOR=81 82 | BY=82 83 | COMPANION=83 84 | INIT=84 85 | THIS=85 86 | SUPER=86 87 | TYPEOF=87 88 | WHERE=88 89 | IF=89 90 | ELSE=90 91 | WHEN=91 92 | TRY=92 93 | CATCH=93 94 | FINALLY=94 95 | FOR=95 96 | DO=96 97 | WHILE=97 98 | THROW=98 99 | RETURN=99 100 | CONTINUE=100 101 | BREAK=101 102 | AS=102 103 | IS=103 104 | IN=104 105 | NOT_IS=105 106 | NOT_IN=106 107 | OUT=107 108 | DYNAMIC=108 109 | PUBLIC=109 110 | PRIVATE=110 111 | PROTECTED=111 112 | INTERNAL=112 113 | ENUM=113 114 | SEALED=114 115 | ANNOTATION=115 116 | DATA=116 117 | INNER=117 118 | VALUE=118 119 | TAILREC=119 120 | OPERATOR=120 121 | INLINE=121 122 | INFIX=122 123 | EXTERNAL=123 124 | SUSPEND=124 125 | OVERRIDE=125 126 | ABSTRACT=126 127 | FINAL=127 128 | OPEN=128 129 | CONST=129 130 | LATEINIT=130 131 | VARARG=131 132 | NOINLINE=132 133 | CROSSINLINE=133 134 | REIFIED=134 135 | EXPECT=135 136 | ACTUAL=136 137 | RealLiteral=137 138 | FloatLiteral=138 139 | DoubleLiteral=139 140 | IntegerLiteral=140 141 | HexLiteral=141 142 | BinLiteral=142 143 | UnsignedLiteral=143 144 | LongLiteral=144 145 | BooleanLiteral=145 146 | NullLiteral=146 147 | CharacterLiteral=147 148 | Identifier=148 149 | IdentifierOrSoftKey=149 150 | FieldIdentifier=150 151 | QUOTE_OPEN=151 152 | TRIPLE_QUOTE_OPEN=152 153 | UNICODE_CLASS_LL=153 154 | UNICODE_CLASS_LM=154 155 | UNICODE_CLASS_LO=155 156 | UNICODE_CLASS_LT=156 157 | UNICODE_CLASS_LU=157 158 | UNICODE_CLASS_ND=158 159 | UNICODE_CLASS_NL=159 160 | QUOTE_CLOSE=160 161 | LineStrRef=161 162 | LineStrText=162 163 | LineStrEscapedChar=163 164 | LineStrExprStart=164 165 | TRIPLE_QUOTE_CLOSE=165 166 | MultiLineStringQuote=166 167 | MultiLineStrRef=167 168 | MultiLineStrText=168 169 | MultiLineStrExprStart=169 170 | Inside_Comment=170 171 | Inside_WS=171 172 | Inside_NL=172 173 | ErrorCharacter=173 174 | '...'=6 175 | '.'=7 176 | ','=8 177 | '('=9 178 | ')'=10 179 | '['=11 180 | ']'=12 181 | '{'=13 182 | '}'=14 183 | '*'=15 184 | '%'=16 185 | '/'=17 186 | '+'=18 187 | '-'=19 188 | '++'=20 189 | '--'=21 190 | '&&'=22 191 | '||'=23 192 | '!'=25 193 | ':'=26 194 | ';'=27 195 | '='=28 196 | '+='=29 197 | '-='=30 198 | '*='=31 199 | '/='=32 200 | '%='=33 201 | '->'=34 202 | '=>'=35 203 | '..'=36 204 | '..<'=37 205 | '::'=38 206 | ';;'=39 207 | '#'=40 208 | '@'=41 209 | '?'=46 210 | '<'=47 211 | '>'=48 212 | '<='=49 213 | '>='=50 214 | '!='=51 215 | '!=='=52 216 | 'as?'=53 217 | '=='=54 218 | '==='=55 219 | '\''=56 220 | '&'=57 221 | 'file'=63 222 | 'field'=64 223 | 'property'=65 224 | 'get'=66 225 | 'set'=67 226 | 'receiver'=68 227 | 'param'=69 228 | 'setparam'=70 229 | 'delegate'=71 230 | 'package'=72 231 | 'import'=73 232 | 'class'=74 233 | 'interface'=75 234 | 'fun'=76 235 | 'object'=77 236 | 'val'=78 237 | 'var'=79 238 | 'typealias'=80 239 | 'constructor'=81 240 | 'by'=82 241 | 'companion'=83 242 | 'init'=84 243 | 'this'=85 244 | 'super'=86 245 | 'typeof'=87 246 | 'where'=88 247 | 'if'=89 248 | 'else'=90 249 | 'when'=91 250 | 'try'=92 251 | 'catch'=93 252 | 'finally'=94 253 | 'for'=95 254 | 'do'=96 255 | 'while'=97 256 | 'throw'=98 257 | 'return'=99 258 | 'continue'=100 259 | 'break'=101 260 | 'as'=102 261 | 'is'=103 262 | 'in'=104 263 | 'out'=107 264 | 'dynamic'=108 265 | 'public'=109 266 | 'private'=110 267 | 'protected'=111 268 | 'internal'=112 269 | 'enum'=113 270 | 'sealed'=114 271 | 'annotation'=115 272 | 'data'=116 273 | 'inner'=117 274 | 'value'=118 275 | 'tailrec'=119 276 | 'operator'=120 277 | 'inline'=121 278 | 'infix'=122 279 | 'external'=123 280 | 'suspend'=124 281 | 'override'=125 282 | 'abstract'=126 283 | 'final'=127 284 | 'open'=128 285 | 'const'=129 286 | 'lateinit'=130 287 | 'vararg'=131 288 | 'noinline'=132 289 | 'crossinline'=133 290 | 'reified'=134 291 | 'expect'=135 292 | 'actual'=136 293 | 'null'=146 294 | '"""'=152 295 | -------------------------------------------------------------------------------- /grammar/src/main/antlr/com/squareup/cash/grammar/UnicodeClasses.tokens: -------------------------------------------------------------------------------- 1 | UNICODE_CLASS_LL=1 2 | UNICODE_CLASS_LM=2 3 | UNICODE_CLASS_LO=3 4 | UNICODE_CLASS_LT=4 5 | UNICODE_CLASS_LU=5 6 | UNICODE_CLASS_ND=6 7 | UNICODE_CLASS_NL=7 8 | -------------------------------------------------------------------------------- /recipes/dependencies/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("cash.lib") 3 | } 4 | 5 | dependencies { 6 | api(project(":core")) 7 | api(project(":grammar")) 8 | api(libs.antlr.runtime) 9 | api(libs.kotlinStdLib) 10 | } 11 | -------------------------------------------------------------------------------- /recipes/dependencies/src/main/kotlin/cash/recipes/dependencies/DependenciesMutator.kt: -------------------------------------------------------------------------------- 1 | package cash.recipes.dependencies 2 | 3 | import cash.grammar.kotlindsl.parse.Mutator 4 | import cash.grammar.kotlindsl.parse.Parser 5 | import cash.grammar.kotlindsl.utils.Blocks.isDependencies 6 | import cash.grammar.kotlindsl.utils.CollectingErrorListener 7 | import cash.grammar.kotlindsl.utils.Context.leafRule 8 | import cash.grammar.kotlindsl.utils.DependencyExtractor 9 | import cash.recipes.dependencies.transform.Transform 10 | import com.squareup.cash.grammar.KotlinParser.NamedBlockContext 11 | import com.squareup.cash.grammar.KotlinParser.PostfixUnaryExpressionContext 12 | import com.squareup.cash.grammar.KotlinParser.ScriptContext 13 | import com.squareup.cash.grammar.KotlinParser.StatementContext 14 | import org.antlr.v4.runtime.CharStream 15 | import org.antlr.v4.runtime.CommonTokenStream 16 | import org.antlr.v4.runtime.Token 17 | import java.io.InputStream 18 | import java.nio.file.Path 19 | 20 | /** Rewrites dependencies according to the provided [transforms]. */ 21 | public class DependenciesMutator private constructor( 22 | private val transforms: List, 23 | input: CharStream, 24 | tokens: CommonTokenStream, 25 | errorListener: CollectingErrorListener, 26 | ) : Mutator(input, tokens, errorListener) { 27 | 28 | private val dependencyExtractor = DependencyExtractor(input, tokens, indent) 29 | private val usedTransforms = mutableListOf() 30 | private var changes = false 31 | 32 | /** 33 | * Returns a list of all used transforms. Might be empty. This can be used to, for example, update a version catalog. 34 | */ 35 | public fun usedTransforms(): List = usedTransforms 36 | 37 | public override fun isChanged(): Boolean = changes 38 | 39 | override fun enterNamedBlock(ctx: NamedBlockContext) { 40 | dependencyExtractor.onEnterBlock() 41 | } 42 | 43 | override fun exitNamedBlock(ctx: NamedBlockContext) { 44 | if (isRealDependenciesBlock(ctx)) { 45 | onExitDependenciesBlock(ctx) 46 | } 47 | 48 | dependencyExtractor.onExitBlock() 49 | } 50 | 51 | private fun isRealDependenciesBlock(ctx: NamedBlockContext): Boolean { 52 | // parent is StatementContext. Parent of that should be ScriptContext 53 | // In contrast, with tasks.shadowJar { dependencies { ... } }, the parent.parent is StatementsContext 54 | if (ctx.parent.parent !is ScriptContext) return false 55 | 56 | return ctx.isDependencies 57 | } 58 | 59 | private fun onExitDependenciesBlock(ctx: NamedBlockContext) { 60 | val container = dependencyExtractor.collectDependencies(ctx) 61 | container.getDependencyDeclarationsWithContext() 62 | .mapNotNull { element -> 63 | val gav = element.declaration.identifier.path 64 | val identifier = buildString { 65 | append(gav.substringBeforeLast(':')) 66 | if (gav.startsWith("\"")) append("\"") 67 | } 68 | 69 | // E.g., "com.foo:bar:1.0" -> libs.fooBar OR 70 | // "com.foo:bar" -> libs.fooBar 71 | // We support the user passing in either the full GAV or just the identifier (without version) 72 | val transform = transforms.find { t -> t.from.matches(gav) } 73 | ?: transforms.find { t -> t.from.matches(identifier) } 74 | val newText = transform?.to?.render() 75 | 76 | if (newText != null) { 77 | // We'll return these entries later, so users can update their version catalogs as appropriate 78 | usedTransforms += transform 79 | element to newText 80 | } else { 81 | null 82 | } 83 | } 84 | .forEach { (element, newText) -> 85 | changes = true 86 | // postfix with parens because we took a shortcut with getStop() and cut it off 87 | rewriter.replace(getStart(element.statement), getStop(element.statement), "${newText})") 88 | } 89 | } 90 | 91 | /** Returns the token marking the start of the identifier (after the opening parentheses). */ 92 | private fun getStart(ctx: StatementContext): Token { 93 | // statement -> postfixUnaryExpression -> postfixUnarySuffix -> callSuffix -> valueArguments -> valueArgument -> expression -> ... -> postfixUnaryExpression -> ... -> lineStringLiteral 94 | // statement -> postfixUnaryExpression -> postfixUnarySuffix -> callSuffix -> valueArguments -> valueArgument -> expression -> ... -> postfixUnaryExpression 95 | 96 | // This makes a lot of assumptions that I'm not sure are always valid. We do know that our ctx is for a dependency 97 | // declaration, though, which constrains the possibilities for the parse tree. 98 | return (ctx.leafRule() as PostfixUnaryExpressionContext) 99 | .postfixUnarySuffix() 100 | .single() 101 | .callSuffix() 102 | .valueArguments() 103 | .valueArgument() 104 | .single() 105 | .expression() 106 | .start 107 | } 108 | 109 | /** Returns the token marking the end of the declaration proper (before any trailing lambda). */ 110 | private fun getStop(ctx: StatementContext): Token { 111 | val default = ctx.stop 112 | 113 | val leaf = ctx.leafRule() 114 | if (leaf !is PostfixUnaryExpressionContext) return default 115 | 116 | val postfix = leaf.postfixUnarySuffix().firstOrNull() ?: return default 117 | val preLambda = postfix.callSuffix().valueArguments() 118 | 119 | // we only want to replace everything BEFORE the trailing lambda (this includes the closing parentheses) 120 | return preLambda.stop 121 | } 122 | 123 | public companion object { 124 | /** 125 | * Returns a [DependenciesMutator], which eagerly parses [buildScript]. 126 | * 127 | * @throws IllegalStateException if [DependencyExtractor] sees an expression it doesn't understand. 128 | * @throws IllegalArgumentException if [DependencyExtractor] sees an expression it doesn't understand. 129 | */ 130 | @Throws(IllegalStateException::class, IllegalArgumentException::class) 131 | public fun of( 132 | buildScript: Path, 133 | transforms: List, 134 | ): DependenciesMutator { 135 | return of(Parser.readOnlyInputStream(buildScript), transforms) 136 | } 137 | 138 | /** 139 | * Returns a [DependenciesMutator], which eagerly parses [buildScript]. 140 | * 141 | * @throws IllegalStateException if [DependencyExtractor] sees an expression it doesn't understand. 142 | * @throws IllegalArgumentException if [DependencyExtractor] sees an expression it doesn't understand. 143 | */ 144 | @Throws(IllegalStateException::class, IllegalArgumentException::class) 145 | public fun of( 146 | buildScript: String, 147 | transforms: List, 148 | ): DependenciesMutator { 149 | return of(buildScript.byteInputStream(), transforms) 150 | } 151 | 152 | /** 153 | * Returns a [DependenciesMutator], which eagerly parses [buildScript]. 154 | * 155 | * @throws IllegalStateException if [DependencyExtractor] sees an expression it doesn't understand. 156 | * @throws IllegalArgumentException if [DependencyExtractor] sees an expression it doesn't understand. 157 | */ 158 | @Throws(IllegalStateException::class, IllegalArgumentException::class) 159 | private fun of( 160 | buildScript: InputStream, 161 | transforms: List, 162 | ): DependenciesMutator { 163 | val errorListener = CollectingErrorListener() 164 | 165 | return Parser( 166 | file = buildScript, 167 | errorListener = errorListener, 168 | listenerFactory = { input, tokens, _ -> 169 | DependenciesMutator( 170 | transforms = transforms, 171 | input = input, 172 | tokens = tokens, 173 | errorListener = errorListener, 174 | ) 175 | } 176 | ).listener() 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /recipes/dependencies/src/main/kotlin/cash/recipes/dependencies/DependenciesSimplifier.kt: -------------------------------------------------------------------------------- 1 | package cash.recipes.dependencies 2 | 3 | import cash.grammar.kotlindsl.model.DependencyDeclaration 4 | import cash.grammar.kotlindsl.parse.KotlinParseException 5 | import cash.grammar.kotlindsl.parse.Parser 6 | import cash.grammar.kotlindsl.parse.Rewriter 7 | import cash.grammar.kotlindsl.utils.Blocks.isDependencies 8 | import cash.grammar.kotlindsl.utils.CollectingErrorListener 9 | import cash.grammar.kotlindsl.utils.Context.leafRule 10 | import cash.grammar.kotlindsl.utils.DependencyExtractor 11 | import cash.grammar.kotlindsl.utils.Whitespace 12 | import cash.grammar.kotlindsl.utils.Whitespace.trimGently 13 | import cash.grammar.utils.ifNotEmpty 14 | import com.squareup.cash.grammar.KotlinParser.NamedBlockContext 15 | import com.squareup.cash.grammar.KotlinParser.PostfixUnaryExpressionContext 16 | import com.squareup.cash.grammar.KotlinParser.ScriptContext 17 | import com.squareup.cash.grammar.KotlinParser.StatementContext 18 | import com.squareup.cash.grammar.KotlinParserBaseListener 19 | import org.antlr.v4.runtime.CharStream 20 | import org.antlr.v4.runtime.CommonTokenStream 21 | import org.antlr.v4.runtime.Token 22 | import java.io.InputStream 23 | import java.nio.file.Path 24 | 25 | public class DependenciesSimplifier private constructor( 26 | private val input: CharStream, 27 | private val tokens: CommonTokenStream, 28 | private val errorListener: CollectingErrorListener, 29 | ) : KotlinParserBaseListener() { 30 | 31 | private val rewriter = Rewriter(tokens) 32 | private val indent = Whitespace.computeIndent(tokens, input) 33 | private val terminalNewlines = Whitespace.countTerminalNewlines(tokens) 34 | private val dependencyExtractor = DependencyExtractor(input, tokens, indent) 35 | 36 | private var changes = false 37 | 38 | public fun isChanged(): Boolean = changes 39 | 40 | @Throws(KotlinParseException::class) 41 | public fun rewritten(): String { 42 | errorListener.getErrorMessages().ifNotEmpty { 43 | throw KotlinParseException.withErrors(it) 44 | } 45 | 46 | return rewriter.text.trimGently(terminalNewlines) 47 | } 48 | 49 | override fun enterNamedBlock(ctx: NamedBlockContext) { 50 | dependencyExtractor.onEnterBlock() 51 | } 52 | 53 | override fun exitNamedBlock(ctx: NamedBlockContext) { 54 | if (isRealDependenciesBlock(ctx)) { 55 | onExitDependenciesBlock(ctx) 56 | } 57 | 58 | dependencyExtractor.onExitBlock() 59 | } 60 | 61 | private fun isRealDependenciesBlock(ctx: NamedBlockContext): Boolean { 62 | // parent is StatementContext. Parent of that should be ScriptContext 63 | // In contrast, with tasks.shadowJar { dependencies { ... } }, the parent.parent is StatementsContext 64 | if (ctx.parent.parent !is ScriptContext) return false 65 | 66 | return ctx.isDependencies 67 | } 68 | 69 | private fun onExitDependenciesBlock(ctx: NamedBlockContext) { 70 | val container = dependencyExtractor.collectDependencies(ctx) 71 | container.getDependencyDeclarationsWithContext() 72 | // we only care about complex declarations. We will rewrite this in simplified form 73 | .filter { it.declaration.isComplex } 74 | .forEach { element -> 75 | val declaration = element.declaration 76 | val elementCtx = element.statement 77 | 78 | val newText = simplify(declaration) 79 | 80 | if (newText != null) { 81 | changes = true 82 | rewriter.replace(elementCtx.start, getStop(elementCtx), newText) 83 | } 84 | } 85 | } 86 | 87 | private fun getStop(ctx: StatementContext): Token { 88 | val default = ctx.stop 89 | 90 | val leaf = ctx.leafRule() 91 | if (leaf !is PostfixUnaryExpressionContext) return default 92 | 93 | val postfix = leaf.postfixUnarySuffix().firstOrNull() ?: return default 94 | val preLambda = postfix.callSuffix().valueArguments() 95 | 96 | // we only want to replace everything BEFORE the trailing lambda 97 | return preLambda.stop 98 | } 99 | 100 | private fun simplify(declaration: DependencyDeclaration): String? { 101 | require(declaration.isComplex) { "Expected complex declaration, was $declaration" } 102 | 103 | // TODO(tsr): For now, ignore those that have ext, classifier, producerConfiguration 104 | if (declaration.ext != null || declaration.classifier != null || declaration.producerConfiguration != null) { 105 | return null 106 | } 107 | 108 | return buildString { 109 | append(declaration.configuration) 110 | append("(") 111 | append(declaration.identifier) 112 | append(")") 113 | } 114 | } 115 | 116 | public companion object { 117 | /** 118 | * Returns a [DependenciesSimplifier], which eagerly parses [buildScript]. 119 | * 120 | * @throws IllegalStateException if [DependencyExtractor] sees an expression it doesn't understand. 121 | * @throws IllegalArgumentException if [DependencyExtractor] sees an expression it doesn't understand. 122 | */ 123 | @Throws(IllegalStateException::class, IllegalArgumentException::class) 124 | public fun of(buildScript: Path): DependenciesSimplifier { 125 | return of(Parser.readOnlyInputStream(buildScript)) 126 | } 127 | 128 | /** 129 | * Returns a [DependenciesSimplifier], which eagerly parses [buildScript]. 130 | * 131 | * @throws IllegalStateException if [DependencyExtractor] sees an expression it doesn't understand. 132 | * @throws IllegalArgumentException if [DependencyExtractor] sees an expression it doesn't understand. 133 | */ 134 | @Throws(IllegalStateException::class, IllegalArgumentException::class) 135 | public fun of(buildScript: String): DependenciesSimplifier { 136 | return of(buildScript.byteInputStream()) 137 | } 138 | 139 | /** 140 | * Returns a [DependenciesSimplifier], which eagerly parses [buildScript]. 141 | * 142 | * @throws IllegalStateException if [DependencyExtractor] sees an expression it doesn't understand. 143 | * @throws IllegalArgumentException if [DependencyExtractor] sees an expression it doesn't understand. 144 | */ 145 | @Throws(IllegalStateException::class, IllegalArgumentException::class) 146 | private fun of(buildScript: InputStream): DependenciesSimplifier { 147 | val errorListener = CollectingErrorListener() 148 | 149 | return Parser( 150 | file = buildScript, 151 | errorListener = errorListener, 152 | listenerFactory = { input, tokens, _ -> 153 | DependenciesSimplifier( 154 | input = input, 155 | tokens = tokens, 156 | errorListener = errorListener, 157 | ) 158 | } 159 | ).listener() 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /recipes/dependencies/src/main/kotlin/cash/recipes/dependencies/transform/Transform.kt: -------------------------------------------------------------------------------- 1 | package cash.recipes.dependencies.transform 2 | 3 | public data class Transform( 4 | public val from: Element, 5 | public val to: Element, 6 | ) { 7 | 8 | public fun from(): String = from.render() 9 | public fun to(): String = to.render() 10 | 11 | public sealed class Element { 12 | 13 | public abstract fun render(): String 14 | 15 | /** A "raw string" declaration, like `com.foo:bar:1.0`, with or without the version string. */ 16 | public data class StringLiteral(public val value: String) : Element() { 17 | // wrap in quotation marks (because it's a string literal!) 18 | override fun render(): String = "\"$value\"" 19 | } 20 | 21 | /** A dependency accessor, like `libs.fooBar`. Doesn't need to represent a version catalog entry. */ 22 | public data class Accessor(public val value: String) : Element() { 23 | override fun render(): String = value 24 | } 25 | 26 | public fun matches(other: String): Boolean = render() == other 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /recipes/dependencies/src/test/kotlin/cash/recipes/dependencies/DependenciesMutatorTest.kt: -------------------------------------------------------------------------------- 1 | package cash.recipes.dependencies 2 | 3 | import cash.recipes.dependencies.transform.Transform 4 | import cash.recipes.dependencies.transform.Transform.Element 5 | import org.assertj.core.api.Assertions.assertThat 6 | import org.junit.jupiter.api.Test 7 | 8 | internal class DependenciesMutatorTest { 9 | 10 | @Test fun `can remap dependency declarations`() { 11 | // Given a build script and a map of changes 12 | val expectedUsedTransforms = listOf( 13 | // Can handle just the identifier (no version) 14 | Transform( 15 | from = Element.StringLiteral("com.foo:bar"), 16 | to = Element.Accessor("libs.fooBar"), 17 | ), 18 | // Can handle full GAV coordinates (including version) 19 | Transform( 20 | from = Element.StringLiteral("group:artifact:1.0"), 21 | to = Element.Accessor("libs.artifact"), 22 | ), 23 | ) 24 | val transforms = expectedUsedTransforms + 25 | // Doesn't find this dependency, so nothing happens 26 | Transform( 27 | from = Element.StringLiteral("org.magic:turtles"), 28 | to = Element.Accessor("libs.magicTurtles"), 29 | ) 30 | 31 | val buildScript = """ 32 | dependencies { 33 | api("com.foo:bar:1.0") 34 | implementation(libs.foo) 35 | runtimeOnly("group:artifact:1.0") { isTransitive = false } 36 | } 37 | """.trimIndent() 38 | 39 | // When 40 | val mutator = DependenciesMutator.of(buildScript, transforms) 41 | val rewrittenContent = mutator.rewritten() 42 | 43 | // Then 44 | assertThat(rewrittenContent).isEqualTo( 45 | """ 46 | dependencies { 47 | api(libs.fooBar) 48 | implementation(libs.foo) 49 | runtimeOnly(libs.artifact) { isTransitive = false } 50 | } 51 | """.trimIndent() 52 | ) 53 | 54 | // ...and we can get the list of transforms 55 | val usedTransforms = mutator.usedTransforms() 56 | assertThat(usedTransforms).containsExactlyElementsOf(expectedUsedTransforms) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /recipes/dependencies/src/test/kotlin/cash/recipes/dependencies/DependenciesSimplifierTest.kt: -------------------------------------------------------------------------------- 1 | package cash.recipes.dependencies 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.junit.jupiter.api.Test 5 | 6 | internal class DependenciesSimplifierTest { 7 | 8 | @Test fun `can simplify dependency declarations`() { 9 | // Given 10 | val buildScript = """ 11 | dependencies { 12 | implementation(libs.foo) 13 | api("com.foo:bar:1.0") 14 | runtimeOnly(group = "foo", name = "bar", version = "2.0") 15 | compileOnly(group = "foo", name = "bar", version = libs.versions.bar.get()) { 16 | isTransitive = false 17 | } 18 | devImplementation(group = "io.netty", name = "netty-transport-native-kqueue", classifier = "osx-x86_64") 19 | } 20 | """.trimIndent() 21 | 22 | // When 23 | val simplifier = DependenciesSimplifier.of(buildScript) 24 | val rewrittenContent = simplifier.rewritten() 25 | 26 | // Then all declarations are simplified 27 | assertThat(rewrittenContent).isEqualTo( 28 | """ 29 | dependencies { 30 | implementation(libs.foo) 31 | api("com.foo:bar:1.0") 32 | runtimeOnly("foo:bar:2.0") 33 | compileOnly("foo:bar:${'$'}{libs.versions.bar.get()}") { 34 | isTransitive = false 35 | } 36 | devImplementation(group = "io.netty", name = "netty-transport-native-kqueue", classifier = "osx-x86_64") 37 | } 38 | """.trimIndent() 39 | ) 40 | } 41 | 42 | @Test fun `can handle user weirdness`() { 43 | // Expect parsing not to throw exception 44 | DependenciesSimplifier.of( 45 | """ 46 | dependencies { 47 | tasks.withType().configureEach { 48 | doThing(listOf("--a", "b")) 49 | } 50 | } 51 | """.trimIndent() 52 | ) 53 | } 54 | 55 | @Test fun `handles dependencies blocks that aren't actually dependencies blocks`() { 56 | // Expect parsing not to throw exception 57 | DependenciesSimplifier.of( 58 | """ 59 | tasks.shadowJar { 60 | dependencies { 61 | exclude { dep -> 62 | dep.name != "foo" 63 | } 64 | } 65 | } 66 | """.trimIndent() 67 | ) 68 | } 69 | 70 | @Test fun `the project function doesn't require named arguments`() { 71 | DependenciesSimplifier.of( 72 | """ 73 | dependencies { 74 | api(project(":imported-protos", "shadow")) 75 | } 76 | """.trimIndent() 77 | ) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /recipes/plugins/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("cash.lib") 3 | } 4 | 5 | dependencies { 6 | api(project(":core")) 7 | api(project(":grammar")) 8 | api(libs.antlr.runtime) 9 | api(libs.kotlinStdLib) 10 | } 11 | -------------------------------------------------------------------------------- /recipes/plugins/src/main/kotlin/cash/recipes/plugins/PluginNormalizer.kt: -------------------------------------------------------------------------------- 1 | package cash.recipes.plugins 2 | 3 | import cash.grammar.kotlindsl.model.Plugin 4 | import cash.grammar.kotlindsl.model.Plugin.Type 5 | import cash.grammar.kotlindsl.parse.KotlinParseException 6 | import cash.grammar.kotlindsl.parse.Parser 7 | import cash.grammar.kotlindsl.parse.Rewriter 8 | import cash.grammar.kotlindsl.utils.Blocks.isBuildscript 9 | import cash.grammar.kotlindsl.utils.Blocks.isPlugins 10 | import cash.grammar.kotlindsl.utils.CollectingErrorListener 11 | import cash.grammar.kotlindsl.utils.Context.leafRule 12 | import cash.grammar.kotlindsl.utils.PluginExtractor 13 | import cash.grammar.kotlindsl.utils.SmartIndent 14 | import cash.grammar.kotlindsl.utils.Whitespace 15 | import cash.grammar.kotlindsl.utils.Whitespace.trimGently 16 | import cash.grammar.utils.ifNotEmpty 17 | import com.squareup.cash.grammar.KotlinLexer 18 | import com.squareup.cash.grammar.KotlinParser 19 | import com.squareup.cash.grammar.KotlinParser.* 20 | import com.squareup.cash.grammar.KotlinParserBaseListener 21 | import org.antlr.v4.runtime.CharStream 22 | import org.antlr.v4.runtime.CommonTokenStream 23 | import org.antlr.v4.runtime.ParserRuleContext 24 | import org.antlr.v4.runtime.tree.TerminalNode 25 | import java.io.InputStream 26 | import java.nio.file.Path 27 | 28 | /** 29 | * Finds plugins applied directly to this project and rewrites the build script to have a "normal" 30 | * form, maintaining application order. These are examples of plugins applied directly to this 31 | * project: 32 | * ``` 33 | * plugins { 34 | * id("foo)" 35 | * kotlin("jvm") 36 | * application 37 | * `kotlin-dsl` 38 | * } 39 | * 40 | * apply(plugin = "bar") 41 | * ``` 42 | * And this would be the normalized form for the above: 43 | * ``` 44 | * plugins { 45 | * id("foo") 46 | * id("org.jetbrains.kotlin.jvm") 47 | * id("application") 48 | * id("kotlin-dsl") 49 | * id("bar") 50 | * } 51 | * ``` 52 | * 53 | * Note that plugins applied in `allprojects` blocks (for example) are ignored for purposes of this 54 | * class. 55 | */ 56 | public class PluginNormalizer private constructor( 57 | private val input: CharStream, 58 | private val tokens: CommonTokenStream, 59 | private val parser: KotlinParser, 60 | private val errorListener: CollectingErrorListener, 61 | ) : KotlinParserBaseListener() { 62 | 63 | private val rewriter = Rewriter(tokens) 64 | 65 | private val appliedPlugins = mutableListOf() 66 | private val blockStack = ArrayDeque() 67 | private var pluginsBlock: NamedBlockContext? = null 68 | 69 | private val smartIndent = SmartIndent(tokens) 70 | private val terminalNewlines = Whitespace.countTerminalNewlines(tokens) 71 | 72 | // Is this an empty script? 73 | private var empty = true 74 | 75 | @Throws(KotlinParseException::class) 76 | public fun rewritten(): String { 77 | errorListener.getErrorMessages().ifNotEmpty { 78 | throw KotlinParseException.withErrors(it) 79 | } 80 | 81 | return rewriter.text.trimGently(terminalNewlines) 82 | } 83 | 84 | override fun enterStatement(ctx: StatementContext) { 85 | empty = false 86 | smartIndent.setIndent(ctx) 87 | } 88 | 89 | override fun enterNamedBlock(ctx: NamedBlockContext) { 90 | blockStack.addFirst(ctx) 91 | } 92 | 93 | override fun exitNamedBlock(ctx: NamedBlockContext) { 94 | if (ctx.isPlugins) { 95 | pluginsBlock = ctx 96 | findPlugins(ctx) 97 | } 98 | 99 | blockStack.removeFirst() 100 | } 101 | 102 | private fun findPlugins(ctx: NamedBlockContext) { 103 | require(ctx.isPlugins) { "Expected plugins block. Was '${ctx.name().text}'" } 104 | 105 | ctx.statements().statement().asSequence() 106 | .filterNot { it is TerminalNode } 107 | .map { it.leafRule() } 108 | .mapNotNull { PluginExtractor.extractFromBlock(it) } 109 | .forEach(appliedPlugins::add) 110 | } 111 | 112 | /** 113 | * Lots of code is a "postfix unary expression", e.g. 114 | * ``` 115 | * apply(plugin = "kotlin") 116 | * ``` 117 | * as well as 118 | * ``` 119 | * "kotlin" 120 | * ``` 121 | * This is the "closest" listener hook for getting the full `apply...` expression. 122 | */ 123 | override fun exitPostfixUnaryExpression(ctx: PostfixUnaryExpressionContext) { 124 | if (blockStack.isNotEmpty()) return 125 | 126 | PluginExtractor.extractFromScript(ctx)?.let { plugin -> 127 | appliedPlugins.add(plugin) 128 | 129 | // Delete this plugin application & surrounding whitespace 130 | rewriter.deleteBlankSpaceToLeft(ctx.start) 131 | 132 | // TODO(tsr): this is problematic. Sometimes it deletes too much, other times not enough. 133 | rewriter.deleteBlankSpaceToRight(ctx.stop) 134 | 135 | rewriter.delete(ctx.start, ctx.stop) 136 | } 137 | } 138 | 139 | override fun exitScript(ctx: ScriptContext) { 140 | // Nothing to do if there are no applied plugins 141 | if (appliedPlugins.isEmpty()) return 142 | 143 | val indent = smartIndent.getSmartIndent() 144 | 145 | var content = buildString { 146 | appendLine("plugins {") 147 | appliedPlugins 148 | .map { plugin -> 149 | when (plugin.type) { 150 | Type.BLOCK_KOTLIN -> plugin.copy( 151 | type = Type.BLOCK_ID, 152 | id = "org.jetbrains.kotlin.${plugin.id}", 153 | ) 154 | 155 | Type.APPLY -> { 156 | // https://github.com/search?type=code&q=org%3Asquareup+NOT+is%3Aarchived+%22apply%28plugin+%3D+%5C%22kotlin-%22 157 | when (plugin.id) { 158 | "kotlin" -> plugin.copy( 159 | type = Type.BLOCK_ID, 160 | id = "org.jetbrains.kotlin.jvm", 161 | ) 162 | 163 | "kotlin-kapt" -> plugin.copy( 164 | type = Type.BLOCK_ID, 165 | id = "org.jetbrains.kotlin.kapt", 166 | ) 167 | 168 | "kotlin-allopen" -> plugin.copy( 169 | type = Type.BLOCK_ID, 170 | id = "org.jetbrains.kotlin.plugin.allopen", 171 | ) 172 | 173 | "kotlin-jpa" -> plugin.copy( 174 | type = Type.BLOCK_ID, 175 | id = "org.jetbrains.kotlin.plugin.jpa", 176 | ) 177 | 178 | "kotlinx-serialization" -> plugin.copy( 179 | type = Type.BLOCK_ID, 180 | id = "org.jetbrains.kotlin.plugin.serialization", 181 | ) 182 | 183 | else -> plugin 184 | } 185 | } 186 | 187 | else -> plugin 188 | } 189 | } 190 | .distinctBy { it.id } 191 | .forEach { plugin -> 192 | append(indent) 193 | 194 | if (plugin.type == Type.BLOCK_ALIAS) { 195 | append("alias(").append(plugin.id).append(")") 196 | } else { 197 | // this handles all other cases 198 | append("id(\"").append(plugin.id).append("\")") 199 | } 200 | 201 | plugin.version?.let { v -> 202 | append(" version $v") 203 | } 204 | 205 | if (!plugin.applied) { 206 | append(" apply false") 207 | } 208 | 209 | appendLine() 210 | } 211 | appendLine("}") 212 | } 213 | 214 | val pluginsBlock = pluginsBlock 215 | if (pluginsBlock != null) { 216 | if (isFollowedByTwoNewLines(pluginsBlock)) { 217 | // TODO(tsr): handle more complex kinds of newline 218 | content = content.removeSuffix("\n") 219 | } 220 | 221 | rewriter.replace(pluginsBlock.start, pluginsBlock.stop, content) 222 | } else { 223 | // There was no plugins block, so we must add one at top 224 | // Special handling in the case of a non-empty script 225 | if (!empty) { 226 | content = "$content\n" 227 | } 228 | 229 | // find the right place to insert a new block. It should be after any imports, if they exist, 230 | // and after a buildscript block, if it exists. 231 | 232 | val buildscriptStop = ctx.statement() 233 | ?.firstOrNull { statement -> statement.namedBlock()?.isBuildscript == true } 234 | ?.stop 235 | 236 | val insertAfter = buildscriptStop ?: ctx.importList().stop 237 | 238 | if (insertAfter != null) { 239 | // if we find a buildscript block or an import list, we insert _after_ that. 240 | rewriter.insertAfter(insertAfter, "\n\n$content") 241 | } else { 242 | // otherwise, we insert before (at the very stop of the script) 243 | rewriter.insertBefore(ctx.start, content) 244 | } 245 | } 246 | } 247 | 248 | private fun isFollowedByTwoNewLines(ctx: ParserRuleContext): Boolean { 249 | var next = ctx.stop.tokenIndex + 1 250 | 251 | if (next >= tokens.size()) return false 252 | var nextToken = tokens.get(next) 253 | if (nextToken.type != KotlinLexer.NL) return false 254 | 255 | next = nextToken.tokenIndex + 1 256 | if (next >= tokens.size()) return false 257 | 258 | nextToken = tokens.get(next) 259 | return nextToken.type == KotlinLexer.NL 260 | } 261 | 262 | public companion object { 263 | public fun of(buildScript: Path): PluginNormalizer { 264 | return of(Parser.readOnlyInputStream(buildScript)) 265 | } 266 | 267 | public fun of(buildScript: String): PluginNormalizer { 268 | return of(buildScript.byteInputStream()) 269 | } 270 | 271 | public fun of(buildScript: InputStream): PluginNormalizer { 272 | val errorListener = CollectingErrorListener() 273 | 274 | return Parser( 275 | file = buildScript, 276 | errorListener = errorListener, 277 | listenerFactory = { input, tokens, parser -> 278 | PluginNormalizer( 279 | input = input, 280 | tokens = tokens, 281 | parser = parser, 282 | errorListener = errorListener, 283 | ) 284 | } 285 | ).listener() 286 | } 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /recipes/plugins/src/main/kotlin/cash/recipes/plugins/exception/NonNormalizedScriptException.kt: -------------------------------------------------------------------------------- 1 | package cash.recipes.plugins.exception 2 | 3 | public class NonNormalizedScriptException(msg: String) : RuntimeException(msg) 4 | -------------------------------------------------------------------------------- /recipes/plugins/src/test/kotlin/cash/recipes/plugins/PluginMutatorTest.kt: -------------------------------------------------------------------------------- 1 | package cash.recipes.plugins 2 | 3 | import cash.recipes.plugins.exception.NonNormalizedScriptException 4 | import org.assertj.core.api.Assertions.assertThat 5 | import org.junit.jupiter.api.Assertions.assertThrows 6 | import org.junit.jupiter.api.Test 7 | 8 | internal class PluginMutatorTest { 9 | @Test 10 | fun `can add plugins to at the top of existing plugin block`() { 11 | val pluginToAdd = setOf("foo", "bar", "baz") 12 | val pluginsToRemove = emptySet() 13 | 14 | val buildScript = 15 | """ 16 | plugins { 17 | id("baz") 18 | } 19 | """.trimIndent() 20 | 21 | // When... 22 | val pluginMutator = PluginMutator.of(buildScript, pluginToAdd, pluginsToRemove) 23 | val rewrittenContent = pluginMutator.rewritten() 24 | val expectedContent = 25 | """ 26 | plugins { 27 | id("foo") 28 | id("bar") 29 | id("baz") 30 | } 31 | """.trimIndent() 32 | 33 | // Then... 34 | assertThat(rewrittenContent).isEqualTo(expectedContent) 35 | } 36 | 37 | @Test 38 | fun `can add plugins to at the top of empty existing plugin block`() { 39 | val pluginToAdd = setOf("foo", "bar", "baz") 40 | val pluginsToRemove = emptySet() 41 | 42 | val buildScript = 43 | """ 44 | plugins { 45 | } 46 | """.trimIndent() 47 | 48 | // When... 49 | val pluginMutator = PluginMutator.of(buildScript, pluginToAdd, pluginsToRemove) 50 | val rewrittenContent = pluginMutator.rewritten() 51 | val expectedContent = 52 | """ 53 | plugins { 54 | id("foo") 55 | id("bar") 56 | id("baz") 57 | } 58 | """.trimIndent() 59 | 60 | // Then... 61 | assertThat(rewrittenContent).isEqualTo(expectedContent) 62 | } 63 | 64 | @Test 65 | fun `can add plugins when no existing plugin block is present`() { 66 | val pluginToAdd = setOf("foo", "bar") 67 | val pluginsToRemove = emptySet() 68 | 69 | val buildScript = 70 | """ 71 | extension { 72 | foo = bar 73 | } 74 | """.trimIndent() 75 | 76 | // When... 77 | val pluginMutator = PluginMutator.of(buildScript, pluginToAdd, pluginsToRemove) 78 | val rewrittenContent = pluginMutator.rewritten() 79 | val expectedContent = 80 | """ 81 | plugins { 82 | id("foo") 83 | id("bar") 84 | } 85 | 86 | extension { 87 | foo = bar 88 | } 89 | """.trimIndent() 90 | 91 | // Then... 92 | assertThat(rewrittenContent).isEqualTo(expectedContent) 93 | } 94 | 95 | @Test 96 | fun `can add plugins when no existing plugin block is present, below imports`() { 97 | val pluginToAdd = setOf("foo", "bar") 98 | val pluginsToRemove = emptySet() 99 | 100 | val buildScript = 101 | """ 102 | import magic 103 | 104 | extension { 105 | foo = bar 106 | } 107 | """.trimIndent() 108 | 109 | // When... 110 | val pluginMutator = PluginMutator.of(buildScript, pluginToAdd, pluginsToRemove) 111 | val rewrittenContent = pluginMutator.rewritten() 112 | val expectedContent = 113 | """ 114 | import magic 115 | 116 | plugins { 117 | id("foo") 118 | id("bar") 119 | } 120 | 121 | extension { 122 | foo = bar 123 | } 124 | """.trimIndent() 125 | 126 | // Then... 127 | assertThat(rewrittenContent).isEqualTo(expectedContent) 128 | } 129 | 130 | @Test 131 | fun `skip adding plugins that are already present`() { 132 | val pluginToAdd = setOf("cash.server") 133 | val pluginsToRemove = emptySet() 134 | 135 | val buildScript = 136 | """ 137 | plugins { 138 | id("cash.server") 139 | } 140 | """.trimIndent() 141 | 142 | // When... 143 | val pluginMutator = PluginMutator.of(buildScript, pluginToAdd, pluginsToRemove) 144 | val rewrittenContent = pluginMutator.rewritten() 145 | val expectedContent = 146 | """ 147 | plugins { 148 | id("cash.server") 149 | } 150 | """.trimIndent() 151 | 152 | // Then... 153 | assertThat(rewrittenContent).isEqualTo(expectedContent) 154 | } 155 | 156 | @Test 157 | fun `can remove plugins`() { 158 | val pluginToAdd = emptySet() 159 | val pluginToRemove = setOf("foo", "bar", "application") 160 | 161 | val buildScript = 162 | """ 163 | plugins { 164 | id("foo") 165 | id("bar") 166 | application 167 | } 168 | """.trimIndent() 169 | 170 | // When... 171 | val pluginMutator = PluginMutator.of(buildScript, pluginToAdd, pluginToRemove) 172 | val rewrittenContent = pluginMutator.rewritten() 173 | val expectedContent = 174 | """ 175 | plugins { 176 | } 177 | """.trimIndent() 178 | 179 | // Then... 180 | assertThat(rewrittenContent).isEqualTo(expectedContent) 181 | } 182 | 183 | @Test 184 | fun `can add and remove of plugins`() { 185 | val pluginToAdd = setOf("foo", "bar") 186 | val pluginToRemove = setOf("a-plugin") 187 | 188 | val buildScript = 189 | """ 190 | plugins { 191 | id("a-plugin") 192 | id("b-plugin") 193 | } 194 | 195 | extension { 196 | foo = bar 197 | } 198 | """.trimIndent() 199 | 200 | // When... 201 | val pluginMutator = PluginMutator.of(buildScript, pluginToAdd, pluginToRemove) 202 | val rewrittenContent = pluginMutator.rewritten() 203 | val expectedContent = 204 | """ 205 | plugins { 206 | id("foo") 207 | id("bar") 208 | id("b-plugin") 209 | } 210 | 211 | extension { 212 | foo = bar 213 | } 214 | """.trimIndent() 215 | 216 | // Then... 217 | assertThat(rewrittenContent).isEqualTo(expectedContent) 218 | } 219 | 220 | @Test 221 | fun `throw error when same plugins are in add and remove sets`() { 222 | val pluginToAdd = setOf("bar") 223 | val pluginToRemove = setOf("foo", "bar") 224 | 225 | val buildScript = 226 | """ 227 | plugins { 228 | id("foo") 229 | id("bar") 230 | } 231 | """.trimIndent() 232 | 233 | assertThrows(IllegalArgumentException::class.java) { 234 | PluginMutator.of(buildScript, pluginToAdd, pluginToRemove) 235 | } 236 | } 237 | 238 | @Test 239 | fun `throws error for non-normalized script, with non-BlockId plugins`() { 240 | val pluginToAdd = setOf("foo") 241 | val pluginToRemove = setOf("bar") 242 | 243 | val buildScript = 244 | """ 245 | plugins { 246 | kotlin("foo") 247 | id("bar") 248 | } 249 | """.trimIndent() 250 | 251 | assertThrows(NonNormalizedScriptException::class.java) { 252 | PluginMutator.of(buildScript, pluginToAdd, pluginToRemove) 253 | } 254 | } 255 | 256 | @Test 257 | fun `throws error for non-normalized script, with apply plugins`() { 258 | val pluginToAdd = setOf("foo") 259 | val pluginToRemove = setOf("bar") 260 | 261 | val buildScript = 262 | """ 263 | apply(plugin = "foo") 264 | """.trimIndent() 265 | 266 | assertThrows(NonNormalizedScriptException::class.java) { 267 | PluginMutator.of(buildScript, pluginToAdd, pluginToRemove) 268 | } 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /recipes/repos/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("cash.lib") 3 | } 4 | 5 | dependencies { 6 | api(project(":core")) 7 | api(project(":grammar")) 8 | api(libs.antlr.runtime) 9 | api(libs.kotlinStdLib) 10 | } 11 | -------------------------------------------------------------------------------- /recipes/repos/src/main/kotlin/cash/recipes/repos/DependencyResolutionManagementAdder.kt: -------------------------------------------------------------------------------- 1 | package cash.recipes.repos 2 | 3 | import cash.grammar.kotlindsl.parse.KotlinParseException 4 | import cash.grammar.kotlindsl.parse.Parser 5 | import cash.grammar.kotlindsl.utils.Blocks.isDependencyResolutionManagement 6 | import cash.grammar.kotlindsl.utils.Blocks.isPlugins 7 | import cash.grammar.kotlindsl.utils.CollectingErrorListener 8 | import cash.grammar.kotlindsl.utils.SmartIndent 9 | import cash.grammar.kotlindsl.utils.Whitespace 10 | import cash.grammar.kotlindsl.utils.Whitespace.trimGently 11 | import cash.grammar.utils.ifNotEmpty 12 | import cash.recipes.repos.exception.AlreadyHasBlockException 13 | import com.squareup.cash.grammar.KotlinParser 14 | import com.squareup.cash.grammar.KotlinParser.NamedBlockContext 15 | import com.squareup.cash.grammar.KotlinParser.ScriptContext 16 | import com.squareup.cash.grammar.KotlinParser.StatementContext 17 | import com.squareup.cash.grammar.KotlinParserBaseListener 18 | import org.antlr.v4.runtime.CharStream 19 | import org.antlr.v4.runtime.CommonTokenStream 20 | import org.antlr.v4.runtime.TokenStreamRewriter 21 | import java.io.InputStream 22 | import java.nio.file.Path 23 | 24 | /** 25 | * This will add a `dependencyResolutionManagement` block to a settings script that doesn't have it. 26 | * 27 | * ``` 28 | * // settings.gradle.kts 29 | * dependencyResolutionManagement { 30 | * repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 31 | * repositories { 32 | * maven(url = "...") // configurable 33 | * } 34 | * } 35 | * ``` 36 | */ 37 | public class DependencyResolutionManagementAdder private constructor( 38 | private val repos: List, 39 | private val input: CharStream, 40 | private val tokens: CommonTokenStream, 41 | private val parser: KotlinParser, 42 | private val errorListener: CollectingErrorListener, 43 | ) : KotlinParserBaseListener() { 44 | 45 | private val smartIndent = SmartIndent(tokens) 46 | private val terminalNewlines = Whitespace.countTerminalNewlines(tokens) 47 | 48 | // Is this an empty script? 49 | private var empty = true 50 | 51 | private var alreadyHasBlock = false 52 | private var isDrmAdded = false 53 | 54 | private val rewriter = TokenStreamRewriter(tokens) 55 | 56 | @Throws(KotlinParseException::class, AlreadyHasBlockException::class) 57 | public fun rewritten(): String { 58 | errorListener.getErrorMessages().ifNotEmpty { 59 | throw KotlinParseException.withErrors(it) 60 | } 61 | 62 | if (alreadyHasBlock) { 63 | throw AlreadyHasBlockException( 64 | "Settings script already has a 'dependencyResolutionManagement' block" 65 | ) 66 | } 67 | 68 | return rewriter.text.trimGently(terminalNewlines) 69 | } 70 | 71 | override fun enterNamedBlock(ctx: NamedBlockContext) { 72 | if (!alreadyHasBlock && ctx.isDependencyResolutionManagement) { 73 | alreadyHasBlock = true 74 | } 75 | } 76 | 77 | // We'll make an effort to insert our new block _after_ the plugins block 78 | override fun exitNamedBlock(ctx: NamedBlockContext) { 79 | if (!isDrmAdded && ctx.isPlugins) { 80 | isDrmAdded = true 81 | rewriter.insertAfter(ctx.stop, getDependencyResolutionManagementText()) 82 | } 83 | } 84 | 85 | // If there was no plugins block, add our block at the end of the script 86 | override fun exitScript(ctx: ScriptContext) { 87 | if (!isDrmAdded) { 88 | rewriter.insertAfter(ctx.stop, getDependencyResolutionManagementText()) 89 | } 90 | } 91 | 92 | override fun enterStatement(ctx: StatementContext) { 93 | empty = false 94 | smartIndent.setIndent(ctx) 95 | } 96 | 97 | private fun getDependencyResolutionManagementText(): String { 98 | val indent = smartIndent.getSmartIndent() 99 | 100 | return buildString { 101 | // Special handling in the case of an empty script 102 | if (!empty) { 103 | appendLine() 104 | appendLine() 105 | } 106 | 107 | appendLine("dependencyResolutionManagement {") 108 | 109 | append(indent).appendLine("repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)") 110 | append(indent).appendLine("repositories {") 111 | 112 | repos.forEach { 113 | append(indent.repeat(2)) 114 | appendLine(it) 115 | } 116 | 117 | append(indent).appendLine("}") 118 | append("}") 119 | } 120 | } 121 | 122 | public companion object { 123 | public fun of( 124 | settingsScript: Path, 125 | repos: List, 126 | ): DependencyResolutionManagementAdder { 127 | return of(Parser.readOnlyInputStream(settingsScript), repos) 128 | } 129 | 130 | public fun of( 131 | settingsScript: String, 132 | repos: List, 133 | ): DependencyResolutionManagementAdder { 134 | return of(settingsScript.byteInputStream(), repos) 135 | } 136 | 137 | public fun of( 138 | settingsScript: InputStream, 139 | repos: List, 140 | ): DependencyResolutionManagementAdder { 141 | check(repos.isNotEmpty()) { "'repos' was empty. Expected at least one element." } 142 | 143 | val errorListener = CollectingErrorListener() 144 | 145 | return Parser( 146 | file = settingsScript, 147 | errorListener = errorListener, 148 | listenerFactory = { input, tokens, parser -> 149 | DependencyResolutionManagementAdder( 150 | repos = repos, 151 | input = input, 152 | tokens = tokens, 153 | parser = parser, 154 | errorListener = errorListener, 155 | ) 156 | } 157 | ).listener() 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /recipes/repos/src/main/kotlin/cash/recipes/repos/RepositoriesDeleter.kt: -------------------------------------------------------------------------------- 1 | package cash.recipes.repos 2 | 3 | import cash.grammar.kotlindsl.parse.KotlinParseException 4 | import cash.grammar.kotlindsl.parse.Parser 5 | import cash.grammar.kotlindsl.parse.Rewriter 6 | import cash.grammar.kotlindsl.utils.Blocks 7 | import cash.grammar.kotlindsl.utils.Blocks.isBuildscript 8 | import cash.grammar.kotlindsl.utils.Blocks.isRepositories 9 | import cash.grammar.kotlindsl.utils.CollectingErrorListener 10 | import cash.grammar.kotlindsl.utils.Whitespace 11 | import cash.grammar.kotlindsl.utils.Whitespace.trimGently 12 | import cash.grammar.utils.ifNotEmpty 13 | import com.squareup.cash.grammar.KotlinParser 14 | import com.squareup.cash.grammar.KotlinParser.NamedBlockContext 15 | import com.squareup.cash.grammar.KotlinParserBaseListener 16 | import org.antlr.v4.runtime.CharStream 17 | import org.antlr.v4.runtime.CommonTokenStream 18 | import java.io.InputStream 19 | import java.nio.file.Path 20 | 21 | /** 22 | * Deletes `repositories {}` blocks from build scripts. 23 | * 24 | * Given this: 25 | * ``` 26 | * // build.gradle.kts 27 | * buildscript { 28 | * repositories { ... } 29 | * ... 30 | * } 31 | * 32 | * repositories { ... } 33 | * 34 | * subprojects { 35 | * ... 36 | * buildscript { 37 | * repositories { ... } 38 | * } 39 | * repositories { ... } 40 | * } 41 | * ``` 42 | * 43 | * Transform into: 44 | * ``` 45 | * buildscript { 46 | * repositories { ... } 47 | * ... 48 | * } 49 | * 50 | * subprojects { 51 | * ... 52 | * } 53 | * ``` 54 | * 55 | * That is, keep only the `buildscript.repositories { ... }` stanza for [buildScript][input] itself. 56 | */ 57 | public class RepositoriesDeleter private constructor( 58 | private val input: CharStream, 59 | private val tokens: CommonTokenStream, 60 | private val parser: KotlinParser, 61 | private val errorListener: CollectingErrorListener, 62 | ) : KotlinParserBaseListener() { 63 | 64 | private val rewriter = Rewriter(tokens) 65 | private val terminalNewlines = Whitespace.countTerminalNewlines(tokens) 66 | private val blockStack = ArrayDeque() 67 | 68 | @Throws(KotlinParseException::class) 69 | public fun rewritten(): String { 70 | errorListener.getErrorMessages().ifNotEmpty { 71 | throw KotlinParseException.withErrors(it) 72 | } 73 | 74 | return rewriter.text.trimGently(terminalNewlines) 75 | } 76 | 77 | override fun enterNamedBlock(ctx: NamedBlockContext) { 78 | blockStack.addFirst(ctx) 79 | } 80 | 81 | override fun exitNamedBlock(ctx: NamedBlockContext) { 82 | if (!isInBuildscriptBlock() && ctx.isRepositories) { 83 | // Delete block 84 | Blocks.getOutermostBlock(blockStack)?.let { block -> 85 | // Delete preceding whitespace 86 | rewriter.deleteBlankSpaceToLeft(block.start) 87 | 88 | rewriter.delete(block.start, block.stop) 89 | } 90 | } 91 | 92 | // Must be last! 93 | blockStack.removeFirst() 94 | } 95 | 96 | /** 97 | * Returns true if the outermost block in the current context is `buildscript {}`. 98 | */ 99 | private fun isInBuildscriptBlock(): Boolean { 100 | return blockStack.isNotEmpty() && blockStack.last().isBuildscript 101 | } 102 | 103 | @Suppress("MemberVisibilityCanBePrivate") 104 | public companion object { 105 | public fun of(buildScript: Path): RepositoriesDeleter { 106 | return of(Parser.readOnlyInputStream(buildScript)) 107 | } 108 | 109 | public fun of(buildScript: String): RepositoriesDeleter { 110 | return of(buildScript.byteInputStream()) 111 | } 112 | 113 | public fun of(buildScript: InputStream): RepositoriesDeleter { 114 | val errorListener = CollectingErrorListener() 115 | 116 | return Parser( 117 | file = buildScript, 118 | errorListener = errorListener, 119 | listenerFactory = { input, tokens, parser -> 120 | RepositoriesDeleter( 121 | input = input, 122 | tokens = tokens, 123 | parser = parser, 124 | errorListener = errorListener, 125 | ) 126 | } 127 | ).listener() 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /recipes/repos/src/main/kotlin/cash/recipes/repos/exception/AlreadyHasBlockException.kt: -------------------------------------------------------------------------------- 1 | package cash.recipes.repos.exception 2 | 3 | public class AlreadyHasBlockException(msg: String) : RuntimeException(msg) { 4 | } -------------------------------------------------------------------------------- /recipes/repos/src/test/kotlin/cash/recipes/repos/DependencyResolutionManagementAdderTest.kt: -------------------------------------------------------------------------------- 1 | package cash.recipes.repos 2 | 3 | import cash.recipes.repos.exception.AlreadyHasBlockException 4 | import org.assertj.core.api.Assertions.assertThat 5 | import org.junit.jupiter.api.Assertions.assertThrows 6 | import org.junit.jupiter.api.Test 7 | 8 | internal class DependencyResolutionManagementAdderTest { 9 | 10 | @Test fun `can add drm block after the plugins block`() { 11 | // Given a settings script 12 | val settingsScript = 13 | """ 14 | rootProject.name = "sample-project" 15 | 16 | pluginManagement { 17 | repositories { 18 | mavenCentral() 19 | } 20 | } 21 | 22 | plugins { 23 | id("java-library") 24 | } 25 | """.trimIndent() 26 | 27 | // When... 28 | val adder = DependencyResolutionManagementAdder.of(settingsScript, listOf("mavenCentral()")) 29 | 30 | // Then... 31 | assertThat(adder.rewritten()).isEqualTo( 32 | """ 33 | rootProject.name = "sample-project" 34 | 35 | pluginManagement { 36 | repositories { 37 | mavenCentral() 38 | } 39 | } 40 | 41 | plugins { 42 | id("java-library") 43 | } 44 | 45 | dependencyResolutionManagement { 46 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 47 | repositories { 48 | mavenCentral() 49 | } 50 | } 51 | """.trimIndent() 52 | ) 53 | } 54 | 55 | @Test fun `can add drm block to script without a plugins block`() { 56 | // Given a settings script 57 | val settingsScript = 58 | """ 59 | rootProject.name = "sample-project" 60 | 61 | pluginManagement { 62 | repositories { 63 | mavenCentral() 64 | } 65 | } 66 | """.trimIndent() 67 | 68 | // When... 69 | val adder = DependencyResolutionManagementAdder.of(settingsScript, listOf("mavenCentral()")) 70 | 71 | // Then... 72 | assertThat(adder.rewritten()).isEqualTo( 73 | """ 74 | rootProject.name = "sample-project" 75 | 76 | pluginManagement { 77 | repositories { 78 | mavenCentral() 79 | } 80 | } 81 | 82 | dependencyResolutionManagement { 83 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 84 | repositories { 85 | mavenCentral() 86 | } 87 | } 88 | """.trimIndent() 89 | ) 90 | } 91 | 92 | @Test fun `can add drm block to an empty script`() { 93 | // Given a settings script 94 | val settingsScript = "" 95 | 96 | // When... 97 | val adder = DependencyResolutionManagementAdder.of(settingsScript, listOf("mavenCentral()")) 98 | 99 | // Then... 100 | assertThat(adder.rewritten()).isEqualTo( 101 | """ 102 | dependencyResolutionManagement { 103 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 104 | repositories { 105 | mavenCentral() 106 | } 107 | } 108 | """.trimIndent() 109 | ) 110 | } 111 | 112 | @Test fun `throws if drm block already present`() { 113 | // Given a settings script 114 | val settingsScript = 115 | """ 116 | dependencyResolutionManagement {} 117 | """.trimIndent() 118 | 119 | // When... 120 | val adder = DependencyResolutionManagementAdder.of(settingsScript, listOf("mavenCentral()")) 121 | 122 | // Then... 123 | assertThrows(AlreadyHasBlockException::class.java) { 124 | adder.rewritten() 125 | } 126 | } 127 | 128 | @Test fun `throws if no repos passed in`() { 129 | // Given a settings script 130 | val settingsScript = "" 131 | 132 | // Expect... 133 | assertThrows(IllegalStateException::class.java) { 134 | DependencyResolutionManagementAdder.of(settingsScript, listOf()) 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /recipes/repos/src/test/kotlin/cash/recipes/repos/RepositoriesDeleterTest.kt: -------------------------------------------------------------------------------- 1 | package cash.recipes.repos 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.junit.jupiter.api.Test 5 | import org.junit.jupiter.api.io.TempDir 6 | import java.nio.file.Path 7 | 8 | internal class RepositoriesDeleterTest { 9 | 10 | // We don't want to delete the top-level `buildscript.repositories {}` 11 | @Test fun `can delete some repositories blocks`() { 12 | // Given a build script 13 | val buildScript = 14 | """ 15 | buildscript { 16 | repositories { 17 | maven(url = "buildscript-repositories") 18 | } 19 | dependencies { 20 | classpath("com.group:artifact:1.0") 21 | } 22 | } 23 | 24 | plugins { 25 | id("java-library") 26 | } 27 | 28 | repositories { 29 | maven(url = "repositories") 30 | } 31 | 32 | // The buildscript and repositories blocks below should be completely deleted 33 | subprojects { 34 | apply(plugin = "java-library") 35 | 36 | buildscript { 37 | repositories { 38 | maven(url = "subprojects-buildscript-repositories") 39 | } 40 | } 41 | 42 | repositories { 43 | maven(url = "subprojects-repositories") 44 | } 45 | } 46 | 47 | allprojects { 48 | buildscript { 49 | repositories { 50 | maven(url = "allprojects-buildscript-repositories") 51 | } 52 | } 53 | } 54 | """.trimIndent() 55 | 56 | // When... 57 | val deleter = RepositoriesDeleter.of(buildScript) 58 | 59 | // Then... 60 | assertThat(deleter.rewritten()).isEqualTo( 61 | """ 62 | buildscript { 63 | repositories { 64 | maven(url = "buildscript-repositories") 65 | } 66 | dependencies { 67 | classpath("com.group:artifact:1.0") 68 | } 69 | } 70 | 71 | plugins { 72 | id("java-library") 73 | } 74 | 75 | // The buildscript and repositories blocks below should be completely deleted 76 | subprojects { 77 | apply(plugin = "java-library") 78 | } 79 | """.trimIndent() 80 | ) 81 | } 82 | 83 | @Test fun `can handle file annotations`() { 84 | // Given a build script 85 | val buildScript = 86 | """ 87 | @file:Suppress("RemoveRedundantQualifierName") 88 | 89 | allprojects { 90 | buildscript { 91 | repositories { 92 | maven(url = "allprojects-buildscript-repositories") 93 | } 94 | } 95 | } 96 | """.trimIndent() 97 | 98 | // When... 99 | val deleter = RepositoriesDeleter.of(buildScript) 100 | 101 | // Then... 102 | assertThat(deleter.rewritten()).isEqualTo( 103 | """ 104 | @file:Suppress("RemoveRedundantQualifierName") 105 | """.trimIndent() 106 | ) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "kotlin-editor" 2 | 3 | pluginManagement { 4 | includeBuild("build-logic") 5 | 6 | repositories { 7 | mavenCentral() 8 | gradlePluginPortal() 9 | } 10 | } 11 | 12 | plugins { 13 | // Keep this version in sync with version catalog 14 | id("com.gradle.develocity") version "3.17.5" 15 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" 16 | id("cash.settings") 17 | } 18 | 19 | dependencyResolutionManagement { 20 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 21 | repositories { 22 | mavenCentral() 23 | } 24 | } 25 | 26 | include(":core") 27 | include(":grammar") 28 | include(":recipes:dependencies") 29 | include(":recipes:plugins") 30 | include(":recipes:repos") 31 | --------------------------------------------------------------------------------