Skip to content

Commit

Permalink
Closes #916: Support customization flattening into modules (#906)
Browse files Browse the repository at this point in the history
Support a new @DesignModuleClass annotation to declare a class that
holds customizations. These classes can be used to consolidate
customizations into a single parameter in a @DesignComponent function,
as well as nested in another @DesignModuleClass.

When declaring a @DesignModuleClass, use the @DesignProperty and
@DesignVariantProperty annotations instead of the normal @Design and
@DesignVariant properties. This is needed because of a bug in KSP that
prevents annotations from being used with multiple targets. Once fixed,
we can remove @DesignProperty and @DesignVariantProperty.

A @DesignModuleClass parameter can be specified in a @DesignComponent
function with the @DesignModule annotation. A @DesignModuleClass class
property can be specified in a @DesignModuleClass class with the
@DesignModuleProperty annotation. Once the KSP bug is fixed, we can
remove the @DesignModuleProperty annotation.

This change creates a new source file BuildModuleClass.kt to process
@DesignModuleClass classes. Each @DesignModuleClass will generate a new
file associated with that class that has an extension function for the
class. The extension function creates a CustomizationContext object,
populates customizations for it, and then returns it. Node queries and
ignored images are also tracked so that any @DesignComponent function
that uses modules will generate the appropriate queries() and
ignoredImages() functions.

This change also cleans up some of the code in BuildProcessor.kt and
moves common functionality into a separate file.
  • Loading branch information
