Skip to content

Commit 05f0d2b

Browse files
nak5ivegeoff-powell
authored andcommitted
adding preview processor
1 parent fcd1c50 commit 05f0d2b

File tree

5 files changed

+339
-1
lines changed

5 files changed

+339
-1
lines changed

gradle/libs.versions.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ bytebuddy = "1.14.19"
66
coroutines = "1.8.1"
77
javaTarget = "11"
88
kotlin = "2.0.10"
9+
kotlinPoet = "1.16.0"
910
ksp = "2.0.10-1.0.24"
1011
layoutlib = "14.0.9"
1112
moshi = "1.15.1"
@@ -30,6 +31,8 @@ composeUi-uiTooling = { module = "androidx.compose.ui:ui-tooling" }
3031
guava = { module = "com.google.guava:guava", version = "33.3.0-jre" }
3132

3233
kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" }
34+
kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinPoet" }
35+
kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlinPoet" }
3336
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
3437
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
3538

paparazzi-preview-processor/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,7 @@ apply plugin: 'org.jetbrains.kotlin.jvm'
22
apply plugin: 'com.vanniktech.maven.publish'
33

44
dependencies {
5+
implementation libs.kotlinpoet
6+
implementation libs.kotlinpoet.ksp
57
implementation libs.ksp
68
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package app.cash.paparazzi.preview.processor
2+
3+
import com.google.devtools.ksp.getVisibility
4+
import com.google.devtools.ksp.symbol.KSFunctionDeclaration
5+
import com.google.devtools.ksp.symbol.KSValueParameter
6+
import com.google.devtools.ksp.symbol.Visibility
7+
import com.squareup.kotlinpoet.CodeBlock
8+
import com.squareup.kotlinpoet.FileSpec
9+
import com.squareup.kotlinpoet.buildCodeBlock
10+
11+
internal object PaparazziPoet {
12+
13+
fun buildFiles(
14+
functions: Sequence<KSFunctionDeclaration>,
15+
isTest: Boolean,
16+
env: EnvironmentOptions
17+
) =
18+
if (isTest) {
19+
emptyList()
20+
} else {
21+
listOf(
22+
buildAnnotationsFile("paparazziPreviews", functions, env)
23+
)
24+
}
25+
26+
@Suppress("SameParameterValue")
27+
private fun buildAnnotationsFile(
28+
propertyName: String,
29+
functions: Sequence<KSFunctionDeclaration>,
30+
env: EnvironmentOptions
31+
) =
32+
FileSpec.scriptBuilder(propertyName, env.namespace)
33+
.addCode(
34+
buildCodeBlock {
35+
addStatement("internal val %L = listOf<%L.PaparazziPreviewData>(", propertyName, PACKAGE_NAME)
36+
indent()
37+
38+
if (functions.count() == 0) {
39+
addEmpty()
40+
} else {
41+
functions.process { func, preview, previewParam ->
42+
val visibilityCheck = checkVisibility(func, previewParam)
43+
val snapshotName = func.snapshotName(env)
44+
45+
when {
46+
visibilityCheck.isPrivate -> addError(
47+
visibilityCheck = visibilityCheck,
48+
function = func,
49+
snapshotName = snapshotName,
50+
preview = preview,
51+
previewParam = previewParam
52+
)
53+
previewParam != null -> addProvider(
54+
function = func,
55+
snapshotName = snapshotName,
56+
preview = preview,
57+
previewParam = previewParam
58+
)
59+
else -> addDefault(
60+
function = func,
61+
snapshotName = snapshotName,
62+
preview = preview
63+
)
64+
}
65+
}
66+
}
67+
68+
unindent()
69+
addStatement(")")
70+
}
71+
)
72+
.build()
73+
74+
private fun CodeBlock.Builder.addEmpty() {
75+
addStatement("%L.PaparazziPreviewData.Empty,", PACKAGE_NAME)
76+
}
77+
78+
private fun Sequence<KSFunctionDeclaration>.process(
79+
block: (KSFunctionDeclaration, PreviewModel, KSValueParameter?) -> Unit
80+
) =
81+
flatMap { func ->
82+
val previewParam = func.previewParam()
83+
func.findDistinctPreviews()
84+
.map { Triple(func, it, previewParam) }
85+
}.forEach { (func, preview, previewParam) ->
86+
block(func, preview, previewParam)
87+
}
88+
89+
private fun CodeBlock.Builder.addError(
90+
visibilityCheck: VisibilityCheck,
91+
function: KSFunctionDeclaration,
92+
snapshotName: String,
93+
preview: PreviewModel,
94+
previewParam: KSValueParameter?
95+
) {
96+
val qualifiedName = if (visibilityCheck.isFunctionPrivate) {
97+
function.qualifiedName?.asString()
98+
} else {
99+
previewParam?.previewParamProvider()?.qualifiedName?.asString()
100+
}
101+
102+
addStatement("%L.PaparazziPreviewData.Error(", PACKAGE_NAME)
103+
indent()
104+
addStatement("snapshotName = %S,", snapshotName)
105+
addStatement("message = %S,", "$qualifiedName is private. Make it internal or public to generate a snapshot.")
106+
addPreviewData(preview)
107+
unindent()
108+
addStatement("),")
109+
}
110+
111+
private fun CodeBlock.Builder.addProvider(
112+
function: KSFunctionDeclaration,
113+
snapshotName: String,
114+
preview: PreviewModel,
115+
previewParam: KSValueParameter
116+
) {
117+
addStatement("%L.PaparazziPreviewData.Provider(", PACKAGE_NAME)
118+
indent()
119+
addStatement("snapshotName = %S,", snapshotName)
120+
addStatement("composable = { %L(it) },", function.qualifiedName?.asString())
121+
addPreviewParameterData(previewParam)
122+
addPreviewData(preview)
123+
unindent()
124+
addStatement("),")
125+
}
126+
127+
private fun CodeBlock.Builder.addDefault(
128+
function: KSFunctionDeclaration,
129+
snapshotName: String,
130+
preview: PreviewModel
131+
) {
132+
addStatement("%L.PaparazziPreviewData.Default(", PACKAGE_NAME)
133+
indent()
134+
addStatement("snapshotName = %S,", snapshotName)
135+
addStatement("composable = { %L() },", function.qualifiedName?.asString())
136+
addPreviewData(preview)
137+
unindent()
138+
addStatement("),")
139+
}
140+
141+
private fun CodeBlock.Builder.addPreviewData(preview: PreviewModel) {
142+
addStatement("preview = %L.PreviewData(", PACKAGE_NAME)
143+
indent()
144+
145+
preview.fontScale.takeIf { it != 1f }
146+
?.let { addStatement("fontScale = %Lf,", it) }
147+
148+
preview.device.takeIf { it.isNotEmpty() }
149+
?.let { addStatement("device = %S,", it) }
150+
151+
preview.widthDp.takeIf { it > -1 }
152+
?.let { addStatement("widthDp = %L,", it) }
153+
154+
preview.heightDp.takeIf { it > -1 }
155+
?.let { addStatement("heightDp = %L,", it) }
156+
157+
preview.uiMode.takeIf { it != 0 }
158+
?.let { addStatement("uiMode = %L,", it) }
159+
160+
preview.locale.takeIf { it.isNotEmpty() }
161+
?.let { addStatement("locale = %S,", it) }
162+
163+
preview.backgroundColor.takeIf { it != 0L && preview.showBackground }
164+
?.let { addStatement("backgroundColor = %S", it.toString(16)) }
165+
166+
unindent()
167+
addStatement("),")
168+
}
169+
170+
private fun CodeBlock.Builder.addPreviewParameterData(previewParam: KSValueParameter) {
171+
addStatement("previewParameter = %L.PreviewParameterData(", PACKAGE_NAME)
172+
indent()
173+
addStatement("name = %S,", previewParam.name?.asString())
174+
addStatement("values = %L().values,", previewParam.previewParamProvider().qualifiedName?.asString())
175+
unindent()
176+
addStatement("),")
177+
}
178+
179+
private fun KSFunctionDeclaration.snapshotName(env: EnvironmentOptions) =
180+
buildList {
181+
containingFile
182+
?.let { "${it.packageName.asString()}.${it.fileName.removeSuffix(".kt")}" }
183+
?.removePrefix("${env.namespace}.")
184+
?.replace(".", "_")
185+
?.let { add(it) }
186+
add(simpleName.asString())
187+
}.joinToString("_")
188+
189+
private fun checkVisibility(
190+
function: KSFunctionDeclaration,
191+
previewParam: KSValueParameter?
192+
) = VisibilityCheck(
193+
isFunctionPrivate = function.getVisibility() == Visibility.PRIVATE,
194+
isPreviewParamProviderPrivate = previewParam?.previewParamProvider()?.getVisibility() == Visibility.PRIVATE
195+
)
196+
}
197+
198+
internal data class VisibilityCheck(
199+
val isFunctionPrivate: Boolean,
200+
val isPreviewParamProviderPrivate: Boolean
201+
) {
202+
val isPrivate = isFunctionPrivate || isPreviewParamProviderPrivate
203+
}

paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/PreviewProcessor.kt

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package app.cash.paparazzi.preview.processor
22

3+
import com.google.devtools.ksp.processing.Dependencies
34
import com.google.devtools.ksp.processing.Resolver
45
import com.google.devtools.ksp.processing.SymbolProcessor
56
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
67
import com.google.devtools.ksp.processing.SymbolProcessorProvider
78
import com.google.devtools.ksp.symbol.KSAnnotated
9+
import com.google.devtools.ksp.validate
10+
import com.squareup.kotlinpoet.ksp.writeTo
11+
import java.io.File
812

913
public class PreviewProcessorProvider : SymbolProcessorProvider {
1014
override fun create(
@@ -15,5 +19,47 @@ public class PreviewProcessorProvider : SymbolProcessorProvider {
1519
public class PreviewProcessor(
1620
private val environment: SymbolProcessorEnvironment
1721
) : SymbolProcessor {
18-
override fun process(resolver: Resolver): List<KSAnnotated> = emptyList()
22+
23+
private var invoked = false
24+
25+
override fun process(resolver: Resolver): List<KSAnnotated> {
26+
if (invoked) return emptyList()
27+
invoked = true
28+
29+
val allFiles = resolver.getAllFiles().toList()
30+
if (allFiles.isEmpty()) return emptyList()
31+
32+
val env = EnvironmentOptions(
33+
namespace = environment.options["app.cash.paparazzi.preview.namespace"]!!
34+
)
35+
36+
val dependencies = Dependencies(true, *allFiles.toTypedArray())
37+
val isTestSourceSet = env.discoverVariant(dependencies).endsWith("UnitTest")
38+
39+
return resolver.getSymbolsWithAnnotation("androidx.compose.runtime.Composable")
40+
.findPaparazzi()
41+
.also { functions ->
42+
"found ${functions.count()} function(s)".log()
43+
PaparazziPoet.buildFiles(functions, isTestSourceSet, env).forEach { file ->
44+
"writing file: ${file.packageName}.${file.name}".log()
45+
file.writeTo(environment.codeGenerator, dependencies)
46+
}
47+
}
48+
.filterNot { it.validate() }
49+
.toList()
50+
}
51+
52+
private fun EnvironmentOptions.discoverVariant(dependencies: Dependencies): String {
53+
environment.codeGenerator.createNewFile(dependencies, namespace, "paparazziVariant", "txt")
54+
val file = environment.codeGenerator.generatedFile.first()
55+
val fileSeparator = Regex.escape(File.separator)
56+
val variantNameRegex = Regex("ksp$fileSeparator(.+)${fileSeparator}resources")
57+
return (variantNameRegex.find(file.absolutePath)?.groups?.get(1)?.value ?: "")
58+
.also {
59+
it.log()
60+
file.writeText(it)
61+
}
62+
}
63+
64+
private fun String.log() = environment.logger.info("PaparazziProcessor - $this")
1965
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package app.cash.paparazzi.preview.processor
2+
3+
import com.google.devtools.ksp.symbol.FunctionKind.TOP_LEVEL
4+
import com.google.devtools.ksp.symbol.KSAnnotated
5+
import com.google.devtools.ksp.symbol.KSAnnotation
6+
import com.google.devtools.ksp.symbol.KSFunctionDeclaration
7+
import com.google.devtools.ksp.symbol.KSType
8+
import com.google.devtools.ksp.symbol.KSValueParameter
9+
10+
internal const val PACKAGE_NAME = "app.cash.paparazzi.annotations"
11+
12+
internal fun KSAnnotation.isPaparazzi() = qualifiedName() == "app.cash.paparazzi.annotations.Paparazzi"
13+
internal fun KSAnnotation.isPreview() = qualifiedName() == "androidx.compose.ui.tooling.preview.Preview"
14+
internal fun KSAnnotation.isPreviewParameter() = qualifiedName() == "androidx.compose.ui.tooling.preview.PreviewParameter"
15+
16+
internal fun KSAnnotation.qualifiedName() = declaration().qualifiedName?.asString() ?: ""
17+
internal fun KSAnnotation.declaration() = annotationType.resolve().declaration
18+
19+
@Suppress("UNCHECKED_CAST")
20+
internal fun <T> KSAnnotation.previewArg(name: String): T = arguments
21+
.first { it.name?.asString() == name }
22+
.let { it.value as T }
23+
24+
internal fun Sequence<KSAnnotated>.findPaparazzi() =
25+
filterIsInstance<KSFunctionDeclaration>()
26+
.filter {
27+
it.annotations.hasPaparazzi() &&
28+
it.functionKind == TOP_LEVEL
29+
}
30+
31+
internal fun Sequence<KSAnnotation>.hasPaparazzi() = filter { it.isPaparazzi() }.count() > 0
32+
33+
/**
34+
* when the same annotations are applied higher in the tree, an endless recursive lookup can occur.
35+
* using a stack to keep to a record of each symbol lets us break when we hit one we've already encountered
36+
*/
37+
internal fun Sequence<KSAnnotation>.findPreviews(stack: Set<KSAnnotation> = setOf()): Sequence<KSAnnotation> {
38+
val direct = filter { it.isPreview() }
39+
val indirect = filterNot { it.isPreview() || stack.contains(it) }
40+
.map { it.declaration().annotations.findPreviews(stack.plus(it)) }
41+
.flatten()
42+
return direct.plus(indirect)
43+
}
44+
45+
internal fun KSFunctionDeclaration.findDistinctPreviews() = annotations.findPreviews().toList()
46+
.map { preview ->
47+
PreviewModel(
48+
fontScale = preview.previewArg("fontScale"),
49+
device = preview.previewArg("device"),
50+
widthDp = preview.previewArg("widthDp"),
51+
heightDp = preview.previewArg("heightDp"),
52+
uiMode = preview.previewArg("uiMode"),
53+
locale = preview.previewArg("locale"),
54+
backgroundColor = preview.previewArg("backgroundColor"),
55+
showBackground = preview.previewArg("showBackground")
56+
)
57+
}
58+
.distinct()
59+
60+
internal fun KSFunctionDeclaration.previewParam() = parameters.firstOrNull { param ->
61+
param.annotations.any { it.isPreviewParameter() }
62+
}
63+
64+
internal fun KSValueParameter.previewParamProvider() = annotations
65+
.first { it.isPreviewParameter() }
66+
.arguments
67+
.first { arg -> arg.name?.asString() == "provider" }
68+
.let { it.value as KSType }
69+
.declaration
70+
71+
internal data class PreviewModel(
72+
val fontScale: Float,
73+
val device: String,
74+
val widthDp: Int,
75+
val heightDp: Int,
76+
val uiMode: Int,
77+
val locale: String,
78+
val backgroundColor: Long,
79+
val showBackground: Boolean
80+
)
81+
82+
internal data class EnvironmentOptions(
83+
val namespace: String
84+
)

0 commit comments

Comments
 (0)