├── .editorconfig ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .idea ├── .gitignore ├── codeStyles │ └── codeStyleConfig.xml ├── kotlinc.xml └── saveactions_settings.xml ├── ktcodeshift-cli ├── src │ ├── test │ │ ├── resources │ │ │ └── examples │ │ │ │ ├── __testfixtures__ │ │ │ │ ├── FlattenListOutput.kt │ │ │ │ ├── AnnotationsInput.kt │ │ │ │ ├── FlattenListInput.kt │ │ │ │ ├── AnnotationsOutput.kt │ │ │ │ ├── RenameVariableInput.kt │ │ │ │ ├── RenameVariableOutput.kt │ │ │ │ ├── JUnit4To5Input.kt │ │ │ │ └── JUnit4To5Output.kt │ │ │ │ ├── Annotations.transform.kts │ │ │ │ ├── Identity.transform.kts │ │ │ │ ├── RenameVariable.transform.kts │ │ │ │ ├── common.transform.kts │ │ │ │ ├── FlattenList.transform.kts │ │ │ │ ├── SortWhenBranches.transform.kts │ │ │ │ ├── JUnit4To5.transform.kts │ │ │ │ └── GenerateBuilders.transform.kts │ │ └── kotlin │ │ │ ├── examples │ │ │ └── TransformTest.kt │ │ │ └── ktcodeshift │ │ │ └── testing │ │ │ └── TestUtil.kt │ └── main │ │ └── kotlin │ │ └── ktcodeshift │ │ ├── CLI.kt │ │ └── App.kt └── build.gradle.kts ├── .gitattributes ├── ktcodeshift-dsl ├── src │ ├── main │ │ └── kotlin │ │ │ └── ktcodeshift │ │ │ ├── script │ │ │ ├── Annotations.kt │ │ │ └── ScriptDefinition.kt │ │ │ ├── FileInfo.kt │ │ │ ├── Ktcodeshift.kt │ │ │ ├── Dsl.kt │ │ │ ├── NodeExtension.kt │ │ │ └── NodeCollection.kt │ └── test │ │ └── kotlin │ │ └── ktcodeshift │ │ └── ApiTest.kt └── build.gradle.kts ├── settings.gradle.kts ├── .github ├── dependabot.yml └── workflows │ ├── document.yaml │ └── java_ci.yaml ├── LICENSE ├── .gitignore ├── gradlew.bat ├── README.md └── gradlew /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangain/ktcodeshift/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | /vcs.xml 5 | /misc.xml 6 | /jarRepositories.xml 7 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/__testfixtures__/FlattenListOutput.kt: -------------------------------------------------------------------------------- 1 | package examples.__testfixtures__ 2 | 3 | val a = listOf(1, 2,3) 4 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/__testfixtures__/AnnotationsInput.kt: -------------------------------------------------------------------------------- 1 | package examples.__testfixtures__ 2 | 3 | val a = listOf(listOf(1, 2), listOf(3)) 4 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/__testfixtures__/FlattenListInput.kt: -------------------------------------------------------------------------------- 1 | package examples.__testfixtures__ 2 | 3 | val a = listOf(listOf(1, 2), listOf(3)) 4 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/__testfixtures__/AnnotationsOutput.kt: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | 4 |

Hello, World!