rylin8 committed Apr 2, 2024
1 parent a68e77b commit c663535
Show file tree
Hide file tree
Showing 8 changed files with 767 additions and 198 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,40 @@ annotation class DesignComponent(
*
* @param node the name of the Figma node
*/
// TODO adding AnnotationTarget.PROPERTY to the list in @Target doesn't work. The annotation will
// not be found when iterating through annotations of the property due to a KSP bug:
// https://github.com/google/ksp/issues/1812. Once this is fixed, add AnnotationTarget.PROPERTY to
// this list so that @Design can be used in @DesignModuleClass class properties instead of
// @DesignProperty
@Target(AnnotationTarget.VALUE_PARAMETER) annotation class Design(val node: String)

/**
* Specify a node customization property within a @DesignModule class.
*
* @param node the name of the Figma node
*/
// TODO remove this once the KSP bug is fixed. This is only because when @Design annotations for
// class properties cannot be found due to a bug in KSP.
@Target(AnnotationTarget.PROPERTY) annotation class DesignProperty(val node: String)

/**
* Specify a variant name for a component set that contains variant children.
*
* @param property the name of the variant property
*/
// TODO add AnnotationTarget.PROPERTY to the list of targets when the KSP bug is fixed
@Target(AnnotationTarget.VALUE_PARAMETER) annotation class DesignVariant(val property: String)

/**
* Specify a variant name for a component set that contains variant children. This annotation should
* be used for @DesignModule class properties only
*
* @param property the name of the variant property
*/
// TODO remove this once the KSP bug is fixed. This is only needed because @DesignVariant
// annotations for class properties cannot be found due to a bug in KSP
@Target(AnnotationTarget.PROPERTY) annotation class DesignVariantProperty(val property: String)

/**
* An optional annotation that goes with a @Design annotation of type @Composable() -> Unit, which
* is used to replace the children of this frame with new data. Adding the @DesignContentTypes
Expand Down Expand Up @@ -108,3 +133,26 @@ enum class DesignMetaKey {
*/
@Target(AnnotationTarget.FUNCTION)
annotation class DesignKeyAction(val key: Char, val metaKeys: Array<DesignMetaKey>)

/**
* Generates a customizations() extension function for the class specified for this annotation. This
* function returns a CustomizationContext that contains customizations for all the DesignCompose
* annotated properties of the class. This class can be used in other classes and interfaces that
* want to reuse these customizations without declaring them again.
*/
@Target(AnnotationTarget.CLASS) annotation class DesignModuleClass

/**
* Add a module object as a parameter to a @DesignComponent function. All customizations within the
* module object will be added to the function.
*/
// TODO add the annotation target PROPERTY to this once the KSP bug is fixed.
@Target(AnnotationTarget.VALUE_PARAMETER) annotation class DesignModule

/**
* Add a module object as a property of another @DesignModuleClass object. All customizations within
* the module object will be added to the containing class's customizations.
*/
// TODO remove this once the KSP bug is fixed. This is only because when @DesignModule annotations
// for class properties cannot be found due to a bug in KSP.
@Target(AnnotationTarget.PROPERTY) annotation class DesignModuleProperty
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
/*
* Copyright 2023 Google LLC
*
* 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:https://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 com.android.designcompose.codegen

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.symbol.ClassKind
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSPropertyDeclaration
import com.google.devtools.ksp.symbol.KSVisitorVoid
import java.io.OutputStream

// Helper class that keeps track of query node names and ignored image node names from properties of
// @DesignModuleClass classes. This class stores hashes of these node names indexed by class name
// so that @DesignComponent functions that use @DesignModule objects can generate the proper
// queries() and ignoredImages() functions by querying this class.
class ModuleNodeNameTable {
private val queries: HashMap<String, HashSet<String>> = HashMap()
private val ignoredImages: HashMap<String, HashSet<String>> = HashMap()
// Maps a module class name to a set of module class names that it references.
private val nestedModules: HashMap<String, HashSet<String>> = HashMap()

// Return a set of node queries for the module named className. This includes any queries from
// nested modules as well.
fun getNodeQueries(className: String): HashSet<String> {
val allQueries = HashSet<String>()
// Call function to recursively add queries
addModuleNodeQueries(className, allQueries)
return allQueries
}

// Add node queries associated with className, then recurse on any classes that className
// references in its properties.
private fun addModuleNodeQueries(className: String, allQueries: HashSet<String>) {
// Add queries for the specified class
val classQueries = queries[className]
classQueries?.let { allQueries.addAll(it) }
// Recurse on any modules that className references
nestedModules[className]?.forEach { addModuleNodeQueries(it, allQueries) }
}

// Return a set of ignored image node names for the module named className. This includes any
// node names from nested modules as well.
fun getIgnoredImages(className: String): HashSet<String>? {
val allIgnored = HashSet<String>()
// Call function to recursively add ignored image nodes
addModuleIgnoredImages(className, allIgnored)
return allIgnored
}

// Add ignored node names associated with className, then recurse on any classes that className
// references in its properties.
private fun addModuleIgnoredImages(className: String, allIgnored: HashSet<String>) {
// Add ignored images for the specified class
val classIgnored = ignoredImages[className]
classIgnored?.let { allIgnored.addAll(it) }
// Recurse on any modules that className references
nestedModules[className]?.forEach { addModuleIgnoredImages(it, allIgnored) }
}

internal fun addNodeToQueries(className: String, nodeName: String) {
if (!queries.containsKey(className)) queries[className] = HashSet()
queries[className]?.add(nodeName)
}

internal fun addIgnoredImage(className: String, nodeName: String) {
if (!ignoredImages.containsKey(className)) ignoredImages[className] = HashSet()
ignoredImages[className]?.add(nodeName)
}

internal fun addNestedModule(className: String, moduleName: String, logger: KSPLogger) {
if (!nestedModules.containsKey(className)) nestedModules[className] = HashSet()

// Check that there is no recursive nesting of module classes
if (hasRecursiveNesting(className, moduleName)) {
logger.error("Recursive nesting detected between $className, $moduleName")
return
}

nestedModules[className]?.add(moduleName)
}

private fun hasRecursiveNesting(className: String, moduleName: String): Boolean {
if (className == moduleName) return true
val nested = nestedModules[moduleName]
nested?.forEach { if (hasRecursiveNesting(className, it)) return true }
return false
}
}

// For each @DesignModuleClass annotation, create a DesignModuleVisitor class to process the
// annotation and associated class.
internal fun processDesignModulesClasses(
resolver: Resolver,
codeGenerator: CodeGenerator,
logger: KSPLogger
): ModuleNodeNameTable {
val nodeNameTable = ModuleNodeNameTable()

val moduleClasses =
resolver
.getSymbolsWithAnnotation("com.android.designcompose.annotation.DesignModuleClass")
.filterIsInstance<KSClassDeclaration>() // Making sure we take only class declarations.
moduleClasses.forEach {
it.accept(
DesignModuleVisitor(
codeGenerator,
logger,
nodeNameTable,
),
Unit
)
}
return nodeNameTable
}

// This class generates a file for a @DesignModuleClass class and creates an extension function
// for the class that sets up all the customizations used in the class.
private class DesignModuleVisitor(
private val codeGenerator: CodeGenerator,
private val logger: KSPLogger,
private val nodeNameTable: ModuleNodeNameTable,
) : KSVisitorVoid() {
private lateinit var out: OutputStream
private lateinit var className: String
private lateinit var qualifiedclassName: String

override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
className = classDeclaration.simpleName.asString()
qualifiedclassName = classDeclaration.qualifiedName?.asString() ?: return
val packageName = classDeclaration.packageName.asString()

if (classDeclaration.classKind != ClassKind.CLASS) {
logger.error(
"@DesignModuleClass must be used with a class. ${classDeclaration.classKind.type} not supported"
)
return
}

// Create a new file for each @DesignDoc annotation
out =
createNewFile(
codeGenerator,
className,
packageName,
setOf(classDeclaration.containingFile!!),
)

// Create the customizations() extension function and boilerplate code
out += "fun $className.customizations(): CustomizationContext {\n"
out += " val customizations = CustomizationContext()\n"
out += " val variantProperties = HashMap<String, String>()\n"

// Iterate through the properties and generate code to set a customization for each
val properties = classDeclaration.declarations
properties.forEach { property -> property.accept(this, data) }

out += " customizations.setVariantProperties(variantProperties)\n"
out += " return customizations\n"
out += "}\n"

out.close()
}

override fun visitPropertyDeclaration(property: KSPropertyDeclaration, data: Unit) {
// Skip non-annotated properties
if (property.annotations.asIterable().toList().isEmpty()) return

val propertyName = property.simpleName.asString()
val variantProperty = property.getVariantProperty()
variantProperty?.let {
out += " variantProperties[\"$variantProperty\"] = $propertyName.name\n"
// Add any variants encountered to the set of node queries
nodeNameTable.addNodeToQueries(qualifiedclassName, it)
return
}

val customizationType = property.customizationType()

// For modules, add a nested module reference from this module to the property.
// Output a line to merge the customizations into ours
if (customizationType == CustomizationType.Module) {
property.type.resolve().declaration.qualifiedName?.let {
nodeNameTable.addNestedModule(qualifiedclassName, it.asString(), logger)
}
out += " customizations.mergeFrom($propertyName.customizations())\n"
return
}

// Get the node name in the Design annotation. If it can't be found, an annotation that is
// not part of DesignCompose was used, so ignore and return.
val nodeName = property.getAnnotatedNodeName() ?: return

// Add to set of ignored images
if (property.customizationType().shouldIgnoreImage())
nodeNameTable.addIgnoredImage(qualifiedclassName, nodeName)

// Create a line of code to set the customization based on the customization type
when (customizationType) {
CustomizationType.Text ->
out += " customizations.setText(\"$nodeName\", $propertyName)\n"
CustomizationType.TextFunction ->
out += " customizations.setTextFunction(\"$nodeName\", $propertyName)\n"
CustomizationType.Image ->
out += " customizations.setImage(\"$nodeName\", $propertyName)\n"
CustomizationType.Brush ->
out += " customizations.setBrush(\"$nodeName\", $propertyName)\n"
CustomizationType.BrushFunction ->
out += " customizations.setBrushFunction(\"$nodeName\", $propertyName)\n"
CustomizationType.Modifier ->
out += " customizations.setModifier(\"$nodeName\", $propertyName)\n"
CustomizationType.TapCallback ->
out += " customizations.setTapCallback(\"$nodeName\", $propertyName)\n"
CustomizationType.ContentReplacement ->
out += " customizations.setContent(\"$nodeName\", $propertyName)\n"
CustomizationType.ComponentReplacement ->
out += " customizations.setComponent(\"$nodeName\", $propertyName)\n"
CustomizationType.ListContent ->
out += " customizations.setListContent(\"$nodeName\", $propertyName)\n"
CustomizationType.ImageWithContext ->
out += " customizations.setImageWithContext(\"$nodeName\", $propertyName)\n"
CustomizationType.Visibility ->
out += " customizations.setVisible(\"$nodeName\", $propertyName)\n"
CustomizationType.TextStyle ->
out += " customizations.setTextStyle(\"$nodeName\", $propertyName)\n"
CustomizationType.Meter ->
out += " customizations.setMeterValue(\"$nodeName\", $propertyName)\n"
CustomizationType.MeterFunction ->
out += " customizations.setMeterFunction(\"$nodeName\", $propertyName)\n"
else ->
logger.error(
"Invalid parameter type ${property.type.typeString()} property \"$propertyName\""
)
}
}
}
Loading

0 comments on commit c663535

Please sign in to comment.