Repository: cashapp/redwood
Pull request #797 (2023-02-07 02:41:24)

Embed representation of schema as JSON in jar
This will allow us to perform codegen without needing to parse anything. This will become essential in the future when we switch to AST-based parsing.

Note, nothing is actually switched yet. This just generates the JSON as a first step.

<details>
<summary>Sample of the <code>test-schema</code>'s JSON</summary>
<p>

```json
{
  "version": 1,
  "type": {
    "names": [
      "example.redwood",
      "ExampleSchema"
    ]
  },
  "scopes": [
    {
      "names": [
        "example.redwood",
        "RowScope"
      ]
    },
    {
      "names": [
        "example.redwood",
        "TestScope"
      ]
    },
    {
      "names": [
        "example.redwood",
        "SecondaryTestScope"
      ]
    }
  ],
  "widgets": [
    {
      "type": {
        "names": [
          "example.redwood",
          "Row"
        ]
      },
      "traits": [
        {
          "kind": "children",
          "tag": 1,
          "name": "children"
        }
      ]
    },
    {
      "type": {
        "names": [
          "example.redwood",
          "ScopedRow"
        ]
      },
      "traits": [
        {
          "kind": "children",
          "tag": 1,
          "name": "children",
          "scope": {
            "names": [
              "example.redwood",
              "RowScope"
            ]
          }
        }
      ]
    },
    {
      "type": {
        "names": [
          "example.redwood",
          "Text"
        ]
      },
      "traits": [
        {
          "kind": "property",
          "tag": 1,
          "name": "text",
          "type": {
            "names": [
              "kotlin",
              "String"
            ],
            "nullable": true
          }
        }
      ]
    },
    {
      "type": {
        "names": [
          "example.redwood",
          "Button"
        ]
      },
      "traits": [
        {
          "kind": "property",
          "tag": 1,
          "name": "text",
          "type": {
            "names": [
              "kotlin",
              "String"
            ],
            "nullable": true
          }
        },
        {
          "kind": "event",
          "tag": 2,
          "name": "onClick"
        }
      ]
    },
    {
      "type": {
        "names": [
          "example.redwood",
          "TextInput"
        ]
      },
      "traits": [
        {
          "kind": "property",
          "tag": 1,
          "name": "text",
          "type": {
            "names": [
              "kotlin",
              "String"
            ],
            "nullable": true
          }
        },
        {
          "kind": "property",
          "tag": 2,
          "name": "customType",
          "type": {
            "names": [
              "kotlin.time",
              "Duration"
            ],
            "nullable": true
          }
        },
        {
          "kind": "event",
          "tag": 3,
          "name": "onChange",
          "parameterType": {
            "names": [
              "kotlin",
              "String"
            ]
          }
        },
        {
          "kind": "event",
          "tag": 4,
          "name": "onChangeCustomType",
          "parameterType": {
            "names": [
              "kotlin.time",
              "Duration"
            ]
          }
        }
      ]
    },
    {
      "type": {
        "names": [
          "example.redwood",
          "Space"
        ]
      }
    }
  ],
  "layoutModifiers": [
    {
      "tag": 1,
      "type": {
        "names": [
          "example.redwood",
          "RowVerticalAlignment"
        ]
      },
      "scopes": [
        {
          "names": [
            "example.redwood",
            "RowScope"
          ]
        }
      ],
      "properties": [
        {
          "name": "direction",
          "type": {
            "names": [
              "kotlin",
              "Int"
            ]
          }
        }
      ]
    },
    {
      "tag": 2,
      "type": {
        "names": [
          "example.redwood",
          "AccessibilityDescription"
        ]
      },
      "scopes": [
        {
          "names": [
            "example.redwood",
            "TestScope"
          ]
        }
      ],
      "properties": [
        {
          "name": "value",
          "type": {
            "names": [
              "kotlin",
              "String"
            ]
          }
        }
      ]
    },
    {
      "tag": 3,
      "type": {
        "names": [
          "example.redwood",
          "CustomType"
        ]
      },
      "scopes": [
        {
          "names": [
            "example.redwood",
            "TestScope"
          ]
        }
      ],
      "properties": [
        {
          "name": "customType",
          "type": {
            "names": [
              "kotlin.time",
              "Duration"
            ]
          }
        }
      ]
    },
    {
      "tag": 4,
      "type": {
        "names": [
          "example.redwood",
          "CustomTypeStateless"
        ]
      },
      "scopes": [
        {
          "names": [
            "example.redwood",
            "TestScope"
          ]
        }
      ]
    },
    {
      "tag": 5,
      "type": {
        "names": [
          "example.redwood",
          "CustomTypeWithDefault"
        ]
      },
      "scopes": [
        {
          "names": [
            "example.redwood",
            "TestScope"
          ]
        }
      ],
      "properties": [
        {
          "name": "customType",
          "type": {
            "names": [
              "kotlin.time",
              "Duration"
            ]
          }
        },
        {
          "name": "string",
          "type": {
            "names": [
              "kotlin",
              "String"
            ]
          },
          "defaultExpression": "\"sup\""
        }
      ]
    },
    {
      "tag": 6,
      "type": {
        "names": [
          "example.redwood",
          "CustomTypeWithMultipleScopes"
        ]
      },
      "scopes": [
        {
          "names": [
            "example.redwood",
            "TestScope"
          ]
        },
        {
          "names": [
            "example.redwood",
            "SecondaryTestScope"
          ]
        }
      ]
    }
  ],
  "dependencies": [
    {
      "names": [
        "app.cash.redwood.layout",
        "RedwoodLayout"
      ]
    }
  ]
}
```
</details>