5 | 6 | 7 | """ 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # 2 | # https://help.github.com/articles/dealing-with-line-endings/ 3 | # 4 | # These are explicitly windows files and should use crlf 5 | *.bat text eol=crlf 6 | 7 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/Annotations.transform.kts: -------------------------------------------------------------------------------- 1 | @file:Import("./common.transform.kts") 2 | 3 | transform { fileInfo -> 4 | "\"\"\"\n" + renderHtml() + "\"\"\"\n" 5 | } 6 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/Identity.transform.kts: -------------------------------------------------------------------------------- 1 | transform { fileInfo -> 2 | Ktcodeshift 3 | .parse(fileInfo.source) 4 | .also { println(Dumper.dump(it)) } 5 | .toSource() 6 | } 7 | -------------------------------------------------------------------------------- /ktcodeshift-dsl/src/main/kotlin/ktcodeshift/script/Annotations.kt: -------------------------------------------------------------------------------- 1 | package ktcodeshift.script 2 | 3 | /** 4 | * Import other script(s) 5 | */ 6 | @Target(AnnotationTarget.FILE) 7 | @Repeatable 8 | @Retention(AnnotationRetention.SOURCE) 9 | annotation class Import(vararg val paths: String) 10 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/__testfixtures__/RenameVariableInput.kt: -------------------------------------------------------------------------------- 1 | class MyClass { 2 | val foo = 1 3 | fun x() { 4 | var foo = "" 5 | } 6 | 7 | fun y() { 8 | val (foo, baz) = Pair(2, 3) 9 | } 10 | 11 | fun foo() { 12 | 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/__testfixtures__/RenameVariableOutput.kt: -------------------------------------------------------------------------------- 1 | class MyClass { 2 | val bar = 1 3 | fun x() { 4 | var bar = "" 5 | } 6 | 7 | fun y() { 8 | val (bar, baz) = Pair(2, 3) 9 | } 10 | 11 | fun foo() { 12 | 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /ktcodeshift-dsl/src/main/kotlin/ktcodeshift/FileInfo.kt: -------------------------------------------------------------------------------- 1 | package ktcodeshift 2 | 3 | import java.nio.file.Path 4 | 5 | /** 6 | * Information about a file to be transformed. 7 | * 8 | * @property source the source code of the file 9 | * @property path the path of the file 10 | */ 11 | interface FileInfo { 12 | val source: String 13 | val path: Path 14 | } 15 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/RenameVariable.transform.kts: -------------------------------------------------------------------------------- 1 | transform { fileInfo -> 2 | Ktcodeshift 3 | .parse(fileInfo.source) 4 | .find() 5 | .filter { n -> 6 | parent is Node.Variable && n.text == "foo" 7 | } 8 | .replaceWith { n -> 9 | n.copy(text = "bar") 10 | } 11 | .toSource() 12 | } 13 | -------------------------------------------------------------------------------- /ktcodeshift-dsl/src/main/kotlin/ktcodeshift/Ktcodeshift.kt: -------------------------------------------------------------------------------- 1 | package ktcodeshift 2 | 3 | import ktast.ast.Node 4 | import ktast.ast.psi.Parser 5 | 6 | /** 7 | * The main entry point for the ktcodeshift API. 8 | */ 9 | object Ktcodeshift { 10 | /** 11 | * Parses the given source code into a Kotlin AST. 12 | */ 13 | fun parse(source: String): Node.KotlinFile { 14 | return Parser.parseFile(source) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/common.transform.kts: -------------------------------------------------------------------------------- 1 | @file:Repository("https://maven.pkg.jetbrains.space/public/p/kotlinx-html/maven") 2 | @file:DependsOn("org.jetbrains.kotlinx:kotlinx-html-jvm:0.7.3") 3 | 4 | import kotlinx.html.*; import kotlinx.html.stream.*; import kotlinx.html.attributes.* 5 | 6 | fun renderHtml(): String { 7 | val addressee = "World" 8 | 9 | return createHTML().html { 10 | body { 11 | h1 { +"Hello, $addressee!" } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * This file was generated by the Gradle 'init' task. 3 | * 4 | * The settings file is used to specify which projects to include in your build. 5 | * 6 | * Detailed information about configuring a multi-project build in Gradle can be found 7 | * in the user manual at https://docs.gradle.org/7.4.2/userguide/multi_project_builds.html 8 | * This project uses @Incubating APIs which are subject to change. 9 | */ 10 | 11 | rootProject.name = "ktcodeshift" 12 | include("ktcodeshift-cli") 13 | include("ktcodeshift-dsl") 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gradle" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/kotlin/examples/TransformTest.kt: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import ktcodeshift.testing.testTransform 4 | import kotlin.test.Test 5 | 6 | class TransformTest { 7 | @Test 8 | fun testFlattenList() = testTransform(this::class, "FlattenList") 9 | 10 | @Test 11 | fun testJUnit4To5() = testTransform(this::class, "JUnit4To5") 12 | 13 | @Test 14 | fun testRenameVariable() = testTransform(this::class, "RenameVariable") 15 | 16 | @Test 17 | fun testAnnotations() = testTransform(this::class, "Annotations") 18 | } 19 | -------------------------------------------------------------------------------- /.idea/saveactions_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 17 | 18 | -------------------------------------------------------------------------------- /ktcodeshift-dsl/src/main/kotlin/ktcodeshift/Dsl.kt: -------------------------------------------------------------------------------- 1 | package ktcodeshift 2 | 3 | typealias TransformFunction = (FileInfo) -> String? 4 | 5 | var transformFunction: TransformFunction? = null 6 | 7 | /** 8 | * Defines the transform function. This function will be called for each target file. 9 | * 10 | * @param fn the transform function. It takes a [FileInfo] and returns the transformed source code. When the function returns null, the target file will be left unmodified. 11 | */ 12 | @Suppress("unused") 13 | fun transform(fn: TransformFunction) { 14 | if (transformFunction != null) { 15 | error("You cannot call transform function more than once.") 16 | } 17 | 18 | transformFunction = fn 19 | } 20 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/FlattenList.transform.kts: -------------------------------------------------------------------------------- 1 | transform { fileInfo -> 2 | Ktcodeshift 3 | .parse(fileInfo.source) 4 | .find() 5 | .filter { n -> isListOf(n) } 6 | .replaceWith { n -> 7 | n.copy(arguments = n.arguments.flatMap { argument -> 8 | val expr = argument.expression 9 | if (expr is Node.Expression.CallExpression && isListOf(expr)) { 10 | expr.arguments 11 | } else { 12 | listOf(argument) 13 | } 14 | }) 15 | } 16 | .toSource() 17 | } 18 | 19 | fun isListOf(call: Node.Expression.CallExpression): Boolean { 20 | val expr = call.calleeExpression as? Node.Expression.NameExpression ?: return false 21 | return expr.text == "listOf" 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/document.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Document 2 | 3 | on: 4 | push: 5 | tags: 6 | - "**" 7 | branches: 8 | - main 9 | - dokka 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | permissions: 16 | contents: write 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up JDK 21 21 | uses: actions/setup-java@v4 22 | with: 23 | java-version: 21 24 | distribution: temurin 25 | - name: Setup Gradle 26 | uses: gradle/actions/setup-gradle@v4 27 | - name: Build document 28 | run: ./gradlew dokkaHtmlMultiModule 29 | - name: Deploy 30 | uses: peaceiris/actions-gh-pages@v3 31 | with: 32 | deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} 33 | publish_dir: ./build/dokka/htmlMultiModule 34 | destination_dir: ./${{ github.ref_name }}/api 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 orangain 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/__testfixtures__/JUnit4To5Input.kt: -------------------------------------------------------------------------------- 1 | import org.junit.* 2 | 3 | class MyTestClass { 4 | companion object { 5 | @BeforeClass 6 | @JvmStatic 7 | fun setup() { 8 | // things to execute once and keep around for the class 9 | } 10 | 11 | @AfterClass 12 | @JvmStatic 13 | fun teardown() { 14 | // clean up after this class, leave nothing dirty behind 15 | } 16 | } 17 | 18 | @Before 19 | fun prepareTest() { 20 | // things to do before each test 21 | } 22 | 23 | @After 24 | fun cleanupTest() { 25 | // things to do after each test 26 | } 27 | 28 | @Ignore 29 | @Test 30 | fun testSkipped() { 31 | // skipped test case 32 | } 33 | 34 | @Test(expected = IllegalStateException::class) 35 | fun testRaises() { 36 | check(1 == 2) 37 | } 38 | 39 | @Test(expected = java.lang.IllegalArgumentException::class) 40 | fun testRaises2() { 41 | require(1 == 2) 42 | } 43 | 44 | @Test 45 | fun testSomething() { 46 | // an actual test case 47 | } 48 | 49 | @Test 50 | fun testSomethingElse() { 51 | // another test case 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/kotlin/ktcodeshift/testing/TestUtil.kt: -------------------------------------------------------------------------------- 1 | package ktcodeshift.testing 2 | 3 | import ktcodeshift.* 4 | import kotlin.io.path.Path 5 | import kotlin.reflect.KClass 6 | import kotlin.script.experimental.host.UrlScriptSource 7 | import kotlin.test.assertEquals 8 | 9 | fun testTransform(testClass: KClass<*>, transformName: String, fixtureName: String = transformName) { 10 | val packageName = testClass.java.packageName 11 | val packageDir = packageName.replace('.', '/') 12 | val transformPath = "${packageDir}/${transformName}.transform.kts" 13 | val inputPath = "${packageDir}/__testfixtures__/${fixtureName}Input.kt" 14 | val outputPath = "${packageDir}/__testfixtures__/${fixtureName}Output.kt" 15 | val scriptUrl = testClass.java.classLoader.getResource(transformPath) 16 | val inputSource = testClass.java.classLoader.getResource(inputPath).readText() 17 | val outputSource = testClass.java.classLoader.getResource(outputPath).readText() 18 | val transform = evalScriptSource(UrlScriptSource(scriptUrl)) 19 | 20 | val changedSource = applyTransform(transform, object : FileInfo { 21 | override val path = Path(inputPath) 22 | override val source = inputSource 23 | }) 24 | 25 | assertEquals(outputSource.trim(), changedSource.trim()) 26 | } 27 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/__testfixtures__/JUnit4To5Output.kt: -------------------------------------------------------------------------------- 1 | import org.junit.jupiter.api.* 2 | 3 | class MyTestClass { 4 | companion object { 5 | @BeforeAll 6 | @JvmStatic 7 | fun setup() { 8 | // things to execute once and keep around for the class 9 | } 10 | 11 | @AfterAll 12 | @JvmStatic 13 | fun teardown() { 14 | // clean up after this class, leave nothing dirty behind 15 | } 16 | } 17 | 18 | @BeforeEach 19 | fun prepareTest() { 20 | // things to do before each test 21 | } 22 | 23 | @AfterEach 24 | fun cleanupTest() { 25 | // things to do after each test 26 | } 27 | 28 | @Disabled 29 | @Test 30 | fun testSkipped() { 31 | // skipped test case 32 | } 33 | 34 | @Test 35 | fun testRaises(){Assertions.assertThrows{ 36 | check(1 == 2) 37 | }} 38 | 39 | @Test 40 | fun testRaises2(){Assertions.assertThrows{ 41 | require(1 == 2) 42 | }} 43 | 44 | @Test 45 | fun testSomething() { 46 | // an actual test case 47 | } 48 | 49 | @Test 50 | fun testSomethingElse() { 51 | // another test case 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.github/workflows/java_ci.yaml: -------------------------------------------------------------------------------- 1 | name: Java CI 2 | 3 | on: [ push ] 4 | 5 | permissions: 6 | contents: write 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up JDK 11 15 | uses: actions/setup-java@v4 16 | with: 17 | java-version: '11' 18 | distribution: 'adopt' 19 | - name: Setup Gradle 20 | uses: gradle/actions/setup-gradle@v4 21 | - name: Execute Gradle build 22 | run: ./gradlew build 23 | 24 | - name: Create release on tag 25 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') 26 | uses: "marvinpinto/action-automatic-releases@v1.2.1" 27 | with: 28 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 29 | prerelease: false 30 | files: | 31 | ktcodeshift-cli/build/distributions/* 32 | 33 | - name: Update formula on tag 34 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') 35 | uses: "mislav/bump-homebrew-formula-action@v2" 36 | with: 37 | download-url: https://github.com/orangain/ktcodeshift/releases/download/${{ github.ref_name }}/ktcodeshift-${{ github.ref_name }}.tar.gz 38 | formula-name: ktcodeshift 39 | homebrew-tap: orangain/homebrew-tap 40 | env: 41 | COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }} 42 | -------------------------------------------------------------------------------- /ktcodeshift-dsl/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 3 | 4 | plugins { 5 | kotlin("jvm") 6 | id("org.jetbrains.dokka") 7 | } 8 | 9 | dependencies { 10 | // Align versions of all Kotlin components 11 | implementation(platform("org.jetbrains.kotlin:kotlin-bom")) 12 | 13 | // Use the Kotlin JDK 8 standard library. 14 | implementation("org.jetbrains.kotlin:kotlin-stdlib") 15 | implementation("com.github.orangain.ktast:ast-psi:0.11.0") 16 | implementation("org.jetbrains.kotlin:kotlin-scripting-common") 17 | implementation("org.jetbrains.kotlin:kotlin-scripting-jvm") 18 | implementation("org.jetbrains.kotlin:kotlin-scripting-dependencies") 19 | implementation("org.jetbrains.kotlin:kotlin-scripting-dependencies-maven") 20 | // coroutines dependency is required for this particular definition 21 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1") 22 | } 23 | 24 | tasks.withType { 25 | compilerOptions { 26 | jvmTarget.set(JvmTarget.JVM_11) 27 | } 28 | } 29 | 30 | java { 31 | toolchain { 32 | languageVersion.set(JavaLanguageVersion.of(11)) 33 | } 34 | } 35 | 36 | testing { 37 | suites { 38 | // Configure the built-in test suite 39 | val test by getting(JvmTestSuite::class) { 40 | // Use Kotlin Test test framework 41 | useKotlinTest() 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ktcodeshift-dsl/src/main/kotlin/ktcodeshift/NodeExtension.kt: -------------------------------------------------------------------------------- 1 | package ktcodeshift 2 | 3 | import ktast.ast.MutableVisitor 4 | import ktast.ast.Node 5 | import ktast.ast.NodePath 6 | import ktast.ast.Writer 7 | import kotlin.reflect.KClass 8 | 9 | /** 10 | * Converts the given node to source code. 11 | */ 12 | fun Node.toSource(): String { 13 | return Writer.write(this) 14 | } 15 | 16 | /** 17 | * Returns [NodeCollection] of all nodes of type [T] under this node. 18 | */ 19 | inline fun Node.KotlinFile.find(): NodeCollection = find(T::class) 20 | 21 | /** 22 | * Returns [NodeCollection] of all nodes of type [T] under this node. 23 | */ 24 | fun Node.KotlinFile.find(kClass: KClass): NodeCollection = find(kClass.java) 25 | 26 | /** 27 | * Returns [NodeCollection] of all nodes of type [T] under this node. 28 | */ 29 | fun Node.KotlinFile.find(javaClass: Class): NodeCollection { 30 | val nodes = mutableListOf>() 31 | MutableVisitor.traverse(this) { path -> 32 | if (javaClass.isAssignableFrom(path.node::class.java)) { 33 | @Suppress("UNCHECKED_CAST") 34 | nodes.add(path as NodePath) 35 | } 36 | path.node 37 | } 38 | return NodeCollection(nodes.toList(), this) 39 | } 40 | 41 | /** 42 | * Get list of annotations for this node. 43 | */ 44 | val Node.WithModifiers.annotations: List 45 | get() = annotationSets.flatMap { it.annotations } 46 | 47 | /** 48 | * Get list of keyword modifiers for this node. 49 | */ 50 | val Node.WithModifiers.keywordModifiers: List 51 | get() = modifiers.mapNotNull { it as? Node.Modifier.KeywordModifier } 52 | 53 | /** 54 | * Returns true if this node is a data class. 55 | */ 56 | val Node.Declaration.ClassDeclaration.isDataClass: Boolean 57 | get() = isClass && keywordModifiers.any { it is Node.Keyword.Data } 58 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/main/kotlin/ktcodeshift/CLI.kt: -------------------------------------------------------------------------------- 1 | package ktcodeshift 2 | 3 | import picocli.CommandLine.* 4 | import java.io.File 5 | import java.util.concurrent.Callable 6 | 7 | @Command( 8 | name = "ktcodeshift", 9 | versionProvider = VersionProvider::class, 10 | mixinStandardHelpOptions = true, 11 | description = ["", "Apply transform logic in TRANSFORM_PATH (recursively) to every PATH.", ""] 12 | ) 13 | class CLI(private val process: (CLIArgs) -> Unit) : 14 | Callable { 15 | @Parameters( 16 | arity = "1..*", 17 | paramLabel = "PATH", 18 | description = ["Search target files in these paths."] 19 | ) 20 | lateinit var targetDirs: Array 21 | 22 | @Option( 23 | names = ["-t", "--transform"], 24 | required = true, 25 | paramLabel = "TRANSFORM_PATH", 26 | description = ["Transform file"] 27 | ) 28 | lateinit var transformFile: File 29 | 30 | @Option( 31 | names = ["-d", "--dry"], 32 | description = ["dry run (no changes are made to files)"] 33 | ) 34 | var dryRun: Boolean = false 35 | 36 | @Option( 37 | names = ["--extensions"], 38 | paramLabel = "EXT", 39 | description = ["Target file extensions to be transformed (comma separated list)", "(default: kt)"] 40 | ) 41 | var extensions: String = "kt" 42 | 43 | override fun call(): Int { 44 | process( 45 | CLIArgs( 46 | transformFile = transformFile, 47 | targetDirs = targetDirs.toList(), 48 | dryRun = dryRun, 49 | extensions = extensions.split(",").toSet(), 50 | ) 51 | ) 52 | return 0 53 | } 54 | } 55 | 56 | data class CLIArgs( 57 | val transformFile: File, 58 | val targetDirs: List, 59 | val dryRun: Boolean, 60 | val extensions: Set, 61 | ) 62 | 63 | class VersionProvider : IVersionProvider { 64 | override fun getVersion(): Array { 65 | return arrayOf(this::class.java.getPackage().implementationVersion ?: "unknown") 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/SortWhenBranches.transform.kts: -------------------------------------------------------------------------------- 1 | /* 2 | You can sort when branches in ../ktast/ast/src/commonMain/kotlin/ktast/ast/{Visitor,MutableVisitor,Writer}.kt using the following command: 3 | 4 | ktcodeshift -t ktcodeshift-cli/src/test/resources/examples/SortWhenBranches.transform.kts ../ktast/ast/src/commonMain/kotlin/ktast/ast 5 | 6 | */ 7 | 8 | import java.nio.charset.StandardCharsets 9 | 10 | transform { fileInfo -> 11 | val fileName = fileInfo.path.fileName.toString() 12 | println("fileName: $fileName") 13 | if (!setOf("Visitor.kt", "MutableVisitor.kt", "Writer.kt").contains(fileName)) { 14 | return@transform null 15 | } 16 | 17 | val nodeSource = 18 | java.io.File("../ktast/ast/src/commonMain/kotlin/ktast/ast/Node.kt").readText(StandardCharsets.UTF_8) 19 | val nodeIndexes = Ktcodeshift.parse(nodeSource) 20 | .find() 21 | .map { nestedClassNames(it, ancestors) } 22 | .mapIndexed { index, names -> names to index } 23 | .toMap() 24 | // println(nodeIndexes) 25 | 26 | Ktcodeshift 27 | .parse(fileInfo.source) 28 | .find() 29 | .filterIndexed { index, _ -> 30 | index == 0 31 | } 32 | .replaceWith { node -> 33 | node.copy( 34 | branches = node.branches.sortedBy { branch -> 35 | if (branch is Node.Expression.WhenExpression.ElseWhenBranch) { 36 | return@sortedBy Int.MAX_VALUE 37 | } 38 | val type = branch.conditions[0].type as Node.Type.SimpleType 39 | val names = type.qualifiers.map { it.name.text } + type.name.text 40 | nodeIndexes[names] ?: 0 41 | } 42 | ) 43 | } 44 | .toSource() 45 | } 46 | 47 | fun nestedClassNames(node: Node, ancestors: Sequence): List { 48 | val nestedClasses = (ancestors.toList().reversed() + node) 49 | .filterIsInstance() 50 | return nestedClasses.map { it.name.text } 51 | } 52 | -------------------------------------------------------------------------------- /ktcodeshift-cli/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 3 | 4 | plugins { 5 | // Add support for Kotlin. 6 | kotlin("jvm") 7 | // Add support for building a CLI application in Java. 8 | application 9 | // Add support for generating document. 10 | id("org.jetbrains.dokka") 11 | } 12 | 13 | dependencies { 14 | // Align versions of all Kotlin components 15 | implementation(platform("org.jetbrains.kotlin:kotlin-bom")) 16 | 17 | // Use the Kotlin JDK 8 standard library. 18 | implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") 19 | implementation("org.jetbrains.kotlin:kotlin-scripting-common") 20 | implementation("org.jetbrains.kotlin:kotlin-scripting-jvm") 21 | implementation("org.jetbrains.kotlin:kotlin-scripting-jvm-host") 22 | implementation("info.picocli:picocli:4.7.6") 23 | implementation(project(":ktcodeshift-dsl")) // the script definition module 24 | implementation("org.jetbrains.kotlin:kotlin-test") 25 | testImplementation("com.github.orangain.ktast:ast-psi:0.10.0") 26 | } 27 | 28 | tasks.withType { 29 | compilerOptions { 30 | jvmTarget.set(JvmTarget.JVM_11) 31 | } 32 | } 33 | 34 | java { 35 | toolchain { 36 | languageVersion.set(JavaLanguageVersion.of(11)) 37 | } 38 | } 39 | 40 | testing { 41 | suites { 42 | // Configure the built-in test suite 43 | val test by getting(JvmTestSuite::class) { 44 | // Use Kotlin Test test framework 45 | useKotlinTest() 46 | } 47 | } 48 | } 49 | 50 | tasks.jar { 51 | manifest { 52 | attributes( 53 | mapOf("Implementation-Version" to project.version.toString()) 54 | ) 55 | } 56 | 57 | // Include LICENSE file in jar. 58 | into("META-INF") { 59 | from("../LICENSE") 60 | } 61 | } 62 | 63 | application { 64 | // Define the main class for the application. 65 | mainClass.set("ktcodeshift.AppKt") 66 | 67 | applicationName = "ktcodeshift" 68 | } 69 | 70 | // Inherit current directory when executed by `gradle run` 71 | tasks.run.get().workingDir = File(System.getProperty("user.dir")) 72 | 73 | // Create .tar.gz instead of .tar 74 | tasks.distTar { 75 | compression = Compression.GZIP 76 | archiveExtension.set("tar.gz") 77 | } 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled class file 2 | *.class 3 | 4 | # Log file 5 | *.log 6 | 7 | # BlueJ files 8 | *.ctxt 9 | 10 | # Mobile Tools for Java (J2ME) 11 | .mtj.tmp/ 12 | 13 | # Package Files # 14 | *.war 15 | *.nar 16 | *.ear 17 | *.zip 18 | *.tar.gz 19 | *.rar 20 | 21 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 22 | hs_err_pid* 23 | 24 | # Ignore Gradle project-specific cache directory 25 | .gradle 26 | 27 | # Ignore Gradle build output directory 28 | build 29 | ### https://raw.github.com/github/gitignore/218a941be92679ce67d0484547e3e142b2f5f6f0/Global/JetBrains.gitignore 30 | 31 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 32 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 33 | 34 | # User-specific stuff 35 | .idea/**/workspace.xml 36 | .idea/**/tasks.xml 37 | .idea/**/usage.statistics.xml 38 | .idea/**/dictionaries 39 | .idea/**/shelf 40 | 41 | # Generated files 42 | .idea/**/contentModel.xml 43 | 44 | # Sensitive or high-churn files 45 | .idea/**/dataSources/ 46 | .idea/**/dataSources.ids 47 | .idea/**/dataSources.local.xml 48 | .idea/**/sqlDataSources.xml 49 | .idea/**/dynamic.xml 50 | .idea/**/uiDesigner.xml 51 | .idea/**/dbnavigator.xml 52 | 53 | # Gradle 54 | .idea/**/gradle.xml 55 | .idea/**/libraries 56 | 57 | # Gradle and Maven with auto-import 58 | # When using Gradle or Maven with auto-import, you should exclude module files, 59 | # since they will be recreated, and may cause churn. Uncomment if using 60 | # auto-import. 61 | .idea/artifacts 62 | .idea/compiler.xml 63 | .idea/jarRepositories.xml 64 | .idea/modules.xml 65 | .idea/*.iml 66 | .idea/modules 67 | *.iml 68 | *.ipr 69 | 70 | # CMake 71 | cmake-build-*/ 72 | 73 | # Mongo Explorer plugin 74 | .idea/**/mongoSettings.xml 75 | 76 | # File-based project format 77 | *.iws 78 | 79 | # IntelliJ 80 | out/ 81 | 82 | # mpeltonen/sbt-idea plugin 83 | .idea_modules/ 84 | 85 | # JIRA plugin 86 | atlassian-ide-plugin.xml 87 | 88 | # Cursive Clojure plugin 89 | .idea/replstate.xml 90 | 91 | # Crashlytics plugin (for Android Studio and IntelliJ) 92 | com_crashlytics_export_strings.xml 93 | crashlytics.properties 94 | crashlytics-build.properties 95 | fabric.properties 96 | 97 | # Editor-based Rest Client 98 | .idea/httpRequests 99 | 100 | # Android studio 3.1+ serialized cache file 101 | .idea/caches/build_file_checksums.ser 102 | 103 | 104 | -------------------------------------------------------------------------------- /ktcodeshift-dsl/src/main/kotlin/ktcodeshift/NodeCollection.kt: -------------------------------------------------------------------------------- 1 | package ktcodeshift 2 | 3 | import ktast.ast.MutableVisitor 4 | import ktast.ast.Node 5 | import ktast.ast.NodePath 6 | import java.util.* 7 | 8 | /** 9 | * A collection of nodes filtered under the root node. 10 | * 11 | * @property nodePaths the list of node paths. 12 | * @property rootNode the root node. 13 | */ 14 | data class NodeCollection( 15 | val nodePaths: List>, 16 | val rootNode: Node.KotlinFile, 17 | ) { 18 | /** 19 | * Returns a new collection containing only elements matching the given [predicate]. 20 | */ 21 | fun filter(predicate: NodeContext.(T) -> Boolean): NodeCollection { 22 | return filterIndexed { _, node -> predicate(node) } 23 | } 24 | 25 | /** 26 | * Returns a new collection containing only elements matching the given [predicate]. The element index is passed as the first argument to the predicate. 27 | */ 28 | fun filterIndexed(predicate: NodeContext.(Int, T) -> Boolean): NodeCollection { 29 | return copy( 30 | nodePaths = nodePaths.filterIndexed { i, path -> NodeContext(path).predicate(i, path.node) } 31 | ) 32 | } 33 | 34 | /** 35 | * Returns a list of the results of applying the given [transform] function to each element in the original collection. 36 | */ 37 | fun map(transform: NodeContext.(T) -> S): List { 38 | return mapIndexed { _, node -> transform(node) } 39 | } 40 | 41 | /** 42 | * Returns a list of the results of applying the given [transform] function to each element in the original collection. The element index is passed as the first argument to the transform function. 43 | */ 44 | fun mapIndexed(transform: NodeContext.(Int, T) -> S): List { 45 | return nodePaths.mapIndexed { i, path -> NodeContext(path).transform(i, path.node) } 46 | } 47 | 48 | /** 49 | * Returns a new AST root node with the nodes in this collection replaced with the result of the given [transform] function applied to each node. 50 | */ 51 | fun replaceWith(fn: NodeContext.(T) -> T): Node.KotlinFile { 52 | val nodeMap = IdentityHashMap() 53 | nodePaths.forEach { nodeMap[it.node] = true } 54 | return MutableVisitor.traverse(rootNode) { path -> 55 | if (nodeMap.contains(path.node)) { 56 | @Suppress("UNCHECKED_CAST") 57 | NodeContext(path).fn(path.node as T) 58 | } else { 59 | path.node 60 | } 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * Context for a node. 67 | * 68 | * @param path the node path. 69 | * @property parent the parent node. 70 | * @property ancestors a sequence of the ancestor nodes, starting from the parent node and ending with the root node. 71 | */ 72 | class NodeContext(path: NodePath<*>) { 73 | val parent: Node? = path.parent?.node 74 | val ancestors: Sequence = path.ancestors() 75 | } 76 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /ktcodeshift-dsl/src/main/kotlin/ktcodeshift/script/ScriptDefinition.kt: -------------------------------------------------------------------------------- 1 | package ktcodeshift.script 2 | 3 | import kotlinx.coroutines.runBlocking 4 | import java.io.File 5 | import kotlin.script.experimental.annotations.KotlinScript 6 | import kotlin.script.experimental.api.* 7 | import kotlin.script.experimental.dependencies.* 8 | import kotlin.script.experimental.dependencies.maven.MavenDependenciesResolver 9 | import kotlin.script.experimental.host.FileBasedScriptSource 10 | import kotlin.script.experimental.host.FileScriptSource 11 | import kotlin.script.experimental.jvm.JvmDependency 12 | import kotlin.script.experimental.jvm.dependenciesFromCurrentContext 13 | import kotlin.script.experimental.jvm.jvm 14 | import kotlin.script.experimental.jvm.jvmTarget 15 | 16 | @Suppress("unused") 17 | @KotlinScript( 18 | // File extension for the script type 19 | fileExtension = "transform.kts", 20 | // Compilation configuration for the script type 21 | compilationConfiguration = TransformScriptCompilationConfiguration::class 22 | ) 23 | abstract class TransformScript 24 | 25 | object TransformScriptCompilationConfiguration : ScriptCompilationConfiguration({ 26 | // Implicit imports for all scripts of this type 27 | defaultImports( 28 | DependsOn::class.qualifiedName!!, 29 | Repository::class.qualifiedName!!, 30 | Import::class.qualifiedName!!, 31 | "ktcodeshift.*", 32 | "ktast.ast.*", 33 | "ktast.builder.*", 34 | ) 35 | jvm { 36 | // Extract the whole classpath from context classloader and use it as dependencies 37 | dependenciesFromCurrentContext(wholeClasspath = true) 38 | // jvmTarget should be the same as the one used in the project 39 | jvmTarget.put("11") 40 | } 41 | // Callbacks 42 | refineConfiguration { 43 | // Process specified annotations with the provided handler 44 | onAnnotations(DependsOn::class, Repository::class, Import::class, handler = ::configureMavenDepsOnAnnotations) 45 | } 46 | }) 47 | 48 | object TransformScriptEvaluationConfiguration : ScriptEvaluationConfiguration({ 49 | }) 50 | 51 | // Handler that reconfigures the compilation on the fly 52 | fun configureMavenDepsOnAnnotations(context: ScriptConfigurationRefinementContext): ResultWithDiagnostics { 53 | val annotations = context.collectedData?.get(ScriptCollectedData.collectedAnnotations)?.takeIf { it.isNotEmpty() } 54 | ?: return context.compilationConfiguration.asSuccess() 55 | val (importAnnotations, otherAnnotations) = annotations.partition { it.annotation is Import } 56 | 57 | val scriptBaseDir = (context.script as? FileBasedScriptSource)?.file?.parentFile 58 | val importedSources = importAnnotations.flatMap { 59 | (it.annotation as Import).paths.map { sourceName -> 60 | FileScriptSource(scriptBaseDir?.resolve(sourceName) ?: File(sourceName)) 61 | } 62 | } 63 | 64 | return runBlocking { 65 | resolver.resolveFromScriptSourceAnnotations(otherAnnotations) 66 | }.onSuccess { 67 | context.compilationConfiguration.with { 68 | dependencies.append(JvmDependency(it)) 69 | if (importedSources.isNotEmpty()) importScripts.append(importedSources) 70 | }.asSuccess() 71 | } 72 | } 73 | 74 | private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver()) 75 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/JUnit4To5.transform.kts: -------------------------------------------------------------------------------- 1 | val annotationNameMap = mapOf( 2 | "Before" to "BeforeEach", 3 | "After" to "AfterEach", 4 | "BeforeClass" to "BeforeAll", 5 | "AfterClass" to "AfterAll", 6 | "Ignore" to "Disabled", 7 | ) 8 | 9 | transform { fileInfo -> 10 | Ktcodeshift 11 | .parse(fileInfo.source) 12 | .find() 13 | .filter { n -> 14 | n.names.size == 3 && n.names.take(2).map { it.text } == listOf("org", "junit") 15 | } 16 | .replaceWith { n -> 17 | n.copy( 18 | names = n.names.take(2) + listOf( 19 | nameExpression("jupiter"), 20 | nameExpression("api"), 21 | nameExpression(n.names[2].text.let { annotationNameMap[it] ?: it }), 22 | ) 23 | ) 24 | } 25 | .find() 26 | .replaceWith { n -> 27 | n.copy( 28 | type = n.type.copy( 29 | name = n.type.name.copy(text = annotationNameMap[n.type.name.text] ?: n.type.name.text) 30 | ) 31 | ) 32 | } 33 | .find() 34 | .filter { n -> 35 | val annotation = getAnnotationByName(n.annotations, "Test") 36 | getValueArgumentByName(annotation?.arguments, "expected") != null 37 | } 38 | .replaceWith { n -> 39 | val annotation = getAnnotationByName(n.annotations, "Test")!! 40 | val argument = getValueArgumentByName(annotation.arguments, "expected")!! 41 | val exceptionClassLiteral = argument.expression as Node.Expression.ClassLiteralExpression 42 | val methodBody = n.body as Node.Expression.BlockExpression 43 | 44 | n.copy( 45 | modifiers = n.modifiers.map { modifier -> 46 | if (modifier is Node.Modifier.AnnotationSet && modifier.annotations.contains(annotation)) { 47 | modifier.copy( 48 | annotations = listOf( 49 | annotation.copy( 50 | lPar = null, 51 | arguments = listOf(), 52 | rPar = null, 53 | ) 54 | ) 55 | ) 56 | } else { 57 | modifier 58 | } 59 | }, 60 | body = blockExpression( 61 | callExpression( 62 | calleeExpression = nameExpression("Assertions.assertThrows"), 63 | typeArguments = listOf( 64 | typeArgument( 65 | type = exceptionClassLiteral.lhsAsType()!!, 66 | ) 67 | ), 68 | lambdaArgument = lambdaExpression( 69 | statements = methodBody.statements, 70 | ), 71 | ) 72 | ) 73 | ) 74 | } 75 | .toSource() 76 | } 77 | 78 | fun getAnnotationByName( 79 | annotations: List, 80 | name: String 81 | ): Node.Modifier.AnnotationSet.Annotation? { 82 | return annotations.find { it.type.name.text == name } 83 | } 84 | 85 | fun getValueArgumentByName(args: List?, name: String): Node.ValueArgument? { 86 | return args.orEmpty().find { it.name?.text == name } 87 | } 88 | -------------------------------------------------------------------------------- /ktcodeshift-dsl/src/test/kotlin/ktcodeshift/ApiTest.kt: -------------------------------------------------------------------------------- 1 | package ktcodeshift 2 | 3 | import ktast.ast.Node 4 | import ktast.builder.* 5 | import kotlin.test.Test 6 | import kotlin.test.assertEquals 7 | import kotlin.test.assertIs 8 | 9 | class FileExtensionsTest { 10 | 11 | private val file = kotlinFile( 12 | declarations = listOf( 13 | classDeclaration( 14 | declarationKeyword = Node.Keyword.Class(), 15 | name = nameExpression("Foo"), 16 | ), 17 | objectDeclaration( 18 | name = nameExpression("Bar") 19 | ), 20 | functionDeclaration( 21 | name = nameExpression("baz") 22 | ) 23 | ) 24 | ) 25 | 26 | @Test 27 | fun testFindByTypeArgument() { 28 | val result = file.find() 29 | assertEquals(1, result.nodePaths.size) 30 | assertIs(result.nodePaths[0].node) 31 | } 32 | 33 | @Test 34 | fun testFindByKClass() { 35 | val result = file.find(Node.Declaration.ClassDeclaration::class) 36 | assertEquals(1, result.nodePaths.size) 37 | assertIs(result.nodePaths[0].node) 38 | } 39 | 40 | @Test 41 | fun testFindByJavaClass() { 42 | val result = file.find(Node.Declaration.ClassDeclaration::class.java) 43 | assertEquals(1, result.nodePaths.size) 44 | assertIs(result.nodePaths[0].node) 45 | } 46 | 47 | @Test 48 | fun testFindByParent() { 49 | val result = file.find() 50 | assertEquals(2, result.nodePaths.size) 51 | } 52 | } 53 | 54 | class NodeCollectionTest { 55 | private val nodeCollection = kotlinFile( 56 | declarations = listOf( 57 | classDeclaration( 58 | declarationKeyword = Node.Keyword.Class(), 59 | name = nameExpression("Foo"), 60 | ), 61 | objectDeclaration( 62 | name = nameExpression("Bar") 63 | ), 64 | functionDeclaration( 65 | name = nameExpression("baz") 66 | ) 67 | ) 68 | ).find() 69 | 70 | @Test 71 | fun testFilter() { 72 | assertEquals(3, nodeCollection.nodePaths.size) 73 | 74 | val result = nodeCollection.filter { it is Node.Declaration.ClassOrObject } 75 | assertEquals(2, result.nodePaths.size) 76 | } 77 | 78 | @Test 79 | fun testFilterIndexed() { 80 | assertEquals(3, nodeCollection.nodePaths.size) 81 | 82 | var expectedIndex = 0 83 | val result = nodeCollection.filterIndexed { i, node -> 84 | assertEquals(expectedIndex++, i) 85 | node is Node.Declaration.ClassOrObject 86 | } 87 | assertEquals(2, result.nodePaths.size) 88 | assertEquals(3, expectedIndex) 89 | } 90 | 91 | @Test 92 | fun testMap() { 93 | assertEquals(3, nodeCollection.nodePaths.size) 94 | 95 | val result = nodeCollection.map { it is Node.Declaration.ClassOrObject } 96 | assertEquals(listOf(true, true, false), result) 97 | } 98 | 99 | @Test 100 | fun testMapIndexed() { 101 | assertEquals(3, nodeCollection.nodePaths.size) 102 | 103 | val result = nodeCollection.mapIndexed { i, node -> 104 | Pair(i, node is Node.Declaration.ClassOrObject) 105 | } 106 | assertEquals( 107 | listOf( 108 | Pair(0, true), 109 | Pair(1, true), 110 | Pair(2, false), 111 | ), result 112 | ) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/main/kotlin/ktcodeshift/App.kt: -------------------------------------------------------------------------------- 1 | package ktcodeshift 2 | 3 | import ktcodeshift.script.TransformScript 4 | import picocli.CommandLine 5 | import kotlin.io.path.Path 6 | import kotlin.io.path.extension 7 | import kotlin.script.experimental.api.ScriptDiagnostic 8 | import kotlin.script.experimental.api.SourceCode 9 | import kotlin.script.experimental.host.toScriptSource 10 | import kotlin.script.experimental.jvmhost.BasicJvmScriptingHost 11 | import kotlin.script.experimental.jvmhost.createJvmCompilationConfigurationFromTemplate 12 | import kotlin.system.exitProcess 13 | 14 | fun main(args: Array) { 15 | val exitCode = CommandLine(CLI(::process)).execute(*args) 16 | exitProcess(exitCode) 17 | } 18 | 19 | private fun process(args: CLIArgs) { 20 | println("Loading transform script ${args.transformFile}") 21 | val transform = evalScriptSource(args.transformFile.toScriptSource(), onError = { exitProcess(1) }) 22 | if (args.dryRun) { 23 | println("Running in dry mode, no files will be written!") 24 | } 25 | val transformResults = args.targetDirs.flatMap { targetDir -> 26 | targetDir.walk() 27 | .filter { it.isFile && args.extensions.contains(it.toPath().extension) } 28 | .map { targetFile -> 29 | println("Processing $targetFile") 30 | val charset = Charsets.UTF_8 31 | val originalSource = targetFile.readText(charset) 32 | val changedSource = try { 33 | applyTransform(transform, object : FileInfo { 34 | override val path = Path(targetFile.absolutePath) 35 | override val source = originalSource 36 | }) 37 | } catch (ex: Exception) { 38 | ex.printStackTrace() 39 | return@map TransformResult.FAILED 40 | } 41 | 42 | if (changedSource == originalSource) { 43 | TransformResult.UNMODIFIED 44 | } else { 45 | if (!args.dryRun) { 46 | targetFile.writeText(changedSource, charset) 47 | } 48 | TransformResult.SUCCEEDED 49 | } 50 | } 51 | } 52 | .groupingBy { it } 53 | .eachCount() 54 | 55 | println("Results:") 56 | println("${transformResults[TransformResult.FAILED] ?: 0} errors") 57 | println("${transformResults[TransformResult.UNMODIFIED] ?: 0} unmodified") 58 | println("${transformResults[TransformResult.SUCCEEDED] ?: 0} ok") 59 | } 60 | 61 | private enum class TransformResult { 62 | SUCCEEDED, UNMODIFIED, FAILED 63 | } 64 | 65 | internal fun evalScriptSource(sourceCode: SourceCode, onError: () -> Unit = {}): TransformFunction { 66 | transformFunction = null 67 | 68 | val compilationConfiguration = createJvmCompilationConfigurationFromTemplate() 69 | val res = BasicJvmScriptingHost().eval(sourceCode, compilationConfiguration, null) 70 | res.reports 71 | .asSequence() 72 | .filter { d -> d.severity >= ScriptDiagnostic.Severity.WARNING } 73 | .forEach { d -> 74 | val source = d.sourcePath?.let { "$it:" }.orEmpty() 75 | val locationString = d.location?.start?.let { "${it.line}:${it.col}: " }.orEmpty() 76 | val extra = d.exception?.let { ": $it" }.orEmpty() 77 | val messageLine = "$source$locationString${d.severity}: ${d.message}$extra" 78 | when (d.severity) { 79 | ScriptDiagnostic.Severity.ERROR, 80 | ScriptDiagnostic.Severity.FATAL -> println(CommandLine.Help.Ansi.AUTO.string("@|bold,red $messageLine |@")) 81 | ScriptDiagnostic.Severity.WARNING -> println(CommandLine.Help.Ansi.AUTO.string("@|yellow $messageLine |@")) 82 | else -> println(messageLine) 83 | } 84 | } 85 | 86 | val transform = transformFunction 87 | if (transform == null && res.reports.any { it.severity >= ScriptDiagnostic.Severity.ERROR }) { 88 | onError() 89 | } 90 | checkNotNull(transform) { "transform is not defined." } 91 | return transform 92 | } 93 | 94 | internal fun applyTransform(transform: TransformFunction, fileInfo: FileInfo): String { 95 | return transform(fileInfo) ?: fileInfo.source 96 | } 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ktcodeshift [![Java CI](https://github.com/orangain/ktcodeshift/actions/workflows/java_ci.yaml/badge.svg)](https://github.com/orangain/ktcodeshift/actions/workflows/java_ci.yaml) 2 | 3 | ktcodeshift is a toolkit for running codemods over multiple Kotlin files inspired 4 | by [jscodeshift](https://github.com/facebook/jscodeshift). It provides: 5 | 6 | - A runner, which executes the provided transform for each file passed to it. It also outputs a summary of how many 7 | files have (not) been transformed. 8 | - A wrapper around [ktast](https://github.com/orangain/ktast), providing a different API. ktast is a Kotlin AST library 9 | and also tries to preserve the style of original code as much as possible. 10 | 11 | ## Setup 12 | 13 | ### Prerequisites 14 | 15 | - Java 11 or later is required. 16 | 17 | ### macOS 18 | 19 | ``` 20 | brew install orangain/tap/ktcodeshift 21 | ``` 22 | 23 | ### Other platforms 24 | 25 | Download the latest archive from [releases](https://github.com/orangain/ktcodeshift/releases) and extract 26 | it. `ktcodeshift` command is available in the `bin` directory. 27 | 28 | ## Usage 29 | 30 | ``` 31 | Usage: ktcodeshift [-dhV] [--extensions=EXT] -t=TRANSFORM_PATH PATH... 32 | 33 | Apply transform logic in TRANSFORM_PATH (recursively) to every PATH. 34 | 35 | PATH... Search target files in these paths. 36 | -d, --dry dry run (no changes are made to files) 37 | --extensions=EXT Target file extensions to be transformed (comma 38 | separated list) 39 | (default: kt) 40 | -h, --help Show this help message and exit. 41 | -t, --transform=TRANSFORM_PATH 42 | Transform file 43 | -V, --version Print version information and exit. 44 | ``` 45 | 46 | For example: 47 | 48 | ``` 49 | ktcodeshift -t RenameVariable.transform.kts src/main/kotlin 50 | ``` 51 | 52 | ## Transform file 53 | 54 | A transform file is a Kotlin script file that defines a lambda function `transform: (FileInfo) -> String?`. 55 | The `transform` function will be called for each file on the target paths by the ktcodeshift. 56 | 57 | The `transform` function takes an 58 | argument [FileInfo](https://orangain.github.io/ktcodeshift/latest/api/ktcodeshift-dsl/ktcodeshift/-file-info/index.html) 59 | , 60 | which has `source: String` and `path: java.nio.file.Path` of the target file, and must return the modified source code 61 | or null. When the transform function return the null or the same source code as the input, the ktcodeshift does not 62 | modify the target file. 63 | 64 | The script filename should end with `.transform.kts`. 65 | 66 | ```kts 67 | transform { fileInfo -> 68 | Ktcodeshift 69 | .parse(fileInfo.source) 70 | .find() 71 | .filter { n -> 72 | parent is Node.Variable && n.text == "foo" 73 | } 74 | .replaceWith { n -> 75 | n.copy(text = "bar") 76 | } 77 | .toSource() 78 | } 79 | ``` 80 | 81 | The following API documents will be helpful to write a transform file. 82 | 83 | - [API document of ktcodeshift](https://orangain.github.io/ktcodeshift/latest/api/ktcodeshift-dsl/ktcodeshift/index.html) 84 | - [API document of ktast](https://orangain.github.io/ktast/latest/api/ast/ktast.ast/index.html) 85 | 86 | ### Implicit imports 87 | 88 | The following imports are implicitly available in the transform file: 89 | 90 | ```kts 91 | import ktast.ast.* 92 | import ktast.builder.* 93 | import ktcodeshift.* 94 | ``` 95 | 96 | ### Annotations 97 | 98 | The following annotations can be used in the transform file: 99 | 100 | | Annotation | Description | 101 | |--------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 102 | | `@file:Repository` | URL of the repository where the library specified in `@file:DependsOn` is hosted.
e.g.: `@file:Repository("https://maven.pkg.jetbrains.space/public/p/kotlinx-html/maven")` | 103 | | `@file:DependsOn` | Dependent library of the transform file.
e.g.: `@file:DependsOn("org.jetbrains.kotlinx:kotlinx-html-jvm:0.7.3")` | 104 | | `@file:Import` | Script file path(s) to import into the transform file.
e.g.: `@file:Import("./common.transform.kts")` | 105 | 106 | 107 | ## Examples 108 | 109 | Example transform files are available 110 | under the [ktcodeshift-cli/src/test/resources/examples/](ktcodeshift-cli/src/test/resources/examples/) directory. The 111 | [\_\_testfixtures\_\_](ktcodeshift-cli/src/test/resources/examples/__testfixtures__) directory also contains pairs of 112 | their input and output. 113 | 114 | ## Development Tips 115 | 116 | ### Dumping AST 117 | 118 | You can dump the AST of a Kotlin file using the [ktast.ast.Dumper](https://orangain.github.io/ktast/latest/api/ast/ktast.ast/-dumper/index.html). This is useful to understand the structure of the AST. For example: 119 | 120 | ```kts 121 | transform { fileInfo -> 122 | Ktcodeshift 123 | .parse(fileInfo.source) 124 | .also { println(Dumper.dump(it)) } // This line dumps the AST. 125 | .toSource() 126 | } 127 | ``` 128 | 129 | ### Builder Functions 130 | 131 | The [ktast.builder](https://orangain.github.io/ktast/latest/api/ast/ktast.builder/index.html) package provides a number of builder functions to create AST nodes. The function name corresponds to the class name of the AST node, i.e. `Node.Expression.NameExpression` is created by `nameExpression()` function. Unlike the parameters of the constructor of the AST node class, many of the parameters of the builder functions are optional and have sensible default values. 132 | 133 | ## Internal 134 | 135 | ### How to release 136 | 137 | 1. Create and push a tag with the version, e.g. `git tag 0.1.0 && git push --tags`. 138 | 2. CI will publish a release note to GitHub and update the Homebrew formula in the [orangain/homebrew-tap](https://github.com/orangain/homebrew-tap) repository. 139 | -------------------------------------------------------------------------------- /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\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | org.gradle.wrapper.GradleWrapperMain \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /ktcodeshift-cli/src/test/resources/examples/GenerateBuilders.transform.kts: -------------------------------------------------------------------------------- 1 | /* 2 | You can generate ktcodeshift-dsl/src/main/kotlin/ktcodeshift/Builder.kt using the following command: 3 | 4 | ktcodeshift -t ktcodeshift-cli/src/test/resources/examples/GenerateBuilders.transform.kts ../ktast/ast/src/commonMain/kotlin/ktast/ast/Node.kt 5 | 6 | */ 7 | 8 | import org.jetbrains.kotlin.util.capitalizeDecapitalize.decapitalizeSmart 9 | import java.nio.charset.StandardCharsets 10 | 11 | transform { fileInfo -> 12 | val stringBuilder = StringBuilder() 13 | Ktcodeshift 14 | .parse(fileInfo.source) 15 | .also { fileNode -> 16 | val fqNames = mutableSetOf>() 17 | 18 | object : Visitor() { 19 | override fun visit(path: NodePath<*>) { 20 | val node = path.node 21 | if (node is Node.Declaration.ClassDeclaration) { 22 | fqNames.add(nestedClassNames(path)) 23 | } 24 | super.visit(path) 25 | } 26 | }.traverse(fileNode) 27 | 28 | println(fqNames) 29 | println("-".repeat(40)) 30 | stringBuilder.appendLine("package ktcodeshift") 31 | stringBuilder.appendLine() 32 | stringBuilder.appendLine("import ktast.ast.Node") 33 | stringBuilder.appendLine("import ktast.ast.NodeSupplement") 34 | 35 | GeneratorVisitor(stringBuilder, fqNames).traverse(fileNode) 36 | } 37 | java.io.File("ktcodeshift-dsl/src/main/kotlin/ktcodeshift/Builder.kt") 38 | .writeText(stringBuilder.toString(), StandardCharsets.UTF_8) 39 | 40 | null 41 | } 42 | 43 | fun nestedClassNames(path: NodePath<*>): List { 44 | val nestedClasses = (path.ancestors().toList().reversed() + path.node) 45 | .filterIsInstance() 46 | return nestedClasses.map { it.name.text } 47 | } 48 | 49 | fun functionNameOf(className: String): String { 50 | return className.decapitalizeSmart() 51 | } 52 | 53 | fun defaultValueOf(parameterName: String, type: Node.Type?): Node.Expression? { 54 | return if (type is Node.Type.NullableType) { 55 | nameExpression("null") 56 | } else if (type is Node.Type.SimpleType) { 57 | val fqName = (type.qualifiers.map { it.name } + type.name).joinToString(".") { it.text } 58 | if (fqName == "List") { 59 | if (parameterName == "variables") { 60 | null 61 | } else { 62 | nameExpression("listOf()") 63 | } 64 | } else if (fqName == "Boolean") { 65 | nameExpression("false") 66 | } else if (fqName == "NodeSupplement") { 67 | nameExpression("NodeSupplement()") 68 | } else { 69 | if (fqName.startsWith("Node.Keyword.") && !fqName.contains(".ValOrVar")) { 70 | nameExpression("$fqName()") 71 | } else { 72 | null 73 | } 74 | } 75 | } else { 76 | null 77 | } 78 | } 79 | 80 | val parenthesizedParamNames = mapOf( 81 | "EnumEntry" to "arguments", 82 | "PropertyDeclaration" to "variables", 83 | "LambdaParameter" to "variables", 84 | "Annotation" to "arguments", 85 | ) 86 | 87 | val angledParamNames = mapOf( 88 | "ClassDeclaration" to "typeParameters", 89 | "FunctionDeclaration" to "typeParameters", 90 | "PropertyDeclaration" to "typeParameters", 91 | "TypeAliasDeclaration" to "typeParameters", 92 | "SimpleTypePiece" to "typeArguments", 93 | "CallExpression" to "typeArguments", 94 | ) 95 | 96 | val keywordTypes = mapOf( 97 | "lPar" to "Node.Keyword.LPar", 98 | "rPar" to "Node.Keyword.RPar", 99 | "lAngle" to "Node.Keyword.Less", 100 | "rAngle" to "Node.Keyword.Greater", 101 | "lBracket" to "Node.Keyword.LBracket", 102 | "rBracket" to "Node.Keyword.RBracket", 103 | ) 104 | 105 | fun expressionOf(className: String, paramName: Node.Expression.NameExpression): Node.Expression { 106 | val keywordType = keywordTypes[paramName.text] 107 | when (val name = paramName.text) { 108 | "lPar", "rPar" -> { 109 | if (className == "Getter") { 110 | return nameExpression("if (body != null) $name ?: $keywordType() else $name") 111 | } 112 | if (className == "Setter") { 113 | return nameExpression("if (parameter != null) $name ?: $keywordType() else $name") 114 | } 115 | if (className == "CallExpression") { 116 | return nameExpression("if (arguments.isNotEmpty() || lambdaArgument == null) $name ?: $keywordType() else $name") 117 | } 118 | when (val parenthesizedParamName = parenthesizedParamNames[className]) { 119 | null -> { 120 | // do nothing 121 | } 122 | "variables" -> { 123 | return nameExpression("$name ?: $keywordType()") 124 | } 125 | else -> { 126 | return nameExpression("if ($parenthesizedParamName.isNotEmpty()) $name ?: $keywordType() else $name") 127 | } 128 | } 129 | } 130 | "lAngle", "rAngle" -> { 131 | val angledParamName = angledParamNames[className] 132 | if (angledParamName != null) { 133 | return nameExpression("if ($angledParamName.isNotEmpty()) $name ?: $keywordType() else $name") 134 | } 135 | } 136 | "lBracket", "rBracket" -> { 137 | if (className == "AnnotationSet") { 138 | return nameExpression("if (annotations.size >= 2) $name ?: $keywordType() else $name") 139 | } 140 | } 141 | } 142 | return paramName.copy() 143 | } 144 | 145 | class GeneratorVisitor( 146 | private val stringBuilder: StringBuilder, 147 | private val fqNames: Set>, 148 | ) : Visitor() { 149 | override fun visit(path: NodePath<*>) { 150 | val v = path.node 151 | if (v is Node.Declaration.ClassDeclaration) { 152 | val nestedNames = nestedClassNames(path) 153 | 154 | if (v.isDataClass && nestedNames[1] != "Keyword") { 155 | val name = v.name.text 156 | val parameters = v.primaryConstructor?.parameters.orEmpty() 157 | val functionName = functionNameOf(name) 158 | 159 | val func = makeBuilderFunction(nestedNames, functionName, parameters, name) 160 | stringBuilder.appendLine(Writer.write(func)) 161 | val firstParameter = func.parameters.firstOrNull() 162 | if (firstParameter?.name?.text == "statements") { 163 | val firstParamType = firstParameter.type as? Node.Type.SimpleType 164 | if (firstParamType != null) { 165 | if (firstParamType.name.text == "List") { 166 | val listElementType = firstParamType.typeArguments[0].type 167 | val varargFunc = makeVarargBuilderFunction(func, firstParameter.name.text, listElementType) 168 | stringBuilder.appendLine(Writer.write(varargFunc)) 169 | } 170 | } 171 | } 172 | if (func.parameters.map { it.name.text }.containsAll(listOf("lPar", "variables", "rPar"))) { 173 | val singleVariableFunc = makeSingleVariableBuilderFunction(func) 174 | stringBuilder.appendLine(Writer.write(singleVariableFunc)) 175 | } 176 | } 177 | } 178 | super.visit(path) 179 | } 180 | 181 | private fun makeBuilderFunction( 182 | nestedNames: List, 183 | functionName: String, 184 | parameters: List, 185 | name: String 186 | ) = functionDeclaration( 187 | supplement = NodeSupplement( 188 | extrasBefore = listOf( 189 | comment( 190 | """ 191 | /** 192 | * Creates a new [${nestedNames.joinToString(".")}] instance. 193 | */ 194 | """.trimIndent() 195 | ) 196 | ) 197 | ), 198 | name = nameExpression(functionName), 199 | parameters = parameters.map { p -> 200 | val fqType = when (val type = p.type) { 201 | is Node.Type.SimpleType -> toFqNameType(type, nestedNames) 202 | is Node.Type.NullableType -> type.copy( 203 | innerType = toFqNameType( 204 | type.innerType as Node.Type.SimpleType, 205 | nestedNames 206 | ) 207 | ) 208 | else -> type 209 | } 210 | functionParameter( 211 | name = p.name, 212 | type = fqType, 213 | defaultValue = defaultValueOf(p.name.text, fqType), 214 | ) 215 | }, 216 | body = callExpression( 217 | calleeExpression = nameExpression(nestedNames.joinToString(".")), 218 | arguments = parameters.map { p -> 219 | valueArgument( 220 | name = p.name, 221 | expression = expressionOf(name, p.name), 222 | ) 223 | }, 224 | ) 225 | ) 226 | 227 | private fun makeVarargBuilderFunction( 228 | func: Node.Declaration.FunctionDeclaration, 229 | firstParameterName: String, 230 | listElementType: Node.Type 231 | ) = func.copy( 232 | parameters = listOf( 233 | functionParameter( 234 | modifiers = listOf(Node.Keyword.Vararg()), 235 | name = nameExpression(firstParameterName), 236 | type = listElementType, 237 | ) 238 | ), 239 | body = callExpression( 240 | calleeExpression = func.name!!.copy(), 241 | arguments = listOf( 242 | valueArgument( 243 | expression = nameExpression("$firstParameterName.toList()"), 244 | ) 245 | ), 246 | ) 247 | ) 248 | 249 | private fun makeSingleVariableBuilderFunction(func: Node.Declaration.FunctionDeclaration): Node.Declaration.FunctionDeclaration { 250 | return func.copy( 251 | parameters = func.parameters.map { param -> 252 | if (param.name.text == "variables") { 253 | param.copy( 254 | name = nameExpression("variable"), 255 | type = (param.type as Node.Type.SimpleType).typeArguments[0].type, 256 | defaultValue = null, 257 | ) 258 | } else { 259 | param 260 | } 261 | }.filterNot { 262 | setOf("lPar", "rPar", "destructuringType").contains(it.name.text) 263 | }, 264 | body = (func.body as Node.Expression.CallExpression).copy( 265 | arguments = (func.body as Node.Expression.CallExpression).arguments.map { 266 | when (it.name?.text) { 267 | "variables" -> { 268 | it.copy( 269 | expression = nameExpression("listOf(variable)"), 270 | ) 271 | } 272 | "lPar", "rPar", "destructuringType" -> { 273 | it.copy( 274 | expression = nullLiteralExpression(), 275 | ) 276 | } 277 | else -> { 278 | it 279 | } 280 | } 281 | } 282 | ) 283 | ) 284 | } 285 | 286 | private fun toFqNameType(type: Node.Type.SimpleType, nestedNames: List): Node.Type.SimpleType { 287 | // e.g. Make List to List 288 | if (type.name.text == "List") { 289 | return type.copy( 290 | typeArguments = type.typeArguments.map { typeArgument -> 291 | typeArgument.copy( 292 | type = toFqNameType( 293 | typeArgument.type as Node.Type.SimpleType, 294 | nestedNames 295 | ), 296 | ) 297 | } 298 | ) 299 | } 300 | 301 | generateSequence(nestedNames) { if (it.isNotEmpty()) it.dropLast(1) else null }.forEach { prefixNames -> 302 | val fqName = prefixNames + type.qualifiers.map { it.name.text } + type.name.text 303 | if (fqNames.contains(fqName)) { 304 | return simpleType( 305 | qualifiers = fqName.dropLast(1).map { simpleTypeQualifier(nameExpression(it)) }, 306 | name = nameExpression(fqName.last()), 307 | ) 308 | } 309 | } 310 | 311 | return type 312 | } 313 | } 314 | --------------------------------------------------------------------------------