Skip to content

Commit

Permalink
adding preview processor
Browse files Browse the repository at this point in the history
  • Loading branch information
nak5ive authored and geoff-powell committed May 14, 2024
1 parent da7f8ea commit 7638efa
Show file tree
Hide file tree
Showing 5 changed files with 339 additions and 1 deletion.
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ compose = "1.6.7"
coroutines = "1.8.1"
javaTarget = "11"
kotlin = "1.9.23"
kotlinPoet = "1.16.0"
ksp = "1.9.23-1.0.20"
ktlint = "1.2.1"
moshi = "1.15.1"
Expand Down Expand Up @@ -37,6 +38,8 @@ grgit = { module = "org.ajoberstar.grgit:grgit-core", version = "5.2.2" }
guava = { module = "com.google.guava:guava", version = "33.2.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" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }

Expand Down
2 changes: 2 additions & 0 deletions paparazzi-preview-processor/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@ apply plugin: 'org.jetbrains.kotlin.jvm'
apply plugin: 'com.vanniktech.maven.publish'

dependencies {
implementation libs.kotlinpoet
implementation libs.kotlinpoet.ksp
implementation libs.ksp
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package app.cash.paparazzi.preview.processor

import com.google.devtools.ksp.getVisibility
import com.google.devtools.ksp.symbol.KSFunctionDeclaration
import com.google.devtools.ksp.symbol.KSValueParameter
import com.google.devtools.ksp.symbol.Visibility
import com.squareup.kotlinpoet.CodeBlock
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.buildCodeBlock

internal object PaparazziPoet {

fun buildFiles(
functions: Sequence<KSFunctionDeclaration>,
isTest: Boolean,
env: EnvironmentOptions
) =
if (isTest) {
emptyList()
} else {
listOf(
buildAnnotationsFile("paparazziPreviews", functions, env)
)
}

@Suppress("SameParameterValue")
private fun buildAnnotationsFile(
propertyName: String,
functions: Sequence<KSFunctionDeclaration>,
env: EnvironmentOptions
) =
FileSpec.scriptBuilder(propertyName, env.namespace)
.addCode(
buildCodeBlock {
addStatement("internal val %L = listOf<%L.PaparazziPreviewData>(", propertyName, PACKAGE_NAME)
indent()

if (functions.count() == 0) {
addEmpty()
} else {
functions.process { func, preview, previewParam ->
val visibilityCheck = checkVisibility(func, previewParam)
val snapshotName = func.snapshotName(env)

when {
visibilityCheck.isPrivate -> addError(
visibilityCheck = visibilityCheck,
function = func,
snapshotName = snapshotName,
preview = preview,
previewParam = previewParam
)
previewParam != null -> addProvider(
function = func,
snapshotName = snapshotName,
preview = preview,
previewParam = previewParam
)
else -> addDefault(
function = func,
snapshotName = snapshotName,
preview = preview
)
}
}
}

unindent()
addStatement(")")
}
)
.build()

private fun CodeBlock.Builder.addEmpty() {
addStatement("%L.PaparazziPreviewData.Empty,", PACKAGE_NAME)
}

private fun Sequence<KSFunctionDeclaration>.process(
block: (KSFunctionDeclaration, PreviewModel, KSValueParameter?) -> Unit
) =
flatMap { func ->
val previewParam = func.previewParam()
func.findDistinctPreviews()
.map { Triple(func, it, previewParam) }
}.forEach { (func, preview, previewParam) ->
block(func, preview, previewParam)
}

private fun CodeBlock.Builder.addError(
visibilityCheck: VisibilityCheck,
function: KSFunctionDeclaration,
snapshotName: String,
preview: PreviewModel,
previewParam: KSValueParameter?
) {
val qualifiedName = if (visibilityCheck.isFunctionPrivate) {
function.qualifiedName?.asString()
} else {
previewParam?.previewParamProvider()?.qualifiedName?.asString()
}

addStatement("%L.PaparazziPreviewData.Error(", PACKAGE_NAME)
indent()
addStatement("snapshotName = %S,", snapshotName)
addStatement("message = %S,", "$qualifiedName is private. Make it internal or public to generate a snapshot.")
addPreviewData(preview)
unindent()
addStatement("),")
}

private fun CodeBlock.Builder.addProvider(
function: KSFunctionDeclaration,
snapshotName: String,
preview: PreviewModel,
previewParam: KSValueParameter
) {
addStatement("%L.PaparazziPreviewData.Provider(", PACKAGE_NAME)
indent()
addStatement("snapshotName = %S,", snapshotName)
addStatement("composable = { %L(it) },", function.qualifiedName?.asString())
addPreviewParameterData(previewParam)
addPreviewData(preview)
unindent()
addStatement("),")
}

private fun CodeBlock.Builder.addDefault(
function: KSFunctionDeclaration,
snapshotName: String,
preview: PreviewModel
) {
addStatement("%L.PaparazziPreviewData.Default(", PACKAGE_NAME)
indent()
addStatement("snapshotName = %S,", snapshotName)
addStatement("composable = { %L() },", function.qualifiedName?.asString())
addPreviewData(preview)
unindent()
addStatement("),")
}

private fun CodeBlock.Builder.addPreviewData(preview: PreviewModel) {
addStatement("preview = %L.PreviewData(", PACKAGE_NAME)
indent()

preview.fontScale.takeIf { it != 1f }
?.let { addStatement("fontScale = %Lf,", it) }

preview.device.takeIf { it.isNotEmpty() }
?.let { addStatement("device = %S,", it) }

preview.widthDp.takeIf { it > -1 }
?.let { addStatement("widthDp = %L,", it) }

preview.heightDp.takeIf { it > -1 }
?.let { addStatement("heightDp = %L,", it) }

preview.uiMode.takeIf { it != 0 }
?.let { addStatement("uiMode = %L,", it) }

preview.locale.takeIf { it.isNotEmpty() }
?.let { addStatement("locale = %S,", it) }

preview.backgroundColor.takeIf { it != 0L && preview.showBackground }
?.let { addStatement("backgroundColor = %S", it.toString(16)) }

unindent()
addStatement("),")
}

private fun CodeBlock.Builder.addPreviewParameterData(previewParam: KSValueParameter) {
addStatement("previewParameter = %L.PreviewParameterData(", PACKAGE_NAME)
indent()
addStatement("name = %S,", previewParam.name?.asString())
addStatement("values = %L().values,", previewParam.previewParamProvider().qualifiedName?.asString())
unindent()
addStatement("),")
}

private fun KSFunctionDeclaration.snapshotName(env: EnvironmentOptions) =
buildList {
containingFile
?.let { "${it.packageName.asString()}.${it.fileName.removeSuffix(".kt")}" }
?.removePrefix("${env.namespace}.")
?.replace(".", "_")
?.let { add(it) }
add(simpleName.asString())
}.joinToString("_")

private fun checkVisibility(
function: KSFunctionDeclaration,
previewParam: KSValueParameter?
) = VisibilityCheck(
isFunctionPrivate = function.getVisibility() == Visibility.PRIVATE,
isPreviewParamProviderPrivate = previewParam?.previewParamProvider()?.getVisibility() == Visibility.PRIVATE
)
}

internal data class VisibilityCheck(
val isFunctionPrivate: Boolean,
val isPreviewParamProviderPrivate: Boolean
) {
val isPrivate = isFunctionPrivate || isPreviewParamProviderPrivate
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package app.cash.paparazzi.preview.processor

import com.google.devtools.ksp.processing.Dependencies
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
import com.google.devtools.ksp.processing.SymbolProcessorProvider
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.validate
import com.squareup.kotlinpoet.ksp.writeTo
import java.io.File

public class PreviewProcessorProvider : SymbolProcessorProvider {
override fun create(
Expand All @@ -15,5 +19,47 @@ public class PreviewProcessorProvider : SymbolProcessorProvider {
public class PreviewProcessor(
private val environment: SymbolProcessorEnvironment
) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> = emptyList()

private var invoked = false

override fun process(resolver: Resolver): List<KSAnnotated> {
if (invoked) return emptyList()
invoked = true

val allFiles = resolver.getAllFiles().toList()
if (allFiles.isEmpty()) return emptyList()

val env = EnvironmentOptions(
namespace = environment.options["app.cash.paparazzi.preview.namespace"]!!
)

val dependencies = Dependencies(true, *allFiles.toTypedArray())
val isTestSourceSet = env.discoverVariant(dependencies).endsWith("UnitTest")

return resolver.getSymbolsWithAnnotation("androidx.compose.runtime.Composable")
.findPaparazzi()
.also { functions ->
"found ${functions.count()} function(s)".log()
PaparazziPoet.buildFiles(functions, isTestSourceSet, env).forEach { file ->
"writing file: ${file.packageName}.${file.name}".log()
file.writeTo(environment.codeGenerator, dependencies)
}
}
.filterNot { it.validate() }
.toList()
}

private fun EnvironmentOptions.discoverVariant(dependencies: Dependencies): String {
environment.codeGenerator.createNewFile(dependencies, namespace, "paparazziVariant", "txt")
val file = environment.codeGenerator.generatedFile.first()
val fileSeparator = Regex.escape(File.separator)
val variantNameRegex = Regex("ksp$fileSeparator(.+)${fileSeparator}resources")
return (variantNameRegex.find(file.absolutePath)?.groups?.get(1)?.value ?: "")
.also {
it.log()
file.writeText(it)
}
}

private fun String.log() = environment.logger.info("PaparazziProcessor - $this")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package app.cash.paparazzi.preview.processor

import com.google.devtools.ksp.symbol.FunctionKind.TOP_LEVEL
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSAnnotation
import com.google.devtools.ksp.symbol.KSFunctionDeclaration
import com.google.devtools.ksp.symbol.KSType
import com.google.devtools.ksp.symbol.KSValueParameter

internal const val PACKAGE_NAME = "app.cash.paparazzi.annotations"

internal fun KSAnnotation.isPaparazzi() = qualifiedName() == "app.cash.paparazzi.annotations.Paparazzi"
internal fun KSAnnotation.isPreview() = qualifiedName() == "androidx.compose.ui.tooling.preview.Preview"
internal fun KSAnnotation.isPreviewParameter() = qualifiedName() == "androidx.compose.ui.tooling.preview.PreviewParameter"

internal fun KSAnnotation.qualifiedName() = declaration().qualifiedName?.asString() ?: ""
internal fun KSAnnotation.declaration() = annotationType.resolve().declaration

@Suppress("UNCHECKED_CAST")
internal fun <T> KSAnnotation.previewArg(name: String): T = arguments
.first { it.name?.asString() == name }
.let { it.value as T }

internal fun Sequence<KSAnnotated>.findPaparazzi() =
filterIsInstance<KSFunctionDeclaration>()
.filter {
it.annotations.hasPaparazzi() &&
it.functionKind == TOP_LEVEL
}

internal fun Sequence<KSAnnotation>.hasPaparazzi() = filter { it.isPaparazzi() }.count() > 0

/**
* when the same annotations are applied higher in the tree, an endless recursive lookup can occur.
* using a stack to keep to a record of each symbol lets us break when we hit one we've already encountered
*/
internal fun Sequence<KSAnnotation>.findPreviews(stack: Set<KSAnnotation> = setOf()): Sequence<KSAnnotation> {
val direct = filter { it.isPreview() }
val indirect = filterNot { it.isPreview() || stack.contains(it) }
.map { it.declaration().annotations.findPreviews(stack.plus(it)) }
.flatten()
return direct.plus(indirect)
}

internal fun KSFunctionDeclaration.findDistinctPreviews() = annotations.findPreviews().toList()
.map { preview ->
PreviewModel(
fontScale = preview.previewArg("fontScale"),
device = preview.previewArg("device"),
widthDp = preview.previewArg("widthDp"),
heightDp = preview.previewArg("heightDp"),
uiMode = preview.previewArg("uiMode"),
locale = preview.previewArg("locale"),
backgroundColor = preview.previewArg("backgroundColor"),
showBackground = preview.previewArg("showBackground")
)
}
.distinct()

internal fun KSFunctionDeclaration.previewParam() = parameters.firstOrNull { param ->
param.annotations.any { it.isPreviewParameter() }
}

internal fun KSValueParameter.previewParamProvider() = annotations
.first { it.isPreviewParameter() }
.arguments
.first { arg -> arg.name?.asString() == "provider" }
.let { it.value as KSType }
.declaration

internal data class PreviewModel(
val fontScale: Float,
val device: String,
val widthDp: Int,
val heightDp: Int,
val uiMode: Int,
val locale: String,
val backgroundColor: Long,
val showBackground: Boolean
)

internal data class EnvironmentOptions(
val namespace: String
)

0 comments on commit 7638efa

Please sign in to comment.