diff --git a/README.md b/README.md index 5e1b3cac..b04380bb 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,8 @@ needs. - IntelliJ IDEA / Android Studio plugin - CLI tool -- Gradle plugin and Web app (🚧 coming soon 🚧) +- Gradle plugin +- Web app (🚧 coming soon 🚧) ## IDEA Plugin diff --git a/components/test/coverage/build.gradle.kts b/components/test/coverage/build.gradle.kts index 9fc66d8d..49fbb2ab 100644 --- a/components/test/coverage/build.gradle.kts +++ b/components/test/coverage/build.gradle.kts @@ -5,6 +5,7 @@ plugins { dependencies { // include only necessary dependencies for the test coverage kover(projects.tools.cli) + kover(projects.tools.gradlePlugin) kover(projects.components.generator.core) kover(projects.components.generator.jvm.poetExtensions) kover(projects.components.generator.iconpack) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index da563dc0..c8b34b28 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +agp = "8.13.0" compose = "1.8.2" intellij = "2.9.0" jacoco = "0.8.13" @@ -8,6 +9,7 @@ leviathan = "3.1.0-1.8.2" [libraries] android-build-tools = "com.android.tools:sdk-common:31.13.0" +compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } highlights = "dev.snipme:highlights:1.1.0" kotlinpoet = "com.squareup:kotlinpoet:2.2.0" kotlin-io = "org.jetbrains.kotlinx:kotlinx-io-core:0.8.0" @@ -31,6 +33,9 @@ mockk = "io.mockk:mockk:1.14.6" ktlint = "com.pinterest.ktlint:ktlint-cli:1.7.1" composeRules = "io.nlopez.compose.rules:ktlint:0.4.27" +agp-api = { module = "com.android.tools.build:gradle-api", version.ref = "agp" } +agp-full = { module = "com.android.tools.build:gradle", version.ref = "agp" } + # Dependencies for build-logic module kotlin-compose-compiler-plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/sdk/core/extensions/api/extensions.api b/sdk/core/extensions/api/extensions.api index 479da095..3247c43e 100644 --- a/sdk/core/extensions/api/extensions.api +++ b/sdk/core/extensions/api/extensions.api @@ -1,5 +1,5 @@ public final class io/github/composegears/valkyrie/sdk/core/extensions/PathUtilsKt { - public static final fun writeToKt (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZ)V - public static synthetic fun writeToKt$default (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZILjava/lang/Object;)V + public static final fun writeToKt (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZ)Ljava/nio/file/Path; + public static synthetic fun writeToKt$default (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZILjava/lang/Object;)Ljava/nio/file/Path; } diff --git a/sdk/core/extensions/src/jvmMain/kotlin/io/github/composegears/valkyrie/sdk/core/extensions/PathUtils.kt b/sdk/core/extensions/src/jvmMain/kotlin/io/github/composegears/valkyrie/sdk/core/extensions/PathUtils.kt index 68531b44..50d96907 100644 --- a/sdk/core/extensions/src/jvmMain/kotlin/io/github/composegears/valkyrie/sdk/core/extensions/PathUtils.kt +++ b/sdk/core/extensions/src/jvmMain/kotlin/io/github/composegears/valkyrie/sdk/core/extensions/PathUtils.kt @@ -1,6 +1,7 @@ package io.github.composegears.valkyrie.sdk.core.extensions import java.io.IOException +import java.nio.file.Path import kotlin.io.path.Path import kotlin.io.path.createParentDirectories import kotlin.io.path.deleteIfExists @@ -27,7 +28,7 @@ private fun String.writeToFile( extension: String, deleteIfExists: Boolean, createParents: Boolean, -) { +): Path { val outputPath = Path(outputDir, "$nameWithoutExtension.$extension") if (deleteIfExists) { @@ -37,4 +38,5 @@ private fun String.writeToFile( outputPath.createParentDirectories() } outputPath.writeText(this) + return outputPath } diff --git a/settings.gradle.kts b/settings.gradle.kts index 102852b1..a776fb47 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -53,6 +53,7 @@ includeBuild("build-logic") include("tools:cli") include("tools:compose-app") +include("tools:gradle-plugin") include("tools:idea-plugin") include("components:generator:core") diff --git a/tools/gradle-plugin/api/gradle-plugin.api b/tools/gradle-plugin/api/gradle-plugin.api new file mode 100644 index 00000000..2b2d4766 --- /dev/null +++ b/tools/gradle-plugin/api/gradle-plugin.api @@ -0,0 +1,41 @@ +public abstract class io/github/composegears/valkyrie/gradle/GenerateImageVectorsTask : org/gradle/api/DefaultTask { + public fun ()V + public final fun execute ()V + public abstract fun getAddTrailingComma ()Lorg/gradle/api/provider/Property; + public abstract fun getDrawableFiles ()Lorg/gradle/api/file/ConfigurableFileCollection; + public abstract fun getGeneratePreview ()Lorg/gradle/api/provider/Property; + public abstract fun getIconPackName ()Lorg/gradle/api/provider/Property; + public abstract fun getIndentSize ()Lorg/gradle/api/provider/Property; + public abstract fun getNestedPackName ()Lorg/gradle/api/provider/Property; + public abstract fun getOutputDirectory ()Lorg/gradle/api/file/DirectoryProperty; + public abstract fun getOutputFormat ()Lorg/gradle/api/provider/Property; + public abstract fun getPackageName ()Lorg/gradle/api/provider/Property; + public abstract fun getPreviewAnnotationType ()Lorg/gradle/api/provider/Property; + public abstract fun getSvgFiles ()Lorg/gradle/api/file/ConfigurableFileCollection; + public abstract fun getUseComposeColors ()Lorg/gradle/api/provider/Property; + public abstract fun getUseExplicitMode ()Lorg/gradle/api/provider/Property; + public abstract fun getUseFlatPackage ()Lorg/gradle/api/provider/Property; +} + +public abstract interface class io/github/composegears/valkyrie/gradle/ValkyrieExtension { + public abstract fun getAddTrailingComma ()Lorg/gradle/api/provider/Property; + public abstract fun getGenerateAtSync ()Lorg/gradle/api/provider/Property; + public abstract fun getGeneratePreview ()Lorg/gradle/api/provider/Property; + public abstract fun getIconPackName ()Lorg/gradle/api/provider/Property; + public abstract fun getIndentSize ()Lorg/gradle/api/provider/Property; + public abstract fun getNestedPackName ()Lorg/gradle/api/provider/Property; + public abstract fun getOutputDirectory ()Lorg/gradle/api/file/DirectoryProperty; + public abstract fun getOutputFormat ()Lorg/gradle/api/provider/Property; + public abstract fun getPackageName ()Lorg/gradle/api/provider/Property; + public abstract fun getPreviewAnnotationType ()Lorg/gradle/api/provider/Property; + public abstract fun getUseComposeColors ()Lorg/gradle/api/provider/Property; + public abstract fun getUseExplicitMode ()Lorg/gradle/api/provider/Property; + public abstract fun getUseFlatPackage ()Lorg/gradle/api/provider/Property; +} + +public final class io/github/composegears/valkyrie/gradle/ValkyrieGradlePlugin : org/gradle/api/Plugin { + public fun ()V + public synthetic fun apply (Ljava/lang/Object;)V + public fun apply (Lorg/gradle/api/Project;)V +} + diff --git a/tools/gradle-plugin/build.gradle.kts b/tools/gradle-plugin/build.gradle.kts new file mode 100644 index 00000000..cdb40466 --- /dev/null +++ b/tools/gradle-plugin/build.gradle.kts @@ -0,0 +1,109 @@ +import java.nio.file.Paths +import java.util.Properties +import kotlin.io.path.exists + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.valkyrie.kover) + alias(libs.plugins.valkyrie.abi) + alias(libs.plugins.buildConfig) + `java-gradle-plugin` +} + +tasks.validatePlugins { + // TODO: https://github.com/gradle/gradle/issues/22600 + enableStricterValidation = true +} + +gradlePlugin { + vcsUrl = "https://github.com/ComposeGears/Valkyrie" + website = "https://github.com/ComposeGears/Valkyrie" + + plugins { + create("valkyrie") { + id = "io.github.composegears.valkyrie" + displayName = name + implementationClass = "io.github.composegears.valkyrie.gradle.ValkyrieGradlePlugin" + description = "Generates Kotlin accessors for ImageVectors, based on input SVG files" + tags.addAll("kotlin", "svg", "xml", "imagevector", "valkyrie") + } + } +} + +val sharedTestResourcesDir: File = + project(projects.components.test.path) + .layout + .projectDirectory + .dir("sharedTestResources/imagevector") + .asFile + +buildConfig.sourceSets.getByName("test") { + packageName = "io.github.composegears.valkyrie.gradle" + useKotlinOutput { topLevelConstants = true } + + // So we can copy the shared test SVG/XML files into our test cases + buildConfigField("RESOURCES_DIR_SVG", sharedTestResourcesDir.resolve("svg")) + buildConfigField("RESOURCES_DIR_XML", sharedTestResourcesDir.resolve("xml")) + buildConfigField("ANDROID_HOME", androidHome()) + buildConfigField("COMPOSE_UI", libs.compose.ui.get().toString()) + + // TODO: Set up tests to run for different gradle versions? + buildConfigField("GRADLE_VERSION", GradleVersion.current().version) +} + +// Adapted from https://github.com/GradleUp/shadow/blob/1d7b0863fed3126bf376f11d563e9176de176cd3/build.gradle.kts#L63-L65 +// Allows gradle test cases to use the same classpath as the parent build - meaning we don't need to specify versions +// when loading plugins into test projects. +val testPluginClasspath by configurations.registering { + isCanBeResolved = true +} + +tasks.pluginUnderTestMetadata { + // Plugins used in tests could be resolved in classpath. + pluginClasspath.from(testPluginClasspath) +} + +dependencies { + compileOnly(libs.agp.api) + compileOnly(libs.kotlin.gradle.plugin) + + api(projects.sdk.core.extensions) + api(projects.components.generator.iconpack) + api(projects.components.generator.jvm.imagevector) + api(projects.components.ir) + api(projects.components.parser.unified) + + testImplementation(libs.bundles.test) + testRuntimeOnly(libs.junit.launcher) + + testPluginClasspath(libs.agp.full) + testPluginClasspath(libs.kotlin.gradle.plugin) +} + +fun androidHome(): String? { + val androidSdkRoot = System.getenv("ANDROID_SDK_ROOT") + if (!androidSdkRoot.isNullOrBlank() && Paths.get(androidSdkRoot).exists()) { + logger.info("Using ANDROID_SDK_ROOT=$androidSdkRoot") + return androidSdkRoot + } + + val androidHome = System.getenv("ANDROID_HOME") + if (!androidHome.isNullOrBlank() && Paths.get(androidHome).exists()) { + logger.info("Using ANDROID_HOME=$androidHome") + return androidHome + } + + val localProps = rootProject.file("local.properties") + if (localProps.exists()) { + val properties = Properties() + localProps.inputStream().use { properties.load(it) } + val sdkHome = properties.getProperty("sdk.dir")?.takeIf { it.isNotBlank() } + if (sdkHome != null && Paths.get(sdkHome).exists()) { + logger.info("Using local.properties sdk.dir $sdkHome") + return sdkHome + } + } + + logger.warn("No Android SDK found - Android unit tests will be skipped") + return null +} diff --git a/tools/gradle-plugin/src/main/kotlin/io/github/composegears/valkyrie/gradle/GenerateImageVectorsTask.kt b/tools/gradle-plugin/src/main/kotlin/io/github/composegears/valkyrie/gradle/GenerateImageVectorsTask.kt new file mode 100644 index 00000000..c88666c7 --- /dev/null +++ b/tools/gradle-plugin/src/main/kotlin/io/github/composegears/valkyrie/gradle/GenerateImageVectorsTask.kt @@ -0,0 +1,112 @@ +package io.github.composegears.valkyrie.gradle + +import io.github.composegears.valkyrie.generator.jvm.imagevector.ImageVectorGenerator +import io.github.composegears.valkyrie.generator.jvm.imagevector.ImageVectorGeneratorConfig +import io.github.composegears.valkyrie.generator.jvm.imagevector.OutputFormat +import io.github.composegears.valkyrie.generator.jvm.imagevector.PreviewAnnotationType +import io.github.composegears.valkyrie.parser.unified.ParserType +import io.github.composegears.valkyrie.parser.unified.SvgXmlParser +import io.github.composegears.valkyrie.parser.unified.ext.toIOPath +import io.github.composegears.valkyrie.sdk.core.extensions.writeToKt +import kotlinx.io.files.Path +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity.RELATIVE +import org.gradle.api.tasks.TaskAction + +@CacheableTask +abstract class GenerateImageVectorsTask : DefaultTask() { + @get:[PathSensitive(RELATIVE) InputFiles] abstract val svgFiles: ConfigurableFileCollection + + @get:[PathSensitive(RELATIVE) InputFiles] abstract val drawableFiles: ConfigurableFileCollection + + @get:Input abstract val packageName: Property + + @get:[Input Optional] abstract val iconPackName: Property + + @get:[Input Optional] abstract val nestedPackName: Property + + @get:Input abstract val outputFormat: Property + + @get:Input abstract val useComposeColors: Property + + @get:Input abstract val generatePreview: Property + + @get:Input abstract val previewAnnotationType: Property + + @get:Input abstract val useFlatPackage: Property + + @get:Input abstract val useExplicitMode: Property + + @get:Input abstract val addTrailingComma: Property + + @get:Input abstract val indentSize: Property + + @get:OutputDirectory abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun execute() { + val packageName = packageName.orNull + ?: throw GradleException("No package name configured for $this") + + // e.g. "/build/generated/sources/valkyrie/main" + val outputDirectory = outputDirectory.get().asFile + outputDirectory.deleteRecursively() // make sure nothing is left over from previous run + outputDirectory.mkdirs() + + val generatedFiles = arrayListOf() + var fileIndex = 0 + + val useFlatPackage = useFlatPackage.get() + val nestedPackName = nestedPackName.getOrElse("") + val config = ImageVectorGeneratorConfig( + packageName = packageName, + iconPackPackage = packageName, + packName = iconPackName.getOrElse(""), + nestedPackName = nestedPackName, + outputFormat = outputFormat.get(), + useComposeColors = useComposeColors.get(), + generatePreview = generatePreview.get(), + previewAnnotationType = previewAnnotationType.get(), + useFlatPackage = useFlatPackage, + useExplicitMode = useExplicitMode.get(), + addTrailingComma = addTrailingComma.get(), + indentSize = indentSize.get(), + ) + + (svgFiles + drawableFiles).files.forEach { file -> + val parseOutput = SvgXmlParser.toIrImageVector(ParserType.Jvm, Path(file.absolutePath)) + val vectorSpecOutput = ImageVectorGenerator.convert( + vector = parseOutput.irImageVector, + iconName = parseOutput.iconName, + config = config, + ) + + val path = vectorSpecOutput.content.writeToKt( + outputDir = when { + useFlatPackage -> outputDirectory + else -> outputDirectory.resolve(nestedPackName.lowercase()) + }.absolutePath, + nameWithoutExtension = vectorSpecOutput.name, + ) + generatedFiles.add(path.toIOPath()) + fileIndex++ + logger.info("File $fileIndex = $path") + } + + logger.lifecycle("Generated ${generatedFiles.size} ImageVectors in package $packageName") + } + + internal companion object { + internal const val TASK_NAME = "generateImageVectors" + } +} diff --git a/tools/gradle-plugin/src/main/kotlin/io/github/composegears/valkyrie/gradle/Utils.kt b/tools/gradle-plugin/src/main/kotlin/io/github/composegears/valkyrie/gradle/Utils.kt new file mode 100644 index 00000000..c5ae5303 --- /dev/null +++ b/tools/gradle-plugin/src/main/kotlin/io/github/composegears/valkyrie/gradle/Utils.kt @@ -0,0 +1,107 @@ +package io.github.composegears.valkyrie.gradle + +import com.android.build.api.dsl.CommonExtension +import io.github.composegears.valkyrie.gradle.GenerateImageVectorsTask.Companion.TASK_NAME +import io.github.composegears.valkyrie.parser.unified.ext.capitalized +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.Directory +import org.gradle.api.file.FileCollection +import org.gradle.api.provider.Provider +import org.gradle.util.GradleVersion +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet + +internal fun registerTask( + target: Project, + extension: ValkyrieExtension, + sourceSet: KotlinSourceSet, +) { + val taskName = "${TASK_NAME}${sourceSet.name.capitalized()}" + target.tasks.register(taskName, GenerateImageVectorsTask::class.java) { task -> + task.description = "Converts SVG & Drawable files into ImageVector Kotlin accessor properties" + + val svgFiles = sourceSet.findSvgFiles() + val drawableFiles = sourceSet.findDrawableFiles() + task.svgFiles.conventionCompat(svgFiles) + task.drawableFiles.conventionCompat(drawableFiles) + task.onlyIf("Needs at least one input file") { + !svgFiles.isEmpty || !drawableFiles.isEmpty + } + + task.packageName.convention(extension.packageName.orElse(target.packageNameOrThrow())) + + val outputRoot = extension.outputDirectory + val perSourceSetDir = outputRoot.map { it.dir(sourceSet.name) } + task.outputDirectory.convention(perSourceSetDir) + sourceSet.kotlin.srcDir(perSourceSetDir) + + task.iconPackName.convention(extension.iconPackName) + task.nestedPackName.convention(extension.nestedPackName) + task.outputFormat.convention(extension.outputFormat) + task.useComposeColors.convention(extension.useComposeColors) + task.generatePreview.convention(extension.generatePreview) + task.previewAnnotationType.convention(extension.previewAnnotationType) + task.useFlatPackage.convention(extension.useFlatPackage) + task.useExplicitMode.convention(extension.useExplicitMode) + task.addTrailingComma.convention(extension.addTrailingComma) + task.indentSize.convention(extension.indentSize) + } +} + +private val NO_PACKAGE_NAME_ERROR = """ + Couldn't automatically estimate package name - make sure to set this property in your gradle script like: + + valkyrie { + packageName = "my.output.package.name" + } +""".trimIndent() + +private fun ConfigurableFileCollection.conventionCompat( + paths: Iterable<*>, +): ConfigurableFileCollection = if (GradleVersion.current() >= GradleVersion.version("8.8")) { + @Suppress("UnstableApiUsage") + convention(paths) +} else { + setFrom(paths) + this +} + +private val ANDROID_PLUGIN_IDS = listOf( + "com.android.application", + "com.android.library", + "com.android.test", + "com.android.dynamic-feature", +) + +internal fun Project.packageNameOrThrow(): Provider = provider { + if (ANDROID_PLUGIN_IDS.any(pluginManager::hasPlugin)) { + extensions + .findByType(CommonExtension::class.java) + ?.namespace + ?: throw GradleException(NO_PACKAGE_NAME_ERROR) + } else { + throw GradleException(NO_PACKAGE_NAME_ERROR) + } +} + +private fun KotlinSourceSet.root(): Directory = with(project) { + // kotlin.srcDirs returns a set like ["src/main/kotlin", "src/main/java"] - we want the "src/main" directory. + val src = provider { + kotlin.srcDirs + .firstOrNull() + ?.resolve("..") + ?: error("No srcDir found for source set $name") + } + return layout.dir(src).get() +} + +private fun KotlinSourceSet.findSvgFiles(): FileCollection = root() + .dir("svg") + .asFileTree + .filter { it.extension == "svg" } + +private fun KotlinSourceSet.findDrawableFiles(): FileCollection = root() + .dir("res") + .asFileTree + .filter { it.parentFile?.name.orEmpty().contains("drawable") && it.extension == "xml" } diff --git a/tools/gradle-plugin/src/main/kotlin/io/github/composegears/valkyrie/gradle/ValkyrieExtension.kt b/tools/gradle-plugin/src/main/kotlin/io/github/composegears/valkyrie/gradle/ValkyrieExtension.kt new file mode 100644 index 00000000..9ab47468 --- /dev/null +++ b/tools/gradle-plugin/src/main/kotlin/io/github/composegears/valkyrie/gradle/ValkyrieExtension.kt @@ -0,0 +1,74 @@ +package io.github.composegears.valkyrie.gradle + +import io.github.composegears.valkyrie.generator.jvm.imagevector.OutputFormat +import io.github.composegears.valkyrie.generator.jvm.imagevector.PreviewAnnotationType +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property + +interface ValkyrieExtension { + /** + * Package name of the generated accessors. If you have any Android gradle plugin applied, this will default to + * the [com.android.build.api.dsl.CommonExtension.namespace] property - or fail otherwise. + */ + val packageName: Property + + /** + * If `true`, accessor generation will be re-run when clicking Sync in the IntelliJ IDE UI. Disabled by default. + */ + val generateAtSync: Property + + /** + * Output location of the generated files. Defaults to `/build/generated/sources/valkyrie`. + */ + val outputDirectory: DirectoryProperty + + /** + * Unset by default + */ + val iconPackName: Property + + /** + * Unset by default + */ + val nestedPackName: Property + + /** + * Defaults to [OutputFormat.BackingProperty] + */ + val outputFormat: Property + + /** + * Defaults to `true` + */ + val useComposeColors: Property + + /** + * Defaults to `false` + */ + val generatePreview: Property + + /** + * Defaults to [PreviewAnnotationType.AndroidX] + */ + val previewAnnotationType: Property + + /** + * Defaults to `false` + */ + val useFlatPackage: Property + + /** + * Defaults to `false` + */ + val useExplicitMode: Property + + /** + * Defaults to `false` + */ + val addTrailingComma: Property + + /** + * Defaults to `4` + */ + val indentSize: Property +} diff --git a/tools/gradle-plugin/src/main/kotlin/io/github/composegears/valkyrie/gradle/ValkyrieGradlePlugin.kt b/tools/gradle-plugin/src/main/kotlin/io/github/composegears/valkyrie/gradle/ValkyrieGradlePlugin.kt new file mode 100644 index 00000000..f0f80455 --- /dev/null +++ b/tools/gradle-plugin/src/main/kotlin/io/github/composegears/valkyrie/gradle/ValkyrieGradlePlugin.kt @@ -0,0 +1,72 @@ +package io.github.composegears.valkyrie.gradle + +import io.github.composegears.valkyrie.generator.jvm.imagevector.OutputFormat +import io.github.composegears.valkyrie.generator.jvm.imagevector.PreviewAnnotationType +import io.github.composegears.valkyrie.gradle.GenerateImageVectorsTask.Companion.TASK_NAME +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetContainer +import org.jetbrains.kotlin.gradle.tasks.AbstractKotlinCompile + +@Suppress("unused") // Registered as a Gradle plugin. +class ValkyrieGradlePlugin : Plugin { + override fun apply(target: Project): Unit = with(target) { + val extension = extensions.create("valkyrie", ValkyrieExtension::class.java).apply { + // Using the same defaults as `SvgXmlToImageVectorCommand` in tools/cli. + packageName.convention(packageNameOrThrow()) + generateAtSync.convention(false) + outputDirectory.convention(layout.buildDirectory.dir("generated/sources/valkyrie")) + outputFormat.convention(OutputFormat.BackingProperty) + useComposeColors.convention(true) + generatePreview.convention(false) + previewAnnotationType.convention(PreviewAnnotationType.AndroidX) + useFlatPackage.convention(false) + useExplicitMode.convention(false) + addTrailingComma.convention(false) + indentSize.convention(4) + } + + pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { + registerTasks(extension) + } + + pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") { + registerTasks(extension) + } + + pluginManager.withPlugin("org.jetbrains.kotlin.android") { + pluginManager.withPlugin("com.android.base") { + registerTasks(extension) + } + } + + val codegenTasks = tasks.withType(GenerateImageVectorsTask::class.java) + + // Run generation immediately if we're syncing Intellij/Android Studio - helps to speed up dev cycle + afterEvaluate { + val isIdeSyncing = System.getProperty("idea.sync.active") == "true" + if (extension.generateAtSync.getOrElse(false) && isIdeSyncing) { + tasks.findByName("prepareKotlinIdeaImport")?.dependsOn(codegenTasks) + } + } + + // Run generation before any kind of kotlin source processing + tasks.withType(AbstractKotlinCompile::class.java).configureEach { compileTask -> + compileTask.dependsOn(codegenTasks) + } + + // Create a wrapper task to invoke all other codegen tasks + tasks.register(TASK_NAME) { task -> + task.dependsOn(codegenTasks) + } + } + + private inline fun Project.registerTasks(extension: ValkyrieExtension) { + extensions.getByType(T::class.java).sourceSets.configureEach { sourceSet -> + registerTask(project, extension, sourceSet) + } + } +} diff --git a/tools/gradle-plugin/src/test/kotlin/io/github/composegears/valkyrie/gradle/Utils.kt b/tools/gradle-plugin/src/test/kotlin/io/github/composegears/valkyrie/gradle/Utils.kt new file mode 100644 index 00000000..28a453a1 --- /dev/null +++ b/tools/gradle-plugin/src/test/kotlin/io/github/composegears/valkyrie/gradle/Utils.kt @@ -0,0 +1,99 @@ +@file:OptIn(ExperimentalPathApi::class) + +package io.github.composegears.valkyrie.gradle + +import assertk.Assert +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.fail +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.copyToRecursively +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.writeText +import org.gradle.testkit.runner.BuildResult +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.gradle.testkit.runner.TaskOutcome.SUCCESS + +internal fun buildRunner(root: Path) = GradleRunner + .create() + .withPluginClasspath() + .withGradleVersion(GRADLE_VERSION) + .withProjectDir(root.toFile()) + +internal fun runTask(root: Path, task: String) = buildRunner(root).runTask(task).build() + +internal fun failTask(root: Path, task: String) = buildRunner(root).runTask(task).buildAndFail() + +internal fun GradleRunner.runTask(task: String) = withArguments( + task, + "--configuration-cache", + "--stacktrace", + "-Pandroid.useAndroidX=true", // needed for android builds to work, unused otherwise +) + +internal fun Path.writeSettingsFile() = resolve("settings.gradle.kts").writeText( + """ + pluginManagement { + repositories { + mavenCentral() + google() + gradlePluginPortal() + } + } + + dependencyResolutionManagement { + repositories { + mavenCentral() + google() + } + } + """.trimIndent(), +) + +internal fun Path.writeTestSvgs(sourceSet: String) { + val destDir = resolve("src/$sourceSet/svg") + destDir.createDirectories() + + val sourceDir = RESOURCES_DIR_SVG.toPath() + sourceDir.copyToRecursively(destDir, followLinks = true) +} + +internal fun Path.writeTestDrawables(sourceSet: String) { + val destDir = resolve("src/$sourceSet/res/drawable") + destDir.createDirectories() + + val sourceDir = RESOURCES_DIR_XML.toPath() + sourceDir.copyToRecursively(destDir, followLinks = true) +} + +internal fun Assert.taskWasSuccessful(name: String) = taskHadResult(name, SUCCESS) + +internal fun Assert.taskHadResult(path: String, expected: TaskOutcome) = transform { it.task(path)?.outcome } + .isNotNull() + .isEqualTo(expected) + +// TODO: https://github.com/assertk-org/assertk/pull/542 +internal fun Assert.doesNotExist() = given { path -> + if (Files.exists(path)) { + fail("$path to not exist, but it does") + } +} + +internal fun androidHomeOrSkip(): Path { + val androidHome = ANDROID_HOME + check(!androidHome.isNullOrBlank()) { + "No Android home directory configured - skipping test" + } + + val androidHomePath = Paths.get(androidHome) + check(androidHomePath.exists()) { + "Configured Android home directory ($androidHomePath) doesn't exist - skipping test" + } + + return androidHomePath +} diff --git a/tools/gradle-plugin/src/test/kotlin/io/github/composegears/valkyrie/gradle/ValkyrieGradlePluginTest.kt b/tools/gradle-plugin/src/test/kotlin/io/github/composegears/valkyrie/gradle/ValkyrieGradlePluginTest.kt new file mode 100644 index 00000000..7eb4ee8c --- /dev/null +++ b/tools/gradle-plugin/src/test/kotlin/io/github/composegears/valkyrie/gradle/ValkyrieGradlePluginTest.kt @@ -0,0 +1,556 @@ +package io.github.composegears.valkyrie.gradle + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.doesNotContain +import assertk.assertions.exists +import io.github.composegears.valkyrie.gradle.GenerateImageVectorsTask.Companion.TASK_NAME +import java.nio.file.Path +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.absolutePathString +import kotlin.io.path.createDirectories +import kotlin.io.path.name +import kotlin.io.path.readText +import kotlin.io.path.walk +import kotlin.io.path.writeText +import org.gradle.testkit.runner.TaskOutcome.SKIPPED +import org.gradle.testkit.runner.TaskOutcome.UP_TO_DATE +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +class ValkyrieGradlePluginTest { + @TempDir lateinit var root: Path + + @BeforeEach + fun beforeEach() { + root.writeSettingsFile() + } + + @Test + fun `Package name doesn't need to be explicitly set if AGP is applied and namespace is set`() { + // given + root.resolve("build.gradle.kts").writeText( + """ + plugins { + kotlin("android") + id("com.android.library") + id("io.github.composegears.valkyrie") + } + + android { + namespace = "a.b.c" + compileSdk = 36 + } + """.trimIndent(), + ) + root.writeTestSvgs(sourceSet = "main") + + // when + val result = runTask(root, TASK_NAME) + + // then + assertThat(result).taskWasSuccessful(":$TASK_NAME") + assertThat(result.output).contains("Generated 4 ImageVectors in package a.b.c") + } + + @Test + fun `Package name needs to be explicitly set if AGP isn't applied`() { + // given + root.resolve("build.gradle.kts").writeText( + """ + plugins { + kotlin("jvm") + id("io.github.composegears.valkyrie") + } + """.trimIndent(), + ) + root.writeTestSvgs(sourceSet = "main") + + // when + val result = failTask(root, TASK_NAME) + + // then + assertThat(result.output).contains("Couldn't automatically estimate package name") + } + + @Test + fun `Setting custom package name`() { + // given + root.resolve("build.gradle.kts").writeText( + """ + plugins { + kotlin("jvm") + id("io.github.composegears.valkyrie") + } + + valkyrie { + packageName = "my.custom.package" + } + """.trimIndent(), + ) + root.writeTestSvgs(sourceSet = "main") + + // when + val result = buildRunner(root) + .withArguments(TASK_NAME, "--info") // for log statement + .build() + + // then + assertThat(result).taskWasSuccessful(":$TASK_NAME") + assertThat(result.output).contains("Generated 4 ImageVectors in package my.custom.package") + } + + @Test + fun `Generate from SVGs with default config`() { + // given + root.resolve("build.gradle.kts").writeText( + """ + plugins { + kotlin("jvm") + id("io.github.composegears.valkyrie") + } + + valkyrie { + packageName = "x.y.z" + } + """.trimIndent(), + ) + root.writeTestSvgs(sourceSet = "main") + + // when + val result = runTask(root, TASK_NAME) + + // then + assertThat(result).taskWasSuccessful(":$TASK_NAME") + + listOf( + "LinearGradient.kt", + "RadialGradient.kt", + "ClipPathGradient.kt", + "LinearGradientWithStroke.kt", + ).forEach { filename -> + assertThat(root.resolve("build/generated/sources/valkyrie/main/$filename")).exists() + } + } + + @OptIn(ExperimentalPathApi::class) + @Test + fun `Generate from test SVGs with custom config`() { + // given + root.resolve("build.gradle.kts").writeText( + """ + import io.github.composegears.valkyrie.generator.jvm.imagevector.OutputFormat + import io.github.composegears.valkyrie.generator.jvm.imagevector.PreviewAnnotationType + + plugins { + kotlin("jvm") + id("io.github.composegears.valkyrie") + } + + valkyrie { + packageName = "x.y.z" + iconPackName = "MyIconPack" + nestedPackName = "MyNestedPack" + outputFormat = OutputFormat.LazyProperty + useComposeColors = false + generatePreview = true + previewAnnotationType = PreviewAnnotationType.Jetbrains + useFlatPackage = true + useExplicitMode = true + addTrailingComma = true + indentSize = 8 + } + """.trimIndent(), + ) + root.writeTestSvgs(sourceSet = "main") + + // when + val result = runTask(root, TASK_NAME) + + // then the expected files are printed to log + assertThat(result).taskWasSuccessful(":$TASK_NAME") + listOf( + "LinearGradient.kt", + "RadialGradient.kt", + "ClipPathGradient.kt", + "LinearGradientWithStroke.kt", + ).forEach { filename -> + assertThat(root.resolve("build/generated/sources/valkyrie/main/$filename")).exists() + } + + // and the LinearGradient file is created with the right visibility, parent pack, nested pack, etc + val linearGradientKt = root + .walk() + .first { it.name == "LinearGradient.kt" } + .readText() + assertThat(linearGradientKt).contains("public val MyIconPack.MyNestedPack.LinearGradient: ImageVector") + } + + @Test + fun `Generate from SVGs in KMP project`() { + // given + root.resolve("build.gradle.kts").writeText( + """ + plugins { + kotlin("multiplatform") + id("com.android.library") + id("io.github.composegears.valkyrie") + } + + android { + namespace = "x.y.z" + compileSdk = 36 + + flavorDimensions += "test" + productFlavors { + create("free") { dimension = "test" } + create("paid") { dimension = "test" } + } + } + + kotlin { + androidTarget() + jvm() + } + """.trimIndent(), + ) + + root.writeTestSvgs(sourceSet = "jvmMain") + root.writeTestSvgs(sourceSet = "androidMain") + + // when + val result = runTask(root, TASK_NAME) + + // no SVGs/drawables under these source sets, so the tasks are skipped (but still registered) + assertThat(result).taskHadResult(":generateImageVectorsAndroidFree", SKIPPED) + assertThat(result).taskHadResult(":generateImageVectorsAndroidPaidDebug", SKIPPED) + assertThat(result).taskHadResult(":generateImageVectorsCommonMain", SKIPPED) + assertThat(result).taskHadResult(":generateImageVectorsJvmTest", SKIPPED) + + // but androidMain and jvmMain have SVGs, so they run successfully + assertThat(result).taskWasSuccessful(":generateImageVectorsAndroidMain") + assertThat(result).taskWasSuccessful(":generateImageVectorsJvmMain") + } + + @Test + fun `Generate from drawables with default config`() { + // given + root.resolve("build.gradle.kts").writeText( + """ + plugins { + kotlin("android") + id("com.android.library") + id("io.github.composegears.valkyrie") + } + + android { + namespace = "x.y.z" + compileSdk = 36 + } + """.trimIndent(), + ) + + root.writeTestDrawables(sourceSet = "main") + + // when + val result = runTask(root, TASK_NAME) + + // then + assertThat(result).taskWasSuccessful(":$TASK_NAME") + listOf( + "OnlyPath.kt", + "IconWithShorthandColor.kt", + "SeveralPath.kt", + "AllPathParams.kt", + ).forEach { filename -> + assertThat(root.resolve("build/generated/sources/valkyrie/main/$filename")).exists() + } + } + + @Test + fun `Running the same task twice with configuration cache skips the second run`() { + // given + root.resolve("build.gradle.kts").writeText( + """ + plugins { + kotlin("android") + id("com.android.library") + id("io.github.composegears.valkyrie") + } + + android { + namespace = "x.y.z" + compileSdk = 36 + } + """.trimIndent(), + ) + + root.writeTestDrawables(sourceSet = "main") + root.writeTestSvgs(sourceSet = "main") + + // First run generates the outputs + val runner = buildRunner(root) + val result1 = runner.withArguments(TASK_NAME, "--configuration-cache").build() + assertThat(result1).taskWasSuccessful(":$TASK_NAME") + + // Second run doesn't need to - no inputs have changed + val result2 = runner.withArguments(TASK_NAME, "--configuration-cache").build() + assertThat(result2).taskHadResult(":$TASK_NAME", UP_TO_DATE) + } + + @Test + fun `Generate outputs for android build variants`() { + // given + root.resolve("build.gradle.kts").writeText( + """ + plugins { + kotlin("android") + id("com.android.library") + id("io.github.composegears.valkyrie") + } + + android { + namespace = "x.y.z" + compileSdk = 36 + + flavorDimensions += "test" + productFlavors { + create("free") { dimension = "test" } + create("paid") { dimension = "test" } + } + } + + dependencies { + implementation("$COMPOSE_UI") + } + """.trimIndent(), + ) + + root.writeTestDrawables(sourceSet = "debug") // just build variant + root.writeTestSvgs(sourceSet = "freeRelease") // flavor + variant + + val result = runTask(root, TASK_NAME) + + // Then the specific variant tasks ran successfully + assertThat(result).taskWasSuccessful(":generateImageVectorsDebug") + assertThat(result).taskWasSuccessful(":generateImageVectorsFreeRelease") + + // and the wrapper + assertThat(result).taskWasSuccessful(":generateImageVectors") + + // and files were generated in the right source sets + listOf( + "build/generated/sources/valkyrie/freeRelease/LinearGradient.kt", + "build/generated/sources/valkyrie/freeRelease/RadialGradient.kt", + "build/generated/sources/valkyrie/freeRelease/ClipPathGradient.kt", + "build/generated/sources/valkyrie/freeRelease/LinearGradientWithStroke.kt", + "build/generated/sources/valkyrie/debug/OnlyPath.kt", + "build/generated/sources/valkyrie/debug/IconWithShorthandColor.kt", + "build/generated/sources/valkyrie/debug/SeveralPath.kt", + "build/generated/sources/valkyrie/debug/AllPathParams.kt", + ).forEach { path -> + assertThat(root.resolve(path)).exists() + } + + // but the empty source sets didn't generate anything + listOf( + "build/generated/sources/valkyrie/free/", + "build/generated/sources/valkyrie/freeDebug/", + "build/generated/sources/valkyrie/main/", + "build/generated/sources/valkyrie/paid/", + "build/generated/sources/valkyrie/paidDebug/", + "build/generated/sources/valkyrie/paidRelease/", + "build/generated/sources/valkyrie/release/", + "build/generated/sources/valkyrie/test/", + ).forEach { path -> + assertThat(root.resolve(path)).doesNotExist() + } + } + + @Test + fun `Compile JVM project generating and using ImageVectors`() { + // given + root.writeTestSvgs(sourceSet = "main") + root.resolve("build.gradle.kts").writeText( + """ + plugins { + kotlin("jvm") + id("io.github.composegears.valkyrie") + } + + valkyrie { + packageName = "com.example.app" + } + + dependencies { + implementation("$COMPOSE_UI") + } + """.trimIndent(), + ) + + val sourceDir = root.resolve("src/main/kotlin/") + sourceDir.createDirectories() + sourceDir.resolve("Test.kt").writeText( + """ + import com.example.app.LinearGradient + + fun accessGeneratedData() { + val linearGradient = LinearGradient + } + """.trimIndent(), + ) + + val result = runTask(root, "assemble") + + // codegen was hooked into compilation + assertThat(result).taskWasSuccessful(":generateImageVectorsMain") + listOf( + "LinearGradient.kt", + "RadialGradient.kt", + "ClipPathGradient.kt", + "LinearGradientWithStroke.kt", + ).forEach { filename -> + assertThat(root.resolve("build/generated/sources/valkyrie/main/$filename")).exists() + } + + // and the compilation succeeded, so the accessor code has access to the generated code dirs + assertThat(result).taskWasSuccessful(":assemble") + } + + @Test + fun `Compile Android project generating and using ImageVectors`() { + // pre-check - we need the Android SDK to pass this test. Skip test if it can't be found + val androidHome = androidHomeOrSkip() + + // given + root.writeTestSvgs(sourceSet = "freeDebug") + root.writeTestDrawables(sourceSet = "paid") + root.resolve("build.gradle.kts").writeText( + """ + plugins { + kotlin("android") + id("com.android.library") + id("io.github.composegears.valkyrie") + } + + android { + namespace = "com.example.app" + compileSdk = 36 + + flavorDimensions += "myFlavor" + productFlavors { + create("free") { dimension = "myFlavor" } + create("paid") { dimension = "myFlavor" } + } + } + + kotlin { + // to match that used in validation.yml for CI + jvmToolchain(21) + } + + dependencies { + implementation("$COMPOSE_UI") + } + """.trimIndent(), + ) + + root.resolve("src/freeDebug/kotlin/").apply { + createDirectories() + resolve("FreeDebugTest.kt").writeText( + """ + import com.example.app.LinearGradient + + fun accessFreeDebugSvgs() { + val linearGradient = LinearGradient + } + """.trimIndent(), + ) + } + + root.resolve("src/paid/kotlin/").apply { + createDirectories() + resolve("PaidTest.kt").writeText( + """ + import com.example.app.SeveralPath + + fun accessPaidDrawables() { + val severalPath = SeveralPath + } + """.trimIndent(), + ) + } + + val result = buildRunner(root) + .withEnvironment(mapOf("ANDROID_HOME" to androidHome.absolutePathString())) + .runTask("assemble") + .build() + + // codegen was hooked into compilation + assertThat(result).taskWasSuccessful(":generateImageVectorsFreeDebug") + assertThat(result).taskWasSuccessful(":generateImageVectorsPaid") + + // and the compilation succeeded, so the accessor code has access to the generated code dirs + assertThat(result).taskWasSuccessful(":assemble") + } + + @Test + fun `Run generate when syncing Intellij IDE`() { + // given + root.resolve("build.gradle.kts").writeText( + """ + plugins { + kotlin("jvm") + id("io.github.composegears.valkyrie") + } + + valkyrie { + packageName = "my.custom.package" + generateAtSync = true + } + + // dummy task to replicate IntelliJ + tasks.register("prepareKotlinIdeaImport") + """.trimIndent(), + ) + root.writeTestSvgs(sourceSet = "main") + + // when the IDE syncs + val result = buildRunner(root) + .withArguments("prepareKotlinIdeaImport", "-Didea.sync.active=true") + .build() + + // then the generate task was run + assertThat(result).taskWasSuccessful(":generateImageVectorsMain") + } + + @Test + fun `Don't run generate when syncing Intellij IDE if config wasn't enabled`() { + // given + root.resolve("build.gradle.kts").writeText( + """ + plugins { + kotlin("jvm") + id("io.github.composegears.valkyrie") + } + + valkyrie { + packageName = "my.custom.package" + } + + // dummy task to replicate IntelliJ + tasks.register("prepareKotlinIdeaImport") + """.trimIndent(), + ) + root.writeTestSvgs(sourceSet = "main") + + // when the IDE syncs + val result = buildRunner(root) + .withArguments("prepareKotlinIdeaImport", "-Didea.sync.active=true") + .build() + + // then the generate task wasn't run + assertThat(result.tasks).doesNotContain(":generateImageVectorsMain") + } +}