diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..5913bec --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.44.4" +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c097c5d..98da604 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,11 @@ on: jobs: semantic_pull_request: - uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/semantic_pull_request.yml@fb76d33002e1a5c6bc7c0ee9ba2b5ff2d9908a00 # v1.17 + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} build: runs-on: ubuntu-latest @@ -35,7 +39,7 @@ jobs: run: | dart pub global activate coverage melos exec --dir-exists="test" -- "dart test --coverage=coverage" - melos exec --dir-exists="test" -- "dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib" + melos exec --dir-exists="test" -- "dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --package=. --report-on=lib" - name: Upload Artifacts uses: actions/upload-artifact@v4 @@ -46,21 +50,14 @@ jobs: - name: Generate Coverage Matrix id: coverage-matrix run: | - # Get packages that have tests - packages=$(melos list --dir-exists="test") - - # Create JSON array of objects with name and path - matrix_json="[" - first=true - for package in $packages; do - if [ "$first" = true ]; then - first=false - else - matrix_json+="," - fi - matrix_json+="{\"name\":\"$package\",\"path\":\"packages/$package/coverage/lcov.info\"}" - done - matrix_json+="]" + matrix_json="$(melos list --dir-exists="test" --json | jq -c --arg root "$PWD" ' + [.[] | { + name: .name, + private: .private, + directory: (.location | ltrimstr($root + "/")), + path: ((.location | ltrimstr($root + "/")) + "/coverage/lcov.info") + }] + ')" echo "matrix=$matrix_json" >> "$GITHUB_OUTPUT" echo "Generated coverage matrix: $matrix_json" @@ -81,13 +78,12 @@ jobs: name: coverage path: packages/ - - name: Inspect directories - run: ls -R packages/ - - name: Check Code Coverage + if: ${{ !matrix.private }} uses: VeryGoodOpenSource/very_good_coverage@c953fca3e24a915e111cc6f55f03f756dcb3964c # v3 with: path: ${{ matrix.path }} + min_coverage: 80 check_pana: needs: build @@ -111,6 +107,16 @@ jobs: run: dart pub global activate pana - name: Verify Pub Score + if: ${{ !matrix.private }} run: | - cd "packages/${{ matrix.name }}" || exit 1 - ../../tool/verify_pub_score.sh "$(cat PANA_SCORE)" + MIN_SCORE="" + if [ -f "${{ matrix.directory }}/PANA_SCORE" ]; then + MIN_SCORE="$(cat "${{ matrix.directory }}/PANA_SCORE")" + fi + + if [ "${{ matrix.name }}" = "shape" ]; then + cd "${{ matrix.directory }}" || exit + ../../tool/verify_pub_score.sh "$MIN_SCORE" + else + tool/verify_pub_score_workspace.sh "$MIN_SCORE" "${{ matrix.directory }}" + fi diff --git a/.gitignore b/.gitignore index 0d25ed4..a4a44e0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ build/ pubspec.lock coverage/ **/pubspec_overrides.yaml + +# FVM Version Cache +.fvm/ \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml index f7b8e45..43834eb 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -2,11 +2,11 @@ - - - - - + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/melos_run_format_ci.xml b/.idea/runConfigurations/melos_run_format_ci.xml new file mode 100644 index 0000000..284fc60 --- /dev/null +++ b/.idea/runConfigurations/melos_run_format_ci.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a7b6726 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dart.flutterSdkPath": ".fvm/versions/3.44.4" +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ffacc..bff9236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project will be documented in this file. +### 0.1.0 - 2026-08-06 + +- feat: simplify API surface and implementation details + - remove `FormBody` and `FormErrors` generics + - add `SimpleFormField` alias for `FormField` + - add `@FieldRequired()` for optional params that must validate as required + - support redirecting factories (`factory Foo(...) = _$Foo`) in generator + - add auto-wrap for plain factory params via `GenericFormField` + - remove `Equatable` usage in favor of explicit `operator ==` / `hashCode` for bodies and errors + - improve constructor/field validation diagnostics in generator +- chore: upgrade Dart version constraint and dependencies +- test: update and improve tests + ### 0.0.2 - 2023-08-11 (`shape_generator` only) - chore(shape_generator): relax dependency constraint on `analyzer` to maximize compatibility with Flutter projects ([#10](https://github.com/Betterment/shape/pull/10)) diff --git a/analysis_options.yaml b/analysis_options.yaml index 7140411..fd19b19 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -5,18 +5,40 @@ formatter: linter: rules: - - cascade_invocations - - lines_longer_than_80_chars - - package_api_docs - - prefer_const_constructors - - prefer_const_constructors_in_immutables - - prefer_const_declarations - - prefer_const_literals_to_create_immutables - - prefer_single_quotes - - public_member_api_docs - - require_trailing_commas - - sort_constructors_first - - sort_pub_dependencies + cascade_invocations: true + lines_longer_than_80_chars: true + prefer_const_constructors: true + prefer_const_constructors_in_immutables: true + prefer_const_declarations: true + prefer_const_literals_to_create_immutables: true + prefer_single_quotes: true + public_member_api_docs: true + require_trailing_commas: true + sort_constructors_first: true + sort_pub_dependencies: true + simple_directive_paths: true + simplify_variable_pattern: true + remove_deprecations_in_breaking_versions: true + switch_on_type: true + unnecessary_unawaited: true + use_null_aware_elements: true + unnecessary_ignore: true + strict_top_level_inference: true + unnecessary_underscores: true + omit_obvious_property_types: true + unnecessary_async: true + unsafe_variance: true + use_truncating_division: true + omit_obvious_local_variable_types: true + avoid_futureor_void: true + unintended_html_in_doc_comment: true + invalid_runtime_check_with_js_interop_types: true + document_ignores: true + unnecessary_library_name: true + missing_code_block_language_in_doc_comment: true + annotate_redeclares: true + no_self_assignments: true + no_wildcard_variable_uses: true analyzer: language: @@ -26,5 +48,5 @@ analyzer: missing_return: error missing_required_param: error exclude: - - "build/**" - - "**/*.g.dart" + - 'build/**' + - '**/*.g.dart' diff --git a/cspell.json b/cspell.json index 74cabdb..82ca554 100644 --- a/cspell.json +++ b/cspell.json @@ -1,8 +1,3 @@ { - "words": [ - "Bodyless", - "pana", - "webp", - "writeln" - ] + "words": ["Bodyless", "pana", "webp", "writeln", "nullable"] } diff --git a/docs/migrations/0.0.1-to-0.1.0.md b/docs/migrations/0.0.1-to-0.1.0.md new file mode 100644 index 0000000..cc9c7d0 --- /dev/null +++ b/docs/migrations/0.0.1-to-0.1.0.md @@ -0,0 +1,81 @@ +# 0.0.1 → 0.1.0 + +## 1) `FormBody` / `FormErrors` generics removed +Remove type args from the base classes. + +```dart +// Before +abstract class ExampleFormBody extends FormBody {} +class ExampleFormErrors extends FormErrors {} + +// After +abstract class ExampleFormBody extends FormBody {} +class ExampleFormErrors extends FormErrors {} +``` + +## 2) New `@FieldRequired()` annotation +If a factory param is optional but should validate as required, add `@FieldRequired()`. + +```dart +// Before +factory ExampleFormBody({ + String? name, + int? age, +}) { + return _$ExampleFormBody( + name: GenericFormField(name, isRequired: true), + age: GenericFormField(age), + ); +} + +// After +factory ExampleFormBody({ + @FieldRequired() String? name, + int? age, +}) = _$ExampleFormBody; +``` + +## 3) Factory body can be simplified +For plain values, you can now use a redirecting factory and let generation auto-wrap. + +```dart +// Before +factory ExampleFormBody({ + required String? name, + required String age, +}) { + return _$ExampleFormBody( + name: GenericFormField(name, isRequired: true), + age: GenericFormField(age), + ); +} + +// After +factory ExampleFormBody({ + @FieldRequired() String? name, + required String age, +}) = _$ExampleFormBody; +``` + +## 4) `SimpleFormField` alias +Use `SimpleFormField` when raw and parsed types are the same. + +```dart +// Before +class NonEmptyNameField extends FormField { + const NonEmptyNameField(super.rawValue); + @override + String get value => rawValue; + @override + NameError? validate() => value.isEmpty ? NameError.empty : null; +} + +// After +class NonEmptyNameField extends SimpleFormField { + const NonEmptyNameField(super.rawValue); + @override + String get value => rawValue; + @override + NameError? validate() => value.isEmpty ? NameError.empty : null; +} +``` diff --git a/packages/shape/README.md b/packages/shape/README.md index 0569de9..2804866 100644 --- a/packages/shape/README.md +++ b/packages/shape/README.md @@ -8,6 +8,7 @@ A package for building forms that can be easily reused, validated, and parsed, p - [Table of Contents](#table-of-contents) - [Summary](#summary) - [Usage](#usage) + - [Migrations](#migrations) - [Principle](#principle) - [Features](#features) - [Access parsed values](#access-parsed-values) @@ -43,32 +44,24 @@ To generate a form body, in this case called `ExampleFormBody`; 1. Create an abstract class `ExampleFormBody` annotated with `@GenerateFormBody()`. 2. Add the `_$ExampleFormBodyFields` mixin. -3. Create a single unnamed factory that returns an instance of `_$ExampleFormBody` containing all form fields that should be present in the form body. All parameters must be an instance of a class that extends `FormField`, a class provided by this package. +3. Add a private empty constructor (`const ExampleFormBody._();`) and one unnamed factory that returns `_$ExampleFormBody`. A full example might look like this: ```dart import 'package:shape/shape.dart'; -import 'package:shape_addons/shape_addons.dart'; +import 'package:shape_starter_kit/shape_starter_kit.dart'; part 'example_form_body.g.dart'; @GenerateFormBody() -abstract class ExampleFormBody with _$ExampleFormBodyFields { +abstract class ExampleFormBody extends FormBody with _$ExampleFormBodyFields { + const ExampleFormBody._(); + factory ExampleFormBody({ - required String? foo, - required String? bar, - }) { - return _$ExampleFormBody( - name: GenericFormField( - value: foo, - isRequired: true, - ), - otherName: RangedDoubleFormField( - value: bar, - ), - ); - } + @FieldRequired() String? foo, + int? bar, + }) = _$ExampleFormBody; } void main() { @@ -76,6 +69,10 @@ void main() { } ``` +### Migrations + +- [0.0.1 → 0.1.0](docs/migrations/0.0.1-to-0.1.0.md) + ### Principle Shape works by separating form fields, bodies, validation logic and parsing logic into separate classes. @@ -135,7 +132,7 @@ To run the example, run `build_runner` in [the `example` folder](https://github. ```shell cd example -flutter pub run build_runner build --delete-conflicting-outputs +flutter pub run build_runner build ``` A new form body will be generated based on the contents of [`example/lib/example_form_body.dart`](https://github.com/betterment/shape/tree/main/packages/shape/example/lib/example_form_body.dart). After the code generator has completed, examine the contents of the file [`example/lib/example_form_body.g.dart`](https://github.com/betterment/shape/tree/main/packages/shape/example/lib/example_form_body.g.dart). diff --git a/packages/shape/example/lib/example_form_body.dart b/packages/shape/example/lib/example_form_body.dart index 0cddf26..7c3e9e4 100644 --- a/packages/shape/example/lib/example_form_body.dart +++ b/packages/shape/example/lib/example_form_body.dart @@ -4,14 +4,9 @@ import 'package:shape_starter_kit/shape_starter_kit.dart'; part 'example_form_body.g.dart'; @GenerateFormBody() -abstract class ExampleFormBody extends FormBody - with _$ExampleFormBodyFields { - factory ExampleFormBody({required String? name, int? age}) { - return _$ExampleFormBody( - name: GenericFormField(name, isRequired: true), - age: GenericFormField(age), - ); - } - +abstract class ExampleFormBody extends FormBody with _$ExampleFormBodyFields { const ExampleFormBody._(); + + factory ExampleFormBody({@FieldRequired() String? name, int? age}) = + _$ExampleFormBody; } diff --git a/packages/shape/example/lib/example_form_body.g.dart b/packages/shape/example/lib/example_form_body.g.dart index 61f4ebe..02e4147 100644 --- a/packages/shape/example/lib/example_form_body.g.dart +++ b/packages/shape/example/lib/example_form_body.g.dart @@ -6,20 +6,17 @@ part of 'example_form_body.dart'; // ShapeGenerator // ************************************************************************** +// ignore_for_file: unused_element, cast_nullable_to_non_nullable, prefer_const_constructors_in_immutables // Form Body "_$ExampleFormBody" @immutable -class _$ExampleFormBody extends ExampleFormBody - with _$ExampleFormBodyFields, EquatableMixin { - factory _$ExampleFormBody({ - required GenericFormField name, - required GenericFormField age, - }) { - return _$ExampleFormBody._(name, age); +class _$ExampleFormBody extends ExampleFormBody { + factory _$ExampleFormBody({String? name, int? age}) { + return _$ExampleFormBody._( + GenericFormField(name, isRequired: true), + GenericFormField(age), + ); } - const _$ExampleFormBody._( - this._name, - this._age, - ) : super._(); + const _$ExampleFormBody._(this._name, this._age) : super._(); @override final GenericFormField _name; @override @@ -30,36 +27,30 @@ class _$ExampleFormBody extends ExampleFormBody int? get age => _age.value; @override ExampleFormErrors validate() { - return ExampleFormErrors( - name: _name.validate(), - age: _age.validate(), - ); + return ExampleFormErrors(name: _name.validate(), age: _age.validate()); } @override _$ExampleFormBodyCopyWith get copyWith => _$ExampleFormBodyCopyWithImpl(this); @override - List get props => [ - _name.rawValue, - _age.rawValue, - ]; + bool operator ==(Object other) { + return other is _$ExampleFormBody && + other._name.rawValue == _name.rawValue && + other._age.rawValue == _age.rawValue; + } + @override - bool get stringify => true; + int get hashCode => Object.hash(_name.rawValue, _age.rawValue); } // Copy With Interface "_$ExampleFormBodyCopyWith" abstract class _$ExampleFormBodyCopyWith { - ExampleFormBody call({ - String? name, - int? age, - }); + ExampleFormBody call({String? name, int? age}); } // Copy With Implementation "_$ExampleFormBodyCopyWithImpl" class _$ExampleFormBodyCopyWithImpl implements _$ExampleFormBodyCopyWith { - const _$ExampleFormBodyCopyWithImpl( - this._instance, - ); + const _$ExampleFormBodyCopyWithImpl(this._instance); final _$ExampleFormBody _instance; static const _defaultValue = Object(); @override @@ -67,7 +58,7 @@ class _$ExampleFormBodyCopyWithImpl implements _$ExampleFormBodyCopyWith { Object? name = _defaultValue, Object? age = _defaultValue, }) { - return ExampleFormBody( + return _$ExampleFormBody( name: name == _defaultValue ? _instance._name.rawValue : name as String?, age: age == _defaultValue ? _instance._age.rawValue : age as int?, ); @@ -101,15 +92,10 @@ mixin _$ExampleFormBodyFields { // Form Errors "ExampleFormErrors" @immutable - /// The form errors for the form body "ExampleFormBody". -class ExampleFormErrors extends FormErrors<_$ExampleFormBody> - with EquatableMixin { +class ExampleFormErrors extends FormErrors { /// The form errors for the form body "ExampleFormBody". - const ExampleFormErrors({ - this.name, - this.age, - }); + const ExampleFormErrors({this.name, this.age}); /// The error for the name field. final GenericValidationError? name; @@ -120,27 +106,22 @@ class ExampleFormErrors extends FormErrors<_$ExampleFormBody> /// Merges this ExampleFormErrors with the [other] /// by replacing any empty fields in this instance with the corresponding field in /// [other] while preserving the non-empty fields in this instance. - ExampleFormErrors mergeWhereEmptyWith({ - required ExampleFormErrors other, - }) { - return ExampleFormErrors( - name: name ?? other.name, - age: age ?? other.age, - ); + ExampleFormErrors mergeWhereEmptyWith({required ExampleFormErrors other}) { + return ExampleFormErrors(name: name ?? other.name, age: age ?? other.age); } /// Copies this ExampleFormErrors and replaces the provided fields. _ExampleFormErrorsCopyWith get copyWith => _ExampleFormErrorsCopyWithImpl(this); @override - List get errors => [ - name, - age, - ]; + List get errors => [name, age]; @override - List get props => errors; + bool operator ==(Object other) { + return other is ExampleFormErrors && other.name == name && other.age == age; + } + @override - bool get stringify => true; + int get hashCode => Object.hash(name, age); } // Copy With Interface "_ExampleFormErrorsCopyWith" @@ -153,9 +134,7 @@ abstract class _ExampleFormErrorsCopyWith { // Copy With Implementation "_ExampleFormErrorsCopyWithImpl" class _ExampleFormErrorsCopyWithImpl implements _ExampleFormErrorsCopyWith { - const _ExampleFormErrorsCopyWithImpl( - this._instance, - ); + const _ExampleFormErrorsCopyWithImpl(this._instance); final ExampleFormErrors _instance; static const _defaultValue = Object(); @override @@ -167,8 +146,9 @@ class _ExampleFormErrorsCopyWithImpl implements _ExampleFormErrorsCopyWith { name: name == _defaultValue ? _instance.name : name as GenericValidationError?, - age: - age == _defaultValue ? _instance.age : age as GenericValidationError?, + age: age == _defaultValue + ? _instance.age + : age as GenericValidationError?, ); } } diff --git a/packages/shape/example/pubspec.yaml b/packages/shape/example/pubspec.yaml index c9e319e..29450e2 100644 --- a/packages/shape/example/pubspec.yaml +++ b/packages/shape/example/pubspec.yaml @@ -2,21 +2,19 @@ name: shape_example description: An example project showcasing how to build a form body using the shape package. version: 1.0.0 publish_to: none +resolution: workspace environment: - sdk: ^3.7.2 - -resolution: workspace + sdk: '>=3.12.2 <4.0.0' dependencies: - shape: - path: ../ - shape_starter_kit: - path: ../../shape_starter_kit + shape: ^0.1.0 + shape_starter_kit: ^0.1.0 dev_dependencies: - build_runner: ^2.4.6 + build_runner: '>=2.15.0 <2.15.3' checks: ^0.3.1 - lints: ^5.1.1 - shape_generator: ^0.0.1 - test: ^1.26.2 + lints: ^6.1.0 + shape_generator: ^0.1.0 + # test 1.31.2+ requires analyzer >=13; keep below that for Flutter 3.44. + test: '>=1.25.0 <1.31.2' diff --git a/packages/shape/example/test/example_form_body_test.dart b/packages/shape/example/test/example_form_body_test.dart new file mode 100644 index 0000000..67bf4e3 --- /dev/null +++ b/packages/shape/example/test/example_form_body_test.dart @@ -0,0 +1,31 @@ +import 'package:checks/checks.dart'; +import 'package:shape_example/example_form_body.dart'; +import 'package:shape_starter_kit/shape_starter_kit.dart'; +import 'package:test/test.dart' hide expect; + +void main() { + group('ExampleFormBody', () { + test('can be constructed and exposes parsed values', () { + final formBody = ExampleFormBody(name: 'Ada', age: 42); + + check(formBody.name).equals('Ada'); + check(formBody.age).equals(42); + }); + + test('validate returns generated errors', () { + final formBody = ExampleFormBody(name: null, age: null); + final errors = formBody.validate(); + + check(errors.name).equals(GenericValidationError.missing); + check(errors.isNotEmpty).isTrue(); + }); + + test('copyWith replaces provided fields', () { + final formBody = ExampleFormBody(name: 'Ada', age: 42); + final updated = formBody.copyWith(name: 'Grace'); + + check(updated.name).equals('Grace'); + check(updated.age).equals(42); + }); + }); +} diff --git a/packages/shape/lib/shape.dart b/packages/shape/lib/shape.dart index a06a083..f7cf937 100644 --- a/packages/shape/lib/shape.dart +++ b/packages/shape/lib/shape.dart @@ -2,7 +2,6 @@ /// parsed, primarily for Flutter apps. library; -export 'package:equatable/equatable.dart'; export 'package:meta/meta.dart'; export 'src/shape.dart'; diff --git a/packages/shape/lib/src/annotations/annotations.dart b/packages/shape/lib/src/annotations/annotations.dart index 040db5e..718990a 100644 --- a/packages/shape/lib/src/annotations/annotations.dart +++ b/packages/shape/lib/src/annotations/annotations.dart @@ -1 +1,2 @@ export 'form_body_annotation.dart'; +export 'form_required_annotation.dart'; diff --git a/packages/shape/lib/src/annotations/form_body_annotation.dart b/packages/shape/lib/src/annotations/form_body_annotation.dart index f97ac5f..b80a762 100644 --- a/packages/shape/lib/src/annotations/form_body_annotation.dart +++ b/packages/shape/lib/src/annotations/form_body_annotation.dart @@ -1,4 +1,3 @@ -import 'package:equatable/equatable.dart'; import 'package:meta/meta.dart'; /// {@template generate_form_body} @@ -8,7 +7,7 @@ import 'package:meta/meta.dart'; /// processed by the `shape_generator` code generator. /// {@endtemplate} @immutable -class GenerateFormBody extends Equatable { +class GenerateFormBody { /// {@macro generate_form_body} const GenerateFormBody({bool? generateFormErrors}) : generateFormErrors = generateFormErrors ?? true; @@ -17,5 +16,11 @@ class GenerateFormBody extends Equatable { final bool generateFormErrors; @override - List get props => [generateFormErrors]; + bool operator ==(Object other) => + identical(this, other) || + other is GenerateFormBody && + other.generateFormErrors == generateFormErrors; + + @override + int get hashCode => generateFormErrors.hashCode; } diff --git a/packages/shape/lib/src/annotations/form_required_annotation.dart b/packages/shape/lib/src/annotations/form_required_annotation.dart new file mode 100644 index 0000000..fab46a3 --- /dev/null +++ b/packages/shape/lib/src/annotations/form_required_annotation.dart @@ -0,0 +1,13 @@ +import 'package:meta/meta.dart'; + +/// {@template field_required_annotation} +/// Marks a form body factory parameter as required for validation. +/// +/// Use on optional parameters that should still be wrapped in a +/// [GenericFormField] with `isRequired: true`. +/// {@endtemplate} +@immutable +class FieldRequired { + /// {@macro field_required_annotation} + const FieldRequired(); +} diff --git a/packages/shape/lib/src/form_body.dart b/packages/shape/lib/src/form_body.dart index 3feff3f..b3c02a6 100644 --- a/packages/shape/lib/src/form_body.dart +++ b/packages/shape/lib/src/form_body.dart @@ -3,42 +3,28 @@ /// /// Used in conjunction with the `shape_generator` package to generate a form /// from a set of [FormField]s. -/// -/// The generic type [E] represents the type of the error container when -/// calling [validate]. /// {@endtemplate} /// /// {@template form_body_sample} /// ```dart /// @GenerateFormBody() -/// abstract class RegistrationFormBody -/// extends FormBody -/// with _$RegistrationFormBodyFields { -/// factory RegistrationFormBody({ -/// required String username, -/// required String age, -/// }) { -/// return _$RegistrationFormBody( -/// username: GenericFormField( -/// value: username, -/// isRequired: true, -/// ), -/// age: AgeFormField( -/// value: age, -/// ), -/// ); -/// } -/// +/// abstract class RegistrationFormBody extends FormBody { /// const RegistrationFormBody._(); +/// +/// factory RegistrationFormBody({ +/// @FieldRequired() String username, +/// @FieldRequired() String age, +/// }) => +/// _$RegistrationFormBody(username: username, age: age); /// } /// ``` /// {@endtemplate} -abstract class FormBody> { +abstract class FormBody { /// {@macro form_body} const FormBody(); /// Validates all the fields in this form. - E validate(); + FormErrors validate(); } /// {@template form_errors} @@ -47,16 +33,12 @@ abstract class FormBody> { /// Used in conjunction with the `shape_generator` package to generate the /// errors for a [FormBody]. /// -/// The generic type [E] represents the type of the [FormBody] container that -/// this error represents. -/// /// Any classes extending [FormErrors] must override the [errors] getter and /// provide it all the errors that occurred during validation. /// /// Use [hasErrors] to determine if there are any errors in this container. /// {@endtemplate} -/// {@macro form_body_sample} -abstract class FormErrors> { +abstract class FormErrors { /// {@macro form_errors} const FormErrors(); diff --git a/packages/shape/lib/src/form_field.dart b/packages/shape/lib/src/form_field.dart index 2b55003..d14c57f 100644 --- a/packages/shape/lib/src/form_field.dart +++ b/packages/shape/lib/src/form_field.dart @@ -9,18 +9,15 @@ /// type [E] if the field is invalid. If the field is valid, it will return /// `null`. /// +/// When [R] and [T] are the same, prefer [SimpleFormField]. +/// /// ```dart /// enum UsernameValidationError { empty, invalid } /// /// class UsernameField -/// extends FormField { -/// const UsernameField({ -/// required String rawValue, -/// this.isRequired = true, -/// }) : super(rawValue); +/// extends SimpleFormField { /// -/// @override -/// String get value => rawValue; +/// const UsernameField(super.rawValue, {this.isRequired = true}); /// /// final bool isRequired; /// @@ -53,3 +50,6 @@ abstract class FormField { /// field is valid, `null` will be returned. E? validate(); } + +/// A [FormField] where the raw and parsed value types are the same. +typedef SimpleFormField = FormField; diff --git a/packages/shape/pubspec.yaml b/packages/shape/pubspec.yaml index 1dc4fdc..5d249f2 100644 --- a/packages/shape/pubspec.yaml +++ b/packages/shape/pubspec.yaml @@ -1,19 +1,19 @@ name: shape description: A package for building forms that can be easily reused, validated, and parsed, primarily for Flutter apps. -version: 0.0.1 +version: 0.1.0 repository: https://github.com/betterment/shape/tree/main/packages/shape +resolution: workspace environment: - sdk: ^3.7.2 - -resolution: workspace + sdk: '>=3.12.2 <4.0.0' dependencies: - equatable: ^2.0.2 - meta: ^1.9.1 + meta: ^1.15.0 dev_dependencies: checks: ^0.3.1 - lints: ^5.1.1 - test: ^1.26.2 + lints: ^6.1.0 + # test 1.31.2+ requires analyzer >=13, we keep this pinned below that + # for Flutter 3.44 compatibility. + test: '>=1.25.0 <1.31.2' diff --git a/packages/shape/test/src/annotations/form_body_annotation_test.dart b/packages/shape/test/src/annotations/form_body_annotation_test.dart index f01bde9..5d31472 100644 --- a/packages/shape/test/src/annotations/form_body_annotation_test.dart +++ b/packages/shape/test/src/annotations/form_body_annotation_test.dart @@ -12,10 +12,25 @@ void main() { check(buildSubject).returnsNormally().isA(); }); - test('has correct props', () { + test('compares equal when generateFormErrors matches', () { final subject = buildSubject(); - check(subject.props).deepEquals([subject.generateFormErrors]); + check(subject).equals(const GenerateFormBody(generateFormErrors: true)); + }); + + test('hashCode matches generateFormErrors', () { + check( + buildSubject(generateFormErrors: true).hashCode, + ).equals(true.hashCode); + check( + buildSubject(generateFormErrors: false).hashCode, + ).equals(false.hashCode); + }); + }); + + group('FieldRequired annotation', () { + test('can be constructed', () { + check(() => const FieldRequired()).returnsNormally().isA(); }); }); } diff --git a/packages/shape/test/src/form_body_test.dart b/packages/shape/test/src/form_body_test.dart index d14b8cc..f086f9e 100644 --- a/packages/shape/test/src/form_body_test.dart +++ b/packages/shape/test/src/form_body_test.dart @@ -1,19 +1,15 @@ -// ignore_for_file: prefer_const_constructors import 'package:checks/checks.dart'; import 'package:shape/shape.dart'; import 'package:test/test.dart' hide expect; -class TestFormBody extends FormBody with EquatableMixin { +class TestFormBody extends FormBody { const TestFormBody(); @override TestFormErrors validate() => const TestFormErrors([]); - - @override - List get props => []; } -class TestFormErrors extends FormErrors { +class TestFormErrors extends FormErrors { const TestFormErrors(this._errors); final List _errors; @@ -28,7 +24,7 @@ void main() { }); test('validate method returns correct type', () { - check(TestFormBody().validate()).isA(); + check(const TestFormBody().validate()).isA(); }); }); @@ -37,7 +33,7 @@ void main() { const nonEmptyFormErrors = TestFormErrors(['Error', 123, null]); test('can be instantiated', () { - check(() => TestFormErrors(const [])).returnsNormally(); + check(() => const TestFormErrors([])).returnsNormally(); }); test('TestFormErrors.errors field returns given errors', () { diff --git a/packages/shape/test/src/form_field_test.dart b/packages/shape/test/src/form_field_test.dart index dec4782..062cf02 100644 --- a/packages/shape/test/src/form_field_test.dart +++ b/packages/shape/test/src/form_field_test.dart @@ -3,19 +3,15 @@ import 'package:shape/shape.dart'; import 'package:test/test.dart' hide expect; class TestFormField extends FormField { - TestFormField({required R? rawValue, E? error}) - : _error = error, - super(rawValue); + TestFormField({required R? rawValue, this.error}) : super(rawValue); @override R? get value => rawValue; - final E? _error; + final E? error; @override - E? validate() { - return _error; - } + E? validate() => error; } void main() { diff --git a/packages/shape_generator/PANA_SCORE b/packages/shape_generator/PANA_SCORE index 8306ec1..fa8f08c 100644 --- a/packages/shape_generator/PANA_SCORE +++ b/packages/shape_generator/PANA_SCORE @@ -1 +1 @@ -130 \ No newline at end of file +150 diff --git a/packages/shape_generator/lib/src/extensions/dart_type_extensions.dart b/packages/shape_generator/lib/src/extensions/dart_type_extensions.dart index ef9c60a..9fba040 100644 --- a/packages/shape_generator/lib/src/extensions/dart_type_extensions.dart +++ b/packages/shape_generator/lib/src/extensions/dart_type_extensions.dart @@ -1,5 +1,5 @@ +import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:analyzer/dart/element/type.dart'; -import 'package:shape_generator/src/extensions/extensions.dart'; /// Extensions on [DartType] for convenience. extension DartTypeExtensions on DartType { @@ -9,8 +9,14 @@ extension DartTypeExtensions on DartType { /// DartType(String).nonNullableDisplayString; // "String" /// DartType(String?).nonNullableDisplayString; // "String" /// ``` - String get nonNullableDisplayString => - getDisplayString().removeIfPresent('?'); + String get nonNullableDisplayString { + final display = getDisplayString(); + if (nullabilitySuffix == NullabilitySuffix.question && + display.endsWith('?')) { + return display.substring(0, display.length - 1); + } + return display; + } /// The potentially nullable display string of this type. /// @@ -19,9 +25,7 @@ extension DartTypeExtensions on DartType { /// /// ```dart /// DartType(String).potentiallyNullableDisplayString; // "String" - /// DartType(String?).potentiallyNullableDisplayString; // "String" + /// DartType(String?).potentiallyNullableDisplayString; // "String?" /// ``` - String get potentiallyNullableDisplayString { - return getDisplayString(); - } + String get potentiallyNullableDisplayString => getDisplayString(); } diff --git a/packages/shape_generator/lib/src/generators/form_body_generator.dart b/packages/shape_generator/lib/src/generators/form_body_generator.dart index 1fdeec5..7cc007a 100644 --- a/packages/shape_generator/lib/src/generators/form_body_generator.dart +++ b/packages/shape_generator/lib/src/generators/form_body_generator.dart @@ -23,7 +23,7 @@ class FormBodyGenerator with SourceGenerator { final List fields; String _getValue(FormBodyFieldMetadata field) { - final name = field.fieldIdentifier.name; + final name = field.fieldName; if (!field.extendsFormField) { return name; } else { @@ -32,7 +32,7 @@ class FormBodyGenerator with SourceGenerator { } String _getRawValue(FormBodyFieldMetadata field) { - final name = field.fieldIdentifier.name; + final name = field.fieldName; if (!field.extendsFormField) { return name; } else { @@ -42,6 +42,13 @@ class FormBodyGenerator with SourceGenerator { @override void write(SourceBuffer buffer) { + final constructorArguments = fields + .map( + (field) => + field.isCustomWrapper ? field.fieldName : field.wrapperExpression, + ) + .join(', '); + buffer ..writeComment( 'Form Body "${generatedClassNames.generatedFormBodyClassName}"', @@ -50,23 +57,23 @@ class FormBodyGenerator with SourceGenerator { ..writeClassDeclarationStart( name: generatedClassNames.generatedFormBodyClassName, extendedClass: generatedClassNames.formBodyClassName, - mixins: [ - generatedClassNames.generatedFormBodyFieldsMixinName, - 'EquatableMixin', - ], ) - ..writeClassFactoryConstructor( + ..writeFactoryConstructorBody( className: generatedClassNames.generatedFormBodyClassName, - factoryName: '', - constructorName: '_', parameters: [ for (final field in fields) FunctionParameter( - type: field.formClassName, - name: field.fieldIdentifier.name, - isRequired: true, + type: field.isCustomWrapper + ? field.formClassName + : field.rawValueType.potentiallyNullableDisplayString, + name: field.fieldName, + isRequired: field.isFactoryParameterRequired, ), ], + body: + 'return ${generatedClassNames.generatedFormBodyClassName}._(' + '$constructorArguments,' + ');', ) ..writeClassConstructor( className: generatedClassNames.generatedFormBodyClassName, @@ -75,26 +82,25 @@ class FormBodyGenerator with SourceGenerator { for (final field in fields) FunctionParameter( type: field.formClassName, - name: '_${field.fieldIdentifier.name}', + name: '_${field.fieldName}', ), ], useConstConstructor: true, useNamedParameters: false, supertypeConstructorName: '_', - passParametersToSuper: false, ); for (final field in fields) { buffer ..writeClassField( type: field.formClassName, - name: '_${field.fieldIdentifier.name}', + name: '_${field.fieldName}', isFinal: true, isOverride: true, ) ..writeClassGetter( type: field.valueType.potentiallyNullableDisplayString, - name: field.fieldIdentifier.name, + name: field.fieldName, value: '_${_getValue(field)}', isOverride: true, ); @@ -113,7 +119,7 @@ class FormBodyGenerator with SourceGenerator { if (!enclosingClassOverridesValidateMethod) { final validationFields = fields .where((f) => f.extendsFormField) - .map((f) => f.fieldIdentifier.name); + .map((f) => f.fieldName); buffer.writeSingleReturnFunction( returnType: generatedClassNames.generatedFormErrorsClassName, functionName: kValidateMethodName, @@ -131,17 +137,9 @@ class FormBodyGenerator with SourceGenerator { value: '${generatedClassNames.generatedCopyWithImplClassName}(this)', isOverride: true, ) - ..writeClassGetter( - type: 'List<${'Object'.nullableTypeString}>', - name: 'props', - value: '[${fields.map((f) => '_${_getRawValue(f)},').join()}]', - isOverride: true, - ) - ..writeClassGetter( - type: 'bool', - name: 'stringify', - value: 'true', - isOverride: true, + ..writeEqualityOperators( + className: generatedClassNames.generatedFormBodyClassName, + equalityFields: [for (final field in fields) '_${_getRawValue(field)}'], ) ..writeClassDeclarationEnd() ..writeComment( @@ -157,8 +155,8 @@ class FormBodyGenerator with SourceGenerator { parameters: [ for (final field in fields) FunctionParameter( - type: field.rawValueType.potentiallyNullableDisplayString, - name: field.fieldIdentifier.name, + type: _copyWithParameterType(field), + name: field.fieldName, isRequired: false, ), ], @@ -187,11 +185,9 @@ class FormBodyGenerator with SourceGenerator { ) ..writeStaticConstClassField(name: '_defaultValue', value: 'Object()'); - final fieldNames = fields.map((f) => f.fieldIdentifier.name); + final fieldNames = fields.map((f) => f.fieldName); final copyWithFields = [ - for (final field in fields) - ''' -${field.fieldIdentifier.name}: ${field.fieldIdentifier.name} == _defaultValue ? _instance._${_getRawValue(field)} : ${field.fieldIdentifier.name} as ${field.rawValueType.potentiallyNullableDisplayString},''', + for (final field in fields) _copyWithArgument(field), ]; buffer @@ -206,10 +202,34 @@ ${field.fieldIdentifier.name}: ${field.fieldIdentifier.name} == _defaultValue ? defaultValue: '_defaultValue', ), ], + // Always call the generated factory so extra user-factory-only params + // (e.g. construction flags) are not required. returnValue: - '''${generatedClassNames.formBodyClassName}(${copyWithFields.join()})''', + '''${generatedClassNames.generatedFormBodyClassName}(${copyWithFields.join()})''', isOverride: true, ) ..writeClassDeclarationEnd(); } + + /// copyWith accepts FormField instances for custom wrappers and raw values + /// for inferred wrappers. + String _copyWithParameterType(FormBodyFieldMetadata field) { + if (field.isCustomWrapper) { + return field.formClassName; + } + return field.rawValueType.potentiallyNullableDisplayString; + } + + String _copyWithArgument(FormBodyFieldMetadata field) { + final name = field.fieldName; + if (field.isCustomWrapper) { + return ''' +$name: $name == _defaultValue ? _instance._$name : $name! as ${field.formClassName},'''; + } + + final rawType = field.rawValueType.potentiallyNullableDisplayString; + final cast = rawType.endsWith('?') ? 'as $rawType' : '! as $rawType'; + return ''' +$name: $name == _defaultValue ? _instance._${_getRawValue(field)} : $name $cast,'''; + } } diff --git a/packages/shape_generator/lib/src/generators/form_errors_generator.dart b/packages/shape_generator/lib/src/generators/form_errors_generator.dart index 1d3bd1a..b2a9d2f 100644 --- a/packages/shape_generator/lib/src/generators/form_errors_generator.dart +++ b/packages/shape_generator/lib/src/generators/form_errors_generator.dart @@ -34,9 +34,8 @@ class FormErrorsGenerator with SourceGenerator { 'The form errors for the form body ' '"${generatedClassNames.formBodyClassName}".', name: generatedClassNames.generatedFormErrorsClassName, - extendedClass: - 'FormErrors<${generatedClassNames.generatedFormBodyClassName}>', - mixins: ['EquatableMixin'], + extendedClass: 'FormErrors', + mixins: [], ) ..writeClassConstructor( documentation: @@ -49,7 +48,7 @@ class FormErrorsGenerator with SourceGenerator { FunctionParameter( // Doesn't show up in a constructor. type: field.errorType.nonNullableDisplayString.nullableTypeString, - name: field.fieldIdentifier.name, + name: field.fieldName, ), ], useConstConstructor: true, @@ -58,20 +57,21 @@ class FormErrorsGenerator with SourceGenerator { for (final field in fields) { buffer.writeClassField( - documentation: 'The error for the ${field.fieldIdentifier.name} field.', + documentation: 'The error for the ${field.fieldName} field.', type: field.errorType.nonNullableDisplayString.nullableTypeString, - name: field.fieldIdentifier.name, + name: field.fieldName, isFinal: true, ); } final mergeWhereEmptyWithFields = [ for (final field in fields) - '''${field.fieldIdentifier.name}: ${field.fieldIdentifier.name} ?? other.${field.fieldIdentifier.name},''', + '''${field.fieldName}: ${field.fieldName} ?? other.${field.fieldName},''', ]; buffer ..writeSingleReturnFunction( - documentation: ''' + documentation: + ''' Merges this ${generatedClassNames.generatedFormErrorsClassName} with the [other] by replacing any empty fields in this instance with the corresponding field in [other] while preserving the non-empty fields in this instance. @@ -99,20 +99,12 @@ Copies this ${generatedClassNames.generatedFormErrorsClassName} and replaces the ..writeClassGetter( type: 'List<${'Object'.nullableTypeString}>', name: 'errors', - value: '[${fields.map((f) => '${f.fieldIdentifier.name},').join()}]', + value: '[${fields.map((f) => '${f.fieldName},').join()}]', isOverride: true, ) - ..writeClassGetter( - type: 'List<${'Object'.nullableTypeString}>', - name: 'props', - value: 'errors', - isOverride: true, - ) - ..writeClassGetter( - type: 'bool', - name: 'stringify', - value: 'true', - isOverride: true, + ..writeEqualityOperators( + className: generatedClassNames.generatedFormErrorsClassName, + equalityFields: fields.map((field) => field.fieldName).toList(), ) ..writeClassDeclarationEnd() ..writeComment( @@ -128,12 +120,11 @@ Copies this ${generatedClassNames.generatedFormErrorsClassName} and replaces the parameters: [ for (final field in fields) FunctionParameter( - type: - field - .errorType - .potentiallyNullableDisplayString - .nullableTypeString, - name: field.fieldIdentifier.name, + type: field + .errorType + .potentiallyNullableDisplayString + .nullableTypeString, + name: field.fieldName, isRequired: false, ), ], @@ -165,8 +156,7 @@ Copies this ${generatedClassNames.generatedFormErrorsClassName} and replaces the final copyWithFields = [ for (final field in fields) - // ignore: no_adjacent_strings_in_list - '''${field.fieldIdentifier.name}: ${field.fieldIdentifier.name} == _defaultValue ? _instance.${field.fieldIdentifier.name} : ${field.fieldIdentifier.name} as ${field.errorType.potentiallyNullableDisplayString.nullableTypeString},''', + '''${field.fieldName}: ${field.fieldName} == _defaultValue ? _instance.${field.fieldName} : ${field.fieldName} as ${field.errorType.potentiallyNullableDisplayString.nullableTypeString},''', ]; buffer ..writeSingleReturnFunction( @@ -176,7 +166,7 @@ Copies this ${generatedClassNames.generatedFormErrorsClassName} and replaces the for (final field in fields) FunctionParameter( type: 'Object'.nullableTypeString, - name: field.fieldIdentifier.name, + name: field.fieldName, defaultValue: '_defaultValue', ), ], diff --git a/packages/shape_generator/lib/src/generators/form_fields_mixin_generator.dart b/packages/shape_generator/lib/src/generators/form_fields_mixin_generator.dart index 8ca2c05..e7c9a66 100644 --- a/packages/shape_generator/lib/src/generators/form_fields_mixin_generator.dart +++ b/packages/shape_generator/lib/src/generators/form_fields_mixin_generator.dart @@ -35,20 +35,20 @@ class FormFieldsMixinGenerator with SourceGenerator { for (final field in fields) { buffer ..writeBodylessClassGetter( - documentation: ''' -The internal ${field.fieldIdentifier.name} field form field. + documentation: + ''' +The internal ${field.fieldName} field form field. This property should not be exposed and is only to be used when implementing a custom `validate` method. ''', type: field.formClassName, - name: '_${field.fieldIdentifier.name}', + name: '_${field.fieldName}', ) ..writeBodylessClassGetter( - documentation: - 'The parsed value of the ${field.fieldIdentifier.name} field.', + documentation: 'The parsed value of the ${field.fieldName} field.', type: field.valueType.potentiallyNullableDisplayString, - name: field.fieldIdentifier.name, + name: field.fieldName, ); } diff --git a/packages/shape_generator/lib/src/generators/shape_generator.dart b/packages/shape_generator/lib/src/generators/shape_generator.dart index 2ecb09d..787d066 100644 --- a/packages/shape_generator/lib/src/generators/shape_generator.dart +++ b/packages/shape_generator/lib/src/generators/shape_generator.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:build/build.dart'; import 'package:shape/shape.dart'; @@ -13,6 +14,11 @@ import 'package:source_gen/source_gen.dart'; /// The [Generator] for Shape. class ShapeGenerator extends GeneratorForAnnotation { + static const _fieldRequiredChecker = TypeChecker.typeNamed( + FieldRequired, + inPackage: 'shape', + ); + @override FutureOr generateForAnnotatedElement( Element element, @@ -30,7 +36,12 @@ class ShapeGenerator extends GeneratorForAnnotation { formBodyClassName: classMetadata.name, ); - final buffer = SourceBuffer(); + final buffer = SourceBuffer() + ..writeComment( + 'ignore_for_file: unused_element, ' + 'cast_nullable_to_non_nullable, ' + 'prefer_const_constructors_in_immutables', + ); try { FormBodyGenerator( @@ -56,7 +67,7 @@ class ShapeGenerator extends GeneratorForAnnotation { return buffer.dump(); } catch (e) { throw Exception(''' -An unknown error occurred while generating the form body for "${element.name}". +An unknown error occurred while generating the form body for "${element.name ?? ''}". Please make sure your class is valid and try again. If this issue keeps occurring please report an issue at @@ -97,14 +108,16 @@ $e final isAbstract = classMetadata.isAbstract; final extendsFormBody = classMetadata.supertype != null && - classMetadata.supertype!.nonNullableDisplayString.startsWith( - kFormBodyBaseClassName, - ); - final hasNamelessFactoryConstructor = classMetadata.constructors.any( - (c) => c.name == '' && c.isFactory, + classMetadata.supertype!.nonNullableDisplayString == + kFormBodyBaseClassName; + final hasGenerativeConstructor = classMetadata.constructors.any( + (constructor) => + !constructor.isFactory && + constructor.name == '_' && + constructor.formalParameters.isEmpty, ); - final hasPrivateConstructor = classMetadata.constructors.any( - (c) => c.isPrivate && c.name == '_' && c.isConst && c.parameters.isEmpty, + final hasNamelessFactoryConstructor = classMetadata.constructors.any( + (c) => (c.name == null || c.name == '' || c.name == 'new') && c.isFactory, ); final validateMethodOverrides = classMetadata.methods.where( @@ -116,8 +129,8 @@ $e final isValid = isAbstract && extendsFormBody && + hasGenerativeConstructor && hasNamelessFactoryConstructor && - hasPrivateConstructor && hasValidValidateMethod; if (!isValid) { @@ -127,13 +140,9 @@ The class "${classMetadata.name}" is not a valid form body. Please make sure your form body class: ${isAbstract ? '✅' : '❌'} is abstract. ${extendsFormBody ? '✅' : '❌'} extends ${generatedClassNames.extendingFormBodyClassName}. +${hasGenerativeConstructor ? '✅' : '❌'} has a private parameterless generative constructor ("const ${classMetadata.name}._();"). ${hasNamelessFactoryConstructor ? '✅' : '❌'} has a nameless factory constructor that returns a "${generatedClassNames.generatedFormBodyClassName}". -${hasPrivateConstructor ? '✅' : '❌'} has a private const constructor ("const ${classMetadata.name}._()"). ${hasValidValidateMethod ? '✅' : '❌'} has no validate method OR a single validate method that returns a "${generatedClassNames.generatedFormErrorsClassName}". - -❗️❗️❗️ -Additionally, make sure the form body class mixes in the ${generatedClassNames.generatedFormBodyFieldsMixinName} mixin. -❗️❗️❗️ '''); } } @@ -191,26 +200,23 @@ that returns an instance of "${generatedClassNames.generatedFormBodyClassName}". buildStep, ); - final constructorBodies = [ - for (final constructorDeclarationNodes in constructorDeclarationNodes) - _getConstructorBody(constructorDeclarationNodes), + final constructorReturnExpressions = [ + for (final constructorDeclaration in constructorDeclarationNodes) + _getReturnExpression(constructorDeclaration), ]; - - final constructorReturnStatements = [ - for (final constructorBody in constructorBodies) - if (constructorBody == null) - null - else - _getReturnStatement(constructorBody), + final constructorRedirectTargetNames = [ + for (final constructorDeclaration in constructorDeclarationNodes) + _getRedirectTargetName(constructorDeclaration), ]; - final result = [ for (var i = 0; i < classMetadata.constructors.length; i++) ClientConstructorMetadata( - name: classMetadata.constructors[i].name, + name: classMetadata.constructors[i].name ?? 'new', enclosingClass: classMetadata.constructors[i].returnType, isFactory: classMetadata.constructors[i].isFactory, - returnStatement: constructorReturnStatements[i], + returnExpression: constructorReturnExpressions[i], + redirectTarget: classMetadata.constructors[i].redirectedConstructor, + redirectTargetName: constructorRedirectTargetNames[i], ), ]; @@ -224,7 +230,7 @@ that returns an instance of "${generatedClassNames.generatedFormBodyClassName}". final result = []; for (final constructor in constructors) { final astNode = await buildStep.resolver.astNodeFor( - constructor, + constructor.firstFragment, resolve: true, ); final visitor = _ConstructorAstVisitor(); @@ -237,30 +243,44 @@ that returns an instance of "${generatedClassNames.generatedFormBodyClassName}". return result; } - BlockFunctionBody? _getConstructorBody(ConstructorDeclaration declaration) { - for (final childEntity in declaration.childEntities) { - if (childEntity is BlockFunctionBody) { - return childEntity; - } + String? _getRedirectTargetName(ConstructorDeclaration declaration) { + final redirect = declaration.redirectedConstructor; + if (redirect == null) { + return null; } - return null; + return redirect.type.name.lexeme; } - ReturnStatement _getReturnStatement(BlockFunctionBody body) { - for (final childEntity in body.block.statements) { - if (childEntity is ReturnStatement) { - return childEntity; + Expression? _getReturnExpression(ConstructorDeclaration declaration) { + if (declaration.redirectedConstructor != null) { + return null; + } + + for (final childEntity in declaration.childEntities) { + if (childEntity is BlockFunctionBody) { + for (final statement in childEntity.block.statements) { + if (statement is ReturnStatement) { + return statement.expression; + } + } + } + if (childEntity is ExpressionFunctionBody) { + return childEntity.expression; } } - throw Exception('No return statement found in body. "$body"'); + return null; } Future> _getFormBodyFieldMetadata( Element element, BuildStep buildStep, ) async { + if (element is! ClassElement) { + throw Exception('Expected a ClassElement.'); + } + final classMetadata = ClientClassMetadata.fromElement(element); final generatedClassNames = GeneratedClassNames( formBodyClassName: classMetadata.name, @@ -271,11 +291,20 @@ that returns an instance of "${generatedClassNames.generatedFormBodyClassName}". buildStep, ).then((results) => results.firstWhere((c) => c.isValid)); - final returnStatement = constructorMetadata.returnStatement; + final returnExpression = constructorMetadata.returnExpression; + if (constructorMetadata.redirectTarget != null || + constructorMetadata.redirectTargetName != null) { + return _buildFieldsFromFactoryParameters( + element: element, + buildStep: buildStep, + classMetadata: classMetadata, + generatedClassNames: generatedClassNames, + ); + } - // TODO(jeroen-meijer): Support identifiers and other value references - final expression = returnStatement!.expression; - if (expression is! MethodInvocation) { + final expression = returnExpression; + if (expression is! MethodInvocation && + expression is! InstanceCreationExpression) { throw Exception(''' No method invocation found in return statement. @@ -287,23 +316,14 @@ by a constructor invocation of the form body class. Please make sure your return statement looks like the following: return ${generatedClassNames.generatedFormBodyClassName}( - foo: FooFormField( - value: 'abc', - ), - bar: BarFormField( - value: 123, - ), + foo: foo, + bar: bar, ); -It is allowed, however, to refer to form fields by variable name, like the -following: - - final foo = FooFormField(value: 'abc'); - final bar = BarFormField(value: 123); +For custom form fields, pass a form field constructor invocation: return ${generatedClassNames.generatedFormBodyClassName}( - foo: foo, - bar: bar, + foo: FooFormField(rawValue: foo), ); ----------------------------------------------- @@ -311,20 +331,34 @@ following: Expression found was: "$expression".'''); } - final invocation = expression; - final generatedFormBodyClassName = invocation.methodName; + final generatedFormBodyClassName = expression is MethodInvocation + ? expression.methodName + : (expression as InstanceCreationExpression).constructorName.name!; assert( generatedFormBodyClassName.name == - constructorMetadata.returnStatementType.name, + constructorMetadata.returnExpressionTypeName, ); assert( generatedFormBodyClassName.name == generatedClassNames.generatedFormBodyClassName, ); - final result = []; + final factoryConstructor = classMetadata.constructors.firstWhere( + (constructor) => + (constructor.name == null || + constructor.name == '' || + constructor.name == 'new') && + constructor.isFactory, + ); + final factoryParameters = { + for (final parameter in factoryConstructor.formalParameters) + if (parameter.name != null) parameter.name!: parameter, + }; - final formBodyArguments = invocation.argumentList.arguments; + final result = []; + final formBodyArguments = expression is MethodInvocation + ? expression.argumentList.arguments + : (expression as InstanceCreationExpression).argumentList.arguments; for (var i = 0; i < formBodyArguments.length; i++) { final formBodyArgument = formBodyArguments[i]; @@ -336,8 +370,7 @@ is not a named argument. ----------------------------------------------- -Please make sure that all arguments are named -parameters. +Please make sure that all arguments are named parameters. ----------------------------------------------- @@ -345,10 +378,10 @@ Argument found: "$formBodyArgument" (of type ${formBodyArgument.runtimeType})''' ); } - final formFieldIdentifier = formBodyArgument.name.label; - if (formFieldIdentifier.name.startsWith('_')) { + final formFieldName = formBodyArgument.name.label.name; + if (formFieldName.startsWith('_')) { throw Exception(''' -The form field with name "$formFieldIdentifier" is not a valid identifier. +The form field with name "$formFieldName" is not a valid identifier. ----------------------------------------------- @@ -357,97 +390,234 @@ not start with an underscore. ----------------------------------------------- -Form field name found: "$formFieldIdentifier"'''); +Form field name found: "$formFieldName"'''); } - final formFieldCreationExpression = formBodyArgument.expression; - final formFieldExpressionType = formFieldCreationExpression.staticType; - ClassElement? formFieldClassElement; + final argumentExpression = formBodyArgument.expression; + final factoryParameter = factoryParameters[formFieldName]; + if (factoryParameter == null) { + throw Exception( + 'Factory parameter "$formFieldName" was not found on ' + '"${classMetadata.name}".', + ); + } - final element = formFieldCreationExpression.staticType?.element; - if (element is ClassElement) { - formFieldClassElement = element; + if (argumentExpression is SimpleIdentifier) { + result.add( + await _buildInferredFormBodyFieldMetadata( + element: element, + buildStep: buildStep, + fieldName: formFieldName, + factoryParameter: factoryParameter, + ), + ); + continue; } - if (formFieldClassElement == null) { - throw Exception( - ''' -Could not determine the type of the form field with name "$formBodyArgument". -The form field is not a class or a simple identifier. + if (argumentExpression is MethodInvocation || + argumentExpression is InstanceCreationExpression) { + result.add( + _buildCustomFormBodyFieldMetadata( + fieldName: formFieldName, + argumentExpression: argumentExpression, + factoryParameter: factoryParameter, + ), + ); + continue; + } ------------------------------------------------ + throw Exception(''' +Could not determine how to wrap the form field with name "$formFieldName". -Make sure that you have imported all necessary libraries and that the referenced -form field exists. +Pass either the factory parameter directly for automatic GenericFormField +wrapping, or a form field constructor invocation for custom validation. ------------------------------------------------ +Expression found: "$argumentExpression".'''); + } -If the form field declaration exists and has been imported, this should be -considered a bug in the shape_generator package. Please report it. + return result; + } -Expression found: $formFieldCreationExpression (with static type ${formFieldCreationExpression.staticType} and runtime type ${formFieldCreationExpression.runtimeType})''', - ); + Future> _buildFieldsFromFactoryParameters({ + required ClassElement element, + required BuildStep buildStep, + required ClientClassMetadata classMetadata, + required GeneratedClassNames generatedClassNames, + }) async { + final factoryConstructor = classMetadata.constructors.firstWhere( + (constructor) => + (constructor.name == null || + constructor.name == '' || + constructor.name == 'new') && + constructor.isFactory, + ); + + final result = []; + for (final parameter in factoryConstructor.formalParameters) { + if (parameter.name == null) { + continue; } - final formFieldClassMetadata = ClientClassMetadata.fromElement( - formFieldClassElement, - withInstanceType: formFieldExpressionType, + result.add( + await _buildInferredFormBodyFieldMetadata( + element: element, + buildStep: buildStep, + fieldName: parameter.name!, + factoryParameter: parameter, + ), ); + } - final instanceTypeArguments = []; + return result; + } - if (formFieldClassMetadata.instanceType != null && - formFieldClassMetadata.instanceType is ParameterizedType) { - final instanceType = - formFieldClassMetadata.instanceType! as ParameterizedType; - instanceTypeArguments.addAll(instanceType.typeArguments); - } + Future _buildInferredFormBodyFieldMetadata({ + required ClassElement element, + required BuildStep buildStep, + required String fieldName, + required FormalParameterElement factoryParameter, + }) async { + final genericFormFieldClass = await _findGenericFormFieldClass( + element, + buildStep, + ); + if (genericFormFieldClass == null) { + throw Exception(''' +Could not infer a form field wrapper for "$fieldName". - if (formFieldClassMetadata.typeParameters.length != - instanceTypeArguments.length) { - throw Exception( - ''' -The number of type parameters of the form field class "${formFieldClassMetadata.name}" -does not match the number of type arguments of the instance of the form field. +Import `package:shape_starter_kit/shape_starter_kit.dart` to use automatic +GenericFormField wrapping, or pass an explicit form field constructor. +'''); + } ------------------------------------------------ + final parameterType = factoryParameter.type; + final isRequired = + factoryParameter.isRequired || + _hasFieldRequiredAnnotation(factoryParameter); + + final wrapperExpression = + 'GenericFormField<${parameterType.getDisplayString()}>' + '($fieldName${isRequired ? ', isRequired: true' : ''})'; + + return FormBodyFieldMetadata( + fieldName: fieldName, + formClassMetadata: ClientClassMetadata.fromElement(genericFormFieldClass), + wrapperExpression: wrapperExpression, + isFactoryParameterRequired: factoryParameter.isRequired, + genericTypeArguments: { + if (genericFormFieldClass.typeParameters.isNotEmpty) + genericFormFieldClass.typeParameters.first: parameterType, + }, + ); + } -You can try fixing this by explicitly defining the generic type arguments. -For example, when using a GenericFormField for a String, the type for T can -be provided as follows: + FormBodyFieldMetadata _buildCustomFormBodyFieldMetadata({ + required String fieldName, + required Expression argumentExpression, + required FormalParameterElement factoryParameter, + }) { + final formFieldExpressionType = argumentExpression.staticType; + ClassElement? formFieldClassElement; + + final expressionElement = formFieldExpressionType?.element; + if (expressionElement is ClassElement) { + formFieldClassElement = expressionElement; + } - MyFormField( - someField: GenericType(...), - ) + if (formFieldClassElement == null) { + throw Exception(''' +Could not determine the type of the form field with name "$fieldName". +The form field is not a class or a simple identifier. ----------------------------------------------- -If the above has already been done, this should be considered a bug in the -shape_generator package. Please report it. +Make sure that you have imported all necessary libraries and that the referenced +form field exists. + +Expression found: $argumentExpression'''); + } + + final formFieldClassMetadata = ClientClassMetadata.fromElement( + formFieldClassElement, + withInstanceType: formFieldExpressionType, + ); + + final instanceTypeArguments = []; + + if (formFieldClassMetadata.instanceType != null && + formFieldClassMetadata.instanceType is ParameterizedType) { + final instanceType = + formFieldClassMetadata.instanceType! as ParameterizedType; + instanceTypeArguments.addAll(instanceType.typeArguments); + } + + if (formFieldClassMetadata.typeParameters.length != + instanceTypeArguments.length) { + throw Exception( + ''' +The number of type parameters of the form field class "${formFieldClassMetadata.name}" +does not match the number of type arguments of the instance of the form field. Type parameters found: "${formFieldClassMetadata.typeParameters}" (length ${formFieldClassMetadata.typeParameters.length}) Instance type arguments found: "$instanceTypeArguments" (length ${instanceTypeArguments.length})''', - ); - } - - final formBodyFieldMetadata = FormBodyFieldMetadata( - fieldIdentifier: formFieldIdentifier, - formClassMetadata: formFieldClassMetadata, - genericTypeArguments: { - for ( - var i = 0; - i < formFieldClassMetadata.typeParameters.length; - i++ - ) ...{ - formFieldClassMetadata.typeParameters[i]: instanceTypeArguments[i], - }, - }, ); + } + + return FormBodyFieldMetadata( + fieldName: fieldName, + formClassMetadata: formFieldClassMetadata, + wrapperExpression: argumentExpression.toSource(), + isCustomWrapper: true, + // Non-nullable FormField params are required here. + isFactoryParameterRequired: + factoryParameter.isRequired || + formFieldExpressionType!.nullabilitySuffix != + NullabilitySuffix.question, + genericTypeArguments: { + for (var i = 0; i < formFieldClassMetadata.typeParameters.length; i++) + formFieldClassMetadata.typeParameters[i]: instanceTypeArguments[i], + }, + ); + } - result.add(formBodyFieldMetadata); + Future _findGenericFormFieldClass( + ClassElement element, + BuildStep buildStep, + ) async { + final local = element.library.getClass('GenericFormField'); + if (local != null) { + return local; } - return result; + for (final imported in element.library.firstFragment.importedLibraries) { + final genericFormField = imported.getClass('GenericFormField'); + if (genericFormField != null) { + return genericFormField; + } + } + + for (final uri in const [ + 'package:shape_starter_kit/shape_starter_kit.dart', + 'package:shape_starter_kit/src/form_fields/generic_form_field.dart', + ]) { + try { + final library = await buildStep.resolver.libraryFor( + AssetId.resolve(Uri.parse(uri), from: buildStep.inputId), + ); + final genericFormField = library.getClass('GenericFormField'); + if (genericFormField != null) { + return genericFormField; + } + } on Object { + continue; + } + } + + return null; + } + + bool _hasFieldRequiredAnnotation(FormalParameterElement parameter) { + return _fieldRequiredChecker.hasAnnotationOfExact(parameter); } } diff --git a/packages/shape_generator/lib/src/generators/source_generator.dart b/packages/shape_generator/lib/src/generators/source_generator.dart index 6cabc71..0818bdc 100644 --- a/packages/shape_generator/lib/src/generators/source_generator.dart +++ b/packages/shape_generator/lib/src/generators/source_generator.dart @@ -128,6 +128,7 @@ class SourceBuffer { required String functionName, List parameters = const [], bool isOverride = false, + bool useNamedParameters = true, }) { _writeDocumentation(documentation); @@ -136,13 +137,12 @@ class SourceBuffer { } if (parameters.isEmpty) { _writeln('$returnType $functionName()'); - } else { + } else if (useNamedParameters) { _writeln('$returnType $functionName({'); for (final parameter in parameters) { - final defaultValueClause = - parameter.defaultValue == null - ? '' - : ' = ${parameter.defaultValue}'; + final defaultValueClause = parameter.defaultValue == null + ? '' + : ' = ${parameter.defaultValue}'; if (parameter.isRequired) { _writeln( '''$_required ${parameter.type} ${parameter.name}$defaultValueClause,''', @@ -152,6 +152,11 @@ class SourceBuffer { } } _writeln('})'); + } else { + final parameterList = parameters + .map((parameter) => '${parameter.type} ${parameter.name}') + .join(', '); + _writeln('$returnType $functionName($parameterList)'); } } @@ -199,8 +204,9 @@ class SourceBuffer { String constructorName = '_', List parameters = const [], }) { - final fullFactoryName = - factoryName.isEmpty ? className : '$className.$factoryName'; + final fullFactoryName = factoryName.isEmpty + ? className + : '$className.$factoryName'; final parameterNames = parameters.map((p) => p.name); writeSingleReturnFunction( @@ -234,8 +240,9 @@ class SourceBuffer { _writeDocumentation(documentation); - final fullConstructorName = - constructorName.isEmpty ? className : '$className.$constructorName'; + final fullConstructorName = constructorName.isEmpty + ? className + : '$className.$constructorName'; final privateInstanceParameterNames = parameters.map( (p) => 'this.${p.name},', ); @@ -267,7 +274,6 @@ class SourceBuffer { if (supertypeConstructorName != null) { _writeln( - // ignore: missing_whitespace_between_adjacent_strings ''' : super.$supertypeConstructorName(${!passParametersToSuper ? '' : privateInstanceParameterNames.join()})''', ); } @@ -358,6 +364,7 @@ class SourceBuffer { required String functionName, List parameters = const [], bool isOverride = false, + bool useNamedParameters = true, }) { writeFunctionSignature( documentation: documentation, @@ -365,6 +372,7 @@ class SourceBuffer { functionName: functionName, parameters: parameters, isOverride: isOverride, + useNamedParameters: useNamedParameters, ); _writeln(' {'); } @@ -443,6 +451,65 @@ class SourceBuffer { void writeMixinDeclarationEnd() { _writeln('}'); } + + /// Writes a factory constructor with a custom body. + void writeFactoryConstructorBody({ + required String className, + List parameters = const [], + required String body, + }) { + writeFunctionStart( + returnType: 'factory', + functionName: className, + parameters: parameters, + ); + _writeln(body); + writeFunctionEnd(); + } + + /// Writes [operator ==] and [hashCode] based on [equalityFields]. + void writeEqualityOperators({ + required String className, + required List equalityFields, + }) { + if (equalityFields.isEmpty) { + writeSingleReturnFunction( + returnType: 'bool', + functionName: 'operator ==', + parameters: [const FunctionParameter(type: 'Object', name: 'other')], + returnValue: 'identical(this, other)', + isOverride: true, + ); + writeClassGetter( + type: 'int', + name: 'hashCode', + value: 'identityHashCode(this)', + isOverride: true, + ); + return; + } + + writeFunctionStart( + returnType: 'bool', + functionName: 'operator ==', + parameters: [const FunctionParameter(type: 'Object', name: 'other')], + isOverride: true, + useNamedParameters: false, + ); + _writeln('return other is $className'); + for (final field in equalityFields) { + _writeln('&& other.$field == $field'); + } + _writeln(';'); + writeFunctionEnd(); + + writeClassGetter( + type: 'int', + name: 'hashCode', + value: 'Object.hash(${equalityFields.map((field) => field).join(', ')})', + isOverride: true, + ); + } } /// A mixin that provides methods for writing Dart code to a [SourceBuffer]. diff --git a/packages/shape_generator/lib/src/models/client_class_metadata.dart b/packages/shape_generator/lib/src/models/client_class_metadata.dart index fdc1a8c..06cf4ba 100644 --- a/packages/shape_generator/lib/src/models/client_class_metadata.dart +++ b/packages/shape_generator/lib/src/models/client_class_metadata.dart @@ -1,7 +1,7 @@ import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:analyzer/dart/element/type_visitor.dart'; -import 'package:analyzer/dart/element/visitor.dart'; import 'package:meta/meta.dart'; /// {@template client_class_metadata} @@ -35,9 +35,6 @@ class ClientClassMetadata { throw Exception('Expected a ClassElement.'); } - final classVisitor = _DefaultClassVisitor(); - element.visitChildren(classVisitor); - final typeVisitor = _DefaultTypeVisitor(); element.thisType.accept(typeVisitor); @@ -48,13 +45,26 @@ class ClientClassMetadata { isAbstract: element.isAbstract, isEnum: element is EnumElement, isMixin: element is MixinElement, - constructors: - classVisitor.constructors - .where((constructor) => !constructor.isSynthetic) - .toList(), - fields: classVisitor.fields, - methods: classVisitor.methods, - typeParameters: classVisitor.typeParameters, + constructors: element.constructors + .where((constructor) => !constructor.isOriginImplicitDefault) + .toList(), + fields: { + for (final field in element.fields) + if (field.name != null) field.name!: field.type, + }, + methods: [ + for (final method in element.methods) + if (method.name != null) + ClientClassMethodMetadata._( + name: method.name!, + returnType: method.returnType, + parameters: method.formalParameters, + isAbstract: method.isAbstract, + isStatic: method.isStatic, + hasOverride: method.metadata.hasOverride, + ), + ], + typeParameters: element.typeParameters, ); } @@ -97,7 +107,14 @@ class ClientClassMetadata { final List typeParameters; /// The base name of the class (based on the [baseType]). - String get name => baseType.getDisplayString(); + String get name { + final display = baseType.getDisplayString(); + if (baseType.nullabilitySuffix == NullabilitySuffix.question && + display.endsWith('?')) { + return display.substring(0, display.length - 1); + } + return display; + } /// Whether this is a valid class or subclass that can be used to generate /// form body code. @@ -140,7 +157,7 @@ class ClientClassMethodMetadata { final DartType returnType; /// The parameters of the method. - final List parameters; + final List parameters; /// Indicates whether the method is abstract. final bool isAbstract; @@ -164,47 +181,6 @@ class ClientClassMethodMetadata { } } -class _DefaultClassVisitor extends SimpleElementVisitor { - final constructors = []; - final fields = {}; - final methods = []; - final typeParameters = []; - - @override - dynamic visitConstructorElement(ConstructorElement element) { - constructors.add(element); - return super.visitConstructorElement(element); - } - - @override - dynamic visitFieldElement(FieldElement element) { - fields[element.name] = element.type; - - return super.visitFieldElement(element); - } - - @override - dynamic visitTypeParameterElement(TypeParameterElement element) { - typeParameters.add(element); - return super.visitTypeParameterElement(element); - } - - @override - dynamic visitMethodElement(MethodElement element) { - methods.add( - ClientClassMethodMetadata._( - name: element.name, - returnType: element.returnType, - parameters: element.parameters, - isAbstract: element.isAbstract, - isStatic: element.isStatic, - hasOverride: element.hasOverride, - ), - ); - return super.visitMethodElement(element); - } -} - class _DefaultTypeVisitor extends TypeVisitor { final _types = []; DartType? get type => _types.isEmpty ? null : _types.last; diff --git a/packages/shape_generator/lib/src/models/client_constructor_metadata.dart b/packages/shape_generator/lib/src/models/client_constructor_metadata.dart index 96534d5..5d24540 100644 --- a/packages/shape_generator/lib/src/models/client_constructor_metadata.dart +++ b/packages/shape_generator/lib/src/models/client_constructor_metadata.dart @@ -1,4 +1,5 @@ import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:shape_generator/src/extensions/extensions.dart'; import 'package:shape_generator/src/models/models.dart'; @@ -12,7 +13,9 @@ class ClientConstructorMetadata { required this.name, required this.enclosingClass, required this.isFactory, - required this.returnStatement, + required this.returnExpression, + this.redirectTarget, + this.redirectTargetName, }); /// The name of the constructor. @@ -24,39 +27,60 @@ class ClientConstructorMetadata { /// Indicates whether the constructor is a factory constructor. final bool isFactory; - /// The return statement of the constructor. - final ReturnStatement? returnStatement; + /// The expression returned from the constructor. + final Expression? returnExpression; + + /// The constructor target for a redirecting factory constructor. + final ConstructorElement? redirectTarget; + + /// The redirect target class name from source, when [redirectTarget] is + /// unresolved. + final String? redirectTargetName; GeneratedClassNames get _classNames => GeneratedClassNames( formBodyClassName: enclosingClass.nonNullableDisplayString, ); - /// The return type of the return statement. - SimpleIdentifier get returnStatementType { - if (returnStatement?.expression is MethodInvocation) { - return (returnStatement!.expression! as MethodInvocation).methodName; + /// The name of the type returned from the constructor. + String get returnExpressionTypeName { + if (redirectTarget != null) { + return redirectTarget!.enclosingElement.name ?? ''; + } + if (redirectTargetName != null) { + return redirectTargetName!; + } + + final expression = returnExpression; + if (expression is MethodInvocation) { + return expression.methodName.name; + } + if (expression is InstanceCreationExpression) { + return expression.constructorName.name?.name ?? + expression.constructorName.type.name.lexeme; } throw Exception( - 'Expression following return statement was not a method invocation.', + 'Expression following return statement was not a constructor invocation.', ); } /// Indicates whether the constructor is unnamed. - bool get isUnnamed => name.isEmpty; + /// + /// Analyzer 14 reports the unnamed constructor name as `new`. + bool get isUnnamed => name.isEmpty || name == 'new'; - /// Indicates whether the [returnStatementType]'s name is the name of the + /// Indicates whether [returnExpressionTypeName] is the name of the /// [enclosingClass], prepended with `_$` (the [kGeneratedClassPrefix]). - /// - /// For example, if the enclosing class is `Foo`, the return statement type - /// should be `_$Foo`, in which case this will be `true`. bool get hasValidReturnStatementType => - returnStatementType.name == _classNames.generatedFormBodyClassName; + returnExpressionTypeName == _classNames.generatedFormBodyClassName; /// Indicates whether the constructor is valid. bool get isValid => isFactory && - returnStatement?.expression is MethodInvocation && + (redirectTarget != null || + redirectTargetName != null || + returnExpression is MethodInvocation || + returnExpression is InstanceCreationExpression) && hasValidReturnStatementType; @override @@ -65,7 +89,7 @@ class ClientConstructorMetadata { 'name: $name, ' 'enclosingClass: $enclosingClass, ' 'isFactory: $isFactory, ' - 'returnStatementType: $returnStatementType' + 'returnExpressionTypeName: $returnExpressionTypeName' ')'; } } diff --git a/packages/shape_generator/lib/src/models/form_body_field_metadata.dart b/packages/shape_generator/lib/src/models/form_body_field_metadata.dart index 24ea09b..76a4b87 100644 --- a/packages/shape_generator/lib/src/models/form_body_field_metadata.dart +++ b/packages/shape_generator/lib/src/models/form_body_field_metadata.dart @@ -1,4 +1,3 @@ -import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:shape_generator/src/extensions/extensions.dart'; @@ -10,28 +9,40 @@ import 'package:shape_generator/src/models/models.dart'; class FormBodyFieldMetadata { /// {@macro form_body_field_metadata} const FormBodyFieldMetadata({ - required this.fieldIdentifier, + required this.fieldName, required this.formClassMetadata, + required this.wrapperExpression, + this.isCustomWrapper = false, + this.isFactoryParameterRequired = false, this.genericTypeArguments = const {}, }); /// The name of the field to be used in the form body. /// - /// ``` + /// ```dart /// MyFormBody( /// age: AgeFormField(...) /// ) /// ``` /// means that the field `age` will be used in the form body. - final SimpleIdentifier fieldIdentifier; + final String fieldName; /// The metadata for the form class being used. final ClientClassMetadata formClassMetadata; + /// The expression used to construct the form field in the generated factory. + final String wrapperExpression; + + /// Indicates whether the wrapper is explicitly provided by user code. + final bool isCustomWrapper; + + /// Whether the user-facing factory parameter is required. + final bool isFactoryParameterRequired; + /// A [Map] of the relationships between the generic type arguments of the /// [formClassType] and the types assigned to those generics. /// - /// ``` + /// ```dart /// class SomeFormField extends FormField {...} /// /// final myFormField = new SomeFormField(...); @@ -58,6 +69,14 @@ class FormBodyFieldMetadata { /// `class NameFormField extends FormField` /// means that the class [formClassName] is `NameFormField`. String get formClassName { + if (genericTypeArguments.isNotEmpty && + formClassMetadata.typeParameters.isNotEmpty) { + final typeArguments = genericTypeArguments.values + .map((type) => type.getDisplayString()) + .join(', '); + final baseName = formClassMetadata.name.split('<').first; + return '$baseName<$typeArguments>'; + } return _formClassType.potentiallyNullableDisplayString; } @@ -66,14 +85,16 @@ class FormBodyFieldMetadata { /// If [extendsFormField] is `false`, this will return the [formClassMetadata] /// instance type or base type. DartType get rawValueType { + if (extendsFormField && genericTypeArguments.length == 1) { + return genericTypeArguments.values.first; + } if (!extendsFormField) { return _formClassType; } - final resolved = - formClassMetadata.instanceType!.asInstanceOf( - formClassMetadata.supertype!.element, - )!; + final resolved = formClassMetadata.instanceType!.asInstanceOf( + formClassMetadata.supertype!.element, + )!; return resolved.typeArguments[0]; } @@ -82,14 +103,16 @@ class FormBodyFieldMetadata { /// If [extendsFormField] is `false`, this will return the [formClassMetadata] /// instance type or base type. DartType get valueType { + if (extendsFormField && genericTypeArguments.length == 1) { + return genericTypeArguments.values.first; + } if (!extendsFormField) { return _formClassType; } - final resolved = - formClassMetadata.instanceType!.asInstanceOf( - formClassMetadata.supertype!.element, - )!; + final resolved = formClassMetadata.instanceType!.asInstanceOf( + formClassMetadata.supertype!.element, + )!; return resolved.typeArguments[1]; } @@ -102,17 +125,18 @@ class FormBodyFieldMetadata { return _formClassType; } - final resolved = - formClassMetadata.instanceType!.asInstanceOf( - formClassMetadata.supertype!.element, - )!; + final resolved = formClassMetadata.instanceType!.asInstanceOf( + formClassMetadata.supertype!.element, + )!; return resolved.typeArguments[2]; } @override String toString() => 'FormBodyFieldMetadata(' - 'fieldIdentifier: $fieldIdentifier, ' + 'fieldName: $fieldName, ' + 'wrapperExpression: $wrapperExpression, ' + 'isCustomWrapper: $isCustomWrapper, ' 'formClassMetadata: $formClassMetadata, ' 'genericTypeArguments: $genericTypeArguments' ')'; @@ -120,13 +144,17 @@ class FormBodyFieldMetadata { /// Creates a copy of this [FormBodyFieldMetadata] with the given fields /// replaced with the new values. FormBodyFieldMetadata copyWith({ - SimpleIdentifier? fieldIdentifier, + String? fieldName, ClientClassMetadata? formClassMetadata, + String? wrapperExpression, + bool? isCustomWrapper, Map? genericTypeArguments, }) { return FormBodyFieldMetadata( - fieldIdentifier: fieldIdentifier ?? this.fieldIdentifier, + fieldName: fieldName ?? this.fieldName, formClassMetadata: formClassMetadata ?? this.formClassMetadata, + wrapperExpression: wrapperExpression ?? this.wrapperExpression, + isCustomWrapper: isCustomWrapper ?? this.isCustomWrapper, genericTypeArguments: genericTypeArguments ?? this.genericTypeArguments, ); } diff --git a/packages/shape_generator/lib/src/models/generated_class_names.dart b/packages/shape_generator/lib/src/models/generated_class_names.dart index 04273dd..43e6337 100644 --- a/packages/shape_generator/lib/src/models/generated_class_names.dart +++ b/packages/shape_generator/lib/src/models/generated_class_names.dart @@ -1,11 +1,10 @@ -import 'package:equatable/equatable.dart'; import 'package:shape_generator/src/models/models.dart'; /// {@template generated_class_names} /// A model that contains the strings for generated classes, mixins and other /// models. /// {@endtemplate} -class GeneratedClassNames extends Equatable { +class GeneratedClassNames { /// {@macro generated_class_names} const GeneratedClassNames({required this.formBodyClassName}); @@ -48,9 +47,5 @@ class GeneratedClassNames extends Equatable { /// The name of the interface that the implementing form body class should /// extend. - String get extendingFormBodyClassName => - '$kFormBodyBaseClassName<$generatedFormErrorsClassName>'; - - @override - List get props => [formBodyClassName]; + String get extendingFormBodyClassName => kFormBodyBaseClassName; } diff --git a/packages/shape_generator/pubspec.yaml b/packages/shape_generator/pubspec.yaml index 70c46f9..c6f7168 100644 --- a/packages/shape_generator/pubspec.yaml +++ b/packages/shape_generator/pubspec.yaml @@ -1,26 +1,31 @@ name: shape_generator description: The code generator for the shape package. For more information, check out the README of the shape package. -version: 0.0.2 +version: 0.1.0 repository: https://github.com/betterment/shape/tree/main/packages/shape_generator +resolution: workspace environment: - sdk: ^3.7.2 - -resolution: workspace + sdk: '>=3.12.2 <4.0.0' dependencies: - analyzer: ">=5.12.0" - build: ^2.4.1 - equatable: ^2.0.2 - meta: ^1.9.1 - path: ^1.8.3 - shape: ^0.0.1 - source_gen: ^1.4.0 + # Stay on analyzer 10.x for Flutter 3.44: its pinned test_core 0.6.17 requires + # analyzer <13, and build 4.0.8+ requires analyzer >=13.3. + analyzer: '>=10.0.0 <11.0.0' + build: '>=4.0.4 <4.0.8' + meta: ^1.15.0 + path: ^1.9.1 + shape: ^0.1.0 + shape_starter_kit: ^0.1.0 + source_gen: ^4.2.0 dev_dependencies: - build_runner: ^2.4.6 + build_runner: '>=2.15.0 <2.15.3' + build_test: ^3.5.0 checks: ^0.3.1 - lints: ^5.1.1 - mocktail: ^0.3.0 - test: ^1.26.2 + lints: ^6.1.0 + mocktail: ^1.0.5 + package_config: ^2.2.0 + # test 1.31.2+ requires analyzer >=13, we keep this pinned below that + # for Flutter 3.44 compatibility. + test: '>=1.25.0 <1.31.2' diff --git a/packages/shape_generator/test/fixtures/test_form_body.dart b/packages/shape_generator/test/fixtures/test_form_body.dart index 7c6d4b0..f17ff64 100644 --- a/packages/shape_generator/test/fixtures/test_form_body.dart +++ b/packages/shape_generator/test/fixtures/test_form_body.dart @@ -1,30 +1,26 @@ -// ignore_for_file: avoid_returning_null import 'package:shape/shape.dart'; part 'test_form_body.g.dart'; @GenerateFormBody() -abstract class TestFormBody extends FormBody - with _$TestFormBodyFields { +abstract class TestFormBody extends FormBody with _$TestFormBodyFields { + const TestFormBody._(); + factory TestFormBody({ required String stringField, required String intField, required Object? nullableField, - }) { - return _$TestFormBody( - stringField: NonEmptyStringFormField(rawValue: stringField), - intField: ValidIntFormField(rawValue: intField), - nullableField: NullableFormField(rawValue: nullableField), - ); - } - - const TestFormBody._(); + }) => _$TestFormBody( + stringField: NonEmptyStringFormField(rawValue: stringField), + intField: ValidIntFormField(rawValue: intField), + nullableField: NullableFormField(rawValue: nullableField), + ); } enum TestValidationError { empty } class NonEmptyStringFormField - extends FormField { + extends SimpleFormField { NonEmptyStringFormField({required String rawValue}) : super(rawValue); @override @@ -56,8 +52,8 @@ class ValidIntFormField extends FormField { } } -class NullableFormField extends FormField { - NullableFormField({required T rawValue}) : super(rawValue); +class NullableFormField extends SimpleFormField { + NullableFormField({required T? rawValue}) : super(rawValue); @override T? get value => rawValue; diff --git a/packages/shape_generator/test/fixtures/test_form_body.expected.txt b/packages/shape_generator/test/fixtures/test_form_body.expected.txt index 09af733..7b9b349 100644 --- a/packages/shape_generator/test/fixtures/test_form_body.expected.txt +++ b/packages/shape_generator/test/fixtures/test_form_body.expected.txt @@ -6,10 +6,10 @@ part of 'test_form_body.dart'; // ShapeGenerator // ************************************************************************** +// ignore_for_file: unused_element, cast_nullable_to_non_nullable, prefer_const_constructors_in_immutables // Form Body "_$TestFormBody" @immutable -class _$TestFormBody extends TestFormBody - with _$TestFormBodyFields, EquatableMixin { +class _$TestFormBody extends TestFormBody { factory _$TestFormBody({ required NonEmptyStringFormField stringField, required ValidIntFormField intField, @@ -17,11 +17,8 @@ class _$TestFormBody extends TestFormBody }) { return _$TestFormBody._(stringField, intField, nullableField); } - const _$TestFormBody._( - this._stringField, - this._intField, - this._nullableField, - ) : super._(); + const _$TestFormBody._(this._stringField, this._intField, this._nullableField) + : super._(); @override final NonEmptyStringFormField _stringField; @override @@ -46,29 +43,33 @@ class _$TestFormBody extends TestFormBody @override _$TestFormBodyCopyWith get copyWith => _$TestFormBodyCopyWithImpl(this); @override - List get props => [ - _stringField.rawValue, - _intField.rawValue, - _nullableField.rawValue, - ]; + bool operator ==(Object other) { + return other is _$TestFormBody && + other._stringField.rawValue == _stringField.rawValue && + other._intField.rawValue == _intField.rawValue && + other._nullableField.rawValue == _nullableField.rawValue; + } + @override - bool get stringify => true; + int get hashCode => Object.hash( + _stringField.rawValue, + _intField.rawValue, + _nullableField.rawValue, + ); } // Copy With Interface "_$TestFormBodyCopyWith" abstract class _$TestFormBodyCopyWith { TestFormBody call({ - String stringField, - String intField, - Object? nullableField, + NonEmptyStringFormField stringField, + ValidIntFormField intField, + NullableFormField nullableField, }); } // Copy With Implementation "_$TestFormBodyCopyWithImpl" class _$TestFormBodyCopyWithImpl implements _$TestFormBodyCopyWith { - const _$TestFormBodyCopyWithImpl( - this._instance, - ); + const _$TestFormBodyCopyWithImpl(this._instance); final _$TestFormBody _instance; static const _defaultValue = Object(); @override @@ -77,16 +78,16 @@ class _$TestFormBodyCopyWithImpl implements _$TestFormBodyCopyWith { Object? intField = _defaultValue, Object? nullableField = _defaultValue, }) { - return TestFormBody( + return _$TestFormBody( stringField: stringField == _defaultValue - ? _instance._stringField.rawValue - : stringField as String, + ? _instance._stringField + : stringField! as NonEmptyStringFormField, intField: intField == _defaultValue - ? _instance._intField.rawValue - : intField as String, + ? _instance._intField + : intField! as ValidIntFormField, nullableField: nullableField == _defaultValue - ? _instance._nullableField.rawValue - : nullableField as Object?, + ? _instance._nullableField + : nullableField! as NullableFormField, ); } } @@ -127,15 +128,10 @@ mixin _$TestFormBodyFields { // Form Errors "TestFormErrors" @immutable - /// The form errors for the form body "TestFormBody". -class TestFormErrors extends FormErrors<_$TestFormBody> with EquatableMixin { +class TestFormErrors extends FormErrors { /// The form errors for the form body "TestFormBody". - const TestFormErrors({ - this.stringField, - this.intField, - this.nullableField, - }); + const TestFormErrors({this.stringField, this.intField, this.nullableField}); /// The error for the stringField field. final TestValidationError? stringField; @@ -149,9 +145,7 @@ class TestFormErrors extends FormErrors<_$TestFormBody> with EquatableMixin { /// Merges this TestFormErrors with the [other] /// by replacing any empty fields in this instance with the corresponding field in /// [other] while preserving the non-empty fields in this instance. - TestFormErrors mergeWhereEmptyWith({ - required TestFormErrors other, - }) { + TestFormErrors mergeWhereEmptyWith({required TestFormErrors other}) { return TestFormErrors( stringField: stringField ?? other.stringField, intField: intField ?? other.intField, @@ -162,15 +156,17 @@ class TestFormErrors extends FormErrors<_$TestFormBody> with EquatableMixin { /// Copies this TestFormErrors and replaces the provided fields. _TestFormErrorsCopyWith get copyWith => _TestFormErrorsCopyWithImpl(this); @override - List get errors => [ - stringField, - intField, - nullableField, - ]; + List get errors => [stringField, intField, nullableField]; @override - List get props => errors; + bool operator ==(Object other) { + return other is TestFormErrors && + other.stringField == stringField && + other.intField == intField && + other.nullableField == nullableField; + } + @override - bool get stringify => true; + int get hashCode => Object.hash(stringField, intField, nullableField); } // Copy With Interface "_TestFormErrorsCopyWith" @@ -184,9 +180,7 @@ abstract class _TestFormErrorsCopyWith { // Copy With Implementation "_TestFormErrorsCopyWithImpl" class _TestFormErrorsCopyWithImpl implements _TestFormErrorsCopyWith { - const _TestFormErrorsCopyWithImpl( - this._instance, - ); + const _TestFormErrorsCopyWithImpl(this._instance); final TestFormErrors _instance; static const _defaultValue = Object(); @override diff --git a/packages/shape_generator/test/fixtures/test_form_body.g.dart b/packages/shape_generator/test/fixtures/test_form_body.g.dart index 09af733..7b9b349 100644 --- a/packages/shape_generator/test/fixtures/test_form_body.g.dart +++ b/packages/shape_generator/test/fixtures/test_form_body.g.dart @@ -6,10 +6,10 @@ part of 'test_form_body.dart'; // ShapeGenerator // ************************************************************************** +// ignore_for_file: unused_element, cast_nullable_to_non_nullable, prefer_const_constructors_in_immutables // Form Body "_$TestFormBody" @immutable -class _$TestFormBody extends TestFormBody - with _$TestFormBodyFields, EquatableMixin { +class _$TestFormBody extends TestFormBody { factory _$TestFormBody({ required NonEmptyStringFormField stringField, required ValidIntFormField intField, @@ -17,11 +17,8 @@ class _$TestFormBody extends TestFormBody }) { return _$TestFormBody._(stringField, intField, nullableField); } - const _$TestFormBody._( - this._stringField, - this._intField, - this._nullableField, - ) : super._(); + const _$TestFormBody._(this._stringField, this._intField, this._nullableField) + : super._(); @override final NonEmptyStringFormField _stringField; @override @@ -46,29 +43,33 @@ class _$TestFormBody extends TestFormBody @override _$TestFormBodyCopyWith get copyWith => _$TestFormBodyCopyWithImpl(this); @override - List get props => [ - _stringField.rawValue, - _intField.rawValue, - _nullableField.rawValue, - ]; + bool operator ==(Object other) { + return other is _$TestFormBody && + other._stringField.rawValue == _stringField.rawValue && + other._intField.rawValue == _intField.rawValue && + other._nullableField.rawValue == _nullableField.rawValue; + } + @override - bool get stringify => true; + int get hashCode => Object.hash( + _stringField.rawValue, + _intField.rawValue, + _nullableField.rawValue, + ); } // Copy With Interface "_$TestFormBodyCopyWith" abstract class _$TestFormBodyCopyWith { TestFormBody call({ - String stringField, - String intField, - Object? nullableField, + NonEmptyStringFormField stringField, + ValidIntFormField intField, + NullableFormField nullableField, }); } // Copy With Implementation "_$TestFormBodyCopyWithImpl" class _$TestFormBodyCopyWithImpl implements _$TestFormBodyCopyWith { - const _$TestFormBodyCopyWithImpl( - this._instance, - ); + const _$TestFormBodyCopyWithImpl(this._instance); final _$TestFormBody _instance; static const _defaultValue = Object(); @override @@ -77,16 +78,16 @@ class _$TestFormBodyCopyWithImpl implements _$TestFormBodyCopyWith { Object? intField = _defaultValue, Object? nullableField = _defaultValue, }) { - return TestFormBody( + return _$TestFormBody( stringField: stringField == _defaultValue - ? _instance._stringField.rawValue - : stringField as String, + ? _instance._stringField + : stringField! as NonEmptyStringFormField, intField: intField == _defaultValue - ? _instance._intField.rawValue - : intField as String, + ? _instance._intField + : intField! as ValidIntFormField, nullableField: nullableField == _defaultValue - ? _instance._nullableField.rawValue - : nullableField as Object?, + ? _instance._nullableField + : nullableField! as NullableFormField, ); } } @@ -127,15 +128,10 @@ mixin _$TestFormBodyFields { // Form Errors "TestFormErrors" @immutable - /// The form errors for the form body "TestFormBody". -class TestFormErrors extends FormErrors<_$TestFormBody> with EquatableMixin { +class TestFormErrors extends FormErrors { /// The form errors for the form body "TestFormBody". - const TestFormErrors({ - this.stringField, - this.intField, - this.nullableField, - }); + const TestFormErrors({this.stringField, this.intField, this.nullableField}); /// The error for the stringField field. final TestValidationError? stringField; @@ -149,9 +145,7 @@ class TestFormErrors extends FormErrors<_$TestFormBody> with EquatableMixin { /// Merges this TestFormErrors with the [other] /// by replacing any empty fields in this instance with the corresponding field in /// [other] while preserving the non-empty fields in this instance. - TestFormErrors mergeWhereEmptyWith({ - required TestFormErrors other, - }) { + TestFormErrors mergeWhereEmptyWith({required TestFormErrors other}) { return TestFormErrors( stringField: stringField ?? other.stringField, intField: intField ?? other.intField, @@ -162,15 +156,17 @@ class TestFormErrors extends FormErrors<_$TestFormBody> with EquatableMixin { /// Copies this TestFormErrors and replaces the provided fields. _TestFormErrorsCopyWith get copyWith => _TestFormErrorsCopyWithImpl(this); @override - List get errors => [ - stringField, - intField, - nullableField, - ]; + List get errors => [stringField, intField, nullableField]; @override - List get props => errors; + bool operator ==(Object other) { + return other is TestFormErrors && + other.stringField == stringField && + other.intField == intField && + other.nullableField == nullableField; + } + @override - bool get stringify => true; + int get hashCode => Object.hash(stringField, intField, nullableField); } // Copy With Interface "_TestFormErrorsCopyWith" @@ -184,9 +180,7 @@ abstract class _TestFormErrorsCopyWith { // Copy With Implementation "_TestFormErrorsCopyWithImpl" class _TestFormErrorsCopyWithImpl implements _TestFormErrorsCopyWith { - const _TestFormErrorsCopyWithImpl( - this._instance, - ); + const _TestFormErrorsCopyWithImpl(this._instance); final TestFormErrors _instance; static const _defaultValue = Object(); @override diff --git a/packages/shape_generator/test/generated_form_body_test.dart b/packages/shape_generator/test/generated_form_body_test.dart index e8ccfb5..32ed742 100644 --- a/packages/shape_generator/test/generated_form_body_test.dart +++ b/packages/shape_generator/test/generated_form_body_test.dart @@ -97,7 +97,9 @@ void main() { intField: '123', nullableField: const Object(), ); - final actual = subject.copyWith(stringField: 'def'); + final actual = subject.copyWith( + stringField: NonEmptyStringFormField(rawValue: 'def'), + ); final checked = buildSubject( stringField: 'def', intField: '123', @@ -113,7 +115,9 @@ void main() { intField: '123', nullableField: 'xyz', ); - final actual = subject.copyWith(nullableField: null); + final actual = subject.copyWith( + nullableField: NullableFormField(rawValue: null), + ); final checked = buildSubject( stringField: 'abc', intField: '123', @@ -131,9 +135,7 @@ void main() { intField: '123', nullableField: const Object(), ).toString, - ).returnsNormally().equals( - r"_$TestFormBody(abc, 123, Instance of 'Object')", - ); + ).returnsNormally().equals(r"Instance of '_$TestFormBody'"); }); }); @@ -266,9 +268,7 @@ void main() { intField: null, nullableField: TestValidationError.empty, ).toString, - ).returnsNormally().equals( - '''TestFormErrors(TestValidationError.empty, null, TestValidationError.empty)''', - ); + ).returnsNormally().equals("Instance of 'TestFormErrors'"); }); }); } diff --git a/packages/shape_generator/test/generator_error_test.dart b/packages/shape_generator/test/generator_error_test.dart new file mode 100644 index 0000000..ba7f49f --- /dev/null +++ b/packages/shape_generator/test/generator_error_test.dart @@ -0,0 +1,123 @@ +import 'package:test/test.dart' hide expect; + +import 'support/shape_generator_test_harness.dart'; + +void main() { + group('invalid form body declarations', () { + test('rejects non-abstract classes', () async { + final result = await runShapeGenerator( + source: validFormBodySource(className: 'ConcreteFormBody').replaceFirst( + 'abstract class ConcreteFormBody', + 'class ConcreteFormBody', + ), + className: 'ConcreteFormBody', + ); + + expectGenerationFailure(result, 'is abstract'); + }); + + test('rejects classes that do not extend FormBody', () async { + final result = await runShapeGenerator( + source: validFormBodySource(className: 'PlainFormBody').replaceFirst( + 'extends FormBody with _\$PlainFormBodyFields', + 'with _\$PlainFormBodyFields implements Object', + ), + className: 'PlainFormBody', + ); + + expectGenerationFailure(result, 'extends FormBody'); + }); + + test('rejects missing factory constructor', () async { + final result = await runShapeGenerator( + source: + ''' +import 'package:shape/shape.dart'; +import 'package:shape_starter_kit/shape_starter_kit.dart'; + +part 'form_body.g.dart'; + +@GenerateFormBody() +abstract class MissingFactoryFormBody extends FormBody {} + +$genericFormFieldSource +''', + className: 'MissingFactoryFormBody', + ); + + expectGenerationFailure(result, 'nameless factory constructor'); + }); + }); + + group('invalid factory bodies', () { + test('rejects positional constructor arguments', () async { + final result = await runShapeGenerator( + source: validFormBodySource( + className: 'PositionalArgsFormBody', + factoryBody: ''' + return _\$PositionalArgsFormBody(name);''', + ), + className: 'PositionalArgsFormBody', + ); + + expectGenerationFailure(result, 'not a named argument'); + }); + + test('rejects private field names', () async { + final result = await runShapeGenerator( + source: validFormBodySource( + className: 'PrivateFieldFormBody', + factoryBody: ''' + return _\$PrivateFieldFormBody( + _secret: name, + );''', + factoryParams: 'required String? name, required String? _secret', + ), + className: 'PrivateFieldFormBody', + ); + + expectGenerationFailure(result, 'not a valid identifier'); + }); + + test('rejects factories returning the wrong generated class', () async { + final result = await runShapeGenerator( + source: validFormBodySource( + className: 'WrongReturnTypeFormBody', + factoryBody: ''' + return WrongClass( + name: name, + );''', + ), + className: 'WrongReturnTypeFormBody', + ); + + expectGenerationFailure( + result, + 'No valid constructors found in class "WrongReturnTypeFormBody"', + ); + }); + + test('rejects multiple valid factory constructors', () async { + final result = await runShapeGenerator( + source: validFormBodySource(className: 'DualFactoryFormBody') + .replaceFirst( + 'factory DualFactoryFormBody({required String? name}) {', + ''' + factory DualFactoryFormBody.alt({required String? name}) { + return _\$DualFactoryFormBody( + name: name, + ); + } + + factory DualFactoryFormBody({required String? name}) {''', + ), + className: 'DualFactoryFormBody', + ); + + expectGenerationFailure( + result, + 'Multiple valid constructors found in class "DualFactoryFormBody"', + ); + }); + }); +} diff --git a/packages/shape_generator/test/generator_success_test.dart b/packages/shape_generator/test/generator_success_test.dart new file mode 100644 index 0000000..406e91a --- /dev/null +++ b/packages/shape_generator/test/generator_success_test.dart @@ -0,0 +1,190 @@ +import 'package:checks/checks.dart'; +import 'package:test/test.dart' hide expect; + +import 'support/shape_generator_test_harness.dart'; + +void main() { + group('successful generation', () { + test('generates a minimal valid form body', () async { + final result = await runShapeGenerator( + source: validFormBodySource(className: 'MinimalFormBody'), + className: 'MinimalFormBody', + ); + + check(result.succeeded).isTrue(); + check(result.generated!).contains('class _\$MinimalFormBody'); + check(result.generated!).contains('MinimalFormErrors'); + }); + + test('wraps plain factory parameters in GenericFormField', () async { + final result = await runShapeGenerator( + source: validFormBodySource( + className: 'GenericFormBody', + factoryParams: '@FieldRequired() Object? value', + factoryBody: ''' + return _\$GenericFormBody( + value: value, + );''', + ), + className: 'GenericFormBody', + ); + + check(result.succeeded).isTrue(); + check( + result.generated!, + ).contains('GenericFormField(value, isRequired: true)'); + }); + + test('generates without form errors when disabled', () async { + final result = await runShapeGenerator( + source: + ''' +import 'package:shape/shape.dart'; +import 'package:shape_starter_kit/shape_starter_kit.dart'; + +part 'form_body.g.dart'; + +@GenerateFormBody(generateFormErrors: false) +abstract class NoErrorsFormBody extends FormBody with _\$NoErrorsFormBodyFields { + const NoErrorsFormBody._(); + + factory NoErrorsFormBody({required String? name}) => + _\$NoErrorsFormBody(name: name); +} + +$genericFormFieldSource +''', + className: 'NoErrorsFormBody', + ); + + check(result.succeeded).isTrue(); + check(result.generated!.contains('class NoErrorsErrors')).isFalse(); + }); + + test('preserves explicit custom FormField wrappers', () async { + final result = await runShapeGenerator( + source: validFormBodySource( + className: 'CustomWrapperFormBody', + factoryParams: 'required String? name', + factoryBody: ''' + return _\$CustomWrapperFormBody( + name: GenericFormField(name, isRequired: true), + );''', + ), + className: 'CustomWrapperFormBody', + ); + + check(result.succeeded).isTrue(); + // Custom wrappers become typed factory params; the call site expression + // is not re-emitted into the generated factory body. + check( + result.generated!, + ).contains('required GenericFormField name'); + check( + result.generated!.contains( + 'GenericFormField(name, isRequired: true)', + ), + ).isFalse(); + }); + + test('marks non-nullable custom FormField params as required ' + 'even when the raw factory params are optional', () async { + final result = await runShapeGenerator( + source: ''' +import 'package:shape/shape.dart'; + +part 'form_body.g.dart'; + +@GenerateFormBody() +abstract class OptionalRawCustomWrapperFormBody extends FormBody + with _\$OptionalRawCustomWrapperFormBodyFields { + const OptionalRawCustomWrapperFormBody._(); + + factory OptionalRawCustomWrapperFormBody({ + String? firstName, + String? lastName, + required bool requireNames, + }) => + _\$OptionalRawCustomWrapperFormBody( + firstName: TrimmedStringFormField( + rawValue: firstName, + isRequired: requireNames, + ), + lastName: TrimmedStringFormField( + rawValue: lastName, + isRequired: requireNames, + ), + ); +} + +class TrimmedStringFormField extends SimpleFormField { + const TrimmedStringFormField({ + required String? rawValue, + this.isRequired = false, + }) : super(rawValue); + + final bool isRequired; + + @override + String? get value => rawValue?.trim(); + + @override + Object? validate() { + if (isRequired && (rawValue == null || rawValue!.trim().isEmpty)) { + return 'missing'; + } + return null; + } +} +''', + className: 'OptionalRawCustomWrapperFormBody', + ); + + check(result.succeeded).isTrue(); + check( + result.generated!, + ).contains('required TrimmedStringFormField firstName'); + check( + result.generated!, + ).contains('required TrimmedStringFormField lastName'); + // requireNames is only used when constructing wrappers in the user + // factory; it must not become a generated form field. + check(result.generated!.contains('requireNames')).isFalse(); + // copyWith must call the generated factory with FormField instances so + // user-factory-only params like requireNames are not required. + check(result.generated!).contains('_\$OptionalRawCustomWrapperFormBody('); + check(result.generated!).contains( + '? _instance._firstName : firstName! as TrimmedStringFormField', + ); + check(result.generated!).contains('ignore_for_file: unused_element'); + }); + + test('supports redirecting factory constructors', () async { + final result = await runShapeGenerator( + source: + ''' +import 'package:shape/shape.dart'; +import 'package:shape_starter_kit/shape_starter_kit.dart'; + +part 'form_body.g.dart'; + +@GenerateFormBody() +abstract class RedirectFormBody extends FormBody with _\$RedirectFormBodyFields { + const RedirectFormBody._(); + + factory RedirectFormBody({@FieldRequired() String? name}) = _\$RedirectFormBody; +} + +$genericFormFieldSource +''', + className: 'RedirectFormBody', + ); + + check(result.succeeded).isTrue(); + check(result.generated!).contains('class _\$RedirectFormBody'); + check( + result.generated!, + ).contains('GenericFormField(name, isRequired: true)'); + }); + }); +} diff --git a/packages/shape_generator/test/src/extensions/dart_type_extensions_test.dart b/packages/shape_generator/test/src/extensions/dart_type_extensions_test.dart index 5836e6d..7d8ea3f 100644 --- a/packages/shape_generator/test/src/extensions/dart_type_extensions_test.dart +++ b/packages/shape_generator/test/src/extensions/dart_type_extensions_test.dart @@ -1,14 +1,16 @@ +import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:analyzer/dart/element/type.dart'; -import 'package:shape_generator/src/extensions/extensions.dart'; +import 'package:checks/checks.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:test/test.dart'; +import 'package:shape_generator/src/extensions/extensions.dart'; +import 'package:test/test.dart' hide expect; class MockType extends Mock implements DartType {} void main() { group('DartTypeExtensions', () { - final typeDisplayString = '$MockType'; - final nullableTypeDisplayString = '$MockType?'; + const typeDisplayString = 'MockType'; + const nullableTypeDisplayString = 'MockType?'; late DartType type; @@ -19,62 +21,47 @@ void main() { group('nonNullableDisplayString', () { group('on a non-nullable type', () { setUp(() { - when(() => type.getDisplayString()).thenReturn(typeDisplayString); + when(type.getDisplayString).thenReturn(typeDisplayString); + when(() => type.nullabilitySuffix).thenReturn(NullabilitySuffix.none); }); test('returns type without question mark', () { - expect(type.nonNullableDisplayString, typeDisplayString); + check(type.nonNullableDisplayString).equals(typeDisplayString); }); }); group('on a nullable type', () { setUp(() { - when(() => type.getDisplayString()).thenAnswer((invocation) { - final withNullability = - invocation.namedArguments[const Symbol('withNullability')] - as bool; - return withNullability - ? nullableTypeDisplayString - : typeDisplayString; - }); + when(type.getDisplayString).thenReturn(nullableTypeDisplayString); + when( + () => type.nullabilitySuffix, + ).thenReturn(NullabilitySuffix.question); }); test('returns type without question mark', () { - expect(type.nonNullableDisplayString, typeDisplayString); + check(type.nonNullableDisplayString).equals(typeDisplayString); }); }); }); group('potentiallyNullableDisplayString', () { - group('without a null-safe context', () { - setUp(() { - when(() => type.getDisplayString()).thenReturn(typeDisplayString); - }); + setUp(() { + when(type.getDisplayString).thenReturn(nullableTypeDisplayString); + }); - test('returns type without question mark', () { - expect(type.potentiallyNullableDisplayString, typeDisplayString); - }); + test('returns type with question mark', () { + check( + type.potentiallyNullableDisplayString, + ).equals(nullableTypeDisplayString); }); + }); - group('with a null-safe context on a nullable type', () { - setUp(() { - when(() => type.getDisplayString()).thenAnswer((invocation) { - final withNullability = - invocation.namedArguments[const Symbol('withNullability')] - as bool; - return withNullability - ? nullableTypeDisplayString - : typeDisplayString; - }); - }); + test('leaves star suffix display strings unchanged', () { + when(type.getDisplayString).thenReturn('MockType*'); + when(() => type.nullabilitySuffix).thenReturn(NullabilitySuffix.star); - test('returns type with question mark', () { - expect( - type.potentiallyNullableDisplayString, - nullableTypeDisplayString, - ); - }); - }); + check(type.potentiallyNullableDisplayString).equals('MockType*'); + check(type.nonNullableDisplayString).equals('MockType*'); }); }); } diff --git a/packages/shape_generator/test/src/extensions/string_extensions_test.dart b/packages/shape_generator/test/src/extensions/string_extensions_test.dart index e9ae046..5df64be 100644 --- a/packages/shape_generator/test/src/extensions/string_extensions_test.dart +++ b/packages/shape_generator/test/src/extensions/string_extensions_test.dart @@ -23,5 +23,15 @@ void main() { check('Object?'.nullableTypeString).equals('Object?'); }); }); + + group('removeIfPresent', () { + test('removes suffix when present', () { + check('foobar'.removeIfPresent('bar')).equals('foo'); + }); + + test('returns unchanged when suffix is absent', () { + check('foo'.removeIfPresent('bar')).equals('foo'); + }); + }); }); } diff --git a/packages/shape_generator/test/src/models/client_metadata_test.dart b/packages/shape_generator/test/src/models/client_metadata_test.dart new file mode 100644 index 0000000..d96d467 --- /dev/null +++ b/packages/shape_generator/test/src/models/client_metadata_test.dart @@ -0,0 +1,139 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:checks/checks.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shape_generator/src/extensions/extensions.dart'; +import 'package:shape_generator/src/models/client_class_metadata.dart'; +import 'package:shape_generator/src/models/client_constructor_metadata.dart'; +import 'package:test/test.dart' hide expect; + +import '../../support/shape_generator_test_harness.dart'; + +class MockInterfaceType extends Mock implements InterfaceType {} + +void main() { + group('ClientClassMetadata.fromElement', () { + test('reads type parameters from generic classes', () async { + await resolveSources( + { + '_resolve_source|lib/metadata.dart': ''' +import 'package:shape/shape.dart'; + +class NullableFormField extends SimpleFormField { + NullableFormField({required T? rawValue}) : super(rawValue); + + @override + GenericValidationError? validate() => null; +} + +enum GenericValidationError { missing } +''', + }, + (resolver) async { + final LibraryElement library = await resolver.libraryFor( + AssetId('_resolve_source', 'lib/metadata.dart'), + ); + final ClassElement element = library.getClass('NullableFormField')!; + final metadata = ClientClassMetadata.fromElement(element); + + check(metadata.typeParameters).length.equals(1); + check(metadata.typeParameters.first.name).equals('T'); + check(metadata.name).equals('NullableFormField'); + }, + packageConfig: await workspacePackageConfigForTests(), + readAllSourcesFromFilesystem: true, + ); + }); + + test('filters implicit default constructors', () async { + await resolveSources( + { + '_resolve_source|lib/metadata.dart': ''' +import 'package:shape/shape.dart'; +import 'package:shape_starter_kit/shape_starter_kit.dart'; + +@GenerateFormBody() +abstract class SampleFormBody extends FormBody with _\$SampleFormBodyFields { + const SampleFormBody._(); + + factory SampleFormBody({@FieldRequired() String? name}) => + _\$SampleFormBody(name: name); +} +''', + }, + (resolver) async { + final LibraryElement library = await resolver.libraryFor( + AssetId('_resolve_source', 'lib/metadata.dart'), + ); + final ClassElement element = library.getClass('SampleFormBody')!; + final metadata = ClientClassMetadata.fromElement(element); + + check( + metadata.constructors.every( + (constructor) => !constructor.isOriginImplicitDefault, + ), + ).isTrue(); + check(metadata.constructors.any((c) => c.isFactory)).isTrue(); + check(metadata.isValid).isTrue(); + check(metadata.toString()).contains('isAbstract: true'); + }, + packageConfig: await workspacePackageConfigForTests(), + readAllSourcesFromFilesystem: true, + ); + }); + }); + + group('ClientConstructorMetadata', () { + late InterfaceType enclosingClass; + + setUp(() { + enclosingClass = MockInterfaceType(); + when(() => enclosingClass.nonNullableDisplayString).thenReturn('Foo'); + when( + () => enclosingClass.nullabilitySuffix, + ).thenReturn(NullabilitySuffix.none); + when(() => enclosingClass.getDisplayString()).thenReturn('Foo'); + }); + + test('treats unnamed constructors as new', () { + final metadata = ClientConstructorMetadata( + name: 'new', + enclosingClass: enclosingClass, + isFactory: true, + returnExpression: null, + redirectTarget: null, + ); + + check(metadata.isUnnamed).isTrue(); + }); + + test('treats empty constructor names as unnamed', () { + final metadata = ClientConstructorMetadata( + name: '', + enclosingClass: enclosingClass, + isFactory: true, + returnExpression: null, + ); + + check(metadata.isUnnamed).isTrue(); + }); + + test('uses redirectTargetName for return expression type', () { + final metadata = ClientConstructorMetadata( + name: 'new', + enclosingClass: enclosingClass, + isFactory: true, + returnExpression: null, + redirectTargetName: '_\$Foo', + ); + + check(metadata.returnExpressionTypeName).equals('_\$Foo'); + check(metadata.hasValidReturnStatementType).isTrue(); + check(metadata.isValid).isTrue(); + check(metadata.toString()).contains('returnExpressionTypeName: _\$Foo'); + }); + }); +} diff --git a/packages/shape_generator/test/src/models/form_body_field_metadata_test.dart b/packages/shape_generator/test/src/models/form_body_field_metadata_test.dart new file mode 100644 index 0000000..c71a7a0 --- /dev/null +++ b/packages/shape_generator/test/src/models/form_body_field_metadata_test.dart @@ -0,0 +1,92 @@ +import 'package:analyzer/dart/element/nullability_suffix.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:checks/checks.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shape_generator/src/extensions/extensions.dart'; +import 'package:shape_generator/src/models/client_class_metadata.dart'; +import 'package:shape_generator/src/models/form_body_field_metadata.dart'; +import 'package:test/test.dart' hide expect; + +class _MockInterfaceType extends Mock implements InterfaceType {} + +void main() { + group('FormBodyFieldMetadata', () { + late InterfaceType formClassType; + late ClientClassMetadata formClassMetadata; + + setUp(() { + formClassType = _MockInterfaceType(); + when(() => formClassType.getDisplayString()).thenReturn('NameFormField'); + when( + () => formClassType.potentiallyNullableDisplayString, + ).thenReturn('NameFormField'); + when( + () => formClassType.nullabilitySuffix, + ).thenReturn(NullabilitySuffix.none); + + formClassMetadata = ClientClassMetadata( + baseType: formClassType, + supertype: null, + instanceType: formClassType, + isAbstract: false, + isEnum: false, + isMixin: false, + constructors: const [], + fields: const {}, + methods: const [], + typeParameters: const [], + ); + }); + + FormBodyFieldMetadata buildSubject({ + String fieldName = 'name', + String wrapperExpression = 'name', + bool isCustomWrapper = false, + }) { + return FormBodyFieldMetadata( + fieldName: fieldName, + formClassMetadata: formClassMetadata, + wrapperExpression: wrapperExpression, + isCustomWrapper: isCustomWrapper, + ); + } + + test('extendsFormField is false when supertype is null', () { + check(buildSubject().extendsFormField).isFalse(); + }); + + test('rawValueType, valueType, and errorType fall back to form type', () { + final subject = buildSubject(); + + check(subject.rawValueType).equals(formClassType); + check(subject.valueType).equals(formClassType); + check(subject.errorType).equals(formClassType); + }); + + test('toString includes key fields', () { + check(buildSubject(isCustomWrapper: true).toString()).equals( + 'FormBodyFieldMetadata(' + 'fieldName: name, ' + 'wrapperExpression: name, ' + 'isCustomWrapper: true, ' + 'formClassMetadata: $formClassMetadata, ' + 'genericTypeArguments: {}' + ')', + ); + }); + + test('copyWith replaces provided fields', () { + final subject = buildSubject(); + final copy = subject.copyWith( + fieldName: 'age', + wrapperExpression: 'GenericFormField(age)', + isCustomWrapper: true, + ); + + check(copy.fieldName).equals('age'); + check(copy.wrapperExpression).equals('GenericFormField(age)'); + check(copy.isCustomWrapper).isTrue(); + check(copy.formClassMetadata).equals(formClassMetadata); + }); + }); +} diff --git a/packages/shape_generator/test/src/models/generated_class_names_test.dart b/packages/shape_generator/test/src/models/generated_class_names_test.dart new file mode 100644 index 0000000..75037ec --- /dev/null +++ b/packages/shape_generator/test/src/models/generated_class_names_test.dart @@ -0,0 +1,28 @@ +import 'package:checks/checks.dart'; +import 'package:shape_generator/src/models/generated_class_names.dart'; +import 'package:test/test.dart' hide expect; + +void main() { + group('GeneratedClassNames', () { + test('strips Body suffix for form errors class name', () { + const names = GeneratedClassNames(formBodyClassName: 'ExampleFormBody'); + + check(names.generatedFormErrorsClassName).equals('ExampleFormErrors'); + }); + + test('uses full class name when Body suffix is absent', () { + const names = GeneratedClassNames(formBodyClassName: 'MyForm'); + + check(names.generatedFormErrorsClassName).equals('MyFormErrors'); + }); + + test('builds generated form body and mixin names', () { + const names = GeneratedClassNames(formBodyClassName: 'ExampleFormBody'); + + check(names.generatedFormBodyClassName).equals('_\$ExampleFormBody'); + check( + names.generatedFormBodyFieldsMixinName, + ).equals('_\$ExampleFormBodyFields'); + }); + }); +} diff --git a/packages/shape_generator/test/support/shape_generator_test_harness.dart b/packages/shape_generator/test/support/shape_generator_test_harness.dart new file mode 100644 index 0000000..afa445b --- /dev/null +++ b/packages/shape_generator/test/support/shape_generator_test_harness.dart @@ -0,0 +1,192 @@ +/// Shared helpers for running [ShapeGenerator] in unit tests. +library; + +import 'dart:isolate'; + +import 'package:analyzer/dart/constant/value.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:package_config/package_config.dart'; +import 'package:shape/shape.dart'; +import 'package:shape_generator/src/generators/shape_generator.dart'; +import 'package:source_gen/source_gen.dart'; + +class _MockBuildStep extends Mock implements BuildStep {} + +Future workspacePackageConfigForTests() => + _workspacePackageConfig(); + +PackageConfig? _cachedPackageConfig; + +Future _workspacePackageConfig() async { + if (_cachedPackageConfig != null) { + return _cachedPackageConfig!; + } + + final packageConfigUri = await Isolate.packageConfig; + if (packageConfigUri == null) { + throw StateError('Could not find a package config for generator tests.'); + } + + _cachedPackageConfig = await loadPackageConfigUri(packageConfigUri); + return _cachedPackageConfig!; +} + +/// Minimal [FormField] used in generator test inputs. +const genericFormFieldSource = ''' +class GenericFormField extends FormField { + const GenericFormField(super.rawValue, {this.isRequired = false}); + + @override + T get value => rawValue; + + final bool isRequired; + + @override + GenericValidationError? validate() { + if (rawValue == null && isRequired) { + return GenericValidationError.missing; + } + return null; + } +} + +enum GenericValidationError { missing } +'''; + +/// Builds a valid form body source string for [className]. +String validFormBodySource({ + required String className, + String? errorsClassName, + String? generatedClassName, + String? factoryBody, + String factoryParams = 'required String? name', +}) { + final generated = generatedClassName ?? '_\$$className'; + final body = + factoryBody ?? + ''' + return $generated( + name: name, + );'''; + + return ''' +import 'package:shape/shape.dart'; +import 'package:shape_starter_kit/shape_starter_kit.dart'; + +part 'form_body.g.dart'; + +@GenerateFormBody() +abstract class $className extends FormBody with _\$${className}Fields { + const $className._(); + + factory $className({$factoryParams}) { + $body + } +} + +$genericFormFieldSource +'''; +} + +/// Runs [ShapeGenerator] against synthetic [source] in `_resolve_source`. +Future runShapeGenerator({ + required String source, + String className = 'TestFormBody', +}) async { + String? generated; + Object? thrown; + + await resolveSources( + {'_resolve_source|lib/_resolve_source.dart': source}, + (resolver) async { + final library = await resolver.libraryFor( + AssetId('_resolve_source', 'lib/_resolve_source.dart'), + ); + final element = library.getClass(className); + if (element == null) { + throw StateError('Class "$className" not found in test source.'); + } + + final buildStep = _MockBuildStep(); + when(() => buildStep.resolver).thenReturn(resolver); + when( + () => buildStep.inputId, + ).thenReturn(AssetId('_resolve_source', 'lib/_resolve_source.dart')); + + final generator = ShapeGenerator(); + try { + generated = await generator.generateForAnnotatedElement( + element, + ConstantReader(_annotationObject(element)), + buildStep, + ); + } on Object catch (error) { + thrown = error; + } + }, + packageConfig: await _workspacePackageConfig(), + readAllSourcesFromFilesystem: true, + ); + + return ShapeGeneratorTestResult(generated: generated, thrown: thrown); +} + +DartObject _annotationObject(ClassElement element) { + final annotation = const TypeChecker.typeNamed( + GenerateFormBody, + inPackage: 'shape', + ).firstAnnotationOfExact(element); + if (annotation == null) { + throw StateError('Class "${element.name}" is missing @GenerateFormBody.'); + } + return annotation; +} + +/// Result of [runShapeGenerator]. +class ShapeGeneratorTestResult { + const ShapeGeneratorTestResult({ + required this.generated, + required this.thrown, + }); + + final String? generated; + final Object? thrown; + + bool get succeeded => thrown == null && generated != null; + + String get failureOutput { + if (thrown != null) { + return thrown.toString(); + } + return generated ?? ''; + } +} + +/// Asserts that generation fails and output contains [message]. +void expectGenerationFailure(ShapeGeneratorTestResult result, String message) { + if (result.succeeded) { + throw TestFailure( + 'Expected generation to fail, but it succeeded.\n' + 'Generated:\n${result.generated}', + ); + } + + if (!result.failureOutput.contains(message)) { + throw TestFailure( + 'Expected failure output to contain:\n$message\n\n' + 'Actual output:\n${result.failureOutput}', + ); + } +} + +/// Minimal test failure type so this library does not depend on `package:test`. +class TestFailure implements Exception { + TestFailure(this.message); + final String message; + + @override + String toString() => message; +} diff --git a/packages/shape_starter_kit/PANA_SCORE b/packages/shape_starter_kit/PANA_SCORE new file mode 100644 index 0000000..a762560 --- /dev/null +++ b/packages/shape_starter_kit/PANA_SCORE @@ -0,0 +1 @@ +160 diff --git a/packages/shape_starter_kit/README.md b/packages/shape_starter_kit/README.md index 5f16ce3..68f4601 100644 --- a/packages/shape_starter_kit/README.md +++ b/packages/shape_starter_kit/README.md @@ -16,22 +16,17 @@ A full example might look like this: ```dart import 'package:shape/shape.dart'; -import 'package:shape_addons/shape_addons.dart'; +import 'package:shape_starter_kit/shape_starter_kit.dart'; part 'example_form_body.g.dart'; @GenerateFormBody() -abstract class ExampleFormBody with _$ExampleFormBodyFields { +abstract class ExampleFormBody extends FormBody with _$ExampleFormBodyFields { + const ExampleFormBody._(); + factory ExampleFormBody({ - required String? foo, - }) { - return _$ExampleFormBody( - name: GenericFormField( - value: foo, - isRequired: true, - ), - ); - } + @FieldRequired() String? foo, + }) = _$ExampleFormBody; } void main() { diff --git a/packages/shape_starter_kit/lib/src/form_fields/generic_form_field.dart b/packages/shape_starter_kit/lib/src/form_fields/generic_form_field.dart index 9037533..adce39d 100644 --- a/packages/shape_starter_kit/lib/src/form_fields/generic_form_field.dart +++ b/packages/shape_starter_kit/lib/src/form_fields/generic_form_field.dart @@ -27,7 +27,7 @@ enum GenericValidationError { /// print(field2.validate()); // GenericValidationError.missing /// ``` /// {@endtemplate} -class GenericFormField extends FormField { +class GenericFormField extends SimpleFormField { /// {@macro generic_form_field} const GenericFormField(super.rawValue, {this.isRequired = false}); diff --git a/packages/shape_starter_kit/pubspec.yaml b/packages/shape_starter_kit/pubspec.yaml index 85e3ed9..2a382c6 100644 --- a/packages/shape_starter_kit/pubspec.yaml +++ b/packages/shape_starter_kit/pubspec.yaml @@ -2,20 +2,20 @@ name: shape_starter_kit description: A set of generic and commonly used form fields and functions for use with the shape package. For more information, check out the README of the shape package. -version: 0.0.1 +version: 0.1.0 repository: https://github.com/betterment/shape/tree/main/packages/shape_generator +resolution: workspace environment: - sdk: ^3.7.2 - -resolution: workspace + sdk: '>=3.12.2 <4.0.0' dependencies: - equatable: ^2.0.2 - meta: ^1.9.1 - shape: ^0.0.1 + meta: ^1.15.0 + shape: ^0.1.0 dev_dependencies: checks: ^0.3.1 - lints: ^5.1.1 - test: ^1.26.2 + lints: ^6.1.0 + # test 1.31.2+ requires analyzer >=13, we keep this pinned below that + # for Flutter 3.44 compatibility. + test: '>=1.25.0 <1.31.2' diff --git a/pubspec.yaml b/pubspec.yaml index 7be1e2e..7640050 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,4 +1,5 @@ name: shape_workspace +publish_to: none workspace: - packages/shape @@ -7,10 +8,10 @@ workspace: - packages/shape_generator environment: - sdk: ^3.7.2 + sdk: '>=3.12.2 <4.0.0' dev_dependencies: - lints: ^5.1.1 + lints: ^6.1.0 melos: ^7.0.0-dev.8 melos: diff --git a/tool/verify_pub_score_workspace.sh b/tool/verify_pub_score_workspace.sh new file mode 100755 index 0000000..7395b63 --- /dev/null +++ b/tool/verify_pub_score_workspace.sh @@ -0,0 +1,137 @@ +#!/bin/bash +set -euo pipefail + +# Runs pana in a temporary sandbox so unpublished workspace packages can be +# scored before they exist on pub.dev. +# +# Sandbox setup: +# - Copies packages with dereferenced LICENSE/CHANGELOG symlinks (rsync -aL), +# otherwise pana misses those files and loses convention points. +# - Rewrites workspace version constraints to path deps so resolution works +# before publish, and strips `resolution: workspace`. +# - Ignores `invalid_dependency` only in the sandbox so temporary path deps +# do not tank the analysis score (published packages use hosted deps). +# +# Usage: +# ./verify_pub_score_workspace.sh +# ./verify_pub_score_workspace.sh 160 packages/shape_generator + +MIN_SCORE="${1:-}" +TARGET_REL="${2:?package directory relative to repo root is required}" + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TARGET="$REPO_ROOT/$TARGET_REL" +PACKAGE_NAME="$(awk '/^name:/{print $2; exit}' "$TARGET/pubspec.yaml")" + +TEMP="$(mktemp -d)" +cleanup() { + rm -rf "$TEMP" +} +trap cleanup EXIT + +copy_package() { + local source_rel="$1" + local dest_name="$2" + # -aL: archive mode + dereference symlinks so LICENSE/CHANGELOG (which point + # at the repo root) become real files inside the sandbox. + rsync -aL \ + --exclude=.dart_tool \ + --exclude=build \ + --exclude=coverage \ + --exclude='melos_*.iml' \ + "$REPO_ROOT/$source_rel/" "$TEMP/$dest_name/" + + # Belt-and-suspenders: always materialize root LICENSE/CHANGELOG as files. + cp "$REPO_ROOT/LICENSE" "$TEMP/$dest_name/LICENSE" + cp "$REPO_ROOT/CHANGELOG.md" "$TEMP/$dest_name/CHANGELOG.md" +} + +copy_package packages/shape shape +copy_package packages/shape_starter_kit shape_starter_kit +copy_package packages/shape_generator shape_generator + +# Preserve workspace analysis options; package includes point at ../../ which +# would miss from the sandbox layout (packages are siblings under $TEMP). +cp "$REPO_ROOT/analysis_options.yaml" "$TEMP/analysis_options.yaml" + +patch_path_dependency() { + local pubspec="$1" + local dependency="$2" + local path="$3" + python3 - "$pubspec" "$dependency" "$path" <<'PY' +import re +import sys + +pubspec_path, dependency, path = sys.argv[1:4] +text = open(pubspec_path, encoding="utf-8").read() +pattern = rf"^ {re.escape(dependency)}: \^[^\n]+$" +replacement = f" {dependency}:\n path: {path}" +updated, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) +if count != 1: + sys.exit(f"Failed to patch {dependency} in {pubspec_path}") +open(pubspec_path, "w", encoding="utf-8").write(updated) +PY +} + +patch_path_dependency "$TEMP/shape_starter_kit/pubspec.yaml" shape "$TEMP/shape" +patch_path_dependency "$TEMP/shape_generator/pubspec.yaml" shape "$TEMP/shape" +patch_path_dependency "$TEMP/shape_generator/pubspec.yaml" shape_starter_kit "$TEMP/shape_starter_kit" + +for pkg in shape shape_starter_kit shape_generator; do + pubspec="$TEMP/$pkg/pubspec.yaml" + python3 - "$pubspec" <<'PY' +import sys + +pubspec_path = sys.argv[1] +lines = [] +for line in open(pubspec_path, encoding="utf-8"): + if line.strip() == "resolution: workspace": + continue + lines.append(line) +open(pubspec_path, "w", encoding="utf-8").writelines(lines) +PY + + # Fix analysis_options include for sandbox layout and suppress path-dep + # warnings that only exist because of the temporary path patches above. + cat > "$TEMP/$pkg/analysis_options.yaml" <<'AOE' +include: ../analysis_options.yaml + +analyzer: + errors: + # Sandbox-only: path deps stand in for unpublished hosted packages. + invalid_dependency: ignore +AOE +done + +cd "$TEMP/$PACKAGE_NAME" + +PANA="$(pana . --no-warning)" +PANA_SCORE="$(echo "$PANA" | sed -n "s/.*Points: \([0-9]*\)\/\([0-9]*\)./\1\/\2/p")" + +if [ -z "$PANA_SCORE" ]; then + echo "Failed to parse pana score from output:" + echo "$PANA" + exit 1 +fi + +echo "score: $PANA_SCORE" +IFS='/' +read -r -a SCORE_ARR <<< "$PANA_SCORE" +SCORE="${SCORE_ARR[0]}" +TOTAL="${SCORE_ARR[1]}" + +if ! [[ "$SCORE" =~ ^[0-9]+$ ]] || ! [[ "$TOTAL" =~ ^[0-9]+$ ]]; then + echo "Invalid score format: $PANA_SCORE" + exit 1 +fi + +if [ -z "$MIN_SCORE" ]; then + MINIMUM_SCORE="$TOTAL" +else + MINIMUM_SCORE="$MIN_SCORE" +fi + +if (( SCORE < MINIMUM_SCORE )); then + echo "minimum score $MINIMUM_SCORE was not met!" + exit 1 +fi