From 4c84cfead34cb07d03ae0df394081c58acae9f98 Mon Sep 17 00:00:00 2001 From: Nicklas Ansman Giertz Date: Mon, 28 Sep 2020 22:54:09 -0400 Subject: [PATCH] Implement an alternate compiler using KSP --- README.md | 30 +- RELEASING.md | 2 +- api/build.gradle.kts | 2 +- build.gradle.kts | 11 +- buildSrc/build.gradle.kts | 6 +- buildSrc/gradle.properties | 1 - buildSrc/src/main/kotlin/Projects.kt | 46 +++ buildSrc/src/main/kotlin/deps.kt | 16 +- .../ansman/autoplugin/gradle/LibraryPlugin.kt | 110 +------ .../gradle/PublishedLibraryPlugin.kt | 136 +++++++++ compiler-common/build.gradle.kts | 9 + .../autoplugin/compiler/AutoPluginHelpers.kt | 68 +++++ .../compiler/AutoPluginHelpersTest.kt | 79 +++++ compiler-ksp/build.gradle.kts | 16 + .../compiler/AutoPluginSymbolProcessor.kt | 135 +++++++++ .../compiler/AutoServiceProcessorTest.kt | 32 ++ compiler-test/build.gradle.kts | 12 + .../test/BaseAutoPluginProcessorTest.kt | 276 ++++++++++++++++++ .../compiler/AutoPluginHelpersTest.kt | 79 +++++ compiler/build.gradle.kts | 18 +- .../compiler/AutoPluginProcessor.kt | 44 +-- .../compiler/AutoServiceProcessorTest.kt | 170 +---------- gradle-plugin/build.gradle.kts | 113 +++++++ .../gradle/AutoPluginGradlePluginTest.kt | 170 +++++++++++ .../autoplugin/gradle/AutoPluginExtension.kt | 50 ++++ .../gradle/AutoPluginGradlePlugin.kt | 57 ++++ gradle.properties | 3 +- settings.gradle.kts | 21 +- 28 files changed, 1393 insertions(+), 319 deletions(-) delete mode 120000 buildSrc/gradle.properties create mode 100644 buildSrc/src/main/kotlin/Projects.kt create mode 100644 buildSrc/src/main/kotlin/se/ansman/autoplugin/gradle/PublishedLibraryPlugin.kt create mode 100644 compiler-common/build.gradle.kts create mode 100644 compiler-common/src/main/kotlin/se/ansman/autoplugin/compiler/AutoPluginHelpers.kt create mode 100644 compiler-common/src/test/kotlin/se/ansman/autoplugin/compiler/AutoPluginHelpersTest.kt create mode 100644 compiler-ksp/build.gradle.kts create mode 100644 compiler-ksp/src/main/kotlin/se/ansman/autoplugin/compiler/AutoPluginSymbolProcessor.kt create mode 100644 compiler-ksp/src/test/kotlin/se/ansman/autoplugin/compiler/AutoServiceProcessorTest.kt create mode 100644 compiler-test/build.gradle.kts create mode 100644 compiler-test/src/main/kotlin/se/ansman/autoplugin/compiler/test/BaseAutoPluginProcessorTest.kt create mode 100644 compiler-test/src/test/kotlin/se/ansman/autoplugin/compiler/AutoPluginHelpersTest.kt create mode 100644 gradle-plugin/build.gradle.kts create mode 100644 gradle-plugin/src/funcTest/kotlin/se/ansman/autoplugin/gradle/AutoPluginGradlePluginTest.kt create mode 100644 gradle-plugin/src/main/kotlin/se/ansman/autoplugin/gradle/AutoPluginExtension.kt create mode 100644 gradle-plugin/src/main/kotlin/se/ansman/autoplugin/gradle/AutoPluginGradlePlugin.kt diff --git a/README.md b/README.md index 7e52fcd..b170395 100644 --- a/README.md +++ b/README.md @@ -47,18 +47,38 @@ apply(plugin = "my-plugin") Setup --- -To get started you'll need to include the api as regular dependency and the compiler as an annotation processor: + +### KSP +If using Kotlin it's preferred to use the Gradle plugin which will use [KSP](https://github.com/google/ksp) to generate +the file: +```kotlin +plugins { + kotlin("jvm") + id("symbol-processing") version "" + id("se.ansman.autoplugin") version "0.1.1" +} + +// You can optionally configure it: +autoPlugin { + // By default he plugin verifies that the Plugin ID is valid. If there are issues with the validation it can + // be disabled like this. + verificationEnabled.set(false) +} +``` + +If you do not want to use the plugin you need to duplicate [what the plugin does](https://github.com/ansman/auto-plugin/tree/main/gradle-plugin/src/main/kotlin/se/ansman/autoplugin/gradle/AutoPluginGradlePlugin.kt). + +### Annotations Processing +If you aren't using Kotlin or does not want to use KSP you can add it as an annotation processor: ```kotlin dependencies { implementation("se.ansman.autoplugin:api:0.1.1") - kapt("se.ansman.autoplugin:compile:0.1.1") - // For non kotlin projects you'll use something like this annotationsProcessor("se.ansman.autoplugin:compile:0.1.1") + // For kotlin projects you'll use this instead + kapt("se.ansman.autoplugin:compile:0.1.1") } ``` -Then simply annotate each plugin with `@AutoPlugin` and assign it an ID. - Attribution --- This library is heavily influenced by [Auto Service](https://github.com/google/auto/tree/master/service). diff --git a/RELEASING.md b/RELEASING.md index 9f25038..4999448 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -3,7 +3,7 @@ 3. Update the `README.md` with the new version. 4. `git commit -am "Prepare for release X.Y.Z"` (where X.Y.Z is the new version) 5. `git push origin` -6. `./gradlew clean bintrayUpload`. +6. `./gradlew clean bintrayUpload publishPlugins`. 7. Visit the [api artifact](https://bintray.com/ansman/auto-plugin/api#central) and [compiler artifact](https://bintray.com/ansman/auto-plugin/compiler#central) and publish to maven central. 8. Release on GitHub 9. Update the `gradle.properties` to the next SNAPSHOT version. diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 5d46d28..f22c169 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - library + `published-library` } dependencies { diff --git a/build.gradle.kts b/build.gradle.kts index ae00aa9..01d253d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,15 @@ -@file:Suppress("UnstableApiUsage") +buildscript { + repositories { + jcenter() + google() + mavenLocal() + } +} allprojects { repositories { - mavenLocal() jcenter() + google() + mavenLocal() } } \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 96c6751..adb9387 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -11,7 +11,7 @@ repositories { } dependencies { - val kotlinVersion = providers.gradleProperty("kotlinVersion").forUseAtConfigurationTime().get() + val kotlinVersion = "1.4.10" api("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") api("org.jetbrains.dokka:dokka-gradle-plugin:$kotlinVersion") api("com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.5") @@ -23,5 +23,9 @@ gradlePlugin { id = name implementationClass = "se.ansman.autoplugin.gradle.LibraryPlugin" } + register("published-library") { + id = name + implementationClass = "se.ansman.autoplugin.gradle.PublishedLibraryPlugin" + } } } \ No newline at end of file diff --git a/buildSrc/gradle.properties b/buildSrc/gradle.properties deleted file mode 120000 index 7677fb7..0000000 --- a/buildSrc/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -../gradle.properties \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Projects.kt b/buildSrc/src/main/kotlin/Projects.kt new file mode 100644 index 0000000..689822b --- /dev/null +++ b/buildSrc/src/main/kotlin/Projects.kt @@ -0,0 +1,46 @@ + +import org.gradle.api.Project +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.api.tasks.Delete +import java.io.File + +/* + * Copyright (c) 2020. Nicklas Ansman Giertz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +val Project.testMavenRepo: File + get() = rootProject.buildDir.resolve("repo") + +fun Project.setupTestPublishing() { + with(extensions.getByType(PublishingExtension::class.java)) { + repositories { + it.maven { maven -> + maven.name = "test" + maven.url = uri(testMavenRepo) + } + } + + publications.register("testLibrary", MavenPublication::class.java) { publication -> + publication.from(project.components.getByName("java")) + publication.artifactId = project.path.removePrefix(":").replace(':', '-') + } + } + val cleanTestRepo = tasks.register("cleanTestRepo", Delete::class.java) { + it.delete = setOf(testMavenRepo) + } + tasks.named("clean") { + it.dependsOn(cleanTestRepo) + } +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/deps.kt b/buildSrc/src/main/kotlin/deps.kt index de37bf2..50c6ae7 100644 --- a/buildSrc/src/main/kotlin/deps.kt +++ b/buildSrc/src/main/kotlin/deps.kt @@ -15,6 +15,14 @@ @Suppress("ClassName", "SpellCheckingInspection", "MemberVisibilityCanBePrivate") object deps { + object kotlin { + object ksp { + const val version = "1.4.10-dev-experimental-20200924" + const val api = "com.google.devtools.ksp:symbol-processing-api:$version" + const val gradlePlugin = "com.google.devtools.ksp:symbol-processing:$version" + } + } + object auto { const val common = "com.google.auto:auto-common:0.11" object service { @@ -36,5 +44,11 @@ object deps { } const val truth = "com.google.truth:truth:1.0.1" - const val compileTesting = "com.github.tschuchortdev:kotlin-compile-testing:1.2.11" + object compileTesting { + const val version = "1.3.1" + const val core = "com.github.tschuchortdev:kotlin-compile-testing:$version" + const val ksp = "com.github.tschuchortdev:kotlin-compile-testing-ksp:$version" + } + + const val kotlinPoet = "com.squareup:kotlinpoet:1.6.0" } \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/se/ansman/autoplugin/gradle/LibraryPlugin.kt b/buildSrc/src/main/kotlin/se/ansman/autoplugin/gradle/LibraryPlugin.kt index c67c217..a3f14ab 100644 --- a/buildSrc/src/main/kotlin/se/ansman/autoplugin/gradle/LibraryPlugin.kt +++ b/buildSrc/src/main/kotlin/se/ansman/autoplugin/gradle/LibraryPlugin.kt @@ -14,128 +14,38 @@ package se.ansman.autoplugin.gradle -import com.jfrog.bintray.gradle.BintrayExtension +import deps import org.gradle.api.JavaVersion import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.plugins.JavaPluginExtension -import org.gradle.api.publish.PublishingExtension -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.jvm.tasks.Jar +import org.gradle.api.tasks.testing.Test import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension -import java.util.* -@Suppress("UnstableApiUsage") abstract class LibraryPlugin : Plugin { override fun apply(target: Project) { with(target) { plugins.apply("org.jetbrains.kotlin.jvm") - plugins.apply("org.jetbrains.dokka") - plugins.apply("maven-publish") - plugins.apply("com.jfrog.bintray") - group = "se.ansman.autoplugin" - version = providers.gradleProperty("version").forUseAtConfigurationTime().get() extensions.configure(JavaPluginExtension::class.java) { it.sourceCompatibility = JavaVersion.VERSION_1_8 it.targetCompatibility = JavaVersion.VERSION_1_8 } - extensions.configure("kotlin") { kotlin -> - kotlin.target.compilations.configureEach { - it.kotlinOptions.jvmTarget = "1.8" - } - } - - val sourcesJar = tasks.register("sourcesJar", Jar::class.java) { task -> - task.from(project.sourceSets.getByName("main").allSource) - task.archiveClassifier.set("sources") - } - - val dokkaJar = tasks.register("dokkaJar", Jar::class.java) { task -> - task.from(tasks.named("dokkaJavadoc")) - task.archiveClassifier.set("javadoc") + with(dependencies) { + add("testImplementation", platform(deps.junit.bom)) } - val publication = publishing.publications.register("library", MavenPublication::class.java) { publication -> - publication.from(project.components.getByName("java")) - publication.artifactId = project.path.removePrefix(":").replace(':', '-') - publication.artifact(sourcesJar) - publication.artifact(dokkaJar) - - with(publication.pom) { - name.set("AutoPlugin") - description.set("Generates configuration files for Gradle Plugins.") - url.set("https://github.com/ansman/auto-plugin") - issueManagement { - it.system.set("GitHub Issues") - it.url.set("https://github.com/ansman/auto-plugin/issues") - } - licenses { - it.license { license -> - license.name.set("Apache 2.0") - license.url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") - } - } - developers { - it.developer { developer -> - developer.id.set("nicklas.ansman") - developer.name.set("Nicklas Ansman Giertz") - developer.email.set("nicklas@ansman.se") - } - } - scm { scm -> - scm.url.set("https://github.com/ansman/auto-plugin") - scm.connection.set("scm:git:git://github.com/ansman/auto-plugin.git") - scm.developerConnection.set("scm:git:ssh://github.com/ansman/auto-plugin.git") - } - } + tasks.named("test", Test::class.java) { test -> + test.useJUnitPlatform() + test.testLogging.events("passed", "skipped", "failed") } - extensions.configure("bintray") { bintray -> - with(bintray) { - user = providers.gradleProperty("BINTRAY_USER").forUseAtConfigurationTime().orNull - key = providers.gradleProperty("BINTRAY_API_KEY").forUseAtConfigurationTime().orNull - setPublications(publication.name) - with(pkg) { - val pub = publication.get() - val pom = pub.pom - repo = "auto-plugin" - name = pub.artifactId - desc = pom.description.get() - issueTrackerUrl = "https://github.com/ansman/auto-plugin/issues" - websiteUrl = pom.url.get() - vcsUrl = pom.url.get() - publish = true - publicDownloadNumbers = true - with(version) { - desc = pom.description.get() - released = Date().toString() - with(gpg) { - sign = true - passphrase = providers.gradleProperty("BINTRAY_GPG_PASSWORD").forUseAtConfigurationTime().orNull - } - } - } + extensions.configure("kotlin") { kotlin -> + kotlin.target.compilations.configureEach { + it.kotlinOptions.jvmTarget = "1.8" } } - - tasks.named("bintrayUpload") { - it.doLast { printPublishedPublications() } - } - - tasks.named("publishToMavenLocal") { - it.doLast { printPublishedPublications() } - } } } - - private fun Project.printPublishedPublications() { - extensions.getByType(PublishingExtension::class.java) - .publications - .filterIsInstance() - .forEach { publication -> - println("Published artifact ${publication.groupId}:${publication.artifactId}:${publication.version}") - } - } } \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/se/ansman/autoplugin/gradle/PublishedLibraryPlugin.kt b/buildSrc/src/main/kotlin/se/ansman/autoplugin/gradle/PublishedLibraryPlugin.kt new file mode 100644 index 0000000..8f4bb46 --- /dev/null +++ b/buildSrc/src/main/kotlin/se/ansman/autoplugin/gradle/PublishedLibraryPlugin.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2020. Nicklas Ansman Giertz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package se.ansman.autoplugin.gradle + +import com.jfrog.bintray.gradle.BintrayExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.jvm.tasks.Jar +import setupTestPublishing +import java.util.* + +@Suppress("UnstableApiUsage") +abstract class PublishedLibraryPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + plugins.apply(LibraryPlugin::class.java) + plugins.apply("org.jetbrains.dokka") + plugins.apply("maven-publish") + plugins.apply("com.jfrog.bintray") + group = "se.ansman.autoplugin" + version = providers.gradleProperty("version").forUseAtConfigurationTime().get() + + val sourcesJar = tasks.register("sourcesJar", Jar::class.java) { task -> + task.from(project.sourceSets.getByName("main").allSource) + task.archiveClassifier.set("sources") + } + + val dokkaJar = tasks.register("dokkaJavadocJar", Jar::class.java) { task -> + task.from(tasks.named("dokkaJavadoc")) + task.archiveClassifier.set("javadoc") + } + + val publication = publishing.publications.register("library", MavenPublication::class.java) { publication -> + publication.from(project.components.getByName("java")) + publication.artifactId = project.path.removePrefix(":").replace(':', '-') + publication.artifact(sourcesJar) + publication.artifact(dokkaJar) + + with(publication.pom) { + name.set("AutoPlugin") + description.set("Generates configuration files for Gradle Plugins.") + url.set("https://github.com/ansman/auto-plugin") + issueManagement { + it.system.set("GitHub Issues") + it.url.set("https://github.com/ansman/auto-plugin/issues") + } + licenses { + it.license { license -> + license.name.set("Apache 2.0") + license.url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + it.developer { developer -> + developer.id.set("nicklas.ansman") + developer.name.set("Nicklas Ansman Giertz") + developer.email.set("nicklas@ansman.se") + } + } + scm { scm -> + scm.url.set("https://github.com/ansman/auto-plugin") + scm.connection.set("scm:git:git://github.com/ansman/auto-plugin.git") + scm.developerConnection.set("scm:git:ssh://github.com/ansman/auto-plugin.git") + } + } + } + + // This is needed for the gradle publish plugin + with(target.artifacts) { + add("archives", dokkaJar) + add("archives", sourcesJar) + } + + extensions.configure("bintray") { bintray -> + with(bintray) { + user = providers.gradleProperty("BINTRAY_USER").forUseAtConfigurationTime().orNull + key = providers.gradleProperty("BINTRAY_API_KEY").forUseAtConfigurationTime().orNull + setPublications(publication.name) + with(pkg) { + val pub = publication.get() + val pom = pub.pom + repo = "auto-plugin" + name = pub.artifactId + desc = pom.description.get() + issueTrackerUrl = "https://github.com/ansman/auto-plugin/issues" + websiteUrl = pom.url.get() + vcsUrl = pom.url.get() + publish = true + publicDownloadNumbers = true + with(version) { + desc = pom.description.get() + released = Date().toString() + with(gpg) { + sign = true + passphrase = providers.gradleProperty("BINTRAY_GPG_PASSWORD").forUseAtConfigurationTime().orNull + } + } + } + } + } + + tasks.named("bintrayUpload") { + it.doLast { printPublishedPublications() } + } + + tasks.named("publishLibraryPublicationToMavenLocal") { + it.doLast { printPublishedPublications() } + } + + setupTestPublishing() + } + } + + private fun Project.printPublishedPublications() { + extensions.getByType(PublishingExtension::class.java) + .publications + .filterIsInstance() + .forEach { publication -> + println("Published artifact ${publication.groupId}:${publication.artifactId}:${publication.version}") + } + } +} \ No newline at end of file diff --git a/compiler-common/build.gradle.kts b/compiler-common/build.gradle.kts new file mode 100644 index 0000000..ca3f94d --- /dev/null +++ b/compiler-common/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + `published-library` +} + +dependencies { + implementation(kotlin("stdlib-jdk8")) + testImplementation(deps.junit.jupiter) + testImplementation(deps.truth) +} \ No newline at end of file diff --git a/compiler-common/src/main/kotlin/se/ansman/autoplugin/compiler/AutoPluginHelpers.kt b/compiler-common/src/main/kotlin/se/ansman/autoplugin/compiler/AutoPluginHelpers.kt new file mode 100644 index 0000000..0316584 --- /dev/null +++ b/compiler-common/src/main/kotlin/se/ansman/autoplugin/compiler/AutoPluginHelpers.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2020. Nicklas Ansman Giertz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package se.ansman.autoplugin.compiler + +import java.io.Writer + + +object AutoPluginHelpers { + private val validPluginCharacters = Regex("[a-zA-Z0-9.-]+") + + fun validatePluginId(pluginId: String, logger: (String) -> Unit = {}): Boolean { + return when { + pluginId.isEmpty() -> { + logger("Plugin IDs must not be empty: $pluginId") + false + } + !validPluginCharacters.matches(pluginId) -> { + logger("Plugin IDs must only contain a-z, A-Z, 0-9, '.' and '-': $pluginId") + false + } + pluginId.first() == '.' || pluginId.last() == '.' -> { + logger("Plugin IDs must not start or end with '.': $pluginId") + false + } + ".." in pluginId -> { + logger("Plugin IDs cannot contain '..': $pluginId") + false + } + else -> true + } + } + + fun fileNameForPluginId(pluginId: String): String = "META-INF/gradle-plugins/$pluginId.properties" + + fun Writer.writeResourceFile(implementationClass: String) { + write("implementation-class=$implementationClass") + } + + object Errors { + fun pluginIdFormat(pluginId: String): String = + """ + Gradle plugin ID '$pluginId' is not valid. Plugin IDs must: + • Contain at least one character + • Only contain alphanumeric characters, '.', and '-'. + • Not start or end with a '.' character. + • Not contain consecutive '.' characters (i.e. '..'). + """.trimIndent() + + fun duplicatePlugins(pluginId: String, existingImplementation: String): String = + "Multiple plugins found with the same ID: '$pluginId' ($existingImplementation also implements it)" + + + fun missingSuperclass(implementationClass: String): String = + "Class $implementationClass does not implement org.gradle.api.Plugin. All classes annotated with @AutoPlugin must be a Gradle Plugin." + } +} \ No newline at end of file diff --git a/compiler-common/src/test/kotlin/se/ansman/autoplugin/compiler/AutoPluginHelpersTest.kt b/compiler-common/src/test/kotlin/se/ansman/autoplugin/compiler/AutoPluginHelpersTest.kt new file mode 100644 index 0000000..cce0df4 --- /dev/null +++ b/compiler-common/src/test/kotlin/se/ansman/autoplugin/compiler/AutoPluginHelpersTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2020. Nicklas Ansman Giertz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package se.ansman.autoplugin.compiler + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.fail +import se.ansman.autoplugin.compiler.AutoPluginHelpers.fileNameForPluginId +import se.ansman.autoplugin.compiler.AutoPluginHelpers.validatePluginId +import se.ansman.autoplugin.compiler.AutoPluginHelpers.writeResourceFile +import java.io.StringWriter + +internal class AutoPluginHelpersTest { + @Test + fun `plugin ID is valid`() { + val logger = { _: String -> fail("Should not log anything") } + assertThat(validatePluginId("valid", logger)) + assertThat(validatePluginId("valid.id", logger)) + assertThat(validatePluginId("com.example.valid", logger)) + assertThat(validatePluginId("valid-id", logger)) + assertThat(validatePluginId("com.example.valid-id", logger)) + } + + @Test + fun `plugin ID contains invalid characters`() { + var callCount = 0 + val logger = { _: String -> callCount += 1 } + assertThat(validatePluginId("inv@lid", logger)) + assertThat(callCount).isEqualTo(1) + assertThat(validatePluginId("invalid!", logger)) + assertThat(callCount).isEqualTo(2) + assertThat(validatePluginId("invalid_id", logger)) + assertThat(callCount).isEqualTo(3) + } + + @Test + fun `plugin ID starting or ending with period`() { + var callCount = 0 + val logger = { _: String -> callCount += 1 } + assertThat(validatePluginId(".invalid", logger)) + assertThat(callCount).isEqualTo(1) + assertThat(validatePluginId("invalid.", logger)) + assertThat(callCount).isEqualTo(2) + } + + @Test + fun `plugin ID contains double period`() { + var callCount = 0 + val logger = { _: String -> callCount += 1 } + assertThat(validatePluginId("invalid..id", logger)) + assertThat(callCount).isEqualTo(1) + } + + @Test + fun `file name`() { + assertThat(fileNameForPluginId("example-plugin")).isEqualTo("META-INF/gradle-plugins/example-plugin.properties") + } + + @Test + fun `writing contents`() { + val contents = with(StringWriter()) { + writeResourceFile("com.example.SomePlugin") + toString() + } + assertThat(contents).isEqualTo("implementation-class=com.example.SomePlugin") + } +} \ No newline at end of file diff --git a/compiler-ksp/build.gradle.kts b/compiler-ksp/build.gradle.kts new file mode 100644 index 0000000..2436fb5 --- /dev/null +++ b/compiler-ksp/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + `published-library` + id("symbol-processing") version deps.kotlin.ksp.version + id("dev.zacsweers.autoservice.ksp") version "0.1.0" +} + +dependencies { + implementation(kotlin("stdlib-jdk8")) + compileOnly(kotlin("compiler-embeddable")) + implementation(project(":api")) + implementation(project(":compiler-common")) + implementation(deps.kotlin.ksp.api) + implementation(deps.kotlinPoet) + testImplementation(deps.compileTesting.ksp) + testImplementation(project(":compiler-test")) +} \ No newline at end of file diff --git a/compiler-ksp/src/main/kotlin/se/ansman/autoplugin/compiler/AutoPluginSymbolProcessor.kt b/compiler-ksp/src/main/kotlin/se/ansman/autoplugin/compiler/AutoPluginSymbolProcessor.kt new file mode 100644 index 0000000..a506368 --- /dev/null +++ b/compiler-ksp/src/main/kotlin/se/ansman/autoplugin/compiler/AutoPluginSymbolProcessor.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2020. Nicklas Ansman Giertz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package se.ansman.autoplugin.compiler + +import com.google.auto.service.AutoService +import com.google.devtools.ksp.isLocal +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSNode +import com.squareup.kotlinpoet.ClassName +import org.jetbrains.kotlin.analyzer.AnalysisResult.CompilationErrorException +import se.ansman.autoplugin.AutoPlugin +import se.ansman.autoplugin.compiler.AutoPluginHelpers.Errors +import se.ansman.autoplugin.compiler.AutoPluginHelpers.validatePluginId +import se.ansman.autoplugin.compiler.AutoPluginHelpers.writeResourceFile +import java.io.IOException + + +@AutoService(SymbolProcessor::class) +class AutoPluginSymbolProcessor : SymbolProcessor { + private lateinit var codeGenerator: CodeGenerator + private lateinit var logger: KSPLogger + private var verify = false + private var verbose = false + private val annotationName = AutoPlugin::class.java.name + private val gradlePluginName = "org.gradle.api.Plugin" + + override fun init( + options: Map, + kotlinVersion: KotlinVersion, + codeGenerator: CodeGenerator, + logger: KSPLogger + ) { + this.codeGenerator = codeGenerator + this.logger = logger + verify = options["autoPlugin.verify"]?.toBoolean() != false + verbose = options["autoPlugin.verbose"]?.toBoolean() == true + } + + override fun process(resolver: Resolver) { + log("Starting processing") + val autoPluginType = resolver.getClassDeclarationByName(resolver.getKSNameFromString(annotationName)) + ?.asType(emptyList()) + ?: compileError("@$annotationName type not found on the classpath.") + + val gradlePluginType = resolver.getClassDeclarationByName(resolver.getKSNameFromString(gradlePluginName)) + ?.asStarProjectedType() + ?: compileError("$gradlePluginName not found on the class path.") + + resolver.getSymbolsWithAnnotation(annotationName) + .asSequence() + .filterIsInstance() + .fold(mutableMapOf()) { providers, providerImplementer -> + val annotation = providerImplementer.annotations.find { it.annotationType.resolve() == autoPluginType } + ?: compileError("@$annotationName annotation not found", providerImplementer) + + val pluginId = annotation.arguments + .single { it.name?.getShortName() == "value" } + .value as String + + if (verify && !validatePluginId(pluginId)) { + compileError(Errors.pluginIdFormat(pluginId), providerImplementer) + } + + val implementationClass = providerImplementer.toBinaryName() + if (verify && !gradlePluginType.isAssignableFrom(providerImplementer.asType(emptyList()))) { + compileError(Errors.missingSuperclass(implementationClass), providerImplementer) + } else { + val existing = providers.put(pluginId, implementationClass) + if (verify && existing != null) { + compileError(Errors.duplicatePlugins(pluginId, existing), providerImplementer) + } + } + providers + } + .forEach { (pluginId, implementationClass) -> + val resourceFile = AutoPluginHelpers.fileNameForPluginId(pluginId) + log("Working on resource file: $resourceFile") + try { + codeGenerator.createNewFile(packageName = "", fileName = resourceFile, extensionName = "") + .bufferedWriter() + .use { writer -> + writer.writeResourceFile(implementationClass) + } + log("Wrote to: $resourceFile") + } catch (e: IOException) { + compileError("Unable to create $resourceFile, $e") + } + } + } + + private fun compileError(error: String, node: KSNode? = null): Nothing { + logger.error(error, node) + throw CompilationErrorException() + } + + private fun log(message: String) { + if (verbose) { + logger.logging(message) + } + } + + /** + * Returns the binary name of a reference type. For example, + * {@code com.google.Foo$Bar}, instead of {@code com.google.Foo.Bar}. + */ + private fun KSClassDeclaration.toBinaryName(): String = toClassName().reflectionName() + + private fun KSClassDeclaration.toClassName(): ClassName { + require(!isLocal()) { "Local/anonymous classes are not supported!" } + val pkgName = packageName.asString() + val typesString = qualifiedName!!.asString().removePrefix("$pkgName.") + + val simpleNames = typesString + .split(".") + return ClassName(pkgName, simpleNames) + } + + override fun finish() {} +} \ No newline at end of file diff --git a/compiler-ksp/src/test/kotlin/se/ansman/autoplugin/compiler/AutoServiceProcessorTest.kt b/compiler-ksp/src/test/kotlin/se/ansman/autoplugin/compiler/AutoServiceProcessorTest.kt new file mode 100644 index 0000000..7af2929 --- /dev/null +++ b/compiler-ksp/src/test/kotlin/se/ansman/autoplugin/compiler/AutoServiceProcessorTest.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2020. Nicklas Ansman Giertz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.ansman.autoplugin.compiler + +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.kspSourcesDir +import com.tschuchort.compiletesting.symbolProcessors +import se.ansman.autoplugin.compiler.test.BaseAutoPluginProcessorTest + +/** Tests the [AutoPluginSymbolProcessorTest]. */ +class AutoPluginSymbolProcessorTest : BaseAutoPluginProcessorTest() { + override fun KotlinCompilation.configure() { + symbolProcessors = listOf(AutoPluginSymbolProcessor()) + } + + override fun getResourceAsText( + compilation: KotlinCompilation, + result: KotlinCompilation.Result, + name: String + ): String = compilation.kspSourcesDir.resolve("resource/$name").readText() +} \ No newline at end of file diff --git a/compiler-test/build.gradle.kts b/compiler-test/build.gradle.kts new file mode 100644 index 0000000..f751922 --- /dev/null +++ b/compiler-test/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + library +} + +dependencies { + implementation(kotlin("stdlib-jdk8")) + implementation(project(":compiler-common")) + api(deps.compileTesting.core) + api(platform(deps.junit.bom)) + api(deps.junit.jupiter) + api(deps.truth) +} \ No newline at end of file diff --git a/compiler-test/src/main/kotlin/se/ansman/autoplugin/compiler/test/BaseAutoPluginProcessorTest.kt b/compiler-test/src/main/kotlin/se/ansman/autoplugin/compiler/test/BaseAutoPluginProcessorTest.kt new file mode 100644 index 0000000..e0f6709 --- /dev/null +++ b/compiler-test/src/main/kotlin/se/ansman/autoplugin/compiler/test/BaseAutoPluginProcessorTest.kt @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2020. Nicklas Ansman Giertz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.ansman.autoplugin.compiler.test + +import com.google.common.truth.Truth.assertThat +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.KotlinCompilation.ExitCode.COMPILATION_ERROR +import com.tschuchort.compiletesting.KotlinCompilation.ExitCode.OK +import com.tschuchort.compiletesting.SourceFile +import org.intellij.lang.annotations.Language +import org.junit.jupiter.api.Test +import se.ansman.autoplugin.compiler.AutoPluginHelpers.Errors +import java.io.ByteArrayOutputStream +import java.io.FilterOutputStream +import java.io.IOException +import java.io.PrintStream + +@Suppress("FunctionName") +abstract class BaseAutoPluginProcessorTest { + @Test + fun `resource file should be generated`() { + val result = compile( + """ + package com.example + + import org.gradle.api.Plugin + import org.gradle.api.Project + import se.ansman.autoplugin.AutoPlugin + + @AutoPlugin("some-plugin") + abstract class SomePlugin : Plugin { + override fun apply(target: Project) {} + } + + @AutoPlugin("com.example.plugin") + abstract class SomeOtherPlugin : Plugin { + override fun apply(target: Project) {} + } + """ + ) + + assertThat(result.exitCode).isEqualTo(OK) + assertThat(result.getResourceAsText("META-INF/gradle-plugins/some-plugin.properties")) + .isEqualTo("implementation-class=com.example.SomePlugin") + assertThat(result.getResourceAsText("META-INF/gradle-plugins/com.example.plugin.properties")) + .isEqualTo("implementation-class=com.example.SomeOtherPlugin") + } + + @Test + fun `class must implement Plugin`() { + val result = compile( + """ + package com.example + + import org.gradle.api.Plugin + import org.gradle.api.Project + import se.ansman.autoplugin.AutoPlugin + + @AutoPlugin("some-plugin") + abstract class SomePlugin { + override fun apply(target: Project) {} + } + """ + ) + + assertThat(result.exitCode).isEqualTo(COMPILATION_ERROR) + result.assertMessage(Errors.missingSuperclass("com.example.SomePlugin")) + } + + @Test + fun `plugin ids must be unique`() { + val result = compile( + """ + package com.example + + import org.gradle.api.Plugin + import org.gradle.api.Project + import se.ansman.autoplugin.AutoPlugin + + @AutoPlugin("some-plugin") + abstract class SomePlugin : Plugin { + override fun apply(target: Project) {} + } + + @AutoPlugin("some-plugin") + abstract class SomeOtherPlugin : Plugin { + override fun apply(target: Project) {} + } + """ + ) + + assertThat(result.exitCode).isEqualTo(COMPILATION_ERROR) + result.assertMessage(Errors.duplicatePlugins("some-plugin", "com.example.SomePlugin")) + } + + @Test + fun `plugin ids must only have valid characters`() { + val result = compile( + """ + package com.example + + import org.gradle.api.Plugin + import org.gradle.api.Project + import se.ansman.autoplugin.AutoPlugin + + @AutoPlugin("some-plugin!") + abstract class SomePlugin : Plugin { + override fun apply(target: Project) {} + } + """ + ) + + assertThat(result.exitCode).isEqualTo(COMPILATION_ERROR) + result.assertMessage(Errors.pluginIdFormat("some-plugin!")) + } + + @Test + fun `plugin ids must not start with period`() { + val result = compile( + """ + package com.example + + import org.gradle.api.Plugin + import org.gradle.api.Project + import se.ansman.autoplugin.AutoPlugin + + @AutoPlugin(".some-plugin") + abstract class SomePlugin : Plugin { + override fun apply(target: Project) {} + } + """ + ) + + assertThat(result.exitCode).isEqualTo(COMPILATION_ERROR) + result.assertMessage(Errors.pluginIdFormat(".some-plugin")) + } + + @Test + fun `plugin ids must not end with period`() { + val result = compile( + """ + package com.example + + import org.gradle.api.Plugin + import org.gradle.api.Project + import se.ansman.autoplugin.AutoPlugin + + @AutoPlugin("some-plugin.") + abstract class SomePlugin : Plugin { + override fun apply(target: Project) {} + } + """ + ) + + assertThat(result.exitCode).isEqualTo(COMPILATION_ERROR) + result.assertMessage(Errors.pluginIdFormat("some-plugin.")) + } + + @Test + fun `plugin ids must not contains double period`() { + val result = compile( + """ + package com.example + + import org.gradle.api.Plugin + import org.gradle.api.Project + import se.ansman.autoplugin.AutoPlugin + + @AutoPlugin("some..plugin") + abstract class SomePlugin : Plugin { + override fun apply(target: Project) {} + } + """ + ) + + assertThat(result.exitCode).isEqualTo(COMPILATION_ERROR) + result.assertMessage(Errors.pluginIdFormat("some..plugin")) + } + + @Test + fun `plugin ids must not be empty`() { + val result = compile( + """ + package com.example + + import org.gradle.api.Plugin + import org.gradle.api.Project + import se.ansman.autoplugin.AutoPlugin + + @AutoPlugin("") + abstract class SomePlugin : Plugin { + override fun apply(target: Project) {} + } + """ + ) + + assertThat(result.exitCode).isEqualTo(COMPILATION_ERROR) + result.assertMessage(Errors.pluginIdFormat("")) + } + + private fun CompileResult.assertMessage(messages: String) { + messages.lineSequence().forEach { message -> assertThat(this.messages).contains(message) } + } + + protected open fun compile(@Language("kotlin") code: String): CompileResult = + with(KotlinCompilation()) { + val output = ByteArrayOutputStream() + val outputPrinter = PrintStream(output) + val oldErr = System.err + System.setErr(outputPrinter) + try { + messageOutputStream = object : FilterOutputStream(System.out) { + override fun write(b: Int) { + super.write(b) + output.write(b) + } + + override fun write(b: ByteArray) { + super.write(b) + output.write(b) + } + + override fun write(b: ByteArray, off: Int, len: Int) { + super.write(b, off, len) + output.write(b, off, len) + } + + override fun flush() { + super.flush() + output.flush() + } + } + sources = listOf(SourceFile.kotlin("Code.kt", code)) + inheritClassPath = true + correctErrorTypes = true + configure() + + val result = compile() + CompileResult( + exitCode = result.exitCode, + messages = output.toString(), + getResourceAsText = { name -> getResourceAsText(this, result, name) } + ) + } finally { + System.setErr(oldErr) + } + + + } + + protected open fun KotlinCompilation.configure() {} + + @Throws(IOException::class) + protected abstract fun getResourceAsText( + compilation: KotlinCompilation, + result: KotlinCompilation.Result, + name: String + ): String + + class CompileResult( + val exitCode: KotlinCompilation.ExitCode, + val messages: String, + val getResourceAsText: (name: String) -> String + ) +} diff --git a/compiler-test/src/test/kotlin/se/ansman/autoplugin/compiler/AutoPluginHelpersTest.kt b/compiler-test/src/test/kotlin/se/ansman/autoplugin/compiler/AutoPluginHelpersTest.kt new file mode 100644 index 0000000..cce0df4 --- /dev/null +++ b/compiler-test/src/test/kotlin/se/ansman/autoplugin/compiler/AutoPluginHelpersTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2020. Nicklas Ansman Giertz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package se.ansman.autoplugin.compiler + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.fail +import se.ansman.autoplugin.compiler.AutoPluginHelpers.fileNameForPluginId +import se.ansman.autoplugin.compiler.AutoPluginHelpers.validatePluginId +import se.ansman.autoplugin.compiler.AutoPluginHelpers.writeResourceFile +import java.io.StringWriter + +internal class AutoPluginHelpersTest { + @Test + fun `plugin ID is valid`() { + val logger = { _: String -> fail("Should not log anything") } + assertThat(validatePluginId("valid", logger)) + assertThat(validatePluginId("valid.id", logger)) + assertThat(validatePluginId("com.example.valid", logger)) + assertThat(validatePluginId("valid-id", logger)) + assertThat(validatePluginId("com.example.valid-id", logger)) + } + + @Test + fun `plugin ID contains invalid characters`() { + var callCount = 0 + val logger = { _: String -> callCount += 1 } + assertThat(validatePluginId("inv@lid", logger)) + assertThat(callCount).isEqualTo(1) + assertThat(validatePluginId("invalid!", logger)) + assertThat(callCount).isEqualTo(2) + assertThat(validatePluginId("invalid_id", logger)) + assertThat(callCount).isEqualTo(3) + } + + @Test + fun `plugin ID starting or ending with period`() { + var callCount = 0 + val logger = { _: String -> callCount += 1 } + assertThat(validatePluginId(".invalid", logger)) + assertThat(callCount).isEqualTo(1) + assertThat(validatePluginId("invalid.", logger)) + assertThat(callCount).isEqualTo(2) + } + + @Test + fun `plugin ID contains double period`() { + var callCount = 0 + val logger = { _: String -> callCount += 1 } + assertThat(validatePluginId("invalid..id", logger)) + assertThat(callCount).isEqualTo(1) + } + + @Test + fun `file name`() { + assertThat(fileNameForPluginId("example-plugin")).isEqualTo("META-INF/gradle-plugins/example-plugin.properties") + } + + @Test + fun `writing contents`() { + val contents = with(StringWriter()) { + writeResourceFile("com.example.SomePlugin") + toString() + } + assertThat(contents).isEqualTo("implementation-class=com.example.SomePlugin") + } +} \ No newline at end of file diff --git a/compiler/build.gradle.kts b/compiler/build.gradle.kts index 9c019c8..3d93b00 100644 --- a/compiler/build.gradle.kts +++ b/compiler/build.gradle.kts @@ -1,11 +1,12 @@ plugins { - library + `published-library` id("kotlin-kapt") } dependencies { implementation(kotlin("stdlib-jdk8")) implementation(project(":api")) + implementation(project(":compiler-common")) compileOnly(deps.auto.service.api) kapt(deps.auto.service.compiler) @@ -13,18 +14,9 @@ dependencies { kapt(deps.incap.compiler) implementation(deps.auto.common) - testImplementation(platform(deps.junit.bom)) - testImplementation(deps.junit.jupiter) - testImplementation(deps.truth) - testImplementation(deps.compileTesting) + testImplementation(deps.compileTesting.core) + testImplementation(project(":compiler-test")) testImplementation(gradleApi()) } -kapt.includeCompileClasspath = false - -tasks.test { - useJUnitPlatform() - testLogging { - events("passed", "skipped", "failed") - } -} \ No newline at end of file +kapt.includeCompileClasspath = false \ No newline at end of file diff --git a/compiler/src/main/kotlin/se/ansman/autoplugin/compiler/AutoPluginProcessor.kt b/compiler/src/main/kotlin/se/ansman/autoplugin/compiler/AutoPluginProcessor.kt index 7bbeaff..de56110 100644 --- a/compiler/src/main/kotlin/se/ansman/autoplugin/compiler/AutoPluginProcessor.kt +++ b/compiler/src/main/kotlin/se/ansman/autoplugin/compiler/AutoPluginProcessor.kt @@ -19,6 +19,8 @@ import com.google.auto.service.AutoService import net.ltgt.gradle.incap.IncrementalAnnotationProcessor import net.ltgt.gradle.incap.IncrementalAnnotationProcessorType import se.ansman.autoplugin.AutoPlugin +import se.ansman.autoplugin.compiler.AutoPluginHelpers.Errors +import se.ansman.autoplugin.compiler.AutoPluginHelpers.writeResourceFile import java.io.IOException import java.io.PrintWriter import java.io.StringWriter @@ -32,7 +34,6 @@ import javax.lang.model.element.TypeElement import javax.tools.Diagnostic.Kind import javax.tools.StandardLocation - @Suppress("UnstableApiUsage") @AutoService(Processor::class) @IncrementalAnnotationProcessor(IncrementalAnnotationProcessorType.ISOLATING) @@ -42,7 +43,7 @@ class AutoPluginProcessor : AbstractProcessor() { * * For example * ``` - * "libraru" -> "com.example.LibraryPlugin" + * "library" -> "com.example.LibraryPlugin" * ``` */ private val providers = mutableMapOf() @@ -89,51 +90,26 @@ class AutoPluginProcessor : AbstractProcessor() { val autoPlugin = typeElement.getAnnotation(AutoPlugin::class.java) val pluginId = autoPlugin.value - if (!validatePluginId(pluginId)) { - error(""" - Plugin ID $pluginId is not valid. Plugin IDs must: - • Only contain alphanumeric characters, '.', and '-'. - • Not start or end with a '.' character. - • Not contain consecutive '.' characters (i.e. '..'). - """.trimIndent(), e) + if (!AutoPluginHelpers.validatePluginId(pluginId) { log(it) }) { + error(Errors.pluginIdFormat(pluginId), e) continue } if (!processingEnv.typeUtils.isAssignable(typeElement.asType(), plugin)) { - error("Plugins must extend $GRADLE_PLUGIN", e) + error(Errors.missingSuperclass(typeElement.getBinaryName()), e) continue } - val existing = providers[pluginId] + val existing = providers.put(pluginId, typeElement) if (existing != null) { - error("Plugin IDs must be unique. $existing uses the same plugin ID.", e) + error("Multiple plugins found with the same ID: '$pluginId' ($existing also implements it)", e) } - providers[pluginId] = typeElement } } - private val validPluginCharacters = Regex("[a-zA-Z0-9.-]+") - private fun validatePluginId(pluginId: String): Boolean { - if (!validPluginCharacters.matches(pluginId)) { - log("Plugin ID $pluginId has invalid characters") - return false - } - - if (pluginId.first() == '.' || pluginId.last() == '.') { - log("Plugin ID $pluginId starts or ends with .") - return false - } - - if (".." in pluginId) { - log("Plugin ID $pluginId contains ..") - return false - } - return true - } - private fun generateConfigFiles() { val filer = processingEnv.filer for ((id, implementationClass) in providers.entries) { - val resourceFile = "META-INF/gradle-plugins/$id.properties" + val resourceFile = AutoPluginHelpers.fileNameForPluginId(id) log("Working on resource file: $resourceFile") try { @@ -150,7 +126,7 @@ class AutoPluginProcessor : AbstractProcessor() { try { filer.createResource(StandardLocation.CLASS_OUTPUT, "", resourceFile, implementationClass) .openWriter() - .use { it.write("implementation-class=${implementationClass.getBinaryName()}") } + .use { it.writeResourceFile(implementationClass.getBinaryName()) } } catch (e: IOException) { fatalError("Unable to create $resourceFile, $e", implementationClass) } diff --git a/compiler/src/test/kotlin/se/ansman/autoplugin/compiler/AutoServiceProcessorTest.kt b/compiler/src/test/kotlin/se/ansman/autoplugin/compiler/AutoServiceProcessorTest.kt index f341f8c..93eae81 100644 --- a/compiler/src/test/kotlin/se/ansman/autoplugin/compiler/AutoServiceProcessorTest.kt +++ b/compiler/src/test/kotlin/se/ansman/autoplugin/compiler/AutoServiceProcessorTest.kt @@ -13,167 +13,23 @@ */ package se.ansman.autoplugin.compiler -import com.google.common.truth.Truth.assertThat import com.tschuchort.compiletesting.KotlinCompilation -import com.tschuchort.compiletesting.KotlinCompilation.ExitCode -import com.tschuchort.compiletesting.SourceFile -import org.intellij.lang.annotations.Language -import org.junit.jupiter.api.Test +import se.ansman.autoplugin.compiler.test.BaseAutoPluginProcessorTest import java.io.FileNotFoundException -import java.io.IOException /** Tests the [AutoPluginProcessor]. */ -class AutoPluginProcessorTest { - @Test - fun `resource file should be generated`() { - val result = compile( - """ - package com.example - - import org.gradle.api.Plugin - import org.gradle.api.Project - import se.ansman.autoplugin.AutoPlugin - - @AutoPlugin("some-plugin") - abstract class SomePlugin : Plugin { - override fun apply(target: Project) {} - } - - @AutoPlugin("com.example.plugin") - abstract class SomeOtherPlugin : Plugin { - override fun apply(target: Project) {} - } - """ - ) +class AutoPluginProcessorTest : BaseAutoPluginProcessorTest() { - assertThat(result.exitCode).isEqualTo(ExitCode.OK) - assertThat(result.classLoader.getResourceAsText("META-INF/gradle-plugins/some-plugin.properties")) - .isEqualTo("implementation-class=com.example.SomePlugin") - assertThat(result.classLoader.getResourceAsText("META-INF/gradle-plugins/com.example.plugin.properties")) - .isEqualTo("implementation-class=com.example.SomeOtherPlugin") + override fun KotlinCompilation.configure() { + annotationProcessors = listOf(AutoPluginProcessor()) } - @Test - fun `plugin ids must be unique`() { - val result = compile( - """ - package com.example - - import org.gradle.api.Plugin - import org.gradle.api.Project - import se.ansman.autoplugin.AutoPlugin - - @AutoPlugin("some-plugin") - abstract class SomePlugin : Plugin { - override fun apply(target: Project) {} - } - - @AutoPlugin("some-plugin") - abstract class SomeOtherPlugin : Plugin { - override fun apply(target: Project) {} - } - """ - ) - - assertThat(result.exitCode).isEqualTo(ExitCode.COMPILATION_ERROR) - assertThat(result.messages).contains("Plugin IDs must be unique.") - } - - @Test - fun `plugin ids must only have valid characters`() { - val result = compile( - """ - package com.example - - import org.gradle.api.Plugin - import org.gradle.api.Project - import se.ansman.autoplugin.AutoPlugin - - @AutoPlugin("some-plugin!") - abstract class SomePlugin : Plugin { - override fun apply(target: Project) {} - } - """ - ) - - assertThat(result.exitCode).isEqualTo(ExitCode.COMPILATION_ERROR) - assertThat(result.messages).contains("Plugin ID some-plugin! is not valid. Plugin IDs must:") - } - - @Test - fun `plugin ids must not start with period`() { - val result = compile( - """ - package com.example - - import org.gradle.api.Plugin - import org.gradle.api.Project - import se.ansman.autoplugin.AutoPlugin - - @AutoPlugin(".some-plugin") - abstract class SomePlugin : Plugin { - override fun apply(target: Project) {} - } - """ - ) - - assertThat(result.exitCode).isEqualTo(ExitCode.COMPILATION_ERROR) - assertThat(result.messages).contains("Plugin ID .some-plugin is not valid. Plugin IDs must:") - } - - @Test - fun `plugin ids must not end with period`() { - val result = compile( - """ - package com.example - - import org.gradle.api.Plugin - import org.gradle.api.Project - import se.ansman.autoplugin.AutoPlugin - - @AutoPlugin("some-plugin.") - abstract class SomePlugin : Plugin { - override fun apply(target: Project) {} - } - """ - ) - - assertThat(result.exitCode).isEqualTo(ExitCode.COMPILATION_ERROR) - assertThat(result.messages).contains("Plugin ID some-plugin. is not valid. Plugin IDs must:") - } - - @Test - fun `plugin ids must not contains double period`() { - val result = compile( - """ - package com.example - - import org.gradle.api.Plugin - import org.gradle.api.Project - import se.ansman.autoplugin.AutoPlugin - - @AutoPlugin("some..plugin") - abstract class SomePlugin : Plugin { - override fun apply(target: Project) {} - } - """ - ) - - assertThat(result.exitCode).isEqualTo(ExitCode.COMPILATION_ERROR) - assertThat(result.messages).contains("Plugin ID some..plugin is not valid. Plugin IDs must:") - } - - private fun compile(@Language("kotlin") code: String) = - with(KotlinCompilation()) { - sources = listOf( - SourceFile.kotlin("Code.kt", code) - ) - annotationProcessors = listOf(AutoPluginProcessor()) - inheritClassPath = true - compile() - } -} - -@Throws(IOException::class) -private fun ClassLoader.getResourceAsText(name: String): String = - (getResourceAsStream(name) ?: throw FileNotFoundException(name)).reader().use { it.readText() } + override fun getResourceAsText( + compilation: KotlinCompilation, + result: KotlinCompilation.Result, + name: String + ): String = + (result.classLoader.getResourceAsStream(name) ?: throw FileNotFoundException(name)) + .reader() + .use { it.readText() } +} \ No newline at end of file diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts new file mode 100644 index 0000000..3c87240 --- /dev/null +++ b/gradle-plugin/build.gradle.kts @@ -0,0 +1,113 @@ +@file:Suppress("UnstableApiUsage") + +import com.squareup.kotlinpoet.* + +plugins { + `published-library` + id("symbol-processing") version deps.kotlin.ksp.version + id("com.gradle.plugin-publish") version "0.12.0" +} + +buildscript { + dependencies { + classpath(deps.kotlinPoet) + } +} + +val funcTestSourceSet: NamedDomainObjectProvider = sourceSets.register("funcTest") { + java.srcDir(file("src/funcTest/kotlin")) + resources.srcDir(file("src/funcTest/resources")) + compileClasspath += sourceSets.getByName("main").output + + configurations.getByName("testRuntimeClasspath") + + configurations.getByName("testCompileClasspath") + runtimeClasspath += output + compileClasspath +} + +dependencies { + implementation(kotlin("stdlib-jdk8")) + compileOnly(project(":api")) + implementation(gradleApi()) + compileOnly(deps.kotlin.ksp.gradlePlugin) + ksp(project(":compiler-ksp")) + + "funcTestImplementation"(project(":api")) + "funcTestImplementation"(platform(deps.junit.bom)) + "funcTestImplementation"(deps.junit.jupiter) + "funcTestImplementation"(deps.truth) + "funcTestImplementation"(gradleTestKit()) +} + +pluginBundle { + website = "https://github.com/ansman/auto-plugin" + vcsUrl = "https://github.com/ansman/auto-plugin" + description = "Generates configuration files for Gradle Plugins." + tags = listOf("plugin-development") + + plugins { + create("autoPlugin") { + displayName = "AutoPlugin" + id = "se.ansman.autoplugin" + } + } + + mavenCoordinates { + groupId = project.group.toString() + artifactId = project.name + version = providers.gradleProperty("version").forUseAtConfigurationTime().get() + } +} + +tasks.withType().configureEach { isEnabled = false } + +val generatedBuildDir: Provider = layout.buildDirectory.dir("generated/build-metadata") +sourceSets { + getByName("main") { + java.srcDir(generatedBuildDir) + resources.srcDir(layout.buildDirectory.dir("generated/ksp/src/main/resources")) + } +} + +val generateBuildMetadata = tasks.register("generateBuildMetadata") { + @Suppress("UnstableApiUsage") + val version = project.providers.gradleProperty("version") + inputs.property("version", version) + outputs.dir(generatedBuildDir) + doFirst { + FileSpec.builder("se.ansman.autoplugin.compiler", "BuildMetadata") + .addType( + TypeSpec.objectBuilder("BuildMetadata") + .addModifiers(KModifier.INTERNAL) + .addProperty( + PropertySpec.builder("VERSION", STRING, KModifier.CONST) + .initializer("%S", version.get()) + .build() + ) + .build() + ) + .build() + .writeTo(generatedBuildDir.get().asFile) + } +} + +tasks.named("compileKotlin").configure { dependsOn(generateBuildMetadata) } + +val funcTest = tasks.register("funcTest", Test::class.java) { + useJUnitPlatform() + testLogging.events("passed", "skipped", "failed") + testClassesDirs = funcTestSourceSet.get().output.classesDirs + classpath = funcTestSourceSet.get().runtimeClasspath + dependsOn( + "publishTestLibraryPublicationToTestRepository", + ":api:publishTestLibraryPublicationToTestRepository", + ":compiler-common:publishTestLibraryPublicationToTestRepository", + ":compiler-ksp:publishTestLibraryPublicationToTestRepository" + ) + val pluginVersion = providers.gradleProperty("version") + .forUseAtConfigurationTime() + .get() + systemProperty("pluginVersion", pluginVersion) + systemProperty("symbolProcessingVersion", deps.kotlin.ksp.version) + systemProperty("localMavenRepo", testMavenRepo.absolutePath) +} + +tasks.named("check") { dependsOn(funcTest) } \ No newline at end of file diff --git a/gradle-plugin/src/funcTest/kotlin/se/ansman/autoplugin/gradle/AutoPluginGradlePluginTest.kt b/gradle-plugin/src/funcTest/kotlin/se/ansman/autoplugin/gradle/AutoPluginGradlePluginTest.kt new file mode 100644 index 0000000..fd39238 --- /dev/null +++ b/gradle-plugin/src/funcTest/kotlin/se/ansman/autoplugin/gradle/AutoPluginGradlePluginTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2020. Nicklas Ansman Giertz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package se.ansman.autoplugin.gradle + +import com.google.common.truth.Truth.assertThat +import org.gradle.testkit.runner.GradleRunner +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.net.URI +import java.nio.file.FileSystems +import java.nio.file.Files + +@Suppress("FunctionName") +internal class AutoPluginGradlePluginTest { + @TempDir + lateinit var testProjectDir: File + + private lateinit var settingsFile: File + private lateinit var buildFile: File + + @BeforeEach + fun setup() { + settingsFile = testProjectDir.resolve("settings.gradle.kts").apply { + writeText(""" + pluginManagement { + resolutionStrategy { + eachPlugin { + when (requested.id.id) { + "symbol-processing" -> useModule("com.google.devtools.ksp:symbol-processing:${'$'}{requested.version}") + } + } + } + repositories { + maven { + setUrl("${System.getProperty("localMavenRepo")}") + } + gradlePluginPortal() + google() + } + } + + rootProject.name = "test" + """.trimIndent()) + } + buildFile = testProjectDir.resolve("build.gradle.kts").apply { + writeText( + """ + plugins { + kotlin("jvm") version embeddedKotlinVersion + id("symbol-processing") version "${System.getProperty("symbolProcessingVersion")}" + } + + buildscript { + repositories { + maven { + setUrl("${System.getProperty("localMavenRepo")}") + } + google() + jcenter() + } + + dependencies { + classpath("se.ansman.autoplugin:gradle-plugin:${System.getProperty("pluginVersion")}") + } + } + + repositories { + maven { + setUrl("${System.getProperty("localMavenRepo")}") + } + google() + jcenter() + } + + apply(plugin = "se.ansman.autoplugin") + + dependencies { + implementation(gradleApi()) + } + """.trimIndent() + ) + } + } + + @Test + fun `no sources`() { + GradleRunner.create() + .withProjectDir(testProjectDir) + .withArguments("--stacktrace") + .build() + } + + @Test + fun `generates resource file`() { + testProjectDir.resolve("src/main/kotlin/com/example") + .apply { check(mkdirs()) } + .resolve("ExamplePlugin.kt") + .writeText( + """ + package com.example + + import org.gradle.api.* + import se.ansman.autoplugin.AutoPlugin + + @AutoPlugin("com.example.plugin") + abstract class ExamplePlugin : Plugin { + override fun apply(target: Project) {} + } + """.trimIndent() + ) + + GradleRunner.create() + .withProjectDir(testProjectDir) + .withArguments("--stacktrace", "compileKotlin") + .build() + + val propertiesFile = + testProjectDir.resolve("build/generated/ksp/src/main/resources/META-INF/gradle-plugins/com.example.plugin.properties") + assertThat(propertiesFile.readText()).isEqualTo("implementation-class=com.example.ExamplePlugin") + } + + @Test + fun `includes resource file in jar`() { + testProjectDir.resolve("src/main/kotlin/com/example") + .apply { check(mkdirs()) } + .resolve("ExamplePlugin.kt") + .writeText( + """ + package com.example + + import org.gradle.api.* + import se.ansman.autoplugin.AutoPlugin + + @AutoPlugin("com.example.plugin") + abstract class ExamplePlugin : Plugin { + override fun apply(target: Project) {} + } + """.trimIndent() + ) + + GradleRunner.create() + .withProjectDir(testProjectDir) + .withArguments("--stacktrace", "jar") + .build() + + + + val uri = URI.create("jar:file:${testProjectDir.resolve("build/libs/test.jar")}") + val contents = FileSystems.newFileSystem(uri, emptyMap()).use { fs -> + Files.newBufferedReader(fs.getPath("META-INF/gradle-plugins/com.example.plugin.properties")).use { + it.readText() + } + } + assertThat(contents).isEqualTo("implementation-class=com.example.ExamplePlugin") + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/se/ansman/autoplugin/gradle/AutoPluginExtension.kt b/gradle-plugin/src/main/kotlin/se/ansman/autoplugin/gradle/AutoPluginExtension.kt new file mode 100644 index 0000000..7ad4485 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/se/ansman/autoplugin/gradle/AutoPluginExtension.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2020. Nicklas Ansman Giertz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package se.ansman.autoplugin.gradle + +import org.gradle.api.provider.Property + +/** + * Configures the [AutoPluginGradlePlugin]. + */ +@Suppress("LeakingThis", "UnstableApiUsage") +abstract class AutoPluginExtension { + /** + * Whether verification of plugins and plugin ids is enabled. + * + * By default the compiler will verify certain things: + * * The plugin must implement [Plugin]. + * * The plugin ID must be valid: + * * Must only contain a-z, A-Z, 0-9, '.' and '-'. + * * Must not start or end with '.'. + * * Must not contain two consecutive periods ('..'). + * * Must contain at least one character. + */ + abstract val verificationEnabled: Property + + /** Enables verbose logging. By default only errors are logged. */ + abstract val verboseLogging: Property + + init { + verificationEnabled.apply { + finalizeValueOnRead() + convention(true) + } + verboseLogging.apply { + finalizeValueOnRead() + convention(false) + } + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/se/ansman/autoplugin/gradle/AutoPluginGradlePlugin.kt b/gradle-plugin/src/main/kotlin/se/ansman/autoplugin/gradle/AutoPluginGradlePlugin.kt new file mode 100644 index 0000000..6b8419b --- /dev/null +++ b/gradle-plugin/src/main/kotlin/se/ansman/autoplugin/gradle/AutoPluginGradlePlugin.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2020. Nicklas Ansman Giertz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package se.ansman.autoplugin.gradle + +import com.google.devtools.ksp.gradle.KspExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.tasks.SourceSetContainer +import se.ansman.autoplugin.AutoPlugin +import se.ansman.autoplugin.compiler.BuildMetadata + +/** + * The plugin for AutoPlugin. Will do the following things: + * * Install the [AutoPluginExtension] with the name `autoPlugin` + * * Add `build/generated/ksp/src/main/resources` to the main source set + * * Add an `implementation` dependency on the API + * * Add a `ksp` dependency on the AutoPlugin compiler + */ +@AutoPlugin("se.ansman.autoplugin") +abstract class AutoPluginGradlePlugin : Plugin { + override fun apply(target: Project) { + with(target) { + val extension = extensions.create("autoPlugin", AutoPluginExtension::class.java) + + pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { + with(property("sourceSets") as SourceSetContainer) { + with(getByName("main")) { + resources.srcDir(target.layout.buildDirectory.dir("generated/ksp/src/main/resources")) + } + } + } + + pluginManager.withPlugin("symbol-processing") { + dependencies.add("implementation", "se.ansman.autoplugin:api:${BuildMetadata.VERSION}") + dependencies.add("ksp", "se.ansman.autoplugin:compiler-ksp:${BuildMetadata.VERSION}") + afterEvaluate { + extensions.configure("ksp") { + it.arg("autoPlugin.verify", extension.verificationEnabled.get().toString()) + it.arg("autoPlugin.verbose", extension.verboseLogging.get().toString()) + } + } + } + } + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index bade64d..405ef30 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,4 @@ kotlin.code.style=official kotlin.stdlib.default.dependency=false -version=0.2.0-SNAPSHOT -kotlinVersion=1.4.10 +version=0.2.0-SNAPSHOT \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index b8323e4..1d412e8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,23 @@ +pluginManagement { + resolutionStrategy { + eachPlugin { + when (requested.id.id) { + "symbol-processing" -> + useModule("com.google.devtools.ksp:symbol-processing:${requested.version}") + } + } + } + repositories { + gradlePluginPortal() + google() + } +} + rootProject.name = "auto-plugin" include(":api") -include(":compiler") \ No newline at end of file +include(":compiler") +include(":compiler-ksp") +include(":compiler-common") +include(":compiler-test") +include(":gradle-plugin") \ No newline at end of file