diff --git a/redwood-schema/src/main/kotlin/app/cash/redwood/schema/annotations.kt b/redwood-schema/src/main/kotlin/app/cash/redwood/schema/annotations.kt index 20ed2887f7..0f8c1ab162 100644 --- a/redwood-schema/src/main/kotlin/app/cash/redwood/schema/annotations.kt +++ b/redwood-schema/src/main/kotlin/app/cash/redwood/schema/annotations.kt @@ -24,7 +24,7 @@ import kotlin.reflect.KClass * Annotates an otherwise unused type with a set of [Widget]-annotated or [Modifier]-annotated * classes which are all part of this schema. * - * ``` + * ```kotlin * @Schema([ * Row::class, * RowAlignment::class, @@ -42,6 +42,44 @@ import kotlin.reflect.KClass public annotation class Schema( val members: Array>, val dependencies: Array = [], + /** + * Widget tags which are reserved. These cannot be used by a widget in [members]. + * This is useful for ensuring tags from old, retired widgets are not accidentally reused. + * + * ```kotlin + * @Schema( + * members = [ + * Row::class, + * Button::class, + * Text::class, + * ], + * reservedWidgets = [ + * 4, // Retired Column widget. + * ], + * ) + * interface MySchema + * ``` + */ + val reservedWidgets: IntArray = [], + /** + * Modifier tags which are reserved. These cannot be used by a modifier in [members]. + * This is useful for ensuring tags from old, retired modifiers are not accidentally reused. + * + * ```kotlin + * @Schema( + * members = [ + * Row::class, + * Button::class, + * Text::class, + * ], + * reservedModifiers = [ + * 3, // Retired RowAlignment modifier. + * ], + * ) + * interface MySchema + * ``` + */ + val reservedModifiers: IntArray = [], ) { @Retention(RUNTIME) @Target // None, use only within @Schema. diff --git a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaParser.kt b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaParser.kt index 7bb8953ff4..3501ff6859 100644 --- a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaParser.kt +++ b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaParser.kt @@ -106,7 +106,7 @@ internal fun parseProtocolSchemaSet(schemaType: KClass<*>): ProtocolSchemaSet { } val widgets = mutableListOf() - val modifier = mutableListOf() + val modifiers = mutableListOf() for (memberType in memberTypes) { val widgetAnnotation = memberType.findAnnotation() val modifierAnnotation = memberType.findAnnotation() @@ -118,7 +118,7 @@ internal fun parseProtocolSchemaSet(schemaType: KClass<*>): ProtocolSchemaSet { } else if (widgetAnnotation != null) { widgets += parseWidget(memberType, widgetAnnotation) } else if (modifierAnnotation != null) { - modifier += parseModifier(memberType, modifierAnnotation) + modifiers += parseModifier(memberType, modifierAnnotation) } else { throw AssertionError() } @@ -137,7 +137,26 @@ internal fun parseProtocolSchemaSet(schemaType: KClass<*>): ProtocolSchemaSet { ) } - val badModifiers = modifier.groupBy(ProtocolModifier::tag).filterValues { it.size > 1 } + val badReservedWidgets = schemaAnnotation.reservedWidgets + .filterNotTo(HashSet(), HashSet()::add) + require(badReservedWidgets.isEmpty()) { + "Schema reserved widgets contains duplicates $badReservedWidgets" + } + + val reservedWidgets = widgets.filter { it.tag in schemaAnnotation.reservedWidgets } + if (reservedWidgets.isNotEmpty()) { + throw IllegalArgumentException( + buildString { + append("Schema @Widget tags must not be included in reserved set ") + appendLine(schemaAnnotation.reservedWidgets.contentToString()) + for (widget in reservedWidgets) { + append("\n- @Widget(${widget.tag}) ${widget.type}") + } + }, + ) + } + + val badModifiers = modifiers.groupBy(ProtocolModifier::tag).filterValues { it.size > 1 } if (badModifiers.isNotEmpty()) { throw IllegalArgumentException( buildString { @@ -150,11 +169,30 @@ internal fun parseProtocolSchemaSet(schemaType: KClass<*>): ProtocolSchemaSet { ) } + val badReservedModifiers = schemaAnnotation.reservedModifiers + .filterNotTo(HashSet(), HashSet()::add) + require(badReservedModifiers.isEmpty()) { + "Schema reserved modifiers contains duplicates $badReservedModifiers" + } + + val reservedModifiers = modifiers.filter { it.tag in schemaAnnotation.reservedModifiers } + if (reservedModifiers.isNotEmpty()) { + throw IllegalArgumentException( + buildString { + append("Schema @Modifier tags must not be included in reserved set ") + appendLine(schemaAnnotation.reservedModifiers.contentToString()) + for (widget in reservedModifiers) { + append("\n- @Modifier(${widget.tag}, …) ${widget.type}") + } + }, + ) + } + val widgetScopes = widgets .flatMap { it.traits } .filterIsInstance() .mapNotNull { it.scope } - val modifierScopes = modifier + val modifierScopes = modifiers .flatMap { it.scopes } val scopes = buildSet { addAll(widgetScopes) @@ -210,7 +248,7 @@ internal fun parseProtocolSchemaSet(schemaType: KClass<*>): ProtocolSchemaSet { type = schemaType.toFqType(), scopes = scopes.toList(), widgets = widgets, - modifiers = modifier, + modifiers = modifiers, taggedDependencies = dependencies.mapValues { (_, schema) -> schema.type }, ) val schemaSet = ParsedProtocolSchemaSet( diff --git a/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaParserTest.kt b/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaParserTest.kt index bb4b9d16f0..c4acac3392 100644 --- a/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaParserTest.kt +++ b/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaParserTest.kt @@ -34,6 +34,7 @@ import assertk.assertions.contains import assertk.assertions.containsExactly import assertk.assertions.containsMatch import assertk.assertions.hasMessage +import assertk.assertions.hasSize import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import assertk.assertions.isFalse @@ -624,6 +625,100 @@ class SchemaParserTest( assertThat(modifier.tag).isEqualTo(1) } + @Schema( + members = [], + reservedWidgets = [1, 2, 3, 1, 3, 1], + ) + interface SchemaDuplicateReservedWidgets + + @Test fun schemaDuplicateReservedWidgetsFails() { + assertFailure { + parser.parse(SchemaDuplicateReservedWidgets::class) + }.isInstanceOf() + .hasMessage("Schema reserved widgets contains duplicates [1, 3]") + } + + @Schema( + members = [], + reservedModifiers = [1, 2, 3, 1, 3, 1], + ) + interface SchemaDuplicateReservedModifiers + + @Test fun schemaDuplicateReservedModifiersFails() { + assertFailure { + parser.parse(SchemaDuplicateReservedModifiers::class) + }.isInstanceOf() + .hasMessage("Schema reserved modifiers contains duplicates [1, 3]") + } + + @Schema( + members = [ReservedWidget::class], + reservedWidgets = [1, 2, 3], + ) + interface SchemaReservedWidgetCollision + + @Widget(2) + object ReservedWidget + + @Test fun schemaReservedWidgetCollision() { + assertFailure { + parser.parse(SchemaReservedWidgetCollision::class) + }.isInstanceOf() + .hasMessage( + """ + |Schema @Widget tags must not be included in reserved set [1, 2, 3] + | + |- @Widget(2) app.cash.redwood.tooling.schema.SchemaParserTest.ReservedWidget + """.trimMargin(), + ) + } + + @Schema( + members = [ReservedModifier::class], + reservedModifiers = [1, 2, 3], + ) + interface SchemaReservedModifierCollision + + @Modifier(2, TestScope::class) + object ReservedModifier + + @Test fun schemaReservedModifierCollision() { + assertFailure { + parser.parse(SchemaReservedModifierCollision::class) + }.isInstanceOf() + .hasMessage( + """ + |Schema @Modifier tags must not be included in reserved set [1, 2, 3] + | + |- @Modifier(2, …) app.cash.redwood.tooling.schema.SchemaParserTest.ReservedModifier + """.trimMargin(), + ) + } + + @Schema( + members = [ReservedModifier::class], + reservedWidgets = [1, 2, 3], + ) + interface SchemaReservedWidgetDoesNotApplyToModifier + + @Test fun schemaReservedWidgetDoesNotApplyToModifier() { + val schema = parser.parse(SchemaReservedWidgetDoesNotApplyToModifier::class).schema + assertThat(schema.widgets).hasSize(0) + assertThat(schema.modifiers).hasSize(1) + } + + @Schema( + members = [ReservedWidget::class], + reservedModifiers = [1, 2, 3], + ) + interface SchemaReservedModifierDoesNotApplyToWidget + + @Test fun schemaReservedModifierDoesNotApplyToWidget() { + val schema = parser.parse(SchemaReservedModifierDoesNotApplyToWidget::class).schema + assertThat(schema.widgets).hasSize(1) + assertThat(schema.modifiers).hasSize(0) + } + @Schema( members = [], dependencies = [