From 3e8af5444e8dc660e6189a13d4c5fd4e5cd14ef2 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 24 Jul 2026 09:51:03 -0400 Subject: [PATCH] feat(mix_generator): support plain MixWidget targets --- packages/mix_annotations/CHANGELOG.md | 5 + packages/mix_annotations/README.md | 30 +- .../mix_annotations/lib/src/annotations.dart | 17 + packages/mix_annotations/pubspec.yaml | 2 +- .../test/annotations_test.dart | 18 + packages/mix_generator/CHANGELOG.md | 8 + packages/mix_generator/README.md | 32 +- .../src/core/builders/mix_widget_builder.dart | 29 ++ .../lib/src/core/models/mix_widget_model.dart | 20 +- .../lib/src/mix_widget_generator.dart | 400 ++++++++++++++++-- packages/mix_generator/pubspec.yaml | 4 +- .../mix_generator/test/core/test_helpers.dart | 4 + .../mix_widget_spec_styler_test.dart | 245 +++++++++++ 13 files changed, 765 insertions(+), 49 deletions(-) diff --git a/packages/mix_annotations/CHANGELOG.md b/packages/mix_annotations/CHANGELOG.md index f4d29ed9f..fd9e110ae 100644 --- a/packages/mix_annotations/CHANGELOG.md +++ b/packages/mix_annotations/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.2.0-beta.1 + + - **FEAT**: Add `MixWidget.target` for plain widget constructor tear-offs and + `factoryParameters` for independent recipe parameter curation. + ## 2.2.0-beta.0 - **FEAT**: Add `MixableField.forwardStyler` and `stylerSurface` for opt-in diff --git a/packages/mix_annotations/README.md b/packages/mix_annotations/README.md index 2f15776c6..43a6d0ebf 100644 --- a/packages/mix_annotations/README.md +++ b/packages/mix_annotations/README.md @@ -146,11 +146,31 @@ value parameters from becoming public widget parameters automatically: final editorStyle = EditorStyler(); ``` -An empty `.only({})` exposes no selectable styler value parameters. Factory -parameters, a valid `Key? key`, and method-level `call()` type parameters -remain automatic in every mode; required styler value parameters must be -selected. Excluded optional parameters are not forwarded, so the styler -method's defaults apply. +An empty `.only({})` exposes no selectable styler value parameters. A valid +`Key? key` and method-level `call()` type parameters remain automatic; +required styler value parameters must be selected. Excluded optional +parameters are not forwarded, so the styler method's defaults apply. + +Use `target` to wrap a plain widget constructor directly and +`factoryParameters` to curate recipe controls independently: + +```dart +@MixWidget( + name: 'FortalButton', + target: RemixButton.new, + factoryParameters: .only({'variant', 'size'}), +) +ButtonStyler fortalButtonStyler({ + ButtonVariant variant = .solid, + ButtonSize size = .medium, + bool highContrast = false, +}); +``` + +The target must be a Widget constructor with a compatible named `style` +parameter. Its `style` and `styleSpec` parameters never surface on the +generated wrapper. Required factory parameters must be selected; omitted +optional parameters use the recipe's defaults. Generators that also support older `mix_annotations` releases interpret an annotation without `widgetParameters` as `.all()`. Using `.only(...)` requires diff --git a/packages/mix_annotations/lib/src/annotations.dart b/packages/mix_annotations/lib/src/annotations.dart index 9a032cba1..c49161976 100644 --- a/packages/mix_annotations/lib/src/annotations.dart +++ b/packages/mix_annotations/lib/src/annotations.dart @@ -211,13 +211,30 @@ class MixWidget { /// the name is derived from the annotated element's name. final String? name; + /// Optional plain widget constructor rendered directly by the generated + /// wrapper. + /// + /// When set, widget parameters are read from this constructor instead of a + /// Styler `call()` method. The constructor must expose a compatible named + /// `style` parameter. `style` and `styleSpec` are supplied or omitted by the + /// generator and never become wrapper fields. + final Function? target; + /// Selection of non-`key` styler `call()` value parameters exposed by the /// generated widget. final MixWidgetParameterSelection widgetParameters; + /// Selection of recipe factory parameters exposed by the generated widget. + /// + /// Required factory parameters must be selected. Optional parameters omitted + /// by `.only(...)` use the factory's own defaults. + final MixWidgetParameterSelection factoryParameters; + const MixWidget({ this.name, + this.target, this.widgetParameters = const MixWidgetParameterSelection.all(), + this.factoryParameters = const MixWidgetParameterSelection.all(), }); } diff --git a/packages/mix_annotations/pubspec.yaml b/packages/mix_annotations/pubspec.yaml index 432685b00..90e916b46 100644 --- a/packages/mix_annotations/pubspec.yaml +++ b/packages/mix_annotations/pubspec.yaml @@ -1,6 +1,6 @@ name: mix_annotations description: Annotations for mix and mix_generator -version: 2.2.0-beta.0 +version: 2.2.0-beta.1 repository: https://github.com/btwld/mix/tree/main/packages/mix_annotations environment: diff --git a/packages/mix_annotations/test/annotations_test.dart b/packages/mix_annotations/test/annotations_test.dart index 2b5f9cd4b..189714819 100644 --- a/packages/mix_annotations/test/annotations_test.dart +++ b/packages/mix_annotations/test/annotations_test.dart @@ -52,8 +52,11 @@ void main() { test('defaults widgetParameters to all', () { const annotation = MixWidget(); + expect(annotation.target, isNull); expect(annotation.widgetParameters.includesAll, isTrue); expect(annotation.widgetParameters.names, isEmpty); + expect(annotation.factoryParameters.includesAll, isTrue); + expect(annotation.factoryParameters.names, isEmpty); }); test('preserves an explicit all selection', () { @@ -78,5 +81,20 @@ void main() { expect(annotation.widgetParameters.includesAll, isFalse); expect(annotation.widgetParameters.names, isEmpty); }); + + test('preserves target and selected factory parameters', () { + const annotation = MixWidget( + target: _Target.new, + factoryParameters: .only({'variant', 'size'}), + ); + + expect(annotation.target, _Target.new); + expect(annotation.factoryParameters.includesAll, isFalse); + expect(annotation.factoryParameters.names, {'variant', 'size'}); + }); }); } + +class _Target { + const _Target(); +} diff --git a/packages/mix_generator/CHANGELOG.md b/packages/mix_generator/CHANGELOG.md index ccd2c046a..068f6a031 100644 --- a/packages/mix_generator/CHANGELOG.md +++ b/packages/mix_generator/CHANGELOG.md @@ -1,3 +1,11 @@ +## 2.2.0-beta.2 + + - **FEAT**: Generate `@MixWidget(target:)` wrappers for plain Widget + constructors without requiring `StyleWidget` or extension `call()` methods. + Preserve target generics, key/default forwarding, enum variant + constructors, independent widget/factory parameter curation, and clean + same-build generated Styler support. + ## 2.2.0-beta.1 - **FIX**: Generate `@MixWidget` wrappers on clean builds when a factory diff --git a/packages/mix_generator/README.md b/packages/mix_generator/README.md index ea438b776..acb259ccf 100644 --- a/packages/mix_generator/README.md +++ b/packages/mix_generator/README.md @@ -174,11 +174,33 @@ stable as a styler evolves, select the supported parameters explicitly: final editorStyle = EditorStyler(); ``` -`.only({})` exposes none of the selectable styler value parameters. Factory -parameters, a valid `Key? key`, and method-level `call()` type parameters are -always automatic, and required styler value parameters must be included in an -`.only(...)` selection. Excluded optional parameters are not forwarded, so the -styler method's defaults apply. +`.only({})` exposes none of the selectable styler value parameters. A valid +`Key? key` and method-level `call()` type parameters remain automatic, and +required styler value parameters must be included in an `.only(...)` +selection. Excluded optional parameters are not forwarded, so the styler +method's defaults apply. + +For a plain Widget that accepts a generated Styler through a named `style` +parameter, configure a direct target and curate factory controls separately: + +```dart +@MixWidget( + name: 'FortalButton', + target: RemixButton.new, + factoryParameters: .only({'variant', 'size'}), +) +ButtonStyler fortalButtonStyler({ + ButtonVariant variant = .solid, + ButtonSize size = .medium, + bool highContrast = false, +}); +``` + +This path reads widget parameters and generic type parameters from the target +constructor, omits `style` and `styleSpec`, and instantiates the target +directly with the recipe result passed through `style`. It does not require +the target to extend `StyleWidget` and does not inspect extension `call()` +methods. For compatibility, an annotation from an older `mix_annotations` release that does not define `widgetParameters` is interpreted as `.all()`. Using diff --git a/packages/mix_generator/lib/src/core/builders/mix_widget_builder.dart b/packages/mix_generator/lib/src/core/builders/mix_widget_builder.dart index 05a58ff51..db66fd401 100644 --- a/packages/mix_generator/lib/src/core/builders/mix_widget_builder.dart +++ b/packages/mix_generator/lib/src/core/builders/mix_widget_builder.dart @@ -118,6 +118,12 @@ class MixWidgetBuilder { ? '${model.factoryReference}(${_factoryArgs()})' : model.factoryReference; + if (model.hasDirectTarget) { + _writeDirectTargetBuild(buffer, invocation); + buffer.writeln(' }'); + return; + } + final callArgs = _callArgs(); final callTarget = '$invocation.call${model.typeParameterInvocation}'; @@ -134,6 +140,29 @@ class MixWidgetBuilder { buffer.writeln(' }'); } + void _writeDirectTargetBuild(StringBuffer buffer, String styleInvocation) { + final constructorSuffix = model.targetConstructorName == null + ? '' + : '.${model.targetConstructorName}'; + final target = + '${model.targetTypeReference}${model.typeParameterInvocation}' + '$constructorSuffix'; + final args = [ + for (final p in model.callParams.where((p) => p.isPositional)) + 'this.${p.name}', + if (model.stylerCallForwardsKey) 'key: this.key', + 'style: $styleInvocation', + for (final p in model.callParams.where((p) => !p.isPositional)) + '${p.name}: this.${p.name}', + ]; + + buffer.writeln(' return $target('); + for (final arg in args) { + buffer.writeln(' $arg,'); + } + buffer.writeln(' );'); + } + /// Renders the comma-separated argument list passed to the factory function. /// Positionals and named params are read through `this` so generated field /// names cannot be shadowed by locals in `build`. diff --git a/packages/mix_generator/lib/src/core/models/mix_widget_model.dart b/packages/mix_generator/lib/src/core/models/mix_widget_model.dart index ee9d2f65f..eab9df721 100644 --- a/packages/mix_generator/lib/src/core/models/mix_widget_model.dart +++ b/packages/mix_generator/lib/src/core/models/mix_widget_model.dart @@ -110,6 +110,13 @@ class MixWidgetModel { /// and the generated `build()` forwards `key: this.key`. final bool stylerCallForwardsKey; + /// Plain widget type instantiated directly, or `null` for the legacy Styler + /// `call()` path. + final String? targetTypeReference; + + /// Named constructor suffix for [targetTypeReference], or `null` for `.new`. + final String? targetConstructorName; + /// Doc comment carried over from the annotated element (with leading /// `///` markers intact), or `null` when the element has no doc. final String? doc; @@ -129,6 +136,8 @@ class MixWidgetModel { required this.callParams, this.callTypeParams = const [], required this.stylerCallForwardsKey, + this.targetTypeReference, + this.targetConstructorName, this.doc, this.variantParamName, this.variantConstructors = const [], @@ -145,7 +154,13 @@ class MixWidgetModel { /// /// The builder applies Dart constructor syntax ordering when emitting code: /// all positional params first, then named params. - List get allParams => [...factoryParams, ...callParams]; + List get allParams { + final seen = {}; + return [ + for (final parameter in [...factoryParams, ...callParams]) + if (seen.add(parameter.name)) parameter, + ]; + } /// Type parameter declaration suffix for the generated widget class. String get typeParameterDeclaration => @@ -153,4 +168,7 @@ class MixWidgetModel { /// Type argument suffix for forwarding to the styler `call()` method. String get typeParameterInvocation => _typeParameterSuffix((p) => p.name); + + /// Whether `build()` instantiates a plain target widget directly. + bool get hasDirectTarget => targetTypeReference != null; } diff --git a/packages/mix_generator/lib/src/mix_widget_generator.dart b/packages/mix_generator/lib/src/mix_widget_generator.dart index 6981bf13e..8cd6b4fac 100644 --- a/packages/mix_generator/lib/src/mix_widget_generator.dart +++ b/packages/mix_generator/lib/src/mix_widget_generator.dart @@ -113,6 +113,7 @@ class MixWidgetGenerator extends GeneratorForAnnotation { TopLevelVariableElement variable, ConstantReader annotation, _WidgetParameterSelection widgetParameters, + ConstructorElement? targetConstructor, String? writtenStylerName, ) { final variableName = requireName( @@ -121,12 +122,20 @@ class MixWidgetGenerator extends GeneratorForAnnotation { ); final library = variable.library; - final callSource = _resolveCallSource( - anchor: variable, - stylerType: variable.type, - writtenStylerName: writtenStylerName, - library: library, - ); + final callSource = targetConstructor == null + ? _resolveCallSource( + anchor: variable, + stylerType: variable.type, + writtenStylerName: writtenStylerName, + library: library, + ) + : _directTargetCallSource( + anchor: variable, + constructor: targetConstructor, + stylerType: variable.type, + writtenStylerName: writtenStylerName, + library: library, + ); _requireUnprefixedFlutterSymbols(variable, callSource.call, library); final call = _extractWidgetCallParams( callSource.call, @@ -136,6 +145,13 @@ class MixWidgetGenerator extends GeneratorForAnnotation { widgetParameters: widgetParameters, baseExcluded: callSource.baseExcluded, ); + final callParams = targetConstructor == null + ? call.params + : _qualifyDirectTargetDefaults( + call.params, + constructor: targetConstructor, + targetTypeReference: callSource.targetTypeReference!, + ); return MixWidgetModel( widgetName: _resolveWidgetName( @@ -147,11 +163,15 @@ class MixWidgetGenerator extends GeneratorForAnnotation { factoryReference: variableName, isFunctionFactory: false, factoryParams: const [], - callParams: call.params, - callTypeParams: callSource.isGenerated - ? const [] - : _extractCallTypeParams(callSource.call, library: library), + callParams: callParams, + callTypeParams: targetConstructor == null + ? (callSource.isGenerated + ? const [] + : _extractCallTypeParams(callSource.call, library: library)) + : _extractTargetTypeParams(targetConstructor, library: library), stylerCallForwardsKey: call.forwardsKey, + targetTypeReference: callSource.targetTypeReference, + targetConstructorName: callSource.targetConstructorName, doc: variable.documentationComment, ); } @@ -160,6 +180,8 @@ class MixWidgetGenerator extends GeneratorForAnnotation { TopLevelFunctionElement function, ConstantReader annotation, _WidgetParameterSelection widgetParameters, + _WidgetParameterSelection factoryParameters, + ConstructorElement? targetConstructor, String? writtenStylerName, ) { final functionName = requireName( @@ -176,12 +198,20 @@ class MixWidgetGenerator extends GeneratorForAnnotation { } final library = function.library; - final callSource = _resolveCallSource( - anchor: function, - stylerType: function.returnType, - writtenStylerName: writtenStylerName, - library: library, - ); + final callSource = targetConstructor == null + ? _resolveCallSource( + anchor: function, + stylerType: function.returnType, + writtenStylerName: writtenStylerName, + library: library, + ) + : _directTargetCallSource( + anchor: function, + constructor: targetConstructor, + stylerType: function.returnType, + writtenStylerName: writtenStylerName, + library: library, + ); _requireUnprefixedFlutterSymbols(function, callSource.call, library); @@ -189,6 +219,7 @@ class MixWidgetGenerator extends GeneratorForAnnotation { function, library: library, factoryReference: functionName, + factoryParameters: factoryParameters, ); final call = _extractWidgetCallParams( callSource.call, @@ -198,19 +229,35 @@ class MixWidgetGenerator extends GeneratorForAnnotation { widgetParameters: widgetParameters, baseExcluded: callSource.baseExcluded, ); + final callParams = targetConstructor == null + ? call.params + : _qualifyDirectTargetDefaults( + call.params, + constructor: targetConstructor, + targetTypeReference: callSource.targetTypeReference!, + ); - _rejectCollisions(function, factoryParams, call.params); + if (targetConstructor == null) { + _rejectCollisions(function, factoryParams, callParams); + } else { + _validateDirectTargetCollisions(function, factoryParams, callParams); + } - final callTypeParams = callSource.isGenerated - ? const [] - : _extractCallTypeParams(callSource.call, library: library); - final variantConstructors = _extractVariantConstructors( - function, - library: library, - widgetTypeParameterNames: { - for (final typeParameter in callTypeParams) typeParameter.name, - }, - ); + final callTypeParams = targetConstructor == null + ? (callSource.isGenerated + ? const [] + : _extractCallTypeParams(callSource.call, library: library)) + : _extractTargetTypeParams(targetConstructor, library: library); + final variantConstructors = + factoryParams.any((parameter) => parameter.name == _variantParamName) + ? _extractVariantConstructors( + function, + library: library, + widgetTypeParameterNames: { + for (final typeParameter in callTypeParams) typeParameter.name, + }, + ) + : null; return MixWidgetModel( widgetName: _resolveWidgetName( @@ -222,15 +269,196 @@ class MixWidgetGenerator extends GeneratorForAnnotation { factoryReference: functionName, isFunctionFactory: true, factoryParams: factoryParams, - callParams: call.params, + callParams: callParams, callTypeParams: callTypeParams, stylerCallForwardsKey: call.forwardsKey, + targetTypeReference: callSource.targetTypeReference, + targetConstructorName: callSource.targetConstructorName, doc: function.documentationComment, variantParamName: variantConstructors == null ? null : _variantParamName, variantConstructors: variantConstructors ?? const [], ); } + 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, + required DartType stylerType, + required String? writtenStylerName, + required LibraryElement library, + }) { + final targetType = constructor.enclosingElement.thisType; + final targetName = + constructor.enclosingElement.name ?? targetType.getDisplayString(); + if (!widgetChecker.isAssignableFromType(targetType)) { + fail( + anchor, + '$_annotationLabel(target:) must reference a Widget constructor, but ' + '`$targetName` is not a Widget subtype.', + ); + } + + final targetReference = referenceFor(constructor.enclosingElement, library); + if (targetReference == null) { + fail( + anchor, + '$_annotationLabel(target:) widget `$targetName` is not visible from ' + 'the annotated library.', + ); + } + + final optionalPositional = optionalPositionalNames( + constructor.formalParameters, + ); + if (optionalPositional.isNotEmpty) { + fail( + anchor, + '$_annotationLabel(target:) does not support optional positional ' + 'target constructor parameters on $targetName: ' + '[${optionalPositional.join(', ')}].', + todo: 'Convert these parameters to required positional or named.', + ); + } + + FormalParameterElement? styleParameter; + for (final parameter in constructor.formalParameters) { + if (parameter.name == 'style' && parameter.isNamed) { + styleParameter = parameter; + break; + } + } + if (styleParameter == null) { + fail( + anchor, + '$_annotationLabel(target:) requires $targetName to expose a named ' + '`style` constructor parameter so the generated widget can pass the ' + 'recipe result through `style`.', + ); + } + + final styleSpecParameter = constructor.formalParameters + .where((parameter) => parameter.name == 'styleSpec') + .firstOrNull; + if (styleSpecParameter != null && styleSpecParameter.isRequired) { + fail( + anchor, + '$_annotationLabel(target:) cannot omit required `styleSpec` on ' + '$targetName.', + ); + } + + final compatible = _targetStyleAcceptsRecipe( + styleParameter.type, + stylerType: stylerType, + writtenStylerName: writtenStylerName, + library: library, + ); + if (!compatible) { + fail( + anchor, + '$_annotationLabel(target:) $targetName `style` parameter cannot ' + 'accept the `${writtenStylerName ?? stylerType.getDisplayString()}` ' + 'recipe result.', + ); + } + + final constructorName = constructor.name; + return _CallSource( + call: constructor, + baseExcluded: stylerBackedTargetParams, + isGenerated: false, + targetTypeReference: targetReference, + targetConstructorName: + constructorName == null || + constructorName.isEmpty || + constructorName == 'new' + ? null + : constructorName, + ); + } + + bool _targetStyleAcceptsRecipe( + DartType targetStyleType, { + required DartType stylerType, + required String? writtenStylerName, + required LibraryElement library, + }) { + if (stylerType is InterfaceType) { + return library.typeSystem.isAssignableTo( + stylerType, + targetStyleType, + strictCasts: false, + ); + } + + if (writtenStylerName == null) return false; + final spec = _findGeneratedStylerSpec(library, writtenStylerName); + if (spec == null) return false; + + // A target can itself refer to a same-build generated Styler, in which + // case analyzer reports InvalidType until the shared part is written. + if (targetStyleType is! InterfaceType) return true; + + final acceptedStyle = findSupertypeMatching(targetStyleType, styleChecker); + if (acceptedStyle == null) { + // Some build-test consumers re-export a lightweight Style stub from a + // barrel rather than its canonical library. Preserve semantic matching + // for that test shape without weakening non-Style targets. + return targetStyleType.getDisplayString() == 'Style<${spec.name}>'; + } + if (acceptedStyle.typeArguments.isEmpty) { + return false; + } + + final acceptedSpec = acceptedStyle.typeArguments.first; + return acceptedSpec is InterfaceType && + acceptedSpec.element.name == spec.name && + acceptedSpec.element.library.uri == spec.library.uri; + } + + List _extractTargetTypeParams( + ConstructorElement constructor, { + required LibraryElement library, + }) { + return [ + for (final typeParameter in constructor.enclosingElement.typeParameters) + _callTypeParam(typeParameter, library: library), + ]; + } + List? _extractVariantConstructors( TopLevelFunctionElement function, { required LibraryElement library, @@ -487,10 +715,11 @@ class MixWidgetGenerator extends GeneratorForAnnotation { /// `mix_annotations` versions before parameter curation do not expose /// [MixWidget.widgetParameters]. Treat that legacy shape as `.all()` so a /// generator upgrade does not break existing `@MixWidget()` consumers. - _WidgetParameterSelection _widgetParameterSelectionFor( + _WidgetParameterSelection _parameterSelectionFor( ConstantReader annotation, + String fieldName, ) { - final selection = annotation.peek('widgetParameters'); + final selection = annotation.peek(fieldName); if (selection == null) return (includesAll: true, names: {}); final names = { @@ -662,10 +891,49 @@ class MixWidgetGenerator extends GeneratorForAnnotation { TopLevelFunctionElement function, { required LibraryElement library, required String factoryReference, + required _WidgetParameterSelection factoryParameters, }) { - final optionalPositional = optionalPositionalNames( - function.formalParameters, - ); + final selectedParameters = []; + final availableNames = { + for (final parameter in function.formalParameters) + if (parameter.name case final String name) name, + }; + + if (!factoryParameters.includesAll) { + for (final name in factoryParameters.names) { + if (!availableNames.contains(name)) { + fail( + function, + '$_annotationLabel factoryParameters selects unknown factory ' + 'parameter `$name`.', + todo: 'Select a parameter declared by the recipe factory.', + ); + } + } + } + + for (final parameter in function.formalParameters) { + final name = parameter.name; + final selected = + factoryParameters.includesAll || + (name != null && factoryParameters.names.contains(name)); + if (!selected) { + if (parameter.isRequired) { + fail( + function, + '$_annotationLabel factoryParameters must include required ' + 'factory parameter `$name`.', + todo: + 'Add `$name` to `factoryParameters: .only({...})` or use ' + '`factoryParameters: .all()`.', + ); + } + continue; + } + selectedParameters.add(parameter); + } + + final optionalPositional = optionalPositionalNames(selectedParameters); if (optionalPositional.isNotEmpty) { fail( function, @@ -676,7 +944,7 @@ class MixWidgetGenerator extends GeneratorForAnnotation { } final params = []; - for (final p in function.formalParameters) { + for (final p in selectedParameters) { _rejectFactoryKeyParam(p, function); rejectReservedName(p, function); rejectFactoryReferenceCollision(p, function, factoryReference); @@ -728,6 +996,31 @@ class MixWidgetGenerator extends GeneratorForAnnotation { } } + void _validateDirectTargetCollisions( + Element anchor, + List factoryParams, + List targetParams, + ) { + final factoryByName = { + for (final parameter in factoryParams) parameter.name: parameter, + }; + for (final targetParam in targetParams) { + final factoryParam = factoryByName[targetParam.name]; + if (factoryParam == null) continue; + if (factoryParam.typeCode == targetParam.typeCode) continue; + + fail( + anchor, + '$_annotationLabel shared factory/target parameter ' + '`${targetParam.name}` has incompatible types ' + '`${factoryParam.typeCode}` and `${targetParam.typeCode}`.', + todo: + 'Use matching types, or exclude the parameter from either ' + '`factoryParameters` or `widgetParameters`.', + ); + } + } + void _requireUnprefixedFlutterSymbols( Element anchor, ExecutableElement callMethod, @@ -908,19 +1201,30 @@ class MixWidgetGenerator extends GeneratorForAnnotation { ConstantReader annotation, BuildStep buildStep, ) async { - final widgetParameters = _widgetParameterSelectionFor(annotation); + final widgetParameters = _parameterSelectionFor( + annotation, + 'widgetParameters', + ); + final factoryParameters = _parameterSelectionFor( + annotation, + 'factoryParameters', + ); + final targetConstructor = _targetConstructorFor(element, annotation); final writtenStylerName = await _writtenStylerTypeName(element, buildStep); final model = switch (element) { TopLevelVariableElement v => _modelForVariable( v, annotation, widgetParameters, + targetConstructor, writtenStylerName, ), TopLevelFunctionElement f => _modelForFunction( f, annotation, widgetParameters, + factoryParameters, + targetConstructor, writtenStylerName, ), _ => fail( @@ -932,6 +1236,24 @@ class MixWidgetGenerator extends GeneratorForAnnotation { return MixWidgetBuilder(model).build(); } + + ConstructorElement? _targetConstructorFor( + Element anchor, + ConstantReader annotation, + ) { + final target = annotation.peek('target'); + if (target == null || target.isNull) return null; + + final constructor = target.objectValue.toFunctionValue(); + if (constructor is! ConstructorElement) { + fail( + anchor, + '$_annotationLabel(target:) must be a constructor tear-off ' + '(e.g., RemixButton.new).', + ); + } + return constructor; + } } /// The executable whose parameters define a generated widget's `call()` @@ -949,9 +1271,17 @@ class _CallSource { /// resolvable `call()` method. final bool isGenerated; + /// Plain widget type rendered directly instead of invoking Styler.call(). + final String? targetTypeReference; + + /// Named constructor on [targetTypeReference], or `null` for `.new`. + final String? targetConstructorName; + const _CallSource({ required this.call, required this.baseExcluded, required this.isGenerated, + this.targetTypeReference, + this.targetConstructorName, }); } diff --git a/packages/mix_generator/pubspec.yaml b/packages/mix_generator/pubspec.yaml index dbfc6208c..ea780067b 100644 --- a/packages/mix_generator/pubspec.yaml +++ b/packages/mix_generator/pubspec.yaml @@ -1,6 +1,6 @@ name: mix_generator description: A code generator for Mix, an expressive way to effortlessly build design systems in Flutter. -version: 2.2.0-beta.1 +version: 2.2.0-beta.2 homepage: https://github.com/btwld/mix repository: https://github.com/btwld/mix/tree/main/packages/mix_generator @@ -8,7 +8,7 @@ environment: sdk: ">=3.11.0 <4.0.0" dependencies: - mix_annotations: ^2.2.0-beta.0 + mix_annotations: ^2.2.0-beta.1 dart_style: ^3.0.0 source_gen: ">=3.0.0 <5.0.0" analyzer: '>=9.0.0 <11.0.0' diff --git a/packages/mix_generator/test/core/test_helpers.dart b/packages/mix_generator/test/core/test_helpers.dart index 68e49f5c7..48cb4f18d 100644 --- a/packages/mix_generator/test/core/test_helpers.dart +++ b/packages/mix_generator/test/core/test_helpers.dart @@ -268,11 +268,15 @@ class MixWidgetParameterSelection { class MixWidget { final String? name; + final Function? target; final MixWidgetParameterSelection widgetParameters; + final MixWidgetParameterSelection factoryParameters; const MixWidget({ this.name, + this.target, this.widgetParameters = const MixWidgetParameterSelection.all(), + this.factoryParameters = const MixWidgetParameterSelection.all(), }); } diff --git a/packages/mix_generator/test/integration/mix_widget_spec_styler_test.dart b/packages/mix_generator/test/integration/mix_widget_spec_styler_test.dart index 1ade357cb..aa89864ce 100644 --- a/packages/mix_generator/test/integration/mix_widget_spec_styler_test.dart +++ b/packages/mix_generator/test/integration/mix_widget_spec_styler_test.dart @@ -400,4 +400,249 @@ ButtonStyler buttonStyle() => throw UnimplementedError(); }, ); }); + + group('MixWidget plain widget targets', () { + test('generates a direct plain StatelessWidget wrapper with independent ' + 'factory and widget filtering', () async { + const body = r''' +enum ButtonVariant { solid, ghost } +enum ButtonSize { small, large } + +@MixableSpec() +final class ButtonSpec extends Spec { + final Color? color; + const ButtonSpec({this.color}); +} + +class PlainButton extends StatelessWidget { + const PlainButton({ + super.key, + required this.label, + this.child, + required this.style, + this.styleSpec, + this.transitionBuilder = defaultTransitionBuilder, + }); + + static Widget defaultTransitionBuilder(Widget child) => child; + + final String label; + final Widget? child; + final Style style; + final StyleSpec? styleSpec; + final Widget Function(Widget) transitionBuilder; + + @override + Widget build(BuildContext context) => child ?? const _Leaf(); +} + +class _Leaf extends Widget { + const _Leaf(); +} + +@MixWidget( + name: 'FortalButton', + target: PlainButton.new, + widgetParameters: .only({'label', 'child', 'transitionBuilder'}), + factoryParameters: .only({'variant', 'size'}), +) +ButtonStyler fortalButtonStyler({ + ButtonVariant variant = ButtonVariant.solid, + ButtonSize size = ButtonSize.small, + bool highContrast = false, +}) => ButtonStyler(); +'''; + + await expectGeneratorOutputResolves( + builder: _combinedBuilder(), + sources: _sources(body), + inputAsset: 'mix|lib/button.dart', + outputAsset: 'mix|lib/button.g.dart', + outputMatcher: allOf([ + contains('class FortalButton extends StatelessWidget {'), + contains('const FortalButton({'), + contains('this.variant = ButtonVariant.solid,'), + contains('this.size = ButtonSize.small,'), + contains('required this.label,'), + contains('const FortalButton.solid('), + contains('const FortalButton.ghost('), + contains('final ButtonVariant variant;'), + contains('final ButtonSize size;'), + contains('final String label;'), + contains('final Widget? child;'), + contains( + 'this.transitionBuilder = PlainButton.defaultTransitionBuilder,', + ), + isNot(contains('final bool highContrast;')), + isNot(contains('final Style style;')), + isNot(contains('final StyleSpec? styleSpec;')), + contains('return PlainButton('), + contains('key: this.key,'), + contains( + 'style: fortalButtonStyler(variant: this.variant, ' + 'size: this.size),', + ), + contains('label: this.label,'), + contains('child: this.child,'), + isNot(contains('.call(')), + ]), + ); + }); + + test('preserves generic target type parameters', () async { + const body = r''' +final class ButtonSpec extends Spec { + const ButtonSpec(); +} + +@MixableSpec() +final class RadioSpec extends Spec { + const RadioSpec(); +} + +class PlainRadio extends StatelessWidget { + const PlainRadio({ + super.key, + required this.value, + required this.style, + this.styleSpec, + }); + + final T value; + final Style style; + final StyleSpec? styleSpec; + + @override + Widget build(BuildContext context) => const _Leaf(); +} + +class _Leaf extends Widget { + const _Leaf(); +} + +@MixWidget(name: 'FortalRadio', target: PlainRadio.new) +RadioStyler fortalRadioStyler() => RadioStyler(); +'''; + + await expectGeneratorOutputResolves( + builder: _combinedBuilder(), + sources: _sources(body), + inputAsset: 'mix|lib/button.dart', + outputAsset: 'mix|lib/button.g.dart', + outputMatcher: allOf([ + contains( + 'class FortalRadio extends StatelessWidget', + ), + contains('final T value;'), + contains('return PlainRadio('), + contains('style: fortalRadioStyler(),'), + contains('value: this.value,'), + ]), + ); + }); + + test('rejects a target without a named style parameter', () async { + const body = r''' +@MixableSpec() +final class ButtonSpec extends Spec { + const ButtonSpec(); +} + +class PlainButton extends StatelessWidget { + const PlainButton({super.key, required this.label}); + final String label; + @override + Widget build(BuildContext context) => const _Leaf(); +} + +class _Leaf extends Widget { + const _Leaf(); +} + +@MixWidget(target: PlainButton.new) +ButtonStyler buttonStyle() => ButtonStyler(); +'''; + + final errors = await _expectMixWidgetError(body); + + expect( + errors, + contains( + '@MixWidget(target:) requires PlainButton to expose a named ' + '`style` constructor parameter', + ), + ); + }); + + test('rejects an incompatible target style parameter', () async { + const body = r''' +@MixableSpec() +final class ButtonSpec extends Spec { + const ButtonSpec(); +} + +class PlainButton extends StatelessWidget { + const PlainButton({super.key, required this.label, required this.style}); + final String label; + final int style; + @override + Widget build(BuildContext context) => const _Leaf(); +} + +class _Leaf extends Widget { + const _Leaf(); +} + +@MixWidget(target: PlainButton.new) +ButtonStyler buttonStyle() => ButtonStyler(); +'''; + + final errors = await _expectMixWidgetError(body); + + expect( + errors, + contains( + '@MixWidget(target:) PlainButton `style` parameter cannot accept ' + 'the `ButtonStyler` recipe result', + ), + ); + }); + + test('rejects required factory parameters omitted from only', () async { + const body = r''' +@MixableSpec() +final class ButtonSpec extends Spec { + const ButtonSpec(); +} + +class PlainButton extends StatelessWidget { + const PlainButton({super.key, required this.label, required this.style}); + final String label; + final Style style; + @override + Widget build(BuildContext context) => const _Leaf(); +} + +class _Leaf extends Widget { + const _Leaf(); +} + +@MixWidget( + target: PlainButton.new, + factoryParameters: .only({}), +) +ButtonStyler buttonStyle({required bool emphasized}) => ButtonStyler(); +'''; + + final errors = await _expectMixWidgetError(body); + + expect( + errors, + contains( + '@MixWidget factoryParameters must include required factory ' + 'parameter `emphasized`', + ), + ); + }); + }); }