Refs #19.


Patch:
diff --git a/redwood-cli/src/main/kotlin/app/cash/redwood/cli/GenerateCommand.kt b/redwood-cli/src/main/kotlin/app/cash/redwood/cli/GenerateCommand.kt
index d2348546a..5c119ff33 100644
--- a/redwood-cli/src/main/kotlin/app/cash/redwood/cli/GenerateCommand.kt
+++ b/redwood-cli/src/main/kotlin/app/cash/redwood/cli/GenerateCommand.kt
@@ -37,6 +37,7 @@ internal class GenerateCommand : CliktCommand(name = "generate") {
     .switch(
       "--compose" to CodegenType.Compose,
       "--compose-protocol" to ProtocolCodegenType.Compose,
+      "--json" to ProtocolCodegenType.Json,
       "--layout-modifiers" to CodegenType.LayoutModifiers,
       "--testing" to CodegenType.Testing,
       "--widget" to CodegenType.Widget,
diff --git a/redwood-gradle-plugin/build.gradle b/redwood-gradle-plugin/build.gradle
index 5f6fc5f17..2558dab50 100644
--- a/redwood-gradle-plugin/build.gradle
+++ b/redwood-gradle-plugin/build.gradle
@@ -36,6 +36,12 @@ gradlePlugin {
       description = "Redwood lint Gradle plugin"
       implementationClass = "app.cash.redwood.gradle.RedwoodLintPlugin"
     }
+    redwoodSchema {
+      id = "app.cash.redwood.schema"
+      displayName = "Redwood schema"
+      description = "Redwood Schema Gradle plugin"
+      implementationClass = "app.cash.redwood.gradle.RedwoodSchemaPlugin"
+    }
     redwoodComposeGenerator {
       id = "app.cash.redwood.generator.compose"
       displayName = "Redwood generator (Compose)"
diff --git a/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodGeneratorExtension.kt b/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodGeneratorExtension.kt
index 9a2632cab..9c16a959b 100644
--- a/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodGeneratorExtension.kt
+++ b/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodGeneratorExtension.kt
@@ -18,11 +18,10 @@ package app.cash.redwood.gradle
 import org.gradle.api.artifacts.dsl.DependencyHandler
 import org.gradle.api.provider.Property
 
-public abstract class RedwoodGeneratorExtension {
+public abstract class RedwoodGeneratorExtension : RedwoodSchemaExtension() {
   /**
    * Reference to the project or dependency which contains the Redwood schema.
    * This value must be a type supported by [DependencyHandler].
    */
   public abstract val source: Property<Any>
-  public abstract val type: Property<String>
 }
diff --git a/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodSchemaExtension.kt b/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodSchemaExtension.kt
new file mode 100644
index 000000000..e5a352d37
--- /dev/null
+++ b/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodSchemaExtension.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2023 Square, Inc.
+ *
+ * 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://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 app.cash.redwood.gradle
+
+import org.gradle.api.provider.Property
+
+public abstract class RedwoodSchemaExtension {
+  /** The fully-qualified name of the `@Schema`-annotated interface. */
+  public abstract val type: Property<String>
+}
diff --git a/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodSchemaPlugin.kt b/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodSchemaPlugin.kt
new file mode 100644
index 000000000..740070f48
--- /dev/null
+++ b/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodSchemaPlugin.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2023 Square, Inc.
+ *
+ * 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://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 app.cash.redwood.gradle
+
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.jvm.tasks.Jar
+import org.gradle.language.base.plugins.LifecycleBasePlugin
+import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
+import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation.Companion.MAIN_COMPILATION_NAME
+
+public class RedwoodSchemaPlugin : Plugin<Project> {
+  override fun apply(project: Project) {
+    val extension = project.extensions.create(
+      "redwoodSchema",
+      RedwoodSchemaExtension::class.java,
+    )
+
+    var applied = false
+    project.plugins.withId("org.jetbrains.kotlin.jvm") {
+      applied = true
+      applyToProject(project, extension)
+    }
+    project.afterEvaluate {
+      check(applied) {
+        "Redwood schema plugin requires 'org.jetbrains.kotlin.jvm' plugin."
+      }
+    }
+  }
+
+  private fun applyToProject(project: Project, extension: RedwoodSchemaExtension) {
+    val cliConfiguration = project.configurations.create("redwoodCli").apply {
+      isCanBeConsumed = false
+      isVisible = false
+    }
+    project.dependencies.add(cliConfiguration.name, project.redwoodDependency("redwood-cli"))
+
+    val kotlin = project.extensions.getByType(KotlinJvmProjectExtension::class.java)
+    val compilation = kotlin.target.compilations.getByName(MAIN_COMPILATION_NAME)
+    val classpath = project.configurations.getByName(compilation.compileDependencyConfigurationName)
+
+    val generate = project.tasks.register("redwoodGenerate", RedwoodGeneratorTask::class.java) {
+      it.group = LifecycleBasePlugin.BUILD_GROUP
+      it.description = "Generate parsed schema JSON"
+
+      it.toolClasspath.from(cliConfiguration)
+      it.outputDir.set(project.layout.buildDirectory.dir("generated/redwood"))
+      it.generatorFlag.set("--json")
+      it.schemaType.set(extension.type)
+      it.classpath.from(classpath, compilation.output.classesDirs)
+    }
+
+    project.tasks.named("jar", Jar::class.java).configure {
+      it.from(generate)
+    }
+
+    compilation.defaultSourceSet.dependencies {
+      api(project.redwoodDependency("redwood-schema"))
+    }
+  }
+}
diff --git a/redwood-layout-schema/build.gradle b/redwood-layout-schema/build.gradle
index b79fbea88..2842a0808 100644
--- a/redwood-layout-schema/build.gradle
+++ b/redwood-layout-schema/build.gradle
@@ -1,8 +1,12 @@
 apply plugin: 'org.jetbrains.kotlin.jvm'
 apply plugin: 'com.vanniktech.maven.publish'
 apply plugin: 'org.jetbrains.dokka' // Must be applied here for publish plugin.
+apply plugin: 'app.cash.redwood.schema'
 
 dependencies {
-  api projects.redwoodSchema
   api projects.redwoodLayoutApi
 }
+
+redwoodSchema {
+  type = 'app.cash.redwood.layout.RedwoodLayout'
+}
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolCodegen.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolCodegen.kt
index d6748f595..a9e594668 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolCodegen.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolCodegen.kt
@@ -16,12 +16,17 @@
 package app.cash.redwood.tooling.codegen
 
 import app.cash.redwood.tooling.codegen.ProtocolCodegenType.Compose
+import app.cash.redwood.tooling.codegen.ProtocolCodegenType.Json
 import app.cash.redwood.tooling.codegen.ProtocolCodegenType.Widget
 import app.cash.redwood.tooling.schema.ProtocolSchema
+import app.cash.redwood.tooling.schema.toEmbeddedSchema
 import java.nio.file.Path
+import kotlin.io.path.createDirectories
+import kotlin.io.path.writeText
 
 public enum class ProtocolCodegenType {
   Compose,
+  Json,
   Widget,
 }
 
@@ -38,6 +43,12 @@ public fun ProtocolSchema.generate(type: ProtocolCodegenType, destination: Path)
         }
       }
     }
+    Json -> {
+      val embeddedSchema = toEmbeddedSchema()
+      val path = destination.resolve(embeddedSchema.path)
+      path.parent.createDirectories()
+      path.writeText(embeddedSchema.json)
+    }
     Widget -> {
       generateDiffConsumingNodeFactory(this).writeTo(destination)
       for (dependency in allSchemas) {
diff --git a/redwood-tooling-schema/build.gradle b/redwood-tooling-schema/build.gradle
index 00b470b39..1861848ee 100644
--- a/redwood-tooling-schema/build.gradle
+++ b/redwood-tooling-schema/build.gradle
@@ -1,10 +1,11 @@
-apply plugin: 'java-library'
 apply plugin: 'org.jetbrains.kotlin.jvm'
+apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
 apply plugin: 'com.vanniktech.maven.publish'
 
 dependencies {
   api projects.redwoodSchema
   api libs.kotlin.reflect
+  implementation libs.kotlinx.serialization.json
 
   testImplementation libs.junit
   testImplementation libs.truth
diff --git a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaJson.kt b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaJson.kt
new file mode 100644
index 000000000..c4d125d5c
--- /dev/null
+++ b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaJson.kt
@@ -0,0 +1,277 @@
+/*
+ * Copyright (C) 2023 Square, Inc.
+ *
+ * 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://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.
+ */
+@file:OptIn(ExperimentalSerializationApi::class) // Tooling use only.
+
+package app.cash.redwood.tooling.schema
+
+import app.cash.redwood.tooling.schema.FqType.Variance.In
+import app.cash.redwood.tooling.schema.FqType.Variance.Invariant
+import app.cash.redwood.tooling.schema.FqType.Variance.Out
+import app.cash.redwood.tooling.schema.ProtocolWidget.ProtocolChildren
+import app.cash.redwood.tooling.schema.ProtocolWidget.ProtocolEvent
+import app.cash.redwood.tooling.schema.ProtocolWidget.ProtocolProperty
+import kotlin.reflect.KClass
+import kotlin.reflect.KType
+import kotlin.reflect.KTypeProjection
+import kotlin.reflect.KVariance.IN
+import kotlin.reflect.KVariance.INVARIANT
+import kotlin.reflect.KVariance.OUT
+import kotlinx.serialization.EncodeDefault
+import kotlinx.serialization.EncodeDefault.Mode.ALWAYS
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+public data class EmbeddedSchema(
+  val path: String,
+  val json: String,
+)
+
+/**
+ * Convert this schema to JSON which can be embedded inside the schema artifact.
+ * This JSON will be read when the schema is used as a dependency.
+ */
+public fun ProtocolSchema.toEmbeddedSchema(): EmbeddedSchema {
+  val schemaJson = SchemaJsonV1(
+    type = FqType(listOf(`package`, name)),
+    scopes = scopes.map { it.toFqType() },
+    widgets = widgets.map { widget ->
+      WidgetJson(
+        type = widget.type.toFqType(),
+        traits = widget.traits.map { trait ->
+          when (trait) {
+            is ProtocolProperty -> {
+              PropertyJson(
+                tag = trait.tag,
+                name = trait.name,
+                type = trait.type.toFqType(),
+                defaultExpression = trait.defaultExpression,
+              )
+            }
+            is ProtocolEvent -> {
+              EventJson(
+                tag = trait.tag,
+                name = trait.name,
+                parameterType = trait.parameterType?.toFqType(),
+                defaultExpression = trait.defaultExpression,
+              )
+            }
+            is ProtocolChildren -> {
+              ChildrenJson(
+                tag = trait.tag,
+                name = trait.name,
+                scope = trait.scope?.toFqType(),
+              )
+            }
+          }
+        },
+      )
+    },
+    layoutModifiers = layoutModifiers.map { layoutModifier ->
+      LayoutModifierJson(
+        tag = layoutModifier.tag,
+        type = layoutModifier.type.toFqType(),
+        scopes = layoutModifier.scopes.map { it.toFqType() },
+        properties = layoutModifier.properties.map {
+          LayoutModifierPropertyJson(
+            name = it.name,
+            type = it.type.toFqType(),
+            defaultExpression = it.defaultExpression,
+          )
+        },
+      )
+    },
+    dependencies = dependencies.map {
+      FqType(listOf(it.`package`, it.name))
+    },
+  )
+  val json = json.encodeToString(SchemaJsonV1.serializer(), schemaJson)
+
+  return EmbeddedSchema(
+    path = "${`package`.replace('.', '/')}/$name.json",
+    json = json,
+  )
+}
+
+private val json = Json {
+  prettyPrint = true
+  prettyPrintIndent = "\t"
+  classDiscriminator = "kind"
+}
+
+@Serializable
+private data class FqType(
+  /**
+   * The package name followed by one or more simple names. The package may be empty.
+   * A wildcard is represented by an empty package and a single "*" simple name.
+   */
+  val names: List<String>,
+  val variance: Variance = Invariant,
+  val parameterTypes: List<FqType> = emptyList(),
+  val nullable: Boolean = false,
+) {
+  enum class Variance {
+    Invariant,
+
+    @SerialName("in")
+    In,
+
+    @SerialName("out")
+    Out,
+  }
+
+  override fun toString(): String = buildString {
+    when (variance) {
+      Invariant -> Unit // Nothing to do.
+      In -> append("in ")
+      Out -> append("out ")
+    }
+    if (names[0] != "") {
+      append(names[0])
+      append('.')
+    }
+    append(names[1])
+    for (i in 2 until names.size) {
+      append('.')
+      append(names[i])
+    }
+    if (parameterTypes.isNotEmpty()) {
+      append('<')
+      parameterTypes.joinTo(this, separator = ", ")
+      append('>')
+    }
+    if (nullable) {
+      append('?')
+    }
+  }
+}
+
+private fun KClass<*>.toFqType(): FqType {
+  var qualifiedName = qualifiedName ?: throw AssertionError(this)
+  val simpleNames = ArrayDeque<String>()
+
+  var target: Class<*>? = java
+  while (target != null) {
+    target = target.enclosingClass
+
+    val dot = qualifiedName.lastIndexOf('.')
+    if (dot == -1) {
+      if (target != null) throw AssertionError(this) // More enclosing classes than dots.
+      simpleNames.addFirst(qualifiedName)
+      qualifiedName = ""
+    } else {
+      simpleNames.addFirst(qualifiedName.substring(dot + 1))
+      qualifiedName = qualifiedName.substring(0, dot)
+    }
+  }
+
+  simpleNames.addFirst(qualifiedName)
+
+  return FqType(simpleNames)
+}
+
+private fun KType.toFqType(): FqType {
+  val kClass = classifier as? KClass<*>
+    ?: throw IllegalArgumentException("$this classifier must be a class")
+
+  return kClass.toFqType().copy(
+    parameterTypes = arguments.map { it.toFqType() },
+    nullable = isMarkedNullable,
+  )
+}
+
+private fun KTypeProjection.toFqType(): FqType {
+  val json = type?.toFqType() ?: FqType(names = listOf("*"))
+  return when (variance) {
+    null, INVARIANT -> json
+    IN -> json.copy(variance = In)
+    OUT -> json.copy(variance = Out)
+  }
+}
+
+@Serializable
+private data class SchemaJsonV1(
+  /** The format version of this JSON. */
+  @EncodeDefault(ALWAYS)
+  val version: Int = 1,
+  val type: FqType,
+  val scopes: List<FqType> = emptyList(),
+  val widgets: List<WidgetJson> = emptyList(),
+  val layoutModifiers: List<LayoutModifierJson> = emptyList(),
+  val dependencies: List<FqType> = emptyList(),
+) {
+  init {
+    require(version == 1) {
+      "Only version 1 is supported"
+    }
+  }
+}
+
+@Serializable
+private data class WidgetJson(
+  val type: FqType,
+  val traits: List<TraitJson> = emptyList(),
+)
+
+@Serializable
+private sealed interface TraitJson {
+  val tag: Int?
+  val name: String
+  val defaultExpression: String?
+}
+
+@Serializable
+@SerialName("property")
+private data class PropertyJson(
+  override val tag: Int? = null,
+  override val name: String,
+  val type: FqType,
+  override val defaultExpression: String? = null,
+) : TraitJson
+
+@Serializable
+@SerialName("event")
+private data class EventJson(
+  override val tag: Int? = null,
+  override val name: String,
+  val parameterType: FqType? = null,
+  override val defaultExpression: String? = null,
+) : TraitJson
+
+@Serializable
+@SerialName("children")
+private data class ChildrenJson(
+  override val tag: Int? = null,
+  override val name: String,
+  val scope: FqType? = null,
+  override val defaultExpression: String? = null,
+) : TraitJson
+
+@Serializable
+private data class LayoutModifierJson(
+  val tag: Int? = null,
+  val type: FqType,
+  val scopes: List<FqType>,
+  val properties: List<LayoutModifierPropertyJson> = emptyList(),
+)
+
+@Serializable
+private data class LayoutModifierPropertyJson(
+  val name: String,
+  val type: FqType,
+  val defaultExpression: String? = null,
+)
diff --git a/redwood-treehouse-lazylayout-schema/build.gradle b/redwood-treehouse-lazylayout-schema/build.gradle
index 0db97eba1..b779003e1 100644
--- a/redwood-treehouse-lazylayout-schema/build.gradle
+++ b/redwood-treehouse-lazylayout-schema/build.gradle
@@ -1,8 +1,12 @@
 apply plugin: 'org.jetbrains.kotlin.jvm'
 apply plugin: 'com.vanniktech.maven.publish'
 apply plugin: 'org.jetbrains.dokka' // Must be applied here for publish plugin.
+apply plugin: 'app.cash.redwood.schema'
 
 dependencies {
-  api projects.redwoodSchema
   api projects.redwoodTreehouseLazylayoutApi
 }
+
+redwoodSchema {
+  type = 'app.cash.redwood.treehouse.lazylayout.RedwoodTreehouseLazyLayout'
+}
diff --git a/samples/counter/schema/build.gradle b/samples/counter/schema/build.gradle
index 063c67a88..30c99dd5a 100644
--- a/samples/counter/schema/build.gradle
+++ b/samples/counter/schema/build.gradle
@@ -1,6 +1,10 @@
 apply plugin: 'org.jetbrains.kotlin.jvm'
+apply plugin: 'app.cash.redwood.schema'
 
 dependencies {
-  api projects.redwoodSchema
   api projects.redwoodLayoutSchema
 }
+
+redwoodSchema {
+  type = 'com.example.redwood.counter.Schema'
+}
diff --git a/samples/emoji-search/schema/build.gradle b/samples/emoji-search/schema/build.gradle
index 03f622518..875122257 100644
--- a/samples/emoji-search/schema/build.gradle
+++ b/samples/emoji-search/schema/build.gradle
@@ -1,8 +1,12 @@
 apply plugin: 'org.jetbrains.kotlin.jvm'
+apply plugin: 'app.cash.redwood.schema'
 
 dependencies {
-  api projects.redwoodSchema
   api projects.redwoodLayoutSchema
   api projects.redwoodTreehouseLazylayoutSchema
   api projects.samples.emojiSearch.values
 }
+
+redwoodSchema {
+  type = 'com.example.redwood.emojisearch.EmojiSearch'
+}


Test patch:
diff --git a/test-schema/build.gradle b/test-schema/build.gradle
index 1d47e7540..c82679c0e 100644
--- a/test-schema/build.gradle
+++ b/test-schema/build.gradle
@@ -1,6 +1,10 @@
 apply plugin: 'org.jetbrains.kotlin.jvm'
+apply plugin: 'app.cash.redwood.schema'
 
 dependencies {
-  implementation projects.redwoodSchema
   implementation projects.redwoodLayoutSchema
 }
+
+redwoodSchema {
+  type = 'example.redwood.ExampleSchema'
+}