diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 879684821d..381ca746dd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ compose = "1.6.8" coroutines = "1.8.1" javaTarget = "11" kotlin = "2.0.20" +kotlinCompileTesting = "1.5.0" kotlinPoet = "1.18.1" ksp = "2.0.20-1.0.24" layoutlib = "14.0.9" @@ -32,6 +33,8 @@ guava = { module = "com.google.guava:guava", version = "33.3.0-jre" } kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinPoet" } kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlinPoet" } +kotlin-compile-testing = { module = "com.github.tschuchortdev:kotlin-compile-testing", version.ref = "kotlinCompileTesting" } +kotlin-compile-testing-ksp = { module = "com.github.tschuchortdev:kotlin-compile-testing-ksp", version.ref = "kotlinCompileTesting" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } diff --git a/paparazzi-preview-processor/build.gradle b/paparazzi-preview-processor/build.gradle index 80d6f9f8b6..9a5b600af8 100644 --- a/paparazzi-preview-processor/build.gradle +++ b/paparazzi-preview-processor/build.gradle @@ -5,4 +5,9 @@ dependencies { implementation libs.kotlinpoet implementation libs.kotlinpoet.ksp implementation libs.ksp + + testImplementation libs.junit + testImplementation libs.truth + testImplementation libs.kotlin.compile.testing + testImplementation libs.kotlin.compile.testing.ksp } diff --git a/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/PaparazziPoet.kt b/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/PaparazziPoet.kt index 284f9f63c8..392f063d9f 100644 --- a/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/PaparazziPoet.kt +++ b/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/PaparazziPoet.kt @@ -9,7 +9,6 @@ import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.buildCodeBlock internal object PaparazziPoet { - fun buildFiles( functions: Sequence, isTest: Boolean, @@ -50,7 +49,6 @@ internal object PaparazziPoet { when { visibilityCheck.isPrivate -> addError( - visibilityCheck = visibilityCheck, function = func, snapshotName = snapshotName, buildErrorMessage = { @@ -58,7 +56,6 @@ internal object PaparazziPoet { } ) previewParam != null -> addError( - visibilityCheck = visibilityCheck, function = func, snapshotName = snapshotName, buildErrorMessage = { @@ -87,24 +84,21 @@ internal object PaparazziPoet { block: (KSFunctionDeclaration, KSValueParameter?) -> Unit ) = flatMap { func -> - val previewParam = func.previewParam() - func.findDistinctPreviews() + val previewParam = func.parameters.firstOrNull { param -> + param.annotations.any { it.isPreviewParameter() } + } + func.annotations.findPreviews().distinct() .map { Pair(func, previewParam) } }.forEach { (func, previewParam) -> block(func, previewParam) } private fun CodeBlock.Builder.addError( - visibilityCheck: VisibilityCheck, function: KSFunctionDeclaration, snapshotName: String, buildErrorMessage: (String?) -> String ) { - val qualifiedName = if (visibilityCheck.isFunctionPrivate) { - function.qualifiedName?.asString() - } else { - null - } + val qualifiedName = function.qualifiedName?.asString() addStatement("%L.PaparazziPreviewData.Error(", PACKAGE_NAME) indent() @@ -114,18 +108,6 @@ internal object PaparazziPoet { addStatement("),") } - private fun CodeBlock.Builder.addProvider( - function: KSFunctionDeclaration, - snapshotName: String - ) { - addStatement("%L.PaparazziPreviewData.Provider(", PACKAGE_NAME) - indent() - addStatement("snapshotName = %S,", snapshotName) - addStatement("composable = { %L(it) },", function.qualifiedName?.asString()) - unindent() - addStatement("),") - } - private fun CodeBlock.Builder.addDefault( function: KSFunctionDeclaration, snapshotName: String diff --git a/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/PreviewProcessor.kt b/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/PreviewProcessor.kt index 339edb7daf..d8b533f841 100644 --- a/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/PreviewProcessor.kt +++ b/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/PreviewProcessor.kt @@ -20,11 +20,13 @@ public class PreviewProcessor( private val environment: SymbolProcessorEnvironment ) : SymbolProcessor { - private var invoked = false - override fun process(resolver: Resolver): List { - if (invoked) return emptyList() - invoked = true + // Due to codgen and multi-round processing of ksp + // https://kotlinlang.org/docs/ksp-multi-round.html + if (resolver.getNewFiles().any { it.fileName.contains("PaparazziPreviews.kt") }) { + "Skipping subsequent run due to PaparazziPreviews.kt already created and caused ksp re-run".log() + return emptyList() + } val allFiles = resolver.getAllFiles().toList() if (allFiles.isEmpty()) return emptyList() @@ -41,7 +43,7 @@ public class PreviewProcessor( .also { functions -> "found ${functions.count()} function(s)".log() PaparazziPoet.buildFiles(functions, isTestSourceSet, env).forEach { file -> - "writing file: ${file.packageName}.${file.name}".log() + "writing file: ${file.packageName}.${file.name}.kt".log() file.writeTo(environment.codeGenerator, dependencies) } } diff --git a/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/Utils.kt b/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/Utils.kt index 0deb57adcd..0b7e245bd1 100644 --- a/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/Utils.kt +++ b/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/Utils.kt @@ -35,12 +35,6 @@ internal fun Sequence.findPreviews(stack: Set = setO return direct.plus(indirect) } -internal fun KSFunctionDeclaration.findDistinctPreviews() = annotations.findPreviews().distinct() - -internal fun KSFunctionDeclaration.previewParam() = parameters.firstOrNull { param -> - param.annotations.any { it.isPreviewParameter() } -} - internal data class EnvironmentOptions( val namespace: String ) diff --git a/paparazzi-preview-processor/src/test/java/app/cash/paparazzi/preview/processor/KspCompileResult.kt b/paparazzi-preview-processor/src/test/java/app/cash/paparazzi/preview/processor/KspCompileResult.kt new file mode 100644 index 0000000000..4fc2d3b767 --- /dev/null +++ b/paparazzi-preview-processor/src/test/java/app/cash/paparazzi/preview/processor/KspCompileResult.kt @@ -0,0 +1,9 @@ +package app.cash.paparazzi.preview.processor + +import com.tschuchort.compiletesting.KotlinCompilation +import java.io.File + +data class KspCompileResult( + val result: KotlinCompilation.Result, + val generatedFiles: List +) diff --git a/paparazzi-preview-processor/src/test/java/app/cash/paparazzi/preview/processor/PreviewProcessorProviderTest.kt b/paparazzi-preview-processor/src/test/java/app/cash/paparazzi/preview/processor/PreviewProcessorProviderTest.kt new file mode 100644 index 0000000000..3e879a497b --- /dev/null +++ b/paparazzi-preview-processor/src/test/java/app/cash/paparazzi/preview/processor/PreviewProcessorProviderTest.kt @@ -0,0 +1,307 @@ +package app.cash.paparazzi.preview.processor + +import app.cash.paparazzi.preview.processor.utils.DefaultComposeSource +import com.google.common.truth.Truth.assertThat +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.kspAllWarningsAsErrors +import com.tschuchort.compiletesting.kspArgs +import com.tschuchort.compiletesting.kspIncremental +import com.tschuchort.compiletesting.kspSourcesDir +import com.tschuchort.compiletesting.symbolProcessorProviders +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class PreviewProcessorProviderTest { + @get:Rule val temporaryFolder = TemporaryFolder() + + private val previewProcessor = PreviewProcessorProvider() + + @Test + fun empty() { + val kspCompileResult = compile( + SourceFile.kotlin( + "SamplePreview.kt", + """ + package test + + import androidx.compose.runtime.Composable + import androidx.compose.ui.tooling.preview.Preview + import app.cash.paparazzi.annotations.Paparazzi + + @Preview + @Composable + fun SamplePreview() = Unit + """ + ) + ) + + assertThat(kspCompileResult.result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + assertThat(kspCompileResult.generatedFiles.size).isEqualTo(2) + val file = kspCompileResult.generatedFiles[0] + assertThat(file.name).isEqualTo("paparazziVariant.txt") + file.withText { + // Assertion for variant has "sources" as we can't specify the variant name in testing KSP + assertThat(it).isEqualTo( + """ + sources + """.trimIndent() + ) + } + + val file2 = kspCompileResult.generatedFiles[1] + assertThat(file2.name).isEqualTo("PaparazziPreviews.kt") + file2.withText { + assertThat(it).isEqualTo( + """ + package test + + internal val paparazziPreviews = listOf( + app.cash.paparazzi.annotations.PaparazziPreviewData.Empty, + ) + """.trimIndent() + ) + } + } + + @Test + fun default() { + val kspCompileResult = compile( + SourceFile.kotlin( + "SamplePreview.kt", + """ + package test + + import androidx.compose.runtime.Composable + import androidx.compose.ui.tooling.preview.Preview + import app.cash.paparazzi.annotations.Paparazzi + + + @Paparazzi + @Preview + @Composable + fun SamplePreview() = Unit + """ + ) + ) + + assertThat(kspCompileResult.result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + assertThat(kspCompileResult.generatedFiles.size).isEqualTo(2) + val file = kspCompileResult.generatedFiles[0] + assertThat(file.name).isEqualTo("paparazziVariant.txt") + file.withText { + // Assertion for variant has "sources" as we can't specify the variant name in testing KSP + assertThat(it).isEqualTo( + """ + sources + """.trimIndent() + ) + } + + val file2 = kspCompileResult.generatedFiles[1] + assertThat(file2.name).isEqualTo("PaparazziPreviews.kt") + file2.withText { + assertThat(it).isEqualTo( + """ + package test + + internal val paparazziPreviews = listOf( + app.cash.paparazzi.annotations.PaparazziPreviewData.Default( + snapshotName = "SamplePreview_SamplePreview", + composable = { test.SamplePreview() }, + ), + ) + """.trimIndent() + ) + } + } + + @Test + fun privatePreview() { + val kspCompileResult = compile( + SourceFile.kotlin( + "SamplePreview.kt", + """ + package test + + import androidx.compose.runtime.Composable + import androidx.compose.ui.tooling.preview.Preview + import app.cash.paparazzi.annotations.Paparazzi + + + @Paparazzi + @Preview + @Composable + private fun SamplePreview() = Unit + """ + ) + ) + + assertThat(kspCompileResult.result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + val file = kspCompileResult.generatedFiles[1] + assertThat(file.name).isEqualTo("PaparazziPreviews.kt") + file.withText { + assertThat(it).isEqualTo( + """ + package test + + internal val paparazziPreviews = listOf( + app.cash.paparazzi.annotations.PaparazziPreviewData.Error( + snapshotName = "SamplePreview_SamplePreview", + message = "test.SamplePreview is private. Make it internal or public to generate a snapshot.", + ), + ) + """.trimIndent() + ) + } + } + + @Test + fun previewParameters() { + val kspCompileResult = compile( + SourceFile.kotlin( + "SamplePreview.kt", + """ + package test + + import androidx.compose.runtime.Composable + import androidx.compose.ui.tooling.preview.Preview + import androidx.compose.ui.tooling.preview.PreviewParameter + import androidx.compose.ui.tooling.preview.PreviewParameterProvider + import app.cash.paparazzi.annotations.Paparazzi + + + @Paparazzi + @Preview + @Composable + fun SamplePreview( + @PreviewParameter(SamplePreviewParameter::class) text: String, + ) = Unit + + + object SamplePreviewParameter: PreviewParameterProvider { + override val values: Sequence = + sequenceOf("test") + } + """ + ) + ) + + assertThat(kspCompileResult.result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + val file = kspCompileResult.generatedFiles[1] + assertThat(file.name).isEqualTo("PaparazziPreviews.kt") + file.withText { + assertThat(it).isEqualTo( + """ + package test + + internal val paparazziPreviews = listOf( + app.cash.paparazzi.annotations.PaparazziPreviewData.Error( + snapshotName = "SamplePreview_SamplePreview", + message = "test.SamplePreview preview uses PreviewParameters which aren't currently supported.", + ), + ) + """.trimIndent() + ) + } + } + + @Test + fun multiplePreviews() { + val kspCompileResult = compile( + SourceFile.kotlin( + "SamplePreview.kt", + """ + package test + + import androidx.compose.runtime.Composable + import androidx.compose.ui.tooling.preview.Preview + import app.cash.paparazzi.annotations.Paparazzi + + + @Paparazzi + @Preview + @Preview(name = "Night Pixel 4", uiMode = 0x20, device = "id:pixel_4") // uiMode maps to android.content.res.Configuration.UI_MODE_NIGHT_YES + @Composable + fun SamplePreview() = Unit + """ + ) + ) + + assertThat(kspCompileResult.result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + assertThat(kspCompileResult.generatedFiles.size).isEqualTo(2) + val file = kspCompileResult.generatedFiles[0] + assertThat(file.name).isEqualTo("paparazziVariant.txt") + file.withText { + // Assertion for variant has "sources" as we can't specify the variant name in testing KSP + assertThat(it).isEqualTo( + """ + sources + """.trimIndent() + ) + } + + val file2 = kspCompileResult.generatedFiles[1] + assertThat(file2.name).isEqualTo("PaparazziPreviews.kt") + file2.withText { + assertThat(it).isEqualTo( + """ + package test + + internal val paparazziPreviews = listOf( + app.cash.paparazzi.annotations.PaparazziPreviewData.Default( + snapshotName = "SamplePreview_SamplePreview", + composable = { test.SamplePreview() }, + ), + app.cash.paparazzi.annotations.PaparazziPreviewData.Default( + snapshotName = "SamplePreview_SamplePreview", + composable = { test.SamplePreview() }, + ), + ) + """.trimIndent() + ) + } + } + + private fun File.withText(doWithText: (String) -> Unit) { + inputStream().use { + doWithText(String(it.readBytes()).trimIndent()) + } + } + + private fun prepareCompilation(vararg sourceFiles: SourceFile): KotlinCompilation = + KotlinCompilation() + .apply { + workingDir = File(temporaryFolder.root, "debug") + inheritClassPath = true + symbolProcessorProviders = listOf(previewProcessor) + sources = sourceFiles.asList() + DefaultComposeSource + verbose = true + kspIncremental = true + kspAllWarningsAsErrors = true + kspArgs["app.cash.paparazzi.preview.namespace"] = "test" + } + + private fun compile(vararg sourceFiles: SourceFile): KspCompileResult { + val compilation = prepareCompilation(*sourceFiles) + val result = compilation.compile() + return KspCompileResult( + result, + findGeneratedFiles(compilation) + ) + } + + private fun findGeneratedFiles(compilation: KotlinCompilation): List { + return compilation.kspSourcesDir + .walkTopDown() + .filter { it.isFile } + .toList() + } +} diff --git a/paparazzi-preview-processor/src/test/java/app/cash/paparazzi/preview/processor/utils/DefaultComposeSource.kt b/paparazzi-preview-processor/src/test/java/app/cash/paparazzi/preview/processor/utils/DefaultComposeSource.kt new file mode 100644 index 0000000000..15ae5c34ca --- /dev/null +++ b/paparazzi-preview-processor/src/test/java/app/cash/paparazzi/preview/processor/utils/DefaultComposeSource.kt @@ -0,0 +1,167 @@ +package app.cash.paparazzi.preview.processor.utils + +import com.tschuchort.compiletesting.SourceFile + +val DefaultComposeSource = + listOf( + SourceFile.kotlin( + "Composable.kt", + """ + package androidx.compose.runtime + + /** + * [Composable] functions are the fundamental building blocks of an application built with Compose. + * + * [Composable] can be applied to a function or lambda to indicate that the function/lambda can be + * used as part of a composition to describe a transformation from application data into a + * tree or hierarchy. + * + * Annotating a function or expression with [Composable] changes the type of that function or + * expression. For example, [Composable] functions can only ever be called from within another + * [Composable] function. A useful mental model for [Composable] functions is that an implicit + * "composable context" is passed into a [Composable] function, and is done so implicitly when it + * is called from within another [Composable] function. This "context" can be used to store + * information from previous executions of the function that happened at the same logical point of + * the tree. + */ + @MustBeDocumented + @Retention(AnnotationRetention.BINARY) + @Target( + // function declarations + // @Composable fun Foo() { ... } + // lambda expressions + // val foo = @Composable { ... } + AnnotationTarget.FUNCTION, + + // type declarations + // var foo: @Composable () -> Unit = { ... } + // parameter types + // foo: @Composable () -> Unit + AnnotationTarget.TYPE, + + // composable types inside of type signatures + // foo: (@Composable () -> Unit) -> Unit + AnnotationTarget.TYPE_PARAMETER, + + // composable property getters and setters + // val foo: Int @Composable get() { ... } + // var bar: Int + // @Composable get() { ... } + AnnotationTarget.PROPERTY_GETTER + ) + annotation class Composable + """ + ), + SourceFile.kotlin( + "PreviewAnnotation.kt", + """ + package androidx.compose.ui.tooling.preview + + + /** + * [Preview] can be applied to either of the following: + * - @[Composable] methods with no parameters to show them in the Android Studio preview. + * - Annotation classes, that could then be used to annotate @[Composable] methods or other + * annotation classes, which will then be considered as indirectly annotated with that [Preview]. + * + * The annotation contains a number of parameters that allow to define the way the @[Composable] + * will be rendered within the preview. + * + * The passed parameters are only read by Studio when rendering the preview. + * + * @param name Display name of this preview allowing to identify it in the panel. + * @param group Group name for this @[Preview]. This allows grouping them in the UI and display only + * one or more of them. + * @param apiLevel API level to be used when rendering the annotated @[Composable] + * @param widthDp Max width in DP the annotated @[Composable] will be rendered in. Use this to + * restrict the size of the rendering viewport. + * @param heightDp Max height in DP the annotated @[Composable] will be rendered in. Use this to + * restrict the size of the rendering viewport. + * @param locale Current user preference for the locale, corresponding to + * [locale](https://d.android.com/guide/topics/resources/providing-resources.html#LocaleQualifier) resource + * qualifier. By default, the `default` folder will be used. + * @param fontScale User preference for the scaling factor for fonts, relative to the base + * density scaling. + * @param showSystemUi If true, the status bar and action bar of the device will be displayed. + * The @[Composable] will be render in the context of a full activity. + * @param showBackground If true, the @[Composable] will use a default background color. + * @param backgroundColor The 32-bit ARGB color int for the background or 0 if not set + * @param uiMode Bit mask of the ui mode as per [android.content.res.Configuration.uiMode] + * @param device Device string indicating the device to use in the preview. See the available + * devices in [Devices]. + * @param wallpaper Integer defining which wallpaper from those available in Android Studio + * to use for dynamic theming. + */ + @MustBeDocumented + @Retention(AnnotationRetention.BINARY) + @Target( + AnnotationTarget.ANNOTATION_CLASS, + AnnotationTarget.FUNCTION + ) + @Repeatable + annotation class Preview( + val name: String = "", + val group: String = "", + val apiLevel: Int = -1, + val widthDp: Int = -1, + val heightDp: Int = -1, + val locale: String = "", + val fontScale: Float = 1f, + val showSystemUi: Boolean = false, + val showBackground: Boolean = false, + val backgroundColor: Long = 0, + val uiMode: Int = 0, + val device: String = "", + val wallpaper: Int = 0, + ) + """ + ), + SourceFile.kotlin( + "Paparazzi.kt", + """ + package app.cash.paparazzi.annotations + + @Target(AnnotationTarget.FUNCTION) + @Retention(AnnotationRetention.BINARY) + annotation class Paparazzi + """ + ), + SourceFile.kotlin( + "PreviewParameter.kt", + """ + package androidx.compose.ui.tooling.preview + + import kotlin.jvm.JvmDefaultWithCompatibility + import kotlin.reflect.KClass + + /** + * Interface to be implemented by any provider of values that you want to be injected as @[Preview] + * parameters. This allows providing sample information for previews. + */ + @JvmDefaultWithCompatibility + interface PreviewParameterProvider { + /** + * [Sequence] of values of type [T] to be passed as @[Preview] parameter. + */ + val values: Sequence + + /** + * Returns the number of elements in the [values] [Sequence]. + */ + val count get() = values.count() + } + + /** + * [PreviewParameter] can be applied to any parameter of a @[Preview]. + * + * @param provider A [PreviewParameterProvider] class to use to inject values to the annotated + * parameter. + * @param limit Max number of values from [provider] to inject to this parameter. + */ + annotation class PreviewParameter( + val provider: KClass>, + val limit: Int = Int.MAX_VALUE + ) + """.trimIndent() + ) + )