diff --git a/packages/mix_generator/CHANGELOG.md b/packages/mix_generator/CHANGELOG.md index 7163c0baf..fbf5b6c57 100644 --- a/packages/mix_generator/CHANGELOG.md +++ b/packages/mix_generator/CHANGELOG.md @@ -11,6 +11,9 @@ `@MixableSpec(target:)` or `@MixWidget(target:)`. Reject a target type parameter named `Key` when it would shadow the generated Flutter key parameter (#1023). + - **FIX**: Allow `@MixableSpec(target:)` to use plain Widget constructors whose + named `style` parameter accepts the generated Styler, without requiring the + target to extend `StyleWidget` (#1022). ## 2.2.0-beta.2 diff --git a/packages/mix_generator/README.md b/packages/mix_generator/README.md index 8d40e62fa..825a7ec29 100644 --- a/packages/mix_generator/README.md +++ b/packages/mix_generator/README.md @@ -32,6 +32,9 @@ The generator emits several surfaces with deliberately different shapes, chosen - **`@MixableSpec`** emits a *rich* mixin (`mixin _$ implements Spec, Diagnosticable`) that fully completes the Spec contract on its own. Specs are immutable value types with no shared concrete behavior to inherit, so the generated mixin can be self-contained — user code only writes `with _$`. - **`@MixableSpec(target: Widget.new)`** also emits a full generated Styler class into the same `.g.dart` part file as the spec mixin. The generated class owns fields, constructors, factories, fluent methods, `call()`, merge, resolve, diagnostics, and props. + The target can be any `Widget` constructor with a named `style` parameter + that accepts the generated Styler; it does not need to extend `StyleWidget`. + A `styleSpec` parameter is supported when it is optional. Direct, uninstantiated generic targets are supported; generated `call()` methods preserve and forward their type parameters and bounds. Instantiated targets such as `Widget.new` and generic constructor tear-offs through typedefs are rejected because their substitutions are not yet supported. A target type parameter named `Key` is also rejected when the constructor forwards Flutter's `key`, because it would shadow the generated `Key? key` parameter. - **`@MixableStyler`** emits a legacy *slim* mixin (`mixin _$Mixin on Style, Diagnosticable`) that fills in per-field plumbing for handwritten styler classes. - **`@Mixable`** emits a *slim* mixin (`mixin _$Mixin on Mix[, DefaultValue][, Diagnosticable]`) for the same reason — Mix subclasses commonly compose intermediate base classes (e.g., `class BoxConstraintsMix extends ConstraintsMix`) and the user keeps that inheritance chain. diff --git a/packages/mix_generator/lib/src/core/builders/spec_styler_class_builder.dart b/packages/mix_generator/lib/src/core/builders/spec_styler_class_builder.dart index 4b6bcaf5b..5a3dce7a5 100644 --- a/packages/mix_generator/lib/src/core/builders/spec_styler_class_builder.dart +++ b/packages/mix_generator/lib/src/core/builders/spec_styler_class_builder.dart @@ -1026,8 +1026,8 @@ class SpecStylerClassBuilder { return buildMixableSpecTargetCall( annotation: annotation, specElement: specElement, - specName: specName, stylerName: stylerName, + allowExactGeneratedStyler: true, hostElement: specElement, hostLibrary: specElement.library, ); diff --git a/packages/mix_generator/lib/src/core/checkers.dart b/packages/mix_generator/lib/src/core/checkers.dart index 6d80ce910..2215fc822 100644 --- a/packages/mix_generator/lib/src/core/checkers.dart +++ b/packages/mix_generator/lib/src/core/checkers.dart @@ -15,11 +15,6 @@ const styleChecker = TypeChecker.fromUrl( 'package:mix/src/core/style.dart#Style', ); -/// `StyleWidget` abstract class from `package:mix`. -const styleWidgetChecker = TypeChecker.fromUrl( - 'package:mix/src/core/style_widget.dart#StyleWidget', -); - /// `StyleSpec` from `package:mix`. const styleSpecChecker = TypeChecker.fromUrl( 'package:mix/src/core/style_spec.dart#StyleSpec', diff --git a/packages/mix_generator/lib/src/core/helpers/widget_call_planner.dart b/packages/mix_generator/lib/src/core/helpers/widget_call_planner.dart index c73fab2c7..30f85df21 100644 --- a/packages/mix_generator/lib/src/core/helpers/widget_call_planner.dart +++ b/packages/mix_generator/lib/src/core/helpers/widget_call_planner.dart @@ -35,8 +35,8 @@ const stylerBackedTargetParams = {'style', 'styleSpec'}; String? buildMixableSpecTargetCall({ required ConstantReader annotation, required InterfaceElement specElement, - required String specName, required String stylerName, + required bool allowExactGeneratedStyler, required Element hostElement, required LibraryElement hostLibrary, bool validateTargetVisibility = false, @@ -74,7 +74,8 @@ String? buildMixableSpecTargetCall({ constructor: constructor, widgetName: widgetName, specElement: specElement, - specName: specName, + stylerName: stylerName, + allowExactGeneratedStyler: allowExactGeneratedStyler, anchor: specElement, ); @@ -87,6 +88,11 @@ String? buildMixableSpecTargetCall({ annotationLabel: '@MixableSpec(target:)', keyOwner: 'the target constructor', ); + final callParams = qualifyTargetMemberDefaults( + call.params, + constructor: constructor, + targetTypeReference: widgetName, + ); if (call.forwardsKey) { for (final typeParameter in constructor.enclosingElement.typeParameters) { if (typeParameter.name != 'Key') continue; @@ -109,13 +115,50 @@ String? buildMixableSpecTargetCall({ return renderWidgetCall( widgetName: widgetName, - params: call.params, + params: callParams, forwardsKey: call.forwardsKey, typeParams: typeParams, indent: indent, ); } +/// Qualifies default-value identifiers that refer to static members of the +/// target class so they remain in scope when copied into generated code. +List qualifyTargetMemberDefaults( + List params, { + required ConstructorElement constructor, + required String targetTypeReference, +}) { + final target = constructor.enclosingElement; + + return [ + for (final param in params) + if (param.defaultValueCode case final code? + when RegExp(r'^[_$A-Za-z][_$A-Za-z0-9]*$').hasMatch(code) && + _isStaticTargetMember(target, code)) + WidgetCallParam( + name: param.name, + typeCode: param.typeCode, + isPositional: param.isPositional, + isRequired: param.isRequired, + defaultValueCode: '$targetTypeReference.$code', + ) + else + param, + ]; +} + +bool _isStaticTargetMember(InterfaceElement target, String name) { + for (final method in target.methods) { + if (method.name == name && method.isStatic) return true; + } + for (final field in target.fields) { + if (field.name == name && field.isStatic) return true; + } + + return false; +} + /// Returns display names of optional positional parameters in declaration /// order, with `` substituted for nameless parameters. List optionalPositionalNames( @@ -192,33 +235,21 @@ void validateMixableSpecTargetConstructor({ required ConstructorElement constructor, required String widgetName, required InterfaceElement specElement, - required String specName, + required String stylerName, + required bool allowExactGeneratedStyler, required Element anchor, }) { - final styleWidgetSupertype = findSupertypeMatching( - constructor.enclosingElement.thisType, - styleWidgetChecker, - ); - if (styleWidgetSupertype == null) { - fail( - anchor, - 'Widget $widgetName must extend StyleWidget<$specName> ' - 'to be used as @MixableSpec(target:).', - ); - } - - final widgetSpecArg = styleWidgetSupertype.typeArguments.first; - if (widgetSpecArg is! InterfaceType || widgetSpecArg.element != specElement) { + final targetType = constructor.enclosingElement.thisType; + if (!widgetChecker.isAssignableFromType(targetType)) { fail( anchor, - 'Spec generic mismatch: $specName annotated, but ' - '$widgetName extends StyleWidget<${widgetSpecArg.getDisplayString()}>.', + '@MixableSpec(target:) must reference a Widget constructor, but ' + '`$widgetName` is not a Widget subtype.', ); } - final optionalPositional = optionalPositionalNames( - constructor.formalParameters, - ); + final parameters = constructor.formalParameters; + final optionalPositional = optionalPositionalNames(parameters); if (optionalPositional.isNotEmpty) { fail( anchor, @@ -229,15 +260,97 @@ void validateMixableSpecTargetConstructor({ ); } - for (final parameter in constructor.formalParameters) { - if (parameter.name == 'style' && parameter.isNamed) return; + final styleParameter = parameters + .where((parameter) => parameter.name == 'style' && parameter.isNamed) + .firstOrNull; + if (styleParameter == null) { + fail( + anchor, + '@MixableSpec(target:) requires $widgetName to expose a named ' + '`style` constructor parameter so the generated call() can pass ' + '`style: this`.', + ); } - fail( - anchor, - '@MixableSpec(target:) requires $widgetName to expose a named ' - '`style` constructor parameter so the generated call() can pass ' - '`style: this`.', + final styleSpecParameter = parameters + .where((parameter) => parameter.name == 'styleSpec') + .firstOrNull; + if (styleSpecParameter != null && styleSpecParameter.isRequired) { + fail( + anchor, + '@MixableSpec(target:) cannot omit required `styleSpec` on $widgetName.', + ); + } + + if (!_targetStyleAcceptsGeneratedStyler( + styleParameter.type, + specElement: specElement, + stylerName: stylerName, + allowExactGeneratedStyler: allowExactGeneratedStyler, + )) { + fail( + anchor, + '@MixableSpec(target:) $widgetName `style` parameter cannot accept ' + 'the generated `$stylerName`.', + ); + } +} + +/// Whether [targetStyleType] can receive the Styler generated for +/// [specElement] in the same build. +/// +/// On a clean build, the generated class is unresolved until the shared part +/// is written. A prior generated part can instead expose it as a resolved +/// interface. Both states are accepted for the exact generated Styler; other +/// resolved types must accept its known `Style` supertype. +/// +/// [allowExactGeneratedStyler] is false for legacy mixins, where `this` is +/// only statically known to satisfy the mixin's `Style` constraint. +bool _targetStyleAcceptsGeneratedStyler( + DartType targetStyleType, { + required InterfaceElement specElement, + required String stylerName, + required bool allowExactGeneratedStyler, +}) { + final specName = specElement.name; + if (specName == null) return false; + + if (targetStyleType is DynamicType || + targetStyleType is InvalidType || + targetStyleType.isDartCoreObject) { + return true; + } + if (targetStyleType is! InterfaceType) return false; + + final targetElement = targetStyleType.element; + final isExactGeneratedStyler = + allowExactGeneratedStyler && + targetElement.name == stylerName && + targetElement.library.uri == specElement.library.uri; + if (isExactGeneratedStyler) return true; + + var acceptedStyle = findSupertypeMatching(targetStyleType, styleChecker); + if (acceptedStyle == null && + targetElement.name == 'Style' && + targetElement.library.uri.toString() == 'package:mix/mix.dart') { + // Lightweight build-test fixtures declare Style directly at the public + // barrel URI. Match that exact identity rather than rendered type text. + acceptedStyle = targetStyleType; + } + if (acceptedStyle == null) return false; + if (acceptedStyle.typeArguments.isEmpty) return false; + + final acceptedSpec = acceptedStyle.typeArguments.first; + final acceptsSpec = + acceptedSpec is InterfaceType && + acceptedSpec.element.name == specName && + acceptedSpec.element.library.uri == specElement.library.uri; + if (!acceptsSpec) return false; + + return specElement.library.typeSystem.isAssignableTo( + acceptedStyle, + targetStyleType, + strictCasts: false, ); } diff --git a/packages/mix_generator/lib/src/mix_widget_generator.dart b/packages/mix_generator/lib/src/mix_widget_generator.dart index 9c7222c4a..f9db55613 100644 --- a/packages/mix_generator/lib/src/mix_widget_generator.dart +++ b/packages/mix_generator/lib/src/mix_widget_generator.dart @@ -147,7 +147,7 @@ class MixWidgetGenerator extends GeneratorForAnnotation { ); final callParams = targetConstructor == null ? call.params - : _qualifyDirectTargetDefaults( + : qualifyTargetMemberDefaults( call.params, constructor: targetConstructor, targetTypeReference: callSource.targetTypeReference!, @@ -237,7 +237,7 @@ class MixWidgetGenerator extends GeneratorForAnnotation { ); final callParams = targetConstructor == null ? call.params - : _qualifyDirectTargetDefaults( + : qualifyTargetMemberDefaults( call.params, constructor: targetConstructor, targetTypeReference: callSource.targetTypeReference!, @@ -292,39 +292,6 @@ class MixWidgetGenerator extends GeneratorForAnnotation { ); } - List _qualifyDirectTargetDefaults( - List params, { - required ConstructorElement constructor, - required String targetTypeReference, - }) { - final target = constructor.enclosingElement; - return [ - for (final param in params) - if (param.defaultValueCode case final code? - when RegExp(r'^[_$A-Za-z][_$A-Za-z0-9]*$').hasMatch(code) && - _isStaticTargetMember(target, code)) - WidgetCallParam( - name: param.name, - typeCode: param.typeCode, - isPositional: param.isPositional, - isRequired: param.isRequired, - defaultValueCode: '$targetTypeReference.$code', - ) - else - param, - ]; - } - - bool _isStaticTargetMember(InterfaceElement target, String name) { - for (final method in target.methods) { - if (method.name == name && method.isStatic) return true; - } - for (final field in target.fields) { - if (field.name == name && field.isStatic) return true; - } - return false; - } - _CallSource _directTargetCallSource({ required Element anchor, required ConstructorElement constructor, @@ -597,7 +564,6 @@ class MixWidgetGenerator extends GeneratorForAnnotation { final specElement = _findGeneratedStylerSpec(library, writtenStylerName); if (specElement == null) return null; - final specName = specElement.name!; final annotationObject = mixableSpecAnnotationChecker.firstAnnotationOf( specElement, ); @@ -605,6 +571,8 @@ class MixWidgetGenerator extends GeneratorForAnnotation { final target = ConstantReader(annotationObject).peek('target'); if (target == null || target.isNull) { + final specName = specElement.name!; + fail( anchor, '$_annotationLabel factory returns the same-build generated styler ' @@ -623,7 +591,8 @@ class MixWidgetGenerator extends GeneratorForAnnotation { constructor: constructor, widgetName: mixableSpecTargetWidgetName(constructor), specElement: specElement, - specName: specName, + stylerName: writtenStylerName, + allowExactGeneratedStyler: true, anchor: anchor, ); diff --git a/packages/mix_generator/lib/src/styler_generator.dart b/packages/mix_generator/lib/src/styler_generator.dart index 018967409..997f10977 100644 --- a/packages/mix_generator/lib/src/styler_generator.dart +++ b/packages/mix_generator/lib/src/styler_generator.dart @@ -85,7 +85,6 @@ class StylerGenerator extends GeneratorForAnnotation { required ClassElement stylerElement, required String stylerName, required InterfaceElement specElement, - required String specName, }) { final specAnnotation = mixableSpecAnnotationChecker.firstAnnotationOf( specElement, @@ -95,8 +94,8 @@ class StylerGenerator extends GeneratorForAnnotation { return buildMixableSpecTargetCall( annotation: ConstantReader(specAnnotation), specElement: specElement, - specName: specName, stylerName: stylerName, + allowExactGeneratedStyler: false, hostElement: stylerElement, hostLibrary: stylerElement.library, validateTargetVisibility: true, @@ -140,7 +139,6 @@ class StylerGenerator extends GeneratorForAnnotation { stylerElement: classElement, stylerName: stylerName, specElement: specType.element, - specName: specName, ); final fields = _extractFields(classElement, stylerName); final config = _extractAnnotationConfig(annotation); diff --git a/packages/mix_generator/test/integration/generator_validation_test.dart b/packages/mix_generator/test/integration/generator_validation_test.dart index 5175ed712..c51def917 100644 --- a/packages/mix_generator/test/integration/generator_validation_test.dart +++ b/packages/mix_generator/test/integration/generator_validation_test.dart @@ -696,6 +696,57 @@ class _Stub extends StatelessWidget { expect(errors, contains('not visible from the annotated library')); }); + test( + 'StylerGenerator rejects targets requiring the concrete legacy Styler', + () async { + const source = r''' +library styler_validation; + +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:mix/src/core/style.dart'; + +part 'styler_validation.g.dart'; + +@MixableSpec(target: PlainWidget.new) +class BoxSpec { + const BoxSpec(); +} + +class PlainWidget extends Widget { + const PlainWidget({required this.style}); + + final BoxStyler style; +} + +@MixableStyler() +class BoxStyler extends Style { + const BoxStyler(); +} +'''; + + final result = await testBuilder( + partBuilder(const StylerGenerator()), + { + ...mixAnnotationsSources, + ...widgetStub, + 'mix|lib/src/core/style.dart': styleStub, + 'mix_generator|lib/styler_validation.dart': source, + }, + generateFor: {'mix_generator|lib/styler_validation.dart'}, + ); + + expect(result.succeeded, isFalse); + expect( + result.errors.join('\n'), + contains( + '@MixableSpec(target:) PlainWidget `style` parameter cannot ' + 'accept the generated `BoxStyler`', + ), + ); + }, + ); + /// This visibility split is only reachable through the legacy /// `@MixableStyler` path because generated specs and their stylers share a /// library. diff --git a/packages/mix_generator/test/integration/spec_styler_generator_smoke_test.dart b/packages/mix_generator/test/integration/spec_styler_generator_smoke_test.dart index 8019cb876..7c6249620 100644 --- a/packages/mix_generator/test/integration/spec_styler_generator_smoke_test.dart +++ b/packages/mix_generator/test/integration/spec_styler_generator_smoke_test.dart @@ -32,6 +32,14 @@ const _styleSpecStub = ''' } '''; +const _canonicalStyleStub = ''' + import 'package:mix/mix.dart' show Spec; + + abstract class Style> { + const Style(); + } +'''; + const _setterTypeMixSources = { 'mix|lib/src/core/mix_element.dart': ''' abstract class Mix { @@ -511,6 +519,11 @@ const _flutterResolveStubs = { final Key? key; } + abstract class StatelessWidget extends Widget { + const StatelessWidget({super.key}); + Widget build(BuildContext context); + } + class BuildContext {} ''', 'flutter|lib/widgets.dart': ''' @@ -1230,7 +1243,95 @@ void main() { expect(errors, contains('must be a constructor tear-off')); }); - test('rejects target widgets that do not extend StyleWidget', () async { + test( + 'supports plain Widget targets with compatible style parameters', + () async { + const input = ''' + library spike; + import 'package:flutter/widgets.dart'; + import 'package:mix/mix.dart'; + import 'package:mix_annotations/mix_annotations.dart'; + part 'spike.g.dart'; + + @MixableSpec(target: PlainWidget.new) + final class BoxSpec extends Spec { + const BoxSpec(); + } + + class PlainWidget extends StatelessWidget { + const PlainWidget({ + super.key, + required this.style, + required this.label, + }); + + final Style style; + final String label; + + @override + Widget build(BuildContext context) => const _Leaf(); + } + + class _Leaf extends Widget { + const _Leaf(); + } + '''; + + await expectGeneratorOutputResolves( + builder: _specStylerPartBuilder(), + sources: { + ...mixAnnotationsSources, + ..._flutterResolveStubs, + ..._mixSourcesWithStyleWidget, + 'mix|lib/spike.dart': input, + }, + inputAsset: 'mix|lib/spike.dart', + outputAsset: 'mix|lib/spike.g.dart', + outputMatcher: allOf( + contains('PlainWidget call({Key? key, required String label})'), + contains( + 'return PlainWidget(key: key, style: this, label: label);', + ), + ), + ); + }, + ); + + test('supports plain Widget targets accepting Object', () async { + const input = ''' + library spike; + import 'package:flutter/widgets.dart'; + import 'package:mix/mix.dart'; + import 'package:mix_annotations/mix_annotations.dart'; + part 'spike.g.dart'; + + @MixableSpec(target: PlainWidget.new) + final class BoxSpec extends Spec { + const BoxSpec(); + } + + class PlainWidget extends Widget { + const PlainWidget({required this.style}); + + final Object style; + } + '''; + + await expectGeneratorOutputResolves( + builder: _specStylerPartBuilder(), + sources: { + ...mixAnnotationsSources, + ..._flutterResolveStubs, + ..._mixSourcesWithStyleWidget, + 'mix|lib/spike.dart': input, + }, + inputAsset: 'mix|lib/spike.dart', + outputAsset: 'mix|lib/spike.g.dart', + outputMatcher: contains('return PlainWidget(style: this);'), + ); + }); + + test('supports targets typed as the same-build generated Styler', () async { const input = ''' library spike; import 'package:flutter/widgets.dart'; @@ -1238,14 +1339,229 @@ void main() { import 'package:mix_annotations/mix_annotations.dart'; part 'spike.g.dart'; + @MixableSpec(target: PlainWidget.new) + final class BoxSpec extends Spec { + const BoxSpec(); + } + + class PlainWidget extends Widget { + const PlainWidget({required this.style}); + + final BoxStyler style; + } + '''; + + await expectGeneratorOutputResolves( + builder: _specStylerPartBuilder(), + sources: { + ...mixAnnotationsSources, + ..._flutterResolveStubs, + ..._mixSourcesWithStyleWidget, + 'mix|lib/spike.dart': input, + }, + inputAsset: 'mix|lib/spike.dart', + outputAsset: 'mix|lib/spike.g.dart', + outputMatcher: contains('return PlainWidget(style: this);'), + ); + }); + + test( + 'supports targets typed as a resolved prior generated Styler', + () async { + // Model a checked-in generated part during input resolution while the + // builder writes its replacement to the normal output asset. + const input = ''' + library spike; + import 'package:flutter/widgets.dart'; + import 'package:mix/mix.dart' hide Style; + import 'package:mix/src/core/style.dart'; + import 'package:mix_annotations/mix_annotations.dart'; + part 'prior.g.dart'; + part 'spike.g.dart'; + + @MixableSpec(target: PlainWidget.new) + final class BoxSpec extends Spec { + const BoxSpec(); + } + class PlainWidget extends Widget { - const PlainWidget({super.key}); + const PlainWidget({required this.style}); + + final BoxStyler style; } + '''; + + const priorOutput = ''' + part of 'spike.dart'; + + final class BoxStyler extends Style { + const BoxStyler(); + } + '''; + + await testBuilder( + _specStylerPartBuilder(), + { + ...mixAnnotationsSources, + ..._flutterResolveStubs, + ..._mixSourcesWithStyleWidget, + 'mix|lib/src/core/style.dart': _canonicalStyleStub, + 'mix|lib/spike.dart': input, + 'mix|lib/prior.g.dart': priorOutput, + }, + generateFor: {'mix|lib/spike.dart'}, + outputs: { + 'mix|lib/spike.g.dart': decodedMatches( + contains('return PlainWidget(style: this);'), + ), + }, + ); + }, + ); + + test( + 'qualifies static target-member defaults in generated calls', + () async { + const input = ''' + library spike; + import 'package:flutter/widgets.dart'; + import 'package:mix/mix.dart'; + import 'package:mix_annotations/mix_annotations.dart'; + part 'spike.g.dart'; + + Widget topLevelTransitionBuilder(Widget child) => child; @MixableSpec(target: PlainWidget.new) final class BoxSpec extends Spec { const BoxSpec(); } + + class PlainWidget extends Widget { + const PlainWidget({ + required this.style, + this.transitionBuilder = defaultTransitionBuilder, + this.fallbackBuilder = topLevelTransitionBuilder, + }); + + static Widget defaultTransitionBuilder(Widget child) => child; + + final Style style; + final Widget Function(Widget) transitionBuilder; + final Widget Function(Widget) fallbackBuilder; + } + '''; + + await expectGeneratorOutputResolves( + builder: _specStylerPartBuilder(), + sources: { + ...mixAnnotationsSources, + ..._flutterResolveStubs, + ..._mixSourcesWithStyleWidget, + 'mix|lib/spike.dart': input, + }, + inputAsset: 'mix|lib/spike.dart', + outputAsset: 'mix|lib/spike.g.dart', + outputMatcher: allOf([ + contains('transitionBuilder ='), + contains('PlainWidget.defaultTransitionBuilder'), + contains('fallbackBuilder = topLevelTransitionBuilder'), + isNot(contains('PlainWidget.topLevelTransitionBuilder')), + ]), + ); + }, + ); + + test('rejects plain Widget targets with required styleSpec', () async { + const input = ''' + library spike; + import 'package:flutter/widgets.dart'; + import 'package:mix/mix.dart'; + import 'package:mix_annotations/mix_annotations.dart'; + part 'spike.g.dart'; + + @MixableSpec(target: PlainWidget.new) + final class BoxSpec extends Spec { + const BoxSpec(); + } + + class PlainWidget extends Widget { + const PlainWidget({ + required this.style, + required this.styleSpec, + }); + + final Style style; + final StyleSpec styleSpec; + } + '''; + + final errors = await _expectSpecStylerValidationError({ + ...mixAnnotationsSources, + ..._flutterResolveStubs, + ..._mixSourcesWithStyleWidget, + 'mix|lib/spike.dart': input, + }); + + expect( + errors, + contains( + '@MixableSpec(target:) cannot omit required `styleSpec` on ' + 'PlainWidget', + ), + ); + }); + + test('rejects target constructors that do not create Widgets', () async { + const input = ''' + library spike; + import 'package:mix/mix.dart'; + import 'package:mix_annotations/mix_annotations.dart'; + part 'spike.g.dart'; + + @MixableSpec(target: PlainTarget.new) + final class BoxSpec extends Spec { + const BoxSpec(); + } + + class PlainTarget { + const PlainTarget({required this.style}); + + final Style style; + } + '''; + + final errors = await _expectSpecStylerValidationError({ + ...mixAnnotationsSources, + ..._flutterResolveStubs, + ..._mixSourcesWithStyleWidget, + 'mix|lib/spike.dart': input, + }); + + expect( + errors, + contains( + '@MixableSpec(target:) must reference a Widget constructor, but ' + '`PlainTarget` is not a Widget subtype', + ), + ); + }); + + test('rejects plain Widget targets without a named style', () async { + const input = ''' + library spike; + import 'package:flutter/widgets.dart'; + import 'package:mix/mix.dart'; + import 'package:mix_annotations/mix_annotations.dart'; + part 'spike.g.dart'; + + @MixableSpec(target: PlainWidget.new) + final class BoxSpec extends Spec { + const BoxSpec(); + } + + class PlainWidget extends Widget { + const PlainWidget(); + } '''; final errors = await _expectSpecStylerValidationError({ @@ -1255,7 +1571,137 @@ void main() { 'mix|lib/spike.dart': input, }); - expect(errors, contains('must extend StyleWidget')); + expect( + errors, + contains( + '@MixableSpec(target:) requires PlainWidget to expose a named ' + '`style` constructor parameter', + ), + ); + }); + + test('rejects plain Widget targets with incompatible style', () async { + const input = ''' + library spike; + import 'package:flutter/widgets.dart'; + import 'package:mix/mix.dart'; + import 'package:mix_annotations/mix_annotations.dart'; + part 'spike.g.dart'; + + final class OtherSpec extends Spec { + const OtherSpec(); + } + + @MixableSpec(target: PlainWidget.new) + final class BoxSpec extends Spec { + const BoxSpec(); + } + + class PlainWidget extends Widget { + const PlainWidget({required this.style}); + + final Style style; + } + '''; + + final errors = await _expectSpecStylerValidationError({ + ...mixAnnotationsSources, + ..._flutterResolveStubs, + ..._mixSourcesWithStyleWidget, + 'mix|lib/spike.dart': input, + }); + + expect( + errors, + contains( + '@MixableSpec(target:) PlainWidget `style` parameter cannot accept ' + 'the generated `BoxStyler`', + ), + ); + }); + + test('rejects unrelated Style types with matching display names', () async { + const input = ''' + library spike; + import 'package:flutter/widgets.dart'; + import 'package:mix/mix.dart' hide Style; + import 'package:mix/unrelated_style.dart'; + import 'package:mix_annotations/mix_annotations.dart'; + part 'spike.g.dart'; + + @MixableSpec(target: PlainWidget.new) + final class BoxSpec extends Spec { + const BoxSpec(); + } + + class PlainWidget extends Widget { + const PlainWidget({required this.style}); + + final Style style; + } + '''; + + final errors = await _expectSpecStylerValidationError({ + ...mixAnnotationsSources, + ..._flutterResolveStubs, + ..._mixSourcesWithStyleWidget, + 'mix|lib/unrelated_style.dart': ''' + final class Style { + const Style(); + } + ''', + 'mix|lib/spike.dart': input, + }); + + expect( + errors, + contains( + '@MixableSpec(target:) PlainWidget `style` parameter cannot accept ' + 'the generated `BoxStyler`', + ), + ); + }); + + test('rejects style subtypes not implemented by the Styler', () async { + const input = ''' + library spike; + import 'package:flutter/widgets.dart'; + import 'package:mix/mix.dart' hide Style; + import 'package:mix/src/core/style.dart'; + import 'package:mix_annotations/mix_annotations.dart'; + part 'spike.g.dart'; + + @MixableSpec(target: PlainWidget.new) + final class BoxSpec extends Spec { + const BoxSpec(); + } + + final class CustomStyle extends Style { + const CustomStyle(); + } + + class PlainWidget extends Widget { + const PlainWidget({required this.style}); + + final CustomStyle style; + } + '''; + + final errors = await _expectSpecStylerValidationError({ + ...mixAnnotationsSources, + ..._flutterResolveStubs, + ..._mixSourcesWithStyleWidget, + 'mix|lib/src/core/style.dart': _canonicalStyleStub, + 'mix|lib/spike.dart': input, + }); + + expect( + errors, + contains( + '@MixableSpec(target:) PlainWidget `style` parameter cannot accept ' + 'the generated `BoxStyler`', + ), + ); }); test('rejects StyleWidget targets for a different spec type', () async { @@ -1287,8 +1733,13 @@ void main() { 'mix|lib/spike.dart': input, }); - expect(errors, contains('Spec generic mismatch')); - expect(errors, contains('StyleWidget')); + expect( + errors, + contains( + '@MixableSpec(target:) MismatchedWidget `style` parameter cannot ' + 'accept the generated `BoxStyler`', + ), + ); }); test( diff --git a/skills/mix/SKILL.md b/skills/mix/SKILL.md index 07ff1da20..17f90c357 100644 --- a/skills/mix/SKILL.md +++ b/skills/mix/SKILL.md @@ -149,7 +149,7 @@ final combined = base.merge(elevated); - **Generated Stylers have `.create()` and default constructors** — many also expose generated factory constructors - **Prefer `@MixableSpec(target: Widget.new)`** — `@MixableStyler` is legacy/deprecated - **Use `@MixWidget` for generated widgets from style factories** — it wraps top-level `Style` variables or functions -- **`@MixWidget(target:)` supports plain Widgets** — the target needs a compatible named `style` parameter; it does not need to extend `StyleWidget` +- **Widget targets can be plain Widgets** — `@MixableSpec(target:)` and `@MixWidget(target:)` need a compatible named `style` parameter; neither requires `StyleWidget` - **Use `@MixableModifier` for generated modifiers** — it emits the modifier contract mixin and `ModifierMix` class - **`mix.dart` is generated** — never edit directly; run `melos run exports` - **Run codegen after spec changes** — `melos run gen:build` diff --git a/skills/mix/references/code-generation.md b/skills/mix/references/code-generation.md index 22e34bab0..fa0e84049 100644 --- a/skills/mix/references/code-generation.md +++ b/skills/mix/references/code-generation.md @@ -57,6 +57,9 @@ Optional method flags: - `GeneratedSpecMethods.skipLerp` With `target: Widget.new`, it also drives generated Styler and `call()` support from the target widget constructor. +The target can be any `Widget` constructor with a named `style` parameter that +accepts the generated Styler; extending `StyleWidget` is not required. A +`styleSpec` parameter must be optional because generated calls omit it. Direct, uninstantiated generic targets are supported; generated `call()` methods preserve and forward their type parameters and bounds. Instantiated targets such as `Widget.new` and generic constructor tear-offs through typedefs are rejected because their substitutions are not yet supported. A target type parameter named `Key` is also rejected when the constructor forwards Flutter's `key`, because it would shadow the generated `Key? key` parameter. ### `@MixableStyler()` Legacy