From cf7bcb5ca5cf2f7211f1c8ef4bc01ceebd6ac427 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 20 Aug 2026 21:53:08 -0400 Subject: [PATCH 1/7] feat(generator): draft immutable AckType model generation --- docs/architecture/acktype-model-generation.md | 300 +++++ packages/ack/lib/ack.dart | 2 + .../ack/lib/src/models/ack_model_adapter.dart | 80 ++ .../test/models/ack_model_adapter_test.dart | 61 + .../ack_annotations/lib/src/ack_type.dart | 26 +- packages/ack_generator/CHANGELOG.md | 22 + packages/ack_generator/README.md | 114 +- packages/ack_generator/build.yaml | 6 +- packages/ack_generator/lib/src/builder.dart | 7 +- .../lib/src/builders/class_builder.dart | 907 ++++++++++++++ .../lib/src/builders/type_builder.dart | 1083 ----------------- packages/ack_generator/lib/src/generator.dart | 315 ++--- .../lib/src/models/schema_model_graph.dart | 285 +++++ packages/ack_generator/pubspec.yaml | 13 +- .../test/src/generator_test.dart | 119 +- 15 files changed, 1858 insertions(+), 1482 deletions(-) create mode 100644 docs/architecture/acktype-model-generation.md create mode 100644 packages/ack/lib/src/models/ack_model_adapter.dart create mode 100644 packages/ack/test/models/ack_model_adapter_test.dart create mode 100644 packages/ack_generator/lib/src/builders/class_builder.dart delete mode 100644 packages/ack_generator/lib/src/builders/type_builder.dart create mode 100644 packages/ack_generator/lib/src/models/schema_model_graph.dart diff --git a/docs/architecture/acktype-model-generation.md b/docs/architecture/acktype-model-generation.md new file mode 100644 index 00000000..64d6a0e0 --- /dev/null +++ b/docs/architecture/acktype-model-generation.md @@ -0,0 +1,300 @@ +# AckType model-class generation + +Status: draft implementation for review before validation. + +## Goal + +Replace the `@AckType()` map-backed extension types with real immutable Dart +classes. The schema remains the single source of truth for validation, defaults, +codecs, and boundary serialization. + +```text +JSON boundary + -> Ack parse +Ack runtime value + -> generated runtime mapper +immutable model + -> generated runtime mapper +Ack runtime value + -> Ack encode +JSON boundary +``` + +The generated class must not implement `Map` and must not keep a +backing map as its application data model. + +## Public API + +Given: + +```dart +@AckType() +final userSchema = Ack.object({ + 'id': Ack.integer(), + 'name': Ack.string(), + 'createdAt': Ack.datetime(), +}); +``` + +Generate: + +```dart +final class User { + User({ + required this.id, + required this.name, + required this.createdAt, + }); + + final int id; + final String name; + final DateTime createdAt; + + factory User.parse(Object? input); + static SchemaResult safeParse(Object? input); + factory User.fromMap(Map map); + factory User.fromJson(Map json); + Map toMap(); + Map toJson(); +} +``` + +`@AckType(name: 'Member')` generates `Member`. The name is exact and no `Type` +suffix is appended. + +## JSON serializable relationship + +Ack does not emit `@JsonSerializable` and does not call private +`json_serializable` generator APIs. + +Ack generates the conventional methods itself: + +```dart +factory User.fromJson(Map json); +Map toJson(); +``` + +This lets source classes processed by `json_serializable` treat an Ack model as a +custom nested type. Actual validation and serialization still run through Ack. + +A second hidden generation pass over an Ack-generated class is intentionally not +part of the architecture. Such a pass would require generated source to be +resolved and analyzed again, creating builder-ordering and incremental-build +complexity. + +## Build architecture + +The generator uses `SharedPartBuilder` with the part ID `ack`. + +```text +source.dart + -> source.ack.g.part Ack fragment in cache + -> source.json_serializable.g.part (when present) + -> source.g.dart source_gen combining builder +``` + +The generator emits declarations only. `source_gen` owns the header, `part of` +directive, output combination, and formatting for the target library language +version. + +## Runtime adapter + +`AckModelAdapter` connects the source schema to a +model's generated runtime conversion functions. + +The schema is stored as a callback rather than an eager value. This avoids +static initialization cycles and preserves top-level schema getter behavior. + +For nested models, generated code calls: + +```dart +Address.$ack.fromRuntime(runtimeMap); +Address.$ack.toRuntime(address); +``` + +It must not call `Address.parse(runtimeMap)`. The parent schema has already +converted boundary values such as strings into runtime values such as +`DateTime`, `Uri`, or custom codec outputs. Parsing again would decode codecs +twice. + +## Normalized model graph + +The old `FieldInfo` and `ModelInfo` structures combine analyzer state with +extension-type output details. The replacement graph separates analysis from +emission. + +A schema identity includes its library URI and declaration name: + +```dart +AckSchemaId( + libraryUri: libraryUri, + declarationName: declarationName, +) +``` + +This prevents collisions between equal declaration names in different +libraries. + +The normalized graph represents: + +- object models; +- value models; +- discriminated unions; +- scalar types; +- external Dart types; +- generated model references; +- lists, sets, and maps; +- input presence separately from nullability; +- bidirectional versus parse-only encoding capability. + +The current draft adds this graph next to the existing analyzer. A follow-up +change will make the analyzer produce it directly and remove output-specific +string overrides. + +## Recursive dependencies + +Generation must not use topological sorting as a recursion strategy. + +Resolution uses three states: + +```text +unseen -> visiting -> resolved +``` + +A declaration is registered before its fields are analyzed. A reference to a +`visiting` declaration becomes a graph edge. It does not recursively create a +second copy of the same model. + +Dart class declarations can reference each other independent of declaration +order. Output order should remain stable and follow source declaration order. + +`Ack.lazy` needs explicit analyzer support before recursive model generation is +considered complete. + +## Field semantics + +Presence and nullability are different: + +| Schema state | Model field | Input behavior | +| --- | --- | --- | +| required, non-nullable | `required T value` | key required, null rejected | +| required, nullable | `required T? value` | key required, null accepted | +| optional, non-nullable | `T? value` | key may be absent, present null rejected | +| optional, nullable | `T? value` | key may be absent or null | +| defaulted | usually `required T value` in constructor | parse supplies default | + +A plain `T?` cannot preserve the distinction between an absent key and a key +whose value is explicitly null. The first model release uses canonical output +and does not add hidden presence bits. Exact three-state preservation can be a +separate API feature. + +## Collections + +Generated fields use concrete typed collections and constructor inputs are +copied to unmodifiable collections. + +```dart +final List
addresses; +final Set tags; +final Map permissions; +``` + +Nested model elements convert through their `$ack` adapters. + +## Additional properties + +Schemas that allow additional properties generate: + +```dart +final Map additionalProperties; +``` + +Encoding merges additional properties first and declared fields second, so an +extra property cannot replace a declared property. + +## One-way transforms + +`transform()` is parse-only. `codec()` is bidirectional. + +The normalized graph tracks encode capability. Full model generation should +produce a build error when any field is parse-only: + +```text +Cannot generate toJson for User because field "color" uses a one-way transform. +Replace transform() with codec(). +``` + +The current draft emitter does not yet propagate this capability from the AST. +It is a required validation item before merge. + +## Discriminated unions + +Generate a sealed hierarchy: + +```dart +sealed class Pet { + const Pet(); +} + +final class Cat extends Pet { + Cat({required this.lives}); + final int lives; + String get kind => 'cat'; +} +``` + +Preserve current discriminator checks: + +- branches are named and statically resolvable; +- branches belong to the same library; +- discriminator literals and enums are compatible; +- broad or conflicting discriminator schemas fail generation; +- a branch belongs to only one union base; +- branch parse operations validate through the union's effective branch. + +## Current draft scope + +This branch contains the main architecture for review: + +- shared-part builder configuration; +- `AckModelAdapter` runtime bridge; +- immutable object and value class emitter; +- sealed discriminated-class emitter; +- normalized graph types and recursive-resolution states; +- annotation contract and naming change. + +It is intentionally not represented as validated. Remaining work includes: + +- migrate all extension-type golden and integration tests; +- make the analyzer produce the normalized graph directly; +- add `Ack.lazy` analysis; +- track defaults and encode capability; +- complete map value typing; +- validate import and generated-name collisions; +- add clean-build `json_serializable` fixtures; +- run formatting, build, analysis, and runtime tests; +- remove legacy extension-only documentation and examples; +- review dependency ranges for the current analyzer/source_gen stack. + +## Validation checklist + +Before this draft can leave draft status: + +```text +[ ] dart pub get +[ ] dart format --output=none --set-exit-if-changed . +[ ] dart analyze --fatal-infos +[ ] dart test +[ ] dart run build_runner clean +[ ] dart run build_runner build --delete-conflicting-outputs +[ ] example package builds from no generated files +[ ] nested DateTime/Uri/Duration round trips +[ ] custom codec round trips +[ ] direct, prefixed, and re-exported model references +[ ] self-recursive and mutually-recursive models +[ ] discriminated branch parse and encode +[ ] additional-property collision behavior +[ ] optional, nullable, and defaulted field behavior +[ ] json_serializable builder coexistence fixture +[ ] json_serializable custom nested-type fixture +``` diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index 06b66251..94769f4e 100644 --- a/packages/ack/lib/ack.dart +++ b/packages/ack/lib/ack.dart @@ -8,6 +8,8 @@ library; export 'src/ack.dart'; // Common types export 'src/common_types.dart' show JsonMap; +// Generated model support +export 'src/models/ack_model_adapter.dart'; // Constraints export 'src/constraints/constraint.dart'; export 'src/constraints/duration_constraint.dart'; diff --git a/packages/ack/lib/src/models/ack_model_adapter.dart b/packages/ack/lib/src/models/ack_model_adapter.dart new file mode 100644 index 00000000..296e9c6a --- /dev/null +++ b/packages/ack/lib/src/models/ack_model_adapter.dart @@ -0,0 +1,80 @@ +import '../schemas/schema.dart'; +import '../validation/schema_result.dart'; + +/// Connects a generated immutable model to an Ack schema. +/// +/// The schema owns boundary validation and codec behavior. Generated model +/// classes only map between the schema's validated runtime value and their +/// stored Dart fields. +final class AckModelAdapter< + Boundary extends Object, + Runtime extends Object, + Model extends Object +> { + /// Creates an adapter for a generated model. + /// + /// [schema] is a callback rather than a stored schema value so generated + /// models can safely participate in recursive and mutually-recursive graphs. + const AckModelAdapter({ + required AckSchema Function() schema, + required Model Function(Runtime value) fromRuntime, + required Runtime Function(Model value) toRuntime, + }) : _schema = schema, + _fromRuntime = fromRuntime, + _toRuntime = toRuntime; + + final AckSchema Function() _schema; + final Model Function(Runtime value) _fromRuntime; + final Runtime Function(Model value) _toRuntime; + + /// Resolves the source schema for this model. + AckSchema get schema => _schema(); + + /// Converts an already-validated Ack runtime value into the model. + /// + /// Use this for nested generated models. Calling [parse] on a nested runtime + /// value can decode codecs twice because the parent schema has already + /// completed boundary-to-runtime conversion. + Model fromRuntime(Runtime value) => _fromRuntime(value); + + /// Converts a model into the runtime value expected by its Ack schema. + Runtime toRuntime(Model value) => _toRuntime(value); + + /// Parses and validates boundary input, then creates the generated model. + Model parse(Object? input, {String? debugName}) { + return schema.parseAs( + input, + (validated) => _fromRuntime(validated as Runtime), + debugName: debugName, + ); + } + + /// Safely parses boundary input into the generated model. + SchemaResult safeParse(Object? input, {String? debugName}) { + return schema.safeParseAs( + input, + (validated) => _fromRuntime(validated as Runtime), + debugName: debugName, + ); + } + + /// Encodes a generated model to its boundary representation. + Boundary encode(Model value, {String? debugName}) { + return schema.encode( + _toRuntime(value), + debugName: debugName, + ) + as Boundary; + } + + /// Safely encodes a generated model to its boundary representation. + SchemaResult safeEncode( + Model value, { + String? debugName, + }) { + return schema.safeEncode( + _toRuntime(value), + debugName: debugName, + ); + } +} diff --git a/packages/ack/test/models/ack_model_adapter_test.dart b/packages/ack/test/models/ack_model_adapter_test.dart new file mode 100644 index 00000000..c5392e92 --- /dev/null +++ b/packages/ack/test/models/ack_model_adapter_test.dart @@ -0,0 +1,61 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +final _userSchema = Ack.object({ + 'name': Ack.string(), + 'age': Ack.integer(), +}); + +final _userAdapter = AckModelAdapter( + schema: () => _userSchema, + fromRuntime: _User.fromRuntime, + toRuntime: (user) => user.toRuntime(), +); + +final class _User { + const _User({required this.name, required this.age}); + + final String name; + final int age; + + static _User fromRuntime(JsonMap value) { + return _User( + name: value['name'] as String, + age: value['age'] as int, + ); + } + + JsonMap toRuntime() => {'name': name, 'age': age}; +} + +void main() { + group('AckModelAdapter', () { + test('parses boundary input into a model', () { + final user = _userAdapter.parse({'name': 'Ada', 'age': 36}); + + expect(user.name, 'Ada'); + expect(user.age, 36); + }); + + test('encodes a model through the source schema', () { + final encoded = _userAdapter.encode( + const _User(name: 'Ada', age: 36), + ); + + expect(encoded, {'name': 'Ada', 'age': 36}); + }); + + test('exposes direct runtime conversion for nested models', () { + final runtime = {'name': 'Ada', 'age': 36}; + final user = _userAdapter.fromRuntime(runtime); + + expect(_userAdapter.toRuntime(user), runtime); + }); + + test('preserves safe parse failures', () { + final result = _userAdapter.safeParse({'name': 'Ada', 'age': '36'}); + + expect(result.isFail, isTrue); + }); + }); +} diff --git a/packages/ack_annotations/lib/src/ack_type.dart b/packages/ack_annotations/lib/src/ack_type.dart index 2c2809ad..d1ad8d67 100644 --- a/packages/ack_annotations/lib/src/ack_type.dart +++ b/packages/ack_annotations/lib/src/ack_type.dart @@ -1,6 +1,6 @@ import 'package:meta/meta_meta.dart'; -/// Marks a top-level Ack schema for extension-type generation. +/// Marks a top-level Ack schema for immutable model-class generation. /// /// Apply `@AckType()` to a top-level schema variable or getter: /// @@ -12,8 +12,9 @@ import 'package:meta/meta_meta.dart'; /// }); /// ``` /// -/// `ack_generator` emits a typed wrapper around the schema's validated -/// representation plus `parse()` and `safeParse()` helpers. +/// `ack_generator` emits a real Dart class with stored typed fields plus +/// `parse`, `safeParse`, `fromMap`, `fromJson`, `toMap`, and `toJson` APIs. +/// Ack remains responsible for validation and codec-aware serialization. /// /// Supported targets: /// - Top-level variables @@ -28,20 +29,19 @@ import 'package:meta/meta_meta.dart'; /// - Local variables @Target({TargetKind.topLevelVariable, TargetKind.getter}) class AckType { - /// Optional custom name for the generated extension type. + /// Optional exact name for the generated model class. /// - /// If not provided, the type name is derived from the schema variable name: - /// - `userSchema` -> `UserType` - /// - `passwordSchema` -> `PasswordType` + /// If omitted, the class name is derived from the schema declaration: + /// - `userSchema` -> `User` + /// - `passwordSchema` -> `Password` /// - /// If provided, the custom name is used with the `Type` suffix: - /// - `@AckType(name: 'CustomUser')` -> `CustomUserType` - /// - `@AckType(name: 'MyPassword')` -> `MyPasswordType` + /// If provided, the value is used directly: + /// - `@AckType(name: 'AppUser')` -> `AppUser` final String? name; - /// Creates an annotation to generate extension types for validated data. + /// Creates an annotation for immutable Ack model generation. /// - /// The [name] value must be a valid Dart identifier and should omit the - /// trailing `Type` suffix. + /// [name] must be a valid Dart class identifier. Do not add a `Type` suffix + /// unless it is intentionally part of the public model name. const AckType({this.name}); } diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 6842ed7b..c1f155f4 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -1,3 +1,25 @@ +## Unreleased + +### Breaking + +* Replace map-backed `@AckType()` extension types with immutable Dart model + classes. Generated names no longer receive a `Type` suffix. +* Generated models no longer implement `Map`. + +### Added + +* Generate `parse`, `safeParse`, `fromMap`, `fromJson`, `toMap`, and `toJson` + APIs for model classes. +* Add `AckModelAdapter` for codec-safe conversion between Ack runtime values and + generated models. +* Add a normalized schema graph foundation for imported, recursive, and + discriminated model dependencies. + +### Changed + +* Use `SharedPartBuilder` and the `source_gen` combining builder so Ack can share + `.g.dart` output with generators such as `json_serializable`. + ## 1.1.0 ### Changed diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index c42e0083..51bea795 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -1,12 +1,13 @@ # Ack Generator -`ack_generator` emits extension types for top-level Ack schemas annotated with -`@AckType()`. +`ack_generator` generates immutable Dart model classes from top-level Ack +schemas annotated with `@AckType()`. -## Overview +> This branch contains a draft class-generation rewrite. See +> [`docs/architecture/acktype-model-generation.md`](../../docs/architecture/acktype-model-generation.md) +> for design decisions, known gaps, and the validation checklist. -Write your schemas directly with the Ack fluent API, then annotate the schema -variable or getter to generate a typed wrapper: +## Overview ```dart import 'package:ack/ack.dart'; @@ -21,19 +22,47 @@ final userSchema = Ack.object({ }); ``` -Running `dart run build_runner build` generates an extension type such as: +Running `dart run build_runner build` generates a real class: ```dart -extension type UserType(Map _data) - implements Map { - static UserType parse(Object? data) { ... } - static SchemaResult safeParse(Object? data) { ... } +final class User { + User({required this.name, required this.email}); + + final String name; + final String email; + + factory User.parse(Object? input) => $ack.parse(input); + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + factory User.fromMap(Map map) => $ack.parse(map); + factory User.fromJson(Map json) => $ack.parse(json); - String get name => _data['name'] as String; - String get email => _data['email'] as String; + Map toMap() => $ack.encode(this); + Map toJson() => Map.from(toMap()); } ``` +The generated class stores typed fields. It does not implement `Map` and does +not use a map-backed extension type. + +## Serialization + +Ack performs parsing and encoding. Generated classes map between Ack's validated +runtime values and stored Dart fields. + +This preserves codecs such as: + +- `Ack.datetime()` (`String` boundary to `DateTime` runtime); +- `Ack.uri()`; +- `Ack.duration()`; +- enum codecs; +- custom bidirectional codecs. + +The generated `fromJson` and `toJson` method shapes are compatible with the +custom-type conventions used by `json_serializable`. Ack does not emit +`@JsonSerializable` and does not call its generator internals. + ## Installation ```yaml @@ -51,38 +80,29 @@ dev_dependencies: - Top-level schema variables - Top-level schema getters -`@AckType()` is not supported on classes or instance members. - -## Supported schema shapes - -- `Ack.object(...)` -- Primitive schemas such as `Ack.string()`, `Ack.integer()`, `Ack.double()`, - `Ack.boolean()` -- `Ack.list(...)` and `Set`-like list wrappers -- `Ack.literal(...)`, `Ack.enumString(...)`, `Ack.enumValues(...)` -- Non-object transforms with explicit output types -- `Ack.discriminated(...)` when branches are top-level `@AckType` object - schemas in the same library - -For discriminated unions, `Ack.discriminated(...)` owns the discriminator -property. Branch schemas normally omit the discriminator field; if they include -it, that field must be `Ack.literal(...)` matching the branch key or -`Ack.enumString(...)` containing the branch key. Boundary payloads must still -include the discriminator key. Conflicting, broad, transformed/refined, and -restrictive discriminator fields are rejected. Generated branches expose the -exact branch literal, and generated subtype `parse()` / `safeParse()` methods -validate through the union's effective branch. - -## Important limitations - -- `Ack.any()` and `Ack.anyOf()` do not generate extension types. -- Inline anonymous object branches are rejected for typed generation. Extract - them to a named top-level schema first. -- Nullable top-level schemas do not emit extension types. -- Nullable list elements are rejected: `Ack.list(item.nullable())` is not - supported. Make the list nullable with `Ack.list(item).nullable()` when the - list itself may be `null`. -- `@AckType()` requires static schema resolution for nested object references. +`@AckType()` is not supported on classes, instance members, or local variables. + +## Planned model shapes + +- `Ack.object(...)` -> immutable `final class` +- Primitive and codec roots -> immutable value class +- `Ack.discriminated(...)` -> `sealed class` with `final` branches +- Nested named schemas -> nested generated model fields +- Lists and sets -> unmodifiable typed collections +- Additional properties -> explicit `additionalProperties` map + +## Current draft limitations + +The rewrite is not yet validated. Before release it still needs: + +- migration of all legacy extension-type fixtures; +- normalized graph integration in the analyzer; +- `Ack.lazy` and recursive-model analysis; +- default and one-way-transform capability tracking; +- complete typed map support; +- current analyzer/source_gen dependency validation; +- clean-build `json_serializable` integration fixtures; +- full build, analysis, and runtime test execution. ## Build commands @@ -90,9 +110,3 @@ validate through the union's effective branch. dart run build_runner build dart run build_runner watch ``` - -## More information - -- Root docs: [../../README.md](../../README.md) -- Annotation package: [../ack_annotations/README.md](../ack_annotations/README.md) -- Example package: [../../example/README.md](../../example/README.md) diff --git a/packages/ack_generator/build.yaml b/packages/ack_generator/build.yaml index 55e4086b..bbb32231 100644 --- a/packages/ack_generator/build.yaml +++ b/packages/ack_generator/build.yaml @@ -2,6 +2,8 @@ builders: ack_generator: import: "package:ack_generator/builder.dart" builder_factories: ["ackGenerator"] - build_extensions: {".dart": [".g.dart"]} + build_extensions: {".dart": [".ack.g.part"]} auto_apply: dependents - build_to: source + build_to: cache + applies_builders: + - "source_gen:combining_builder" diff --git a/packages/ack_generator/lib/src/builder.dart b/packages/ack_generator/lib/src/builder.dart index ea20cde4..87c8e85a 100644 --- a/packages/ack_generator/lib/src/builder.dart +++ b/packages/ack_generator/lib/src/builder.dart @@ -3,7 +3,10 @@ import 'package:source_gen/source_gen.dart'; import 'generator.dart'; -/// Creates the builder for ack_generator +/// Creates the shared-part builder for Ack model generation. Builder ackGenerator(BuilderOptions options) { - return LibraryBuilder(AckSchemaGenerator(), generatedExtension: '.g.dart'); + return SharedPartBuilder( + [AckSchemaGenerator()], + 'ack', + ); } diff --git a/packages/ack_generator/lib/src/builders/class_builder.dart b/packages/ack_generator/lib/src/builders/class_builder.dart new file mode 100644 index 00000000..ad4de148 --- /dev/null +++ b/packages/ack_generator/lib/src/builders/class_builder.dart @@ -0,0 +1,907 @@ +import 'package:analyzer/dart/element/element2.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:code_builder/code_builder.dart'; + +import '../models/field_info.dart'; +import '../models/model_info.dart'; + +class _ModelLookups { + _ModelLookups(List models) + : byClassName = { + for (final model in models) model.className: model, + }, + bySchemaName = { + for (final model in models) model.schemaClassName: model, + }; + + final Map byClassName; + final Map bySchemaName; +} + +/// Emits immutable Dart classes from analyzed Ack schemas. +/// +/// Ack remains responsible for validation and boundary/runtime codecs. The +/// generated class stores typed fields and delegates parse/encode operations to +/// [AckModelAdapter]. +final class AckClassBuilder { + static const _runtimeMapType = 'Map'; + static const _jsonMapType = 'Map'; + + static const _reservedObjectMembers = { + r'$ack', + 'parse', + 'safeParse', + 'fromMap', + 'fromJson', + 'toMap', + 'toJson', + 'safeToMap', + 'safeToJson', + '_fromAckRuntime', + '_toAckRuntime', + 'additionalProperties', + }; + + String? _ackImportPrefix; + + void setAckImportPrefix(String? prefix) { + _ackImportPrefix = prefix; + } + + List buildClasses(List models) { + if (models.isEmpty) return const []; + + final lookups = _ModelLookups(models); + _validateModels(models); + + final result = []; + final emittedClassNames = {}; + + for (final model in models) { + if (model.isNullableSchema) { + throw StateError( + 'Top-level nullable schema "${model.schemaClassName}" cannot ' + 'generate a non-nullable model class.', + ); + } + + if (model.isDiscriminatedBaseDefinition) { + if (emittedClassNames.add(model.className)) { + result.add(_buildUnionBase(model, lookups)); + } + + final subtypeNames = model.subtypeNames ?? const {}; + for (final entry in subtypeNames.entries) { + final subtype = lookups.bySchemaName[entry.value]; + if (subtype == null) { + throw StateError( + 'Could not resolve discriminated branch "${entry.value}" ' + 'for ${model.className}.', + ); + } + if (emittedClassNames.add(subtype.className)) { + result.add( + _buildUnionSubtype( + subtype, + baseModel: model, + discriminatorValue: entry.key, + lookups: lookups, + ), + ); + } + } + continue; + } + + if (model.isDiscriminatedSubtype) continue; + if (!emittedClassNames.add(model.className)) continue; + + result.add( + model.representationType == kMapType + ? _buildObjectClass(model, lookups) + : _buildValueClass(model), + ); + } + + return result; + } + + void _validateModels(List models) { + final classNames = {}; + for (final model in models) { + if (!classNames.add(model.className)) { + throw StateError( + 'Multiple @AckType declarations generate the class ' + '"${model.className}".', + ); + } + + if (model.representationType != kMapType) continue; + for (final field in model.fields) { + if (_reservedObjectMembers.contains(field.name)) { + throw StateError( + 'Schema field "${field.jsonKey}" conflicts with generated member ' + '"${field.name}" on ${model.className}.', + ); + } + } + } + } + + Class _buildObjectClass(ModelInfo model, _ModelLookups lookups) { + return Class( + (b) => b + ..name = model.className + ..modifier = ClassModifier.final$ + ..docs.addAll(_buildDocs(model, 'Immutable model')) + ..fields.addAll([ + for (final field in model.fields) _buildField(field, lookups), + if (model.additionalProperties) _buildAdditionalPropertiesField(), + _buildAdapterField( + model, + schemaExpression: model.schemaClassName, + ), + ]) + ..constructors.addAll([ + _buildObjectConstructor(model, lookups), + _buildParseFactory(model.className), + _buildFromMapFactory(model.className), + _buildFromJsonFactory(model.className), + ]) + ..methods.addAll([ + _buildSafeParse(model.className), + _buildToMap(), + _buildToJson(), + _buildSafeToMap(), + _buildSafeToJson(), + _buildObjectFromRuntime(model, lookups), + _buildObjectToRuntime(model, lookups), + ]), + ); + } + + Class _buildValueClass(ModelInfo model) { + final className = model.className; + final runtimeType = model.representationType; + + return Class( + (b) => b + ..name = className + ..modifier = ClassModifier.final$ + ..docs.addAll(_buildDocs(model, 'Immutable value model')) + ..fields.addAll([ + Field( + (f) => f + ..name = 'value' + ..modifier = FieldModifier.final$ + ..type = refer(runtimeType), + ), + _buildAdapterField( + model, + schemaExpression: model.schemaClassName, + ), + ]) + ..constructors.addAll([ + Constructor( + (c) => c.requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..toThis = true, + ), + ), + ), + _buildParseFactory(className), + Constructor( + (c) => c + ..factory = true + ..name = 'fromJson' + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'json' + ..type = refer('Object?'), + ), + ) + ..body = const Code(r'return $ack.parse(json);'), + ), + ]) + ..methods.addAll([ + _buildSafeParse(className), + Method( + (m) => m + ..name = 'toJson' + ..body = const Code(r'return $ack.encode(this);'), + ), + Method( + (m) => m + ..name = 'safeToJson' + ..body = const Code(r'return $ack.safeEncode(this);'), + ), + Method( + (m) => m + ..name = '_fromAckRuntime' + ..static = true + ..returns = refer(className) + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..type = refer(runtimeType), + ), + ) + ..lambda = true + ..body = Code('$className(value)'), + ), + Method( + (m) => m + ..name = '_toAckRuntime' + ..returns = refer(runtimeType) + ..lambda = true + ..body = const Code('value'), + ), + ]), + ); + } + + Class _buildUnionBase(ModelInfo model, _ModelLookups lookups) { + final discriminatorKey = model.discriminatorKey!; + final cases = []; + for (final entry in model.subtypeNames!.entries) { + final subtype = lookups.bySchemaName[entry.value]; + if (subtype == null) continue; + cases.add( + '${_stringLiteral(entry.key)} => ' + '${subtype.className}._fromAckRuntime(value)', + ); + } + + final switchBody = ''' +return switch (value[${_stringLiteral(discriminatorKey)}]) { + ${cases.join(',\n ')}, + final unknown => throw StateError( + 'Unknown $discriminatorKey: \$unknown', + ), +};'''; + + return Class( + (b) => b + ..name = model.className + ..sealed = true + ..docs.addAll(_buildDocs(model, 'Discriminated model base')) + ..fields.add( + _buildAdapterField( + model, + schemaExpression: model.schemaClassName, + ), + ) + ..constructors.addAll([ + Constructor((c) => c.constant = true), + _buildParseFactory(model.className), + _buildFromMapFactory(model.className), + _buildFromJsonFactory(model.className), + ]) + ..methods.addAll([ + _buildSafeParse(model.className), + Method( + (m) => m + ..type = MethodType.getter + ..name = discriminatorKey + ..returns = refer('String'), + ), + _buildToMap(), + _buildToJson(), + _buildSafeToMap(), + _buildSafeToJson(), + Method( + (m) => m + ..name = '_fromAckRuntime' + ..static = true + ..returns = refer(model.className) + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..type = refer(_runtimeMapType), + ), + ) + ..body = Code(switchBody), + ), + Method( + (m) => m + ..name = '_toAckRuntime' + ..returns = refer(_runtimeMapType), + ), + ]), + ); + } + + Class _buildUnionSubtype( + ModelInfo model, { + required ModelInfo baseModel, + required String discriminatorValue, + required _ModelLookups lookups, + }) { + final discriminatorKey = baseModel.discriminatorKey!; + final effectiveFields = model.fields + .where((field) => field.jsonKey != discriminatorKey) + .toList(); + final effectiveModel = ModelInfo( + className: model.className, + schemaClassName: model.schemaClassName, + description: model.description, + fields: effectiveFields, + additionalProperties: model.additionalProperties, + discriminatorKey: discriminatorKey, + discriminatorValue: discriminatorValue, + schemaIdentity: model.schemaIdentity, + discriminatedBaseClassName: baseModel.className, + representationType: kMapType, + isNullableSchema: false, + ); + + return Class( + (b) => b + ..name = model.className + ..modifier = ClassModifier.final$ + ..extend = refer(baseModel.className) + ..docs.addAll(_buildDocs(model, 'Discriminated model branch')) + ..fields.addAll([ + for (final field in effectiveFields) _buildField(field, lookups), + if (model.additionalProperties) _buildAdditionalPropertiesField(), + _buildAdapterField( + model, + schemaExpression: + '${baseModel.schemaClassName}.effectiveBranch(' + '${_stringLiteral(discriminatorValue)})', + ), + ]) + ..constructors.addAll([ + _buildObjectConstructor(effectiveModel, lookups), + _buildParseFactory(model.className), + _buildFromMapFactory(model.className), + _buildFromJsonFactory(model.className), + ]) + ..methods.addAll([ + _buildSafeParse(model.className), + Method( + (m) => m + ..type = MethodType.getter + ..name = discriminatorKey + ..returns = refer('String') + ..lambda = true + ..body = Code(_stringLiteral(discriminatorValue)), + ), + _buildObjectFromRuntime(effectiveModel, lookups), + _buildObjectToRuntime( + effectiveModel, + lookups, + extraEntries: { + discriminatorKey: _stringLiteral(discriminatorValue), + }, + ), + ]), + ); + } + + List _buildDocs(ModelInfo model, String kind) { + return [ + '/// $kind generated from `${model.schemaClassName}`.', + if (model.description != null) '/// ${model.description}', + ]; + } + + Field _buildField(FieldInfo field, _ModelLookups lookups) { + return Field( + (f) => f + ..name = field.name + ..modifier = FieldModifier.final$ + ..type = refer(_fieldType(field, lookups)) + ..docs.addAll([ + if (field.description != null) '/// ${field.description}', + ]), + ); + } + + Field _buildAdditionalPropertiesField() { + return Field( + (f) => f + ..name = 'additionalProperties' + ..modifier = FieldModifier.final$ + ..type = refer(_runtimeMapType) + ..docs.add( + '/// Properties accepted by a schema with additional properties.', + ), + ); + } + + Field _buildAdapterField( + ModelInfo model, { + required String schemaExpression, + }) { + final adapter = _qualifyAckSymbol('AckModelAdapter'); + return Field( + (f) => f + ..name = r'$ack' + ..static = true + ..modifier = FieldModifier.final$ + ..assignment = Code(''' +$adapter( + schema: () => $schemaExpression, + fromRuntime: ${model.className}._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), +)'''), + ); + } + + Constructor _buildObjectConstructor( + ModelInfo model, + _ModelLookups lookups, + ) { + return Constructor( + (c) { + for (final field in model.fields) { + c.optionalParameters.add( + Parameter( + (p) => p + ..name = field.name + ..named = true + ..required = field.isRequired + ..type = refer(_fieldType(field, lookups)), + ), + ); + c.initializers.add( + Code('${field.name} = ${_constructorValue(field)}'), + ); + } + + if (model.additionalProperties) { + c.optionalParameters.add( + Parameter( + (p) => p + ..name = 'additionalProperties' + ..named = true + ..type = refer(_runtimeMapType) + ..defaultTo = const Code('const {}'), + ), + ); + c.initializers.add( + const Code( + 'additionalProperties = ' + 'Map.unmodifiable(additionalProperties)', + ), + ); + } + }, + ); + } + + String _constructorValue(FieldInfo field) { + final name = field.name; + final nullable = field.isNullable || !field.isRequired; + + if (field.isList) { + return nullable + ? '$name == null ? null : List.unmodifiable($name)' + : 'List.unmodifiable($name)'; + } + if (field.isSet) { + return nullable + ? '$name == null ? null : Set.unmodifiable($name)' + : 'Set.unmodifiable($name)'; + } + if (field.isMap) { + return nullable + ? '$name == null ? null : Map.unmodifiable($name)' + : 'Map.unmodifiable($name)'; + } + return name; + } + + Constructor _buildParseFactory(String className) { + return Constructor( + (c) => c + ..factory = true + ..name = 'parse' + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'input' + ..type = refer('Object?'), + ), + ) + ..body = const Code(r'return $ack.parse(input);'), + ); + } + + Constructor _buildFromMapFactory(String className) { + return Constructor( + (c) => c + ..factory = true + ..name = 'fromMap' + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'map' + ..type = refer(_runtimeMapType), + ), + ) + ..body = const Code(r'return $ack.parse(map);'), + ); + } + + Constructor _buildFromJsonFactory(String className) { + return Constructor( + (c) => c + ..factory = true + ..name = 'fromJson' + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'json' + ..type = refer(_jsonMapType), + ), + ) + ..body = const Code(r'return $ack.parse(json);'), + ); + } + + Method _buildSafeParse(String className) { + return Method( + (m) => m + ..name = 'safeParse' + ..static = true + ..returns = refer( + '${_qualifyAckSymbol('SchemaResult')}<$className>', + ) + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'input' + ..type = refer('Object?'), + ), + ) + ..lambda = true + ..body = const Code(r'$ack.safeParse(input)'), + ); + } + + Method _buildToMap() { + return Method( + (m) => m + ..name = 'toMap' + ..returns = refer(_runtimeMapType) + ..lambda = true + ..body = const Code(r'$ack.encode(this)'), + ); + } + + Method _buildToJson() { + return Method( + (m) => m + ..name = 'toJson' + ..returns = refer(_jsonMapType) + ..lambda = true + ..body = const Code('Map.from(toMap())'), + ); + } + + Method _buildSafeToMap() { + return Method( + (m) => m + ..name = 'safeToMap' + ..returns = refer( + '${_qualifyAckSymbol('SchemaResult')}<$_runtimeMapType>', + ) + ..lambda = true + ..body = const Code(r'$ack.safeEncode(this)'), + ); + } + + Method _buildSafeToJson() { + return Method( + (m) => m + ..name = 'safeToJson' + ..returns = refer( + '${_qualifyAckSymbol('SchemaResult')}<$_runtimeMapType>', + ) + ..lambda = true + ..body = const Code('safeToMap()'), + ); + } + + Method _buildObjectFromRuntime( + ModelInfo model, + _ModelLookups lookups, + ) { + final arguments = [ + for (final field in model.fields) + '${field.name}: ${_decodeField(field, lookups)}', + if (model.additionalProperties) + 'additionalProperties: ${_decodeAdditionalProperties(model)}', + ]; + + return Method( + (m) => m + ..name = '_fromAckRuntime' + ..static = true + ..returns = refer(model.className) + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..type = refer(_runtimeMapType), + ), + ) + ..body = Code(''' +return ${model.className}( + ${arguments.join(',\n ')}, +);'''), + ); + } + + Method _buildObjectToRuntime( + ModelInfo model, + _ModelLookups lookups, { + Map extraEntries = const {}, + }) { + final entries = [ + if (model.additionalProperties) '...additionalProperties', + for (final entry in extraEntries.entries) + '${_stringLiteral(entry.key)}: ${entry.value}', + for (final field in model.fields) _encodeFieldEntry(field, lookups), + ]; + + return Method( + (m) => m + ..name = '_toAckRuntime' + ..returns = refer(_runtimeMapType) + ..body = Code(''' +return { + ${entries.join(',\n ')}, +};'''), + ); + } + + String _decodeField(FieldInfo field, _ModelLookups lookups) { + final read = 'value[${_stringLiteral(field.jsonKey)}]'; + final nullable = field.isNullable || !field.isRequired; + final nonNull = _decodeNonNullField(field, lookups, read); + + if (!nullable) return nonNull; + if (_isDirectCastField(field)) { + final baseType = _baseFieldType(field, lookups); + return '$read as $baseType?'; + } + return '$read == null ? null : $nonNull'; + } + + String _decodeNonNullField( + FieldInfo field, + _ModelLookups lookups, + String read, + ) { + if (field.nestedSchemaRef != null) { + final typeName = _generatedModelName(field, lookups); + final castType = _nestedCastType(field, lookups); + return '$typeName.\$ack.fromRuntime($read as $castType)'; + } + + if (field.isList || field.isSet) { + final elementType = _collectionElementType(field, lookups); + if (_isGeneratedCollection(field, lookups)) { + final castType = _collectionElementCastType(field, lookups); + final converted = + '($read as List).map((item) => ' + '$elementType.\$ack.fromRuntime(item as $castType))'; + return field.isSet + ? '$converted.toSet()' + : '$converted.toList(growable: false)'; + } + return field.isSet + ? '($read as List).cast<$elementType>().toSet()' + : '($read as List).cast<$elementType>()'; + } + + if (field.isMap) { + return 'Map.from($read as Map)'; + } + + return '$read as ${_baseFieldType(field, lookups)}'; + } + + String _encodeFieldEntry(FieldInfo field, _ModelLookups lookups) { + final key = _stringLiteral(field.jsonKey); + final encoded = _encodeFieldValue(field, lookups); + if (!field.isRequired) { + return 'if (${field.name} != null) $key: $encoded'; + } + return '$key: $encoded'; + } + + String _encodeFieldValue(FieldInfo field, _ModelLookups lookups) { + final nullable = field.isNullable || !field.isRequired; + final name = field.name; + final nonNull = _encodeNonNullField(field, lookups, nullable ? '$name!' : name); + if (!nullable) return nonNull; + return '$name == null ? null : $nonNull'; + } + + String _encodeNonNullField( + FieldInfo field, + _ModelLookups lookups, + String value, + ) { + if (field.nestedSchemaRef != null) { + final typeName = _generatedModelName(field, lookups); + return '$typeName.\$ack.toRuntime($value)'; + } + + if (field.isList || field.isSet) { + if (_isGeneratedCollection(field, lookups)) { + final elementType = _collectionElementType(field, lookups); + return '$value.map((item) => ' + '$elementType.\$ack.toRuntime(item)).toList(growable: false)'; + } + return field.isSet ? '$value.toList(growable: false)' : value; + } + + return value; + } + + String _decodeAdditionalProperties(ModelInfo model) { + final knownKeys = model.fields.map((field) => field.jsonKey).toList(); + if (knownKeys.isEmpty) return 'Map.unmodifiable(value)'; + + final keys = knownKeys.map(_stringLiteral).join(', '); + return 'Map.unmodifiable(Map.fromEntries(' + 'value.entries.where((entry) => ' + '!const {$keys}.contains(entry.key))))'; + } + + String _fieldType(FieldInfo field, _ModelLookups lookups) { + final base = _baseFieldType(field, lookups); + if (field.isNullable || !field.isRequired) { + return base.endsWith('?') ? base : '$base?'; + } + return base; + } + + String _baseFieldType(FieldInfo field, _ModelLookups lookups) { + if (field.nestedSchemaRef != null) { + return _generatedModelName(field, lookups); + } + + if (field.type.isDartCoreString) return 'String'; + if (field.type.isDartCoreInt) return 'int'; + if (field.type.isDartCoreDouble) return 'double'; + if (field.type.isDartCoreBool) return 'bool'; + if (field.type.isDartCoreNum) return 'num'; + + if (_isSpecialType(field.type) || field.isEnum) { + return field.displayTypeOverride ?? + field.type.getDisplayString(withNullability: false); + } + + if (field.isList) { + return 'List<${_collectionElementType(field, lookups)}>'; + } + if (field.isSet) { + return 'Set<${_collectionElementType(field, lookups)}>'; + } + if (field.isMap) return _runtimeMapType; + + if (field.displayTypeOverride != null) { + return field.displayTypeOverride!; + } + + return 'Object?'; + } + + String _generatedModelName(FieldInfo field, _ModelLookups lookups) { + final override = field.displayTypeOverride; + if (override != null) return _removeGeneratedTypeSuffix(override); + + final schemaName = field.nestedSchemaRef; + final model = schemaName == null ? null : lookups.bySchemaName[schemaName]; + return model?.className ?? 'Object'; + } + + String _nestedCastType(FieldInfo field, _ModelLookups lookups) { + final override = field.nestedSchemaCastTypeOverride; + if (override != null) return override; + + final schemaName = field.nestedSchemaRef; + final model = schemaName == null ? null : lookups.bySchemaName[schemaName]; + return model?.representationType ?? _runtimeMapType; + } + + String _collectionElementType( + FieldInfo field, + _ModelLookups lookups, + ) { + final override = field.collectionElementDisplayTypeOverride; + if (override != null) { + return _isGeneratedCollection(field, lookups) + ? _removeGeneratedTypeSuffix(override) + : override; + } + + final schemaRef = field.listElementSchemaRef; + if (schemaRef != null) { + final model = lookups.bySchemaName[schemaRef]; + if (model != null) return model.className; + } + + final type = field.type; + if (type is ParameterizedType && type.typeArguments.isNotEmpty) { + return type.typeArguments.first.getDisplayString( + withNullability: false, + ); + } + + return 'Object?'; + } + + String _collectionElementCastType( + FieldInfo field, + _ModelLookups lookups, + ) { + final override = field.collectionElementCastTypeOverride; + if (override != null) return override; + + final schemaRef = field.listElementSchemaRef; + if (schemaRef != null) { + final model = lookups.bySchemaName[schemaRef]; + if (model != null) return model.representationType; + } + + return _runtimeMapType; + } + + bool _isGeneratedCollection( + FieldInfo field, + _ModelLookups lookups, + ) { + if (field.collectionElementIsCustomType) return true; + final schemaRef = field.listElementSchemaRef; + return schemaRef != null && lookups.bySchemaName.containsKey(schemaRef); + } + + bool _isDirectCastField(FieldInfo field) { + return field.nestedSchemaRef == null && + !field.isList && + !field.isSet && + !field.isMap; + } + + bool _isSpecialType(DartType type) { + final element = type.element3; + if (element is! InterfaceElement2) return false; + final name = element.name3; + final library = element.library2?.uri.toString(); + return library == 'dart:core' && + (name == 'DateTime' || name == 'Uri' || name == 'Duration'); + } + + String _removeGeneratedTypeSuffix(String name) { + final separator = name.lastIndexOf('.'); + final prefix = separator < 0 ? '' : name.substring(0, separator + 1); + final localName = separator < 0 ? name : name.substring(separator + 1); + if (!localName.endsWith('Type')) return name; + return '$prefix${localName.substring(0, localName.length - 4)}'; + } + + String _qualifyAckSymbol(String symbol) { + final prefix = _ackImportPrefix; + return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; + } + + String _stringLiteral(String value) { + final escaped = value + .replaceAll(r'\', r'\\') + .replaceAll("'", r"\'") + .replaceAll(r'$', r'\$'); + return "'$escaped'"; + } +} diff --git a/packages/ack_generator/lib/src/builders/type_builder.dart b/packages/ack_generator/lib/src/builders/type_builder.dart deleted file mode 100644 index d0df8d7a..00000000 --- a/packages/ack_generator/lib/src/builders/type_builder.dart +++ /dev/null @@ -1,1083 +0,0 @@ -import 'package:analyzer/dart/element/element2.dart'; -import 'package:analyzer/dart/element/type.dart'; -import 'package:build/build.dart' show log; -import 'package:code_builder/code_builder.dart'; - -import '../models/field_info.dart'; -import '../models/model_info.dart'; - -class _ModelLookups { - final Map byClassName; - final Map bySchemaClassName; - - _ModelLookups(List models) - : byClassName = _buildByClassName(models), - bySchemaClassName = _buildBySchemaClassName(models); - - ModelInfo? classByName(String name) => byClassName[name]; - ModelInfo? schemaByName(String name) => bySchemaClassName[name]; - - static Map _buildByClassName(List models) { - final map = {}; - for (final model in models) { - map.putIfAbsent(model.className, () => model); - } - return map; - } - - static Map _buildBySchemaClassName( - List models, - ) { - final map = {}; - for (final model in models) { - map.putIfAbsent(model.schemaClassName, () => model); - } - return map; - } -} - -enum _GeneratedHelper { listCast, setCast } - -/// Builds Dart 3 extension types for models annotated with `@AckType` -/// -/// Extension types provide zero-cost type-safe wrappers over validated -/// `Map` data returned by schema validation. -class TypeBuilder { - String? _ackImportPrefix; - - /// Configures the import prefix used for `package:ack/ack.dart` in the - /// source library that owns the generated part file. - /// - /// When Ack is imported as `import 'package:ack/ack.dart' as ack;`, - /// generated references must use `ack.SchemaResult` in part files. - void setAckImportPrefix(String? prefix) { - _ackImportPrefix = prefix; - } - - /// Builds top-level private helper functions used by generated extension - /// types. - /// - /// Helpers are emitted only when needed to avoid analyzer warnings in - /// generated files. - List buildTopLevelHelpers(List models) { - if (models.isEmpty) return const []; - - final lookups = _ModelLookups(models); - final helpers = <_GeneratedHelper>{}; - - for (final model in models) { - for (final field in model.fields) { - if ((field.isList || field.isSet) && - !_isCustomElementType(field, lookups)) { - helpers.add( - field.isSet ? _GeneratedHelper.setCast : _GeneratedHelper.listCast, - ); - } - } - } - - final result = []; - if (helpers.contains(_GeneratedHelper.listCast)) { - result.add(_buildListCastHelper()); - } - if (helpers.contains(_GeneratedHelper.setCast)) { - result.add(_buildSetCastHelper()); - } - - return result; - } - - /// Builds an extension type for the given model - /// - /// Returns null if the model should not generate an extension type: - /// - Discriminated base schemas (generated separately via - /// [buildDiscriminatedExtensionBase]) - /// - Nullable schema variables (representation is non-nullable) - ExtensionType? buildExtensionType( - ModelInfo model, - List allModels, - ) { - // Discriminated base schemas get dedicated base extension types. - if (model.isDiscriminatedBaseDefinition) { - return null; - } - - // Nullable schema variables can't be safely wrapped (representation is non-nullable). - if (model.isNullableSchema) { - return null; - } - - final typeName = _getExtensionTypeName(model); - final schemaVarName = _toCamelCase(model.schemaClassName); - final lookups = _ModelLookups(allModels); - - final isObjectSchema = model.representationType == kMapType; - final valueVarName = isObjectSchema ? '_data' : '_value'; - - return ExtensionType( - (b) => b - ..name = typeName - ..docs.addAll(_buildDocs(model)) - ..representationDeclaration = RepresentationDeclaration( - (r) => r - ..declaredRepresentationType = refer(model.representationType) - ..name = valueVarName, - ) - ..implements.add(refer(model.representationType)) - ..methods.addAll([ - ..._buildStaticFactories(model, schemaVarName), - ..._buildGetters(model, lookups), - // Only add args for object schemas - if (isObjectSchema) ...[ - if (model.additionalProperties) _buildArgsGetter(model), - ], - ]), - ); - } - - /// Builds an extension type for discriminated @AckType base schemas. - ExtensionType? buildDiscriminatedExtensionBase( - ModelInfo model, - List allModels, - ) { - if (!model.isDiscriminatedBaseDefinition) { - return null; - } - - final typeName = _getExtensionTypeName(model); - final schemaVarName = _toCamelCase(model.schemaClassName); - final subtypeNames = model.subtypeNames; - - if (subtypeNames == null || subtypeNames.isEmpty) { - return null; - } - - // Resolve schemaClassName → className for the switch expression. - // subtypeNames stores schemaClassName for @AckType models; - // _buildDiscriminatorSwitchExpression needs className to emit TypeName. - final resolvedSubtypeNames = {}; - for (final entry in subtypeNames.entries) { - final branchModel = allModels.firstWhere( - (m) => m.schemaClassName == entry.value, - orElse: () => throw StateError( - 'Failed to resolve discriminated subtype "${entry.value}" ' - '(discriminator "${entry.key}") for base ' - '"${model.schemaClassName}" while building ' - 'extension type "$typeName".', - ), - ); - resolvedSubtypeNames[entry.key] = branchModel.className; - } - - return ExtensionType( - (b) => b - ..name = typeName - ..docs.addAll(_buildDocs(model)) - ..representationDeclaration = RepresentationDeclaration( - (r) => r - ..declaredRepresentationType = refer('Map') - ..name = '_data', - ) - ..implements.add(refer('Map')) - ..methods.addAll([ - Method( - (m) => m - ..type = MethodType.getter - ..name = model.discriminatorKey! - ..returns = refer('String') - ..lambda = true - ..body = Code("_data['${model.discriminatorKey}'] as String"), - ), - _buildDiscriminatedFactory( - model, - schemaVarName, - resolvedSubtypeNames, - ), - _buildDiscriminatedSafeParse( - model, - schemaVarName, - resolvedSubtypeNames, - ), - ]), - ); - } - - /// Builds extension type for discriminated subtypes - ExtensionType? buildDiscriminatedSubtype( - ModelInfo model, - ModelInfo baseModel, - List allModels, - ) { - if (!model.isDiscriminatedSubtype) { - return null; - } - - final typeName = _getExtensionTypeName(model); - final baseTypeName = _getExtensionTypeName(baseModel); - final lookups = _ModelLookups(allModels); - final discriminatorKey = baseModel.discriminatorKey!; - - return ExtensionType( - (b) => b - ..name = typeName - ..docs.addAll(_buildDocs(model)) - ..representationDeclaration = RepresentationDeclaration( - (r) => r - ..declaredRepresentationType = refer('Map') - ..name = '_data', - ) - ..implements.addAll([ - refer(baseTypeName), - refer('Map'), - ]) - ..methods.addAll([ - // Subtype factories validate through the effective branch schema. - Method( - (m) => m - ..type = MethodType.getter - ..name = discriminatorKey - ..returns = refer('String') - ..lambda = true - ..body = Code("_data['$discriminatorKey'] as String"), - ), - ..._buildDiscriminatedSubtypeFactories(model, baseModel), - // Add regular field getters - ..._buildGetters(model, lookups, skipJsonKeys: {discriminatorKey}), - if (model.additionalProperties) - _buildArgsGetter(model, additionalKnownKeys: {discriminatorKey}), - ]), - ); - } - - /// Sorts models in topological order (dependencies before dependents). - /// - /// If circular dependencies are detected, logs a warning and falls back to the - /// original input order. Extension types based on `Map` work - /// correctly with cycles since they all wrap the same underlying type. - List topologicalSort(List models) { - final lookups = _ModelLookups(models); - final sorted = []; - final visiting = {}; - final visited = {}; - var hasCycle = false; - final cycleParticipants = {}; - - // Build dependency map - final dependencies = >{}; - for (final model in models) { - dependencies[model.className] = _extractDependencies(model, lookups); - } - - void visit(String className) { - if (visited.contains(className)) return; - - if (visiting.contains(className)) { - // Cycle detected - mark flag but continue - // Extension types with Map representation work fine with cycles - hasCycle = true; - cycleParticipants.add(className); - return; - } - - visiting.add(className); - - // Visit dependencies first - final deps = dependencies[className] ?? {}; - for (final dep in deps) { - if (dependencies.containsKey(dep)) { - visit(dep); - } - } - - visiting.remove(className); - visited.add(className); - - // Add to sorted list - final model = lookups.classByName(className); - if (model == null) { - throw StateError('Missing model for class name: $className'); - } - sorted.add(model); - } - - // Visit all models - for (final model in models) { - visit(model.className); - } - - // If cycle detected, fall back to original order - // This is safe because extension types wrap Map which doesn't - // require declaration order - if (hasCycle) { - log.warning( - 'Circular dependency detected between extension types: ' - '${cycleParticipants.join(', ')}. ' - 'Using original declaration order (safe for Map-based types).', - ); - return models; - } - - return sorted; - } - - // --- Private Helper Methods --- - - String _qualifyAckSymbol(String symbol) { - final prefix = _ackImportPrefix; - if (prefix == null || prefix.isEmpty) return symbol; - return '$prefix.$symbol'; - } - - Reference _schemaResultRef(Reference innerType) { - return TypeReference( - (b) => b - ..symbol = _qualifyAckSymbol('SchemaResult') - ..types.add(innerType), - ); - } - - String _getExtensionTypeName(ModelInfo model) { - return '${model.className}Type'; - } - - String _toCamelCase(String text) { - if (text.isEmpty) return text; - return text[0].toLowerCase() + text.substring(1); - } - - List _buildDocs(ModelInfo model) { - final docs = ['/// Extension type for ${model.className}']; - if (model.description != null) { - docs.add('/// ${model.description}'); - } - return docs; - } - - List _buildStaticFactories(ModelInfo model, String schemaVarName) { - final typeName = _getExtensionTypeName(model); - final castType = model.representationType; - - return [ - // Static parse factory - Method( - (m) => m - ..name = 'parse' - ..static = true - ..returns = refer(typeName) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..body = Code(''' -return $schemaVarName.parseAs( - data, - (validated) => $typeName(validated as $castType), -);'''), - ), - // Static safeParse method - Method( - (m) => m - ..name = 'safeParse' - ..static = true - ..returns = _schemaResultRef(refer(typeName)) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..body = Code(''' -return $schemaVarName.safeParseAs( - data, - (validated) => $typeName(validated as $castType), -);'''), - ), - ]; - } - - List _buildDiscriminatedSubtypeFactories( - ModelInfo model, - ModelInfo baseModel, - ) { - final typeName = _getExtensionTypeName(model); - final baseSchemaVarName = _toCamelCase(baseModel.schemaClassName); - final discriminatorValue = model.discriminatorValue!; - final effectiveBranch = - "$baseSchemaVarName.effectiveBranch('$discriminatorValue')"; - - return [ - Method( - (m) => m - ..name = 'parse' - ..static = true - ..returns = refer(typeName) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..body = Code(''' -return $effectiveBranch.parseAs( - data, - (validated) => $typeName(validated as Map), -);'''), - ), - Method( - (m) => m - ..name = 'safeParse' - ..static = true - ..returns = _schemaResultRef(refer(typeName)) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..body = Code(''' -return $effectiveBranch.safeParseAs( - data, - (validated) => $typeName(validated as Map), -);'''), - ), - ]; - } - - Method _buildDiscriminatedFactory( - ModelInfo model, - String schemaVarName, - Map subtypeNames, - ) { - final typeName = _getExtensionTypeName(model); - final discriminatorKey = model.discriminatorKey!; - final switchExpression = _buildDiscriminatorSwitchExpression( - 'map', - discriminatorKey, - subtypeNames, - ); - - return Method( - (m) => m - ..name = 'parse' - ..static = true - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..returns = refer(typeName) - ..body = Code(''' -return $schemaVarName.parseAs( - data, - (validated) { - final map = validated as Map; - return $switchExpression; - }, -);'''), - ); - } - - Method _buildDiscriminatedSafeParse( - ModelInfo model, - String schemaVarName, - Map subtypeNames, - ) { - final typeName = _getExtensionTypeName(model); - final discriminatorKey = model.discriminatorKey!; - final switchExpression = _buildDiscriminatorSwitchExpression( - 'map', - discriminatorKey, - subtypeNames, - ); - - return Method( - (m) => m - ..name = 'safeParse' - ..static = true - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..returns = _schemaResultRef(refer(typeName)) - ..body = Code(''' -return $schemaVarName.safeParseAs( - data, - (validated) { - final map = validated as Map; - return $switchExpression; - }, -);'''), - ); - } - - String _buildDiscriminatorSwitchExpression( - String mapVarName, - String discriminatorKey, - Map subtypeNames, - ) { - final cases = []; - for (final entry in subtypeNames.entries) { - final discriminatorValue = entry.key; - final subtypeClassName = entry.value; - final subtypeTypeName = '${subtypeClassName}Type'; - - cases.add(" '$discriminatorValue' => $subtypeTypeName($mapVarName)"); - } - - return ''' -switch ($mapVarName['$discriminatorKey']) { -${cases.join(',\n')}, - _ => throw StateError('Unknown $discriminatorKey: \${$mapVarName['$discriminatorKey']}'), -}'''; - } - - List _buildGetters( - ModelInfo model, - _ModelLookups lookups, { - Set skipJsonKeys = const {}, - }) { - return model.fields - .where((field) => !skipJsonKeys.contains(field.jsonKey)) - .map((field) => _buildGetter(field, lookups)) - .toList(); - } - - Method _buildGetter(FieldInfo field, _ModelLookups lookups) { - final baseType = _resolveFieldType(field, lookups); - // Optional fields need nullable return types because the key might be missing. - final needsNullHandling = field.isNullable || !field.isRequired; - final returnType = _applyNullability(baseType, needsNullHandling); - final body = _buildGetterBody( - field, - lookups, - baseType: baseType, - needsNullHandling: needsNullHandling, - ); - - return Method( - (m) => m - ..type = MethodType.getter - ..name = field.name - ..returns = refer(returnType) - ..lambda = true - ..body = Code(body), - ); - } - - String _applyNullability(String baseType, bool shouldBeNullable) { - if (!shouldBeNullable) return baseType; - if (baseType.endsWith('?')) return baseType; - return '$baseType?'; - } - - String _buildGetterBody( - FieldInfo field, - _ModelLookups lookups, { - required String baseType, - required bool needsNullHandling, - }) { - final key = field.jsonKey; - - if (needsNullHandling) { - return _buildNullableGetter(field, lookups, key, baseType: baseType); - } else { - return _buildNonNullableGetter(field, lookups, key, baseType: baseType); - } - } - - String _buildNonNullableGetter( - FieldInfo field, - _ModelLookups lookups, - String key, { - required String baseType, - }) { - // Nested schema variable reference (e.g., 'status': statusSchema). - if (field.nestedSchemaRef != null) { - if (field.displayTypeOverride != null) { - final castType = field.nestedSchemaCastTypeOverride ?? kMapType; - return "${field.displayTypeOverride!}(_data['$key'] as $castType)"; - } - - final referencedModel = _findSchemaModel(field.nestedSchemaRef!, lookups); - if (referencedModel != null) { - final typeName = '${referencedModel.className}Type'; - final castType = referencedModel.representationType; - return "$typeName(_data['$key'] as $castType)"; - } - return "_data['$key'] as Map"; - } - - // Primitive and already-validated core value types. - if (field.isPrimitive || _isSpecialType(field.type)) { - return "_data['$key'] as $baseType"; - } - - // Enums (validated by schema, returns the enum value) - if (field.isEnum) { - return "_data['$key'] as $baseType"; - } - - if (field.displayTypeOverride != null) { - return "_data['$key'] as $baseType"; - } - - // Lists - if (field.isList) { - return _buildListGetter(field, lookups, key); - } - - // Maps - if (field.isMap) { - return "_data['$key'] as Map"; - } - - // Sets - if (field.isSet) { - return _buildSetGetter(field, lookups, key); - } - - // Nested schema - if (field.isNestedSchema && _hasAckType(field, lookups)) { - final typeConstructor = _getTypeConstructor(field); - return "$typeConstructor(_data['$key'] as Map)"; - } - - // Generic or unknown - return as Object? - return "_data['$key']"; - } - - String _buildNullableGetter( - FieldInfo field, - _ModelLookups lookups, - String key, { - required String baseType, - }) { - if (field.nestedSchemaRef != null) { - final nonNullPart = _buildNonNullableGetter( - field, - lookups, - key, - baseType: baseType, - ); - return "_data['$key'] != null ? $nonNullPart : null"; - } - - // For primitives, enums, and already-validated core value types, - // nullable cast works directly on the validated map payload. - if (field.isPrimitive || field.isEnum || _isSpecialType(field.type)) { - return "_data['$key'] as $baseType?"; - } - - if (field.displayTypeOverride != null) { - return "_data['$key'] as $baseType?"; - } - - // For complex types, check null first - final nonNullPart = _buildNonNullableGetter( - field, - lookups, - key, - baseType: baseType, - ); - return "_data['$key'] != null ? $nonNullPart : null"; - } - - String _buildListGetter(FieldInfo field, _ModelLookups lookups, String key) => - _buildCollectionGetter(field, lookups, key, isSet: false); - - String _buildSetGetter(FieldInfo field, _ModelLookups lookups, String key) => - _buildCollectionGetter(field, lookups, key, isSet: true); - - /// Builds getter code for List or Set collections - String _buildCollectionGetter( - FieldInfo field, - _ModelLookups lookups, - String key, { - required bool isSet, - }) { - final elementType = _getCollectionElementType(field, lookups, isSet: isSet); - - final suffix = isSet ? '.toSet()' : ''; - - // Check if element type is a custom type with @AckType - if (_isCustomElementType(field, lookups)) { - // Return eager List for object lists (per requirements: List, not Iterable) - final listSuffix = isSet ? '' : '.toList()'; - final castType = _getCustomElementCastType(field, elementType, lookups); - final constructorName = field.collectionElementIsCustomType - ? _asExtensionTypeName(elementType) - : '${elementType}Type'; - return "(_data['$key'] as List).map((e) => $constructorName(e as $castType))$listSuffix$suffix"; - } - - // Primitive lists/sets - direct cast - if (isSet) { - return "_\$ackSetCast<$elementType>(_data['$key'])"; - } - return "_\$ackListCast<$elementType>(_data['$key'])"; - } - - Method _buildListCastHelper() { - return Method( - (m) => m - ..name = '_\$ackListCast' - ..types.add(refer('T')) - ..returns = TypeReference( - (b) => b - ..symbol = 'List' - ..types.add(refer('T')), - ) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'value' - ..type = refer('Object?'), - ), - ) - ..lambda = true - ..body = Code('(value as List).cast()'), - ); - } - - Method _buildSetCastHelper() { - return Method( - (m) => m - ..name = '_\$ackSetCast' - ..types.add(refer('T')) - ..returns = TypeReference( - (b) => b - ..symbol = 'Set' - ..types.add(refer('T')), - ) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'value' - ..type = refer('Object?'), - ), - ) - ..lambda = true - ..body = Code('(value as List).cast().toSet()'), - ); - } - - String _resolveFieldType(FieldInfo field, _ModelLookups lookups) { - // Nested schema variable reference (e.g., 'status': statusSchema). - if (field.nestedSchemaRef != null) { - if (field.displayTypeOverride != null) { - return field.displayTypeOverride!; - } - - final referencedModel = _findSchemaModel(field.nestedSchemaRef!, lookups); - if (referencedModel != null) { - return '${referencedModel.className}Type'; - } - return kMapType; - } - - // Primitives - if (field.type.isDartCoreString) return 'String'; - if (field.type.isDartCoreInt) return 'int'; - if (field.type.isDartCoreDouble) return 'double'; - if (field.type.isDartCoreBool) return 'bool'; - if (field.type.isDartCoreNum) return 'num'; - - // Special types - if (_isSpecialType(field.type)) { - return field.type.getDisplayString(withNullability: false); - } - - // Enums - if (field.isEnum) { - return field.displayTypeOverride ?? - field.type.getDisplayString(withNullability: false); - } - - // Lists - if (field.isList) { - final elementType = _getCollectionElementType( - field, - lookups, - isSet: false, - ); - // Use List for all list types (eager evaluation per requirements) - if (_isCustomElementType(field, lookups)) { - final customElementType = field.collectionElementIsCustomType - ? _asExtensionTypeName(elementType) - : '${elementType}Type'; - return 'List<$customElementType>'; - } - return 'List<$elementType>'; - } - - // Maps - if (field.isMap) { - return 'Map'; - } - - // Sets - if (field.isSet) { - final elementType = _getCollectionElementType( - field, - lookups, - isSet: true, - ); - if (_isCustomElementType(field, lookups)) { - final customElementType = field.collectionElementIsCustomType - ? _asExtensionTypeName(elementType) - : '${elementType}Type'; - return 'Set<$customElementType>'; - } - return 'Set<$elementType>'; - } - - // Generic types - if (field.isGeneric) { - return 'Object?'; - } - - if (field.displayTypeOverride != null) { - return field.displayTypeOverride!; - } - - // Nested schema - if (field.isNestedSchema && _hasAckType(field, lookups)) { - final baseType = field.type.getDisplayString(withNullability: false); - return '${baseType}Type'; - } - - // Fallback to Object? - return 'Object?'; - } - - String _getCollectionElementType( - FieldInfo field, - _ModelLookups lookups, { - required bool isSet, - }) { - if (field.collectionElementDisplayTypeOverride != null) { - return field.collectionElementDisplayTypeOverride!; - } - - // Check for schema variable reference first (e.g., Ack.list(addressSchema)) - if (!isSet && field.listElementSchemaRef != null) { - final referencedModel = _findSchemaModel( - field.listElementSchemaRef!, - lookups, - ); - if (referencedModel != null) { - return referencedModel.className; - } - return kMapType; - } - - if (field.type is! ParameterizedType) return 'dynamic'; - - final paramType = field.type as ParameterizedType; - if (paramType.typeArguments.isEmpty) return 'dynamic'; - - return _resolveTypeReference(paramType.typeArguments[0], lookups); - } - - String _resolveTypeReference(DartType type, _ModelLookups lookups) { - final baseType = type.getDisplayString(withNullability: false); - - // Primitives - if (type.isDartCoreString) return 'String'; - if (type.isDartCoreInt) return 'int'; - if (type.isDartCoreDouble) return 'double'; - if (type.isDartCoreBool) return 'bool'; - if (type.isDartCoreNum) return 'num'; - - // Special types - if (_isSpecialType(type)) return baseType; - - // Check if this is a custom type with @AckType - final element = type.element3; - if (element is InterfaceElement2) { - if (_hasAckTypeForElement(element, lookups)) { - return baseType; - } - } - - return baseType; - } - - bool _isSpecialType(DartType type) { - final element = type.element3; - if (element == null) return false; - - final name = element.name3; - final library = element.library2?.name3; - - return (name == 'DateTime' && library == 'dart.core') || - (name == 'Uri' && library == 'dart.core') || - (name == 'Duration' && library == 'dart.core'); - } - - bool _hasAckType(FieldInfo field, _ModelLookups lookups) { - final element = field.type.element3; - if (element is! InterfaceElement2) return false; - - return _hasAckTypeForElement(element, lookups); - } - - bool _hasAckTypeForElement(InterfaceElement2 element, _ModelLookups lookups) { - final name = element.name3; - if (name == null) return false; - return lookups.byClassName.containsKey(name); - } - - bool _isCustomElementType(FieldInfo field, _ModelLookups lookups) { - if (field.collectionElementIsCustomType) { - return true; - } - - // Check for schema variable reference first (e.g., Ack.list(addressSchema)) - if (field.listElementSchemaRef != null) { - return _findSchemaModel(field.listElementSchemaRef!, lookups) != null; - } - - if (field.type is! ParameterizedType) return false; - - final paramType = field.type as ParameterizedType; - if (paramType.typeArguments.isEmpty) return false; - - final elementType = paramType.typeArguments[0]; - final element = elementType.element3; - - if (element is! InterfaceElement2) return false; - - return _hasAckTypeForElement(element, lookups); - } - - String _getTypeConstructor(FieldInfo field) { - final baseType = field.type.getDisplayString(withNullability: false); - return '${baseType}Type'; - } - - String _asExtensionTypeName(String typeName) { - return typeName.endsWith('Type') ? typeName : '${typeName}Type'; - } - - ModelInfo? _findSchemaModel(String schemaVarName, _ModelLookups lookups) { - return lookups.schemaByName(schemaVarName); - } - - String _getCustomElementCastType( - FieldInfo field, - String elementType, - _ModelLookups lookups, - ) { - if (field.collectionElementCastTypeOverride != null) { - return field.collectionElementCastTypeOverride!; - } - - if (field.listElementSchemaRef != null) { - final referencedModel = _findSchemaModel( - field.listElementSchemaRef!, - lookups, - ); - if (referencedModel != null) { - return referencedModel.representationType; - } - } - - final elementModel = lookups.classByName(elementType); - return elementModel?.representationType ?? kMapType; - } - - Set _extractDependencies(ModelInfo model, _ModelLookups lookups) { - final dependencies = {}; - - final discriminatedBaseClassName = model.discriminatedBaseClassName; - if (discriminatedBaseClassName != null && - lookups.byClassName.containsKey(discriminatedBaseClassName)) { - dependencies.add(discriminatedBaseClassName); - } - - for (final field in model.fields) { - if (field.isPrimitive || - field.isEnum || - field.isGeneric || - _isSpecialType(field.type)) { - continue; - } - - // Nested schema - if (field.isNestedSchema) { - final typeName = field.type.getDisplayString(withNullability: false); - - // Skip self-references - circular schemas are valid for Map-based extension types - // Extension types don't require declaration order since they all wrap Map - if (typeName == model.className) { - continue; // Self-reference doesn't create a dependency - } - - if (lookups.byClassName.containsKey(typeName)) { - dependencies.add(typeName); - } - } - - // List/Set of objects - if (field.isList || field.isSet) { - if (field.type is ParameterizedType) { - final paramType = field.type as ParameterizedType; - if (paramType.typeArguments.isNotEmpty) { - final elementType = paramType.typeArguments[0]; - final element = elementType.element3; - - if (element is InterfaceElement2) { - final name = element.name3; - if (name != null && lookups.byClassName.containsKey(name)) { - dependencies.add(name); - } - } - } - } - } - } - - return dependencies; - } - - /// Builds the `args` getter that returns additional properties - /// - /// Returns a Map containing only properties that are not explicitly - /// defined in the schema. This is useful when additionalProperties: true. - Method _buildArgsGetter( - ModelInfo model, { - Set additionalKnownKeys = const {}, - }) { - final knownKeys = { - ...additionalKnownKeys, - ...model.fields.map((f) => f.jsonKey), - }; - - // Generate filter condition inline for better performance - final conditions = knownKeys.map((k) => "e.key != '$k'").toList(); - final filterExpr = conditions.isEmpty - ? '_data' - : 'Map.fromEntries(_data.entries.where((e) => ${conditions.join(' && ')}))'; - - return Method( - (m) => m - ..type = MethodType.getter - ..name = 'args' - ..returns = refer('Map') - ..lambda = true - ..body = Code(filterExpr), - ); - } -} diff --git a/packages/ack_generator/lib/src/generator.dart b/packages/ack_generator/lib/src/generator.dart index 2913eeba..5896c55e 100644 --- a/packages/ack_generator/lib/src/generator.dart +++ b/packages/ack_generator/lib/src/generator.dart @@ -2,24 +2,15 @@ import 'package:ack_annotations/ack_annotations.dart'; import 'package:analyzer/dart/element/element2.dart'; import 'package:build/build.dart'; import 'package:code_builder/code_builder.dart'; -import 'package:dart_style/dart_style.dart'; -import 'package:logging/logging.dart'; import 'package:source_gen/source_gen.dart'; import 'analyzer/schema_ast_analyzer.dart'; -import 'builders/type_builder.dart'; +import 'builders/class_builder.dart'; import 'models/model_info.dart'; -import 'validation/code_validator.dart'; - -/// Logger for schema generation warnings and diagnostics. -final _log = Logger('AckSchemaGenerator'); - -/// Generates extension types for top-level schemas annotated with `@AckType`. -class AckSchemaGenerator extends Generator { - final _formatter = DartFormatter( - languageVersion: DartFormatter.latestLanguageVersion, - ); +/// Generates immutable model classes for top-level schemas annotated with +/// `@AckType`. +final class AckSchemaGenerator extends Generator { @override String generate(LibraryReader library, BuildStep buildStep) { final annotatedVariables = []; @@ -72,227 +63,109 @@ class AckSchemaGenerator extends Generator { return ''; } - final helperMethods = []; - final extensionTypes = []; - final schemaAstAnalyzer = SchemaAstAnalyzer(); - final typeBuilder = TypeBuilder(); - typeBuilder.setAckImportPrefix(_resolveAckImportPrefix(library)); - - final modelInfos = []; + final analyzer = SchemaAstAnalyzer(); + final models = []; for (final variable in annotatedVariables) { try { - final modelInfo = schemaAstAnalyzer.analyzeSchemaVariable( + final model = analyzer.analyzeSchemaVariable( variable, customTypeName: _extractAckTypeName(variable), ); - if (modelInfo != null) { - modelInfos.add(modelInfo); - } - } catch (e) { + if (model != null) models.add(model); + } catch (error) { throw InvalidGenerationSource( - 'Failed to analyze schema variable "${variable.name3}": $e', + 'Failed to analyze schema variable "${variable.name3}": $error', element: variable, todo: - 'Ensure the variable uses Ack schema syntax such as Ack.object(), Ack.string(), or another @AckType schema reference.', + 'Ensure the variable uses statically analyzable Ack schema syntax.', ); } } for (final getter in annotatedGetters) { try { - final modelInfo = schemaAstAnalyzer.analyzeSchemaGetter( + final model = analyzer.analyzeSchemaGetter( getter, customTypeName: _extractAckTypeName(getter), ); - if (modelInfo != null) { - modelInfos.add(modelInfo); - } - } catch (e) { + if (model != null) models.add(model); + } catch (error) { throw InvalidGenerationSource( - 'Failed to analyze schema getter "${getter.name3}": $e', + 'Failed to analyze schema getter "${getter.name3}": $error', element: getter, todo: - 'Ensure the getter returns Ack schema syntax such as Ack.object(), Ack.string(), or another @AckType schema reference.', + 'Ensure the getter returns a statically analyzable Ack schema.', ); } } - final linkedModelInfos = _linkDiscriminatedModels(modelInfos); - - _generateExtensionTypes( - annotatedVariables, - annotatedGetters, - linkedModelInfos, - typeBuilder, - helperMethods, - extensionTypes, - ); - - if (extensionTypes.isEmpty) { - return ''; - } - - final inputFileName = buildStep.inputId.pathSegments.last; - final generatedLibrary = Library( - (b) => b - ..directives.add(Directive.partOf(inputFileName)) - ..body.addAll([...helperMethods, ...extensionTypes]), - ); - - final emitter = DartEmitter( - allocator: Allocator.none, - orderDirectives: true, - useNullSafetySyntax: true, - ); + final linkedModels = _linkDiscriminatedModels(models); + _validateGeneratedClassNames(library, linkedModels); - final generatedCode = generatedLibrary.accept(emitter).toString(); + final classBuilder = AckClassBuilder() + ..setAckImportPrefix(_resolveAckImportPrefix(library)); - String formattedCode; + final List classes; try { - formattedCode = _formatter.format(generatedCode); - } catch (e) { - _log.warning('Code formatting failed, using unformatted output: $e'); - formattedCode = generatedCode; - } - - final validation = CodeValidator.validate(formattedCode); - if (validation.isFailure) { - throw InvalidGenerationSource( - 'Generated code validation failed: ${validation.errorMessage}\n' - 'Generated output:\n$formattedCode', - todo: 'Fix the code generation logic to produce valid Dart syntax.', - ); - } - - return formattedCode; - } - - void _generateExtensionTypes( - List annotatedVariables, - List annotatedGetters, - List models, - TypeBuilder typeBuilder, - List helperMethods, - List extensionTypes, - ) { - final typedElements = [ - for (final model in models) - _findAnnotatedSchemaElement( - model.schemaClassName, + classes = classBuilder.buildClasses(linkedModels); + } catch (error) { + final element = linkedModels.isEmpty + ? null + : _findAnnotatedSchemaElement( + linkedModels.first.schemaClassName, annotatedVariables, annotatedGetters, - ) ?? - (throw InvalidGenerationSource( - 'Could not find schema declaration "${model.schemaClassName}"', - todo: - 'Ensure the schema variable or getter exists and is annotated with @AckType.', - )), - ]; - - List sortedModels; - try { - sortedModels = typeBuilder.topologicalSort(models); - } catch (e) { - final element = typedElements.first; + ); throw InvalidGenerationSource( - 'Extension type dependency resolution failed: $e', + 'Ack model class generation failed: $error', element: element, todo: - 'Check for circular dependencies in the typed schema graph and ensure nested schemas resolve to @AckType declarations.', + 'Check generated-name collisions, nullable root schemas, and unsupported schema shapes.', ); } - final generatedTypeModels = []; + if (classes.isEmpty) return ''; + + // SharedPartBuilder owns the generated header, `part of` directive, and + // target-language formatting. Generators return declarations only. + final generatedLibrary = Library((b) => b.body.addAll(classes)); + return generatedLibrary + .accept( + DartEmitter( + allocator: Allocator.none, + orderDirectives: true, + useNullSafetySyntax: true, + ), + ) + .toString(); + } - for (final model in sortedModels) { - final element = _findAnnotatedSchemaElement( - model.schemaClassName, - annotatedVariables, - annotatedGetters, - ); - if (element == null) { + void _validateGeneratedClassNames( + LibraryReader library, + List models, + ) { + final existingNames = { + for (final element in library.classes) + if (element.name3 case final name?) name, + }; + + final generatedNames = {}; + for (final model in models) { + if (!generatedNames.add(model.className)) { throw InvalidGenerationSource( - 'Could not find schema declaration "${model.schemaClassName}"', - todo: - 'Ensure the schema variable or getter exists and is annotated with @AckType.', + 'Multiple @AckType declarations generate "${model.className}".', + todo: 'Give one declaration a unique @AckType(name: ...) value.', ); } - - try { - if (model.isDiscriminatedBaseDefinition) { - final baseExtension = typeBuilder.buildDiscriminatedExtensionBase( - model, - sortedModels, - ); - if (baseExtension != null) { - extensionTypes.add(baseExtension); - } - - final subtypeNames = model.subtypeNames; - if (subtypeNames == null) { - continue; - } - - final emittedSubtypeSchemaNames = {}; - for (final subtypeSchemaName in subtypeNames.values) { - if (!emittedSubtypeSchemaNames.add(subtypeSchemaName)) { - throw InvalidGenerationSource( - 'Discriminated base "${model.schemaClassName}" maps multiple discriminator values to subtype "$subtypeSchemaName".', - element: element, - todo: - 'Ensure each discriminator value maps to a unique branch schema.', - ); - } - - final subtypeModel = sortedModels.firstWhere( - (candidate) => candidate.schemaClassName == subtypeSchemaName, - orElse: () => throw InvalidGenerationSource( - 'Subtype "$subtypeSchemaName" was not found while generating "${model.schemaClassName}".', - element: element, - ), - ); - - final subtypeExtension = typeBuilder.buildDiscriminatedSubtype( - subtypeModel, - model, - sortedModels, - ); - if (subtypeExtension != null) { - extensionTypes.add(subtypeExtension); - generatedTypeModels.add(subtypeModel); - } - } - continue; - } - - if (model.isDiscriminatedSubtype) { - continue; - } - - final extensionType = typeBuilder.buildExtensionType( - model, - sortedModels, - ); - if (extensionType != null) { - extensionTypes.add(extensionType); - generatedTypeModels.add(model); - } - } catch (e) { + if (existingNames.contains(model.className)) { throw InvalidGenerationSource( - 'Extension type generation failed for ${element.name3}: $e', - element: element, + 'Generated class "${model.className}" conflicts with an existing class in this library.', todo: - 'Ensure nested schemas resolve to @AckType declarations and unsupported schema shapes are not annotated.', + 'Rename the existing class or set a unique @AckType(name: ...) value.', ); } } - - if (generatedTypeModels.isNotEmpty) { - helperMethods.addAll( - typeBuilder.buildTopLevelHelpers(generatedTypeModels), - ); - } } List _linkDiscriminatedModels(List models) { @@ -304,49 +177,41 @@ class AckSchemaGenerator extends Generator { for (var i = 0; i < linked.length; i++) { final baseModel = linked[i]; - if (!baseModel.isDiscriminatedBaseDefinition) { - continue; - } + if (!baseModel.isDiscriminatedBaseDefinition) continue; final discriminatorKey = baseModel.discriminatorKey; final subtypeNames = baseModel.subtypeNames; - if (discriminatorKey == null || subtypeNames == null) { - continue; - } + if (discriminatorKey == null || subtypeNames == null) continue; for (final entry in subtypeNames.entries) { - final discriminatorValue = entry.key; final branchSchemaClassName = entry.value; final branchIndex = modelIndexBySchemaClassName[branchSchemaClassName]; - if (branchIndex == null) { throw InvalidGenerationSource( 'Could not resolve discriminated branch "$branchSchemaClassName" for base "${baseModel.schemaClassName}".', todo: - 'Ensure every Ack.discriminated(...) branch references an @AckType schema declared in the same library.', + 'Ensure every branch references an @AckType schema in the same library.', ); } final branchModel = linked[branchIndex]; - final canonicalBranchIdentity = + final canonicalIdentity = branchModel.schemaIdentity ?? branchSchemaClassName; - final existingOwner = - branchOwnerByCanonicalIdentity[canonicalBranchIdentity]; + final existingOwner = branchOwnerByCanonicalIdentity[canonicalIdentity]; if (existingOwner != null && existingOwner != baseModel.schemaClassName) { throw InvalidGenerationSource( 'Branch schema "$branchSchemaClassName" is mapped to multiple discriminated bases: "$existingOwner" and "${baseModel.schemaClassName}".', - todo: - 'A branch schema can only belong to one Ack.discriminated(...) base.', + todo: 'A branch schema can belong to only one discriminated base.', ); } - branchOwnerByCanonicalIdentity[canonicalBranchIdentity] = + branchOwnerByCanonicalIdentity[canonicalIdentity] = baseModel.schemaClassName; linked[branchIndex] = _copyModelInfo( branchModel, discriminatorKey: discriminatorKey, - discriminatorValue: discriminatorValue, + discriminatorValue: entry.key, discriminatedBaseClassName: baseModel.className, ); } @@ -387,9 +252,7 @@ class AckSchemaGenerator extends Generator { final annotation = TypeChecker.typeNamed( AckType, ).firstAnnotationOfExact(element); - if (annotation == null) { - return null; - } + if (annotation == null) return null; final nameField = ConstantReader(annotation).peek('name'); return nameField != null && !nameField.isNull @@ -399,48 +262,32 @@ class AckSchemaGenerator extends Generator { Element2? _findAnnotatedSchemaElement( String schemaName, - List annotatedVariables, - List annotatedGetters, + List variables, + List getters, ) { - for (final variable in annotatedVariables) { - if (variable.name3 == schemaName) { - return variable; - } + for (final variable in variables) { + if (variable.name3 == schemaName) return variable; } - - for (final getter in annotatedGetters) { - if (getter.name3 == schemaName) { - return getter; - } + for (final getter in getters) { + if (getter.name3 == schemaName) return getter; } - return null; } String? _resolveAckImportPrefix(LibraryReader library) { for (final import in library.element.firstFragment.libraryImports2) { - if (!_isAckImport(import)) { - continue; - } - - final prefixElement = import.prefix2?.element; - final prefix = prefixElement?.name3; - if (prefix != null && prefix.isNotEmpty) { - return prefix; - } - return null; + if (!_isAckImport(import)) continue; + final prefix = import.prefix2?.element.name3; + return prefix == null || prefix.isEmpty ? null : prefix; } - return null; } bool _isAckImport(LibraryImport import) { final importedLibrary = import.importedLibrary2; - if (importedLibrary != null && - importedLibrary.uri.toString() == 'package:ack/ack.dart') { + if (importedLibrary?.uri.toString() == 'package:ack/ack.dart') { return true; } - return import.uri.toString().contains('package:ack/ack.dart'); } } diff --git a/packages/ack_generator/lib/src/models/schema_model_graph.dart b/packages/ack_generator/lib/src/models/schema_model_graph.dart new file mode 100644 index 00000000..cc4fd981 --- /dev/null +++ b/packages/ack_generator/lib/src/models/schema_model_graph.dart @@ -0,0 +1,285 @@ +/// Stable identity for a schema declaration across libraries. +final class AckSchemaId { + const AckSchemaId({ + required this.libraryUri, + required this.declarationName, + }); + + final Uri libraryUri; + final String declarationName; + + @override + bool operator ==(Object other) { + return other is AckSchemaId && + other.libraryUri == libraryUri && + other.declarationName == declarationName; + } + + @override + int get hashCode => Object.hash(libraryUri, declarationName); + + @override + String toString() => '$libraryUri::$declarationName'; +} + +/// Whether every value in a generated model graph can be encoded back to the +/// schema boundary. +enum AckEncodeCapability { + bidirectional, + parseOnly, +} + +/// Input-presence semantics for an object field. +/// +/// Presence and nullability are deliberately separate. A field can be required +/// and nullable, optional and non-nullable, or defaulted by the schema. +enum AckFieldPresence { + required, + optional, + defaulted, +} + +/// A normalized Dart/runtime type used by generation. +/// +/// Analyzer objects and output-specific cast strings must not escape the +/// analysis layer. Emitters consume this structural representation instead. +sealed class AckTypeRef { + const AckTypeRef(); + + Iterable get modelDependencies => const []; +} + +/// A core scalar such as `String`, `int`, `double`, `bool`, or `num`. +final class AckScalarTypeRef extends AckTypeRef { + const AckScalarTypeRef(this.dartType); + + final String dartType; +} + +/// A visible type declared outside the generated model graph. +final class AckExternalTypeRef extends AckTypeRef { + const AckExternalTypeRef({ + required this.dartType, + required this.libraryUri, + this.importPrefix, + }); + + final String dartType; + final Uri libraryUri; + final String? importPrefix; + + String get visibleName { + final prefix = importPrefix; + return prefix == null || prefix.isEmpty ? dartType : '$prefix.$dartType'; + } +} + +/// A reference to another generated Ack model. +final class AckModelTypeRef extends AckTypeRef { + const AckModelTypeRef({ + required this.schemaId, + required this.className, + this.importPrefix, + }); + + final AckSchemaId schemaId; + final String className; + final String? importPrefix; + + String get visibleName { + final prefix = importPrefix; + return prefix == null || prefix.isEmpty ? className : '$prefix.$className'; + } + + @override + Iterable get modelDependencies => [schemaId]; +} + +final class AckListTypeRef extends AckTypeRef { + const AckListTypeRef(this.elementType); + + final AckTypeRef elementType; + + @override + Iterable get modelDependencies => + elementType.modelDependencies; +} + +final class AckSetTypeRef extends AckTypeRef { + const AckSetTypeRef(this.elementType); + + final AckTypeRef elementType; + + @override + Iterable get modelDependencies => + elementType.modelDependencies; +} + +final class AckMapTypeRef extends AckTypeRef { + const AckMapTypeRef(this.valueType); + + final AckTypeRef valueType; + + @override + Iterable get modelDependencies => valueType.modelDependencies; +} + +/// A field in a normalized object model. +final class AckFieldNode { + const AckFieldNode({ + required this.dartName, + required this.jsonKey, + required this.presence, + required this.nullable, + required this.runtimeType, + this.description, + }); + + final String dartName; + final String jsonKey; + final AckFieldPresence presence; + final bool nullable; + final AckTypeRef runtimeType; + final String? description; + + bool get isRequired => presence == AckFieldPresence.required; +} + +/// Base node for a generated class or value object. +sealed class AckModelNode { + const AckModelNode({ + required this.id, + required this.className, + required this.boundaryType, + required this.runtimeType, + required this.encodeCapability, + this.description, + }); + + final AckSchemaId id; + final String className; + final AckTypeRef boundaryType; + final AckTypeRef runtimeType; + final AckEncodeCapability encodeCapability; + final String? description; + + Iterable get dependencies; +} + +/// A regular immutable class generated from `Ack.object(...)`. +final class AckObjectModelNode extends AckModelNode { + AckObjectModelNode({ + required super.id, + required super.className, + required super.boundaryType, + required super.runtimeType, + required super.encodeCapability, + required Iterable fields, + this.additionalProperties = false, + super.description, + }) : fields = List.unmodifiable(fields); + + final List fields; + final bool additionalProperties; + + @override + Iterable get dependencies sync* { + for (final field in fields) { + yield* field.runtimeType.modelDependencies; + } + } +} + +/// A value class generated from primitive, codec, list, or map root schemas. +final class AckValueModelNode extends AckModelNode { + const AckValueModelNode({ + required super.id, + required super.className, + required super.boundaryType, + required super.runtimeType, + required super.encodeCapability, + super.description, + }); + + @override + Iterable get dependencies => runtimeType.modelDependencies; +} + +/// A sealed class generated from `Ack.discriminated(...)`. +final class AckUnionModelNode extends AckModelNode { + AckUnionModelNode({ + required super.id, + required super.className, + required super.boundaryType, + required super.runtimeType, + required super.encodeCapability, + required this.discriminatorKey, + required Map branches, + super.description, + }) : branches = Map.unmodifiable(branches); + + final String discriminatorKey; + final Map branches; + + @override + Iterable get dependencies => branches.values; +} + +/// Resolution state used while building recursive model graphs. +enum AckResolutionState { + unseen, + visiting, + resolved, +} + +/// Mutable graph assembly with immutable model nodes. +/// +/// A declaration is registered before its fields are analyzed. References to a +/// `visiting` declaration become graph edges instead of recursively expanding a +/// duplicate model. This supports self-recursive and mutually-recursive models. +final class AckModelGraph { + final Map _nodes = {}; + final Map _states = {}; + final List _sourceOrder = []; + + Iterable get nodes sync* { + for (final id in _sourceOrder) { + final node = _nodes[id]; + if (node != null) yield node; + } + } + + AckResolutionState stateOf(AckSchemaId id) => + _states[id] ?? AckResolutionState.unseen; + + void begin(AckSchemaId id) { + final state = stateOf(id); + if (state == AckResolutionState.resolved) { + throw StateError('Schema $id is already resolved.'); + } + if (state == AckResolutionState.unseen) { + _sourceOrder.add(id); + } + _states[id] = AckResolutionState.visiting; + } + + void complete(AckModelNode node) { + final state = stateOf(node.id); + if (state != AckResolutionState.visiting) { + throw StateError( + 'Schema ${node.id} must be visiting before it can be completed.', + ); + } + _nodes[node.id] = node; + _states[node.id] = AckResolutionState.resolved; + } + + AckModelNode? nodeFor(AckSchemaId id) => _nodes[id]; + + /// Returns source-stable dependencies for diagnostics and tests. + List dependenciesOf(AckSchemaId id) { + final node = _nodes[id]; + if (node == null) return const []; + return List.unmodifiable(node.dependencies.toSet()); + } +} diff --git a/packages/ack_generator/pubspec.yaml b/packages/ack_generator/pubspec.yaml index 28055064..782605d2 100644 --- a/packages/ack_generator/pubspec.yaml +++ b/packages/ack_generator/pubspec.yaml @@ -1,8 +1,8 @@ name: ack_generator -description: Code generator for AckType extension-type generation +description: Code generator for immutable model classes from Ack schemas version: 1.1.0 -repository: https://github.com/btwld/ack -issue_tracker: https://github.com/btwld/ack/issues +repository: https://github.com/conceptadev/ack +issue_tracker: https://github.com/conceptadev/ack/issues resolution: workspace environment: @@ -12,18 +12,18 @@ dependencies: # Core code generation dependencies analyzer: ">=7.0.0 <9.0.0" build: ">=3.0.0 <5.0.0" + build_config: ^1.1.0 source_gen: ">=3.0.0 <5.0.0" code_builder: ^4.10.0 - + # Ack packages (versions are overridden locally by Melos) ack: ^1.0.0 ack_annotations: ^1.0.0 - + # Utilities collection: ^1.18.0 logging: ^1.3.0 meta: ^1.15.0 - dart_style: ^3.1.0 dev_dependencies: build_runner: ^2.1.7 @@ -32,4 +32,3 @@ dev_dependencies: path: ^1.9.0 # Code quality lints: ^5.0.0 - diff --git a/packages/ack_generator/test/src/generator_test.dart b/packages/ack_generator/test/src/generator_test.dart index 0ec38fd7..0a0569cf 100644 --- a/packages/ack_generator/test/src/generator_test.dart +++ b/packages/ack_generator/test/src/generator_test.dart @@ -13,16 +13,14 @@ void main() { generator = AckSchemaGenerator(); }); - test( - 'generates extension types for annotated schema variables and getters', - () async { - final builder = SharedPartBuilder([generator], 'ack'); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' + test('generates immutable classes for annotated schemas', () async { + final builder = SharedPartBuilder([generator], 'ack'); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @@ -34,19 +32,24 @@ final userSchema = Ack.object({ @AckType(name: 'Status') AckSchema get statusSchema => Ack.string(); ''', - }, - outputs: { - 'test_pkg|lib/schema.ack.g.part': decodedMatches( - allOf([ - contains('extension type UserType(Map _data)'), - contains('extension type StatusType(String _value)'), - contains('String get name'), - ]), - ), - }, - ); - }, - ); + }, + outputs: { + 'test_pkg|lib/schema.ack.g.part': decodedMatches( + allOf([ + contains('final class User'), + contains('final String name;'), + contains('factory User.parse(Object? input)'), + contains('factory User.fromJson(Map json)'), + contains('Map toJson()'), + contains('final class Status'), + contains('final String value;'), + isNot(contains('extension type')), + isNot(contains('implements Map')), + ]), + ), + }, + ); + }); test('does not emit output when no AckType declarations exist', () async { final builder = SharedPartBuilder([generator], 'ack'); @@ -62,7 +65,7 @@ class PlainData { }, outputs: const {}); }); - test('does not inject duplicate generated header in part output', () async { + test('leaves shared-part framing to source_gen', () async { final builder = SharedPartBuilder([generator], 'ack'); await testBuilder( @@ -73,8 +76,6 @@ class PlainData { import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -part 'model.ack.g.dart'; - @AckType() final modelSchema = Ack.object({ 'id': Ack.string(), @@ -85,39 +86,8 @@ final modelSchema = Ack.object({ 'test_pkg|lib/model.ack.g.part': decodedMatches( allOf([ contains('// AckSchemaGenerator'), - contains("part of 'model.dart';"), isNot(contains('// GENERATED CODE - DO NOT MODIFY BY HAND')), - ]), - ), - }, - ); - }); - - test('preserves formatting', () async { - final builder = SharedPartBuilder([generator], 'ack'); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/formatted.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final wellFormattedSchema = Ack.object({ - 'firstName': Ack.string(), - 'lastName': Ack.string(), - 'age': Ack.integer(), -}); -''', - }, - outputs: { - 'test_pkg|lib/formatted.ack.g.part': decodedMatches( - allOf([ - isNot(contains('\t')), - contains(' '), - isNot(contains(' \n')), + isNot(contains("part of 'model.dart';")), ]), ), }, @@ -186,38 +156,5 @@ class BadSchema { expect(sawPlacementError, isTrue); }); - - test('reports invalid AckType placement on static getters', () async { - final builder = SharedPartBuilder([generator], 'ack'); - var sawPlacementError = false; - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/bad.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -class BadSchema { - @AckType() - static AckSchema get valueSchema => Ack.string(); -} -''', - }, - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE') { - sawPlacementError = true; - expect( - log.message, - contains('top-level schema variables or getters'), - ); - } - }, - ); - - expect(sawPlacementError, isTrue); - }); }); } From 7fa7b10ac7f9f1434d6f5532f01dccf34b7bde57 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 21 Aug 2026 18:19:26 -0400 Subject: [PATCH 2/7] feat(generator): implement immutable AckType model generation Replaces old code generation approach with new immutable model classes: - New model_emitter.dart for generating immutable AckType classes - schema_model_graph_builder.dart for schema analysis - Removes old schema_ast_analyzer, class_builder, and related models - Updates tests to use new v2 generation approach - Generates .ack.dart files instead of .g.dart - Updates documentation and examples --- README.md | 19 +- docs/api-reference/index.mdx | 17 +- docs/architecture/acktype-model-generation.md | 348 +- docs/core-concepts/json-serialization.mdx | 8 +- docs/core-concepts/typesafe-schemas.mdx | 154 +- docs/getting-started/installation.mdx | 6 +- example/README.md | 8 +- example/lib/args_getter_example.ack.dart | 239 ++ example/lib/args_getter_example.dart | 8 +- example/lib/args_getter_example.g.dart | 106 - example/lib/pet.ack.dart | 114 + example/lib/pet.dart | 2 +- example/lib/pet.g.dart | 88 - .../lib/schema_types_discriminated.ack.dart | 146 + example/lib/schema_types_discriminated.dart | 2 +- example/lib/schema_types_discriminated.g.dart | 92 - example/lib/schema_types_edge_cases.ack.dart | 681 ++++ example/lib/schema_types_edge_cases.dart | 4 +- example/lib/schema_types_edge_cases.g.dart | 319 -- example/lib/schema_types_primitives.ack.dart | 453 +++ example/lib/schema_types_primitives.dart | 12 +- example/lib/schema_types_primitives.g.dart | 243 -- example/lib/schema_types_simple.ack.dart | 51 + example/lib/schema_types_simple.dart | 2 +- example/lib/schema_types_simple.g.dart | 32 - example/lib/schema_types_transforms.ack.dart | 146 + example/lib/schema_types_transforms.dart | 27 +- example/lib/schema_types_transforms.g.dart | 68 - example/lib/user_with_color.ack.dart | 173 + example/lib/user_with_color.dart | 11 +- example/lib/user_with_color.g.dart | 86 - example/pubspec.yaml | 2 +- example/test/args_getter_example_test.dart | 18 +- .../test/schema_types_discriminated_test.dart | 27 +- .../test/schema_types_edge_cases_test.dart | 24 +- .../test/schema_types_transforms_test.dart | 18 +- example/test/schema_variable_test.dart | 27 +- example/test/user_with_color_test.dart | 70 +- example/test/verify_implements_works.dart | 69 +- example/user_with_color_example.dart | 12 +- packages/ack/CHANGELOG.md | 7 + .../ack/lib/src/models/ack_model_adapter.dart | 16 +- .../test/models/ack_model_adapter_test.dart | 14 +- packages/ack_annotations/CHANGELOG.md | 8 + packages/ack_annotations/README.md | 11 +- .../ack_annotations/lib/src/ack_type.dart | 10 +- packages/ack_generator/CHANGELOG.md | 19 +- packages/ack_generator/README.md | 121 +- packages/ack_generator/analysis_options.yaml | 7 - packages/ack_generator/build.yaml | 7 +- .../lib/src/analyzer/schema_ast_analyzer.dart | 3573 ----------------- .../analyzer/schema_model_graph_builder.dart | 1163 ++++++ packages/ack_generator/lib/src/builder.dart | 7 +- .../lib/src/builders/class_builder.dart | 907 ----- .../lib/src/builders/model_emitter.dart | 651 +++ packages/ack_generator/lib/src/generator.dart | 286 +- .../lib/src/models/constraint_info.dart | 7 - .../lib/src/models/field_info.dart | 126 - .../lib/src/models/model_info.dart | 68 - .../lib/src/models/schema_model_graph.dart | 108 +- .../lib/src/validation/code_validator.dart | 89 - packages/ack_generator/pubspec.yaml | 15 +- .../test/additional_properties_args_test.dart | 197 - .../test/bugs/schema_variable_bugs_test.dart | 1359 ------- .../test/code_validation_test.dart | 46 - .../ack_type_cross_file_resolution_test.dart | 930 ----- .../ack_type_custom_name_test.dart | 219 - .../ack_type_discriminated_test.dart | 678 ---- .../ack_type_enum_literal_fields_test.dart | 438 -- .../integration/ack_type_getter_test.dart | 203 - .../integration/ack_type_golden_test.dart | 70 - .../ack_type_nested_schema_test.dart | 150 - .../integration/ack_type_transform_test.dart | 326 -- .../example_folder_build_test.dart | 353 +- .../json_serializable_build_test.dart | 132 + .../test/integration/v2_contract_test.dart | 150 + .../test/integration/v2_graph_test.dart | 197 + .../test/integration/v2_models_test.dart | 302 ++ .../integration/v2_runtime_build_test.dart | 267 ++ .../test/src/generator_test.dart | 193 +- .../test/src/test_utilities.dart | 229 -- .../test/test_utils/analysis_utils.dart | 59 - .../test_utils/generation_test_utils.dart | 27 - .../test/test_utils/test_assets.dart | 356 -- 84 files changed, 5619 insertions(+), 12414 deletions(-) create mode 100644 example/lib/args_getter_example.ack.dart delete mode 100644 example/lib/args_getter_example.g.dart create mode 100644 example/lib/pet.ack.dart delete mode 100644 example/lib/pet.g.dart create mode 100644 example/lib/schema_types_discriminated.ack.dart delete mode 100644 example/lib/schema_types_discriminated.g.dart create mode 100644 example/lib/schema_types_edge_cases.ack.dart delete mode 100644 example/lib/schema_types_edge_cases.g.dart create mode 100644 example/lib/schema_types_primitives.ack.dart delete mode 100644 example/lib/schema_types_primitives.g.dart create mode 100644 example/lib/schema_types_simple.ack.dart delete mode 100644 example/lib/schema_types_simple.g.dart create mode 100644 example/lib/schema_types_transforms.ack.dart delete mode 100644 example/lib/schema_types_transforms.g.dart create mode 100644 example/lib/user_with_color.ack.dart delete mode 100644 example/lib/user_with_color.g.dart delete mode 100644 packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart create mode 100644 packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart delete mode 100644 packages/ack_generator/lib/src/builders/class_builder.dart create mode 100644 packages/ack_generator/lib/src/builders/model_emitter.dart delete mode 100644 packages/ack_generator/lib/src/models/constraint_info.dart delete mode 100644 packages/ack_generator/lib/src/models/field_info.dart delete mode 100644 packages/ack_generator/lib/src/models/model_info.dart delete mode 100644 packages/ack_generator/lib/src/validation/code_validator.dart delete mode 100644 packages/ack_generator/test/additional_properties_args_test.dart delete mode 100644 packages/ack_generator/test/bugs/schema_variable_bugs_test.dart delete mode 100644 packages/ack_generator/test/code_validation_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_custom_name_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_discriminated_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_enum_literal_fields_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_getter_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_golden_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_nested_schema_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_transform_test.dart create mode 100644 packages/ack_generator/test/integration/json_serializable_build_test.dart create mode 100644 packages/ack_generator/test/integration/v2_contract_test.dart create mode 100644 packages/ack_generator/test/integration/v2_graph_test.dart create mode 100644 packages/ack_generator/test/integration/v2_models_test.dart create mode 100644 packages/ack_generator/test/integration/v2_runtime_build_test.dart delete mode 100644 packages/ack_generator/test/src/test_utilities.dart delete mode 100644 packages/ack_generator/test/test_utils/analysis_utils.dart delete mode 100644 packages/ack_generator/test/test_utils/generation_test_utils.dart delete mode 100644 packages/ack_generator/test/test_utils/test_assets.dart diff --git a/README.md b/README.md index 594990ed..ec4dfc16 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This repository is a monorepo containing: - **[ack](./packages/ack)**: Core validation library with a fluent schema-building API, codecs, and JSON Schema export - **[ack_annotations](./packages/ack_annotations)**: The `@AckType()` annotation that marks schemas for code generation -- **[ack_generator](./packages/ack_generator)**: Code generator that turns `@AckType()` schemas into type-safe extension types +- **[ack_generator](./packages/ack_generator)**: Code generator that turns `@AckType()` schemas into immutable model classes - **[ack_firebase_ai](./packages/ack_firebase_ai)**: Firebase AI (Gemini) schema converter for structured-output generation - **[ack_json_schema_builder](./packages/ack_json_schema_builder)**: Converter to `json_schema_builder` schemas - **[example](./example)**: Example projects demonstrating usage of all packages @@ -120,7 +120,7 @@ if (result.isOk) { ## Code generation -Generate type-safe wrappers for hand-written schemas with `@AckType()`. Add +Generate immutable models for hand-written schemas with `@AckType()`. Add `ack_annotations` to `dependencies` and `ack_generator` + `build_runner` to `dev_dependencies`, then annotate a top-level schema: @@ -128,7 +128,7 @@ Generate type-safe wrappers for hand-written schemas with `@AckType()`. Add import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -part 'user.g.dart'; +part 'user.ack.dart'; @AckType() final userSchema = Ack.object({ @@ -143,15 +143,18 @@ Run the generator: dart run build_runner build ``` -This emits a `UserType` extension type with `parse`/`safeParse` and typed -getters — no manual casting: +This emits a `User` class with stored typed fields, validation helpers, and a +JSON boundary: ```dart -final user = UserType.parse({'name': 'Alice', 'email': 'alice@example.com'}); -print(user.name); // typed String getter +final user = User.parse({'name': 'Alice', 'email': 'alice@example.com'}); +print(user.name); // String +print(user.toJson()); // {'name': 'Alice', 'email': 'alice@example.com'} ``` -`@AckType()` supports objects, primitives, lists, enums, explicit transforms, and discriminated unions. See the [TypeSafe Schemas guide](https://docs.page/btwld/ack/core-concepts/typesafe-schemas). +`@AckType()` supports objects, primitives, lists, enums, bidirectional codecs, +named recursion, and discriminated unions. One-way transforms are rejected +because a generated model must be encodable. See the [TypeSafe Schemas guide](https://docs.page/btwld/ack/core-concepts/typesafe-schemas). ## Codecs diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index 0d988510..58b8dce5 100644 --- a/docs/api-reference/index.mdx +++ b/docs/api-reference/index.mdx @@ -264,7 +264,7 @@ Schema reference for recursive object graphs. ## Code generation annotations -Use the [`ack_generator`](https://pub.dev/packages/ack_generator) builder to turn annotations into extension types. After adding the annotations below, run: +Use the [`ack_generator`](https://pub.dev/packages/ack_generator) builder to turn annotated top-level schemas into immutable model classes. After adding the annotation and a matching `.ack.dart` part directive, run: ```bash dart run build_runner build @@ -274,18 +274,18 @@ dart run build_runner build **Target**: Schema variables and getters -**Generates**: An extension type wrapper around the existing schema +**Generates**: An immutable model class backed by the existing schema -Annotate a schema variable or getter to generate an extension type wrapper. The schema stays in your source file. +Annotate a top-level schema variable or getter. The schema stays in your source file and remains responsible for validation and codecs. **Supported schema types:** -- `Ack.object({...})` → Object extension types +- `Ack.object({...})` → immutable object models - Primitives: `Ack.string()`, `Ack.integer()`, `Ack.double()`, `Ack.boolean()` -- Collections: `Ack.list(...)` +- Collections: `Ack.list(...)`, typed sets and maps - Enums: `Ack.literal()`, `Ack.enumString()`, `Ack.enumValues()` - Discriminated unions: `Ack.discriminated(...)` -**Unsupported:** `Ack.any()`, `Ack.anyOf()` +**Unsupported:** nullable roots, one-way transforms, `Ack.any()`, `Ack.anyOf()`, bare `Ack.instance()`, and anonymous inline objects For `Ack.discriminated(...)` constraints with `@AckType`, see [Type-safe Schemas](../core-concepts/typesafe-schemas.mdx#discriminated-schemas). @@ -299,13 +299,14 @@ final userSchema = Ack.object({ }); // Generated: -// - extension type UserType(Map _data) { ... } +// - final class User { ... } // - The schema variable remains unchanged // Usage: -final user = UserType.parse({'name': 'Alice', 'email': 'alice@example.com'}); +final user = User.parse({'name': 'Alice', 'email': 'alice@example.com'}); print(user.name); // Type-safe String access print(user.email); // Type-safe String access +print(user.toJson()); ``` ### `EnumSchema` diff --git a/docs/architecture/acktype-model-generation.md b/docs/architecture/acktype-model-generation.md index 64d6a0e0..8359b65c 100644 --- a/docs/architecture/acktype-model-generation.md +++ b/docs/architecture/acktype-model-generation.md @@ -1,300 +1,112 @@ -# AckType model-class generation +# Ack model-class generation -Status: draft implementation for review before validation. - -## Goal - -Replace the `@AckType()` map-backed extension types with real immutable Dart -classes. The schema remains the single source of truth for validation, defaults, -codecs, and boundary serialization. +`@AckType()` generates immutable Dart classes while the source Ack schema +remains responsible for validation, defaults, codecs, and serialization. ```text -JSON boundary - -> Ack parse -Ack runtime value - -> generated runtime mapper -immutable model - -> generated runtime mapper -Ack runtime value - -> Ack encode -JSON boundary -``` - -The generated class must not implement `Map` and must not keep a -backing map as its application data model. - -## Public API - -Given: - -```dart -@AckType() -final userSchema = Ack.object({ - 'id': Ack.integer(), - 'name': Ack.string(), - 'createdAt': Ack.datetime(), -}); -``` - -Generate: - -```dart -final class User { - User({ - required this.id, - required this.name, - required this.createdAt, - }); - - final int id; - final String name; - final DateTime createdAt; - - factory User.parse(Object? input); - static SchemaResult safeParse(Object? input); - factory User.fromMap(Map map); - factory User.fromJson(Map json); - Map toMap(); - Map toJson(); -} +boundary input -> Ack parse -> runtime value -> generated model +generated model -> runtime value -> Ack encode -> boundary output ``` -`@AckType(name: 'Member')` generates `Member`. The name is exact and no `Type` -suffix is appended. +## Build contract -## JSON serializable relationship - -Ack does not emit `@JsonSerializable` and does not call private -`json_serializable` generator APIs. - -Ack generates the conventional methods itself: - -```dart -factory User.fromJson(Map json); -Map toJson(); -``` +Each annotated library declares a dedicated part such as +`part 'user.ack.dart';`. A `PartBuilder` writes that source file and runs before +`json_serializable`. Libraries using both builders declare both `.ack.dart` and +`.g.dart`; the outputs aren't combined. -This lets source classes processed by `json_serializable` treat an Ack model as a -custom nested type. Actual validation and serialization still run through Ack. +The dedicated source output lets later builders resolve Ack-generated model +classes. Clean-build tests cover same-library and cross-library +`json_serializable` consumers. -A second hidden generation pass over an Ack-generated class is intentionally not -part of the architecture. Such a pass would require generated source to be -resolved and analyzed again, creating builder-ordering and incremental-build -complexity. +## Public model contract -## Build architecture +`userSchema` generates `User`. A custom `@AckType(name: 'MemberType')` value is +used exactly. -The generator uses `SharedPartBuilder` with the part ID `ack`. - -```text -source.dart - -> source.ack.g.part Ack fragment in cache - -> source.json_serializable.g.part (when present) - -> source.g.dart source_gen combining builder -``` - -The generator emits declarations only. `source_gen` owns the header, `part of` -directive, output combination, and formatting for the target library language -version. +Object models have an unchecked public constructor, stored typed fields, +`parse`, `safeParse`, `fromJson`, `toJson`, `safeToJson`, and a public static +`$ack` adapter. They don't implement `Map` and don't provide `fromMap` or +`toMap` aliases. Scalar and collection roots generate value models whose +`fromJson` and `toJson` signatures use the schema's boundary type. ## Runtime adapter -`AckModelAdapter` connects the source schema to a -model's generated runtime conversion functions. - -The schema is stored as a callback rather than an eager value. This avoids -static initialization cycles and preserves top-level schema getter behavior. - -For nested models, generated code calls: - -```dart -Address.$ack.fromRuntime(runtimeMap); -Address.$ack.toRuntime(address); -``` - -It must not call `Address.parse(runtimeMap)`. The parent schema has already -converted boundary values such as strings into runtime values such as -`DateTime`, `Uri`, or custom codec outputs. Parsing again would decode codecs -twice. - -## Normalized model graph - -The old `FieldInfo` and `ModelInfo` structures combine analyzer state with -extension-type output details. The replacement graph separates analysis from -emission. - -A schema identity includes its library URI and declaration name: - -```dart -AckSchemaId( - libraryUri: libraryUri, - declarationName: declarationName, -) -``` - -This prevents collisions between equal declaration names in different -libraries. +`AckModelAdapter` connects a source schema to the +generated runtime conversion functions. The adapter stores the schema as a +callback to avoid static initialization cycles and to support schema getters. -The normalized graph represents: +Nested conversion calls `Address.$ack.fromRuntime(...)` and +`Address.$ack.toRuntime(...)`. It never reparses a value that the parent schema +has already decoded, which matters for `DateTime`, `Uri`, enums, and custom +codecs. -- object models; -- value models; -- discriminated unions; -- scalar types; -- external Dart types; -- generated model references; -- lists, sets, and maps; -- input presence separately from nullability; -- bidirectional versus parse-only encoding capability. +Generated roots are non-nullable. Nullable top-level schemas are rejected, and +the adapter's generic bounds preserve that invariant. -The current draft adds this graph next to the existing analyzer. A follow-up -change will make the analyzer produce it directly and remove output-specific -string overrides. +## Normalized graph -## Recursive dependencies +Analysis produces one graph consumed directly by the emitter. Nodes carry: -Generation must not use topological sorting as a recursion strategy. +- model identity and source location; +- structural boundary and runtime type references; +- object, value, and discriminated-union shape; +- field presence separately from nullability; +- encode capability and named model references. -Resolution uses three states: +All annotated declarations are registered before resolution. Resolution uses +`unseen`, `visiting`, and `resolved` states: named `Ack.lazy` edges may point to +a visiting declaration, while ordinary alias cycles fail. Model identity +includes the library URI and declaration name, so equal names in different +libraries remain distinct. -```text -unseen -> visiting -> resolved -``` - -A declaration is registered before its fields are analyzed. A reference to a -`visiting` declaration becomes a graph edge. It does not recursively create a -second copy of the same model. - -Dart class declarations can reference each other independent of declaration -order. Output order should remain stable and follow source declaration order. - -`Ack.lazy` needs explicit analyzer support before recursive model generation is -considered complete. - -## Field semantics - -Presence and nullability are different: - -| Schema state | Model field | Input behavior | -| --- | --- | --- | -| required, non-nullable | `required T value` | key required, null rejected | -| required, nullable | `required T? value` | key required, null accepted | -| optional, non-nullable | `T? value` | key may be absent, present null rejected | -| optional, nullable | `T? value` | key may be absent or null | -| defaulted | usually `required T value` in constructor | parse supplies default | +The analyzer uses current `Element`, `PropertyAccessorElement`, and +`TopLevelVariableElement` APIs. AST inspection is limited to syntax that generic +`AckSchema` types can't express on their own: object fields, +collection elements, modifiers, codecs, lazy callbacks, and union branches. -A plain `T?` cannot preserve the distinction between an absent key and a key -whose value is explicitly null. The first model release uses canonical output -and does not add hidden presence bits. Exact three-state preservation can be a -separate API feature. +## Field and collection semantics -## Collections +Required, nullable, optional, and defaulted fields remain distinct. Optional +null fields are omitted during encoding; required nullable fields encode null. +Defaulted fields stay required in the unchecked constructor because arbitrary +schema defaults can't become Dart parameter defaults safely. -Generated fields use concrete typed collections and constructor inputs are -copied to unmodifiable collections. +Every represented list, set, and map is recursively copied into an unmodifiable +collection. Passthrough objects store unknown values in an unmodifiable +`additionalProperties` map. Encoding writes additional entries first and +declared fields second, so unknown data can't replace a declared property. -```dart -final List
addresses; -final Set tags; -final Map permissions; -``` - -Nested model elements convert through their `$ack` adapters. - -## Additional properties - -Schemas that allow additional properties generate: +## Unsupported shapes -```dart -final Map additionalProperties; -``` - -Encoding merges additional properties first and declared fields second, so an -extra property cannot replace a declared property. +Generation reports a located error for: -## One-way transforms +- one-way `.transform()` calls; +- nullable roots; +- `Ack.any()`, `Ack.anyOf()`, and bare `Ack.instance()`; +- anonymous inline objects and unresolved dynamic schema factories; +- invalid custom names and namespace/member collisions; +- cross-library discriminated branches. -`transform()` is parse-only. `codec()` is bidirectional. +These shapes don't provide the static, bidirectional contract required by an +immutable generated model. A one-way transform can usually migrate to a custom +`.codec()` with both decode and encode callbacks. -The normalized graph tracks encode capability. Full model generation should -produce a build error when any field is parse-only: +## Emission -```text -Cannot generate toJson for User because field "color" uses a one-way transform. -Replace transform() with codec(). -``` +The emitter reads only the normalized graph and uses `code_builder` for +declarations and type references. Runtime-to-model, model-to-runtime, and +immutable-copy operations share structural type traversal. Empty objects are +emitted structurally rather than through comma-sensitive templates. -The current draft emitter does not yet propagate this capability from the AST. -It is a required validation item before merge. +Discriminated unions become a sealed base plus final same-library branches. +The base dispatches by discriminator; each branch has a constant discriminator +and uses the union's effective branch schema for its adapter. -## Discriminated unions +## Validation -Generate a sealed hierarchy: - -```dart -sealed class Pet { - const Pet(); -} - -final class Cat extends Pet { - Cat({required this.lives}); - final int lives; - String get kind => 'cat'; -} -``` - -Preserve current discriminator checks: - -- branches are named and statically resolvable; -- branches belong to the same library; -- discriminator literals and enums are compatible; -- broad or conflicting discriminator schemas fail generation; -- a branch belongs to only one union base; -- branch parse operations validate through the union's effective branch. - -## Current draft scope - -This branch contains the main architecture for review: - -- shared-part builder configuration; -- `AckModelAdapter` runtime bridge; -- immutable object and value class emitter; -- sealed discriminated-class emitter; -- normalized graph types and recursive-resolution states; -- annotation contract and naming change. - -It is intentionally not represented as validated. Remaining work includes: - -- migrate all extension-type golden and integration tests; -- make the analyzer produce the normalized graph directly; -- add `Ack.lazy` analysis; -- track defaults and encode capability; -- complete map value typing; -- validate import and generated-name collisions; -- add clean-build `json_serializable` fixtures; -- run formatting, build, analysis, and runtime tests; -- remove legacy extension-only documentation and examples; -- review dependency ranges for the current analyzer/source_gen stack. - -## Validation checklist - -Before this draft can leave draft status: - -```text -[ ] dart pub get -[ ] dart format --output=none --set-exit-if-changed . -[ ] dart analyze --fatal-infos -[ ] dart test -[ ] dart run build_runner clean -[ ] dart run build_runner build --delete-conflicting-outputs -[ ] example package builds from no generated files -[ ] nested DateTime/Uri/Duration round trips -[ ] custom codec round trips -[ ] direct, prefixed, and re-exported model references -[ ] self-recursive and mutually-recursive models -[ ] discriminated branch parse and encode -[ ] additional-property collision behavior -[ ] optional, nullable, and defaulted field behavior -[ ] json_serializable builder coexistence fixture -[ ] json_serializable custom nested-type fixture -``` +The generator suite uses real workspace Ack package sources. Process fixtures +build temporary packages from no generated output, run strict analysis and +runtime tests, verify current `json_serializable` interoperability, then rebuild +and compare generated bytes for determinism. The checked example package keeps +its generated `.ack.dart` files as reviewable fixtures. diff --git a/docs/core-concepts/json-serialization.mdx b/docs/core-concepts/json-serialization.mdx index 9405e7bf..a5d89581 100644 --- a/docs/core-concepts/json-serialization.mdx +++ b/docs/core-concepts/json-serialization.mdx @@ -84,18 +84,18 @@ if (result.isOk) { } ``` -## Parsing JSON into typed wrappers +## Parsing JSON into generated models -Once a schema is annotated with `@AckType()` (see [TypeSafe Schemas](./typesafe-schemas.mdx) for setup), parse JSON straight into typed getters — no manual casting: +Once a schema is annotated with `@AckType()` (see [TypeSafe Schemas](./typesafe-schemas.mdx) for setup), parse JSON into a generated immutable model: ```dart import 'dart:convert'; final jsonData = jsonDecode('{"name": "Alice", "email": "alice@example.com"}'); -final result = UserType.safeParse(jsonData); +final result = User.safeParse(jsonData); if (result.isOk) { - final user = result.getOrThrow()!; + final user = result.getOrThrow(); print(user.name); // typed String print(user.email); // typed String? } diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index 915e469e..2a7417ca 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -2,14 +2,9 @@ title: TypeSafe Schemas --- -Tired of writing `data['name'] as String` after every parse? Annotate a top-level schema with `@AckType()` and run the generator once to get typed getters like `user.name`. The schema stays in your source file; the generator adds a typed wrapper around its validated representation. - -## Overview - -1. Define schemas with the Ack fluent API. -2. Annotate each top-level schema variable or getter with `@AckType()`. -3. Run `dart run build_runner build`. -4. Use the generated `TypeName.parse()` / `TypeName.safeParse()` helpers. +Annotate a top-level Ack schema with `@AckType()` to generate an immutable Dart +model. The source schema still owns validation, defaults, and codecs; the model +adds stored typed fields and an explicit JSON boundary. ## Basic usage @@ -17,7 +12,7 @@ Tired of writing `data['name'] as String` after every parse? Annotate a top-leve import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -part 'user_schema.g.dart'; +part 'user_schema.ack.dart'; @AckType() final addressSchema = Ack.object({ @@ -33,92 +28,115 @@ final userSchema = Ack.object({ }); ``` -The generated part file contains `AddressType` and `UserType` extension types, each with typed field getters and `parse()` / `safeParse()` static methods. +Run `dart run build_runner build`. The generated part contains `Address` and +`User` classes: + +```dart +final user = User.parse(payload); +print(user.address.city); + +final json = user.toJson(); +final result = User.safeParse(payload); // SchemaResult +``` -The type name drops a trailing `Schema` and adds `Type` (`userSchema` → `UserType`). Override it with `@AckType(name: 'AppUser')`, which generates `AppUserType`. +`userSchema` becomes `User`: a trailing `Schema` is removed and no suffix is +added. `@AckType(name: 'AppUser')` generates `AppUser` exactly. Custom names +must be unchanged UpperCamelCase identifiers. -## Supported schema shapes +The public constructor is intentionally unchecked. `parse` and `fromJson` +validate input. `toJson` validates a constructed model while encoding it, and +`safeToJson` returns the validation result instead of throwing. -`@AckType()` supports: +## Field behavior -- `Ack.object(...)` -- `Ack.string()`, `Ack.integer()`, `Ack.double()`, `Ack.boolean()` -- `Ack.list(...)` -- `Ack.literal(...)`, `Ack.enumString(...)`, `Ack.enumValues(...)` -- non-object transforms with explicit output types -- `Ack.discriminated(...)` with the constraints below +- Required fields are required constructor parameters. +- Required nullable fields remain required and use `T?`. +- Optional fields use nullable constructor parameters and are omitted from JSON + when null. +- Defaulted fields remain required in the unchecked constructor; parsing still + applies the schema default. +- Stored lists, sets, and maps are copied recursively into unmodifiable + collections. +- A passthrough object exposes unknown keys through + `additionalProperties`. Declared fields win if an extra key collides. -`Ack.any()` and `Ack.anyOf()` are not supported. +Generated models don't implement `Map`. Use `toJson()` when a map is needed. -## Discriminated schemas +## Supported schemas -`Ack.discriminated(...)` works with `@AckType()` when all of the following hold: +`@AckType()` supports: -- `schemas` is a non-empty map literal -- the base schema is non-nullable -- each branch is a top-level, non-nullable `@AckType` object schema in the same library -- branch schemas omit the discriminator field, or include it as `Ack.literal(...)` matching the branch key, or `Ack.enumString(...)` containing the branch key +- objects and empty objects; +- scalar roots, `num`, literals, enums, lists, sets, and typed maps; +- built-in codecs such as `Ack.datetime()`, plus custom bidirectional codecs; +- named nested models, aliases, defaults, and additional properties; +- direct, prefixed, and re-exported model references; +- named `Ack.lazy` self-recursion and mutual recursion; +- same-library discriminated unions. -Example: +One-way `.transform()` calls are rejected because a generated model must encode +back to its boundary value. Replace them with `.codec(decode: ..., encode: ...)`. + +The generator also rejects nullable roots, `Ack.any()`, `Ack.anyOf()`, bare +`Ack.instance()`, anonymous inline object fields, unresolved dynamic schema +factories, name/member collisions, and cross-library discriminated branches. + +## Nested models and recursion + +Object fields reference named top-level schemas: ```dart @AckType() -final catSchema = Ack.object({ - 'lives': Ack.integer(), +final AckSchema nodeSchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list(Ack.lazy('node', () => nodeSchema)), }); +``` + +The explicit schema type breaks Dart's top-level inference cycle. The generated +`Node` stores `List`. Nested conversion uses the referenced model's public +`$ack` adapter, which avoids decoding codec values twice. + +## Discriminated unions + +All branches must be named `@AckType` object schemas in the same library: +```dart @AckType() -final dogSchema = Ack.object({ - 'breed': Ack.string(), -}); +final catSchema = Ack.object({'lives': Ack.integer()}); + +@AckType() +final dogSchema = Ack.object({'breed': Ack.string()}); @AckType() final petSchema = Ack.discriminated( discriminatorKey: 'type', - schemas: { - 'cat': catSchema, - 'dog': dogSchema, - }, + schemas: {'cat': catSchema, 'dog': dogSchema}, ); ``` -`Ack.discriminated(...)` owns the discriminator property. Boundary payloads must include the discriminator key; branch schemas should usually omit it. When a branch includes the discriminator field, it must be an exact literal or enum containing the branch key: - -```dart -@AckType() -final catSchema = Ack.object({ - 'type': Ack.literal('cat'), // allowed, but usually unnecessary - 'lives': Ack.integer(), -}); -``` - -Conflicting discriminator fields, broad `Ack.string()`, and transformed or refined discriminator fields are rejected. Generated subtype `parse()` / `safeParse()` methods validate through the union's effective branch. +This generates a sealed `Pet` base and final `Cat` and `Dog` branches. Boundary +payloads include the discriminator. A branch may omit that field or declare a +compatible literal; the generated branch exposes a constant discriminator and +encodes it safely. -## Resolution rules +## Using json_serializable -- Nested object fields must reference a named top-level schema — inline anonymous objects are rejected. -- `Ack.list(...)` element schemas must be statically resolvable. -- Cross-file references work for direct imports, prefixed imports, and re-exports. -- Unannotated object schema references fail generation rather than silently falling back to raw maps. -- Circular alias/reference chains fail generation with a clear error. +Ack owns `.ack.dart`; `json_serializable` continues to own `.g.dart`. Declare +both when a library uses both generators: -## Limitations +```dart +part 'account.ack.dart'; +part 'account.g.dart'; +``` -- `@AckType()` only works on top-level schema variables and getters. -- Nullable top-level schemas do not emit extension types. -- `Ack.list(...)` rejects nullable item schemas. Make the list itself nullable - with `Ack.list(item).nullable()` when the whole field may be null. -- Use `.transform(...)` with an explicit output type so the generator can infer the representation type. +Ack runs first, so `json_serializable` can resolve generated models in the same +library. The generated `fromJson` and `toJson` methods also work when an Ack +model is imported from another library. ## Build checklist -1. Add `ack_annotations`, `ack_generator`, and `build_runner` to your pubspec. -2. Add `part '.g.dart';` to the file. -3. Annotate top-level schema variables or getters with `@AckType()`. +1. Add `ack` and `ack_annotations` to dependencies. +2. Add `ack_generator` and `build_runner` to dev dependencies. +3. Add `part '.ack.dart';` to each annotated library. 4. Run `dart run build_runner build`. - -## Next steps - -- [JSON Serialization](./json-serialization.mdx) — parse JSON straight into generated types -- [Common Recipes](../guides/common-recipes.mdx) — patterns that combine schemas and generated types -- [API Reference](../api-reference/index.mdx) — core API quick reference and generated API docs diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 1de94512..b57f3068 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -44,13 +44,15 @@ dev_dependencies: build_runner: ^2.4.0 ``` -`ack_generator` does not generate schemas from classes. It reads top-level Ack schema variables and getters annotated with `@AckType()` and emits typed extension wrappers with `parse()`/`safeParse()` helpers. +`ack_generator` does not generate schemas from classes. It reads top-level Ack +schema variables and getters annotated with `@AckType()` and emits immutable +models with typed fields, parsing helpers, and JSON methods. ```dart import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -part 'user.g.dart'; +part 'user.ack.dart'; @AckType() final userSchema = Ack.object({ diff --git a/example/README.md b/example/README.md index 957f553c..9bde3325 100644 --- a/example/README.md +++ b/example/README.md @@ -1,16 +1,16 @@ # Ack Example Package -This package demonstrates Ack schemas built directly in source and typed with -`@AckType()`. +This package demonstrates Ack schemas built directly in source and converted to +immutable models with `@AckType()`. ## Included examples - Primitive typed schemas in `lib/schema_types_primitives.dart` - Object schemas in `lib/schema_types_simple.dart` - Discriminated schemas in `lib/schema_types_discriminated.dart` -- Transform-backed schemas in `lib/schema_types_transforms.dart` +- Built-in and custom codec schemas in `lib/schema_types_transforms.dart` - Edge cases and strict resolution in `lib/schema_types_edge_cases.dart` -- Cross-schema object wrappers in `lib/pet.dart`, `lib/user_with_color.dart`, +- Cross-schema object models in `lib/pet.dart`, `lib/user_with_color.dart`, and `lib/args_getter_example.dart` - Codecs (built-in and custom) in `lib/codecs_example.dart` diff --git a/example/lib/args_getter_example.ack.dart b/example/lib/args_getter_example.ack.dart new file mode 100644 index 00000000..8b7f8b46 --- /dev/null +++ b/example/lib/args_getter_example.ack.dart @@ -0,0 +1,239 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +part of 'args_getter_example.dart'; + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +/// Immutable model generated from `userConfigSchema`. +final class UserConfig { + UserConfig({ + required this.username, + required this.email, + Map additionalProperties = const {}, + }) : additionalProperties = _ackImmutableCopyMap(additionalProperties); + + factory UserConfig.parse(Object? input) { + return $ack.parse(input); + } + + factory UserConfig.fromJson(Map json) { + return $ack.parse(json); + } + + final String username; + + final String email; + + /// Properties accepted by a schema with additional properties. + final Map additionalProperties; + + static final $ack = AckModelAdapter( + schema: () => userConfigSchema, + fromRuntime: UserConfig._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static UserConfig _fromAckRuntime(Map value) { + return UserConfig( + username: value['username'] as String, + email: value['email'] as String, + additionalProperties: _ackImmutableCopyMap( + Map.fromEntries( + value.entries.where( + (entry) => !const {'username', 'email'}.contains(entry.key), + ), + ), + ), + ); + } + + Map _toAckRuntime() { + return { + ...additionalProperties, + 'username': username, + 'email': email, + }; + } +} + +/// Immutable model generated from `apiRequestSchema`. +final class ApiRequest { + ApiRequest({ + required this.method, + required this.url, + Map additionalProperties = const {}, + }) : additionalProperties = _ackImmutableCopyMap(additionalProperties); + + factory ApiRequest.parse(Object? input) { + return $ack.parse(input); + } + + factory ApiRequest.fromJson(Map json) { + return $ack.parse(json); + } + + final String method; + + final String url; + + /// Properties accepted by a schema with additional properties. + final Map additionalProperties; + + static final $ack = AckModelAdapter( + schema: () => apiRequestSchema, + fromRuntime: ApiRequest._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static ApiRequest _fromAckRuntime(Map value) { + return ApiRequest( + method: value['method'] as String, + url: value['url'] as String, + additionalProperties: _ackImmutableCopyMap( + Map.fromEntries( + value.entries.where( + (entry) => !const {'method', 'url'}.contains(entry.key), + ), + ), + ), + ); + } + + Map _toAckRuntime() { + return { + ...additionalProperties, + 'method': method, + 'url': url, + }; + } +} + +/// Immutable model generated from `featureFlagsSchema`. +final class FeatureFlags { + FeatureFlags({ + required this.appVersion, + required this.environment, + Map additionalProperties = const {}, + }) : additionalProperties = _ackImmutableCopyMap(additionalProperties); + + factory FeatureFlags.parse(Object? input) { + return $ack.parse(input); + } + + factory FeatureFlags.fromJson(Map json) { + return $ack.parse(json); + } + + final String appVersion; + + final String environment; + + /// Properties accepted by a schema with additional properties. + final Map additionalProperties; + + static final $ack = AckModelAdapter( + schema: () => featureFlagsSchema, + fromRuntime: FeatureFlags._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static FeatureFlags _fromAckRuntime(Map value) { + return FeatureFlags( + appVersion: value['appVersion'] as String, + environment: value['environment'] as String, + additionalProperties: _ackImmutableCopyMap( + Map.fromEntries( + value.entries.where( + (entry) => !const { + 'appVersion', + 'environment', + }.contains(entry.key), + ), + ), + ), + ); + } + + Map _toAckRuntime() { + return { + ...additionalProperties, + 'appVersion': appVersion, + 'environment': environment, + }; + } +} + +/// Immutable model generated from `dynamicDataSchema`. +final class DynamicData { + DynamicData({Map additionalProperties = const {}}) + : additionalProperties = _ackImmutableCopyMap(additionalProperties); + + factory DynamicData.parse(Object? input) { + return $ack.parse(input); + } + + factory DynamicData.fromJson(Map json) { + return $ack.parse(json); + } + + /// Properties accepted by a schema with additional properties. + final Map additionalProperties; + + static final $ack = AckModelAdapter( + schema: () => dynamicDataSchema, + fromRuntime: DynamicData._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static DynamicData _fromAckRuntime(Map value) { + return DynamicData(additionalProperties: _ackImmutableCopyMap(value)); + } + + Map _toAckRuntime() { + return {...additionalProperties}; + } +} + +Object? _ackImmutableCopyValue(Object? value) => switch (value) { + List() => List.unmodifiable(value.map(_ackImmutableCopyValue)), + Set() => Set.unmodifiable(value.map(_ackImmutableCopyValue)), + Map() => Map.unmodifiable( + value.map((key, item) => MapEntry(key, _ackImmutableCopyValue(item))), + ), + _ => value, +}; +Map _ackImmutableCopyMap(Map value) => + Map.unmodifiable( + value.map((key, item) => MapEntry(key, _ackImmutableCopyValue(item))), + ); diff --git a/example/lib/args_getter_example.dart b/example/lib/args_getter_example.dart index 41bb021d..ce9c7d8e 100644 --- a/example/lib/args_getter_example.dart +++ b/example/lib/args_getter_example.dart @@ -1,14 +1,14 @@ -/// This file demonstrates the automatic `args` getter feature +/// This file demonstrates immutable additional-property storage /// for schemas with additionalProperties enabled via passthrough() library; import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -part 'args_getter_example.g.dart'; +part 'args_getter_example.ack.dart'; /// Example 1: User configuration with additional metadata -/// The generated extension type will have an `args` getter that returns +/// The generated model has `additionalProperties`, which contains /// only the additional properties (not 'username' or 'email') @AckType() final userConfigSchema = Ack.object({ @@ -17,7 +17,7 @@ final userConfigSchema = Ack.object({ }).passthrough(); /// Example 2: API request with explicit additionalProperties -/// Same behavior as passthrough() - generates args getter +/// Same behavior as passthrough(). @AckType() final apiRequestSchema = Ack.object({ 'method': Ack.string(), diff --git a/example/lib/args_getter_example.g.dart b/example/lib/args_getter_example.g.dart deleted file mode 100644 index ee3cedc3..00000000 --- a/example/lib/args_getter_example.g.dart +++ /dev/null @@ -1,106 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ************************************************************************** -// AckSchemaGenerator -// ************************************************************************** - -part of 'args_getter_example.dart'; - -/// Extension type for UserConfig -extension type UserConfigType(Map _data) - implements Map { - static UserConfigType parse(Object? data) { - return userConfigSchema.parseAs( - data, - (validated) => UserConfigType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return userConfigSchema.safeParseAs( - data, - (validated) => UserConfigType(validated as Map), - ); - } - - String get username => _data['username'] as String; - - String get email => _data['email'] as String; - - Map get args => Map.fromEntries( - _data.entries.where((e) => e.key != 'username' && e.key != 'email'), - ); -} - -/// Extension type for ApiRequest -extension type ApiRequestType(Map _data) - implements Map { - static ApiRequestType parse(Object? data) { - return apiRequestSchema.parseAs( - data, - (validated) => ApiRequestType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return apiRequestSchema.safeParseAs( - data, - (validated) => ApiRequestType(validated as Map), - ); - } - - String get method => _data['method'] as String; - - String get url => _data['url'] as String; - - Map get args => Map.fromEntries( - _data.entries.where((e) => e.key != 'method' && e.key != 'url'), - ); -} - -/// Extension type for FeatureFlags -extension type FeatureFlagsType(Map _data) - implements Map { - static FeatureFlagsType parse(Object? data) { - return featureFlagsSchema.parseAs( - data, - (validated) => FeatureFlagsType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return featureFlagsSchema.safeParseAs( - data, - (validated) => FeatureFlagsType(validated as Map), - ); - } - - String get appVersion => _data['appVersion'] as String; - - String get environment => _data['environment'] as String; - - Map get args => Map.fromEntries( - _data.entries.where((e) => e.key != 'appVersion' && e.key != 'environment'), - ); -} - -/// Extension type for DynamicData -extension type DynamicDataType(Map _data) - implements Map { - static DynamicDataType parse(Object? data) { - return dynamicDataSchema.parseAs( - data, - (validated) => DynamicDataType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return dynamicDataSchema.safeParseAs( - data, - (validated) => DynamicDataType(validated as Map), - ); - } - - Map get args => _data; -} diff --git a/example/lib/pet.ack.dart b/example/lib/pet.ack.dart new file mode 100644 index 00000000..13c235c1 --- /dev/null +++ b/example/lib/pet.ack.dart @@ -0,0 +1,114 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +part of 'pet.dart'; + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +/// Discriminated model base generated from `petSchema`. +sealed class Pet { + const Pet(); + + factory Pet.parse(Object? input) { + return $ack.parse(input); + } + + factory Pet.fromJson(Map json) { + return $ack.parse(json); + } + + static final $ack = AckModelAdapter( + schema: () => petSchema, + fromRuntime: Pet._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + String get type; + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static Pet _fromAckRuntime(Map value) { + return switch (value['type']) { + 'cat' => Cat._fromAckRuntime(value), + 'dog' => Dog._fromAckRuntime(value), + final unknown => throw StateError('Unknown type: $unknown'), + }; + } + + Map _toAckRuntime(); +} + +/// Discriminated model branch generated from `catSchema`. +final class Cat extends Pet { + Cat({required this.lives}); + + factory Cat.parse(Object? input) { + return $ack.parse(input); + } + + factory Cat.fromJson(Map json) { + return $ack.parse(json); + } + + final int lives; + + static final $ack = AckModelAdapter( + schema: () => petSchema.effectiveBranch('cat'), + fromRuntime: Cat._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + @override + String get type => 'cat'; + + static Cat _fromAckRuntime(Map value) { + return Cat(lives: value['lives'] as int); + } + + @override + Map _toAckRuntime() { + return {'type': 'cat', 'lives': lives}; + } +} + +/// Discriminated model branch generated from `dogSchema`. +final class Dog extends Pet { + Dog({required this.breed}); + + factory Dog.parse(Object? input) { + return $ack.parse(input); + } + + factory Dog.fromJson(Map json) { + return $ack.parse(json); + } + + final String breed; + + static final $ack = AckModelAdapter( + schema: () => petSchema.effectiveBranch('dog'), + fromRuntime: Dog._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + @override + String get type => 'dog'; + + static Dog _fromAckRuntime(Map value) { + return Dog(breed: value['breed'] as String); + } + + @override + Map _toAckRuntime() { + return {'type': 'dog', 'breed': breed}; + } +} diff --git a/example/lib/pet.dart b/example/lib/pet.dart index fb78aa07..496212b7 100644 --- a/example/lib/pet.dart +++ b/example/lib/pet.dart @@ -1,7 +1,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -part 'pet.g.dart'; +part 'pet.ack.dart'; /// Pet schemas: discriminated by 'type' @AckType() diff --git a/example/lib/pet.g.dart b/example/lib/pet.g.dart deleted file mode 100644 index f33a27af..00000000 --- a/example/lib/pet.g.dart +++ /dev/null @@ -1,88 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ************************************************************************** -// AckSchemaGenerator -// ************************************************************************** - -part of 'pet.dart'; - -/// Extension type for Pet -extension type PetType(Map _data) - implements Map { - String get type => _data['type'] as String; - - static PetType parse(Object? data) { - return petSchema.parseAs(data, (validated) { - final map = validated as Map; - return switch (map['type']) { - 'cat' => CatType(map), - 'dog' => DogType(map), - _ => throw StateError('Unknown type: ${map['type']}'), - }; - }); - } - - static SchemaResult safeParse(Object? data) { - return petSchema.safeParseAs(data, (validated) { - final map = validated as Map; - return switch (map['type']) { - 'cat' => CatType(map), - 'dog' => DogType(map), - _ => throw StateError('Unknown type: ${map['type']}'), - }; - }); - } -} - -/// Extension type for Cat -extension type CatType(Map _data) - implements PetType, Map { - String get type => _data['type'] as String; - - static CatType parse(Object? data) { - return petSchema - .effectiveBranch('cat') - .parseAs( - data, - (validated) => CatType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return petSchema - .effectiveBranch('cat') - .safeParseAs( - data, - (validated) => CatType(validated as Map), - ); - } - - int get lives => _data['lives'] as int; -} - -/// Extension type for Dog -extension type DogType(Map _data) - implements PetType, Map { - String get type => _data['type'] as String; - - static DogType parse(Object? data) { - return petSchema - .effectiveBranch('dog') - .parseAs( - data, - (validated) => DogType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return petSchema - .effectiveBranch('dog') - .safeParseAs( - data, - (validated) => DogType(validated as Map), - ); - } - - String get breed => _data['breed'] as String; -} diff --git a/example/lib/schema_types_discriminated.ack.dart b/example/lib/schema_types_discriminated.ack.dart new file mode 100644 index 00000000..1caf1cb8 --- /dev/null +++ b/example/lib/schema_types_discriminated.ack.dart @@ -0,0 +1,146 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +part of 'schema_types_discriminated.dart'; + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +/// Discriminated model base generated from `petSchema`. +sealed class Pet { + const Pet(); + + factory Pet.parse(Object? input) { + return $ack.parse(input); + } + + factory Pet.fromJson(Map json) { + return $ack.parse(json); + } + + static final $ack = AckModelAdapter( + schema: () => petSchema, + fromRuntime: Pet._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + String get kind; + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static Pet _fromAckRuntime(Map value) { + return switch (value['kind']) { + 'cat' => Cat._fromAckRuntime(value), + 'dog' => Dog._fromAckRuntime(value), + final unknown => throw StateError('Unknown kind: $unknown'), + }; + } + + Map _toAckRuntime(); +} + +/// Discriminated model branch generated from `catSchema`. +final class Cat extends Pet { + Cat({required this.lives}); + + factory Cat.parse(Object? input) { + return $ack.parse(input); + } + + factory Cat.fromJson(Map json) { + return $ack.parse(json); + } + + final int lives; + + static final $ack = AckModelAdapter( + schema: () => petSchema.effectiveBranch('cat'), + fromRuntime: Cat._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + @override + String get kind => 'cat'; + + static Cat _fromAckRuntime(Map value) { + return Cat(lives: value['lives'] as int); + } + + @override + Map _toAckRuntime() { + return {'kind': 'cat', 'lives': lives}; + } +} + +/// Discriminated model branch generated from `dogSchema`. +final class Dog extends Pet { + Dog({ + required this.bark, + Map additionalProperties = const {}, + }) : additionalProperties = _ackImmutableCopyMap(additionalProperties); + + factory Dog.parse(Object? input) { + return $ack.parse(input); + } + + factory Dog.fromJson(Map json) { + return $ack.parse(json); + } + + final bool bark; + + /// Properties accepted by a schema with additional properties. + final Map additionalProperties; + + static final $ack = AckModelAdapter( + schema: () => petSchema.effectiveBranch('dog'), + fromRuntime: Dog._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + @override + String get kind => 'dog'; + + static Dog _fromAckRuntime(Map value) { + return Dog( + bark: value['bark'] as bool, + additionalProperties: _ackImmutableCopyMap( + Map.fromEntries( + value.entries.where( + (entry) => !const {'kind', 'bark'}.contains(entry.key), + ), + ), + ), + ); + } + + @override + Map _toAckRuntime() { + return { + ...additionalProperties, + 'kind': 'dog', + 'bark': bark, + }; + } +} + +Object? _ackImmutableCopyValue(Object? value) => switch (value) { + List() => List.unmodifiable(value.map(_ackImmutableCopyValue)), + Set() => Set.unmodifiable(value.map(_ackImmutableCopyValue)), + Map() => Map.unmodifiable( + value.map((key, item) => MapEntry(key, _ackImmutableCopyValue(item))), + ), + _ => value, +}; +Map _ackImmutableCopyMap(Map value) => + Map.unmodifiable( + value.map((key, item) => MapEntry(key, _ackImmutableCopyValue(item))), + ); diff --git a/example/lib/schema_types_discriminated.dart b/example/lib/schema_types_discriminated.dart index 7a23c78a..b5995db7 100644 --- a/example/lib/schema_types_discriminated.dart +++ b/example/lib/schema_types_discriminated.dart @@ -1,7 +1,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -part 'schema_types_discriminated.g.dart'; +part 'schema_types_discriminated.ack.dart'; /// Discriminated schema example for @AckType extension generation. @AckType() diff --git a/example/lib/schema_types_discriminated.g.dart b/example/lib/schema_types_discriminated.g.dart deleted file mode 100644 index 289e7341..00000000 --- a/example/lib/schema_types_discriminated.g.dart +++ /dev/null @@ -1,92 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ************************************************************************** -// AckSchemaGenerator -// ************************************************************************** - -part of 'schema_types_discriminated.dart'; - -/// Extension type for Pet -extension type PetType(Map _data) - implements Map { - String get kind => _data['kind'] as String; - - static PetType parse(Object? data) { - return petSchema.parseAs(data, (validated) { - final map = validated as Map; - return switch (map['kind']) { - 'cat' => CatType(map), - 'dog' => DogType(map), - _ => throw StateError('Unknown kind: ${map['kind']}'), - }; - }); - } - - static SchemaResult safeParse(Object? data) { - return petSchema.safeParseAs(data, (validated) { - final map = validated as Map; - return switch (map['kind']) { - 'cat' => CatType(map), - 'dog' => DogType(map), - _ => throw StateError('Unknown kind: ${map['kind']}'), - }; - }); - } -} - -/// Extension type for Cat -extension type CatType(Map _data) - implements PetType, Map { - String get kind => _data['kind'] as String; - - static CatType parse(Object? data) { - return petSchema - .effectiveBranch('cat') - .parseAs( - data, - (validated) => CatType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return petSchema - .effectiveBranch('cat') - .safeParseAs( - data, - (validated) => CatType(validated as Map), - ); - } - - int get lives => _data['lives'] as int; -} - -/// Extension type for Dog -extension type DogType(Map _data) - implements PetType, Map { - String get kind => _data['kind'] as String; - - static DogType parse(Object? data) { - return petSchema - .effectiveBranch('dog') - .parseAs( - data, - (validated) => DogType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return petSchema - .effectiveBranch('dog') - .safeParseAs( - data, - (validated) => DogType(validated as Map), - ); - } - - bool get bark => _data['bark'] as bool; - - Map get args => Map.fromEntries( - _data.entries.where((e) => e.key != 'kind' && e.key != 'bark'), - ); -} diff --git a/example/lib/schema_types_edge_cases.ack.dart b/example/lib/schema_types_edge_cases.ack.dart new file mode 100644 index 00000000..5771f9a7 --- /dev/null +++ b/example/lib/schema_types_edge_cases.ack.dart @@ -0,0 +1,681 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +part of 'schema_types_edge_cases.dart'; + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +/// Immutable model generated from `productSchema`. +final class Product { + Product({ + required this.name, + required List tags, + required List scores, + required List flags, + }) : tags = List.unmodifiable(tags.map((item) => item)), + scores = List.unmodifiable(scores.map((item) => item)), + flags = List.unmodifiable(flags.map((item) => item)); + + factory Product.parse(Object? input) { + return $ack.parse(input); + } + + factory Product.fromJson(Map json) { + return $ack.parse(json); + } + + final String name; + + final List tags; + + final List scores; + + final List flags; + + static final $ack = AckModelAdapter( + schema: () => productSchema, + fromRuntime: Product._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static Product _fromAckRuntime(Map value) { + return Product( + name: value['name'] as String, + tags: List.unmodifiable( + (value['tags'] as List).map((item) => item as String), + ), + scores: List.unmodifiable( + (value['scores'] as List).map((item) => item as int), + ), + flags: List.unmodifiable( + (value['flags'] as List).map((item) => item as bool), + ), + ); + } + + Map _toAckRuntime() { + return { + 'name': name, + 'tags': tags.map((item) => item).toList(growable: false), + 'scores': scores.map((item) => item).toList(growable: false), + 'flags': flags.map((item) => item).toList(growable: false), + }; + } +} + +/// Immutable model generated from `gridSchema`. +final class Grid { + Grid({required this.name, required List> matrix}) + : matrix = List>.unmodifiable( + matrix.map((item) => List.unmodifiable(item.map((item) => item))), + ); + + factory Grid.parse(Object? input) { + return $ack.parse(input); + } + + factory Grid.fromJson(Map json) { + return $ack.parse(json); + } + + final String name; + + final List> matrix; + + static final $ack = AckModelAdapter( + schema: () => gridSchema, + fromRuntime: Grid._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static Grid _fromAckRuntime(Map value) { + return Grid( + name: value['name'] as String, + matrix: List>.unmodifiable( + (value['matrix'] as List).map( + (item) => + List.unmodifiable((item as List).map((item) => item as int)), + ), + ), + ); + } + + Map _toAckRuntime() { + return { + 'name': name, + 'matrix': matrix + .map((item) => item.map((item) => item).toList(growable: false)) + .toList(growable: false), + }; + } +} + +/// Immutable model generated from `addressSchema`. +final class Address { + Address({ + required this.street, + required this.city, + required this.zipCode, + required this.country, + }); + + factory Address.parse(Object? input) { + return $ack.parse(input); + } + + factory Address.fromJson(Map json) { + return $ack.parse(json); + } + + final String street; + + final String city; + + final String zipCode; + + final String country; + + static final $ack = AckModelAdapter( + schema: () => addressSchema, + fromRuntime: Address._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult
safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static Address _fromAckRuntime(Map value) { + return Address( + street: value['street'] as String, + city: value['city'] as String, + zipCode: value['zipCode'] as String, + country: value['country'] as String, + ); + } + + Map _toAckRuntime() { + return { + 'street': street, + 'city': city, + 'zipCode': zipCode, + 'country': country, + }; + } +} + +/// Immutable model generated from `personSchema`. +final class Person { + Person({ + required this.name, + required this.email, + required this.address, + required this.age, + }); + + factory Person.parse(Object? input) { + return $ack.parse(input); + } + + factory Person.fromJson(Map json) { + return $ack.parse(json); + } + + final String name; + + final String email; + + final Address address; + + final int age; + + static final $ack = AckModelAdapter( + schema: () => personSchema, + fromRuntime: Person._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static Person _fromAckRuntime(Map value) { + return Person( + name: value['name'] as String, + email: value['email'] as String, + address: Address.$ack.fromRuntime( + value['address'] as Map, + ), + age: value['age'] as int, + ); + } + + Map _toAckRuntime() { + return { + 'name': name, + 'email': email, + 'address': Address.$ack.toRuntime(address), + 'age': age, + }; + } +} + +/// Immutable model generated from `employeeSchema`. +final class Employee { + Employee({ + required this.name, + required this.employeeId, + required this.homeAddress, + required this.workAddress, + }); + + factory Employee.parse(Object? input) { + return $ack.parse(input); + } + + factory Employee.fromJson(Map json) { + return $ack.parse(json); + } + + final String name; + + final String employeeId; + + final Address homeAddress; + + final Address workAddress; + + static final $ack = AckModelAdapter( + schema: () => employeeSchema, + fromRuntime: Employee._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static Employee _fromAckRuntime(Map value) { + return Employee( + name: value['name'] as String, + employeeId: value['employeeId'] as String, + homeAddress: Address.$ack.fromRuntime( + value['homeAddress'] as Map, + ), + workAddress: Address.$ack.fromRuntime( + value['workAddress'] as Map, + ), + ); + } + + Map _toAckRuntime() { + return { + 'name': name, + 'employeeId': employeeId, + 'homeAddress': Address.$ack.toRuntime(homeAddress), + 'workAddress': Address.$ack.toRuntime(workAddress), + }; + } +} + +/// Immutable model generated from `modifierSchema`. +final class Modifier { + Modifier({ + required this.requiredField, + this.optionalField, + required this.nullableField, + this.optionalNullable, + this.nullableOptional, + }); + + factory Modifier.parse(Object? input) { + return $ack.parse(input); + } + + factory Modifier.fromJson(Map json) { + return $ack.parse(json); + } + + final String requiredField; + + final String? optionalField; + + final String? nullableField; + + final String? optionalNullable; + + final String? nullableOptional; + + static final $ack = AckModelAdapter( + schema: () => modifierSchema, + fromRuntime: Modifier._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static Modifier _fromAckRuntime(Map value) { + return Modifier( + requiredField: value['requiredField'] as String, + optionalField: value['optionalField'] as String?, + nullableField: value['nullableField'] as String?, + optionalNullable: value['optionalNullable'] as String?, + nullableOptional: value['nullableOptional'] as String?, + ); + } + + Map _toAckRuntime() { + return { + 'requiredField': requiredField, + if (optionalField != null) 'optionalField': optionalField!, + 'nullableField': nullableField, + if (optionalNullable != null) 'optionalNullable': optionalNullable!, + if (nullableOptional != null) 'nullableOptional': nullableOptional!, + }; + } +} + +/// Immutable model generated from `taggedItemSchema`. +final class TaggedItem { + TaggedItem({ + required this.name, + required List requiredTags, + List? optionalTags, + required List? nullableTags, + }) : requiredTags = List.unmodifiable( + requiredTags.map((item) => item), + ), + optionalTags = switch (optionalTags) { + null => null, + final fieldValue => List.unmodifiable( + fieldValue.map((item) => item), + ), + }, + nullableTags = switch (nullableTags) { + null => null, + final fieldValue => List.unmodifiable( + fieldValue.map((item) => item), + ), + }; + + factory TaggedItem.parse(Object? input) { + return $ack.parse(input); + } + + factory TaggedItem.fromJson(Map json) { + return $ack.parse(json); + } + + final String name; + + final List requiredTags; + + final List? optionalTags; + + final List? nullableTags; + + static final $ack = AckModelAdapter( + schema: () => taggedItemSchema, + fromRuntime: TaggedItem._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static TaggedItem _fromAckRuntime(Map value) { + return TaggedItem( + name: value['name'] as String, + requiredTags: List.unmodifiable( + (value['requiredTags'] as List).map((item) => item as String), + ), + optionalTags: switch (value['optionalTags']) { + null => null, + final fieldValue => List.unmodifiable( + (fieldValue as List).map((item) => item as String), + ), + }, + nullableTags: switch (value['nullableTags']) { + null => null, + final fieldValue => List.unmodifiable( + (fieldValue as List).map((item) => item as String), + ), + }, + ); + } + + Map _toAckRuntime() { + return { + 'name': name, + 'requiredTags': requiredTags.map((item) => item).toList(growable: false), + if (optionalTags != null) + 'optionalTags': optionalTags! + .map((item) => item) + .toList(growable: false), + 'nullableTags': switch (nullableTags) { + null => null, + final fieldValue => + fieldValue.map((item) => item).toList(growable: false), + }, + }; + } +} + +/// Immutable model generated from `contactListSchema`. +final class ContactList { + ContactList({required this.name, required List
addresses}) + : addresses = List
.unmodifiable(addresses.map((item) => item)); + + factory ContactList.parse(Object? input) { + return $ack.parse(input); + } + + factory ContactList.fromJson(Map json) { + return $ack.parse(json); + } + + final String name; + + final List
addresses; + + static final $ack = AckModelAdapter( + schema: () => contactListSchema, + fromRuntime: ContactList._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static ContactList _fromAckRuntime(Map value) { + return ContactList( + name: value['name'] as String, + addresses: List
.unmodifiable( + (value['addresses'] as List).map( + (item) => Address.$ack.fromRuntime(item as Map), + ), + ), + ); + } + + Map _toAckRuntime() { + return { + 'name': name, + 'addresses': addresses + .map((item) => Address.$ack.toRuntime(item)) + .toList(growable: false), + }; + } +} + +/// Immutable model generated from `emptySchema`. +final class Empty { + Empty(); + + factory Empty.parse(Object? input) { + return $ack.parse(input); + } + + factory Empty.fromJson(Map json) { + return $ack.parse(json); + } + + static final $ack = AckModelAdapter( + schema: () => emptySchema, + fromRuntime: Empty._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static Empty _fromAckRuntime(Map value) { + return Empty(); + } + + Map _toAckRuntime() { + return {}; + } +} + +/// Immutable model generated from `minimalSchema`. +final class Minimal { + Minimal({required this.id}); + + factory Minimal.parse(Object? input) { + return $ack.parse(input); + } + + factory Minimal.fromJson(Map json) { + return $ack.parse(json); + } + + final String id; + + static final $ack = AckModelAdapter( + schema: () => minimalSchema, + fromRuntime: Minimal._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static Minimal _fromAckRuntime(Map value) { + return Minimal(id: value['id'] as String); + } + + Map _toAckRuntime() { + return {'id': id}; + } +} + +/// Immutable model generated from `namedItemSchema`. +final class NamedItem { + NamedItem({required this.name}); + + factory NamedItem.parse(Object? input) { + return $ack.parse(input); + } + + factory NamedItem.fromJson(Map json) { + return $ack.parse(json); + } + + final String name; + + static final $ack = AckModelAdapter( + schema: () => namedItemSchema, + fromRuntime: NamedItem._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static NamedItem _fromAckRuntime(Map value) { + return NamedItem(name: value['name'] as String); + } + + Map _toAckRuntime() { + return {'name': name}; + } +} + +/// Immutable model generated from `item`. +final class Item { + Item({required this.id}); + + factory Item.parse(Object? input) { + return $ack.parse(input); + } + + factory Item.fromJson(Map json) { + return $ack.parse(json); + } + + final String id; + + static final $ack = AckModelAdapter( + schema: () => item, + fromRuntime: Item._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static Item _fromAckRuntime(Map value) { + return Item(id: value['id'] as String); + } + + Map _toAckRuntime() { + return {'id': id}; + } +} + +/// Immutable model generated from `myCustomSchema123`. +final class MyCustomSchema123 { + MyCustomSchema123({required this.value}); + + factory MyCustomSchema123.parse(Object? input) { + return $ack.parse(input); + } + + factory MyCustomSchema123.fromJson(Map json) { + return $ack.parse(json); + } + + final String value; + + static final $ack = AckModelAdapter( + schema: () => myCustomSchema123, + fromRuntime: MyCustomSchema123._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static MyCustomSchema123 _fromAckRuntime(Map value) { + return MyCustomSchema123(value: value['value'] as String); + } + + Map _toAckRuntime() { + return {'value': value}; + } +} diff --git a/example/lib/schema_types_edge_cases.dart b/example/lib/schema_types_edge_cases.dart index b5e422ea..ea861046 100644 --- a/example/lib/schema_types_edge_cases.dart +++ b/example/lib/schema_types_edge_cases.dart @@ -12,7 +12,7 @@ library; import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -part 'schema_types_edge_cases.g.dart'; +part 'schema_types_edge_cases.ack.dart'; // ============================================================================ // EDGE CASE 1: List Type Extraction @@ -136,7 +136,7 @@ final contactListSchema = Ack.object({ /// Empty schema (edge case) /// /// EXPECTED BEHAVIOR: -/// - Should generate extension type with no fields +/// Generates an immutable model with no stored schema fields. /// - parse() should still work @AckType() final emptySchema = Ack.object({}); diff --git a/example/lib/schema_types_edge_cases.g.dart b/example/lib/schema_types_edge_cases.g.dart deleted file mode 100644 index 8570bab9..00000000 --- a/example/lib/schema_types_edge_cases.g.dart +++ /dev/null @@ -1,319 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ************************************************************************** -// AckSchemaGenerator -// ************************************************************************** - -part of 'schema_types_edge_cases.dart'; - -List _$ackListCast(Object? value) => (value as List).cast(); - -/// Extension type for Product -extension type ProductType(Map _data) - implements Map { - static ProductType parse(Object? data) { - return productSchema.parseAs( - data, - (validated) => ProductType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return productSchema.safeParseAs( - data, - (validated) => ProductType(validated as Map), - ); - } - - String get name => _data['name'] as String; - - List get tags => _$ackListCast(_data['tags']); - - List get scores => _$ackListCast(_data['scores']); - - List get flags => _$ackListCast(_data['flags']); -} - -/// Extension type for Grid -extension type GridType(Map _data) - implements Map { - static GridType parse(Object? data) { - return gridSchema.parseAs( - data, - (validated) => GridType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return gridSchema.safeParseAs( - data, - (validated) => GridType(validated as Map), - ); - } - - String get name => _data['name'] as String; - - List> get matrix => _$ackListCast>(_data['matrix']); -} - -/// Extension type for Address -extension type AddressType(Map _data) - implements Map { - static AddressType parse(Object? data) { - return addressSchema.parseAs( - data, - (validated) => AddressType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return addressSchema.safeParseAs( - data, - (validated) => AddressType(validated as Map), - ); - } - - String get street => _data['street'] as String; - - String get city => _data['city'] as String; - - String get zipCode => _data['zipCode'] as String; - - String get country => _data['country'] as String; -} - -/// Extension type for Person -extension type PersonType(Map _data) - implements Map { - static PersonType parse(Object? data) { - return personSchema.parseAs( - data, - (validated) => PersonType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return personSchema.safeParseAs( - data, - (validated) => PersonType(validated as Map), - ); - } - - String get name => _data['name'] as String; - - String get email => _data['email'] as String; - - AddressType get address => - AddressType(_data['address'] as Map); - - int get age => _data['age'] as int; -} - -/// Extension type for Employee -extension type EmployeeType(Map _data) - implements Map { - static EmployeeType parse(Object? data) { - return employeeSchema.parseAs( - data, - (validated) => EmployeeType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return employeeSchema.safeParseAs( - data, - (validated) => EmployeeType(validated as Map), - ); - } - - String get name => _data['name'] as String; - - String get employeeId => _data['employeeId'] as String; - - AddressType get homeAddress => - AddressType(_data['homeAddress'] as Map); - - AddressType get workAddress => - AddressType(_data['workAddress'] as Map); -} - -/// Extension type for Modifier -extension type ModifierType(Map _data) - implements Map { - static ModifierType parse(Object? data) { - return modifierSchema.parseAs( - data, - (validated) => ModifierType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return modifierSchema.safeParseAs( - data, - (validated) => ModifierType(validated as Map), - ); - } - - String get requiredField => _data['requiredField'] as String; - - String? get optionalField => _data['optionalField'] as String?; - - String? get nullableField => _data['nullableField'] as String?; - - String? get optionalNullable => _data['optionalNullable'] as String?; - - String? get nullableOptional => _data['nullableOptional'] as String?; -} - -/// Extension type for TaggedItem -extension type TaggedItemType(Map _data) - implements Map { - static TaggedItemType parse(Object? data) { - return taggedItemSchema.parseAs( - data, - (validated) => TaggedItemType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return taggedItemSchema.safeParseAs( - data, - (validated) => TaggedItemType(validated as Map), - ); - } - - String get name => _data['name'] as String; - - List get requiredTags => _$ackListCast(_data['requiredTags']); - - List? get optionalTags => _data['optionalTags'] != null - ? _$ackListCast(_data['optionalTags']) - : null; - - List? get nullableTags => _data['nullableTags'] != null - ? _$ackListCast(_data['nullableTags']) - : null; -} - -/// Extension type for ContactList -extension type ContactListType(Map _data) - implements Map { - static ContactListType parse(Object? data) { - return contactListSchema.parseAs( - data, - (validated) => ContactListType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return contactListSchema.safeParseAs( - data, - (validated) => ContactListType(validated as Map), - ); - } - - String get name => _data['name'] as String; - - List get addresses => (_data['addresses'] as List) - .map((e) => AddressType(e as Map)) - .toList(); -} - -/// Extension type for Empty -extension type EmptyType(Map _data) - implements Map { - static EmptyType parse(Object? data) { - return emptySchema.parseAs( - data, - (validated) => EmptyType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return emptySchema.safeParseAs( - data, - (validated) => EmptyType(validated as Map), - ); - } -} - -/// Extension type for Minimal -extension type MinimalType(Map _data) - implements Map { - static MinimalType parse(Object? data) { - return minimalSchema.parseAs( - data, - (validated) => MinimalType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return minimalSchema.safeParseAs( - data, - (validated) => MinimalType(validated as Map), - ); - } - - String get id => _data['id'] as String; -} - -/// Extension type for NamedItem -extension type NamedItemType(Map _data) - implements Map { - static NamedItemType parse(Object? data) { - return namedItemSchema.parseAs( - data, - (validated) => NamedItemType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return namedItemSchema.safeParseAs( - data, - (validated) => NamedItemType(validated as Map), - ); - } - - String get name => _data['name'] as String; -} - -/// Extension type for Item -extension type ItemType(Map _data) - implements Map { - static ItemType parse(Object? data) { - return item.parseAs( - data, - (validated) => ItemType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return item.safeParseAs( - data, - (validated) => ItemType(validated as Map), - ); - } - - String get id => _data['id'] as String; -} - -/// Extension type for MyCustomSchema123 -extension type MyCustomSchema123Type(Map _data) - implements Map { - static MyCustomSchema123Type parse(Object? data) { - return myCustomSchema123.parseAs( - data, - (validated) => MyCustomSchema123Type(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return myCustomSchema123.safeParseAs( - data, - (validated) => MyCustomSchema123Type(validated as Map), - ); - } - - String get value => _data['value'] as String; -} diff --git a/example/lib/schema_types_primitives.ack.dart b/example/lib/schema_types_primitives.ack.dart new file mode 100644 index 00000000..9c35f3a1 --- /dev/null +++ b/example/lib/schema_types_primitives.ack.dart @@ -0,0 +1,453 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +part of 'schema_types_primitives.dart'; + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +/// Immutable value model generated from `passwordSchema`. +final class Password { + Password(this.value); + + factory Password.parse(Object? input) { + return $ack.parse(input); + } + + factory Password.fromJson(String json) { + return $ack.parse(json); + } + + final String value; + + static final $ack = AckModelAdapter( + schema: () => passwordSchema, + fromRuntime: Password._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + String toJson() => $ack.encode(this); + + SchemaResult safeToJson() => $ack.safeEncode(this); + + static Password _fromAckRuntime(String value) => Password(value); + + String _toAckRuntime() => value; +} + +/// Immutable value model generated from `ageSchema`. +final class Age { + Age(this.value); + + factory Age.parse(Object? input) { + return $ack.parse(input); + } + + factory Age.fromJson(int json) { + return $ack.parse(json); + } + + final int value; + + static final $ack = AckModelAdapter( + schema: () => ageSchema, + fromRuntime: Age._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + int toJson() => $ack.encode(this); + + SchemaResult safeToJson() => $ack.safeEncode(this); + + static Age _fromAckRuntime(int value) => Age(value); + + int _toAckRuntime() => value; +} + +/// Immutable value model generated from `priceSchema`. +final class Price { + Price(this.value); + + factory Price.parse(Object? input) { + return $ack.parse(input); + } + + factory Price.fromJson(double json) { + return $ack.parse(json); + } + + final double value; + + static final $ack = AckModelAdapter( + schema: () => priceSchema, + fromRuntime: Price._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + double toJson() => $ack.encode(this); + + SchemaResult safeToJson() => $ack.safeEncode(this); + + static Price _fromAckRuntime(double value) => Price(value); + + double _toAckRuntime() => value; +} + +/// Immutable value model generated from `activeSchema`. +final class Active { + Active(this.value); + + factory Active.parse(Object? input) { + return $ack.parse(input); + } + + factory Active.fromJson(bool json) { + return $ack.parse(json); + } + + final bool value; + + static final $ack = AckModelAdapter( + schema: () => activeSchema, + fromRuntime: Active._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + bool toJson() => $ack.encode(this); + + SchemaResult safeToJson() => $ack.safeEncode(this); + + static Active _fromAckRuntime(bool value) => Active(value); + + bool _toAckRuntime() => value; +} + +/// Immutable value model generated from `tagsSchema`. +final class Tags { + Tags(List value) + : value = List.unmodifiable(value.map((item) => item)); + + factory Tags.parse(Object? input) { + return $ack.parse(input); + } + + factory Tags.fromJson(List json) { + return $ack.parse(json); + } + + final List value; + + static final $ack = AckModelAdapter( + schema: () => tagsSchema, + fromRuntime: Tags._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + List toJson() => $ack.encode(this); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static Tags _fromAckRuntime(List value) => Tags(value); + + List _toAckRuntime() => value; +} + +/// Immutable value model generated from `scoresSchema`. +final class Scores { + Scores(List value) + : value = List.unmodifiable(value.map((item) => item)); + + factory Scores.parse(Object? input) { + return $ack.parse(input); + } + + factory Scores.fromJson(List json) { + return $ack.parse(json); + } + + final List value; + + static final $ack = AckModelAdapter( + schema: () => scoresSchema, + fromRuntime: Scores._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + List toJson() => $ack.encode(this); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static Scores _fromAckRuntime(List value) => Scores(value); + + List _toAckRuntime() => value; +} + +/// Immutable value model generated from `statusSchema`. +final class StatusLiteral { + StatusLiteral(this.value); + + factory StatusLiteral.parse(Object? input) { + return $ack.parse(input); + } + + factory StatusLiteral.fromJson(String json) { + return $ack.parse(json); + } + + final String value; + + static final $ack = AckModelAdapter( + schema: () => statusSchema, + fromRuntime: StatusLiteral._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + String toJson() => $ack.encode(this); + + SchemaResult safeToJson() => $ack.safeEncode(this); + + static StatusLiteral _fromAckRuntime(String value) => StatusLiteral(value); + + String _toAckRuntime() => value; +} + +/// Immutable value model generated from `roleSchema`. +final class Role { + Role(this.value); + + factory Role.parse(Object? input) { + return $ack.parse(input); + } + + factory Role.fromJson(String json) { + return $ack.parse(json); + } + + final String value; + + static final $ack = AckModelAdapter( + schema: () => roleSchema, + fromRuntime: Role._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + String toJson() => $ack.encode(this); + + SchemaResult safeToJson() => $ack.safeEncode(this); + + static Role _fromAckRuntime(String value) => Role(value); + + String _toAckRuntime() => value; +} + +/// Immutable value model generated from `userRoleSchema`. +final class UserRoleModel { + UserRoleModel(this.value); + + factory UserRoleModel.parse(Object? input) { + return $ack.parse(input); + } + + factory UserRoleModel.fromJson(String json) { + return $ack.parse(json); + } + + final UserRole value; + + static final $ack = AckModelAdapter( + schema: () => userRoleSchema, + fromRuntime: UserRoleModel._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + String toJson() => $ack.encode(this); + + SchemaResult safeToJson() => $ack.safeEncode(this); + + static UserRoleModel _fromAckRuntime(UserRole value) => UserRoleModel(value); + + UserRole _toAckRuntime() => value; +} + +/// Immutable value model generated from `statusEnumSchema`. +final class StatusEnum { + StatusEnum(this.value); + + factory StatusEnum.parse(Object? input) { + return $ack.parse(input); + } + + factory StatusEnum.fromJson(String json) { + return $ack.parse(json); + } + + final Status value; + + static final $ack = AckModelAdapter( + schema: () => statusEnumSchema, + fromRuntime: StatusEnum._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + String toJson() => $ack.encode(this); + + SchemaResult safeToJson() => $ack.safeEncode(this); + + static StatusEnum _fromAckRuntime(Status value) => StatusEnum(value); + + Status _toAckRuntime() => value; +} + +/// Immutable value model generated from `optionalStatusSchema`. +final class OptionalStatus { + OptionalStatus(this.value); + + factory OptionalStatus.parse(Object? input) { + return $ack.parse(input); + } + + factory OptionalStatus.fromJson(String json) { + return $ack.parse(json); + } + + final String value; + + static final $ack = AckModelAdapter( + schema: () => optionalStatusSchema, + fromRuntime: OptionalStatus._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + String toJson() => $ack.encode(this); + + SchemaResult safeToJson() => $ack.safeEncode(this); + + static OptionalStatus _fromAckRuntime(String value) => OptionalStatus(value); + + String _toAckRuntime() => value; +} + +/// Immutable value model generated from `defaultedEnumSchema`. +final class DefaultedEnum { + DefaultedEnum(this.value); + + factory DefaultedEnum.parse(Object? input) { + return $ack.parse(input); + } + + factory DefaultedEnum.fromJson(String json) { + return $ack.parse(json); + } + + final UserRole value; + + static final $ack = AckModelAdapter( + schema: () => defaultedEnumSchema, + fromRuntime: DefaultedEnum._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + String toJson() => $ack.encode(this); + + SchemaResult safeToJson() => $ack.safeEncode(this); + + static DefaultedEnum _fromAckRuntime(UserRole value) => DefaultedEnum(value); + + UserRole _toAckRuntime() => value; +} + +/// Immutable value model generated from `chainedEnumStringSchema`. +final class ChainedEnumString { + ChainedEnumString(this.value); + + factory ChainedEnumString.parse(Object? input) { + return $ack.parse(input); + } + + factory ChainedEnumString.fromJson(String json) { + return $ack.parse(json); + } + + final String value; + + static final $ack = AckModelAdapter( + schema: () => chainedEnumStringSchema, + fromRuntime: ChainedEnumString._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + String toJson() => $ack.encode(this); + + SchemaResult safeToJson() => $ack.safeEncode(this); + + static ChainedEnumString _fromAckRuntime(String value) => + ChainedEnumString(value); + + String _toAckRuntime() => value; +} + +/// Immutable value model generated from `refinedAgeSchema`. +final class RefinedAge { + RefinedAge(this.value); + + factory RefinedAge.parse(Object? input) { + return $ack.parse(input); + } + + factory RefinedAge.fromJson(int json) { + return $ack.parse(json); + } + + final int value; + + static final $ack = AckModelAdapter( + schema: () => refinedAgeSchema, + fromRuntime: RefinedAge._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + int toJson() => $ack.encode(this); + + SchemaResult safeToJson() => $ack.safeEncode(this); + + static RefinedAge _fromAckRuntime(int value) => RefinedAge(value); + + int _toAckRuntime() => value; +} diff --git a/example/lib/schema_types_primitives.dart b/example/lib/schema_types_primitives.dart index 50971f7a..68e84bea 100644 --- a/example/lib/schema_types_primitives.dart +++ b/example/lib/schema_types_primitives.dart @@ -1,10 +1,10 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -part 'schema_types_primitives.g.dart'; +part 'schema_types_primitives.ack.dart'; -// Note: Primitive schemas generate extension types, but you can still use -// the schema directly via parse() or safeParse(). +// Primitive schemas generate immutable value models while the schema remains +// available directly for parse() and safeParse(). /// Test primitive schema types with @AckType @@ -38,7 +38,7 @@ final tagsSchema = Ack.list(Ack.string()); final scoresSchema = Ack.list(Ack.integer()); // Literal schema -@AckType() +@AckType(name: 'StatusLiteral') final statusSchema = Ack.literal('active'); // String enum schema @@ -46,7 +46,7 @@ final statusSchema = Ack.literal('active'); final roleSchema = Ack.enumString(['admin', 'user', 'guest']); // EnumValues schemas -@AckType() +@AckType(name: 'UserRoleModel') final userRoleSchema = Ack.enumValues(UserRole.values); @AckType() @@ -56,7 +56,6 @@ final statusEnumSchema = Ack.enumValues(Status.values); @AckType() final optionalStatusSchema = Ack.literal('active').optional(); -@AckType() final nullableRoleSchema = Ack.enumString(['admin', 'user']).nullable(); @AckType() @@ -64,7 +63,6 @@ final defaultedEnumSchema = Ack.enumValues( UserRole.values, ).withDefault(UserRole.guest); -@AckType() final optionalNullableLiteralSchema = Ack.literal( 'pending', ).optional().nullable(); diff --git a/example/lib/schema_types_primitives.g.dart b/example/lib/schema_types_primitives.g.dart deleted file mode 100644 index 3e77b95c..00000000 --- a/example/lib/schema_types_primitives.g.dart +++ /dev/null @@ -1,243 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ************************************************************************** -// AckSchemaGenerator -// ************************************************************************** - -part of 'schema_types_primitives.dart'; - -/// Extension type for Password -extension type PasswordType(String _value) implements String { - static PasswordType parse(Object? data) { - return passwordSchema.parseAs( - data, - (validated) => PasswordType(validated as String), - ); - } - - static SchemaResult safeParse(Object? data) { - return passwordSchema.safeParseAs( - data, - (validated) => PasswordType(validated as String), - ); - } -} - -/// Extension type for Age -extension type AgeType(int _value) implements int { - static AgeType parse(Object? data) { - return ageSchema.parseAs(data, (validated) => AgeType(validated as int)); - } - - static SchemaResult safeParse(Object? data) { - return ageSchema.safeParseAs( - data, - (validated) => AgeType(validated as int), - ); - } -} - -/// Extension type for Price -extension type PriceType(double _value) implements double { - static PriceType parse(Object? data) { - return priceSchema.parseAs( - data, - (validated) => PriceType(validated as double), - ); - } - - static SchemaResult safeParse(Object? data) { - return priceSchema.safeParseAs( - data, - (validated) => PriceType(validated as double), - ); - } -} - -/// Extension type for Active -extension type ActiveType(bool _value) implements bool { - static ActiveType parse(Object? data) { - return activeSchema.parseAs( - data, - (validated) => ActiveType(validated as bool), - ); - } - - static SchemaResult safeParse(Object? data) { - return activeSchema.safeParseAs( - data, - (validated) => ActiveType(validated as bool), - ); - } -} - -/// Extension type for Tags -extension type TagsType(List _value) implements List { - static TagsType parse(Object? data) { - return tagsSchema.parseAs( - data, - (validated) => TagsType(validated as List), - ); - } - - static SchemaResult safeParse(Object? data) { - return tagsSchema.safeParseAs( - data, - (validated) => TagsType(validated as List), - ); - } -} - -/// Extension type for Scores -extension type ScoresType(List _value) implements List { - static ScoresType parse(Object? data) { - return scoresSchema.parseAs( - data, - (validated) => ScoresType(validated as List), - ); - } - - static SchemaResult safeParse(Object? data) { - return scoresSchema.safeParseAs( - data, - (validated) => ScoresType(validated as List), - ); - } -} - -/// Extension type for Status -extension type StatusType(String _value) implements String { - static StatusType parse(Object? data) { - return statusSchema.parseAs( - data, - (validated) => StatusType(validated as String), - ); - } - - static SchemaResult safeParse(Object? data) { - return statusSchema.safeParseAs( - data, - (validated) => StatusType(validated as String), - ); - } -} - -/// Extension type for Role -extension type RoleType(String _value) implements String { - static RoleType parse(Object? data) { - return roleSchema.parseAs( - data, - (validated) => RoleType(validated as String), - ); - } - - static SchemaResult safeParse(Object? data) { - return roleSchema.safeParseAs( - data, - (validated) => RoleType(validated as String), - ); - } -} - -/// Extension type for UserRole -extension type UserRoleType(UserRole _value) implements UserRole { - static UserRoleType parse(Object? data) { - return userRoleSchema.parseAs( - data, - (validated) => UserRoleType(validated as UserRole), - ); - } - - static SchemaResult safeParse(Object? data) { - return userRoleSchema.safeParseAs( - data, - (validated) => UserRoleType(validated as UserRole), - ); - } -} - -/// Extension type for StatusEnum -extension type StatusEnumType(Status _value) implements Status { - static StatusEnumType parse(Object? data) { - return statusEnumSchema.parseAs( - data, - (validated) => StatusEnumType(validated as Status), - ); - } - - static SchemaResult safeParse(Object? data) { - return statusEnumSchema.safeParseAs( - data, - (validated) => StatusEnumType(validated as Status), - ); - } -} - -/// Extension type for OptionalStatus -extension type OptionalStatusType(String _value) implements String { - static OptionalStatusType parse(Object? data) { - return optionalStatusSchema.parseAs( - data, - (validated) => OptionalStatusType(validated as String), - ); - } - - static SchemaResult safeParse(Object? data) { - return optionalStatusSchema.safeParseAs( - data, - (validated) => OptionalStatusType(validated as String), - ); - } -} - -/// Extension type for DefaultedEnum -extension type DefaultedEnumType(UserRole _value) implements UserRole { - static DefaultedEnumType parse(Object? data) { - return defaultedEnumSchema.parseAs( - data, - (validated) => DefaultedEnumType(validated as UserRole), - ); - } - - static SchemaResult safeParse(Object? data) { - return defaultedEnumSchema.safeParseAs( - data, - (validated) => DefaultedEnumType(validated as UserRole), - ); - } -} - -/// Extension type for ChainedEnumString -extension type ChainedEnumStringType(String _value) implements String { - static ChainedEnumStringType parse(Object? data) { - return chainedEnumStringSchema.parseAs( - data, - (validated) => ChainedEnumStringType(validated as String), - ); - } - - static SchemaResult safeParse(Object? data) { - return chainedEnumStringSchema.safeParseAs( - data, - (validated) => ChainedEnumStringType(validated as String), - ); - } -} - -/// Extension type for RefinedAge -extension type RefinedAgeType(int _value) implements int { - static RefinedAgeType parse(Object? data) { - return refinedAgeSchema.parseAs( - data, - (validated) => RefinedAgeType(validated as int), - ); - } - - static SchemaResult safeParse(Object? data) { - return refinedAgeSchema.safeParseAs( - data, - (validated) => RefinedAgeType(validated as int), - ); - } -} diff --git a/example/lib/schema_types_simple.ack.dart b/example/lib/schema_types_simple.ack.dart new file mode 100644 index 00000000..f0587bce --- /dev/null +++ b/example/lib/schema_types_simple.ack.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +part of 'schema_types_simple.dart'; + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +/// Immutable model generated from `userSchema`. +final class User { + User({required this.name, required this.age, required this.active}); + + factory User.parse(Object? input) { + return $ack.parse(input); + } + + factory User.fromJson(Map json) { + return $ack.parse(json); + } + + final String name; + + final int age; + + final bool active; + + static final $ack = AckModelAdapter( + schema: () => userSchema, + fromRuntime: User._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static User _fromAckRuntime(Map value) { + return User( + name: value['name'] as String, + age: value['age'] as int, + active: value['active'] as bool, + ); + } + + Map _toAckRuntime() { + return {'name': name, 'age': age, 'active': active}; + } +} diff --git a/example/lib/schema_types_simple.dart b/example/lib/schema_types_simple.dart index ac344ff9..551dcc04 100644 --- a/example/lib/schema_types_simple.dart +++ b/example/lib/schema_types_simple.dart @@ -1,7 +1,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -part 'schema_types_simple.g.dart'; +part 'schema_types_simple.ack.dart'; /// Simple example: Basic primitives @AckType() diff --git a/example/lib/schema_types_simple.g.dart b/example/lib/schema_types_simple.g.dart deleted file mode 100644 index 32234fbc..00000000 --- a/example/lib/schema_types_simple.g.dart +++ /dev/null @@ -1,32 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ************************************************************************** -// AckSchemaGenerator -// ************************************************************************** - -part of 'schema_types_simple.dart'; - -/// Extension type for User -extension type UserType(Map _data) - implements Map { - static UserType parse(Object? data) { - return userSchema.parseAs( - data, - (validated) => UserType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return userSchema.safeParseAs( - data, - (validated) => UserType(validated as Map), - ); - } - - String get name => _data['name'] as String; - - int get age => _data['age'] as int; - - bool get active => _data['active'] as bool; -} diff --git a/example/lib/schema_types_transforms.ack.dart b/example/lib/schema_types_transforms.ack.dart new file mode 100644 index 00000000..cf0bb714 --- /dev/null +++ b/example/lib/schema_types_transforms.ack.dart @@ -0,0 +1,146 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +part of 'schema_types_transforms.dart'; + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +/// Immutable value model generated from `colorSchema`. +final class ColorModel { + ColorModel(this.value); + + factory ColorModel.parse(Object? input) { + return $ack.parse(input); + } + + factory ColorModel.fromJson(String json) { + return $ack.parse(json); + } + + final Color value; + + static final $ack = AckModelAdapter( + schema: () => colorSchema, + fromRuntime: ColorModel._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + String toJson() => $ack.encode(this); + + SchemaResult safeToJson() => $ack.safeEncode(this); + + static ColorModel _fromAckRuntime(Color value) => ColorModel(value); + + Color _toAckRuntime() => value; +} + +/// Immutable model generated from `profileSchema`. +final class Profile { + Profile({ + required this.homepage, + required this.birthday, + required this.lastLogin, + required this.timeout, + required List links, + required this.favoriteColor, + required this.slug, + required this.accent, + required List colors, + required List customColors, + required this.tagList, + }) : links = List.unmodifiable(links.map((item) => item)), + colors = List.unmodifiable(colors.map((item) => item)), + customColors = List.unmodifiable( + customColors.map((item) => item), + ); + + factory Profile.parse(Object? input) { + return $ack.parse(input); + } + + factory Profile.fromJson(Map json) { + return $ack.parse(json); + } + + final Uri homepage; + + final DateTime birthday; + + final DateTime lastLogin; + + final Duration timeout; + + final List links; + + final Color favoriteColor; + + final String slug; + + final ColorModel accent; + + final List colors; + + final List customColors; + + final TagList tagList; + + static final $ack = AckModelAdapter( + schema: () => profileSchema, + fromRuntime: Profile._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static Profile _fromAckRuntime(Map value) { + return Profile( + homepage: value['homepage'] as Uri, + birthday: value['birthday'] as DateTime, + lastLogin: value['lastLogin'] as DateTime, + timeout: value['timeout'] as Duration, + links: List.unmodifiable( + (value['links'] as List).map((item) => item as Uri), + ), + favoriteColor: value['favoriteColor'] as Color, + slug: value['slug'] as String, + accent: ColorModel.$ack.fromRuntime(value['accent'] as Color), + colors: List.unmodifiable( + (value['colors'] as List).map( + (item) => ColorModel.$ack.fromRuntime(item as Color), + ), + ), + customColors: List.unmodifiable( + (value['customColors'] as List).map((item) => item as Color), + ), + tagList: value['tagList'] as TagList, + ); + } + + Map _toAckRuntime() { + return { + 'homepage': homepage, + 'birthday': birthday, + 'lastLogin': lastLogin, + 'timeout': timeout, + 'links': links.map((item) => item).toList(growable: false), + 'favoriteColor': favoriteColor, + 'slug': slug, + 'accent': ColorModel.$ack.toRuntime(accent), + 'colors': colors + .map((item) => ColorModel.$ack.toRuntime(item)) + .toList(growable: false), + 'customColors': customColors.map((item) => item).toList(growable: false), + 'tagList': tagList, + }; + } +} diff --git a/example/lib/schema_types_transforms.dart b/example/lib/schema_types_transforms.dart index 5bc7bae3..147cce5b 100644 --- a/example/lib/schema_types_transforms.dart +++ b/example/lib/schema_types_transforms.dart @@ -1,7 +1,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -part 'schema_types_transforms.g.dart'; +part 'schema_types_transforms.ack.dart'; class Color { final String value; @@ -15,8 +15,11 @@ class TagList { final baseColorSchema = Ack.string(); -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); +@AckType(name: 'ColorModel') +final colorSchema = Ack.string().codec( + decode: Color.new, + encode: (color) => color.value, +); @AckType() final profileSchema = Ack.object({ @@ -25,14 +28,24 @@ final profileSchema = Ack.object({ 'lastLogin': Ack.datetime(), 'timeout': Ack.duration(), 'links': Ack.list(Ack.uri()), - 'favoriteColor': Ack.string().transform((value) => Color(value)), - 'slug': Ack.string().transform((value) => '$value#'), + 'favoriteColor': Ack.string().codec( + decode: Color.new, + encode: (color) => color.value, + ), + 'slug': Ack.string().codec( + decode: (value) => '$value#', + encode: (value) => + value.endsWith('#') ? value.substring(0, value.length - 1) : value, + ), 'accent': colorSchema, 'colors': Ack.list(colorSchema), 'customColors': Ack.list( - baseColorSchema.transform((value) => Color(value)), + baseColorSchema.codec( + decode: Color.new, + encode: (color) => color.value, + ), ), 'tagList': Ack.list( Ack.string(), - ).transform((value) => TagList(value)), + ).codec(decode: TagList.new, encode: (tags) => tags.value), }); diff --git a/example/lib/schema_types_transforms.g.dart b/example/lib/schema_types_transforms.g.dart deleted file mode 100644 index 13764b8d..00000000 --- a/example/lib/schema_types_transforms.g.dart +++ /dev/null @@ -1,68 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ************************************************************************** -// AckSchemaGenerator -// ************************************************************************** - -part of 'schema_types_transforms.dart'; - -List _$ackListCast(Object? value) => (value as List).cast(); - -/// Extension type for Color -extension type ColorType(Color _value) implements Color { - static ColorType parse(Object? data) { - return colorSchema.parseAs( - data, - (validated) => ColorType(validated as Color), - ); - } - - static SchemaResult safeParse(Object? data) { - return colorSchema.safeParseAs( - data, - (validated) => ColorType(validated as Color), - ); - } -} - -/// Extension type for Profile -extension type ProfileType(Map _data) - implements Map { - static ProfileType parse(Object? data) { - return profileSchema.parseAs( - data, - (validated) => ProfileType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return profileSchema.safeParseAs( - data, - (validated) => ProfileType(validated as Map), - ); - } - - Uri get homepage => _data['homepage'] as Uri; - - DateTime get birthday => _data['birthday'] as DateTime; - - DateTime get lastLogin => _data['lastLogin'] as DateTime; - - Duration get timeout => _data['timeout'] as Duration; - - List get links => _$ackListCast(_data['links']); - - Color get favoriteColor => _data['favoriteColor'] as Color; - - String get slug => _data['slug'] as String; - - ColorType get accent => ColorType(_data['accent'] as Color); - - List get colors => - (_data['colors'] as List).map((e) => ColorType(e as Color)).toList(); - - List get customColors => _$ackListCast(_data['customColors']); - - TagList get tagList => _data['tagList'] as TagList; -} diff --git a/example/lib/user_with_color.ack.dart b/example/lib/user_with_color.ack.dart new file mode 100644 index 00000000..df12873e --- /dev/null +++ b/example/lib/user_with_color.ack.dart @@ -0,0 +1,173 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +part of 'user_with_color.dart'; + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +/// Immutable value model generated from `colorSchema`. +final class ColorModel { + ColorModel(this.value); + + factory ColorModel.parse(Object? input) { + return $ack.parse(input); + } + + factory ColorModel.fromJson(String json) { + return $ack.parse(json); + } + + final Color value; + + static final $ack = AckModelAdapter( + schema: () => colorSchema, + fromRuntime: ColorModel._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + String toJson() => $ack.encode(this); + + SchemaResult safeToJson() => $ack.safeEncode(this); + + static ColorModel _fromAckRuntime(Color value) => ColorModel(value); + + Color _toAckRuntime() => value; +} + +/// Immutable model generated from `profileSchema`. +final class Profile { + Profile({required this.bio, this.website}); + + factory Profile.parse(Object? input) { + return $ack.parse(input); + } + + factory Profile.fromJson(Map json) { + return $ack.parse(json); + } + + final String bio; + + final Uri? website; + + static final $ack = AckModelAdapter( + schema: () => profileSchema, + fromRuntime: Profile._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static Profile _fromAckRuntime(Map value) { + return Profile( + bio: value['bio'] as String, + website: value['website'] as Uri?, + ); + } + + Map _toAckRuntime() { + return { + 'bio': bio, + if (website != null) 'website': website!, + }; + } +} + +/// Immutable model generated from `userWithColorSchema`. +final class UserWithColor { + UserWithColor({ + required this.firstName, + required this.lastName, + required this.age, + required this.profile, + required this.color, + this.favoriteColor, + required this.pet, + required List pets, + }) : pets = List.unmodifiable(pets.map((item) => item)); + + factory UserWithColor.parse(Object? input) { + return $ack.parse(input); + } + + factory UserWithColor.fromJson(Map json) { + return $ack.parse(json); + } + + final String firstName; + + final String lastName; + + final int age; + + final Profile profile; + + final ColorModel color; + + final ColorModel? favoriteColor; + + final Pet pet; + + final List pets; + + static final $ack = AckModelAdapter( + schema: () => userWithColorSchema, + fromRuntime: UserWithColor._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), + ); + + static SchemaResult safeParse(Object? input) => + $ack.safeParse(input); + + Map toJson() => Map.from($ack.encode(this)); + + SchemaResult> safeToJson() => $ack.safeEncode(this); + + static UserWithColor _fromAckRuntime(Map value) { + return UserWithColor( + firstName: value['firstName'] as String, + lastName: value['lastName'] as String, + age: value['age'] as int, + profile: Profile.$ack.fromRuntime( + value['profile'] as Map, + ), + color: ColorModel.$ack.fromRuntime(value['color'] as Color), + favoriteColor: switch (value['favoriteColor']) { + null => null, + final fieldValue => ColorModel.$ack.fromRuntime(fieldValue as Color), + }, + pet: Pet.$ack.fromRuntime(value['pet'] as Map), + pets: List.unmodifiable( + (value['pets'] as List).map( + (item) => Pet.$ack.fromRuntime(item as Map), + ), + ), + ); + } + + Map _toAckRuntime() { + return { + 'firstName': firstName, + 'lastName': lastName, + 'age': age, + 'profile': Profile.$ack.toRuntime(profile), + 'color': ColorModel.$ack.toRuntime(color), + if (favoriteColor != null) + 'favoriteColor': ColorModel.$ack.toRuntime(favoriteColor!), + 'pet': Pet.$ack.toRuntime(pet), + 'pets': pets + .map((item) => Pet.$ack.toRuntime(item)) + .toList(growable: false), + }; + } +} diff --git a/example/lib/user_with_color.dart b/example/lib/user_with_color.dart index 85855d39..0f2044a8 100644 --- a/example/lib/user_with_color.dart +++ b/example/lib/user_with_color.dart @@ -3,7 +3,7 @@ import 'package:ack_annotations/ack_annotations.dart'; import 'pet.dart'; -part 'user_with_color.g.dart'; +part 'user_with_color.ack.dart'; class Color { final int value; @@ -14,14 +14,17 @@ class Color { '#${value.toRadixString(16).padLeft(6, '0').toUpperCase()}'; } -/// Color schema: validates hex code format, then transforms to Color object -@AckType() +/// Color schema: validates and bidirectionally maps hex values to Color. +@AckType(name: 'ColorModel') final colorSchema = Ack.string() .refine( (value) => RegExp(r'^#[0-9a-fA-F]{6}$').hasMatch(value), message: 'Must be a valid hex color code (e.g., #FF0000)', ) - .transform((hex) => Color(int.parse(hex.substring(1), radix: 16))); + .codec( + decode: (hex) => Color(int.parse(hex.substring(1), radix: 16)), + encode: (color) => color.toString(), + ); /// Profile: nested object with bio and website @AckType() diff --git a/example/lib/user_with_color.g.dart b/example/lib/user_with_color.g.dart deleted file mode 100644 index 5ccf93a1..00000000 --- a/example/lib/user_with_color.g.dart +++ /dev/null @@ -1,86 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ************************************************************************** -// AckSchemaGenerator -// ************************************************************************** - -part of 'user_with_color.dart'; - -/// Extension type for Color -extension type ColorType(Color _value) implements Color { - static ColorType parse(Object? data) { - return colorSchema.parseAs( - data, - (validated) => ColorType(validated as Color), - ); - } - - static SchemaResult safeParse(Object? data) { - return colorSchema.safeParseAs( - data, - (validated) => ColorType(validated as Color), - ); - } -} - -/// Extension type for Profile -extension type ProfileType(Map _data) - implements Map { - static ProfileType parse(Object? data) { - return profileSchema.parseAs( - data, - (validated) => ProfileType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return profileSchema.safeParseAs( - data, - (validated) => ProfileType(validated as Map), - ); - } - - String get bio => _data['bio'] as String; - - Uri? get website => _data['website'] as Uri?; -} - -/// Extension type for UserWithColor -extension type UserWithColorType(Map _data) - implements Map { - static UserWithColorType parse(Object? data) { - return userWithColorSchema.parseAs( - data, - (validated) => UserWithColorType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return userWithColorSchema.safeParseAs( - data, - (validated) => UserWithColorType(validated as Map), - ); - } - - String get firstName => _data['firstName'] as String; - - String get lastName => _data['lastName'] as String; - - int get age => _data['age'] as int; - - ProfileType get profile => - ProfileType(_data['profile'] as Map); - - ColorType get color => ColorType(_data['color'] as Color); - - ColorType? get favoriteColor => _data['favoriteColor'] != null - ? ColorType(_data['favoriteColor'] as Color) - : null; - - PetType get pet => PetType(_data['pet'] as Map); - - List get pets => (_data['pets'] as List) - .map((e) => PetType(e as Map)) - .toList(); -} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 25a032c4..8590dc74 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -6,7 +6,7 @@ publish_to: none resolution: workspace environment: - sdk: '>=3.8.0 <4.0.0' + sdk: '>=3.9.0 <4.0.0' dependencies: ack: ^1.0.0 diff --git a/example/test/args_getter_example_test.dart b/example/test/args_getter_example_test.dart index bc88ccf0..d8aa0eac 100644 --- a/example/test/args_getter_example_test.dart +++ b/example/test/args_getter_example_test.dart @@ -3,9 +3,9 @@ import 'package:test/test.dart'; import 'package:ack_example/args_getter_example.dart'; void main() { - group('Args getter examples', () { - test('args excludes declared fields', () { - final config = UserConfigType.parse({ + group('Additional properties examples', () { + test('additionalProperties excludes declared fields', () { + final config = UserConfig.parse({ 'username': 'leo', 'email': 'leo@example.com', 'theme': 'dark', @@ -14,11 +14,11 @@ void main() { expect(config.username, 'leo'); expect(config.email, 'leo@example.com'); - expect(config.args, {'theme': 'dark', 'retries': 3}); + expect(config.additionalProperties, {'theme': 'dark', 'retries': 3}); }); test('passthrough additional properties are preserved', () { - final request = ApiRequestType.parse({ + final request = ApiRequest.parse({ 'method': 'POST', 'url': 'https://api.example.com/users', 'headers': {'x-trace': '123'}, @@ -27,17 +27,17 @@ void main() { expect(request.method, 'POST'); expect(request.url, 'https://api.example.com/users'); - expect(request.args, { + expect(request.additionalProperties, { 'headers': {'x-trace': '123'}, 'timeoutMs': 5000, }); }); - test('empty-schema passthrough keeps all properties in args', () { + test('empty-schema passthrough keeps all additional properties', () { final data = {'enabled': true, 'rollout': 25, 'label': 'beta'}; - final dynamicData = DynamicDataType.parse(data); + final dynamicData = DynamicData.parse(data); - expect(dynamicData.args, data); + expect(dynamicData.additionalProperties, data); }); }); } diff --git a/example/test/schema_types_discriminated_test.dart b/example/test/schema_types_discriminated_test.dart index 5cb21097..d00e8e86 100644 --- a/example/test/schema_types_discriminated_test.dart +++ b/example/test/schema_types_discriminated_test.dart @@ -5,19 +5,19 @@ import 'package:test/test.dart'; void main() { group('discriminated generated types with omitted branch discriminators', () { test('base parser returns the matching subtype', () { - final cat = omitted.PetType.parse({'kind': 'cat', 'lives': 9}); - final dog = omitted.PetType.parse({'kind': 'dog', 'bark': true}); + final cat = omitted.Pet.parse({'kind': 'cat', 'lives': 9}); + final dog = omitted.Pet.parse({'kind': 'dog', 'bark': true}); - expect(cat, isA()); - expect(dog, isA()); + expect(cat, isA()); + expect(dog, isA()); }); test('subtype parser rejects another valid union branch', () { - final result = omitted.CatType.safeParse({'kind': 'dog', 'bark': true}); + final result = omitted.Cat.safeParse({'kind': 'dog', 'bark': true}); expect(result.isFail, isTrue); expect( - () => omitted.CatType.parse({'kind': 'dog', 'bark': true}), + () => omitted.Cat.parse({'kind': 'dog', 'bark': true}), throwsA(anything), ); }); @@ -25,22 +25,19 @@ void main() { group('discriminated generated types with explicit branch literals', () { test('base parser returns the matching subtype', () { - final cat = explicit.PetType.parse({'type': 'cat', 'lives': 9}); - final dog = explicit.PetType.parse({'type': 'dog', 'breed': 'Poodle'}); + final cat = explicit.Pet.parse({'type': 'cat', 'lives': 9}); + final dog = explicit.Pet.parse({'type': 'dog', 'breed': 'Poodle'}); - expect(cat, isA()); - expect(dog, isA()); + expect(cat, isA()); + expect(dog, isA()); }); test('subtype parser rejects another valid union branch', () { - final result = explicit.CatType.safeParse({ - 'type': 'dog', - 'breed': 'Poodle', - }); + final result = explicit.Cat.safeParse({'type': 'dog', 'breed': 'Poodle'}); expect(result.isFail, isTrue); expect( - () => explicit.CatType.parse({'type': 'dog', 'breed': 'Poodle'}), + () => explicit.Cat.parse({'type': 'dog', 'breed': 'Poodle'}), throwsA(anything), ); }); diff --git a/example/test/schema_types_edge_cases_test.dart b/example/test/schema_types_edge_cases_test.dart index 80fa7a87..aca517b8 100644 --- a/example/test/schema_types_edge_cases_test.dart +++ b/example/test/schema_types_edge_cases_test.dart @@ -5,7 +5,7 @@ import 'package:ack_example/schema_types_edge_cases.dart'; void main() { group('Edge case schema examples', () { test('typed list extraction keeps element types', () { - final product = ProductType.parse({ + final product = Product.parse({ 'name': 'Widget', 'tags': ['sale', 'featured'], 'scores': [1, 2, 3], @@ -17,8 +17,8 @@ void main() { expect(product.flags, everyElement(isA())); }); - test('nested schema references produce typed nested wrappers', () { - final employee = EmployeeType.parse({ + test('nested schema references produce typed nested models', () { + final employee = Employee.parse({ 'name': 'Leo', 'employeeId': 'EMP-1', 'homeAddress': { @@ -35,13 +35,13 @@ void main() { }, }); - expect(employee.homeAddress, isA()); + expect(employee.homeAddress, isA
()); expect(employee.homeAddress.city, 'Miami'); expect(employee.workAddress.street, '200 Market St'); }); test('optional and nullable fields are surfaced as nullable getters', () { - final modifier = ModifierType.parse({ + final modifier = Modifier.parse({ 'requiredField': 'value', 'nullableField': null, 'nullableOptional': null, @@ -55,17 +55,17 @@ void main() { }); test('empty and minimal schemas still parse', () { - final empty = EmptyType.parse({}); - final minimal = MinimalType.parse({'id': 'abc-123'}); + final empty = Empty.parse({}); + final minimal = Minimal.parse({'id': 'abc-123'}); - expect(empty, isEmpty); + expect(empty.toJson(), isEmpty); expect(minimal.id, 'abc-123'); }); - test('naming variations generate the expected type wrappers', () { - final named = NamedItemType.parse({'name': 'named'}); - final itemValue = ItemType.parse({'id': 'item-1'}); - final custom = MyCustomSchema123Type.parse({'value': 'custom'}); + test('naming variations generate the expected model classes', () { + final named = NamedItem.parse({'name': 'named'}); + final itemValue = Item.parse({'id': 'item-1'}); + final custom = MyCustomSchema123.parse({'value': 'custom'}); expect(named.name, 'named'); expect(itemValue.id, 'item-1'); diff --git a/example/test/schema_types_transforms_test.dart b/example/test/schema_types_transforms_test.dart index e9f7b453..92116aa6 100644 --- a/example/test/schema_types_transforms_test.dart +++ b/example/test/schema_types_transforms_test.dart @@ -3,16 +3,16 @@ import 'package:test/test.dart'; import 'package:ack_example/schema_types_transforms.dart'; void main() { - group('Transform schema examples', () { - test('top-level transformed wrappers preserve the transformed value', () { - final color = ColorType.parse('#12AB34'); + group('Codec schema examples', () { + test('top-level codec models preserve the decoded value', () { + final color = ColorModel.parse('#12AB34'); - expect(color, isA()); - expect(color.value, '#12AB34'); + expect(color, isA()); + expect(color.value.value, '#12AB34'); }); - test('profile wrapper exposes transformed object getters', () { - final profile = ProfileType.parse({ + test('profile model exposes decoded codec values', () { + final profile = Profile.parse({ 'homepage': 'https://example.com', 'birthday': '2025-06-15', 'lastLogin': '2025-06-15T10:30:00Z', @@ -38,8 +38,8 @@ void main() { ]); expect(profile.favoriteColor.value, '#FF5733'); expect(profile.slug, 'docs#'); - expect(profile.accent.value, '#00FF00'); - expect(profile.colors.map((color) => color.value), [ + expect(profile.accent.value.value, '#00FF00'); + expect(profile.colors.map((color) => color.value.value), [ '#111111', '#222222', ]); diff --git a/example/test/schema_variable_test.dart b/example/test/schema_variable_test.dart index 5df2cdfa..ce7460f9 100644 --- a/example/test/schema_variable_test.dart +++ b/example/test/schema_variable_test.dart @@ -4,51 +4,50 @@ import 'package:test/test.dart'; import 'package:ack_example/schema_types_simple.dart'; void main() { - group('Schema Variable Extension Types', () { - test('UserType parses valid data', () { + group('Schema variable models', () { + test('User parses valid data', () { final data = {'name': 'Alice', 'age': 30, 'active': true}; - final user = UserType.parse(data); + final user = User.parse(data); expect(user.name, 'Alice'); expect(user.age, 30); expect(user.active, true); }); - test('UserType validates data through schema', () { + test('User validates data through schema', () { final invalidData = { 'name': 'Alice', 'age': 'not a number', // Invalid type 'active': true, }; - expect(() => UserType.parse(invalidData), throwsA(isA())); + expect(() => User.parse(invalidData), throwsA(isA())); }); - test('UserType can be used as Map (implements Map)', () { - final user = UserType.parse({'name': 'Alice', 'age': 30, 'active': true}); + test('User serializes to a JSON map', () { + final user = User.parse({'name': 'Alice', 'age': 30, 'active': true}); - // Extension type implements Map, so it can be used directly as a map - final Map json = user; + final json = user.toJson(); expect(json, {'name': 'Alice', 'age': 30, 'active': true}); expect(json['name'], 'Alice'); expect(json['age'], 30); }); - test('UserType safeParse returns success for valid data', () { - final result = UserType.safeParse({ + test('User safeParse returns success for valid data', () { + final result = User.safeParse({ 'name': 'Alice', 'age': 30, 'active': true, }); expect(result.isOk, true); - expect(result.getOrNull(), isA>()); + expect(result.getOrNull(), isA()); }); - test('UserType safeParse returns failure for invalid data', () { - final result = UserType.safeParse({ + test('User safeParse returns failure for invalid data', () { + final result = User.safeParse({ 'name': 'Alice', 'age': 'not a number', 'active': true, diff --git a/example/test/user_with_color_test.dart b/example/test/user_with_color_test.dart index c75a1ab6..f0a49d7c 100644 --- a/example/test/user_with_color_test.dart +++ b/example/test/user_with_color_test.dart @@ -39,9 +39,9 @@ void main() { expect(result.isFail, isTrue); }); - test('ColorType parse works', () { - final color = ColorType.parse('#00FF00'); - expect(color.toString(), '#00FF00'); + test('Color parse works', () { + final color = ColorModel.parse('#00FF00'); + expect(color.value.toString(), '#00FF00'); }); }); @@ -57,7 +57,7 @@ void main() { test('parses profile without optional website', () { final result = profileSchema.safeParse({'bio': 'Hello world'}); expect(result.isOk, isTrue); - final profile = ProfileType.parse({'bio': 'Hello world'}); + final profile = Profile.parse({'bio': 'Hello world'}); expect(profile.bio, 'Hello world'); expect(profile.website, isNull); }); @@ -101,68 +101,68 @@ void main() { expect(result.isFail, isTrue); }); - test('PetType.parse dispatches to CatType', () { - final pet = PetType.parse({'type': 'cat', 'lives': 9}); + test('Pet.parse dispatches to Cat', () { + final pet = Pet.parse({'type': 'cat', 'lives': 9}); expect(pet.type, 'cat'); - final cat = pet as CatType; + final cat = pet as Cat; expect(cat.lives, 9); }); - test('PetType.parse dispatches to DogType', () { - final pet = PetType.parse({'type': 'dog', 'breed': 'Poodle'}); + test('Pet.parse dispatches to Dog', () { + final pet = Pet.parse({'type': 'dog', 'breed': 'Poodle'}); expect(pet.type, 'dog'); - final dog = pet as DogType; + final dog = pet as Dog; expect(dog.breed, 'Poodle'); }); }); group('UserWithColorSchema', () { test('parses valid data with all fields', () { - final user = UserWithColorType.parse(_validData()); + final user = UserWithColor.parse(_validData()); expect(user.firstName, 'Leo'); expect(user.lastName, 'Farias'); expect(user.age, 30); expect(user.profile.bio, 'Software engineer'); expect(user.profile.website, Uri.parse('https://example.com')); - expect(user.color.toString(), '#FF5733'); + expect(user.color.value.toString(), '#FF5733'); expect(user.pet.type, 'cat'); expect(user.pets.length, 2); }); test('firstName validation - rejects empty', () { final data = _validData()..['firstName'] = ''; - final result = UserWithColorType.safeParse(data); + final result = UserWithColor.safeParse(data); expect(result.isFail, isTrue); }); test('lastName validation - rejects too long', () { final data = _validData()..['lastName'] = 'A' * 51; - final result = UserWithColorType.safeParse(data); + final result = UserWithColor.safeParse(data); expect(result.isFail, isTrue); }); test('age validation - rejects negative', () { final data = _validData()..['age'] = -1; - final result = UserWithColorType.safeParse(data); + final result = UserWithColor.safeParse(data); expect(result.isFail, isTrue); }); test('age validation - rejects over 150', () { final data = _validData()..['age'] = 151; - final result = UserWithColorType.safeParse(data); + final result = UserWithColor.safeParse(data); expect(result.isFail, isTrue); }); test('color validation - rejects invalid hex', () { final data = _validData()..['color'] = 'red'; - final result = UserWithColorType.safeParse(data); + final result = UserWithColor.safeParse(data); expect(result.isFail, isTrue); }); }); group('Object-level refine (firstName != lastName)', () { test('accepts when firstName and lastName differ', () { - final result = UserWithColorType.safeParse(_validData()); + final result = UserWithColor.safeParse(_validData()); expect(result.isOk, isTrue); }); @@ -170,73 +170,73 @@ void main() { final data = _validData() ..['firstName'] = 'Same' ..['lastName'] = 'Same'; - final result = UserWithColorType.safeParse(data); + final result = UserWithColor.safeParse(data); expect(result.isFail, isTrue); }); }); - group('Optional transformed schema (favoriteColor)', () { + group('Optional codec schema (favoriteColor)', () { test('omitted favoriteColor returns null', () { - final user = UserWithColorType.parse(_validData()); + final user = UserWithColor.parse(_validData()); expect(user.favoriteColor, isNull); }); test('provided favoriteColor parses correctly', () { final data = _validData()..['favoriteColor'] = '#00FF00'; - final user = UserWithColorType.parse(data); + final user = UserWithColor.parse(data); expect(user.favoriteColor, isNotNull); - expect(user.favoriteColor.toString(), '#00FF00'); + expect(user.favoriteColor?.value.toString(), '#00FF00'); }); test('invalid favoriteColor fails validation', () { final data = _validData()..['favoriteColor'] = 'bad'; - final result = UserWithColorType.safeParse(data); + final result = UserWithColor.safeParse(data); expect(result.isFail, isTrue); }); }); group('Nested discriminated type (pet)', () { test('accesses nested cat fields via cast', () { - final user = UserWithColorType.parse(_validData()); + final user = UserWithColor.parse(_validData()); expect(user.pet.type, 'cat'); - final cat = user.pet as CatType; + final cat = user.pet as Cat; expect(cat.lives, 7); }); test('nested dog in pet field', () { final data = _validData()..['pet'] = {'type': 'dog', 'breed': 'Husky'}; - final user = UserWithColorType.parse(data); + final user = UserWithColor.parse(data); expect(user.pet.type, 'dog'); - final dog = user.pet as DogType; + final dog = user.pet as Dog; expect(dog.breed, 'Husky'); }); test('rejects invalid nested pet', () { final data = _validData()..['pet'] = {'type': 'fish'}; - final result = UserWithColorType.safeParse(data); + final result = UserWithColor.safeParse(data); expect(result.isFail, isTrue); }); }); group('List of discriminated types (pets)', () { test('parses list with mixed pet types', () { - final user = UserWithColorType.parse(_validData()); + final user = UserWithColor.parse(_validData()); expect(user.pets.length, 2); expect(user.pets[0].type, 'cat'); expect(user.pets[1].type, 'dog'); }); test('can cast list elements to subtypes', () { - final user = UserWithColorType.parse(_validData()); - final cat = user.pets[0] as CatType; + final user = UserWithColor.parse(_validData()); + final cat = user.pets[0] as Cat; expect(cat.lives, 9); - final dog = user.pets[1] as DogType; + final dog = user.pets[1] as Dog; expect(dog.breed, 'Labrador'); }); test('empty pets list is valid', () { final data = _validData()..['pets'] = []; - final user = UserWithColorType.parse(data); + final user = UserWithColor.parse(data); expect(user.pets, isEmpty); }); @@ -245,7 +245,7 @@ void main() { ..['pets'] = [ {'type': 'cat', 'lives': 0}, // lives min is 1 ]; - final result = UserWithColorType.safeParse(data); + final result = UserWithColor.safeParse(data); expect(result.isFail, isTrue); }); }); diff --git a/example/test/verify_implements_works.dart b/example/test/verify_implements_works.dart index 779d4881..6fa68e00 100644 --- a/example/test/verify_implements_works.dart +++ b/example/test/verify_implements_works.dart @@ -2,77 +2,32 @@ import 'package:ack_example/schema_types_simple.dart'; import 'package:test/test.dart'; void main() { - group('Extension type with implements Map', () { - test('can use Map operator[]', () { - final user = UserType.parse({'name': 'John', 'age': 30, 'active': true}); + group('Generated immutable model', () { + test('exposes typed fields and an explicit JSON boundary', () { + final user = User.parse({'name': 'John', 'age': 30, 'active': true}); - // Both accessors work - expect(user.name, 'John'); // Custom getter - expect(user['name'], 'John'); // Map operator from implements + expect(user.name, 'John'); + expect(user.age, 30); + expect(user.active, true); + expect(user.toJson(), {'name': 'John', 'age': 30, 'active': true}); }); - test('can use Map.keys', () { - final user = UserType.parse({'name': 'John', 'age': 30, 'active': true}); - - expect(user.keys, containsAll(['name', 'age', 'active'])); - }); - - test('can use Map.values', () { - final user = UserType.parse({'name': 'John', 'age': 30, 'active': true}); - - expect(user.values, containsAll(['John', 30, true])); - }); - - test('can use Map.forEach', () { - final user = UserType.parse({'name': 'John', 'age': 30, 'active': true}); - - final collected = {}; - user.forEach((key, value) { - collected[key] = value; - }); - - expect(collected, {'name': 'John', 'age': 30, 'active': true}); - }); - - test('can use Map.containsKey', () { - final user = UserType.parse({'name': 'John', 'age': 30, 'active': true}); - - expect(user.containsKey('name'), true); - expect(user.containsKey('missing'), false); - }); - - test('can be passed to function expecting Map', () { - void processMap(Map map) { - expect(map['name'], 'John'); - } - - final user = UserType.parse({'name': 'John', 'age': 30, 'active': true}); - - // Can pass UserType where Map is expected! - processMap(user); - }); - - test('safeParse returns SchemaResult', () { - final result = UserType.safeParse({ + test('safeParse returns SchemaResult', () { + final result = User.safeParse({ 'name': 'John', 'age': 30, 'active': true, }); expect(result.isOk, true); - - // The result should be UserType, not Map! final user = result.getOrNull(); - expect(user, isA()); + expect(user, isA()); expect(user?.name, 'John'); expect(user?.age, 30); }); - test('safeParse fail returns correct type', () { - final result = UserType.safeParse({ - 'name': 'John', - // Missing 'age' - should fail - }); + test('safeParse failure preserves the typed result contract', () { + final result = User.safeParse({'name': 'John'}); expect(result.isFail, true); expect(result.getOrNull(), isNull); diff --git a/example/user_with_color_example.dart b/example/user_with_color_example.dart index 9621ac03..b9047738 100644 --- a/example/user_with_color_example.dart +++ b/example/user_with_color_example.dart @@ -17,7 +17,7 @@ void main() { }; print('--- Valid data ---'); - final user = UserWithColorType.parse(validData); + final user = UserWithColor.parse(validData); print('Name: ${user.firstName} ${user.lastName}'); print('Age: ${user.age}'); print('Bio: ${user.profile.bio}'); @@ -31,7 +31,7 @@ void main() { // Valid with favoriteColor provided print('--- With favorite color ---'); final withFav = {...validData, 'favoriteColor': '#00FF00'}; - final userFav = UserWithColorType.parse(withFav); + final userFav = UserWithColor.parse(withFav); print('Favorite color: ${userFav.favoriteColor}'); print(''); @@ -44,14 +44,14 @@ void main() { print(' - ${p.type}'); } // Can access discriminated subtype fields via cast - final cat = userPet.pet as CatType; + final cat = userPet.pet as Cat; print('Cat lives: ${cat.lives}'); print(''); // Invalid hex color print('--- Invalid hex color ---'); final badColor = {...validData, 'color': 'not-a-color'}; - final colorResult = UserWithColorType.safeParse(badColor); + final colorResult = UserWithColor.safeParse(badColor); colorResult.match( onOk: (val) => print('OK: $val'), onFail: (error) => print('Error: $error'), @@ -61,7 +61,7 @@ void main() { // Age out of range print('--- Age out of range ---'); final badAge = {...validData, 'age': -5}; - final ageResult = UserWithColorType.safeParse(badAge); + final ageResult = UserWithColor.safeParse(badAge); ageResult.match( onOk: (val) => print('OK: $val'), onFail: (error) => print('Error: $error'), @@ -74,7 +74,7 @@ void main() { ...validData, 'profile': {'website': 'https://example.com'}, }; - final profileResult = UserWithColorType.safeParse(badProfile); + final profileResult = UserWithColor.safeParse(badProfile); profileResult.match( onOk: (val) => print('OK: $val'), onFail: (error) => print('Error: $error'), diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 0dd17178..c266778e 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -1,3 +1,10 @@ +## Unreleased + +### Added + +* Add `AckModelAdapter` as the non-nullable runtime bridge used by generated + immutable Ack models. + ## 1.1.0 ### Fixed diff --git a/packages/ack/lib/src/models/ack_model_adapter.dart b/packages/ack/lib/src/models/ack_model_adapter.dart index 296e9c6a..53765f6d 100644 --- a/packages/ack/lib/src/models/ack_model_adapter.dart +++ b/packages/ack/lib/src/models/ack_model_adapter.dart @@ -60,21 +60,11 @@ final class AckModelAdapter< /// Encodes a generated model to its boundary representation. Boundary encode(Model value, {String? debugName}) { - return schema.encode( - _toRuntime(value), - debugName: debugName, - ) - as Boundary; + return schema.encode(_toRuntime(value), debugName: debugName) as Boundary; } /// Safely encodes a generated model to its boundary representation. - SchemaResult safeEncode( - Model value, { - String? debugName, - }) { - return schema.safeEncode( - _toRuntime(value), - debugName: debugName, - ); + SchemaResult safeEncode(Model value, {String? debugName}) { + return schema.safeEncode(_toRuntime(value), debugName: debugName); } } diff --git a/packages/ack/test/models/ack_model_adapter_test.dart b/packages/ack/test/models/ack_model_adapter_test.dart index c5392e92..a761afbc 100644 --- a/packages/ack/test/models/ack_model_adapter_test.dart +++ b/packages/ack/test/models/ack_model_adapter_test.dart @@ -1,10 +1,7 @@ import 'package:ack/ack.dart'; import 'package:test/test.dart'; -final _userSchema = Ack.object({ - 'name': Ack.string(), - 'age': Ack.integer(), -}); +final _userSchema = Ack.object({'name': Ack.string(), 'age': Ack.integer()}); final _userAdapter = AckModelAdapter( schema: () => _userSchema, @@ -19,10 +16,7 @@ final class _User { final int age; static _User fromRuntime(JsonMap value) { - return _User( - name: value['name'] as String, - age: value['age'] as int, - ); + return _User(name: value['name'] as String, age: value['age'] as int); } JsonMap toRuntime() => {'name': name, 'age': age}; @@ -38,9 +32,7 @@ void main() { }); test('encodes a model through the source schema', () { - final encoded = _userAdapter.encode( - const _User(name: 'Ada', age: 36), - ); + final encoded = _userAdapter.encode(const _User(name: 'Ada', age: 36)); expect(encoded, {'name': 'Ada', 'age': 36}); }); diff --git a/packages/ack_annotations/CHANGELOG.md b/packages/ack_annotations/CHANGELOG.md index 8d359e17..e0827d53 100644 --- a/packages/ack_annotations/CHANGELOG.md +++ b/packages/ack_annotations/CHANGELOG.md @@ -1,3 +1,11 @@ +## Unreleased + +### Breaking + +* Define `@AckType()` as immutable model-class generation. Custom names are + exact, generated class names no longer add `Type`, and annotated libraries + declare a dedicated `.ack.dart` part. + ## 1.1.0 * See [release notes](https://github.com/btwld/ack/releases/tag/v1.1.0) for details. diff --git a/packages/ack_annotations/README.md b/packages/ack_annotations/README.md index efcb99ac..f33f3499 100644 --- a/packages/ack_annotations/README.md +++ b/packages/ack_annotations/README.md @@ -23,7 +23,7 @@ Annotate a top-level Ack schema variable or getter and run `build_runner`: import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -part 'user.g.dart'; +part 'user.ack.dart'; @AckType() final userSchema = Ack.object({ @@ -32,8 +32,8 @@ final userSchema = Ack.object({ }); ``` -`ack_generator` emits an extension type such as `UserType` with typed getters -plus `parse()` and `safeParse()` helpers. +`ack_generator` emits an immutable `User` class with typed fields, an unchecked +constructor, parsing helpers, JSON methods, and a public `$ack` adapter. Generate the wrapper with: @@ -43,14 +43,15 @@ dart run build_runner build ## Custom names -Use `name` to override the generated type prefix: +Use `name` to set the exact generated class name: ```dart @AckType(name: 'Password') final passwordSchema = Ack.string().minLength(8); ``` -This generates `PasswordType`. +This generates `Password`. Names must be unchanged UpperCamelCase identifiers; +an intentional `Type` suffix is kept exactly. ## Supported targets diff --git a/packages/ack_annotations/lib/src/ack_type.dart b/packages/ack_annotations/lib/src/ack_type.dart index d1ad8d67..31e899bb 100644 --- a/packages/ack_annotations/lib/src/ack_type.dart +++ b/packages/ack_annotations/lib/src/ack_type.dart @@ -12,8 +12,10 @@ import 'package:meta/meta_meta.dart'; /// }); /// ``` /// -/// `ack_generator` emits a real Dart class with stored typed fields plus -/// `parse`, `safeParse`, `fromMap`, `fromJson`, `toMap`, and `toJson` APIs. +/// The declaring library must include its dedicated generated part, for +/// example `part 'user.ack.dart';`. `ack_generator` emits a real Dart class +/// with stored typed fields plus `parse`, `safeParse`, `fromJson`, `toJson`, +/// `safeToJson`, and a public static `$ack` adapter. /// Ack remains responsible for validation and codec-aware serialization. /// /// Supported targets: @@ -41,7 +43,7 @@ class AckType { /// Creates an annotation for immutable Ack model generation. /// - /// [name] must be a valid Dart class identifier. Do not add a `Type` suffix - /// unless it is intentionally part of the public model name. + /// [name] must be an unchanged UpperCamelCase Dart identifier. It is used + /// exactly; a `Type` suffix is retained when intentionally supplied. const AckType({this.name}); } diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index c1f155f4..af765457 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -8,8 +8,8 @@ ### Added -* Generate `parse`, `safeParse`, `fromMap`, `fromJson`, `toMap`, and `toJson` - APIs for model classes. +* Generate `parse`, `safeParse`, `fromJson`, `toJson`, `safeToJson`, unchecked + constructors, and public `$ack` adapters for model classes. * Add `AckModelAdapter` for codec-safe conversion between Ack runtime values and generated models. * Add a normalized schema graph foundation for imported, recursive, and @@ -17,8 +17,11 @@ ### Changed -* Use `SharedPartBuilder` and the `source_gen` combining builder so Ack can share - `.g.dart` output with generators such as `json_serializable`. +* Generate dedicated `.ack.dart` source parts before `json_serializable`, which + keeps Ack declarations resolvable by later builders. +* Reject parse-only transforms and schema shapes without a static model form. +* Support named recursion, cross-file references, custom codecs, additional + properties, and sealed discriminated model hierarchies. ## 1.1.0 @@ -48,7 +51,7 @@ ### Breaking * Remove class-based schema generation. `ack_generator` now supports only - top-level `@AckType()` schema variables and getters. + top-level `@Ack()` schema variables and getters. ## 1.0.0-beta.11 @@ -81,7 +84,7 @@ * **Analyzer**: Refactored field analyzer, model analyzer, and schema AST analyzer for correctness (#50). * **Builders**: Improved type builder, field builder, and schema builder (#50). * **Generator**: Centralized null/default handling in generator output (#65). -* **AckType factories**: Generate direct `schema.parseAs(...)` / `schema.safeParseAs(...)` calls and stop emitting `_$ackParse` / `_$ackSafeParse` helpers. +* **Ack factories**: Generate direct `schema.parseAs(...)` / `schema.safeParseAs(...)` calls and stop emitting `_$ackParse` / `_$ackSafeParse` helpers. ## 1.0.0-beta.5 (2026-01-14) @@ -92,7 +95,7 @@ ### Bug Fixes * **List types**: Resolve list element types with method chain modifiers (#60). Fixed type resolution for complex list schemas with chained method calls. -* **AckType casts**: Fix @AckType schema ref casts and improve nested schema handling (#59). +* **Ack casts**: Fix @Ack schema ref casts and improve nested schema handling (#59). ### Improvements @@ -105,7 +108,7 @@ * **Primitives**: Comprehensive fixes for primitive schema generation and correctness. * **Typed list getters**: Support `Ack.list(schemaRef)` for typed list getters (#47). * **Field descriptions**: Add field descriptions to generated schema output (#44). -* **Extension types**: Generate extension types for all AckType schemas; skip for nullable AckType schemas. +* **Extension types**: Generate extension types for all Ack schemas; skip for nullable Ack schemas. ### Improvements diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index 51bea795..1ff27d2d 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -1,19 +1,15 @@ # Ack Generator -`ack_generator` generates immutable Dart model classes from top-level Ack -schemas annotated with `@AckType()`. +`ack_generator` turns top-level Ack schemas annotated with `@AckType()` into +immutable Dart model classes. -> This branch contains a draft class-generation rewrite. See -> [`docs/architecture/acktype-model-generation.md`](../../docs/architecture/acktype-model-generation.md) -> for design decisions, known gaps, and the validation checklist. - -## Overview +## Usage ```dart import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -part 'user_schema.g.dart'; +part 'user_schema.ack.dart'; @AckType() final userSchema = Ack.object({ @@ -22,91 +18,72 @@ final userSchema = Ack.object({ }); ``` -Running `dart run build_runner build` generates a real class: +Run `dart run build_runner build`. A declaration ending in `Schema` loses that +suffix, so `userSchema` generates `User`: ```dart final class User { - User({required this.name, required this.email}); + User({required String name, required String email}); final String name; final String email; - factory User.parse(Object? input) => $ack.parse(input); - static SchemaResult safeParse(Object? input) => - $ack.safeParse(input); - - factory User.fromMap(Map map) => $ack.parse(map); - factory User.fromJson(Map json) => $ack.parse(json); + factory User.parse(Object? input); + static SchemaResult safeParse(Object? input); + factory User.fromJson(Map json); + Map toJson(); + SchemaResult> safeToJson(); - Map toMap() => $ack.encode(this); - Map toJson() => Map.from(toMap()); + static final $ack = AckModelAdapter(/* ... */); } ``` -The generated class stores typed fields. It does not implement `Map` and does -not use a map-backed extension type. - -## Serialization - -Ack performs parsing and encoding. Generated classes map between Ack's validated -runtime values and stored Dart fields. +Constructors don't validate immediately. Use `parse` for untrusted input; +`toJson` and `safeToJson` validate a directly constructed model while encoding +it. Generated models don't implement `Map`, and there are no `fromMap` or +`toMap` aliases. -This preserves codecs such as: +Use `@AckType(name: 'MemberType')` to choose an exact class name. Custom names +must be unchanged UpperCamelCase identifiers. -- `Ack.datetime()` (`String` boundary to `DateTime` runtime); -- `Ack.uri()`; -- `Ack.duration()`; -- enum codecs; -- custom bidirectional codecs. +## Schema support -The generated `fromJson` and `toJson` method shapes are compatible with the -custom-type conventions used by `json_serializable`. Ack does not emit -`@JsonSerializable` and does not call its generator internals. +The generator supports objects, empty objects, scalar and collection roots, +literals, enums, defaults, additional properties, built-in and custom +bidirectional codecs, named nested models, aliases, named `Ack.lazy` recursion, +and same-library discriminated unions. Lists, sets, and maps stored by a model +are copied recursively into unmodifiable collections. -## Installation +Generation rejects shapes without a useful static, encodable model contract: -```yaml -dependencies: - ack: ^1.0.0 - ack_annotations: ^1.0.0 +- one-way `.transform()` calls; use `.codec()` with an encoder; +- nullable roots; +- `Ack.any()`, `Ack.anyOf()`, and bare `Ack.instance()`; +- anonymous inline object fields and unresolved dynamic schema factories; +- invalid names, generated-member collisions, and cross-library union branches. -dev_dependencies: - ack_generator: ^1.0.0 - build_runner: ^2.4.0 -``` - -## Supported declarations +Named model references work through direct imports, prefixes, and re-exports. +Nested conversion uses each model's public `$ack` adapter so codec runtime +values aren't parsed twice. -- Top-level schema variables -- Top-level schema getters +## JSON serialization -`@AckType()` is not supported on classes, instance members, or local variables. +Ack writes a dedicated `.ack.dart` part before `json_serializable`. A library +using both generators declares both parts: -## Planned model shapes - -- `Ack.object(...)` -> immutable `final class` -- Primitive and codec roots -> immutable value class -- `Ack.discriminated(...)` -> `sealed class` with `final` branches -- Nested named schemas -> nested generated model fields -- Lists and sets -> unmodifiable typed collections -- Additional properties -> explicit `additionalProperties` map - -## Current draft limitations +```dart +part 'account.ack.dart'; +part 'account.g.dart'; +``` -The rewrite is not yet validated. Before release it still needs: +Generated Ack models provide the conventional `fromJson` and `toJson` methods +that `json_serializable` uses for custom nested types, in the same library or +across imports. -- migration of all legacy extension-type fixtures; -- normalized graph integration in the analyzer; -- `Ack.lazy` and recursive-model analysis; -- default and one-way-transform capability tracking; -- complete typed map support; -- current analyzer/source_gen dependency validation; -- clean-build `json_serializable` integration fixtures; -- full build, analysis, and runtime test execution. +## Supported declarations -## Build commands +`@AckType()` can annotate top-level schema variables and top-level schema +getters. Classes, instance members, and local variables are rejected. -```bash -dart run build_runner build -dart run build_runner watch -``` +For design details and migration notes, see +[`docs/architecture/acktype-model-generation.md`](../../docs/architecture/acktype-model-generation.md). diff --git a/packages/ack_generator/analysis_options.yaml b/packages/ack_generator/analysis_options.yaml index f7f5cba5..6fe32757 100644 --- a/packages/ack_generator/analysis_options.yaml +++ b/packages/ack_generator/analysis_options.yaml @@ -2,13 +2,6 @@ # See /analysis_options.yaml at the workspace root. include: ../../analysis_options.yaml -analyzer: - errors: - # Suppresses deprecations from the EXTERNAL `analyzer` package (mid-migration - # from the `Element2` API back to `Element`). Not related to Ack's own API, - # which has no deprecations. Remove once ack_generator migrates off Element2. - deprecated_member_use: ignore - linter: rules: # Generator-specific relaxations for codegen-heavy code. diff --git a/packages/ack_generator/build.yaml b/packages/ack_generator/build.yaml index bbb32231..6ca5fa30 100644 --- a/packages/ack_generator/build.yaml +++ b/packages/ack_generator/build.yaml @@ -2,8 +2,7 @@ builders: ack_generator: import: "package:ack_generator/builder.dart" builder_factories: ["ackGenerator"] - build_extensions: {".dart": [".ack.g.part"]} + build_extensions: {".dart": [".ack.dart"]} auto_apply: dependents - build_to: cache - applies_builders: - - "source_gen:combining_builder" + build_to: source + runs_before: ["json_serializable"] diff --git a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart deleted file mode 100644 index cc88f728..00000000 --- a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart +++ /dev/null @@ -1,3573 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'package:analyzer/dart/analysis/results.dart'; -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/dart/ast/token.dart' show Keyword; -import 'package:analyzer/dart/element/element2.dart'; -import 'package:analyzer/dart/element/type.dart'; -import 'package:analyzer/dart/element/type_provider.dart'; -import 'package:logging/logging.dart'; -import 'package:source_gen/source_gen.dart'; - -import '../models/field_info.dart'; -import '../models/model_info.dart'; - -/// Logger for schema AST analysis warnings and diagnostics. -final _log = Logger('SchemaAstAnalyzer'); - -typedef _SchemaReference = ({String name, String? prefix}); -typedef _ListElementRef = ({ - MethodInvocation? invocation, - _SchemaReference? schemaRef, -}); -typedef _SchemaChainInfo = ({ - MethodInvocation? ackBase, - _SchemaReference? schemaReference, - bool isOptional, - bool isNullable, - bool wasTruncated, - MethodInvocation? transformInvocation, - DartType? transformOutputType, - String? transformOutputTypeString, -}); - -typedef _SchemaTypeMapping = ({ - DartType dartType, - String? listElementSchemaRef, - String? listElementDisplayTypeOverride, - String? listElementCastTypeOverride, - bool listElementIsCustomType, -}); -typedef _ListElementAnalysis = ({ - _SchemaTypeMapping mapping, - String elementRepresentationType, -}); -typedef _ResolvedSchemaElement = ({ - Element2 element, - LibraryImport? importDirective, -}); - -class _ResolvedSchemaReference { - final String schemaName; - final ModelInfo modelInfo; - final String? importPrefix; - final LibraryImport? importDirective; - final bool hasAckTypeAnnotation; - final Element2 sourceDeclaration; - final Uri? sourceLibraryUri; - - const _ResolvedSchemaReference({ - required this.schemaName, - required this.modelInfo, - required this.importPrefix, - required this.importDirective, - required this.hasAckTypeAnnotation, - required this.sourceDeclaration, - required this.sourceLibraryUri, - }); -} - -/// Analyzes schema variables by walking the AST -/// -/// This analyzer inspects the AST structure of schema definitions -/// (like `Ack.object({...})`) to extract field type information without -/// requiring const evaluation or string parsing. -class SchemaAstAnalyzer { - final Map _schemaVariableTypeCache = {}; - final Set _schemaVariableTypeStack = {}; - final Map _schemaReferenceCache = {}; - final Set _schemaReferenceResolutionStack = {}; - final Map> _classByNameCache = {}; - final Map> - _schemaVarByNameCache = {}; - final Map> - _schemaGetterByNameCache = {}; - - Map _classesByName(LibraryElement2 library) { - return _classByNameCache.putIfAbsent(library, () { - final map = {}; - for (final classElement in library.classes) { - final name = classElement.name3; - if (name != null) { - map.putIfAbsent(name, () => classElement); - } - } - return map; - }); - } - - Map _schemaVarsByName( - LibraryElement2 library, - ) { - return _schemaVarByNameCache.putIfAbsent(library, () { - final map = {}; - for (final variable in library.topLevelVariables) { - final name = variable.name3; - if (name != null) { - map.putIfAbsent(name, () => variable); - } - } - return map; - }); - } - - Map _schemaGettersByName(LibraryElement2 library) { - return _schemaGetterByNameCache.putIfAbsent(library, () { - final map = {}; - for (final getter in library.getters) { - if (getter.isSynthetic) continue; - - final name = getter.name3; - if (name != null) { - map.putIfAbsent(name, () => getter); - } - } - return map; - }); - } - - /// Analyzes a schema variable annotated with @AckType - /// - /// Walks the AST to extract type information from the schema definition. - ModelInfo? analyzeSchemaVariable( - TopLevelVariableElement2 element, { - String? customTypeName, - }) { - // Get the AST node for this variable using the fragment - final fragment = element.firstFragment; - final session = fragment.libraryFragment.element.session; - final library = element.library2; - - final parsedLibResult = session.getParsedLibraryByElement2(library); - - // getParsedLibraryByElement returns a SomeParsedLibraryResult which might not have getElementDeclaration - // We need to check if it's actually a ParsedLibraryResult - if (parsedLibResult is! ParsedLibraryResult) { - throw InvalidGenerationSource( - 'Could not get parsed library for "${element.name3}"', - element: element, - ); - } - - final declaration = parsedLibResult.getFragmentDeclaration(fragment); - if (declaration == null || declaration.node is! VariableDeclaration) { - throw InvalidGenerationSource( - 'Could not find variable declaration for "${element.name3}"', - element: element, - ); - } - - final varDecl = declaration.node as VariableDeclaration; - final initializer = varDecl.initializer; - - if (initializer == null) { - throw InvalidGenerationSource( - 'Schema variable "${element.name3}" must have an initializer', - element: element, - ); - } - - if (initializer is MethodInvocation) { - final model = _parseSchemaFromAST( - element.name3!, - initializer, - element, - customTypeName: customTypeName, - ); - if (model == null) return null; - return _withSchemaIdentity(model, element); - } - - final schemaReference = _extractSchemaReference(initializer); - if (schemaReference != null) { - final model = _parseSchemaAlias( - variableName: element.name3!, - reference: schemaReference, - element: element, - customTypeName: customTypeName, - ); - return _withSchemaIdentity(model, element); - } - - throw InvalidGenerationSource( - 'Schema variable "${element.name3}" must be initialized with a schema ' - '(e.g., Ack.object({...}))', - element: element, - ); - } - - /// Analyzes a top-level schema getter annotated with @AckType. - /// - /// Supported forms: - /// - `AckSchema get userSchema => Ack.object({...});` - /// - `AckSchema get userSchema { return Ack.object({...}); }` - ModelInfo? analyzeSchemaGetter( - GetterElement element, { - String? customTypeName, - }) { - final fragment = element.firstFragment; - final session = fragment.libraryFragment.element.session; - final library = element.library2; - - final parsedLibResult = session.getParsedLibraryByElement2(library); - if (parsedLibResult is! ParsedLibraryResult) { - throw InvalidGenerationSource( - 'Could not get parsed library for getter "${element.name3}"', - element: element, - ); - } - - final declaration = parsedLibResult.getFragmentDeclaration(fragment); - if (declaration == null || declaration.node is! FunctionDeclaration) { - throw InvalidGenerationSource( - 'Could not find getter declaration for "${element.name3}"', - element: element, - ); - } - - final getterDecl = declaration.node as FunctionDeclaration; - if (!getterDecl.isGetter) { - throw InvalidGenerationSource( - '"${element.name3}" is not a getter declaration', - element: element, - ); - } - - final body = getterDecl.functionExpression.body; - Expression? schemaExpression; - - if (body is ExpressionFunctionBody) { - schemaExpression = body.expression; - } else if (body is BlockFunctionBody) { - final statements = body.block.statements; - if (statements.length != 1 || statements.first is! ReturnStatement) { - throw InvalidGenerationSource( - 'Schema getter "${element.name3}" must return a schema expression', - element: element, - todo: - 'Use an expression body or a single return statement (e.g., return Ack.object({...});).', - ); - } - - final returnStatement = statements.first as ReturnStatement; - schemaExpression = returnStatement.expression; - } - - if (schemaExpression is MethodInvocation) { - final model = _parseSchemaFromAST( - element.name3!, - schemaExpression, - element, - customTypeName: customTypeName, - ); - if (model == null) return null; - return _withSchemaIdentity(model, element); - } - - final schemaReference = _extractSchemaReference(schemaExpression); - if (schemaReference != null) { - final model = _parseSchemaAlias( - variableName: element.name3!, - reference: schemaReference, - element: element, - customTypeName: customTypeName, - ); - return _withSchemaIdentity(model, element); - } - - throw InvalidGenerationSource( - 'Schema getter "${element.name3}" must return an Ack schema invocation or schema reference', - element: element, - todo: - 'Return a schema expression such as Ack.object({...}), Ack.string(), or another @AckType schema variable/getter.', - ); - } - - ModelInfo _parseSchemaAlias({ - required String variableName, - required _SchemaReference reference, - required Element2 element, - String? customTypeName, - }) { - final resolved = _resolveSchemaReference(reference, element); - if (resolved == null) { - final referenceLabel = _formatSchemaReference(reference); - throw InvalidGenerationSource( - 'Could not resolve schema alias "$variableName" ' - 'to "$referenceLabel"', - element: element, - ); - } - - final aliasTypeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - final sourceModel = resolved.modelInfo; - - return ModelInfo( - className: aliasTypeName, - schemaClassName: variableName, - fields: sourceModel.fields, - additionalProperties: sourceModel.additionalProperties, - discriminatorKey: sourceModel.discriminatorKey, - discriminatorValue: sourceModel.discriminatorValue, - subtypeNames: sourceModel.subtypeNames, - schemaIdentity: - sourceModel.schemaIdentity ?? - _declarationVisitKey(resolved.sourceDeclaration), - discriminatedBaseClassName: sourceModel.discriminatedBaseClassName, - representationType: sourceModel.representationType, - - isNullableSchema: sourceModel.isNullableSchema, - ); - } - - /// Parses a schema from a MethodInvocation AST node - ModelInfo? _parseSchemaFromAST( - String variableName, - MethodInvocation invocation, - Element2 element, { - String? customTypeName, - }) { - final chain = _analyzeSchemaChain(invocation); - final baseInvocation = chain.ackBase; - final schemaReference = chain.schemaReference; - - if (schemaReference != null) { - return _parseSchemaReferenceChain( - variableName: variableName, - schemaReference: schemaReference, - element: element, - customTypeName: customTypeName, - isNullable: chain.isNullable, - transformOutputTypeString: _requireTransformOutputType( - chain, - element, - contextLabel: 'Schema "$variableName"', - ), - ); - } - - if (baseInvocation == null) { - throw InvalidGenerationSource( - 'Schema must be an Ack.xxx() method call (e.g., Ack.object(), Ack.string()) or a schema reference.', - element: element, - ); - } - - final methodName = baseInvocation.methodName.name; - final isNullable = chain.isNullable; - final transformOutputTypeString = _requireTransformOutputType( - chain, - element, - contextLabel: 'Schema "$variableName"', - ); - _throwIfUnsupportedTransformedBaseSchema( - schemaMethod: methodName, - transformOutputTypeString: transformOutputTypeString, - element: element, - contextLabel: 'Schema "$variableName"', - ); - - late final ModelInfo model; - switch (methodName) { - case 'object': - model = _parseObjectSchema( - variableName, - baseInvocation, - invocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'string': - model = _parseStringSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'integer': - model = _parseIntegerSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'double': - model = _parseDoubleSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'boolean': - model = _parseBooleanSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'list': - final typeProvider = element.library2?.typeProvider; - if (typeProvider == null) { - throw InvalidGenerationSource( - 'Could not get type provider for library', - element: element, - ); - } - final listElementAnalysis = _analyzeListElement( - baseInvocation, - element, - typeProvider, - ); - model = _parseListSchema( - variableName, - element, - isNullable: isNullable, - listElementAnalysis: listElementAnalysis, - customTypeName: customTypeName, - ); - break; - case 'literal': - model = _parseLiteralSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'enumString': - model = _parseEnumStringSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'enumValues': - model = _parseEnumValuesSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'uri': - model = _parseRepresentationSchema( - variableName, - element, - representationType: 'Uri', - isNullable: isNullable, - - customTypeName: customTypeName, - ); - break; - case 'date': - case 'datetime': - model = _parseRepresentationSchema( - variableName, - element, - representationType: 'DateTime', - isNullable: isNullable, - - customTypeName: customTypeName, - ); - break; - case 'duration': - model = _parseRepresentationSchema( - variableName, - element, - representationType: 'Duration', - isNullable: isNullable, - - customTypeName: customTypeName, - ); - break; - case 'discriminated': - model = _parseDiscriminatedSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - default: - throw InvalidGenerationSource( - 'Unsupported schema type for @AckType: Ack.$methodName(). ' - 'Supported types: object, string, integer, double, boolean, list, literal, enumString, enumValues, uri, date, datetime, duration, discriminated', - element: element, - ); - } - - if (transformOutputTypeString != null) { - return _withRepresentationType(model, transformOutputTypeString); - } - - return model; - } - - ModelInfo _parseSchemaReferenceChain({ - required String variableName, - required _SchemaReference schemaReference, - required Element2 element, - String? customTypeName, - required bool isNullable, - required String? transformOutputTypeString, - }) { - final resolved = _resolveSchemaReference(schemaReference, element); - if (resolved == null) { - final referenceLabel = _formatSchemaReference(schemaReference); - throw InvalidGenerationSource( - 'Could not resolve schema reference "$referenceLabel" for "$variableName".', - element: element, - ); - } - - if (transformOutputTypeString != null) { - _throwIfUnsupportedTransformedReferencedSchema( - resolved: resolved, - element: element, - contextLabel: 'Schema "$variableName"', - ); - } - - final aliasTypeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - final sourceModel = resolved.modelInfo; - - return ModelInfo( - className: aliasTypeName, - schemaClassName: variableName, - fields: sourceModel.fields, - additionalProperties: sourceModel.additionalProperties, - discriminatorKey: sourceModel.discriminatorKey, - discriminatorValue: sourceModel.discriminatorValue, - subtypeNames: sourceModel.subtypeNames, - schemaIdentity: - sourceModel.schemaIdentity ?? - _declarationVisitKey(resolved.sourceDeclaration), - discriminatedBaseClassName: sourceModel.discriminatedBaseClassName, - representationType: - transformOutputTypeString ?? sourceModel.representationType, - isNullableSchema: isNullable || sourceModel.isNullableSchema, - ); - } - - /// Parses Ack.object() schema - ModelInfo _parseObjectSchema( - String variableName, - MethodInvocation baseInvocation, - MethodInvocation fullInvocation, - Element2 element, { - required bool isNullable, - String? customTypeName, - }) { - // Extract the properties map from the first argument - final args = baseInvocation.argumentList.arguments; - if (args.isEmpty) { - throw InvalidGenerationSource( - 'Ack.object() requires a properties map argument', - element: element, - ); - } - - final firstArg = args.first; - if (firstArg is! SetOrMapLiteral) { - throw InvalidGenerationSource( - 'Ack.object() first argument must be a map literal', - element: element, - ); - } - - // Extract fields from the map literal - final fields = _extractFieldsFromMapLiteral(firstArg, element); - - // Check if additionalProperties is enabled via passthrough() or parameter - final hasAdditionalProperties = _hasAdditionalPropertiesFromInvocation( - baseInvocation, - fullInvocation, - ); - - // Generate extension type name from variable name or custom override - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: fields, - additionalProperties: hasAdditionalProperties, - isNullableSchema: isNullable, - ); - } - - /// Parses Ack.discriminated(...) schema for @AckType bases. - /// - /// Current constraints: - /// - Base cannot be nullable - /// - `schemas` must be a non-empty map literal - /// - Branches must be top-level schema variable/getter references - /// - Branches must be @AckType object schemas and non-nullable - /// - Branch discriminator properties may be omitted or compatible - /// - Each branch schema can only appear once per discriminated base - /// - Branches must be declared in the same library - ModelInfo _parseDiscriminatedSchema( - String variableName, - MethodInvocation baseInvocation, - Element2 element, { - required bool isNullable, - String? customTypeName, - }) { - if (isNullable) { - throw InvalidGenerationSource( - 'Ack.discriminated(...) cannot be nullable when used with @AckType.', - element: element, - todo: 'Remove `.nullable()` from the discriminated base schema.', - ); - } - - String? discriminatorKey; - SetOrMapLiteral? schemasLiteral; - - for (final argument in baseInvocation.argumentList.arguments) { - if (argument is! NamedExpression) continue; - - final name = argument.name.label.name; - if (name == 'discriminatorKey') { - final expression = argument.expression; - if (expression is! SimpleStringLiteral) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): `discriminatorKey` must be a string literal.', - element: element, - ); - } - discriminatorKey = expression.value; - } else if (name == 'schemas') { - final expression = argument.expression; - if (expression is! SetOrMapLiteral) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): `schemas` must be a map literal.', - element: element, - ); - } - // Check actual content: if non-empty, entries must be MapLiteralEntry. - // We avoid relying on `isMap` since it may return false in unresolved - // contexts (e.g., build_test / source_gen pipelines). - if (expression.elements.isNotEmpty && - expression.elements.first is! MapLiteralEntry) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): `schemas` must be a map literal.', - element: element, - ); - } - schemasLiteral = expression; - } - } - - final resolvedDiscriminatorKey = discriminatorKey; - if (resolvedDiscriminatorKey == null || resolvedDiscriminatorKey.isEmpty) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): missing required `discriminatorKey` string literal.', - element: element, - ); - } - - final resolvedSchemasLiteral = schemasLiteral; - if (resolvedSchemasLiteral == null) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): missing required `schemas` map literal.', - element: element, - ); - } - if (resolvedSchemasLiteral.elements.isEmpty) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): `schemas` must contain at least one branch.', - element: element, - ); - } - - final currentLibraryUri = element.library2?.uri; - final subtypeNames = {}; - - for (final schemaEntry in resolvedSchemasLiteral.elements) { - if (schemaEntry is! MapLiteralEntry) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): `schemas` must contain key/value map entries.', - element: element, - ); - } - - final keyExpression = schemaEntry.key; - if (keyExpression is! SimpleStringLiteral) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): discriminator values in `schemas` must be string literals.', - element: element, - ); - } - - final discriminatorValue = keyExpression.value; - if (subtypeNames.containsKey(discriminatorValue)) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): duplicate discriminator value "$discriminatorValue".', - element: element, - ); - } - - final branchReference = _extractSchemaReference(schemaEntry.value); - if (branchReference == null) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): branch "$discriminatorValue" must reference a top-level schema variable/getter.', - element: element, - todo: - 'Extract inline expressions to a top-level @AckType schema variable/getter and reference it.', - ); - } - - final resolvedBranch = _resolveSchemaReference(branchReference, element); - if (resolvedBranch == null) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): could not resolve branch reference "${_formatSchemaReference(branchReference)}".', - element: element, - ); - } - - if (!resolvedBranch.hasAckTypeAnnotation) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" must be annotated with @AckType.', - element: element, - ); - } - - if (resolvedBranch.modelInfo.representationType != kMapType) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" must be an object schema (Map representation).', - element: element, - ); - } - - if (resolvedBranch.modelInfo.isNullableSchema) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" cannot be nullable.', - element: element, - ); - } - - if (resolvedBranch.sourceLibraryUri != currentLibraryUri) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" must be declared in the same library as "$variableName".', - element: element, - ); - } - - if (resolvedBranch.modelInfo.isDiscriminatedBase) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" is itself a discriminated base. ' - 'Nested discriminated unions are not supported.', - element: element, - todo: - 'Use a plain Ack.object(...) schema for each branch, not another Ack.discriminated(...).', - ); - } - - final discriminatorCompatibilityError = - _analyzeDiscriminatorPropertyCompatibility( - declaration: resolvedBranch.sourceDeclaration, - discriminatorKey: resolvedDiscriminatorKey, - discriminatorValue: discriminatorValue, - visitedDeclarations: {}, - ); - - if (discriminatorCompatibilityError != null) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" ' - '$discriminatorCompatibilityError', - element: element, - todo: - 'Omit "$resolvedDiscriminatorKey" from the branch schema, or make it accept "$discriminatorValue".', - ); - } - - subtypeNames[discriminatorValue] = - resolvedBranch.modelInfo.schemaClassName; - } - - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: const [], - representationType: kMapType, - isNullableSchema: false, - discriminatorKey: resolvedDiscriminatorKey, - subtypeNames: subtypeNames, - ); - } - - String? _analyzeDiscriminatorPropertyCompatibility({ - required Element2 declaration, - required String discriminatorKey, - required String discriminatorValue, - required Set visitedDeclarations, - }) { - final declarationKey = _declarationVisitKey(declaration); - if (!visitedDeclarations.add(declarationKey)) { - return 'has a recursive discriminator property reference that cannot be analyzed.'; - } - - final schemaExpression = _extractSchemaExpressionForDeclaration( - declaration, - ); - if (schemaExpression == null) { - return 'has a discriminator property that could not be analyzed.'; - } - - return _analyzeDiscriminatorSchemaExpressionCompatibility( - expression: schemaExpression, - contextElement: declaration, - discriminatorKey: discriminatorKey, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - String? _analyzeDiscriminatorSchemaExpressionCompatibility({ - required Expression expression, - required Element2 contextElement, - required String discriminatorKey, - required String discriminatorValue, - required Set visitedDeclarations, - }) { - if (expression is MethodInvocation) { - final schemaReferenceBase = _findSchemaVariableBase(expression); - if (schemaReferenceBase != null) { - final resolvedBranch = _resolveSchemaReference( - schemaReferenceBase, - contextElement, - ); - if (resolvedBranch == null) { - return 'has a discriminator property reference that could not be resolved.'; - } - - return _analyzeDiscriminatorPropertyCompatibility( - declaration: resolvedBranch.sourceDeclaration, - discriminatorKey: discriminatorKey, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - final baseInvocation = _findBaseAckInvocation(expression); - if (baseInvocation == null || - baseInvocation.methodName.name != 'object') { - return _analyzeDiscriminatorPropertySchemaExpression( - expression: expression, - contextElement: contextElement, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - return _analyzeDiscriminatorObjectInvocation( - objectInvocation: baseInvocation, - contextElement: contextElement, - discriminatorKey: discriminatorKey, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - final schemaReference = _extractSchemaReference(expression); - if (schemaReference == null) { - return 'has a discriminator property that could not be analyzed.'; - } - final resolvedBranch = _resolveSchemaReference( - schemaReference, - contextElement, - ); - if (resolvedBranch == null) { - return 'has a discriminator property reference that could not be resolved.'; - } - - return _analyzeDiscriminatorPropertyCompatibility( - declaration: resolvedBranch.sourceDeclaration, - discriminatorKey: discriminatorKey, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - String? _analyzeDiscriminatorObjectInvocation({ - required MethodInvocation objectInvocation, - required Element2 contextElement, - required String discriminatorKey, - required String discriminatorValue, - required Set visitedDeclarations, - }) { - final args = objectInvocation.argumentList.arguments; - if (args.isEmpty) return null; - - final firstArg = args.first; - if (firstArg is! SetOrMapLiteral) return null; - - for (final mapElement in firstArg.elements) { - if (mapElement is! MapLiteralEntry) continue; - final keyExpression = mapElement.key; - if (keyExpression is! SimpleStringLiteral || - keyExpression.value != discriminatorKey) { - continue; - } - - return _analyzeDiscriminatorPropertySchemaExpression( - expression: mapElement.value, - contextElement: contextElement, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - return null; - } - - String? _analyzeDiscriminatorPropertySchemaExpression({ - required Expression expression, - required Element2 contextElement, - required String discriminatorValue, - required Set visitedDeclarations, - }) { - if (expression is MethodInvocation) { - final schemaReferenceBase = _findSchemaVariableBase(expression); - if (schemaReferenceBase != null) { - final resolved = _resolveSchemaReference( - schemaReferenceBase, - contextElement, - ); - if (resolved == null) { - return 'has a discriminator property reference that could not be resolved.'; - } - - return _analyzeDiscriminatorPropertySchemaDeclaration( - declaration: resolved.sourceDeclaration, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - final baseInvocation = _findBaseAckInvocation(expression); - if (baseInvocation == null) { - return 'has a discriminator property that could not be analyzed.'; - } - - final schemaMethod = baseInvocation.methodName.name; - if (schemaMethod == 'literal') { - if (!_hasOnlyNonRestrictiveDiscriminatorMethods( - expression, - baseInvocation, - baseMethod: 'literal', - )) { - return 'has discriminator property schema ${expression.toSource()} that could not be proven to accept "$discriminatorValue".'; - } - - final literalValue = _extractSingleStringArgument(baseInvocation); - if (literalValue == null) { - return 'has a discriminator literal that is not a string literal.'; - } - if (literalValue == discriminatorValue) { - return null; - } - return 'has discriminator literal "$literalValue", but is mapped as "$discriminatorValue".'; - } - - if (schemaMethod == 'enumString') { - if (!_hasOnlyNonRestrictiveDiscriminatorMethods( - expression, - baseInvocation, - baseMethod: 'enumString', - )) { - return 'has discriminator property schema ${expression.toSource()} that could not be proven to accept "$discriminatorValue".'; - } - - final allowedValues = _extractStringListArgument(baseInvocation); - if (allowedValues == null) { - return 'has an Ack.enumString(...) discriminator that is not a string list literal.'; - } - if (allowedValues.contains(discriminatorValue)) { - return null; - } - return 'has discriminator enum values ${allowedValues.map((v) => '"$v"').join(', ')}, ' - 'which do not include "$discriminatorValue".'; - } - - return 'has discriminator property schema ${expression.toSource()} that could not be proven to accept "$discriminatorValue".'; - } - - final schemaReference = _extractSchemaReference(expression); - if (schemaReference == null) { - return 'has a discriminator property that could not be analyzed.'; - } - final resolved = _resolveSchemaReference(schemaReference, contextElement); - if (resolved == null) { - return 'has a discriminator property reference that could not be resolved.'; - } - - return _analyzeDiscriminatorPropertySchemaDeclaration( - declaration: resolved.sourceDeclaration, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - String? _analyzeDiscriminatorPropertySchemaDeclaration({ - required Element2 declaration, - required String discriminatorValue, - required Set visitedDeclarations, - }) { - final declarationKey = _declarationVisitKey(declaration); - if (!visitedDeclarations.add(declarationKey)) { - return 'has a recursive discriminator property reference that cannot be analyzed.'; - } - - final schemaExpression = _extractSchemaExpressionForDeclaration( - declaration, - ); - if (schemaExpression == null) { - return 'has a discriminator property reference that could not be analyzed.'; - } - - return _analyzeDiscriminatorPropertySchemaExpression( - expression: schemaExpression, - contextElement: declaration, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - String? _extractSingleStringArgument(MethodInvocation invocation) { - final arguments = invocation.argumentList.arguments; - if (arguments.length != 1 || arguments.first is! SimpleStringLiteral) { - return null; - } - return (arguments.first as SimpleStringLiteral).value; - } - - List? _extractStringListArgument(MethodInvocation invocation) { - final arguments = invocation.argumentList.arguments; - if (arguments.length != 1 || arguments.first is! ListLiteral) { - return null; - } - - final values = []; - for (final element in (arguments.first as ListLiteral).elements) { - if (element is! SimpleStringLiteral) return null; - values.add(element.value); - } - return values; - } - - bool _hasOnlyNonRestrictiveDiscriminatorMethods( - MethodInvocation expression, - MethodInvocation baseInvocation, { - required String baseMethod, - }) { - final (chain, _) = _collectMethodChain(expression); - const allowedMethods = {'optional', 'nullable', 'describe'}; - - for (final invocation in chain) { - final methodName = invocation.methodName.name; - if (identical(invocation, baseInvocation)) { - return methodName == baseMethod; - } - if (!allowedMethods.contains(methodName)) { - return false; - } - } - - return true; - } - - String _declarationVisitKey(Element2 declaration) { - final libraryUri = declaration.library2?.uri.toString() ?? 'unknown'; - final name = declaration.name3 ?? ''; - return '$libraryUri::$name'; - } - - ModelInfo _withSchemaIdentity(ModelInfo model, Element2 declaration) { - if (model.schemaIdentity != null) { - return model; - } - - return ModelInfo( - className: model.className, - schemaClassName: model.schemaClassName, - description: model.description, - fields: model.fields, - additionalProperties: model.additionalProperties, - discriminatorKey: model.discriminatorKey, - discriminatorValue: model.discriminatorValue, - subtypeNames: model.subtypeNames, - schemaIdentity: _declarationVisitKey(declaration), - discriminatedBaseClassName: model.discriminatedBaseClassName, - representationType: model.representationType, - isNullableSchema: model.isNullableSchema, - ); - } - - Expression? _extractSchemaExpressionForDeclaration(Element2 declaration) { - if (declaration is TopLevelVariableElement2) { - final fragment = declaration.firstFragment; - final session = fragment.libraryFragment.element.session; - final library = declaration.library2; - final parsedLibResult = session.getParsedLibraryByElement2(library); - if (parsedLibResult is! ParsedLibraryResult) { - return null; - } - - final variableDeclaration = parsedLibResult.getFragmentDeclaration( - fragment, - ); - if (variableDeclaration == null || - variableDeclaration.node is! VariableDeclaration) { - return null; - } - - final variableNode = variableDeclaration.node as VariableDeclaration; - return variableNode.initializer; - } - - if (declaration is GetterElement) { - final fragment = declaration.firstFragment; - final session = fragment.libraryFragment.element.session; - final library = declaration.library2; - final parsedLibResult = session.getParsedLibraryByElement2(library); - if (parsedLibResult is! ParsedLibraryResult) { - return null; - } - - final getterDeclaration = parsedLibResult.getFragmentDeclaration( - fragment, - ); - if (getterDeclaration == null || - getterDeclaration.node is! FunctionDeclaration) { - return null; - } - - final functionDeclaration = getterDeclaration.node as FunctionDeclaration; - if (!functionDeclaration.isGetter) return null; - - final body = functionDeclaration.functionExpression.body; - if (body is ExpressionFunctionBody) { - return body.expression; - } - - if (body is BlockFunctionBody) { - final statements = body.block.statements; - if (statements.length != 1 || statements.first is! ReturnStatement) { - return null; - } - - final returnStatement = statements.first as ReturnStatement; - return returnStatement.expression; - } - } - - return null; - } - - bool _hasAdditionalPropertiesFromInvocation( - MethodInvocation baseInvocation, - MethodInvocation fullInvocation, - ) { - bool hasAdditionalProperties = false; - - // First check for named parameter in the base Ack.object() call - for (final arg in baseInvocation.argumentList.arguments) { - if (arg is NamedExpression && - arg.name.label.name == 'additionalProperties') { - if (arg.expression is BooleanLiteral) { - hasAdditionalProperties = (arg.expression as BooleanLiteral).value; - } - } - } - - // Then walk forward from fullInvocation to find passthrough() in the chain - // The chain looks like: Ack.object({...}).passthrough() - // fullInvocation is the outermost call (passthrough if present) - // We need to check if passthrough() was called - MethodInvocation? current = fullInvocation; - while (current != null && current != baseInvocation) { - final methodName = current.methodName.name; - - if (methodName == 'passthrough') { - hasAdditionalProperties = true; - break; - } - - // Move down the chain towards the base - final target = current.target; - if (target is MethodInvocation) { - current = target; - } else { - break; - } - } - - return hasAdditionalProperties; - } - - /// Extracts field information from a map literal - List _extractFieldsFromMapLiteral( - SetOrMapLiteral mapLiteral, - Element2 element, - ) { - final fields = []; - - for (final mapElement in mapLiteral.elements) { - if (mapElement is! MapLiteralEntry) continue; - - final key = mapElement.key; - final value = mapElement.value; - - // Key should be a string literal - if (key is! SimpleStringLiteral) { - throw InvalidGenerationSource( - 'Map keys must be string literals in schema definition', - element: element, - ); - } - - final fieldName = key.value; - - // Validate that the field name is a valid Dart identifier - _validateFieldName(fieldName, element); - - final fieldInfo = _parseFieldValue(fieldName, value, element); - if (fieldInfo != null) { - fields.add(fieldInfo); - } - } - - return fields; - } - - /// Parses a field's value expression to determine its type - FieldInfo? _parseFieldValue( - String fieldName, - Expression value, - Element2 element, - ) { - // Handle Ack.xxx() method calls - if (value is MethodInvocation) { - final schemaReferenceField = _parseSchemaReferenceMethod( - fieldName, - value, - element, - ); - if (schemaReferenceField != null) { - return schemaReferenceField; - } - return _parseSchemaMethod(fieldName, value, element); - } - - // Handle references to other schema variables (for nested objects) - final schemaReference = _extractSchemaReference(value); - if (schemaReference != null) { - return _buildFieldInfoForSchemaReference( - fieldName: fieldName, - schemaReference: schemaReference, - element: element, - ); - } - - return null; - } - - FieldInfo? _parseSchemaReferenceMethod( - String fieldName, - MethodInvocation invocation, - Element2 element, - ) { - final chain = _analyzeSchemaChain(invocation); - final schemaReference = chain.schemaReference; - if (schemaReference == null) { - return null; - } - - return _buildFieldInfoForSchemaReference( - fieldName: fieldName, - schemaReference: schemaReference, - element: element, - isRequired: !chain.isOptional, - isNullable: chain.isNullable, - transformedOutputType: chain.transformOutputType, - transformedRepresentationType: _requireTransformOutputType( - chain, - element, - contextLabel: 'Field "$fieldName"', - ), - ); - } - - FieldInfo _buildFieldInfoForSchemaReference({ - required String fieldName, - required _SchemaReference schemaReference, - required Element2 element, - bool isRequired = true, - bool isNullable = false, - DartType? transformedOutputType, - String? transformedRepresentationType, - }) { - final schemaVarName = schemaReference.name; - final library = element.library2; - - final typeProvider = library?.typeProvider; - if (typeProvider == null) { - throw InvalidGenerationSource( - 'Could not get type provider for library', - element: element, - ); - } - - final resolvedReference = _resolveSchemaReference(schemaReference, element); - if (resolvedReference == null) { - throw InvalidGenerationSource( - 'Could not resolve schema reference "$schemaVarName" for field ' - '"$fieldName".', - element: element, - todo: - 'Ensure "$schemaVarName" exists, is imported, and is declared as an Ack schema.', - ); - } - - final hasTransformOverride = transformedRepresentationType != null; - if (hasTransformOverride) { - _throwIfUnsupportedTransformedReferencedSchema( - resolved: resolvedReference, - element: element, - contextLabel: 'Field "$fieldName"', - ); - } - - final representationType = - transformedRepresentationType ?? - resolvedReference.modelInfo.representationType; - final visibleRepresentationType = _resolveVisibleRepresentationType( - representationType: representationType, - resolved: resolvedReference, - contextElement: element, - ); - final hasTypedReference = - resolvedReference.hasAckTypeAnnotation && !hasTransformOverride; - final isObjectRepresentation = representationType == kMapType; - if (isObjectRepresentation && !hasTypedReference) { - throw InvalidGenerationSource( - 'Field "$fieldName" references object schema "$schemaVarName" ' - 'without @AckType. This would fall back to Map.', - element: element, - todo: - 'Annotate "$schemaVarName" with @AckType() so the generator can emit a typed wrapper.', - ); - } - - final mappedType = - transformedOutputType ?? - _representationTypeToDartType(representationType, typeProvider); - final typeBaseName = hasTypedReference - ? _qualifyTypeBaseName( - resolvedReference.modelInfo.className, - resolvedReference.importPrefix, - ) - : null; - final rawDisplayTypeOverride = - !hasTypedReference && - !mappedType.isDartCoreString && - !mappedType.isDartCoreInt && - !mappedType.isDartCoreDouble && - !mappedType.isDartCoreBool && - !mappedType.isDartCoreNum && - !mappedType.isDartCoreList && - !mappedType.isDartCoreMap && - !mappedType.isDartCoreSet - ? visibleRepresentationType - : null; - - return FieldInfo( - name: fieldName, - jsonKey: fieldName, - type: mappedType, - isRequired: isRequired, - isNullable: isNullable, - constraints: [], - nestedSchemaRef: hasTypedReference ? schemaVarName : null, - displayTypeOverride: hasTypedReference - ? '${typeBaseName}Type' - : rawDisplayTypeOverride, - nestedSchemaCastTypeOverride: hasTypedReference - ? visibleRepresentationType - : null, - ); - } - - /// Parses a schema method call (e.g., Ack.string(), Ack.integer().optional()) - FieldInfo _parseSchemaMethod( - String fieldName, - MethodInvocation invocation, - Element2 element, - ) { - final chain = _analyzeSchemaChain(invocation); - final baseInvocation = chain.ackBase; - - if (baseInvocation == null) { - if (chain.wasTruncated) { - throw InvalidGenerationSource( - 'Field "$fieldName" schema method chain exceeded max depth of 20. ' - '@AckType requires statically analyzable schema chains.', - element: element, - todo: - 'Reduce the chaining depth or extract part of the schema into a named variable.', - ); - } - - throw InvalidGenerationSource( - 'Could not determine schema type for field "$fieldName"', - element: element, - ); - } - - final schemaMethod = baseInvocation.methodName.name; - final transformOutputTypeString = _requireTransformOutputType( - chain, - element, - contextLabel: 'Field "$fieldName"', - ); - _throwIfUnsupportedTransformedBaseSchema( - schemaMethod: schemaMethod, - transformOutputTypeString: transformOutputTypeString, - element: element, - contextLabel: 'Field "$fieldName"', - ); - - if (schemaMethod == 'object') { - throw InvalidGenerationSource( - 'Field "$fieldName" uses anonymous inline Ack.object(...). ' - 'Strict typed generation requires a named schema reference.', - element: element, - todo: - 'Extract this inline object schema into a top-level @AckType() variable and reference it by name.', - ); - } - - final typeProvider = element.library2!.typeProvider; - final listElementAnalysis = - schemaMethod == 'list' && transformOutputTypeString == null - ? _analyzeListElement(baseInvocation, element, typeProvider) - : null; - // Map schema type to Dart type (passing full invocation for context) - // Also captures schema variable reference and list metadata for typed wrappers. - final mappedType = - listElementAnalysis?.mapping ?? - _mapSchemaTypeToDartType(invocation, element); - - String? displayTypeOverride; - var collectionElementDisplayTypeOverride = - mappedType.listElementDisplayTypeOverride; - - if (schemaMethod == 'enumValues') { - displayTypeOverride = _extractEnumTypeNameFromInvocation(baseInvocation); - } else if (schemaMethod == 'list') { - collectionElementDisplayTypeOverride = - _extractListEnumElementTypeName(baseInvocation) ?? - collectionElementDisplayTypeOverride; - } - - return FieldInfo( - name: fieldName, - jsonKey: fieldName, - type: mappedType.dartType, - isRequired: !chain.isOptional, - isNullable: chain.isNullable, - constraints: [], - listElementSchemaRef: mappedType.listElementSchemaRef, - displayTypeOverride: - displayTypeOverride ?? - (transformOutputTypeString != null && - !mappedType.dartType.isDartCoreString && - !mappedType.dartType.isDartCoreInt && - !mappedType.dartType.isDartCoreDouble && - !mappedType.dartType.isDartCoreBool && - !mappedType.dartType.isDartCoreNum && - !mappedType.dartType.isDartCoreList && - !mappedType.dartType.isDartCoreMap && - !mappedType.dartType.isDartCoreSet - ? transformOutputTypeString - : null), - collectionElementDisplayTypeOverride: - collectionElementDisplayTypeOverride, - collectionElementCastTypeOverride: mappedType.listElementCastTypeOverride, - collectionElementIsCustomType: mappedType.listElementIsCustomType, - ); - } - - /// Maps a schema method invocation to a Dart type and optional schema reference - /// - /// Returns a record containing the field [DartType] plus list metadata used - /// by the type builder for typed list getters. - _SchemaTypeMapping _mapSchemaTypeToDartType( - MethodInvocation invocation, - Element2 element, - ) { - final chain = _analyzeSchemaChain(invocation); - final schemaReference = chain.schemaReference; - final baseInvocation = chain.ackBase; - - // We need to get the type provider from the element's library - final library = element.library2!; - final typeProvider = library.typeProvider; - final transformOutputTypeString = _requireTransformOutputType( - chain, - element, - contextLabel: 'Schema expression', - ); - - if (schemaReference != null) { - return _resolveSchemaVariableType( - schemaReference, - element, - typeProvider, - transformedOutputType: chain.transformOutputType, - transformedRepresentationType: transformOutputTypeString, - ); - } - - if (baseInvocation == null) { - throw InvalidGenerationSource( - 'Could not determine schema type for "${invocation.toSource()}".', - element: element, - ); - } - - final schemaMethod = baseInvocation.methodName.name; - _throwIfUnsupportedTransformedBaseSchema( - schemaMethod: schemaMethod, - transformOutputTypeString: transformOutputTypeString, - element: element, - contextLabel: 'Schema expression', - ); - - if (transformOutputTypeString != null) { - return ( - dartType: chain.transformOutputType ?? typeProvider.dynamicType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - } - - switch (schemaMethod) { - case 'string': - return ( - dartType: typeProvider.stringType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'integer': - return ( - dartType: typeProvider.intType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'double': - return ( - dartType: typeProvider.doubleType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'boolean': - return ( - dartType: typeProvider.boolType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'list': - // Extract element type from Ack.list(elementSchema) argument - // This may return a schema variable reference for nested schemas - return _analyzeListElement( - baseInvocation, - element, - typeProvider, - ).mapping; - case 'object': - // Nested objects represented as Map - // Note: Using dynamicType for analyzer; generated code uses Object? - return ( - dartType: typeProvider.mapType( - typeProvider.stringType, - typeProvider.dynamicType, - ), - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'enumString': - case 'literal': - return ( - dartType: typeProvider.stringType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'enumValues': - final resolvedType = _resolveEnumValuesType( - baseInvocation, - library: library, - ); - if (resolvedType != null) { - return ( - dartType: resolvedType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - } - // Fallback to `dynamic` if the enum type can't be resolved. - // This avoids incorrectly assuming `String` when EnumSchema.parse() - // returns the enum value type T. - _log.warning( - 'Could not resolve enum type for Ack.enumValues(); falling back to dynamic.', - ); - return ( - dartType: typeProvider.dynamicType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'uri': - return ( - dartType: _dartCoreType(typeProvider, 'Uri'), - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'date': - case 'datetime': - return ( - dartType: _dartCoreType(typeProvider, 'DateTime'), - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'duration': - return ( - dartType: _dartCoreType(typeProvider, 'Duration'), - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - default: - throw InvalidGenerationSource( - 'Unsupported schema method: Ack.$schemaMethod()', - element: element, - ); - } - } - - /// Extracts the enum type name from an `Ack.enumValues(...)` invocation. - /// - /// Prefers source text only when it contains a qualifier - /// (e.g., `alias.UserRole`) so import prefixes are preserved in generated - /// part files. - /// - /// For non-qualified names, prefers resolved static types to avoid - /// incorrectly treating arbitrary `.values` receivers as enum type names - /// (for example, `holder.values` should resolve to the list element type). - String? _extractEnumTypeNameFromInvocation(MethodInvocation invocation) { - final sourceTypeName = _extractEnumTypeNameFromSource(invocation); - if (sourceTypeName != null && sourceTypeName.contains('.')) { - return sourceTypeName; - } - - final resolvedType = _resolveEnumValuesType(invocation); - if (resolvedType != null) { - return resolvedType.getDisplayString(withNullability: false); - } - - return sourceTypeName; - } - - String? _extractEnumTypeNameFromSource(MethodInvocation invocation) { - // From type argument: Ack.enumValues(...) or Ack.enumValues(...) - final typeArgs = invocation.typeArguments?.arguments; - if (typeArgs != null && typeArgs.isNotEmpty) { - return typeArgs.first.toSource(); - } - - // From argument pattern: Ack.enumValues(UserRole.values) / Ack.enumValues(alias.UserRole.values) - final args = invocation.argumentList.arguments; - if (args.isNotEmpty) { - final firstArg = args.first; - if (firstArg is PrefixedIdentifier && - firstArg.identifier.name == 'values') { - final targetSource = firstArg.prefix.toSource(); - if (_looksLikeTypeReference(targetSource)) { - return targetSource; - } - } - if (firstArg is PropertyAccess && - firstArg.propertyName.name == 'values') { - final targetSource = firstArg.target?.toSource(); - if (targetSource != null && _looksLikeTypeReference(targetSource)) { - return targetSource; - } - } - } - - return null; - } - - bool _looksLikeTypeReference(String source) { - final trimmed = source.trim(); - if (trimmed.isEmpty) return false; - - final identifier = trimmed.split('.').last; - if (identifier.isEmpty) return false; - - final firstCodeUnit = identifier.codeUnitAt(0); - const uppercaseA = 65; - const uppercaseZ = 90; - const underscore = 95; - return (firstCodeUnit >= uppercaseA && firstCodeUnit <= uppercaseZ) || - firstCodeUnit == underscore; - } - - String? _extractListEnumElementTypeName(MethodInvocation listInvocation) { - final args = listInvocation.argumentList.arguments; - if (args.isEmpty) return null; - - final ref = _resolveListElementRef(args.first); - final elementSchema = ref.invocation == null - ? null - : _analyzeSchemaChain(ref.invocation!).ackBase; - if (elementSchema == null || - elementSchema.methodName.name != 'enumValues') { - return null; - } - - return _extractEnumTypeNameFromInvocation(elementSchema); - } - - /// Resolves enum type `T` from an `Ack.enumValues(...)` invocation. - /// - /// Resolution strategy (in order): - /// 1. Explicit type argument's resolved type (`Ack.enumValues(...)`) - /// 2. Invocation static type argument (`EnumSchema`) - /// 3. First argument static type (`List` from `T.values`) - /// 4. Source name lookup in the library/import scope - DartType? _resolveEnumValuesType( - MethodInvocation invocation, { - LibraryElement2? library, - }) { - final typeArgs = invocation.typeArguments?.arguments; - if (typeArgs != null && typeArgs.isNotEmpty) { - final explicitType = typeArgs.first.type; - if (explicitType is InterfaceType) { - return explicitType; - } - } - - final invocationType = invocation.staticType; - if (invocationType is InterfaceType && - invocationType.typeArguments.isNotEmpty) { - final schemaTypeArg = invocationType.typeArguments.first; - if (schemaTypeArg is InterfaceType) { - return schemaTypeArg; - } - } - - final args = invocation.argumentList.arguments; - if (args.isNotEmpty) { - final resolvedFromArgument = _resolveEnumValuesTypeFromArgument( - args.first, - library: library, - ); - if (resolvedFromArgument != null) { - return resolvedFromArgument; - } - } - - if (library != null) { - final enumTypeName = _extractEnumTypeNameFromSource(invocation); - if (enumTypeName != null) { - final resolvedByName = _resolveTypeByName(enumTypeName, library); - if (resolvedByName != null) { - return resolvedByName; - } - } - } - - return null; - } - - DartType? _resolveEnumValuesTypeFromArgument( - Expression argument, { - LibraryElement2? library, - }) { - final enumFromStaticType = _extractEnumTypeFromCandidate( - argument.staticType, - ); - if (enumFromStaticType != null) { - return enumFromStaticType; - } - - if (library == null) { - return null; - } - - final resolvedExpressionType = _resolveExpressionType(argument, library); - return _extractEnumTypeFromCandidate(resolvedExpressionType); - } - - DartType? _extractEnumTypeFromCandidate(DartType? candidate) { - if (candidate is! InterfaceType) { - return null; - } - - if (candidate.element3 is EnumElement2) { - return candidate; - } - - if (candidate.isDartCoreList && candidate.typeArguments.isNotEmpty) { - final elementType = candidate.typeArguments.first; - if (elementType is InterfaceType && - elementType.element3 is EnumElement2) { - return elementType; - } - } - - return null; - } - - DartType? _resolveExpressionType( - Expression expression, - LibraryElement2 library, - ) { - final staticType = expression.staticType; - if (staticType != null && staticType is! DynamicType) { - return staticType; - } - - if (expression is SimpleIdentifier) { - final variableType = _schemaVarsByName(library)[expression.name]?.type; - if (variableType != null) { - return variableType; - } - - final getterType = _schemaGettersByName( - library, - )[expression.name]?.returnType; - if (getterType != null) { - return getterType; - } - - return _resolveTypeByName(expression.name, library); - } - - if (expression is PrefixedIdentifier) { - final targetType = _resolveExpressionType(expression.prefix, library); - if (targetType is InterfaceType) { - final memberType = _resolveClassMemberType( - targetType: targetType, - memberName: expression.identifier.name, - library: library, - ); - if (memberType != null) { - return memberType; - } - } - - return _resolveTypeByName(expression.toSource(), library); - } - - if (expression is PropertyAccess) { - final target = expression.target; - if (target != null) { - final targetType = _resolveExpressionType(target, library); - if (targetType is InterfaceType) { - final memberType = _resolveClassMemberType( - targetType: targetType, - memberName: expression.propertyName.name, - library: library, - ); - if (memberType != null) { - return memberType; - } - } - } - } - - return null; - } - - DartType? _resolveClassMemberType({ - required InterfaceType targetType, - required String memberName, - required LibraryElement2 library, - }) { - final className = targetType.element3.name3; - if (className == null) return null; - - final classElement = _classesByName(library)[className]; - if (classElement == null) return null; - - final allFields = [ - ...classElement.fields2, - ...classElement.allSupertypes.expand((type) => type.element3.fields2), - ]; - - final field = allFields.firstWhereOrNull( - (current) => current.name3 == memberName, - ); - if (field != null) { - return field.type; - } - - final allGetters = [ - ...classElement.getters2, - ...classElement.allSupertypes.expand((type) => type.element3.getters2), - ]; - - final getter = allGetters.firstWhereOrNull( - (current) => current.name3 == memberName, - ); - return getter?.returnType; - } - - DartType? _resolveTypeByName(String typeName, LibraryElement2 library) { - final normalizedTypeName = typeName.trim(); - if (normalizedTypeName.isEmpty) return null; - - final scopeResult = library.firstFragment.scope.lookup(normalizedTypeName); - final scopeType = _resolveTypeFromElement(scopeResult.getter2); - if (scopeType != null) { - return scopeType; - } - - // Try import namespaces directly as a fallback for simple imported names. - for (final import in library.firstFragment.libraryImports2) { - final importedElement = import.namespace.get2(normalizedTypeName); - final importedType = _resolveTypeFromElement(importedElement); - if (importedType != null) { - return importedType; - } - } - - // Last-resort local lookup. - for (final enumElement in library.enums) { - if (enumElement.name3 == normalizedTypeName) { - return enumElement.thisType; - } - } - for (final classElement in library.classes) { - if (classElement.name3 == normalizedTypeName) { - return classElement.thisType; - } - } - - return null; - } - - DartType? _resolveTypeFromElement(Element2? element) { - if (element is EnumElement2) { - return element.thisType; - } - - if (element is ClassElement2) { - return element.thisType; - } - - if (element is TypeAliasElement2) { - final aliasedType = element.aliasedType; - if (aliasedType is InterfaceType) { - return aliasedType; - } - } - - return null; - } - - _ListElementRef _resolveListElementRef(Expression firstArg) { - if (firstArg is MethodInvocation) { - final schemaRef = _findSchemaVariableBase(firstArg); - if (schemaRef != null) { - return (invocation: firstArg, schemaRef: schemaRef); - } - - return (invocation: firstArg, schemaRef: null); - } - - final schemaRef = _extractSchemaReference(firstArg); - if (schemaRef != null) { - return (invocation: null, schemaRef: schemaRef); - } - - return (invocation: null, schemaRef: null); - } - - /// Analyzes the element schema used by Ack.list(...). - /// - /// Returns the generated list mapping and the list element representation - /// type string. - _ListElementAnalysis _analyzeListElement( - MethodInvocation listInvocation, - Element2 element, - TypeProvider typeProvider, - ) { - final args = listInvocation.argumentList.arguments; - - if (args.isEmpty) { - throw InvalidGenerationSource( - 'Ack.list(...) requires an element schema argument for strict typed generation.', - element: element, - todo: - 'Provide a concrete element schema, e.g. Ack.list(Ack.string()) or Ack.list(namedSchema).', - ); - } - - final firstArg = args.first; - - final ref = _resolveListElementRef(firstArg); - if (ref.invocation != null) { - final chain = _analyzeSchemaChain(ref.invocation!); - _rejectNullableListElement(chain.isNullable, element); - final baseInvocation = chain.ackBase; - final transformOutputTypeString = _requireTransformOutputType( - chain, - element, - contextLabel: 'Ack.list(...) element schema', - ); - - if (baseInvocation != null && - baseInvocation.methodName.name == 'object') { - throw InvalidGenerationSource( - 'Ack.list(Ack.object(...)) uses an anonymous inline object schema. ' - 'Strict typed generation requires a named schema reference.', - element: element, - todo: - 'Extract the inline object to a top-level @AckType() variable and use Ack.list(namedSchema).', - ); - } - - if (chain.schemaReference != null) { - _rejectIfReferencesNullableSchema(chain.schemaReference!, element); - final mapping = _resolveSchemaVariableType( - chain.schemaReference!, - element, - typeProvider, - transformedOutputType: chain.transformOutputType, - transformedRepresentationType: transformOutputTypeString, - ); - return ( - mapping: mapping, - elementRepresentationType: _resolveSchemaVariableElementTypeString( - chain.schemaReference!, - element, - transformedRepresentationType: transformOutputTypeString, - ), - ); - } - - if (baseInvocation == null) { - final rawExpression = firstArg.toSource(); - throw InvalidGenerationSource( - 'Could not statically resolve Ack.list($rawExpression) element type.', - element: element, - todo: - 'Use Ack.list(Ack.()), Ack.list(enumSchema), or Ack.list(namedSchema) so the generator can infer a concrete element type.', - ); - } - - final methodName = baseInvocation.methodName.name; - _throwIfUnsupportedTransformedBaseSchema( - schemaMethod: methodName, - transformOutputTypeString: transformOutputTypeString, - element: element, - contextLabel: 'Ack.list(...) element schema', - ); - - if (methodName == 'list') { - final nested = _analyzeListElement( - baseInvocation, - element, - typeProvider, - ); - return ( - mapping: _wrapListElementMapping(nested.mapping, typeProvider), - elementRepresentationType: - transformOutputTypeString ?? - 'List<${nested.elementRepresentationType}>', - ); - } - - final elementMapping = _mapSchemaTypeToDartType(ref.invocation!, element); - final elementRepresentationType = - transformOutputTypeString ?? - (methodName == 'enumValues' - ? _extractEnumTypeNameFromInvocation(baseInvocation) ?? 'dynamic' - : _mapSchemaMethodToType(methodName)); - return ( - mapping: _wrapListElementMapping(elementMapping, typeProvider), - elementRepresentationType: elementRepresentationType, - ); - } - - if (ref.schemaRef != null) { - _rejectIfReferencesNullableSchema(ref.schemaRef!, element); - final mapping = _resolveSchemaVariableType( - ref.schemaRef!, - element, - typeProvider, - ); - return ( - mapping: mapping, - elementRepresentationType: _resolveSchemaVariableElementTypeString( - ref.schemaRef!, - element, - ), - ); - } - - final rawExpression = firstArg.toSource(); - throw InvalidGenerationSource( - 'Could not statically resolve Ack.list($rawExpression) element type.', - element: element, - todo: - 'Use Ack.list(Ack.()), Ack.list(enumSchema), or Ack.list(namedSchema) so the generator can infer a concrete element type.', - ); - } - - void _rejectNullableListElement(bool isNullable, Element2 element) { - if (!isNullable) return; - - throw InvalidGenerationSource( - 'Ack.list(...) does not support nullable element schemas.', - element: element, - todo: - 'Remove `.nullable()` from the element schema. Make the list itself nullable with `Ack.list(item).nullable()` when needed.', - ); - } - - void _rejectIfReferencesNullableSchema( - _SchemaReference reference, - Element2 element, - ) { - final resolved = _resolveSchemaReference(reference, element); - _rejectNullableListElement( - resolved?.modelInfo.isNullableSchema ?? false, - element, - ); - } - - _SchemaTypeMapping _wrapListElementMapping( - _SchemaTypeMapping elementMapping, - TypeProvider typeProvider, - ) { - return ( - dartType: typeProvider.listType(elementMapping.dartType), - listElementSchemaRef: elementMapping.listElementSchemaRef, - listElementDisplayTypeOverride: - elementMapping.listElementDisplayTypeOverride, - listElementCastTypeOverride: elementMapping.listElementCastTypeOverride, - listElementIsCustomType: elementMapping.listElementIsCustomType, - ); - } - - /// Resolves a schema reference to its list element type. - /// - /// Looks up the schema in local/imported namespaces and returns the - /// appropriate list type plus metadata needed by the type builder. - _SchemaTypeMapping _resolveSchemaVariableType( - _SchemaReference schemaReference, - Element2 element, - TypeProvider typeProvider, { - DartType? transformedOutputType, - String? transformedRepresentationType, - }) { - final resolved = _resolveSchemaReference(schemaReference, element); - if (resolved == null) { - throw InvalidGenerationSource( - 'Could not resolve schema reference "${schemaReference.name}" ' - 'used in Ack.list(...)', - element: element, - todo: - 'Ensure "${schemaReference.name}" exists, is imported, and is declared as an Ack schema.', - ); - } - - final modelInfo = resolved.modelInfo; - final hasTransformOverride = transformedRepresentationType != null; - if (hasTransformOverride) { - _throwIfUnsupportedTransformedReferencedSchema( - resolved: resolved, - element: element, - contextLabel: 'Ack.list(${schemaReference.name}) element schema', - ); - } - - final representationType = - transformedRepresentationType ?? modelInfo.representationType; - final visibleRepresentationType = _resolveVisibleRepresentationType( - representationType: representationType, - resolved: resolved, - contextElement: element, - ); - final hasTypedReference = - resolved.hasAckTypeAnnotation && !hasTransformOverride; - final isObjectRepresentation = representationType == kMapType; - if (isObjectRepresentation && !hasTypedReference) { - throw InvalidGenerationSource( - 'Ack.list(${schemaReference.name}) references object schema ' - '"${schemaReference.name}" without @AckType. This would fall back to ' - 'Map.', - element: element, - todo: - 'Annotate "${schemaReference.name}" with @AckType() so list getters can emit typed wrappers.', - ); - } - - final elementDartType = - transformedOutputType ?? - _representationTypeToDartType(representationType, typeProvider); - - final typeBaseName = hasTypedReference - ? _qualifyTypeBaseName(modelInfo.className, resolved.importPrefix) - : null; - final listElementDisplayTypeOverride = hasTypedReference - ? typeBaseName - : (!elementDartType.isDartCoreString && - !elementDartType.isDartCoreInt && - !elementDartType.isDartCoreDouble && - !elementDartType.isDartCoreBool && - !elementDartType.isDartCoreNum && - !elementDartType.isDartCoreList && - !elementDartType.isDartCoreMap && - !elementDartType.isDartCoreSet - ? visibleRepresentationType - : null); - - return ( - dartType: typeProvider.listType(elementDartType), - listElementSchemaRef: hasTypedReference ? resolved.schemaName : null, - listElementDisplayTypeOverride: listElementDisplayTypeOverride, - listElementCastTypeOverride: hasTypedReference - ? visibleRepresentationType - : null, - listElementIsCustomType: hasTypedReference, - ); - } - - /// Resolves a schema reference to its representation type string. - /// - /// This is used for top-level list schemas so we can cast to the correct - /// element type (e.g., `String` for `Ack.string()` schema variables). - /// - /// Throws when the schema variable cannot be resolved or if a circular - /// reference is detected. - String _resolveSchemaVariableElementTypeString( - _SchemaReference schemaReference, - Element2 element, { - String? transformedRepresentationType, - }) { - final library = element.library2; - // Use library-scoped cache key to prevent collisions across libraries - final prefix = schemaReference.prefix ?? ''; - final transformKey = transformedRepresentationType ?? ''; - final cacheKey = - '${library?.uri ?? 'unknown'}::$prefix::${schemaReference.name}::$transformKey'; - - final cached = _schemaVariableTypeCache[cacheKey]; - if (cached != null) { - return cached; - } - - if (_schemaVariableTypeStack.contains(cacheKey)) { - throw InvalidGenerationSource( - 'Circular schema variable reference detected for ' - '"${schemaReference.name}" in Ack.list(...).', - element: element, - todo: - 'Break the circular Ack.list(...) schema references so element types ' - 'can be resolved statically.', - ); - } - - _schemaVariableTypeStack.add(cacheKey); - - String? resolvedType; - try { - if (library == null) { - throw InvalidGenerationSource( - 'Could not resolve library while analyzing schema reference ' - '"${schemaReference.name}"', - element: element, - ); - } - - final resolved = _resolveSchemaReference(schemaReference, element); - if (resolved == null) { - throw InvalidGenerationSource( - 'Could not resolve schema reference "${schemaReference.name}" ' - 'used in Ack.list(...)', - element: element, - todo: - 'Ensure "${schemaReference.name}" exists, is imported, and is declared as an Ack schema.', - ); - } - - final representationType = - transformedRepresentationType ?? - resolved.modelInfo.representationType; - final hasTypedReference = - resolved.hasAckTypeAnnotation && - transformedRepresentationType == null; - - if (representationType == kMapType && !hasTypedReference) { - throw InvalidGenerationSource( - 'Ack.list(${schemaReference.name}) references object schema ' - '"${schemaReference.name}" without @AckType. This would fall back to ' - 'Map.', - element: element, - todo: - 'Annotate "${schemaReference.name}" with @AckType() so list getters can emit typed wrappers.', - ); - } - - resolvedType = _resolveVisibleRepresentationType( - representationType: representationType, - resolved: resolved, - contextElement: element, - ); - return resolvedType; - } finally { - _schemaVariableTypeStack.remove(cacheKey); - if (resolvedType != null) { - _schemaVariableTypeCache[cacheKey] = resolvedType; - } - } - } - - _ResolvedSchemaReference? _resolveSchemaReference( - _SchemaReference reference, - Element2 contextElement, - ) { - final library = contextElement.library2; - if (library == null) { - return null; - } - - final cacheKey = _schemaReferenceCacheKey(reference, library); - final cached = _schemaReferenceCache[cacheKey]; - if (cached != null || _schemaReferenceCache.containsKey(cacheKey)) { - return cached; - } - - if (_schemaReferenceResolutionStack.contains(cacheKey)) { - final referenceLabel = _formatSchemaReference(reference); - throw InvalidGenerationSource( - 'Circular schema reference detected for "$referenceLabel".', - element: contextElement, - todo: - 'Break the circular alias/reference chain between @AckType schemas.', - ); - } - - _schemaReferenceResolutionStack.add(cacheKey); - - _ResolvedSchemaReference? resolvedReference; - var shouldCacheResult = false; - - try { - final resolvedElementMatch = _resolveSchemaElement(reference, library); - if (resolvedElementMatch == null) { - shouldCacheResult = true; - resolvedReference = null; - return null; - } - final resolvedElement = resolvedElementMatch.element; - - TopLevelVariableElement2? schemaVariable; - GetterElement? schemaGetter; - Element2? sourceDeclaration; - - if (resolvedElement is TopLevelVariableElement2) { - schemaVariable = resolvedElement; - sourceDeclaration = resolvedElement; - } else if (resolvedElement is GetterElement) { - if (resolvedElement.isSynthetic) { - final variable = resolvedElement.variable3; - if (variable is TopLevelVariableElement2) { - schemaVariable = variable; - sourceDeclaration = variable; - } - } else { - schemaGetter = resolvedElement; - sourceDeclaration = resolvedElement; - } - } - - if (schemaVariable == null && schemaGetter != null) { - // Ensure this is top-level only. - if (schemaGetter.enclosingElement2 is! LibraryElement2) { - shouldCacheResult = true; - resolvedReference = null; - return null; - } - } - - if (schemaVariable == null && schemaGetter == null) { - shouldCacheResult = true; - resolvedReference = null; - return null; - } - - final schemaName = schemaVariable?.name3 ?? schemaGetter?.name3; - if (schemaName == null) { - shouldCacheResult = true; - resolvedReference = null; - return null; - } - - final declarationForMetadata = - sourceDeclaration ?? schemaVariable ?? schemaGetter; - if (declarationForMetadata == null) { - shouldCacheResult = true; - resolvedReference = null; - return null; - } - - final hasAckTypeAnnotation = _hasAckTypeAnnotation( - declarationForMetadata, - ); - - final customTypeName = _extractAckTypeName(declarationForMetadata); - - ModelInfo? modelInfo; - if (schemaVariable != null) { - modelInfo = analyzeSchemaVariable( - schemaVariable, - customTypeName: customTypeName, - ); - } else if (schemaGetter != null) { - modelInfo = analyzeSchemaGetter( - schemaGetter, - customTypeName: customTypeName, - ); - } - - if (modelInfo == null) { - shouldCacheResult = true; - resolvedReference = null; - return null; - } - - resolvedReference = _ResolvedSchemaReference( - schemaName: schemaName, - modelInfo: modelInfo, - importPrefix: reference.prefix, - importDirective: resolvedElementMatch.importDirective, - hasAckTypeAnnotation: hasAckTypeAnnotation, - sourceDeclaration: declarationForMetadata, - sourceLibraryUri: declarationForMetadata.library2?.uri, - ); - shouldCacheResult = true; - return resolvedReference; - } on InvalidGenerationSource { - rethrow; - } catch (e, st) { - _log.warning( - 'Unexpected error resolving schema reference ' - '"${_formatSchemaReference(reference)}": $e\n$st', - ); - shouldCacheResult = true; - resolvedReference = null; - return null; - } finally { - _schemaReferenceResolutionStack.remove(cacheKey); - if (shouldCacheResult) { - _schemaReferenceCache[cacheKey] = resolvedReference; - } - } - } - - _ResolvedSchemaElement? _resolveSchemaElement( - _SchemaReference reference, - LibraryElement2 library, - ) { - if (reference.prefix != null) { - // Prefer an exact prefix match when the source used `prefix.symbol`. - for (final import in library.firstFragment.libraryImports2) { - final prefixName = _elementName(import.prefix2?.element); - if (prefixName != reference.prefix) continue; - - final importedElement = import.namespace.getPrefixed2( - reference.prefix!, - reference.name, - ); - if (importedElement != null) { - return (element: importedElement, importDirective: import); - } - } - - // Strict behavior: when a prefix is specified, never resolve from a - // different namespace. - return null; - } - - final scopeResult = library.firstFragment.scope.lookup(reference.name); - final scopedElement = scopeResult.getter2; - if (scopedElement != null) { - return ( - element: scopedElement, - importDirective: _findImportDirectiveForElement( - reference.name, - scopedElement, - library, - ), - ); - } - - for (final import in library.firstFragment.libraryImports2) { - final importedElement = import.namespace.get2(reference.name); - if (importedElement != null) { - return (element: importedElement, importDirective: import); - } - } - - return null; - } - - LibraryImport? _findImportDirectiveForElement( - String name, - Element2 element, - LibraryElement2 library, - ) { - for (final import in library.firstFragment.libraryImports2) { - final importedElement = import.namespace.get2(name); - if (_elementsMatch(importedElement, element)) { - return import; - } - } - return null; - } - - bool _elementsMatch(Element2? first, Element2? second) { - if (identical(first, second)) { - return true; - } - if (first == null || second == null) { - return false; - } - return first.library2?.uri == second.library2?.uri && - first.name3 == second.name3; - } - - String? _elementName(Element2? element) { - final modernName = element?.name3; - if (modernName != null && modernName.isNotEmpty) { - return modernName; - } - return null; - } - - bool _hasAckTypeAnnotation(Element2 element) { - return TypeChecker.typeNamed(AckType).hasAnnotationOfExact(element); - } - - String? _extractAckTypeName(Element2 element) { - final annotation = TypeChecker.typeNamed( - AckType, - ).firstAnnotationOfExact(element); - if (annotation == null) return null; - - final nameField = ConstantReader(annotation).peek('name'); - return (nameField != null && !nameField.isNull) - ? nameField.stringValue - : null; - } - - String _qualifyTypeBaseName(String baseTypeName, String? prefix) { - if (prefix == null || prefix.isEmpty) { - return baseTypeName; - } - return '$prefix.$baseTypeName'; - } - - String _resolveVisibleRepresentationType({ - required String representationType, - required _ResolvedSchemaReference resolved, - required Element2 contextElement, - }) { - final contextLibrary = contextElement.library2; - if (contextLibrary == null) { - throw InvalidGenerationSource( - 'Could not resolve libraries while qualifying transformed representation ' - 'type "$representationType".', - element: contextElement, - ); - } - - if (resolved.sourceLibraryUri == contextLibrary.uri) { - return representationType; - } - - if (_containsUnsupportedRepresentationSyntax(representationType)) { - throw InvalidGenerationSource( - 'Transformed representation type "$representationType" for ' - '"${resolved.schemaName}" uses unsupported syntax for cross-file ' - 'generation.', - element: contextElement, - todo: - 'Use a nominal type with optional nested generics/nullability, or keep the schema in the same library.', - ); - } - - final tokenPattern = RegExp(r'[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*'); - final buffer = StringBuffer(); - var lastIndex = 0; - - for (final match in tokenPattern.allMatches(representationType)) { - buffer.write(representationType.substring(lastIndex, match.start)); - final token = match.group(0)!; - buffer.write( - _resolveVisibleRepresentationToken( - token: token, - resolved: resolved, - contextLibrary: contextLibrary, - fullRepresentationType: representationType, - contextElement: contextElement, - ), - ); - lastIndex = match.end; - } - - buffer.write(representationType.substring(lastIndex)); - return buffer.toString(); - } - - String _resolveVisibleRepresentationToken({ - required String token, - required _ResolvedSchemaReference resolved, - required LibraryElement2 contextLibrary, - required String fullRepresentationType, - required Element2 contextElement, - }) { - if (_isBuiltInRepresentationIdentifier(token)) { - return token; - } - - if (token.contains('.')) { - throw InvalidGenerationSource( - 'Transformed representation type "$fullRepresentationType" for ' - '"${resolved.schemaName}" uses a qualified type that cannot be ' - 'referenced across library boundaries.', - element: contextElement, - todo: - 'Use an unqualified exported representation type, import that type directly into the consuming library, or keep the schema in the same library.', - ); - } - - final importNamespaceType = _resolveImportedType(token, resolved); - final scopedElement = contextLibrary.firstFragment.scope - .lookup(token) - .getter2; - final scopedType = _resolveTypeFromElement(scopedElement); - final localContextType = - scopedElement != null && - _findImportDirectiveForElement( - token, - scopedElement, - contextLibrary, - ) == - null - ? scopedType - : null; - final importedContextTypes = _resolveImportedTypesByName( - token, - contextLibrary, - ); - final unqualifiedContextType = - localContextType ?? - (importedContextTypes.length == 1 ? importedContextTypes.single : null); - final hasAmbiguousImportedTypes = - localContextType == null && importedContextTypes.length > 1; - final prefix = resolved.importPrefix; - - if (importNamespaceType != null && prefix != null && prefix.isNotEmpty) { - return '$prefix.$token'; - } - - if (importNamespaceType != null) { - if (unqualifiedContextType != null && - _sameResolvedType(importNamespaceType, unqualifiedContextType)) { - return token; - } - - if (hasAmbiguousImportedTypes || unqualifiedContextType != null) { - throw InvalidGenerationSource( - 'Transformed representation type "$fullRepresentationType" for ' - '"${resolved.schemaName}" is ambiguous in this library.', - element: contextElement, - todo: - 'Use a prefixed schema import or rename/import the representation type so the generated cast resolves unambiguously.', - ); - } - } - - if (hasAmbiguousImportedTypes) { - throw InvalidGenerationSource( - 'Transformed representation type "$fullRepresentationType" for ' - '"${resolved.schemaName}" is ambiguous in this library.', - element: contextElement, - todo: - 'Use a prefixed schema import or rename/import the representation type so the generated cast resolves unambiguously.', - ); - } - - if (unqualifiedContextType != null) { - return token; - } - - throw InvalidGenerationSource( - 'Transformed representation type "$fullRepresentationType" for ' - '"${resolved.schemaName}" is not visible from this library.', - element: contextElement, - todo: - 'Export the representation type from the referenced schema library or import that type directly into this library.', - ); - } - - bool _sameResolvedType(DartType first, DartType second) { - return _resolvedTypeIdentity(first) == _resolvedTypeIdentity(second); - } - - List _resolveImportedTypesByName( - String token, - LibraryElement2 library, - ) { - final normalizedToken = token.trim(); - if (normalizedToken.isEmpty) { - return const []; - } - - final importedTypesByIdentity = {}; - for (final import in library.firstFragment.libraryImports2) { - final importedElement = import.namespace.get2(normalizedToken); - final importedType = _resolveTypeFromElement(importedElement); - if (importedType == null) { - continue; - } - - importedTypesByIdentity.putIfAbsent( - _resolvedTypeIdentity(importedType), - () => importedType, - ); - } - - return importedTypesByIdentity.values.toList(growable: false); - } - - DartType? _resolveImportedType( - String token, - _ResolvedSchemaReference resolved, - ) { - final importDirective = resolved.importDirective; - if (importDirective == null) { - return null; - } - - final prefix = resolved.importPrefix; - final importedElement = prefix != null && prefix.isNotEmpty - ? importDirective.namespace.getPrefixed2(prefix, token) - : importDirective.namespace.get2(token); - return _resolveTypeFromElement(importedElement); - } - - String _resolvedTypeIdentity(DartType type) { - if (type is InterfaceType) { - final element = type.element3; - final libraryUri = element.library2.uri.toString(); - final name = - element.name3 ?? type.getDisplayString(withNullability: false); - return '$libraryUri::$name'; - } - - return type.getDisplayString(withNullability: false); - } - - bool _isBuiltInRepresentationIdentifier(String token) { - return token == 'String' || - token == 'int' || - token == 'double' || - token == 'bool' || - token == 'num' || - token == 'dynamic' || - token == 'Object' || - token == 'Null' || - token == 'Never' || - token == 'void' || - token == 'Uri' || - token == 'DateTime' || - token == 'Duration' || - token == 'List' || - token == 'Set' || - token == 'Map'; - } - - bool _containsUnsupportedRepresentationSyntax(String representationType) { - return representationType.contains('(') || - representationType.contains(')') || - representationType.contains('{') || - representationType.contains('}') || - representationType.contains('=>'); - } - - String _schemaReferenceCacheKey( - _SchemaReference reference, - LibraryElement2 library, - ) { - final prefix = reference.prefix ?? ''; - return '${library.uri}::$prefix::${reference.name}'; - } - - String _formatSchemaReference(_SchemaReference reference) { - final prefix = reference.prefix; - if (prefix == null || prefix.isEmpty) { - return reference.name; - } - return '$prefix.${reference.name}'; - } - - DartType _representationTypeToDartType( - String representationType, - TypeProvider typeProvider, - ) { - return switch (representationType) { - 'String' => typeProvider.stringType, - 'int' => typeProvider.intType, - 'double' => typeProvider.doubleType, - 'bool' => typeProvider.boolType, - 'num' => typeProvider.numType, - 'Uri' => _dartCoreType(typeProvider, 'Uri'), - 'DateTime' => _dartCoreType(typeProvider, 'DateTime'), - 'Duration' => _dartCoreType(typeProvider, 'Duration'), - _ when representationType.startsWith('Map<') => typeProvider.mapType( - typeProvider.stringType, - typeProvider.dynamicType, - ), - _ when representationType.startsWith('List<') => typeProvider.listType( - typeProvider.dynamicType, - ), - _ => typeProvider.dynamicType, - }; - } - - DartType _dartCoreType(TypeProvider typeProvider, String typeName) { - final type = _resolveTypeByName( - typeName, - typeProvider.stringType.element3.library2, - ); - return type ?? typeProvider.dynamicType; - } - - /// Extracts the identifier name from different expression forms. - /// - /// Supports simple identifiers, prefixed identifiers (`prefix.name`), - /// and property accesses (`expr.name`). - String? _identifierName(Expression? expression) { - if (expression == null) return null; - - if (expression is SimpleIdentifier) { - return expression.name; - } - - if (expression is PrefixedIdentifier) { - return expression.identifier.name; - } - - if (expression is PropertyAccess) { - return expression.propertyName.name; - } - - return null; - } - - bool _isAckTarget(Expression? target) { - return _identifierName(target) == 'Ack'; - } - - _SchemaReference? _extractSchemaReference(Expression? target) { - if (target == null) return null; - - if (target is SimpleIdentifier) { - if (target.name == 'Ack') return null; - return (name: target.name, prefix: null); - } - - if (target is PrefixedIdentifier) { - final name = target.identifier.name; - if (name == 'Ack') return null; - return (name: name, prefix: target.prefix.name); - } - - if (target is PropertyAccess) { - final name = target.propertyName.name; - if (name == 'Ack') return null; - - final targetExpression = target.target; - String? prefix; - if (targetExpression is SimpleIdentifier) { - prefix = targetExpression.name; - } else if (targetExpression is PrefixedIdentifier) { - prefix = targetExpression.identifier.name; - } - - return (name: name, prefix: prefix); - } - - return null; - } - - (List, bool) _collectMethodChain( - MethodInvocation invocation, - ) { - final chain = []; - MethodInvocation? current = invocation; - - // Safety limit to prevent infinite loops on malformed AST - const maxDepth = 20; - var depth = 0; - - while (current != null && depth < maxDepth) { - chain.add(current); - final target = current.target; - if (target is MethodInvocation) { - current = target; - depth++; - } else { - break; - } - } - - return (chain, depth >= maxDepth); - } - - _SchemaChainInfo _analyzeSchemaChain(MethodInvocation invocation) { - final (chain, truncated) = _collectMethodChain(invocation); - MethodInvocation? ackBase; - _SchemaReference? schemaReference; - var isOptional = false; - var isNullable = false; - MethodInvocation? transformInvocation; - DartType? transformOutputType; - String? transformOutputTypeString; - - for (final current in chain) { - final methodName = current.methodName.name; - - if (methodName == 'optional') { - isOptional = true; - } else if (methodName == 'nullable') { - isNullable = true; - } else if (methodName == 'transform' && transformInvocation == null) { - transformInvocation = current; - final typeArgs = current.typeArguments?.arguments; - if (typeArgs != null && typeArgs.isNotEmpty) { - final typeArg = typeArgs.first; - transformOutputType = typeArg.type; - transformOutputTypeString = typeArg.toSource(); - } - } - - final target = current.target; - if (ackBase == null && _isAckTarget(target)) { - ackBase = current; - } - - if (schemaReference == null) { - final reference = _extractSchemaReference(target); - if (reference != null) { - schemaReference = reference; - } - } - } - - if (truncated) { - _log.warning( - 'Schema method chain exceeded max depth of 20. ' - 'Type inference may fall back to dynamic.', - ); - } - - return ( - ackBase: ackBase, - schemaReference: schemaReference, - isOptional: isOptional, - isNullable: isNullable, - wasTruncated: truncated, - transformInvocation: transformInvocation, - transformOutputType: transformOutputType, - transformOutputTypeString: transformOutputTypeString, - ); - } - - String? _requireTransformOutputType( - _SchemaChainInfo chain, - Element2 element, { - required String contextLabel, - }) { - if (chain.transformInvocation == null) { - return null; - } - - final typeName = chain.transformOutputTypeString; - if (typeName != null && typeName.isNotEmpty) { - return typeName; - } - - throw InvalidGenerationSource( - '$contextLabel uses .transform(...) without an explicit output type. ' - '@AckType requires .transform(...) so the generated type can be inferred.', - element: element, - todo: - 'Add an explicit type argument, for example .transform((value) => ...).', - ); - } - - void _throwIfUnsupportedTransformedBaseSchema({ - required String schemaMethod, - required String? transformOutputTypeString, - required Element2 element, - required String contextLabel, - }) { - if (transformOutputTypeString == null) { - return; - } - - if (schemaMethod == 'object') { - throw InvalidGenerationSource( - '$contextLabel transforms an Ack.object(...) schema. ' - 'Transformed object schemas are not supported by @AckType.', - element: element, - todo: - 'Remove .transform() from the object schema or expose the transformed result through a separate non-object schema.', - ); - } - - if (schemaMethod == 'discriminated') { - throw InvalidGenerationSource( - '$contextLabel transforms an Ack.discriminated(...) schema. ' - 'Transformed discriminated schemas are not supported by @AckType.', - element: element, - todo: - 'Remove .transform() from the discriminated schema or expose the transformed result through a separate non-object schema.', - ); - } - } - - void _throwIfUnsupportedTransformedReferencedSchema({ - required _ResolvedSchemaReference resolved, - required Element2 element, - required String contextLabel, - }) { - final modelInfo = resolved.modelInfo; - if (modelInfo.isDiscriminatedBaseDefinition) { - throw InvalidGenerationSource( - '$contextLabel transforms referenced discriminated schema ' - '"${resolved.schemaName}". Transformed discriminated schemas are not supported by @AckType.', - element: element, - todo: - 'Remove .transform() from the referenced discriminated schema or expose a separate non-object schema.', - ); - } - - if (modelInfo.representationType == kMapType) { - throw InvalidGenerationSource( - '$contextLabel transforms referenced object schema ' - '"${resolved.schemaName}". Transformed object schemas are not supported by @AckType.', - element: element, - todo: - 'Remove .transform() from the referenced object schema or expose a separate non-object schema.', - ); - } - } - - /// Walks a method chain to find the base Ack.xxx() invocation. - /// - /// For `Ack.string().describe('...').optional()`, returns `Ack.string()`. - /// For `Ack.integer().min(0).max(100)`, returns `Ack.integer()`. - /// - /// Returns `null` if no Ack.xxx() base is found. - MethodInvocation? _findBaseAckInvocation(MethodInvocation invocation) { - final (chain, truncated) = _collectMethodChain(invocation); - - for (final current in chain) { - final target = current.target; - if (_isAckTarget(target)) { - return current; - } - } - - if (truncated) { - _log.warning( - 'Method chain exceeded max depth of 20. ' - 'List element type will fall back to dynamic.', - ); - } - return null; - } - - /// Walks a method chain to find a schema variable base reference. - /// - /// For `itemSchema.optional().nullable()`, returns `(name: itemSchema)`. - /// For `deck.slideSchema.describe('...')`, returns - /// `(name: slideSchema, prefix: deck)`. - /// - /// Returns `null` if the chain doesn't end with a schema variable identifier - /// (e.g., if it's an Ack.xxx() chain or unknown structure). - /// - _SchemaReference? _findSchemaVariableBase(MethodInvocation invocation) { - final (chain, truncated) = _collectMethodChain(invocation); - - for (final current in chain) { - final target = current.target; - - final schemaReference = _extractSchemaReference(target); - if (schemaReference != null) { - return schemaReference; - } - - // If target resolves to Ack, this is an Ack.xxx() chain - if (_isAckTarget(target)) { - return null; - } - } - - if (truncated) { - _log.warning( - 'Schema variable method chain exceeded max depth of 20. ' - 'List element type will fall back to dynamic.', - ); - } - return null; - } - - /// Resolves the base class name for a schema variable, honoring custom overrides. - String _resolveModelClassName( - String variableName, - Element2 element, { - String? customTypeName, - }) { - if (customTypeName == null) { - return _generateTypeNameFromVariable(variableName); - } - - final trimmed = customTypeName.trim(); - if (trimmed.isEmpty) { - throw InvalidGenerationSource( - 'Custom @AckType name cannot be empty', - element: element, - todo: 'Provide a non-empty type name in the @AckType annotation.', - ); - } - - const identifierPattern = r'^[A-Za-z_][A-Za-z0-9_]*$'; - if (!RegExp(identifierPattern).hasMatch(trimmed)) { - throw InvalidGenerationSource( - 'Invalid custom @AckType name "$customTypeName". ' - 'Type names must start with a letter or underscore and can only contain letters, numbers, and underscores.', - element: element, - todo: 'Update the @AckType annotation to use a valid Dart identifier.', - ); - } - - // Ensure leading character is uppercase for consistency. - if (trimmed.length == 1) { - return trimmed.toUpperCase(); - } - - return trimmed[0].toUpperCase() + trimmed.substring(1); - } - - /// Generates an extension type name from a schema variable name - /// - /// Examples: - /// - "userSchema" → "User" - /// - "addressSchema" → "Address" - /// - "myDataSchema" → "MyData" - String _generateTypeNameFromVariable(String variableName) { - // Remove "Schema" suffix if present - var name = variableName; - if (name.endsWith('Schema')) { - name = name.substring(0, name.length - 'Schema'.length); - } - - // Capitalize first letter - if (name.isEmpty) return 'Type'; - return name[0].toUpperCase() + name.substring(1); - } - - /// Parses Ack.string() schema - ModelInfo _parseStringSchema( - String variableName, - MethodInvocation invocation, - Element2 element, { - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: 'String', - isNullableSchema: isNullable, - ); - } - - /// Parses Ack.integer() schema - ModelInfo _parseIntegerSchema( - String variableName, - MethodInvocation invocation, - Element2 element, { - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: 'int', - isNullableSchema: isNullable, - ); - } - - /// Parses Ack.double() schema - ModelInfo _parseDoubleSchema( - String variableName, - MethodInvocation invocation, - Element2 element, { - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: 'double', - isNullableSchema: isNullable, - ); - } - - /// Parses Ack.boolean() schema - ModelInfo _parseBooleanSchema( - String variableName, - MethodInvocation invocation, - Element2 element, { - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: 'bool', - isNullableSchema: isNullable, - ); - } - - /// Parses Ack.list() schema - /// - /// Extracts the element type from list schema definitions to generate - /// correctly typed extension types (e.g., `List` not `List`). - /// - /// Examples: - /// - `Ack.list(Ack.string())` → `List` - /// - `Ack.list(Ack.integer())` → `List` - /// - `Ack.list(Ack.list(Ack.double()))` → `List>` (nested) - /// - `Ack.list(addressSchema)` → `List>` (schema reference) - ModelInfo _parseListSchema( - String variableName, - Element2 element, { - required bool isNullable, - required _ListElementAnalysis listElementAnalysis, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: - 'List<${listElementAnalysis.elementRepresentationType}>', - isNullableSchema: isNullable, - ); - } - - ModelInfo _parseRepresentationSchema( - String variableName, - Element2 element, { - required String representationType, - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: const [], - representationType: representationType, - isNullableSchema: isNullable, - ); - } - - ModelInfo _withRepresentationType( - ModelInfo model, - String representationType, - ) { - return ModelInfo( - className: model.className, - schemaClassName: model.schemaClassName, - description: model.description, - fields: model.fields, - additionalProperties: model.additionalProperties, - discriminatorKey: model.discriminatorKey, - discriminatorValue: model.discriminatorValue, - subtypeNames: model.subtypeNames, - schemaIdentity: model.schemaIdentity, - discriminatedBaseClassName: model.discriminatedBaseClassName, - representationType: representationType, - isNullableSchema: model.isNullableSchema, - ); - } - - /// Parses Ack.literal() schema - /// - /// Literal schemas are StringSchema with a literal constraint. - /// The constraint is enforced at runtime, not in the extension type. - /// - /// Example: Ack.literal('active') → extension type StatusType(String) - ModelInfo _parseLiteralSchema( - String variableName, - MethodInvocation invocation, - Element2 element, { - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: 'String', - isNullableSchema: isNullable, - ); - } - - /// Parses `Ack.enumString()` schema. - /// - /// String-enum schemas are `StringSchema` values with an enum constraint. - /// The allowed values are enforced at runtime, not in the extension type. - /// - /// Example: `Ack.enumString(['a', 'b'])` -> `extension type XType(String)` - ModelInfo _parseEnumStringSchema( - String variableName, - MethodInvocation invocation, - Element2 element, { - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: 'String', - isNullableSchema: isNullable, - ); - } - - /// Parses `Ack.enumValues()` schema - /// - /// EnumValues schemas wrap Dart enum types with validation. - /// The representation type is the enum type itself. - /// - /// Example: `Ack.enumValues([...])` → extension type XType(UserRole) - ModelInfo _parseEnumValuesSchema( - String variableName, - MethodInvocation invocation, - Element2 element, { - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - final enumTypeName = _extractEnumTypeNameFromInvocation(invocation); - - // If we couldn't extract the enum type, throw an error - if (enumTypeName == null) { - throw InvalidGenerationSource( - 'Could not determine enum type for Ack.enumValues(). ' - 'Use explicit type argument: Ack.enumValues([...]) ' - 'or pass enum.values: Ack.enumValues(YourEnum.values)', - element: element, - ); - } - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: enumTypeName, - isNullableSchema: isNullable, - ); - } - - /// Maps Ack schema method names to Dart type strings - /// - /// Used for generating string representations of types in list element contexts. - /// For nested lists, this function is called recursively via [_parseListSchema]. - String _mapSchemaMethodToType(String methodName) { - return switch (methodName) { - 'string' || 'enumString' || 'literal' => 'String', - 'integer' => 'int', - 'double' => 'double', - 'boolean' => 'bool', - 'uri' => 'Uri', - 'date' || 'datetime' => 'DateTime', - 'duration' => 'Duration', - 'object' => kMapType, - 'list' => 'List', - _ => 'dynamic', - }; - } - - /// Validates that a field name is a valid Dart identifier - /// - /// Throws [InvalidGenerationSource] if the field name: - /// - Contains invalid characters (must match [a-zA-Z_$][a-zA-Z0-9_$]*) - /// - Is a Dart reserved keyword - void _validateFieldName(String fieldName, Element2 element) { - // Check if key is a valid Dart identifier - final identifierRegex = RegExp(r'^[a-zA-Z_$][a-zA-Z0-9_$]*$'); - if (!identifierRegex.hasMatch(fieldName)) { - throw InvalidGenerationSource( - 'JSON key "$fieldName" is not a valid Dart identifier. ' - 'Keys must start with a letter, underscore, or dollar sign, and can only ' - 'contain letters, numbers, underscores, and dollar signs.', - element: element, - todo: - 'Use a valid Dart identifier as the key, or consider transforming ' - 'the key to a valid identifier (e.g., "user-id" → "userId").', - ); - } - - // Reject only reserved words. Built-in and pseudo keywords are allowed - // as identifiers in many contexts (for example `of`, `augment`). - final keyword = Keyword.keywords[fieldName]; - if (keyword?.isReservedWord == true) { - throw InvalidGenerationSource( - 'JSON key "$fieldName" is a Dart reserved keyword and cannot be used as a field name.', - element: element, - todo: - 'Use a different key that is not a Dart reserved keyword, or prefix it ' - '(e.g., "class" → "classValue" or "klass").', - ); - } - } -} diff --git a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart new file mode 100644 index 00000000..70d4028f --- /dev/null +++ b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart @@ -0,0 +1,1163 @@ +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.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:source_gen/source_gen.dart'; + +import '../models/schema_model_graph.dart'; + +final class _Declaration { + const _Declaration({ + required this.element, + required this.expression, + required this.id, + required this.className, + }); + + final Element element; + final Expression expression; + final AckSchemaId id; + final String className; +} + +final class _SchemaChain { + const _SchemaChain({ + required this.base, + required this.reference, + required this.optional, + required this.nullable, + required this.defaulted, + required this.hasTransform, + required this.hasCodec, + }); + + final MethodInvocation? base; + final Expression? reference; + final bool optional; + final bool nullable; + final bool defaulted; + final bool hasTransform; + final bool hasCodec; +} + +typedef _SchemaTypes = ({AckTypeRef boundary, AckTypeRef runtime}); + +/// Builds the single normalized graph consumed by Ack model emission. +final class SchemaModelGraphBuilder { + SchemaModelGraphBuilder(this.library); + + static const _reservedMembers = { + r'$ack', + 'parse', + 'safeParse', + 'fromJson', + 'toJson', + 'safeToJson', + '_fromAckRuntime', + '_toAckRuntime', + 'hashCode', + 'noSuchMethod', + 'toString', + 'runtimeType', + }; + + static const _dartKeywords = { + 'abstract', + 'as', + 'assert', + 'async', + 'await', + 'base', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'covariant', + 'default', + 'deferred', + 'do', + 'dynamic', + 'else', + 'enum', + 'export', + 'extends', + 'extension', + 'external', + 'factory', + 'false', + 'final', + 'finally', + 'for', + 'get', + 'hide', + 'if', + 'implements', + 'import', + 'in', + 'interface', + 'is', + 'late', + 'library', + 'mixin', + 'new', + 'null', + 'of', + 'on', + 'operator', + 'part', + 'required', + 'rethrow', + 'return', + 'sealed', + 'set', + 'show', + 'static', + 'super', + 'switch', + 'sync', + 'this', + 'throw', + 'true', + 'try', + 'typedef', + 'var', + 'void', + 'when', + 'while', + 'with', + 'yield', + }; + + static const _generatedHelperNames = { + '_ackImmutableCopyValue', + '_ackImmutableCopyMap', + }; + + final LibraryReader library; + final AckModelGraph _graph = AckModelGraph(); + final Map _declarationsByElement = {}; + final Map _declarationsById = {}; + final Map _unionOwnerByBranch = {}; + + Future build(List annotatedElements) async { + final libraryElement = library.element; + final resolved = await libraryElement.session.getResolvedLibraryByElement( + libraryElement, + ); + if (resolved is! ResolvedLibraryResult) { + throw InvalidGenerationSource( + 'Could not resolve ${libraryElement.uri} for Ack model generation.', + ); + } + + for (final element in annotatedElements) { + final expression = _declarationExpression(resolved, element); + final declarationName = element.name; + if (declarationName == null || expression == null) { + throw InvalidGenerationSource( + 'Could not resolve the schema expression for ${element.displayName}.', + element: element, + ); + } + final id = AckSchemaId( + libraryUri: libraryElement.uri, + declarationName: declarationName, + ); + final declaration = _Declaration( + element: element, + expression: expression, + id: id, + className: _className( + declarationName, + _annotationName(element), + element, + ), + ); + _registerElement(element, declaration); + _declarationsById[id] = declaration; + } + + _validateClassNames(); + for (final declaration in _declarationsById.values) { + await _resolve( + declaration, + throughLazy: false, + path: declaration.id.declarationName, + ); + } + _validateGeneratedHelperNames(); + return _graph; + } + + void _registerElement(Element element, _Declaration declaration) { + _declarationsByElement[element.baseElement] = declaration; + _declarationsByElement[element] = declaration; + if (element is TopLevelVariableElement) { + final getter = element.getter; + if (getter != null) { + _declarationsByElement[getter.baseElement] = declaration; + } + } else if (element is GetterElement) { + _declarationsByElement[element.variable.baseElement] = declaration; + } + } + + Expression? _declarationExpression( + ResolvedLibraryResult result, + Element element, + ) { + final declaration = result.getFragmentDeclaration(element.firstFragment); + final node = declaration?.node; + if (node is VariableDeclaration) return node.initializer; + if (node is FunctionDeclaration && node.isGetter) { + final body = node.functionExpression.body; + if (body is ExpressionFunctionBody) return body.expression; + if (body is BlockFunctionBody && body.block.statements.length == 1) { + final statement = body.block.statements.single; + if (statement is ReturnStatement) return statement.expression; + } + } + return null; + } + + Future _resolve( + _Declaration declaration, { + required bool throughLazy, + required String path, + }) async { + switch (_graph.stateOf(declaration.id)) { + case AckResolutionState.resolved: + return; + case AckResolutionState.visiting: + if (throughLazy) return; + throw InvalidGenerationSource( + 'Ordinary schema alias cycle detected at $path. Recursive model ' + 'edges must use named Ack.lazy(...).', + element: declaration.element, + ); + case AckResolutionState.unseen: + break; + } + + _graph.begin(declaration.id); + final chain = _chain(declaration.expression); + _rejectTransform(chain, path, declaration.element); + if (chain.nullable) { + throw InvalidGenerationSource( + '$path is a nullable root. Generated Ack models are non-nullable.', + element: declaration.element, + todo: 'Remove .nullable() from the annotated root schema.', + ); + } + + final baseName = chain.base?.methodName.name; + final AckModelNode node; + if (chain.hasCodec) { + node = await _valueNode(declaration, path); + } else if (baseName == 'object') { + node = await _objectNode(declaration, chain, path); + } else if (baseName == 'discriminated') { + node = await _unionNode(declaration, chain, path); + } else if (chain.reference != null) { + node = await _aliasNode(declaration, chain.reference!, path); + } else { + const supportedValueRoots = { + 'string', + 'integer', + 'double', + 'number', + 'boolean', + 'list', + 'literal', + 'enumString', + 'enumValues', + 'uri', + 'date', + 'datetime', + 'duration', + 'codec', + 'lazy', + }; + if (!supportedValueRoots.contains(baseName)) { + _rejectUnsupportedRoot(baseName, path, declaration.element); + } + node = await _valueNode(declaration, path); + } + _graph.complete(node); + } + + Future _valueNode( + _Declaration declaration, + String path, + ) async { + final expression = declaration.expression; + final types = _schemaTypes(expression, path, declaration.element); + return AckValueModelNode( + id: declaration.id, + className: declaration.className, + boundaryType: types.boundary, + runtimeRef: await _runtimeRefForSchema( + expression, + path: path, + context: declaration.element, + throughLazy: false, + ), + encodeCapability: AckEncodeCapability.bidirectional, + sourceLocation: _location(declaration.element), + description: _description(expression), + ); + } + + Future _aliasNode( + _Declaration declaration, + Expression reference, + String path, + ) async { + final target = _localDeclaration(reference); + if (target == null) { + throw InvalidGenerationSource( + '$path is an unresolvable dynamic schema alias.', + element: declaration.element, + ); + } + await _resolve( + target, + throughLazy: false, + path: '$path -> ${target.id.declarationName}', + ); + final source = _graph.nodeFor(target.id)!; + if (source is AckObjectModelNode) { + return AckObjectModelNode( + id: declaration.id, + className: declaration.className, + boundaryType: source.boundaryType, + runtimeRef: source.runtimeRef, + encodeCapability: source.encodeCapability, + sourceLocation: _location(declaration.element), + fields: source.fields, + additionalProperties: source.additionalProperties, + description: source.description, + ); + } + if (source is AckUnionModelNode) { + throw InvalidGenerationSource( + '$path aliases a discriminated union. Annotate and use the original ' + 'union model directly.', + element: declaration.element, + ); + } + return AckValueModelNode( + id: declaration.id, + className: declaration.className, + boundaryType: source.boundaryType, + runtimeRef: source.runtimeRef, + encodeCapability: source.encodeCapability, + sourceLocation: _location(declaration.element), + description: source.description, + ); + } + + Future _objectNode( + _Declaration declaration, + _SchemaChain chain, + String path, + ) async { + final invocation = chain.base!; + final arguments = _argumentExpressions(invocation.argumentList); + if (arguments.isEmpty || arguments.first is! SetOrMapLiteral) { + throw InvalidGenerationSource( + '$path must use a map literal in Ack.object(...).', + element: declaration.element, + ); + } + final literal = arguments.first as SetOrMapLiteral; + final additionalProperties = _additionalProperties(declaration.expression); + final fields = []; + for (final entry in literal.elements) { + if (entry is! MapLiteralEntry || entry.key is! SimpleStringLiteral) { + throw InvalidGenerationSource( + '$path object keys must be string literals.', + element: declaration.element, + ); + } + final jsonKey = (entry.key as SimpleStringLiteral).value; + if (!RegExp(r'^[A-Za-z_$][A-Za-z0-9_$]*$').hasMatch(jsonKey) || + _dartKeywords.contains(jsonKey)) { + throw InvalidGenerationSource( + '$path.$jsonKey cannot be represented as a Dart field name.', + element: declaration.element, + ); + } + if (_reservedMembers.contains(jsonKey)) { + throw InvalidGenerationSource( + '$path.$jsonKey conflicts with generated/Object member "$jsonKey".', + element: declaration.element, + ); + } + if (additionalProperties && jsonKey == 'additionalProperties') { + throw InvalidGenerationSource( + '$path.$jsonKey conflicts with the generated additional-properties ' + 'member.', + element: declaration.element, + ); + } + final fieldPath = '$path.$jsonKey'; + final fieldChain = _chain(entry.value); + _rejectTransform(fieldChain, fieldPath, declaration.element); + if (fieldChain.base?.methodName.name == 'object') { + throw InvalidGenerationSource( + '$fieldPath uses an anonymous inline Ack.object(...).', + element: declaration.element, + todo: 'Extract it to a named @AckType schema declaration.', + ); + } + fields.add( + AckFieldNode( + dartName: jsonKey, + jsonKey: jsonKey, + presence: _fieldPresence(fieldChain), + nullable: fieldChain.nullable, + runtimeRef: await _runtimeRefForSchema( + entry.value, + path: fieldPath, + context: declaration.element, + throughLazy: false, + ), + description: _description(entry.value), + ), + ); + } + final types = _schemaTypes( + declaration.expression, + path, + declaration.element, + ); + return AckObjectModelNode( + id: declaration.id, + className: declaration.className, + boundaryType: types.boundary, + runtimeRef: types.runtime, + encodeCapability: AckEncodeCapability.bidirectional, + sourceLocation: _location(declaration.element), + fields: fields, + additionalProperties: additionalProperties, + description: _description(declaration.expression), + ); + } + + Future _unionNode( + _Declaration declaration, + _SchemaChain chain, + String path, + ) async { + String? discriminatorKey; + SetOrMapLiteral? branchesLiteral; + for (final argumentNode in chain.base!.argumentList.arguments) { + final argument = _namedArgument(argumentNode); + if (argument == null) continue; + switch (argument.name) { + case 'discriminatorKey': + final value = argument.expression; + if (value is SimpleStringLiteral) discriminatorKey = value.value; + case 'schemas': + final value = argument.expression; + if (value is SetOrMapLiteral) branchesLiteral = value; + } + } + if (discriminatorKey == null || branchesLiteral == null) { + throw InvalidGenerationSource( + '$path must provide literal discriminatorKey and schemas arguments.', + element: declaration.element, + ); + } + if (_reservedMembers.contains(discriminatorKey) || + _dartKeywords.contains(discriminatorKey) || + discriminatorKey == 'additionalProperties') { + throw InvalidGenerationSource( + '$path.$discriminatorKey conflicts with a generated member or Dart ' + 'keyword.', + element: declaration.element, + ); + } + + final branches = {}; + for (final element in branchesLiteral.elements) { + if (element is! MapLiteralEntry || element.key is! SimpleStringLiteral) { + throw InvalidGenerationSource( + '$path discriminated branches must be a string-keyed map literal.', + element: declaration.element, + ); + } + final value = (element.key as SimpleStringLiteral).value; + final target = _localDeclaration(element.value); + if (target == null) { + throw InvalidGenerationSource( + '$path.$value is a cross-library or unresolvable discriminated branch.', + element: declaration.element, + ); + } + _validateUnionBranchDiscriminator(target, discriminatorKey, value); + await _resolve(target, throughLazy: false, path: '$path.$value'); + final branch = _graph.nodeFor(target.id); + if (branch is! AckObjectModelNode) { + throw InvalidGenerationSource( + '$path.$value must reference an @AckType object schema.', + element: declaration.element, + ); + } + final owner = _unionOwnerByBranch[target.id]; + if (owner != null && owner != declaration.id) { + throw InvalidGenerationSource( + '${target.id.declarationName} belongs to multiple discriminated unions.', + element: declaration.element, + ); + } + _unionOwnerByBranch[target.id] = declaration.id; + _graph.replace( + AckObjectModelNode( + id: branch.id, + className: branch.className, + boundaryType: branch.boundaryType, + runtimeRef: branch.runtimeRef, + encodeCapability: branch.encodeCapability, + sourceLocation: branch.sourceLocation, + fields: branch.fields, + additionalProperties: branch.additionalProperties, + unionId: declaration.id, + discriminatorKey: discriminatorKey, + discriminatorValue: value, + description: branch.description, + ), + ); + branches[value] = target.id; + } + if (branches.isEmpty) { + throw InvalidGenerationSource( + '$path must declare at least one discriminated branch.', + element: declaration.element, + ); + } + final types = _schemaTypes( + declaration.expression, + path, + declaration.element, + ); + return AckUnionModelNode( + id: declaration.id, + className: declaration.className, + boundaryType: types.boundary, + runtimeRef: types.runtime, + encodeCapability: AckEncodeCapability.bidirectional, + sourceLocation: _location(declaration.element), + discriminatorKey: discriminatorKey, + branches: branches, + description: _description(declaration.expression), + ); + } + + Future _runtimeRefForSchema( + Expression expression, { + required String path, + required Element context, + required bool throughLazy, + }) async { + final chain = _chain(expression); + _rejectTransform(chain, path, context); + if (chain.hasCodec) { + return _schemaTypes(expression, path, context).runtime; + } + final baseName = chain.base?.methodName.name; + switch (baseName) { + case 'object': + throw InvalidGenerationSource( + '$path uses an anonymous inline Ack.object(...).', + element: context, + ); + case 'any': + case 'anyOf': + case 'instance': + _rejectUnsupportedRoot(baseName, path, context); + case 'list': + final arguments = _argumentExpressions(chain.base!.argumentList); + if (arguments.isEmpty) { + throw InvalidGenerationSource( + '$path has an empty Ack.list().', + element: context, + ); + } + return AckListTypeRef( + await _runtimeRefForSchema( + arguments.first, + path: '$path[]', + context: context, + throughLazy: throughLazy, + ), + ); + case 'enumValues': + final arguments = _argumentExpressions(chain.base!.argumentList); + final valuesType = arguments.firstOrNull?.staticType; + if (valuesType is InterfaceType && + valuesType.isDartCoreList && + valuesType.typeArguments.length == 1) { + return _typeRef(valuesType.typeArguments.single, context); + } + throw InvalidGenerationSource( + '$path Ack.enumValues(...) enum type is not statically resolvable.', + element: context, + ); + case 'lazy': + return _lazyType(chain.base!, path, context); + case 'discriminated': + throw InvalidGenerationSource( + '$path uses an anonymous discriminated union.', + element: context, + ); + } + + final reference = chain.reference; + if (reference != null) { + final model = await _modelReference( + reference, + path: path, + context: context, + throughLazy: throughLazy, + ); + if (model != null) return model; + } + return _schemaTypes(expression, path, context).runtime; + } + + Future _lazyType( + MethodInvocation invocation, + String path, + Element context, + ) async { + final arguments = _argumentExpressions(invocation.argumentList); + if (arguments.length < 2 || arguments.first is! SimpleStringLiteral) { + throw InvalidGenerationSource( + '$path must use named Ack.lazy(name, () => schema) recursion.', + element: context, + ); + } + final callback = arguments[1]; + if (callback is! FunctionExpression) { + throw InvalidGenerationSource( + '$path Ack.lazy builder must be a closure.', + element: context, + ); + } + final body = callback.body; + Expression? target; + if (body is ExpressionFunctionBody) target = body.expression; + if (body is BlockFunctionBody && body.block.statements.length == 1) { + final statement = body.block.statements.single; + if (statement is ReturnStatement) target = statement.expression; + } + if (target == null) { + throw InvalidGenerationSource( + '$path Ack.lazy builder is not statically resolvable.', + element: context, + ); + } + final model = await _modelReference( + target, + path: path, + context: context, + throughLazy: true, + ); + if (model == null) { + throw InvalidGenerationSource( + '$path Ack.lazy must resolve to a named @AckType schema.', + element: context, + ); + } + return model; + } + + Future _modelReference( + Expression expression, { + required String path, + required Element context, + required bool throughLazy, + }) async { + final element = _referencedElement(expression); + if (element == null) return null; + final local = _declarationsByElement[element.baseElement]; + if (local != null) { + await _resolve(local, throughLazy: throughLazy, path: path); + final runtime = _schemaTypes( + local.expression, + path, + local.element, + ).runtime; + return AckModelTypeRef( + schemaId: local.id, + className: local.className, + runtimeRef: runtime, + ); + } + if (!_hasAckType(element)) return null; + final declaration = _propertyDeclaration(element); + final name = declaration.name; + final owningLibrary = declaration.library; + if (name == null || owningLibrary == null) return null; + return AckModelTypeRef( + schemaId: AckSchemaId( + libraryUri: owningLibrary.uri, + declarationName: name, + ), + className: _className(name, _annotationName(declaration), declaration), + runtimeRef: _schemaTypes(expression, path, context).runtime, + importPrefix: _expressionPrefix(expression), + ); + } + + _SchemaTypes _schemaTypes( + Expression expression, + String path, + Element context, + ) { + final type = expression.staticType; + if (type is! InterfaceType) { + throw InvalidGenerationSource( + '$path has no resolvable AckSchema type.', + element: context, + ); + } + final InterfaceElement? ackElement = _isAckSchema(type) + ? type.element + : type.element.allSupertypes.where(_isAckSchema).firstOrNull?.element; + final ackType = ackElement == null ? null : type.asInstanceOf(ackElement); + if (ackType == null || ackType.typeArguments.length != 2) { + throw InvalidGenerationSource( + '$path does not resolve to AckSchema.', + element: context, + ); + } + return ( + boundary: _typeRef(ackType.typeArguments[0], context), + runtime: _typeRef(ackType.typeArguments[1], context), + ); + } + + bool _isAckSchema(InterfaceType type) { + return type.element.name == 'AckSchema' && + type.element.library.uri.toString() == + 'package:ack/src/schemas/schema.dart'; + } + + AckTypeRef _typeRef(DartType type, Element context) { + if (type is DynamicType) { + return const AckNullableTypeRef(AckScalarTypeRef('Object')); + } + if (type is TypeParameterType) { + return AckExternalTypeRef( + name: type.element.name ?? 'Object', + libraryUri: context.library?.uri ?? Uri.parse('dart:core'), + ); + } + if (type is! InterfaceType) { + throw InvalidGenerationSource( + 'Unsupported runtime type ${type.getDisplayString()}.', + element: context, + ); + } + final nullable = type.nullabilitySuffix == NullabilitySuffix.question; + final name = type.element.name ?? type.getDisplayString(); + AckTypeRef result; + if (type.isDartCoreList && type.typeArguments.length == 1) { + result = AckListTypeRef(_typeRef(type.typeArguments.single, context)); + } else if (type.isDartCoreSet && type.typeArguments.length == 1) { + result = AckSetTypeRef(_typeRef(type.typeArguments.single, context)); + } else if (type.isDartCoreMap && type.typeArguments.length == 2) { + result = AckMapTypeRef(_typeRef(type.typeArguments[1], context)); + } else if (type.element.library.uri.toString() == 'dart:core' && + const { + 'String', + 'int', + 'double', + 'num', + 'bool', + 'Object', + }.contains(name)) { + result = AckScalarTypeRef(name); + } else { + final owner = type.element.library.uri; + result = AckExternalTypeRef( + name: name, + libraryUri: owner, + importPrefix: _visiblePrefix(owner), + typeArguments: [ + for (final argument in type.typeArguments) + _typeRef(argument, context), + ], + ); + } + return nullable ? AckNullableTypeRef(result) : result; + } + + String? _visiblePrefix(Uri target) { + for (final import in library.element.firstFragment.libraryImports) { + if (import.importedLibrary?.uri != target) continue; + return import.prefix?.element.name; + } + return null; + } + + _SchemaChain _chain(Expression expression) { + if (expression is! MethodInvocation) { + return _SchemaChain( + base: null, + reference: expression, + optional: false, + nullable: false, + defaulted: false, + hasTransform: false, + hasCodec: false, + ); + } + MethodInvocation? current = expression; + MethodInvocation? base; + Expression? reference; + var optional = false; + var nullable = false; + var defaulted = false; + var transform = false; + var codec = false; + while (current != null) { + final name = current.methodName.name; + optional |= name == 'optional'; + nullable |= name == 'nullable'; + defaulted |= name == 'withDefault'; + transform |= name == 'transform'; + codec |= name == 'codec'; + final target = current.target; + if (_isAckTarget(target)) { + base = current; + break; + } + if (target is MethodInvocation) { + current = target; + } else { + reference = target; + break; + } + } + return _SchemaChain( + base: base, + reference: reference, + optional: optional, + nullable: nullable, + defaulted: defaulted, + hasTransform: transform, + hasCodec: codec, + ); + } + + bool _isAckTarget(Expression? expression) { + if (expression is SimpleIdentifier) return expression.name == 'Ack'; + if (expression is PrefixedIdentifier) { + return expression.identifier.name == 'Ack'; + } + return false; + } + + Element? _referencedElement(Expression expression) { + if (expression is SimpleIdentifier) { + final element = expression.element; + return element == null ? null : _propertyDeclaration(element); + } + if (expression is PrefixedIdentifier) { + final element = expression.identifier.element; + return element == null ? null : _propertyDeclaration(element); + } + if (expression is MethodInvocation) { + final chain = _chain(expression); + final reference = chain.reference; + return reference == null ? null : _referencedElement(reference); + } + return null; + } + + Element _propertyDeclaration(Element element) { + if (element is GetterElement && element.isOriginVariable) { + return element.variable.baseElement; + } + return element.baseElement; + } + + _Declaration? _localDeclaration(Expression expression) { + final element = _referencedElement(expression); + return element == null ? null : _declarationsByElement[element.baseElement]; + } + + String? _expressionPrefix(Expression expression) { + if (expression is PrefixedIdentifier) return expression.prefix.name; + if (expression is MethodInvocation && expression.target != null) { + return _expressionPrefix(expression.target!); + } + return null; + } + + bool _hasAckType(Element element) { + return TypeChecker.typeNamed( + AckType, + ).hasAnnotationOfExact(_propertyDeclaration(element)); + } + + String? _annotationName(Element element) { + final annotation = TypeChecker.typeNamed( + AckType, + ).firstAnnotationOfExact(_propertyDeclaration(element)); + final field = annotation == null + ? null + : ConstantReader(annotation).peek('name'); + return field == null || field.isNull ? null : field.stringValue; + } + + AckFieldPresence _fieldPresence(_SchemaChain chain) { + if (chain.defaulted) return AckFieldPresence.defaulted; + if (chain.optional) return AckFieldPresence.optional; + return AckFieldPresence.required; + } + + String _className( + String declarationName, + String? customName, + Element element, + ) { + if (customName != null) { + if (customName.trim() != customName || + !RegExp(r'^[A-Z][A-Za-z0-9]*$').hasMatch(customName)) { + throw InvalidGenerationSource( + 'Invalid @AckType name "$customName". Names must be unchanged UpperCamelCase identifiers.', + element: element, + ); + } + return customName; + } + var stem = declarationName; + if (stem.endsWith('Schema')) { + stem = stem.substring(0, stem.length - 'Schema'.length); + } + if (stem.isEmpty || !RegExp(r'^[A-Za-z][A-Za-z0-9]*$').hasMatch(stem)) { + throw InvalidGenerationSource( + 'Cannot derive an UpperCamelCase model name from "$declarationName".', + element: element, + ); + } + return '${stem[0].toUpperCase()}${stem.substring(1)}'; + } + + void _validateClassNames() { + final generated = {}; + final localNames = { + for (final element in library.allElements) + if (element.name case final name?) name, + }; + for (final declaration in _declarationsById.values) { + final name = declaration.className; + if (!generated.add(name)) { + throw InvalidGenerationSource( + 'Multiple @AckType declarations generate "$name".', + element: declaration.element, + ); + } + if (localNames.contains(name)) { + throw InvalidGenerationSource( + 'Generated class "$name" conflicts with a local declaration.', + element: declaration.element, + ); + } + final visible = library.element.firstFragment.scope.lookup(name).getter; + if (visible != null) { + throw InvalidGenerationSource( + 'Generated class "$name" conflicts with a visible unprefixed import.', + element: declaration.element, + ); + } + } + } + + void _validateGeneratedHelperNames() { + AckObjectModelNode? passthroughNode; + for (final node in _graph.nodes.whereType()) { + if (node.additionalProperties) { + passthroughNode = node; + break; + } + } + if (passthroughNode == null) return; + + final localNames = { + for (final element in library.allElements) + if (element.name case final name?) name, + }; + for (final helperName in _generatedHelperNames) { + if (!localNames.contains(helperName)) continue; + throw InvalidGenerationSource( + 'Generated helper "$helperName" conflicts with a local declaration.', + element: _declarationsById[passthroughNode.id]?.element, + ); + } + } + + void _validateUnionBranchDiscriminator( + _Declaration branch, + String discriminatorKey, + String discriminatorValue, + ) { + final chain = _chain(branch.expression); + final object = chain.base; + if (object?.methodName.name != 'object') return; + final arguments = _argumentExpressions(object!.argumentList); + if (arguments.firstOrNull case SetOrMapLiteral(:final elements)) { + for (final entry in elements.whereType()) { + final key = entry.key; + if (key is! SimpleStringLiteral || key.value != discriminatorKey) { + continue; + } + if (_matchesDiscriminator(entry.value, discriminatorValue)) return; + throw InvalidGenerationSource( + '${branch.id.declarationName}.$discriminatorKey must be an exact ' + 'literal or enum containing "$discriminatorValue".', + element: branch.element, + ); + } + } + } + + bool _matchesDiscriminator(Expression expression, String expected) { + final chain = _chain(expression); + final base = chain.base; + if (base == null || !identical(base, expression)) return false; + final arguments = _argumentExpressions(base.argumentList); + return switch (base.methodName.name) { + 'literal' => + arguments.firstOrNull is SimpleStringLiteral && + (arguments.first as SimpleStringLiteral).value == expected, + 'enumString' => + arguments.firstOrNull is ListLiteral && + (arguments.first as ListLiteral).elements.any( + (element) => + element is SimpleStringLiteral && element.value == expected, + ), + _ => false, + }; + } + + void _rejectTransform(_SchemaChain chain, String path, Element element) { + if (!chain.hasTransform) return; + throw InvalidGenerationSource( + '$path uses one-way .transform(). Migrate this path to .codec() with an encoder.', + element: element, + ); + } + + Never _rejectUnsupportedRoot(String? name, String path, Element element) { + final label = switch (name) { + 'any' => 'Ack.any()', + 'anyOf' => 'Ack.anyOf()', + 'instance' => 'bare Ack.instance()', + null => 'an unresolvable dynamic schema factory', + _ => 'Ack.$name()', + }; + throw InvalidGenerationSource( + '$path uses unsupported $label; it cannot provide a static model shape.', + element: element, + ); + } + + bool _additionalProperties(Expression expression) { + Expression? current = expression; + while (current is MethodInvocation) { + if (current.methodName.name == 'passthrough') return true; + if (current.methodName.name == 'object') { + for (final argumentNode in current.argumentList.arguments) { + final argument = _namedArgument(argumentNode); + if (argument != null && + argument.name == 'additionalProperties' && + argument.expression is BooleanLiteral) { + return (argument.expression as BooleanLiteral).value; + } + } + } + current = current.target; + } + return false; + } + + String? _description(Expression expression) { + Expression? current = expression; + while (current is MethodInvocation) { + final arguments = _argumentExpressions(current.argumentList); + if (current.methodName.name == 'describe' && + arguments.firstOrNull is SimpleStringLiteral) { + return (arguments.first as SimpleStringLiteral).value; + } + current = current.target; + } + return null; + } + + /// Normalizes analyzer 10's expression arguments and analyzer 13's + /// dedicated argument nodes into the expression API used by the graph. + List _argumentExpressions(ArgumentList argumentList) => + argumentList.arguments + .map((argument) => _argumentExpression(argument)) + .toList(growable: false); + + Expression _argumentExpression(AstNode argument) { + final named = _namedArgument(argument); + if (named != null) return named.expression; + if (argument is Expression) return argument; + + final dynamic dynamicArgument = argument; + // Analyzer 13+ wraps positional expressions in the Argument interface. + // ignore: avoid_dynamic_calls + return dynamicArgument.argumentExpression as Expression; + } + + ({String name, Expression expression})? _namedArgument(AstNode argument) { + final dynamic dynamicArgument = argument; + String? name; + try { + // Analyzer 13+ exposes a Token directly. + // ignore: avoid_dynamic_calls + name = dynamicArgument.name.lexeme as String?; + } on Object { + try { + // Analyzer 10 exposes NamedExpression.name as a Label. + // ignore: avoid_dynamic_calls + name = dynamicArgument.name.label.name as String?; + } on Object { + return null; + } + } + if (name == null) return null; + + try { + // Analyzer 13+ uses Argument.argumentExpression. + // ignore: avoid_dynamic_calls + final expression = dynamicArgument.argumentExpression as Expression; + return (name: name, expression: expression); + } on Object { + // Analyzer 10 uses NamedExpression.expression. + // ignore: avoid_dynamic_calls + final expression = dynamicArgument.expression as Expression; + return (name: name, expression: expression); + } + } + + AckSourceLocation _location(Element element) { + return AckSourceLocation( + libraryUri: element.library?.uri ?? library.element.uri, + offset: element.firstFragment.offset, + length: element.name?.length ?? 0, + ); + } +} diff --git a/packages/ack_generator/lib/src/builder.dart b/packages/ack_generator/lib/src/builder.dart index 87c8e85a..e6c8a5c6 100644 --- a/packages/ack_generator/lib/src/builder.dart +++ b/packages/ack_generator/lib/src/builder.dart @@ -3,10 +3,7 @@ import 'package:source_gen/source_gen.dart'; import 'generator.dart'; -/// Creates the shared-part builder for Ack model generation. +/// Creates the dedicated-part builder for Ack model generation. Builder ackGenerator(BuilderOptions options) { - return SharedPartBuilder( - [AckSchemaGenerator()], - 'ack', - ); + return PartBuilder([AckSchemaGenerator()], '.ack.dart', options: options); } diff --git a/packages/ack_generator/lib/src/builders/class_builder.dart b/packages/ack_generator/lib/src/builders/class_builder.dart deleted file mode 100644 index ad4de148..00000000 --- a/packages/ack_generator/lib/src/builders/class_builder.dart +++ /dev/null @@ -1,907 +0,0 @@ -import 'package:analyzer/dart/element/element2.dart'; -import 'package:analyzer/dart/element/type.dart'; -import 'package:code_builder/code_builder.dart'; - -import '../models/field_info.dart'; -import '../models/model_info.dart'; - -class _ModelLookups { - _ModelLookups(List models) - : byClassName = { - for (final model in models) model.className: model, - }, - bySchemaName = { - for (final model in models) model.schemaClassName: model, - }; - - final Map byClassName; - final Map bySchemaName; -} - -/// Emits immutable Dart classes from analyzed Ack schemas. -/// -/// Ack remains responsible for validation and boundary/runtime codecs. The -/// generated class stores typed fields and delegates parse/encode operations to -/// [AckModelAdapter]. -final class AckClassBuilder { - static const _runtimeMapType = 'Map'; - static const _jsonMapType = 'Map'; - - static const _reservedObjectMembers = { - r'$ack', - 'parse', - 'safeParse', - 'fromMap', - 'fromJson', - 'toMap', - 'toJson', - 'safeToMap', - 'safeToJson', - '_fromAckRuntime', - '_toAckRuntime', - 'additionalProperties', - }; - - String? _ackImportPrefix; - - void setAckImportPrefix(String? prefix) { - _ackImportPrefix = prefix; - } - - List buildClasses(List models) { - if (models.isEmpty) return const []; - - final lookups = _ModelLookups(models); - _validateModels(models); - - final result = []; - final emittedClassNames = {}; - - for (final model in models) { - if (model.isNullableSchema) { - throw StateError( - 'Top-level nullable schema "${model.schemaClassName}" cannot ' - 'generate a non-nullable model class.', - ); - } - - if (model.isDiscriminatedBaseDefinition) { - if (emittedClassNames.add(model.className)) { - result.add(_buildUnionBase(model, lookups)); - } - - final subtypeNames = model.subtypeNames ?? const {}; - for (final entry in subtypeNames.entries) { - final subtype = lookups.bySchemaName[entry.value]; - if (subtype == null) { - throw StateError( - 'Could not resolve discriminated branch "${entry.value}" ' - 'for ${model.className}.', - ); - } - if (emittedClassNames.add(subtype.className)) { - result.add( - _buildUnionSubtype( - subtype, - baseModel: model, - discriminatorValue: entry.key, - lookups: lookups, - ), - ); - } - } - continue; - } - - if (model.isDiscriminatedSubtype) continue; - if (!emittedClassNames.add(model.className)) continue; - - result.add( - model.representationType == kMapType - ? _buildObjectClass(model, lookups) - : _buildValueClass(model), - ); - } - - return result; - } - - void _validateModels(List models) { - final classNames = {}; - for (final model in models) { - if (!classNames.add(model.className)) { - throw StateError( - 'Multiple @AckType declarations generate the class ' - '"${model.className}".', - ); - } - - if (model.representationType != kMapType) continue; - for (final field in model.fields) { - if (_reservedObjectMembers.contains(field.name)) { - throw StateError( - 'Schema field "${field.jsonKey}" conflicts with generated member ' - '"${field.name}" on ${model.className}.', - ); - } - } - } - } - - Class _buildObjectClass(ModelInfo model, _ModelLookups lookups) { - return Class( - (b) => b - ..name = model.className - ..modifier = ClassModifier.final$ - ..docs.addAll(_buildDocs(model, 'Immutable model')) - ..fields.addAll([ - for (final field in model.fields) _buildField(field, lookups), - if (model.additionalProperties) _buildAdditionalPropertiesField(), - _buildAdapterField( - model, - schemaExpression: model.schemaClassName, - ), - ]) - ..constructors.addAll([ - _buildObjectConstructor(model, lookups), - _buildParseFactory(model.className), - _buildFromMapFactory(model.className), - _buildFromJsonFactory(model.className), - ]) - ..methods.addAll([ - _buildSafeParse(model.className), - _buildToMap(), - _buildToJson(), - _buildSafeToMap(), - _buildSafeToJson(), - _buildObjectFromRuntime(model, lookups), - _buildObjectToRuntime(model, lookups), - ]), - ); - } - - Class _buildValueClass(ModelInfo model) { - final className = model.className; - final runtimeType = model.representationType; - - return Class( - (b) => b - ..name = className - ..modifier = ClassModifier.final$ - ..docs.addAll(_buildDocs(model, 'Immutable value model')) - ..fields.addAll([ - Field( - (f) => f - ..name = 'value' - ..modifier = FieldModifier.final$ - ..type = refer(runtimeType), - ), - _buildAdapterField( - model, - schemaExpression: model.schemaClassName, - ), - ]) - ..constructors.addAll([ - Constructor( - (c) => c.requiredParameters.add( - Parameter( - (p) => p - ..name = 'value' - ..toThis = true, - ), - ), - ), - _buildParseFactory(className), - Constructor( - (c) => c - ..factory = true - ..name = 'fromJson' - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'json' - ..type = refer('Object?'), - ), - ) - ..body = const Code(r'return $ack.parse(json);'), - ), - ]) - ..methods.addAll([ - _buildSafeParse(className), - Method( - (m) => m - ..name = 'toJson' - ..body = const Code(r'return $ack.encode(this);'), - ), - Method( - (m) => m - ..name = 'safeToJson' - ..body = const Code(r'return $ack.safeEncode(this);'), - ), - Method( - (m) => m - ..name = '_fromAckRuntime' - ..static = true - ..returns = refer(className) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'value' - ..type = refer(runtimeType), - ), - ) - ..lambda = true - ..body = Code('$className(value)'), - ), - Method( - (m) => m - ..name = '_toAckRuntime' - ..returns = refer(runtimeType) - ..lambda = true - ..body = const Code('value'), - ), - ]), - ); - } - - Class _buildUnionBase(ModelInfo model, _ModelLookups lookups) { - final discriminatorKey = model.discriminatorKey!; - final cases = []; - for (final entry in model.subtypeNames!.entries) { - final subtype = lookups.bySchemaName[entry.value]; - if (subtype == null) continue; - cases.add( - '${_stringLiteral(entry.key)} => ' - '${subtype.className}._fromAckRuntime(value)', - ); - } - - final switchBody = ''' -return switch (value[${_stringLiteral(discriminatorKey)}]) { - ${cases.join(',\n ')}, - final unknown => throw StateError( - 'Unknown $discriminatorKey: \$unknown', - ), -};'''; - - return Class( - (b) => b - ..name = model.className - ..sealed = true - ..docs.addAll(_buildDocs(model, 'Discriminated model base')) - ..fields.add( - _buildAdapterField( - model, - schemaExpression: model.schemaClassName, - ), - ) - ..constructors.addAll([ - Constructor((c) => c.constant = true), - _buildParseFactory(model.className), - _buildFromMapFactory(model.className), - _buildFromJsonFactory(model.className), - ]) - ..methods.addAll([ - _buildSafeParse(model.className), - Method( - (m) => m - ..type = MethodType.getter - ..name = discriminatorKey - ..returns = refer('String'), - ), - _buildToMap(), - _buildToJson(), - _buildSafeToMap(), - _buildSafeToJson(), - Method( - (m) => m - ..name = '_fromAckRuntime' - ..static = true - ..returns = refer(model.className) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'value' - ..type = refer(_runtimeMapType), - ), - ) - ..body = Code(switchBody), - ), - Method( - (m) => m - ..name = '_toAckRuntime' - ..returns = refer(_runtimeMapType), - ), - ]), - ); - } - - Class _buildUnionSubtype( - ModelInfo model, { - required ModelInfo baseModel, - required String discriminatorValue, - required _ModelLookups lookups, - }) { - final discriminatorKey = baseModel.discriminatorKey!; - final effectiveFields = model.fields - .where((field) => field.jsonKey != discriminatorKey) - .toList(); - final effectiveModel = ModelInfo( - className: model.className, - schemaClassName: model.schemaClassName, - description: model.description, - fields: effectiveFields, - additionalProperties: model.additionalProperties, - discriminatorKey: discriminatorKey, - discriminatorValue: discriminatorValue, - schemaIdentity: model.schemaIdentity, - discriminatedBaseClassName: baseModel.className, - representationType: kMapType, - isNullableSchema: false, - ); - - return Class( - (b) => b - ..name = model.className - ..modifier = ClassModifier.final$ - ..extend = refer(baseModel.className) - ..docs.addAll(_buildDocs(model, 'Discriminated model branch')) - ..fields.addAll([ - for (final field in effectiveFields) _buildField(field, lookups), - if (model.additionalProperties) _buildAdditionalPropertiesField(), - _buildAdapterField( - model, - schemaExpression: - '${baseModel.schemaClassName}.effectiveBranch(' - '${_stringLiteral(discriminatorValue)})', - ), - ]) - ..constructors.addAll([ - _buildObjectConstructor(effectiveModel, lookups), - _buildParseFactory(model.className), - _buildFromMapFactory(model.className), - _buildFromJsonFactory(model.className), - ]) - ..methods.addAll([ - _buildSafeParse(model.className), - Method( - (m) => m - ..type = MethodType.getter - ..name = discriminatorKey - ..returns = refer('String') - ..lambda = true - ..body = Code(_stringLiteral(discriminatorValue)), - ), - _buildObjectFromRuntime(effectiveModel, lookups), - _buildObjectToRuntime( - effectiveModel, - lookups, - extraEntries: { - discriminatorKey: _stringLiteral(discriminatorValue), - }, - ), - ]), - ); - } - - List _buildDocs(ModelInfo model, String kind) { - return [ - '/// $kind generated from `${model.schemaClassName}`.', - if (model.description != null) '/// ${model.description}', - ]; - } - - Field _buildField(FieldInfo field, _ModelLookups lookups) { - return Field( - (f) => f - ..name = field.name - ..modifier = FieldModifier.final$ - ..type = refer(_fieldType(field, lookups)) - ..docs.addAll([ - if (field.description != null) '/// ${field.description}', - ]), - ); - } - - Field _buildAdditionalPropertiesField() { - return Field( - (f) => f - ..name = 'additionalProperties' - ..modifier = FieldModifier.final$ - ..type = refer(_runtimeMapType) - ..docs.add( - '/// Properties accepted by a schema with additional properties.', - ), - ); - } - - Field _buildAdapterField( - ModelInfo model, { - required String schemaExpression, - }) { - final adapter = _qualifyAckSymbol('AckModelAdapter'); - return Field( - (f) => f - ..name = r'$ack' - ..static = true - ..modifier = FieldModifier.final$ - ..assignment = Code(''' -$adapter( - schema: () => $schemaExpression, - fromRuntime: ${model.className}._fromAckRuntime, - toRuntime: (model) => model._toAckRuntime(), -)'''), - ); - } - - Constructor _buildObjectConstructor( - ModelInfo model, - _ModelLookups lookups, - ) { - return Constructor( - (c) { - for (final field in model.fields) { - c.optionalParameters.add( - Parameter( - (p) => p - ..name = field.name - ..named = true - ..required = field.isRequired - ..type = refer(_fieldType(field, lookups)), - ), - ); - c.initializers.add( - Code('${field.name} = ${_constructorValue(field)}'), - ); - } - - if (model.additionalProperties) { - c.optionalParameters.add( - Parameter( - (p) => p - ..name = 'additionalProperties' - ..named = true - ..type = refer(_runtimeMapType) - ..defaultTo = const Code('const {}'), - ), - ); - c.initializers.add( - const Code( - 'additionalProperties = ' - 'Map.unmodifiable(additionalProperties)', - ), - ); - } - }, - ); - } - - String _constructorValue(FieldInfo field) { - final name = field.name; - final nullable = field.isNullable || !field.isRequired; - - if (field.isList) { - return nullable - ? '$name == null ? null : List.unmodifiable($name)' - : 'List.unmodifiable($name)'; - } - if (field.isSet) { - return nullable - ? '$name == null ? null : Set.unmodifiable($name)' - : 'Set.unmodifiable($name)'; - } - if (field.isMap) { - return nullable - ? '$name == null ? null : Map.unmodifiable($name)' - : 'Map.unmodifiable($name)'; - } - return name; - } - - Constructor _buildParseFactory(String className) { - return Constructor( - (c) => c - ..factory = true - ..name = 'parse' - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'input' - ..type = refer('Object?'), - ), - ) - ..body = const Code(r'return $ack.parse(input);'), - ); - } - - Constructor _buildFromMapFactory(String className) { - return Constructor( - (c) => c - ..factory = true - ..name = 'fromMap' - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'map' - ..type = refer(_runtimeMapType), - ), - ) - ..body = const Code(r'return $ack.parse(map);'), - ); - } - - Constructor _buildFromJsonFactory(String className) { - return Constructor( - (c) => c - ..factory = true - ..name = 'fromJson' - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'json' - ..type = refer(_jsonMapType), - ), - ) - ..body = const Code(r'return $ack.parse(json);'), - ); - } - - Method _buildSafeParse(String className) { - return Method( - (m) => m - ..name = 'safeParse' - ..static = true - ..returns = refer( - '${_qualifyAckSymbol('SchemaResult')}<$className>', - ) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'input' - ..type = refer('Object?'), - ), - ) - ..lambda = true - ..body = const Code(r'$ack.safeParse(input)'), - ); - } - - Method _buildToMap() { - return Method( - (m) => m - ..name = 'toMap' - ..returns = refer(_runtimeMapType) - ..lambda = true - ..body = const Code(r'$ack.encode(this)'), - ); - } - - Method _buildToJson() { - return Method( - (m) => m - ..name = 'toJson' - ..returns = refer(_jsonMapType) - ..lambda = true - ..body = const Code('Map.from(toMap())'), - ); - } - - Method _buildSafeToMap() { - return Method( - (m) => m - ..name = 'safeToMap' - ..returns = refer( - '${_qualifyAckSymbol('SchemaResult')}<$_runtimeMapType>', - ) - ..lambda = true - ..body = const Code(r'$ack.safeEncode(this)'), - ); - } - - Method _buildSafeToJson() { - return Method( - (m) => m - ..name = 'safeToJson' - ..returns = refer( - '${_qualifyAckSymbol('SchemaResult')}<$_runtimeMapType>', - ) - ..lambda = true - ..body = const Code('safeToMap()'), - ); - } - - Method _buildObjectFromRuntime( - ModelInfo model, - _ModelLookups lookups, - ) { - final arguments = [ - for (final field in model.fields) - '${field.name}: ${_decodeField(field, lookups)}', - if (model.additionalProperties) - 'additionalProperties: ${_decodeAdditionalProperties(model)}', - ]; - - return Method( - (m) => m - ..name = '_fromAckRuntime' - ..static = true - ..returns = refer(model.className) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'value' - ..type = refer(_runtimeMapType), - ), - ) - ..body = Code(''' -return ${model.className}( - ${arguments.join(',\n ')}, -);'''), - ); - } - - Method _buildObjectToRuntime( - ModelInfo model, - _ModelLookups lookups, { - Map extraEntries = const {}, - }) { - final entries = [ - if (model.additionalProperties) '...additionalProperties', - for (final entry in extraEntries.entries) - '${_stringLiteral(entry.key)}: ${entry.value}', - for (final field in model.fields) _encodeFieldEntry(field, lookups), - ]; - - return Method( - (m) => m - ..name = '_toAckRuntime' - ..returns = refer(_runtimeMapType) - ..body = Code(''' -return { - ${entries.join(',\n ')}, -};'''), - ); - } - - String _decodeField(FieldInfo field, _ModelLookups lookups) { - final read = 'value[${_stringLiteral(field.jsonKey)}]'; - final nullable = field.isNullable || !field.isRequired; - final nonNull = _decodeNonNullField(field, lookups, read); - - if (!nullable) return nonNull; - if (_isDirectCastField(field)) { - final baseType = _baseFieldType(field, lookups); - return '$read as $baseType?'; - } - return '$read == null ? null : $nonNull'; - } - - String _decodeNonNullField( - FieldInfo field, - _ModelLookups lookups, - String read, - ) { - if (field.nestedSchemaRef != null) { - final typeName = _generatedModelName(field, lookups); - final castType = _nestedCastType(field, lookups); - return '$typeName.\$ack.fromRuntime($read as $castType)'; - } - - if (field.isList || field.isSet) { - final elementType = _collectionElementType(field, lookups); - if (_isGeneratedCollection(field, lookups)) { - final castType = _collectionElementCastType(field, lookups); - final converted = - '($read as List).map((item) => ' - '$elementType.\$ack.fromRuntime(item as $castType))'; - return field.isSet - ? '$converted.toSet()' - : '$converted.toList(growable: false)'; - } - return field.isSet - ? '($read as List).cast<$elementType>().toSet()' - : '($read as List).cast<$elementType>()'; - } - - if (field.isMap) { - return 'Map.from($read as Map)'; - } - - return '$read as ${_baseFieldType(field, lookups)}'; - } - - String _encodeFieldEntry(FieldInfo field, _ModelLookups lookups) { - final key = _stringLiteral(field.jsonKey); - final encoded = _encodeFieldValue(field, lookups); - if (!field.isRequired) { - return 'if (${field.name} != null) $key: $encoded'; - } - return '$key: $encoded'; - } - - String _encodeFieldValue(FieldInfo field, _ModelLookups lookups) { - final nullable = field.isNullable || !field.isRequired; - final name = field.name; - final nonNull = _encodeNonNullField(field, lookups, nullable ? '$name!' : name); - if (!nullable) return nonNull; - return '$name == null ? null : $nonNull'; - } - - String _encodeNonNullField( - FieldInfo field, - _ModelLookups lookups, - String value, - ) { - if (field.nestedSchemaRef != null) { - final typeName = _generatedModelName(field, lookups); - return '$typeName.\$ack.toRuntime($value)'; - } - - if (field.isList || field.isSet) { - if (_isGeneratedCollection(field, lookups)) { - final elementType = _collectionElementType(field, lookups); - return '$value.map((item) => ' - '$elementType.\$ack.toRuntime(item)).toList(growable: false)'; - } - return field.isSet ? '$value.toList(growable: false)' : value; - } - - return value; - } - - String _decodeAdditionalProperties(ModelInfo model) { - final knownKeys = model.fields.map((field) => field.jsonKey).toList(); - if (knownKeys.isEmpty) return 'Map.unmodifiable(value)'; - - final keys = knownKeys.map(_stringLiteral).join(', '); - return 'Map.unmodifiable(Map.fromEntries(' - 'value.entries.where((entry) => ' - '!const {$keys}.contains(entry.key))))'; - } - - String _fieldType(FieldInfo field, _ModelLookups lookups) { - final base = _baseFieldType(field, lookups); - if (field.isNullable || !field.isRequired) { - return base.endsWith('?') ? base : '$base?'; - } - return base; - } - - String _baseFieldType(FieldInfo field, _ModelLookups lookups) { - if (field.nestedSchemaRef != null) { - return _generatedModelName(field, lookups); - } - - if (field.type.isDartCoreString) return 'String'; - if (field.type.isDartCoreInt) return 'int'; - if (field.type.isDartCoreDouble) return 'double'; - if (field.type.isDartCoreBool) return 'bool'; - if (field.type.isDartCoreNum) return 'num'; - - if (_isSpecialType(field.type) || field.isEnum) { - return field.displayTypeOverride ?? - field.type.getDisplayString(withNullability: false); - } - - if (field.isList) { - return 'List<${_collectionElementType(field, lookups)}>'; - } - if (field.isSet) { - return 'Set<${_collectionElementType(field, lookups)}>'; - } - if (field.isMap) return _runtimeMapType; - - if (field.displayTypeOverride != null) { - return field.displayTypeOverride!; - } - - return 'Object?'; - } - - String _generatedModelName(FieldInfo field, _ModelLookups lookups) { - final override = field.displayTypeOverride; - if (override != null) return _removeGeneratedTypeSuffix(override); - - final schemaName = field.nestedSchemaRef; - final model = schemaName == null ? null : lookups.bySchemaName[schemaName]; - return model?.className ?? 'Object'; - } - - String _nestedCastType(FieldInfo field, _ModelLookups lookups) { - final override = field.nestedSchemaCastTypeOverride; - if (override != null) return override; - - final schemaName = field.nestedSchemaRef; - final model = schemaName == null ? null : lookups.bySchemaName[schemaName]; - return model?.representationType ?? _runtimeMapType; - } - - String _collectionElementType( - FieldInfo field, - _ModelLookups lookups, - ) { - final override = field.collectionElementDisplayTypeOverride; - if (override != null) { - return _isGeneratedCollection(field, lookups) - ? _removeGeneratedTypeSuffix(override) - : override; - } - - final schemaRef = field.listElementSchemaRef; - if (schemaRef != null) { - final model = lookups.bySchemaName[schemaRef]; - if (model != null) return model.className; - } - - final type = field.type; - if (type is ParameterizedType && type.typeArguments.isNotEmpty) { - return type.typeArguments.first.getDisplayString( - withNullability: false, - ); - } - - return 'Object?'; - } - - String _collectionElementCastType( - FieldInfo field, - _ModelLookups lookups, - ) { - final override = field.collectionElementCastTypeOverride; - if (override != null) return override; - - final schemaRef = field.listElementSchemaRef; - if (schemaRef != null) { - final model = lookups.bySchemaName[schemaRef]; - if (model != null) return model.representationType; - } - - return _runtimeMapType; - } - - bool _isGeneratedCollection( - FieldInfo field, - _ModelLookups lookups, - ) { - if (field.collectionElementIsCustomType) return true; - final schemaRef = field.listElementSchemaRef; - return schemaRef != null && lookups.bySchemaName.containsKey(schemaRef); - } - - bool _isDirectCastField(FieldInfo field) { - return field.nestedSchemaRef == null && - !field.isList && - !field.isSet && - !field.isMap; - } - - bool _isSpecialType(DartType type) { - final element = type.element3; - if (element is! InterfaceElement2) return false; - final name = element.name3; - final library = element.library2?.uri.toString(); - return library == 'dart:core' && - (name == 'DateTime' || name == 'Uri' || name == 'Duration'); - } - - String _removeGeneratedTypeSuffix(String name) { - final separator = name.lastIndexOf('.'); - final prefix = separator < 0 ? '' : name.substring(0, separator + 1); - final localName = separator < 0 ? name : name.substring(separator + 1); - if (!localName.endsWith('Type')) return name; - return '$prefix${localName.substring(0, localName.length - 4)}'; - } - - String _qualifyAckSymbol(String symbol) { - final prefix = _ackImportPrefix; - return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; - } - - String _stringLiteral(String value) { - final escaped = value - .replaceAll(r'\', r'\\') - .replaceAll("'", r"\'") - .replaceAll(r'$', r'\$'); - return "'$escaped'"; - } -} diff --git a/packages/ack_generator/lib/src/builders/model_emitter.dart b/packages/ack_generator/lib/src/builders/model_emitter.dart new file mode 100644 index 00000000..bde25c54 --- /dev/null +++ b/packages/ack_generator/lib/src/builders/model_emitter.dart @@ -0,0 +1,651 @@ +import 'package:code_builder/code_builder.dart'; + +import '../models/schema_model_graph.dart'; + +/// Emits immutable model declarations solely from a normalized model graph. +final class AckModelEmitter { + AckModelEmitter({this.ackPrefix}); + + final String? ackPrefix; + + List emit(AckModelGraph graph) { + final nodes = {for (final node in graph.nodes) node.id: node}; + final output = []; + for (final node in graph.nodes) { + switch (node) { + case AckObjectModelNode(:final unionId) when unionId != null: + continue; + case AckObjectModelNode(): + output.add(_object(node)); + case AckValueModelNode(): + output.add(_value(node)); + case AckUnionModelNode(): + output.add(_union(node, nodes)); + for (final branchId in node.branches.values) { + final branch = nodes[branchId]; + if (branch is AckObjectModelNode) { + output.add(_branch(branch, node)); + } + } + } + } + final needsDynamicMapCopy = graph.nodes.whereType().any( + (node) => node.additionalProperties, + ); + if (needsDynamicMapCopy) { + output.addAll([_immutableValueHelper(), _immutableMapHelper()]); + } + return output; + } + + Class _object(AckObjectModelNode node) { + return Class( + (b) => b + ..name = node.className + ..modifier = ClassModifier.final$ + ..docs.addAll(_docs(node, 'Immutable model')) + ..fields.addAll([ + for (final field in node.fields) _field(field), + if (node.additionalProperties) _additionalPropertiesField(), + _adapter(node, node.id.declarationName), + ]) + ..constructors.addAll([ + _objectConstructor(node.fields, node.additionalProperties), + _parseFactory(), + _fromJsonFactory(_objectJsonType), + ]) + ..methods.addAll([ + _safeParse(node.className), + _objectToJson(), + _objectSafeToJson(), + _objectFromRuntime(node), + _objectToRuntime(node), + ]), + ); + } + + Class _value(AckValueModelNode node) { + final runtimeRef = _type(node.runtimeRef); + final boundaryType = _type(node.boundaryType); + return Class( + (b) => b + ..name = node.className + ..modifier = ClassModifier.final$ + ..docs.addAll(_docs(node, 'Immutable value model')) + ..fields.addAll([ + Field( + (f) => f + ..name = 'value' + ..modifier = FieldModifier.final$ + ..type = refer(runtimeRef), + ), + _adapter(node, node.id.declarationName), + ]) + ..constructors.addAll([ + _valueConstructor(node, runtimeRef), + _parseFactory(), + _fromJsonFactory(boundaryType), + ]) + ..methods.addAll([ + _safeParse(node.className), + Method( + (m) => m + ..name = 'toJson' + ..returns = refer(boundaryType) + ..lambda = true + ..body = const Code(r'$ack.encode(this)'), + ), + Method( + (m) => m + ..name = 'safeToJson' + ..returns = refer('${_ack('SchemaResult')}<$boundaryType>') + ..lambda = true + ..body = const Code(r'$ack.safeEncode(this)'), + ), + Method( + (m) => m + ..name = '_fromAckRuntime' + ..static = true + ..returns = refer(node.className) + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..type = refer(runtimeRef), + ), + ) + ..lambda = true + ..body = Code('${node.className}(value)'), + ), + Method( + (m) => m + ..name = '_toAckRuntime' + ..returns = refer(runtimeRef) + ..lambda = true + ..body = const Code('value'), + ), + ]), + ); + } + + Class _union(AckUnionModelNode node, Map nodes) { + final cases = []; + for (final entry in node.branches.entries) { + final branch = nodes[entry.value]!; + cases.add( + '${_literal(entry.key)} => ${branch.className}._fromAckRuntime(value)', + ); + } + return Class( + (b) => b + ..name = node.className + ..sealed = true + ..docs.addAll(_docs(node, 'Discriminated model base')) + ..fields.add(_adapter(node, node.id.declarationName)) + ..constructors.addAll([ + Constructor((c) => c.constant = true), + _parseFactory(), + _fromJsonFactory(_objectJsonType), + ]) + ..methods.addAll([ + _safeParse(node.className), + Method( + (m) => m + ..type = MethodType.getter + ..name = node.discriminatorKey + ..returns = refer('String'), + ), + _objectToJson(), + _objectSafeToJson(), + Method( + (m) => m + ..name = '_fromAckRuntime' + ..static = true + ..returns = refer(node.className) + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..type = refer(_runtimeMapType), + ), + ) + ..body = Code(''' +return switch (value[${_literal(node.discriminatorKey)}]) { + ${cases.join(',\n ')}, + final unknown => throw StateError( + 'Unknown ${node.discriminatorKey}: \$unknown', + ), +};'''), + ), + Method( + (m) => m + ..name = '_toAckRuntime' + ..returns = refer(_runtimeMapType), + ), + ]), + ); + } + + Class _branch(AckObjectModelNode node, AckUnionModelNode union) { + final discriminator = node.discriminatorKey!; + final value = node.discriminatorValue!; + final fields = node.fields + .where((field) => field.jsonKey != discriminator) + .toList(); + return Class( + (b) => b + ..name = node.className + ..modifier = ClassModifier.final$ + ..extend = refer(union.className) + ..docs.addAll(_docs(node, 'Discriminated model branch')) + ..fields.addAll([ + for (final field in fields) _field(field), + if (node.additionalProperties) _additionalPropertiesField(), + _adapter( + node, + '${union.id.declarationName}.effectiveBranch(${_literal(value)})', + ), + ]) + ..constructors.addAll([ + _objectConstructor(fields, node.additionalProperties), + _parseFactory(), + _fromJsonFactory(_objectJsonType), + ]) + ..methods.addAll([ + _safeParse(node.className), + Method( + (m) => m + ..annotations.add(refer('override')) + ..type = MethodType.getter + ..name = discriminator + ..returns = refer('String') + ..lambda = true + ..body = Code(_literal(value)), + ), + _objectFromRuntime( + node, + fields: fields, + additionalKnownKeys: {discriminator}, + ), + _objectToRuntime( + node, + fields: fields, + leadingEntries: {discriminator: _literal(value)}, + isOverride: true, + ), + ]), + ); + } + + Field _field(AckFieldNode field) => Field( + (f) => f + ..name = field.dartName + ..modifier = FieldModifier.final$ + ..type = refer(_fieldType(field)) + ..docs.addAll([ + if (field.description != null) '/// ${field.description}', + ]), + ); + + Field _additionalPropertiesField() => Field( + (f) => f + ..name = 'additionalProperties' + ..modifier = FieldModifier.final$ + ..type = refer(_runtimeMapType) + ..docs.add( + '/// Properties accepted by a schema with additional properties.', + ), + ); + + Field _adapter(AckModelNode node, String schemaExpression) => Field( + (f) => f + ..name = r'$ack' + ..static = true + ..modifier = FieldModifier.final$ + ..assignment = Code(''' +${_ack('AckModelAdapter')}( + schema: () => $schemaExpression, + fromRuntime: ${node.className}._fromAckRuntime, + toRuntime: (model) => model._toAckRuntime(), +)'''), + ); + + Constructor _objectConstructor( + List fields, + bool additionalProperties, + ) => Constructor((c) { + for (final field in fields) { + final copyType = _nonNullable(field.runtimeRef); + final needsCopy = _requiresImmutableCopy(copyType); + c.optionalParameters.add( + Parameter( + (p) => p + ..name = field.dartName + ..named = true + ..required = field.isRequired + ..toThis = !needsCopy + ..type = needsCopy ? refer(_fieldType(field)) : null, + ), + ); + if (!needsCopy) continue; + + final value = field.dartName; + final copy = _immutableCopy(copyType, value); + var initializer = copy; + if (!field.isRequired || field.nullable) { + initializer = + 'switch ($value) {' + ' null => null,' + ' final fieldValue => ${_immutableCopy(copyType, 'fieldValue')},' + ' }'; + } + c.initializers.add(Code('${field.dartName} = $initializer')); + } + if (additionalProperties) { + c.optionalParameters.add( + Parameter( + (p) => p + ..name = 'additionalProperties' + ..named = true + ..type = refer(_runtimeMapType) + ..defaultTo = const Code('const {}'), + ), + ); + c.initializers.add( + const Code( + 'additionalProperties = _ackImmutableCopyMap(additionalProperties)', + ), + ); + } + }); + + Constructor _valueConstructor(AckValueModelNode node, String runtimeType) { + final needsCopy = _requiresImmutableCopy(node.runtimeRef); + return Constructor((c) { + c.requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..toThis = !needsCopy + ..type = needsCopy ? refer(runtimeType) : null, + ), + ); + if (needsCopy) { + c.initializers.add( + Code('value = ${_immutableCopy(node.runtimeRef, 'value')}'), + ); + } + }); + } + + Constructor _parseFactory() => Constructor( + (c) => c + ..factory = true + ..name = 'parse' + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'input' + ..type = refer('Object?'), + ), + ) + ..body = const Code(r'return $ack.parse(input);'), + ); + + Constructor _fromJsonFactory(String boundaryType) => Constructor( + (c) => c + ..factory = true + ..name = 'fromJson' + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'json' + ..type = refer(boundaryType), + ), + ) + ..body = const Code(r'return $ack.parse(json);'), + ); + + Method _safeParse(String className) => Method( + (m) => m + ..name = 'safeParse' + ..static = true + ..returns = refer('${_ack('SchemaResult')}<$className>') + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'input' + ..type = refer('Object?'), + ), + ) + ..lambda = true + ..body = const Code(r'$ack.safeParse(input)'), + ); + + Method _objectToJson() => Method( + (m) => m + ..name = 'toJson' + ..returns = refer(_objectJsonType) + ..lambda = true + ..body = const Code('Map.from(\$ack.encode(this))'), + ); + + Method _objectSafeToJson() => Method( + (m) => m + ..name = 'safeToJson' + ..returns = refer('${_ack('SchemaResult')}<$_runtimeMapType>') + ..lambda = true + ..body = const Code(r'$ack.safeEncode(this)'), + ); + + Method _objectFromRuntime( + AckObjectModelNode node, { + List? fields, + Set additionalKnownKeys = const {}, + }) { + final effectiveFields = fields ?? node.fields; + final arguments = [ + for (final field in effectiveFields) + '${field.dartName}: ${_decodeField(field)}', + if (node.additionalProperties) + 'additionalProperties: ${_additionalPropertiesDecode(effectiveFields, additionalKnownKeys)}', + ]; + return Method( + (m) => m + ..name = '_fromAckRuntime' + ..static = true + ..returns = refer(node.className) + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..type = refer(_runtimeMapType), + ), + ) + ..body = Code(''' +return ${node.className}( + ${arguments.join(',\n ')}${arguments.isEmpty ? '' : ','} +);'''), + ); + } + + Method _objectToRuntime( + AckObjectModelNode node, { + List? fields, + Map leadingEntries = const {}, + bool isOverride = false, + }) { + final effectiveFields = fields ?? node.fields; + final entries = [ + if (node.additionalProperties) '...additionalProperties', + for (final entry in leadingEntries.entries) + '${_literal(entry.key)}: ${entry.value}', + for (final field in effectiveFields) _encodeField(field), + ]; + return Method((m) { + m + ..name = '_toAckRuntime' + ..returns = refer(_runtimeMapType) + ..body = Code(''' +return { + ${entries.join(',\n ')}${entries.isEmpty ? '' : ','} +};'''); + if (isOverride) m.annotations.add(refer('override')); + }); + } + + String _decodeField(AckFieldNode field) { + final read = 'value[${_literal(field.jsonKey)}]'; + final decoded = _fromRuntime(field.runtimeRef, read); + if (field.isRequired && !field.nullable) return decoded; + + final runtimeRef = _nonNullable(field.runtimeRef); + if (!_requiresRuntimeConversion(runtimeRef)) { + return '$read as ${_type(runtimeRef)}?'; + } + return 'switch ($read) {' + ' null => null,' + ' final fieldValue => ${_fromRuntime(runtimeRef, 'fieldValue')},' + ' }'; + } + + String _encodeField(AckFieldNode field) { + final runtimeRef = _nonNullable(field.runtimeRef); + if (field.presence == AckFieldPresence.optional) { + return 'if (${field.dartName} != null) ${_literal(field.jsonKey)}: ${_toRuntime(runtimeRef, '${field.dartName}!')}'; + } + if (field.nullable && _requiresRuntimeConversion(runtimeRef)) { + return '${_literal(field.jsonKey)}: switch (${field.dartName}) {' + ' null => null,' + ' final fieldValue => ${_toRuntime(runtimeRef, 'fieldValue')},' + ' }'; + } + return '${_literal(field.jsonKey)}: ${_toRuntime(runtimeRef, field.dartName)}'; + } + + String _additionalPropertiesDecode( + List fields, + Set additionalKnownKeys, + ) { + final keys = { + ...additionalKnownKeys, + for (final field in fields) field.jsonKey, + }; + if (keys.isEmpty) return '_ackImmutableCopyMap(value)'; + return '_ackImmutableCopyMap(Map.fromEntries(' + 'value.entries.where((entry) => !const {' + '${keys.map(_literal).join(', ')}' + '}.contains(entry.key))))'; + } + + String _fromRuntime(AckTypeRef type, String expression) { + return switch (type) { + AckNullableTypeRef(:final inner) => + '$expression == null ? null : ${_fromRuntime(inner, '$expression!')}', + AckModelTypeRef(:final runtimeRef, :final visibleName) => + '$visibleName.\$ack.fromRuntime($expression as ${_type(runtimeRef)})', + AckListTypeRef(:final elementType) => + 'List<${_type(elementType)}>.unmodifiable(($expression as List).map((item) => ${_fromRuntime(elementType, 'item')}))', + AckSetTypeRef(:final elementType) => + 'Set<${_type(elementType)}>.unmodifiable(($expression as Set).map((item) => ${_fromRuntime(elementType, 'item')}))', + AckMapTypeRef(:final valueType) => + 'Map.unmodifiable(($expression as Map).map((key, item) => MapEntry(key as String, ${_fromRuntime(valueType, 'item')})))', + _ => '$expression as ${_type(type)}', + }; + } + + String _toRuntime(AckTypeRef type, String expression) { + return switch (type) { + AckNullableTypeRef(:final inner) => + '$expression == null ? null : ${_toRuntime(inner, '$expression!')}', + AckModelTypeRef(:final visibleName) => + '$visibleName.\$ack.toRuntime($expression)', + AckListTypeRef(:final elementType) => + '$expression.map((item) => ${_toRuntime(elementType, 'item')}).toList(growable: false)', + AckSetTypeRef(:final elementType) => + '$expression.map((item) => ${_toRuntime(elementType, 'item')}).toSet()', + AckMapTypeRef(:final valueType) => + '$expression.map((key, item) => MapEntry(key, ${_toRuntime(valueType, 'item')}))', + _ => expression, + }; + } + + String _immutableCopy(AckTypeRef type, String expression) { + return switch (type) { + AckNullableTypeRef(:final inner) => + '$expression == null ? null : ${_immutableCopy(inner, '$expression!')}', + AckListTypeRef(:final elementType) => + 'List<${_type(elementType)}>.unmodifiable($expression.map((item) => ${_immutableCopy(elementType, 'item')}))', + AckSetTypeRef(:final elementType) => + 'Set<${_type(elementType)}>.unmodifiable($expression.map((item) => ${_immutableCopy(elementType, 'item')}))', + AckMapTypeRef(:final valueType) => + 'Map.unmodifiable($expression.map((key, item) => MapEntry(key, ${_immutableCopy(valueType, 'item')})))', + _ => expression, + }; + } + + String _fieldType(AckFieldNode field) { + final base = _type(field.runtimeRef); + if (field.isRequired && !field.nullable) return base; + return field.runtimeRef is AckNullableTypeRef ? base : '$base?'; + } + + AckTypeRef _nonNullable(AckTypeRef type) => switch (type) { + AckNullableTypeRef(:final inner) => inner, + _ => type, + }; + + bool _requiresImmutableCopy(AckTypeRef type) => switch (type) { + AckNullableTypeRef(:final inner) => _requiresImmutableCopy(inner), + AckListTypeRef() || AckSetTypeRef() || AckMapTypeRef() => true, + _ => false, + }; + + bool _requiresRuntimeConversion(AckTypeRef type) => switch (type) { + AckNullableTypeRef(:final inner) => _requiresRuntimeConversion(inner), + AckModelTypeRef() || + AckListTypeRef() || + AckSetTypeRef() || + AckMapTypeRef() => true, + _ => false, + }; + + String _type(AckTypeRef type) { + return switch (type) { + AckNullableTypeRef(:final inner) => '${_type(inner)}?', + AckScalarTypeRef(:final dartType) => dartType, + AckExternalTypeRef(:final visibleName, :final typeArguments) => + typeArguments.isEmpty + ? visibleName + : '$visibleName<${typeArguments.map(_type).join(', ')}>', + AckModelTypeRef(:final visibleName) => visibleName, + AckListTypeRef(:final elementType) => 'List<${_type(elementType)}>', + AckSetTypeRef(:final elementType) => 'Set<${_type(elementType)}>', + AckMapTypeRef(:final valueType) => 'Map', + }; + } + + Method _immutableValueHelper() => Method( + (m) => m + ..name = '_ackImmutableCopyValue' + ..returns = refer('Object?') + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..type = refer('Object?'), + ), + ) + ..lambda = true + ..body = const Code(''' +switch (value) { + List() => List.unmodifiable(value.map(_ackImmutableCopyValue)), + Set() => Set.unmodifiable(value.map(_ackImmutableCopyValue)), + Map() => Map.unmodifiable( + value.map((key, item) => MapEntry(key, _ackImmutableCopyValue(item))), + ), + _ => value, +}'''), + ); + + Method _immutableMapHelper() => Method( + (m) => m + ..name = '_ackImmutableCopyMap' + ..returns = refer(_runtimeMapType) + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..type = refer(_runtimeMapType), + ), + ) + ..lambda = true + ..body = const Code(''' +Map.unmodifiable( + value.map( + (key, item) => MapEntry(key, _ackImmutableCopyValue(item)), + ), +)'''), + ); + + List _docs(AckModelNode node, String kind) => [ + '/// $kind generated from `${node.id.declarationName}`.', + if (node.description != null) '/// ${node.description}', + ]; + + String _ack(String symbol) { + final prefix = ackPrefix; + return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; + } + + String _literal(String value) { + final escaped = value + .replaceAll(r'\', r'\\') + .replaceAll("'", r"\'") + .replaceAll(r'$', r'\$'); + return "'$escaped'"; + } + + static const _runtimeMapType = 'Map'; + static const _objectJsonType = 'Map'; +} diff --git a/packages/ack_generator/lib/src/generator.dart b/packages/ack_generator/lib/src/generator.dart index 5896c55e..7b047ecd 100644 --- a/packages/ack_generator/lib/src/generator.dart +++ b/packages/ack_generator/lib/src/generator.dart @@ -1,136 +1,60 @@ import 'package:ack_annotations/ack_annotations.dart'; -import 'package:analyzer/dart/element/element2.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; import 'package:build/build.dart'; import 'package:code_builder/code_builder.dart'; import 'package:source_gen/source_gen.dart'; -import 'analyzer/schema_ast_analyzer.dart'; -import 'builders/class_builder.dart'; -import 'models/model_info.dart'; +import 'analyzer/schema_model_graph_builder.dart'; +import 'builders/model_emitter.dart'; /// Generates immutable model classes for top-level schemas annotated with /// `@AckType`. final class AckSchemaGenerator extends Generator { @override - String generate(LibraryReader library, BuildStep buildStep) { - final annotatedVariables = []; - final annotatedGetters = []; + Future generate(LibraryReader library, BuildStep buildStep) async { + final annotated = []; for (final element in library.allElements) { - if (element is ClassElement2 && _hasAckTypeAnnotation(element)) { + if (!_hasAckType(element)) continue; + if (element is ClassElement) { throw InvalidGenerationSource( '@AckType can only be applied to top-level schema variables or getters, not classes.', element: element, - todo: - 'Remove @AckType from the class and annotate a top-level schema variable or getter instead.', ); } - - if (element is TopLevelVariableElement2 && - _hasAckTypeAnnotation(element)) { - annotatedVariables.add(element); - } else if (element is GetterElement && _hasAckTypeAnnotation(element)) { - final isTopLevel = element.enclosingElement2 is LibraryElement2; - if (!isTopLevel) { + if (element is TopLevelVariableElement) { + annotated.add(element); + } else if (element is GetterElement && element.isOriginDeclaration) { + if (element.enclosingElement is! LibraryElement) { throw InvalidGenerationSource( '@AckType can only be applied to top-level schema variables or getters.', element: element, - todo: - 'Move this getter to the library level or annotate a top-level schema variable instead.', ); } - - if (!element.isSynthetic) { - annotatedGetters.add(element); - } + annotated.add(element); } } for (final classElement in library.classes) { for (final getter in classElement.getters) { - if (_hasAckTypeAnnotation(getter)) { + if (_hasAckType(getter)) { throw InvalidGenerationSource( '@AckType can only be applied to top-level schema variables or getters.', element: getter, - todo: - 'Move this getter to the library level or annotate a top-level schema variable instead.', ); } } } - if (annotatedVariables.isEmpty && annotatedGetters.isEmpty) { - return ''; - } - - final analyzer = SchemaAstAnalyzer(); - final models = []; - - for (final variable in annotatedVariables) { - try { - final model = analyzer.analyzeSchemaVariable( - variable, - customTypeName: _extractAckTypeName(variable), - ); - if (model != null) models.add(model); - } catch (error) { - throw InvalidGenerationSource( - 'Failed to analyze schema variable "${variable.name3}": $error', - element: variable, - todo: - 'Ensure the variable uses statically analyzable Ack schema syntax.', - ); - } - } - - for (final getter in annotatedGetters) { - try { - final model = analyzer.analyzeSchemaGetter( - getter, - customTypeName: _extractAckTypeName(getter), - ); - if (model != null) models.add(model); - } catch (error) { - throw InvalidGenerationSource( - 'Failed to analyze schema getter "${getter.name3}": $error', - element: getter, - todo: - 'Ensure the getter returns a statically analyzable Ack schema.', - ); - } - } - - final linkedModels = _linkDiscriminatedModels(models); - _validateGeneratedClassNames(library, linkedModels); - - final classBuilder = AckClassBuilder() - ..setAckImportPrefix(_resolveAckImportPrefix(library)); - - final List classes; - try { - classes = classBuilder.buildClasses(linkedModels); - } catch (error) { - final element = linkedModels.isEmpty - ? null - : _findAnnotatedSchemaElement( - linkedModels.first.schemaClassName, - annotatedVariables, - annotatedGetters, - ); - throw InvalidGenerationSource( - 'Ack model class generation failed: $error', - element: element, - todo: - 'Check generated-name collisions, nullable root schemas, and unsupported schema shapes.', - ); - } - - if (classes.isEmpty) return ''; + if (annotated.isEmpty) return ''; + await _requireAckPartDirective(buildStep, annotated.first); - // SharedPartBuilder owns the generated header, `part of` directive, and - // target-language formatting. Generators return declarations only. - final generatedLibrary = Library((b) => b.body.addAll(classes)); - return generatedLibrary + final graph = await SchemaModelGraphBuilder(library).build(annotated); + final specs = AckModelEmitter( + ackPrefix: _ackImportPrefix(library), + ).emit(graph); + return Library((b) => b.body.addAll(specs)) .accept( DartEmitter( allocator: Allocator.none, @@ -141,153 +65,35 @@ final class AckSchemaGenerator extends Generator { .toString(); } - void _validateGeneratedClassNames( - LibraryReader library, - List models, - ) { - final existingNames = { - for (final element in library.classes) - if (element.name3 case final name?) name, - }; - - final generatedNames = {}; - for (final model in models) { - if (!generatedNames.add(model.className)) { - throw InvalidGenerationSource( - 'Multiple @AckType declarations generate "${model.className}".', - todo: 'Give one declaration a unique @AckType(name: ...) value.', - ); - } - if (existingNames.contains(model.className)) { - throw InvalidGenerationSource( - 'Generated class "${model.className}" conflicts with an existing class in this library.', - todo: - 'Rename the existing class or set a unique @AckType(name: ...) value.', - ); - } - } - } - - List _linkDiscriminatedModels(List models) { - final linked = List.from(models); - final modelIndexBySchemaClassName = { - for (var i = 0; i < linked.length; i++) linked[i].schemaClassName: i, - }; - final branchOwnerByCanonicalIdentity = {}; - - for (var i = 0; i < linked.length; i++) { - final baseModel = linked[i]; - if (!baseModel.isDiscriminatedBaseDefinition) continue; - - final discriminatorKey = baseModel.discriminatorKey; - final subtypeNames = baseModel.subtypeNames; - if (discriminatorKey == null || subtypeNames == null) continue; - - for (final entry in subtypeNames.entries) { - final branchSchemaClassName = entry.value; - final branchIndex = modelIndexBySchemaClassName[branchSchemaClassName]; - if (branchIndex == null) { - throw InvalidGenerationSource( - 'Could not resolve discriminated branch "$branchSchemaClassName" for base "${baseModel.schemaClassName}".', - todo: - 'Ensure every branch references an @AckType schema in the same library.', - ); - } - - final branchModel = linked[branchIndex]; - final canonicalIdentity = - branchModel.schemaIdentity ?? branchSchemaClassName; - final existingOwner = branchOwnerByCanonicalIdentity[canonicalIdentity]; - if (existingOwner != null && - existingOwner != baseModel.schemaClassName) { - throw InvalidGenerationSource( - 'Branch schema "$branchSchemaClassName" is mapped to multiple discriminated bases: "$existingOwner" and "${baseModel.schemaClassName}".', - todo: 'A branch schema can belong to only one discriminated base.', - ); - } - branchOwnerByCanonicalIdentity[canonicalIdentity] = - baseModel.schemaClassName; - - linked[branchIndex] = _copyModelInfo( - branchModel, - discriminatorKey: discriminatorKey, - discriminatorValue: entry.key, - discriminatedBaseClassName: baseModel.className, - ); - } - } - - return linked; - } - - ModelInfo _copyModelInfo( - ModelInfo model, { - String? discriminatorKey, - String? discriminatorValue, - Map? subtypeNames, - String? discriminatedBaseClassName, - }) { - return ModelInfo( - className: model.className, - schemaClassName: model.schemaClassName, - description: model.description, - fields: model.fields, - additionalProperties: model.additionalProperties, - discriminatorKey: discriminatorKey ?? model.discriminatorKey, - discriminatorValue: discriminatorValue ?? model.discriminatorValue, - subtypeNames: subtypeNames ?? model.subtypeNames, - schemaIdentity: model.schemaIdentity, - discriminatedBaseClassName: - discriminatedBaseClassName ?? model.discriminatedBaseClassName, - representationType: model.representationType, - isNullableSchema: model.isNullableSchema, + bool _hasAckType(Element element) => + TypeChecker.typeNamed(AckType).hasAnnotationOfExact(element); + + Future _requireAckPartDirective( + BuildStep buildStep, + Element annotatedElement, + ) async { + final inputName = buildStep.inputId.pathSegments.last; + final baseName = inputName.substring(0, inputName.length - '.dart'.length); + final expectedPart = '$baseName.ack.dart'; + final unit = await buildStep.resolver.compilationUnitFor(buildStep.inputId); + final hasExpectedPart = unit.directives.whereType().any( + (directive) => directive.uri.stringValue == expectedPart, + ); + if (hasExpectedPart) return; + throw InvalidGenerationSource( + "Ack model generation requires `part '$expectedPart';` in this library.", + element: annotatedElement, + todo: "Add `part '$expectedPart';` next to the library's directives.", ); } - bool _hasAckTypeAnnotation(Element2 element) { - return TypeChecker.typeNamed(AckType).hasAnnotationOfExact(element); - } - - String? _extractAckTypeName(Element2 element) { - final annotation = TypeChecker.typeNamed( - AckType, - ).firstAnnotationOfExact(element); - if (annotation == null) return null; - - final nameField = ConstantReader(annotation).peek('name'); - return nameField != null && !nameField.isNull - ? nameField.stringValue - : null; - } - - Element2? _findAnnotatedSchemaElement( - String schemaName, - List variables, - List getters, - ) { - for (final variable in variables) { - if (variable.name3 == schemaName) return variable; - } - for (final getter in getters) { - if (getter.name3 == schemaName) return getter; - } - return null; - } - - String? _resolveAckImportPrefix(LibraryReader library) { - for (final import in library.element.firstFragment.libraryImports2) { - if (!_isAckImport(import)) continue; - final prefix = import.prefix2?.element.name3; - return prefix == null || prefix.isEmpty ? null : prefix; + String? _ackImportPrefix(LibraryReader library) { + for (final import in library.element.firstFragment.libraryImports) { + if (import.importedLibrary?.uri.toString() != 'package:ack/ack.dart') { + continue; + } + return import.prefix?.element.name; } return null; } - - bool _isAckImport(LibraryImport import) { - final importedLibrary = import.importedLibrary2; - if (importedLibrary?.uri.toString() == 'package:ack/ack.dart') { - return true; - } - return import.uri.toString().contains('package:ack/ack.dart'); - } } diff --git a/packages/ack_generator/lib/src/models/constraint_info.dart b/packages/ack_generator/lib/src/models/constraint_info.dart deleted file mode 100644 index 02098a6c..00000000 --- a/packages/ack_generator/lib/src/models/constraint_info.dart +++ /dev/null @@ -1,7 +0,0 @@ -/// Information about a validation constraint -class ConstraintInfo { - final String name; - final List arguments; - - const ConstraintInfo({required this.name, required this.arguments}); -} diff --git a/packages/ack_generator/lib/src/models/field_info.dart b/packages/ack_generator/lib/src/models/field_info.dart deleted file mode 100644 index cd211f9c..00000000 --- a/packages/ack_generator/lib/src/models/field_info.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'package:analyzer/dart/element/element2.dart'; -import 'package:analyzer/dart/element/type.dart'; -import 'package:logging/logging.dart'; - -import 'constraint_info.dart'; - -/// Logger for field info extraction warnings and diagnostics. -final _log = Logger('FieldInfo'); - -/// Information about a field in the model -class FieldInfo { - final String name; - final String jsonKey; - final DartType type; - final bool isRequired; - final bool isNullable; - final List constraints; - final String? description; - - /// For list/set fields containing schema variable references (e.g., `Ack.list(addressSchema)`), - /// this stores the schema variable name so the type builder can generate - /// properly typed getters like `List`. - final String? listElementSchemaRef; - - /// For nested object fields that reference another schema variable (e.g., `'address': addressSchema`), - /// this stores the schema variable name so the type builder can generate - /// properly typed getters like `AddressType get address`. - final String? nestedSchemaRef; - - /// Optional display type override used when source qualification matters - /// (e.g., `alias.UserRole` from a prefixed import). - final String? displayTypeOverride; - - /// Optional collection element display type override for list/set fields. - final String? collectionElementDisplayTypeOverride; - - /// Optional cast type override for list/set element wrappers - /// (for example, `Map` for object schema references). - final String? collectionElementCastTypeOverride; - - /// Whether list/set elements should be wrapped as generated extension types. - final bool collectionElementIsCustomType; - - /// Optional cast type override for nested schema references. - final String? nestedSchemaCastTypeOverride; - - const FieldInfo({ - required this.name, - required this.jsonKey, - required this.type, - required this.isRequired, - required this.isNullable, - required this.constraints, - this.description, - this.listElementSchemaRef, - this.nestedSchemaRef, - this.displayTypeOverride, - this.collectionElementDisplayTypeOverride, - this.collectionElementCastTypeOverride, - this.collectionElementIsCustomType = false, - this.nestedSchemaCastTypeOverride, - }); - - /// Whether this field references another schema model - bool get isNestedSchema => - !isPrimitive && !isList && !isMap && !isSet && !isEnum && !isGeneric; - - /// Whether this field is a generic type parameter - bool get isGeneric { - // Check if this is a type parameter (like T, U, etc.) - return type is TypeParameterType; - } - - /// Whether this is a Set type - bool get isSet => type.isDartCoreSet; - - /// Whether this is a primitive type - bool get isPrimitive { - // Check if it's a built-in Dart type - return type.isDartCoreString || - type.isDartCoreInt || - type.isDartCoreDouble || - type.isDartCoreBool || - type.isDartCoreNum; - } - - /// Whether this field is an enum type - bool get isEnum { - final element = type.element3; - if (element == null) return false; - - // Check if this is an enum by looking at the element type - return element is EnumElement2; - } - - /// Get enum values if this is an enum type - List get enumValues { - if (!isEnum) return []; - final element = type.element3; - if (element == null) return []; - - // For enums, get the enum constants using the analyzer API - if (element is EnumElement2) { - try { - final enumConstants = element.constants2 - .map((field) => field.name3!) - .toList(); - - return enumConstants; - } catch (e) { - // If the analyzer can't resolve the enum constants, fall back to empty - // values so generation still works for manual string-enum schemas. - _log.warning('Could not extract enum values for ${element.name3}: $e'); - return []; - } - } - - return []; - } - - /// Whether this is a List type - bool get isList => type.isDartCoreList; - - /// Whether this is a Map type - bool get isMap => type.isDartCoreMap; -} diff --git a/packages/ack_generator/lib/src/models/model_info.dart b/packages/ack_generator/lib/src/models/model_info.dart deleted file mode 100644 index 146b1555..00000000 --- a/packages/ack_generator/lib/src/models/model_info.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'field_info.dart'; - -/// Default representation type for object schemas -const String kMapType = 'Map'; - -/// Information about an annotated model class -class ModelInfo { - final String className; - final String schemaClassName; - final String? description; - final List fields; - final bool additionalProperties; - - /// Computed property: returns list of required field JSON keys - List get requiredFields => - fields.where((f) => f.isRequired).map((f) => f.jsonKey).toList(); - - /// Field name for discrimination. - /// - /// This is set on declared discriminated bases and may also be propagated - /// to linked schema-variable subtypes. - final String? discriminatorKey; - - /// This class's discriminator value (only for subtypes) - final String? discriminatorValue; - - /// Map of discriminator values to subtype schema declarations. - final Map? subtypeNames; - - /// Canonical schema declaration identity. - final String? schemaIdentity; - - /// Parent discriminated base class name for subtypes. - final String? discriminatedBaseClassName; - - /// Computed property: Whether this model has a discriminator key. - /// - /// This may be true for linked schema-variable subtypes. - bool get isDiscriminatedBase => discriminatorKey != null; - - /// Computed property: Whether this model is a declared discriminated base. - bool get isDiscriminatedBaseDefinition => - discriminatorKey != null && subtypeNames != null; - - /// Computed property: Whether this class is a discriminated subtype (has discriminatedValue) - bool get isDiscriminatedSubtype => discriminatorValue != null; - - /// Representation type for extension type (e.g., `String`, `int`, `Map`) - final String representationType; - - /// Whether the schema declaration is nullable via `.nullable()`. - final bool isNullableSchema; - - const ModelInfo({ - required this.className, - required this.schemaClassName, - this.description, - required this.fields, - this.additionalProperties = false, - this.discriminatorKey, - this.discriminatorValue, - this.subtypeNames, - this.schemaIdentity, - this.discriminatedBaseClassName, - this.representationType = kMapType, - this.isNullableSchema = false, - }); -} diff --git a/packages/ack_generator/lib/src/models/schema_model_graph.dart b/packages/ack_generator/lib/src/models/schema_model_graph.dart index cc4fd981..1f48963c 100644 --- a/packages/ack_generator/lib/src/models/schema_model_graph.dart +++ b/packages/ack_generator/lib/src/models/schema_model_graph.dart @@ -1,9 +1,6 @@ /// Stable identity for a schema declaration across libraries. final class AckSchemaId { - const AckSchemaId({ - required this.libraryUri, - required this.declarationName, - }); + const AckSchemaId({required this.libraryUri, required this.declarationName}); final Uri libraryUri; final String declarationName; @@ -24,20 +21,13 @@ final class AckSchemaId { /// Whether every value in a generated model graph can be encoded back to the /// schema boundary. -enum AckEncodeCapability { - bidirectional, - parseOnly, -} +enum AckEncodeCapability { bidirectional, parseOnly } /// Input-presence semantics for an object field. /// /// Presence and nullability are deliberately separate. A field can be required /// and nullable, optional and non-nullable, or defaulted by the schema. -enum AckFieldPresence { - required, - optional, - defaulted, -} +enum AckFieldPresence { required, optional, defaulted } /// A normalized Dart/runtime type used by generation. /// @@ -49,6 +39,16 @@ sealed class AckTypeRef { Iterable get modelDependencies => const []; } +/// A nullable structural type reference. +final class AckNullableTypeRef extends AckTypeRef { + const AckNullableTypeRef(this.inner); + + final AckTypeRef inner; + + @override + Iterable get modelDependencies => inner.modelDependencies; +} + /// A core scalar such as `String`, `int`, `double`, `bool`, or `num`. final class AckScalarTypeRef extends AckTypeRef { const AckScalarTypeRef(this.dartType); @@ -59,18 +59,27 @@ final class AckScalarTypeRef extends AckTypeRef { /// A visible type declared outside the generated model graph. final class AckExternalTypeRef extends AckTypeRef { const AckExternalTypeRef({ - required this.dartType, + required this.name, required this.libraryUri, this.importPrefix, + this.typeArguments = const [], }); - final String dartType; + final String name; final Uri libraryUri; final String? importPrefix; + final List typeArguments; String get visibleName { final prefix = importPrefix; - return prefix == null || prefix.isEmpty ? dartType : '$prefix.$dartType'; + return prefix == null || prefix.isEmpty ? name : '$prefix.$name'; + } + + @override + Iterable get modelDependencies sync* { + for (final argument in typeArguments) { + yield* argument.modelDependencies; + } } } @@ -79,11 +88,13 @@ final class AckModelTypeRef extends AckTypeRef { const AckModelTypeRef({ required this.schemaId, required this.className, + required this.runtimeRef, this.importPrefix, }); final AckSchemaId schemaId; final String className; + final AckTypeRef runtimeRef; final String? importPrefix; String get visibleName { @@ -101,8 +112,7 @@ final class AckListTypeRef extends AckTypeRef { final AckTypeRef elementType; @override - Iterable get modelDependencies => - elementType.modelDependencies; + Iterable get modelDependencies => elementType.modelDependencies; } final class AckSetTypeRef extends AckTypeRef { @@ -111,8 +121,7 @@ final class AckSetTypeRef extends AckTypeRef { final AckTypeRef elementType; @override - Iterable get modelDependencies => - elementType.modelDependencies; + Iterable get modelDependencies => elementType.modelDependencies; } final class AckMapTypeRef extends AckTypeRef { @@ -131,7 +140,7 @@ final class AckFieldNode { required this.jsonKey, required this.presence, required this.nullable, - required this.runtimeType, + required this.runtimeRef, this.description, }); @@ -139,10 +148,23 @@ final class AckFieldNode { final String jsonKey; final AckFieldPresence presence; final bool nullable; - final AckTypeRef runtimeType; + final AckTypeRef runtimeRef; final String? description; - bool get isRequired => presence == AckFieldPresence.required; + bool get isRequired => presence != AckFieldPresence.optional; +} + +/// Stable source position for diagnostics without leaking analyzer objects. +final class AckSourceLocation { + const AckSourceLocation({ + required this.libraryUri, + required this.offset, + required this.length, + }); + + final Uri libraryUri; + final int offset; + final int length; } /// Base node for a generated class or value object. @@ -151,16 +173,18 @@ sealed class AckModelNode { required this.id, required this.className, required this.boundaryType, - required this.runtimeType, + required this.runtimeRef, required this.encodeCapability, + required this.sourceLocation, this.description, }); final AckSchemaId id; final String className; final AckTypeRef boundaryType; - final AckTypeRef runtimeType; + final AckTypeRef runtimeRef; final AckEncodeCapability encodeCapability; + final AckSourceLocation sourceLocation; final String? description; Iterable get dependencies; @@ -172,20 +196,27 @@ final class AckObjectModelNode extends AckModelNode { required super.id, required super.className, required super.boundaryType, - required super.runtimeType, + required super.runtimeRef, required super.encodeCapability, + required super.sourceLocation, required Iterable fields, this.additionalProperties = false, + this.unionId, + this.discriminatorKey, + this.discriminatorValue, super.description, }) : fields = List.unmodifiable(fields); final List fields; final bool additionalProperties; + final AckSchemaId? unionId; + final String? discriminatorKey; + final String? discriminatorValue; @override Iterable get dependencies sync* { for (final field in fields) { - yield* field.runtimeType.modelDependencies; + yield* field.runtimeRef.modelDependencies; } } } @@ -196,13 +227,14 @@ final class AckValueModelNode extends AckModelNode { required super.id, required super.className, required super.boundaryType, - required super.runtimeType, + required super.runtimeRef, required super.encodeCapability, + required super.sourceLocation, super.description, }); @override - Iterable get dependencies => runtimeType.modelDependencies; + Iterable get dependencies => runtimeRef.modelDependencies; } /// A sealed class generated from `Ack.discriminated(...)`. @@ -211,8 +243,9 @@ final class AckUnionModelNode extends AckModelNode { required super.id, required super.className, required super.boundaryType, - required super.runtimeType, + required super.runtimeRef, required super.encodeCapability, + required super.sourceLocation, required this.discriminatorKey, required Map branches, super.description, @@ -226,11 +259,7 @@ final class AckUnionModelNode extends AckModelNode { } /// Resolution state used while building recursive model graphs. -enum AckResolutionState { - unseen, - visiting, - resolved, -} +enum AckResolutionState { unseen, visiting, resolved } /// Mutable graph assembly with immutable model nodes. /// @@ -276,6 +305,15 @@ final class AckModelGraph { AckModelNode? nodeFor(AckSchemaId id) => _nodes[id]; + void replace(AckModelNode node) { + if (stateOf(node.id) != AckResolutionState.resolved) { + throw StateError( + 'Schema ${node.id} must be resolved before replacement.', + ); + } + _nodes[node.id] = node; + } + /// Returns source-stable dependencies for diagnostics and tests. List dependenciesOf(AckSchemaId id) { final node = _nodes[id]; diff --git a/packages/ack_generator/lib/src/validation/code_validator.dart b/packages/ack_generator/lib/src/validation/code_validator.dart deleted file mode 100644 index aa23cb0a..00000000 --- a/packages/ack_generator/lib/src/validation/code_validator.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:analyzer/dart/analysis/utilities.dart'; - -/// Standard validation utility for generated Dart code using analyzer API. -/// -/// This follows Dart ecosystem best practices by using the official analyzer -/// package to validate syntax before writing generated files. -/// -/// Note: This validator only checks for SYNTAX errors (parsing issues like -/// missing braces, invalid tokens). It does NOT check for SEMANTIC errors -/// (undefined identifiers, missing imports) as those are expected in generated -/// code that will be resolved when combined with the main source file. -class CodeValidator { - /// Validates that the given Dart code is syntactically correct. - /// - /// Returns a [ValidationResult] indicating success or containing error details. - /// Uses the standard `parseString` function from the analyzer package. - static ValidationResult validate(String dartCode) { - try { - // Use parseString with throwIfDiagnostics: false to capture errors - final result = parseString(content: dartCode, throwIfDiagnostics: false); - - // Check if there are any parsing errors - if (result.errors.isEmpty) { - return ValidationResult.success(); - } - - // Convert analyzer errors to readable format - final errorMessages = result.errors.map((error) { - final lineInfo = result.lineInfo; - final location = lineInfo.getLocation(error.offset); - return 'Line ${location.lineNumber}: ${error.message}'; - }).toList(); - - return ValidationResult.failure( - 'Generated code contains syntax errors', - errorMessages, - ); - } catch (e) { - // If parseString throws an exception, treat it as a validation failure - return ValidationResult.failure('Failed to parse generated code', [ - 'Parsing exception: $e', - ]); - } - } -} - -/// Result of code validation containing success status and error details. -class ValidationResult { - final bool isSuccess; - final String? errorSummary; - final List errorDetails; - - const ValidationResult._({ - required this.isSuccess, - this.errorSummary, - this.errorDetails = const [], - }); - - /// Creates a successful validation result. - factory ValidationResult.success() { - return const ValidationResult._(isSuccess: true); - } - - /// Creates a failed validation result with error details. - factory ValidationResult.failure(String summary, List details) { - return ValidationResult._( - isSuccess: false, - errorSummary: summary, - errorDetails: details, - ); - } - - /// Returns true if validation failed. - bool get isFailure => !isSuccess; - - /// Gets a formatted error message combining summary and details. - String get errorMessage { - if (isSuccess) return ''; - - final buffer = StringBuffer(errorSummary ?? 'Validation failed'); - if (errorDetails.isNotEmpty) { - buffer.writeln(':'); - for (final detail in errorDetails) { - buffer.writeln(' • $detail'); - } - } - return buffer.toString(); - } -} diff --git a/packages/ack_generator/pubspec.yaml b/packages/ack_generator/pubspec.yaml index 782605d2..1adaa1c9 100644 --- a/packages/ack_generator/pubspec.yaml +++ b/packages/ack_generator/pubspec.yaml @@ -1,19 +1,18 @@ name: ack_generator description: Code generator for immutable model classes from Ack schemas version: 1.1.0 -repository: https://github.com/conceptadev/ack -issue_tracker: https://github.com/conceptadev/ack/issues +repository: https://github.com/btwld/ack +issue_tracker: https://github.com/btwld/ack/issues resolution: workspace environment: - sdk: '>=3.8.0 <4.0.0' + sdk: '>=3.9.0 <4.0.0' dependencies: # Core code generation dependencies - analyzer: ">=7.0.0 <9.0.0" - build: ">=3.0.0 <5.0.0" - build_config: ^1.1.0 - source_gen: ">=3.0.0 <5.0.0" + analyzer: ">=10.0.0 <15.0.0" + build: ^4.0.0 + source_gen: ^4.2.4 code_builder: ^4.10.0 # Ack packages (versions are overridden locally by Melos) @@ -28,6 +27,8 @@ dependencies: dev_dependencies: build_runner: ^2.1.7 build_test: ^3.1.0 + json_annotation: ^4.12.0 + json_serializable: ^6.14.1 test: ^1.25.15 path: ^1.9.0 # Code quality diff --git a/packages/ack_generator/test/additional_properties_args_test.dart b/packages/ack_generator/test/additional_properties_args_test.dart deleted file mode 100644 index fc31462b..00000000 --- a/packages/ack_generator/test/additional_properties_args_test.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'package:ack_generator/builder.dart'; -import 'package:build/build.dart'; -import 'package:build_test/build_test.dart'; -import 'package:test/test.dart'; - -import 'test_utils/test_assets.dart'; - -void main() { - group('Additional Properties Args Getter', () { - test( - 'generates args getter for schema variable with .passthrough()', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - 'age': Ack.integer(), -}).passthrough(); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('Map get args =>'), - contains("e.key != 'name' && e.key != 'age'"), - ]), - ), - }, - ); - }, - ); - - test( - 'generates args getter for schema variable with explicit additionalProperties: true', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - 'age': Ack.integer(), -}, additionalProperties: true); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('Map get args =>'), - contains("e.key != 'name' && e.key != 'age'"), - ]), - ), - }, - ); - }, - ); - - test( - 'does not generate args getter for schema variable without additionalProperties', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - 'age': Ack.integer(), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - isNot(contains('Map get args')), - ), - }, - ); - }, - ); - - test( - 'generates args getter with no conditions when there are no fields', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/empty.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final emptySchema = Ack.object({}, additionalProperties: true); -''', - }, - outputs: { - 'test_pkg|lib/empty.g.dart': decodedMatches( - allOf([ - contains('Map get args =>'), - contains('_data'), - // Should not have filter conditions when no fields exist - isNot(contains('where')), - ]), - ), - }, - ); - }, - ); - - test('generates correct filter for single field', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/single.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final singleFieldSchema = Ack.object({ - 'name': Ack.string(), -}).passthrough(); -''', - }, - outputs: { - 'test_pkg|lib/single.g.dart': decodedMatches( - allOf([ - contains('Map get args =>'), - contains("e.key != 'name'"), - // Should not have && when there's only one field - isNot(contains(' && ')), - ]), - ), - }, - ); - }); - - test('generates correct filter for three fields', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/three.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final threeFieldsSchema = Ack.object({ - 'name': Ack.string(), - 'age': Ack.integer(), - 'email': Ack.string(), -}).passthrough(); -''', - }, - outputs: { - 'test_pkg|lib/three.g.dart': decodedMatches( - allOf([ - contains('Map get args =>'), - contains("e.key != 'name'"), - contains("e.key != 'age'"), - contains("e.key != 'email'"), - contains(' && '), - ]), - ), - }, - ); - }); - }); -} diff --git a/packages/ack_generator/test/bugs/schema_variable_bugs_test.dart b/packages/ack_generator/test/bugs/schema_variable_bugs_test.dart deleted file mode 100644 index ed91a1db..00000000 --- a/packages/ack_generator/test/bugs/schema_variable_bugs_test.dart +++ /dev/null @@ -1,1359 +0,0 @@ -/// Regression tests for schema variable type extraction. -/// -/// These tests verify correct behavior for previously reported issues: -/// - Issue #1: List type extraction (simple primitives) -/// - Issue #2: Nested schema references -/// - Issue #3: Method chain walker safety -/// - Issue #4: List elements with method chain modifiers -/// - Issue #5: Nested object lists with method chain modifiers -/// - Issue #6: Schema variable references with method chain modifiers -library; - -import 'package:ack_generator/src/analyzer/schema_ast_analyzer.dart'; -import 'package:analyzer/dart/element/element2.dart'; -import 'package:analyzer/dart/element/type.dart'; -import 'package:build/build.dart'; -import 'package:build_test/build_test.dart'; -import 'package:source_gen/source_gen.dart'; -import 'package:test/test.dart'; - -import '../test_utils/test_assets.dart'; - -void main() { - group('List type extraction', () { - test('extracts String from Ack.list(Ack.string())', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final listSchema = Ack.object({ - 'tags': Ack.list(Ack.string()), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'listSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final tagsField = modelInfo!.fields.firstWhere( - (f) => f.name == 'tags', - orElse: () => throw StateError('tags field not found'), - ); - - expect(tagsField.type.isDartCoreList, isTrue); - - final listType = tagsField.type as InterfaceType; - expect(listType.typeArguments.length, 1); - - final elementType = listType.typeArguments.first; - expect( - elementType.isDartCoreString, - isTrue, - reason: - 'Expected String, got ' - '${elementType.getDisplayString(withNullability: false)}', - ); - }); - }); - - test('extracts int from Ack.list(Ack.integer())', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final listSchema = Ack.object({ - 'numbers': Ack.list(Ack.integer()), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables.firstWhere( - (e) => e.name3 == 'listSchema', - ); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - final numbersField = modelInfo!.fields.firstWhere( - (f) => f.name == 'numbers', - ); - - final listType = numbersField.type as InterfaceType; - final elementType = listType.typeArguments.first; - - expect( - elementType.isDartCoreInt, - isTrue, - reason: - 'Expected int, got ' - '${elementType.getDisplayString(withNullability: false)}', - ); - }); - }); - - test('handles nested lists (List>)', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final nestedListSchema = Ack.object({ - 'matrix': Ack.list(Ack.list(Ack.integer())), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables.firstWhere( - (e) => e.name3 == 'nestedListSchema', - ); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - final matrixField = modelInfo!.fields.firstWhere( - (f) => f.name == 'matrix', - ); - - final outerListType = matrixField.type as InterfaceType; - expect(outerListType.isDartCoreList, isTrue); - - final innerType = outerListType.typeArguments.first; - expect( - innerType.isDartCoreList, - isTrue, - reason: - 'Expected List, got ' - '${innerType.getDisplayString(withNullability: false)}', - ); - - if (innerType is InterfaceType && innerType.isDartCoreList) { - final innerElementType = innerType.typeArguments.first; - expect( - innerElementType.isDartCoreInt, - isTrue, - reason: - 'Expected int, got ' - '${innerElementType.getDisplayString(withNullability: false)}', - ); - } - }); - }); - }); - - group('Top-level list schema variables', () { - test('resolves element type from schema variable reference', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final statusSchema = Ack.string().minLength(1); - -@AckType() -final statusesSchema = Ack.list(statusSchema); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'statusesSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - expect(modelInfo!.representationType, equals('List')); - }); - }); - - test('throws on circular list schema variable references', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final schemaASchema = Ack.list(schemaBSchema); - -@AckType() -final schemaBSchema = Ack.list(schemaASchema); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'schemaASchema'); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - ); - }); - }); - - test('throws on top-level Ack.list(Ack.object(...))', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final usersSchema = Ack.list(Ack.object({ - 'id': Ack.string(), -})); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'usersSchema'); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - ); - }); - }); - - test( - 'throws on top-level Ack.list(schemaFactory()) when element is not statically resolvable', - () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -schemaFactory() => Ack.string(); - -@AckType() -final usersSchema = Ack.list(schemaFactory()); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'usersSchema'); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - ); - }); - }, - ); - - test('supports prefixed Ack invocations in list schemas', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart' as ack; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final statusSchema = ack.Ack.string(); - -@AckType() -final statusesSchema = ack.Ack.list(ack.Ack.string().minLength(2)); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'statusesSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - expect(modelInfo!.representationType, equals('List')); - }); - }); - }); - - group('Schema alias cycles', () { - test('throws a clear circular-reference error for alias cycles', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final schemaASchema = schemaBSchema; - -@AckType() -final schemaBSchema = schemaASchema; -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'schemaASchema'); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA( - predicate( - (error) => - error is InvalidGenerationSource && - error.toString().contains('Circular schema reference'), - ), - ), - ); - }); - }); - }); - - group('Nested schema references', () { - test('resolves schema variable reference', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final addressSchema = Ack.object({ - 'street': Ack.string(), - 'city': Ack.string(), -}); - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - 'address': addressSchema, -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables.firstWhere( - (e) => e.name3 == 'userSchema', - ); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final addressField = modelInfo!.fields.firstWhere( - (f) => f.name == 'address', - orElse: () => throw StateError('address field not found'), - ); - - expect( - addressField.type.isDartCoreMap, - isTrue, - reason: 'Expected Map type for nested schema reference', - ); - }); - }); - - test('handles multiple schema references', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final addressSchema = Ack.object({'street': Ack.string()}); - -@AckType() -final phoneSchema = Ack.object({'number': Ack.string()}); - -@AckType() -final contactSchema = Ack.object({ - 'name': Ack.string(), - 'address': addressSchema, - 'phone': phoneSchema, -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables.firstWhere( - (e) => e.name3 == 'contactSchema', - ); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect( - modelInfo!.fields.length, - 3, - reason: - 'Expected 3 fields (name, address, phone), ' - 'got ${modelInfo.fields.map((f) => f.name).join(", ")}', - ); - }); - }); - }); - - group('Method chain walker', () { - test('handles normal method chains correctly', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final chainedSchema = Ack.object({ - 'optionalNullable': Ack.string().optional().nullable(), - 'nullableOptional': Ack.string().nullable().optional(), - 'basicField': Ack.string(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables.firstWhere( - (e) => e.name3 == 'chainedSchema', - ); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - // Verify optional().nullable() - final optNullField = modelInfo!.fields.firstWhere( - (f) => f.name == 'optionalNullable', - ); - expect(optNullField.isRequired, isFalse); - expect(optNullField.isNullable, isTrue); - - // Verify nullable().optional() (different order, same result) - final nullOptField = modelInfo.fields.firstWhere( - (f) => f.name == 'nullableOptional', - ); - expect(nullOptField.isRequired, isFalse); - expect(nullOptField.isNullable, isTrue); - }); - }); - - test( - 'throws a clear error when field chains exceed analyzer depth', - () async { - // Create a chain with 25 .optional() calls to test depth limits - final deepChain = List.generate(25, (_) => 'optional()').join('.'); - - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': - ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final deepSchema = Ack.object({ - 'field': Ack.string().$deepChain, -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables.firstWhere( - (e) => e.name3 == 'deepSchema', - ); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA( - predicate( - (error) => - error is InvalidGenerationSource && - error.toString().contains('exceeded max depth of 20'), - ), - ), - ); - }); - }, - ); - }); - - group('List elements with method chain modifiers', () { - test('extracts String from Ack.list(Ack.string().describe(...))', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final testSchema = Ack.object({ - 'colors': Ack.list(Ack.string().describe('A hex color value')), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'testSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - final colorsField = modelInfo!.fields.firstWhere( - (f) => f.name == 'colors', - ); - final listType = colorsField.type as InterfaceType; - final elementType = listType.typeArguments.first; - - expect( - elementType.isDartCoreString, - isTrue, - reason: - 'Expected String, got ' - '${elementType.getDisplayString(withNullability: false)}', - ); - }); - }); - - test('extracts String from Ack.list(Ack.enumString(...))', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final testSchema = Ack.object({ - 'styles': Ack.list(Ack.enumString(['bold', 'italic', 'underline'])), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'testSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - final stylesField = modelInfo!.fields.firstWhere( - (f) => f.name == 'styles', - ); - final listType = stylesField.type as InterfaceType; - final elementType = listType.typeArguments.first; - - expect( - elementType.isDartCoreString, - isTrue, - reason: - 'Expected String, got ' - '${elementType.getDisplayString(withNullability: false)}', - ); - }); - }); - - test('extracts int from Ack.list(Ack.integer().min(0).max(100))', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final testSchema = Ack.object({ - 'scores': Ack.list(Ack.integer().min(0).max(100)), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'testSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - final scoresField = modelInfo!.fields.firstWhere( - (f) => f.name == 'scores', - ); - final listType = scoresField.type as InterfaceType; - final elementType = listType.typeArguments.first; - - expect( - elementType.isDartCoreInt, - isTrue, - reason: - 'Expected int, got ' - '${elementType.getDisplayString(withNullability: false)}', - ); - }); - }); - - test( - 'throws on Ack.list(schemaFactory()) when element is not statically resolvable', - () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -schemaFactory() => Ack.string(); - -@AckType() -final testSchema = Ack.object({ - 'items': Ack.list(schemaFactory()), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'testSchema'); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - ); - }); - }, - ); - - test( - 'throws when list element method chain exceeds analyzer depth', - () async { - final deepChain = - 'Ack.string()${List.filled(24, ".describe('x')").join()}'; - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': - ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final testSchema = Ack.object({ - 'items': Ack.list($deepChain), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'testSchema'); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - ); - }); - }, - ); - }); - - group('Nested object lists with method chain modifiers', () { - test('throws on Ack.list(Ack.object({...}).describe(...))', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final testSchema = Ack.object({ - 'items': Ack.list(Ack.object({ - 'name': Ack.string(), - }).describe('An item')), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'testSchema'); - - final analyzer = SchemaAstAnalyzer(); - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - ); - }); - }); - - test('throws on Ack.list(Ack.object({...}).optional())', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final testSchema = Ack.object({ - 'records': Ack.list(Ack.object({ - 'id': Ack.integer(), - }).optional()), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'testSchema'); - - final analyzer = SchemaAstAnalyzer(); - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - ); - }); - }); - }); - - group('Schema variable references with method chain modifiers', () { - test('extracts Map from Ack.list(schemaRef.optional())', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final itemSchema = Ack.object({ - 'name': Ack.string(), -}); - -@AckType() -final containerSchema = Ack.object({ - 'items': Ack.list(itemSchema.optional()), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'containerSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - final itemsField = modelInfo!.fields.firstWhere( - (f) => f.name == 'items', - ); - final listType = itemsField.type as InterfaceType; - final elementType = listType.typeArguments.first; - - expect( - elementType.isDartCoreMap, - isTrue, - reason: - 'Expected Map, got ' - '${elementType.getDisplayString(withNullability: false)}', - ); - }); - }); - - test('extracts Map from Ack.list(schemaRef.describe(...))', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final addressSchema = Ack.object({ - 'street': Ack.string(), -}); - -@AckType() -final userSchema = Ack.object({ - 'addresses': Ack.list(addressSchema.describe('User address')), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'userSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - final addressesField = modelInfo!.fields.firstWhere( - (f) => f.name == 'addresses', - ); - final listType = addressesField.type as InterfaceType; - final elementType = listType.typeArguments.first; - - expect( - elementType.isDartCoreMap, - isTrue, - reason: - 'Expected Map, got ' - '${elementType.getDisplayString(withNullability: false)}', - ); - }); - }); - }); - - group('Field name keyword validation', () { - const allowedKeywords = [ - 'of', - 'augment', - 'abstract', - 'covariant', - 'show', - 'hide', - 'on', - ]; - const reservedKeywords = ['class', 'if', 'return', 'void']; - - test('allows built-in and pseudo keywords as object field names', () async { - final properties = allowedKeywords - .map((keyword) => " '$keyword': Ack.string(),") - .join('\n'); - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': - ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final keywordSchema = Ack.object({ -$properties -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'keywordSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - expect( - modelInfo!.fields.map((f) => f.name), - containsAll(allowedKeywords), - ); - }); - }); - - test('rejects reserved keywords as object field names', () async { - for (final keyword in reservedKeywords) { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': - ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final keywordSchema = Ack.object({ - '$keyword': Ack.string(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'keywordSchema'); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - reason: 'Expected "$keyword" to be rejected as reserved keyword.', - ); - }); - } - }); - }); - - group('Enum, literal, and enumValues as fields inside Ack.object()', () { - test('handles Ack.enumString() as field in Ack.object()', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final reviewSchema = Ack.object({ - 'file': Ack.string(), - 'severity': Ack.enumString(['error', 'warning', 'info']), - 'message': Ack.string(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'reviewSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - expect(modelInfo!.fields.length, 3); - - final severityField = modelInfo.fields.firstWhere( - (f) => f.name == 'severity', - ); - - expect(severityField.type.isDartCoreString, isTrue); - }); - }); - - test('handles Ack.enumString() with optional/nullable modifiers', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final formSchema = Ack.object({ - 'priority': Ack.enumString(['low', 'medium', 'high']).optional().nullable(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'formSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final priorityField = modelInfo!.fields.firstWhere( - (f) => f.name == 'priority', - ); - - expect(priorityField.type.isDartCoreString, isTrue); - expect(priorityField.isRequired, isFalse); - expect(priorityField.isNullable, isTrue); - }); - }); - - test('handles Ack.literal() as field in Ack.object()', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final eventSchema = Ack.object({ - 'type': Ack.literal('click'), - 'target': Ack.string(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'eventSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final typeField = modelInfo!.fields.firstWhere((f) => f.name == 'type'); - - expect(typeField.type.isDartCoreString, isTrue); - }); - }); - - test('handles Ack.enumValues() as field in Ack.object()', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -enum UserRole { admin, editor, viewer } - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - 'role': Ack.enumValues(UserRole.values), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'userSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - expect(modelInfo!.fields.length, 2); - - final roleField = modelInfo.fields.firstWhere((f) => f.name == 'role'); - - expect(roleField.type.element3, isNotNull); - expect( - roleField.type.getDisplayString(withNullability: false), - equals('UserRole'), - ); - }); - }); - - test( - 'handles Ack.enumValues() with optional modifier in Ack.object()', - () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -enum Priority { low, medium, high, critical } - -@AckType() -final taskSchema = Ack.object({ - 'title': Ack.string(), - 'priority': Ack.enumValues(Priority.values).optional(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'taskSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final priorityField = modelInfo!.fields.firstWhere( - (f) => f.name == 'priority', - ); - - expect(priorityField.isRequired, isFalse); - expect( - priorityField.type.getDisplayString(withNullability: false), - equals('Priority'), - ); - }); - }, - ); - - test('handles Ack.list(Ack.enumString()) in Ack.object()', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final configSchema = Ack.object({ - 'tags': Ack.list(Ack.enumString(['a', 'b', 'c'])), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'configSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final tagsField = modelInfo!.fields.firstWhere((f) => f.name == 'tags'); - - expect(tagsField.type.isDartCoreList, isTrue); - - final listType = tagsField.type as InterfaceType; - final elementType = listType.typeArguments.first; - expect(elementType.isDartCoreString, isTrue); - }); - }); - - test('handles Ack.list(Ack.enumValues()) in Ack.object()', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -enum UserRole { admin, editor, viewer } - -@AckType() -final teamSchema = Ack.object({ - 'name': Ack.string(), - 'roles': Ack.list(Ack.enumValues(UserRole.values)), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'teamSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final rolesField = modelInfo!.fields.firstWhere( - (f) => f.name == 'roles', - ); - - expect(rolesField.type.isDartCoreList, isTrue); - - final listType = rolesField.type as InterfaceType; - final elementType = listType.typeArguments.first; - expect( - elementType.getDisplayString(withNullability: false), - equals('UserRole'), - ); - }); - }); - - test('handles Ack.list(Ack.literal()) in Ack.object()', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final actionSchema = Ack.object({ - 'types': Ack.list(Ack.literal('click')), - 'name': Ack.string(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'actionSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final typesField = modelInfo!.fields.firstWhere( - (f) => f.name == 'types', - ); - - expect(typesField.type.isDartCoreList, isTrue); - - final listType = typesField.type as InterfaceType; - final elementType = listType.typeArguments.first; - expect(elementType.isDartCoreString, isTrue); - }); - }); - - test('handles Ack.enumValues().nullable() without optional()', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -enum UserRole { admin, editor, viewer } - -@AckType() -final profileSchema = Ack.object({ - 'name': Ack.string(), - 'role': Ack.enumValues(UserRole.values).nullable(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name3 == 'profileSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final roleField = modelInfo!.fields.firstWhere((f) => f.name == 'role'); - - expect( - roleField.type.getDisplayString(withNullability: false), - equals('UserRole'), - ); - expect(roleField.isNullable, isTrue); - expect( - roleField.isRequired, - isTrue, - reason: 'nullable() alone should not make the field optional', - ); - }); - }); - }); -} diff --git a/packages/ack_generator/test/code_validation_test.dart b/packages/ack_generator/test/code_validation_test.dart deleted file mode 100644 index f7cff9d2..00000000 --- a/packages/ack_generator/test/code_validation_test.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:ack_generator/src/validation/code_validator.dart'; -import 'package:test/test.dart'; - -void main() { - group('CodeValidator', () { - test('should catch syntax errors', () { - const invalidSyntax = ''' - class Test { - void method() { - // Missing closing brace - } - '''; - - final result = CodeValidator.validate(invalidSyntax); - expect(result.isFailure, isTrue); - expect(result.errorMessage, contains('syntax')); - }); - - test('should pass valid syntax even with undefined identifiers', () { - const validSyntaxInvalidSemantic = ''' - class Test extends UndefinedClass { - UndefinedType method() { - return undefinedFunction(); - } - } - '''; - - final result = CodeValidator.validate(validSyntaxInvalidSemantic); - // This should pass because syntax is valid, even though semantics are wrong - expect(result.isSuccess, isTrue); - }); - - test('should catch actual syntax errors like missing braces', () { - const actualSyntaxError = ''' - class Test { - void method( { - return; - } - } - '''; - - final result = CodeValidator.validate(actualSyntaxError); - expect(result.isFailure, isTrue); - }); - }); -} diff --git a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart b/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart deleted file mode 100644 index 36d33c20..00000000 --- a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart +++ /dev/null @@ -1,930 +0,0 @@ -import 'package:ack_generator/builder.dart'; -import 'package:build/build.dart'; -import 'package:build_test/build_test.dart'; -import 'package:test/test.dart'; - -import '../test_utils/generation_test_utils.dart'; -import '../test_utils/test_assets.dart'; - -void main() { - group('@AckType cross-file schema references', () { - test( - 'resolves typed nested getters across files (direct import)', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final slideSchema = Ack.object({ - 'id': Ack.string(), - 'title': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'deck_schemas.dart'; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': slideSchema, - 'slides': Ack.list(slideSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/deck_schemas.g.dart': decodedMatches( - contains('extension type SlideType(Map _data)'), - ), - 'test_pkg|lib/deck_tools_schemas.g.dart': decodedMatches( - allOf([ - contains( - 'extension type DeckToolArgsType(Map _data)', - ), - contains('SlideType get currentSlide'), - contains( - "SlideType(_data['currentSlide'] as Map)", - ), - contains('List get slides'), - contains('SlideType(e as Map)'), - ]), - ), - }, - ); - }, - ); - - test('resolves typed nested getters for prefixed schema imports', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'deck_schemas.dart' as deck; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': deck.slideSchema, - 'slides': Ack.list(deck.slideSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/deck_schemas.g.dart': decodedMatches( - contains('extension type SlideType(Map _data)'), - ), - 'test_pkg|lib/deck_tools_schemas.g.dart': decodedMatches( - allOf([ - contains('deck.SlideType get currentSlide'), - contains('List get slides'), - ]), - ), - }, - ); - }); - - test('resolves typed nested getters through re-exported schemas', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_schema_exports.dart': ''' -export 'deck_schemas.dart'; -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'deck_schema_exports.dart'; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': slideSchema, - 'slides': Ack.list(slideSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/deck_schemas.g.dart': decodedMatches( - contains('extension type SlideType(Map _data)'), - ), - 'test_pkg|lib/deck_tools_schemas.g.dart': decodedMatches( - allOf([ - contains('SlideType get currentSlide'), - contains('List get slides'), - ]), - ), - }, - ); - }); - - test('resolves transformed schema refs across files', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'palette_schemas.dart'; - -@AckType() -final themeSchema = Ack.object({ - 'accent': colorSchema, - 'colors': Ack.list(colorSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/palette_schemas.g.dart': decodedMatches( - contains('extension type ColorType(Color _value)'), - ), - 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( - allOf([ - contains('extension type ThemeType(Map _data)'), - contains('ColorType get accent'), - contains("ColorType(_data['accent'] as Color)"), - contains('List get colors'), - contains('ColorType(e as Color)'), - ]), - ), - }, - ); - }); - - test( - 'resolves transformed schema refs through re-exported schemas', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/palette_schema_exports.dart': ''' -export 'palette_schemas.dart'; -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'palette_schema_exports.dart'; - -@AckType() -final themeSchema = Ack.object({ - 'accent': colorSchema, - 'colors': Ack.list(colorSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/palette_schemas.g.dart': decodedMatches( - contains('extension type ColorType(Color _value)'), - ), - 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( - allOf([ - contains( - 'extension type ThemeType(Map _data)', - ), - contains('ColorType get accent'), - contains("ColorType(_data['accent'] as Color)"), - contains('List get colors'), - contains('ColorType(e as Color)'), - ]), - ), - }, - ); - }, - ); - - test('resolves prefixed transformed schema refs across files', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'palette_schemas.dart' as palette; - -@AckType() -final themeSchema = Ack.object({ - 'accent': palette.colorSchema, - 'colors': Ack.list(palette.colorSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/palette_schemas.g.dart': decodedMatches( - contains('extension type ColorType(Color _value)'), - ), - 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( - allOf([ - contains('palette.ColorType get accent'), - contains("palette.ColorType(_data['accent'] as palette.Color)"), - contains('List get colors'), - contains('palette.ColorType(e as palette.Color)'), - ]), - ), - }, - ); - }); - - test( - 'resolves prefixed transformed refs without @AckType using visible representation types', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; - -class Color { - final String value; - const Color(this.value); -} - -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'palette_schemas.dart' as palette; - -@AckType() -final themeSchema = Ack.object({ - 'accent': palette.colorSchema, - 'colors': Ack.list(palette.colorSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( - allOf([ - contains('palette.Color get accent'), - contains("_data['accent'] as palette.Color"), - contains('List get colors'), - contains("_\$ackListCast(_data['colors'])"), - ]), - ), - }, - ); - }, - ); - - test( - 'fails for direct-import transformed refs when representation types are not visible', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'is not visible from this library', - expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, - assets: { - ...allAssets, - 'test_pkg|lib/hidden_types.dart': ''' -class HiddenColor { - final String value; - const HiddenColor(this.value); -} -''', - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -import 'hidden_types.dart'; - -@AckType() -final hiddenColorSchema = Ack.string() - .transform((value) => HiddenColor(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'palette_schemas.dart'; - -@AckType() -final themeSchema = Ack.object({ - 'accent': hiddenColorSchema, -}); -''', - }, - ); - }, - ); - - test( - 'resolves prefixed transformed generic refs when representation types are exported', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -class Box { - final T value; - const Box(this.value); -} - -@AckType() -final boxedColorSchema = - Ack.string().transform>((value) => Box(Color(value))); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'palette_schemas.dart' as palette; - -class Color { - final String localValue; - const Color(this.localValue); -} - -class Box { - final T localValue; - const Box(this.localValue); -} - -@AckType() -final themeSchema = Ack.object({ - 'accent': palette.boxedColorSchema, - 'colors': Ack.list(palette.boxedColorSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/palette_schemas.g.dart': decodedMatches( - contains('extension type BoxedColorType(Box _value)'), - ), - 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( - allOf([ - contains('palette.BoxedColorType get accent'), - contains( - "palette.BoxedColorType(_data['accent'] as palette.Box)", - ), - contains('List get colors'), - contains( - 'palette.BoxedColorType(e as palette.Box)', - ), - ]), - ), - }, - ); - }, - ); - - test( - 'fails for direct-import transformed refs when representation types collide locally', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'is ambiguous in this library', - expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, - assets: { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'palette_schemas.dart'; - -class Color { - final String localValue; - const Color(this.localValue); -} - -@AckType() -final themeSchema = Ack.object({ - 'accent': colorSchema, -}); -''', - }, - ); - }, - ); - - test( - 'fails for direct-import transformed refs when multiple imports expose the representation type', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'is ambiguous in this library', - expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, - assets: { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/alt_color_types.dart': ''' -class Color { - final String localValue; - const Color(this.localValue); -} -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'alt_color_types.dart'; -import 'palette_schemas.dart'; - -@AckType() -final themeSchema = Ack.object({ - 'accent': colorSchema, -}); -''', - }, - ); - }, - ); - - test( - 'fails for prefixed transformed refs when representation types are not visible', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'is not visible from this library', - expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, - assets: { - ...allAssets, - 'test_pkg|lib/hidden_types.dart': ''' -class HiddenColor { - final String value; - const HiddenColor(this.value); -} -''', - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -import 'hidden_types.dart'; - -@AckType() -final hiddenColorSchema = Ack.string() - .transform((value) => HiddenColor(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'palette_schemas.dart' as palette; - -@AckType() -final themeSchema = Ack.object({ - 'accent': palette.hiddenColorSchema, -}); -''', - }, - ); - }, - ); - - test( - 'fails for cross-file transformed refs that use qualified representation types', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: - 'uses a qualified type that cannot be referenced across library boundaries', - expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, - assets: { - ...allAssets, - 'test_pkg|lib/hidden_types.dart': ''' -class HiddenColor { - final String value; - const HiddenColor(this.value); -} -''', - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -import 'hidden_types.dart' as dep; - -@AckType() -final hiddenColorSchema = Ack.string() - .transform((value) => dep.HiddenColor(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'palette_schemas.dart' as palette; - -@AckType() -final themeSchema = Ack.object({ - 'accent': palette.hiddenColorSchema, -}); -''', - }, - ); - }, - ); - - test('supports @AckType alias schema declarations', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'deck_schemas.dart'; - -@AckType() -final sharedSlideSchema = slideSchema; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': sharedSlideSchema, -}); -''', - }, - outputs: { - 'test_pkg|lib/deck_schemas.g.dart': decodedMatches( - contains('extension type SlideType(Map _data)'), - ), - 'test_pkg|lib/deck_tools_schemas.g.dart': decodedMatches( - allOf([ - contains('extension type SharedSlideType'), - contains('SharedSlideType get currentSlide'), - ]), - ), - }, - ); - }); - - test( - 'resolves typed nested getters for schema references with optional/nullable modifiers', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'deck_schemas.dart'; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': slideSchema.optional(), - 'selectedSlide': slideSchema.nullable(), -}); -''', - }, - outputs: { - 'test_pkg|lib/deck_schemas.g.dart': decodedMatches( - contains('extension type SlideType(Map _data)'), - ), - 'test_pkg|lib/deck_tools_schemas.g.dart': decodedMatches( - allOf([ - contains('SlideType? get currentSlide'), - contains( - "SlideType? get currentSlide => _data['currentSlide'] != null", - ), - contains( - "? SlideType(_data['currentSlide'] as Map)", - ), - contains('SlideType? get selectedSlide'), - contains( - "SlideType? get selectedSlide => _data['selectedSlide'] != null", - ), - contains( - "? SlideType(_data['selectedSlide'] as Map)", - ), - ]), - ), - }, - ); - }, - ); - - test('fails when nested object schema reference is unresolved', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; - -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'deck_schemas.dart' as deck; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': deck.missingSlideSchema, -}); -''', - }, - outputs: {'test_pkg|lib/deck_tools_schemas.g.dart': anything}, - ), - // Generator emits: 'Could not resolve schema reference "missingSlideSchema"' - throwsA(isA()), - ); - }); - - test( - 'fails when prefixed schema reference does not exist in that prefix namespace', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/a_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final aOnlySchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/b_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'a_schemas.dart' as a; -import 'b_schemas.dart'; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': a.slideSchema, - 'known': slideSchema, -}); -''', - }, - outputs: {'test_pkg|lib/deck_tools_schemas.g.dart': anything}, - ), - // Generator emits: 'Could not resolve schema reference "slideSchema"' - throwsA(isA()), - ); - }, - ); - - test('fails when nested object schema reference lacks @AckType', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; - -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'deck_schemas.dart'; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': slideSchema, - 'slides': Ack.list(slideSchema), -}); -''', - }, - outputs: {'test_pkg|lib/deck_tools_schemas.g.dart': anything}, - ), - // Generator emits: 'references object schema "slideSchema" without @AckType' - throwsA(isA()), - ); - }); - - test( - 'resolves direct-import transformed refs without @AckType using visible representation types', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/palette.dart': ''' -import 'package:ack/ack.dart'; - -class Color { - final String value; - const Color(this.value); -} - -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'palette.dart' as palette; - -@AckType() -final themeSchema = Ack.object({ - 'primary': palette.colorSchema, - 'accents': Ack.list(palette.colorSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( - allOf([ - contains('palette.Color get primary'), - contains("_data['primary'] as palette.Color"), - contains('List get accents'), - contains("_\$ackListCast(_data['accents'])"), - ]), - ), - }, - ); - }, - ); - - test( - 'fails when Ack.list(schemaRef) object reference lacks @AckType', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; - -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'deck_schemas.dart'; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'slides': Ack.list(slideSchema), -}); -''', - }, - outputs: {'test_pkg|lib/deck_tools_schemas.g.dart': anything}, - ), - // Generator emits: 'Ack.list(slideSchema) references object schema without @AckType' - throwsA(isA()), - ); - }, - ); - }); -} diff --git a/packages/ack_generator/test/integration/ack_type_custom_name_test.dart b/packages/ack_generator/test/integration/ack_type_custom_name_test.dart deleted file mode 100644 index e20a79eb..00000000 --- a/packages/ack_generator/test/integration/ack_type_custom_name_test.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'package:ack_generator/builder.dart'; -import 'package:build/build.dart'; -import 'package:build_test/build_test.dart'; -import 'package:test/test.dart'; - -import '../test_utils/test_assets.dart'; - -void main() { - group('@AckType custom names', () { - test('generates extension types for non-nullable schemas', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -// Primitive schemas - extension types ARE generated -@AckType() -final passwordSchema = Ack.string(); - -@AckType(name: 'CustomPassword') -final customPasswordSchema = Ack.string().nullable(); - -@AckType(name: 'Order2') -final orderSchema = Ack.integer(); - -// Object schema - extension type IS generated -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), -}); - -@AckType(name: 'CustomUser') -final customUserSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - // Non-nullable primitives SHOULD have extension types - contains('extension type PasswordType(String _value)'), - contains('extension type Order2Type(int _value)'), - // Nullable schema should NOT generate extension type - isNot( - contains('extension type CustomPasswordType(String _value)'), - ), - // Object types SHOULD have extension types - contains('extension type UserType(Map _data)'), - contains( - 'extension type CustomUserType(Map _data)', - ), - ]), - ), - }, - ); - }); - - test('normalizes lowercase custom names', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType(name: 'customUser') -final customUserSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains( - 'extension type CustomUserType(Map _data)', - ), - ]), - ), - }, - ); - }); - - test('throws when custom name contains invalid characters', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType(name: 'bad-name') -final invalidSchema = Ack.string(); -''', - }, - outputs: {}, - onLog: (log) { - if (log.level.name == 'SEVERE') { - expect( - log.message, - contains('Invalid custom @AckType name "bad-name"'), - ); - } - }, - ); - }); - - test('throws when custom name is empty', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType(name: '') -final invalidSchema = Ack.string(); -''', - }, - outputs: {}, - onLog: (log) { - if (log.level.name == 'SEVERE') { - expect( - log.message, - contains('Custom @AckType name cannot be empty'), - ); - } - }, - ); - }); - - test('rejects @AckType on classes', () async { - final builder = ackGenerator(BuilderOptions.empty); - var sawError = false; - - try { - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -class User { - final String name; - User(this.name); -} -''', - }, - outputs: {}, - onLog: (log) { - if (log.level.name == 'SEVERE') { - sawError = true; - expect(log.message, contains('AckType')); - } - }, - ); - } catch (error) { - sawError = true; - expect(error.toString(), contains('AckType')); - } - - expect(sawError, isTrue); - }); - - test('nullable object schemas do not generate extension types', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -// Non-nullable object schema - extension type IS generated -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), -}); - -// Nullable object schema - extension type should NOT be generated -@AckType() -final nullableUserSchema = Ack.object({ - 'name': Ack.string(), -}).nullable(); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - // Non-nullable object SHOULD have extension type - contains('extension type UserType(Map _data)'), - // Nullable object should NOT have extension type - isNot(contains('extension type NullableUserType')), - ]), - ), - }, - ); - }); - }); -} diff --git a/packages/ack_generator/test/integration/ack_type_discriminated_test.dart b/packages/ack_generator/test/integration/ack_type_discriminated_test.dart deleted file mode 100644 index 2b2e8acc..00000000 --- a/packages/ack_generator/test/integration/ack_type_discriminated_test.dart +++ /dev/null @@ -1,678 +0,0 @@ -import 'package:ack_generator/builder.dart'; -import 'package:build/build.dart'; -import 'package:build_test/build_test.dart'; -import 'package:test/test.dart'; - -import '../test_utils/generation_test_utils.dart'; -import '../test_utils/test_assets.dart'; - -void main() { - group('@AckType discriminated schemas', () { - test( - 'generates discriminated subtypes when branches omit discriminator property', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'lives': Ack.integer(), -}); - -@AckType() -ObjectSchema get dogSchema => Ack.object({ - 'bark': Ack.boolean(), -}).passthrough(); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - 'dog': dogSchema, - }, -); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type PetType(Map _data)'), - contains('implements Map'), - contains("switch (map['kind'])"), - contains("'cat' => CatType(map)"), - contains("'dog' => DogType(map)"), - contains('extension type CatType(Map _data)'), - contains('extension type DogType(Map _data)'), - contains('implements PetType, Map'), - contains('return petSchema.parseAs('), - contains('return petSchema.safeParseAs('), - contains(".effectiveBranch('cat')"), - contains(".effectiveBranch('dog')"), - isNot(contains('return catSchema.parseAs(')), - isNot(contains('return catSchema.safeParseAs(')), - isNot(contains('return dogSchema.parseAs(')), - isNot(contains('return dogSchema.safeParseAs(')), - isNot(contains("map['kind'] !=")), - isNot(contains('Expected kind')), - contains('Map get args =>'), - contains("e.key != 'kind' && e.key != 'bark'"), - predicate((content) { - final source = content as String; - final count = RegExp( - r"String get kind => _data\['kind'\] as String;", - ).allMatches(source).length; - return count == 3; - }, 'contains one kind getter per generated type'), - ]), - ), - }, - ); - }, - ); - - test('allows existing matching discriminator literal', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type PetType(Map _data)'), - contains('extension type CatType(Map _data)'), - contains("String get kind => _data['kind'] as String;"), - ]), - ), - }, - ); - }); - - test('allows existing matching discriminator enum', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.enumString(['cat', 'kitty']), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type PetType(Map _data)'), - contains('extension type CatType(Map _data)'), - contains("String get kind => _data['kind'] as String;"), - ]), - ), - }, - ); - }); - - test('fails when branch discriminator property is broad string', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'could not be proven to accept "cat"', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.string(), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }); - - test( - 'fails when matching discriminator literal has restrictive chain', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'could not be proven to accept "cat"', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat').minLength(4), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }, - ); - - test( - 'fails when matching discriminator enum has restrictive chain', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'could not be proven to accept "cat"', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.enumString(['cat']).minLength(4), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }, - ); - - test('fails when a branch is an inline expression', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'must reference a top-level schema variable/getter', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), - }), - }, -); -''', - }, - ); - }); - - test('fails when a branch lacks @AckType', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'must be annotated with @AckType', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }); - - test('fails when a branch schema is not object-shaped', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'must be an object schema', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final catSchema = Ack.string(); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }); - - test('fails when a branch comes from another library', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'must be declared in the same library', - expectedOutputs: {'test_pkg|lib/branches.g.dart': anything}, - assets: { - ...allAssets, - 'test_pkg|lib/branches.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); -''', - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'branches.dart'; - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }); - - test('fails when discriminated base is nullable', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'cannot be nullable when used with @AckType', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -).nullable(); -''', - }, - ); - }); - - test('fails when schemas map is empty', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'must contain at least one branch', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: {}, -); -''', - }, - ); - }); - - test('fails when a branch schema is nullable', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'cannot be nullable', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}).nullable(); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }); - - test('fails when discriminator values are duplicated', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'duplicate discriminator value', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); - -@AckType() -final dogSchema = Ack.object({ - 'kind': Ack.literal('dog'), - 'bark': Ack.boolean(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - 'cat': dogSchema, - }, -); -''', - }, - ); - }); - - test( - 'fails when branch map key mismatches discriminator literal', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'but is mapped as', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - 'kitty': catSchema, - }, -); -''', - }, - ); - }, - ); - - test( - 'fails when schemas key does not match branch discriminator literal', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'but is mapped as', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final dogSchema = Ack.object({ - 'kind': Ack.literal('dog'), - 'bark': Ack.boolean(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': dogSchema, - }, -); -''', - }, - ); - }, - ); - - test('fails when a branch is reused across multiple bases', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'mapped to multiple discriminated bases', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); - -@AckType() -final anotherPetSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }); - - test('fails when aliased branch is reused across multiple bases', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'mapped to multiple discriminated bases', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); - -@AckType() -final catAliasOne = catSchema; - -@AckType() -final catAliasTwo = catSchema; - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catAliasOne, - }, -); - -@AckType() -final anotherPetSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catAliasTwo, - }, -); -''', - }, - ); - }); - - test('fails when a branch is itself a discriminated base', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'Nested discriminated unions are not supported', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); - -@AckType() -final innerSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); - -@AckType() -final outerSchema = Ack.discriminated( - discriminatorKey: 'type', - schemas: { - 'inner': innerSchema, - }, -); -''', - }, - ); - }); - }); -} diff --git a/packages/ack_generator/test/integration/ack_type_enum_literal_fields_test.dart b/packages/ack_generator/test/integration/ack_type_enum_literal_fields_test.dart deleted file mode 100644 index 3c9e1eea..00000000 --- a/packages/ack_generator/test/integration/ack_type_enum_literal_fields_test.dart +++ /dev/null @@ -1,438 +0,0 @@ -import 'package:ack_generator/builder.dart'; -import 'package:build/build.dart'; -import 'package:build_test/build_test.dart'; -import 'package:test/test.dart'; - -import '../test_utils/test_assets.dart'; - -void main() { - group('@AckType with enumString, literal, and enumValues fields', () { - test('enumString field generates String getter', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final reviewSchema = Ack.object({ - 'file': Ack.string(), - 'severity': Ack.enumString(['error', 'warning', 'info']), - 'message': Ack.string(), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type ReviewType(Map _data)'), - contains('String get file'), - contains("_data['file'] as String"), - contains('String get severity'), - contains("_data['severity'] as String"), - contains('String get message'), - contains("_data['message'] as String"), - ]), - ), - }, - ); - }); - - test('literal field generates String getter', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final eventSchema = Ack.object({ - 'type': Ack.literal('click'), - 'target': Ack.string(), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type EventType(Map _data)'), - contains('String get type'), - contains("_data['type'] as String"), - contains('String get target'), - contains("_data['target'] as String"), - ]), - ), - }, - ); - }); - - test('enumValues field generates enum type getter', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -enum UserRole { admin, editor, viewer } - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - 'role': Ack.enumValues(UserRole.values), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type UserType(Map _data)'), - contains('String get name'), - contains('UserRole get role'), - contains("_data['role'] as UserRole"), - ]), - ), - }, - ); - }); - - test( - 'enumString with optional().nullable() generates String? getter', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final formSchema = Ack.object({ - 'title': Ack.string(), - 'priority': Ack.enumString(['low', 'medium', 'high']).optional().nullable(), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type FormType(Map _data)'), - contains('String get title'), - contains('String? get priority'), - contains("_data['priority'] as String?"), - ]), - ), - }, - ); - }, - ); - - test('enumValues with optional() generates T? getter', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -enum Priority { low, medium, high, critical } - -@AckType() -final taskSchema = Ack.object({ - 'title': Ack.string(), - 'priority': Ack.enumValues(Priority.values).optional(), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type TaskType(Map _data)'), - contains('String get title'), - contains('Priority? get priority'), - contains("_data['priority'] as Priority?"), - ]), - ), - }, - ); - }); - - test('Ack.list(Ack.enumString()) generates List getter', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final configSchema = Ack.object({ - 'name': Ack.string(), - 'tags': Ack.list(Ack.enumString(['a', 'b', 'c'])), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type ConfigType(Map _data)'), - contains('String get name'), - contains('List get tags'), - contains("_\$ackListCast(_data['tags'])"), - ]), - ), - }, - ); - }); - - test('Ack.list(Ack.enumValues()) generates List getter', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -enum UserRole { admin, editor, viewer } - -@AckType() -final teamSchema = Ack.object({ - 'name': Ack.string(), - 'roles': Ack.list(Ack.enumValues(UserRole.values)), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type TeamType(Map _data)'), - contains('String get name'), - contains('List get roles'), - contains("_\$ackListCast(_data['roles'])"), - ]), - ), - }, - ); - }); - - test( - 'enumValues with imported prefixed enum keeps prefixed field type', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/enums.dart': ''' -enum UserRole { admin, editor, viewer } -''', - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'enums.dart' as models; - -@AckType() -final userSchema = Ack.object({ - 'role': Ack.enumValues(models.UserRole.values), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type UserType(Map _data)'), - contains('models.UserRole get role'), - contains("_data['role'] as models.UserRole"), - ]), - ), - }, - ); - }, - ); - - test( - 'top-level list enumValues preserves imported prefixed enum type', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/enums.dart': ''' -enum UserRole { admin, editor, viewer } -''', - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'enums.dart' as models; - -@AckType() -final roleListSchema = Ack.list(Ack.enumValues(models.UserRole.values)); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains( - 'extension type RoleListType(List _value)', - ), - contains('implements List'), - contains('RoleListType(validated as List)'), - ]), - ), - }, - ); - }, - ); - - test( - 'list enumValues with imported prefixed enum keeps prefixed getter type', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/enums.dart': ''' -enum UserRole { admin, editor, viewer } -''', - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'enums.dart' as models; - -@AckType() -final teamSchema = Ack.object({ - 'roles': Ack.list(Ack.enumValues(models.UserRole.values)), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('List get roles'), - contains("_\$ackListCast(_data['roles'])"), - ]), - ), - }, - ); - }, - ); - - test( - 'mixed schema with literal, enumString, enumValues, and string fields', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -enum Color { red, green, blue } - -@AckType() -final widgetSchema = Ack.object({ - 'type': Ack.literal('button'), - 'label': Ack.string(), - 'color': Ack.enumValues(Color.values), - 'style': Ack.enumString(['solid', 'outline', 'ghost']), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains( - 'extension type WidgetType(Map _data)', - ), - contains('String get type'), - contains("_data['type'] as String"), - contains('String get label'), - contains("_data['label'] as String"), - contains('Color get color'), - contains("_data['color'] as Color"), - contains('String get style'), - contains("_data['style'] as String"), - ]), - ), - }, - ); - }, - ); - - test( - 'enumValues infers enum type from variable and property inputs', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -enum UserRole { admin, editor, viewer } - -final roleValues = UserRole.values; - -class RoleHolder { - const RoleHolder(this.values); - final List values; -} - -const holder = RoleHolder(UserRole.values); - -@AckType() -final userSchema = Ack.object({ - 'roleFromVar': Ack.enumValues(roleValues), - 'roleFromProp': Ack.enumValues(holder.values), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('UserRole get roleFromVar'), - contains("_data['roleFromVar'] as UserRole"), - contains('UserRole get roleFromProp'), - contains("_data['roleFromProp'] as UserRole"), - ]), - ), - }, - ); - }, - ); - }); -} diff --git a/packages/ack_generator/test/integration/ack_type_getter_test.dart b/packages/ack_generator/test/integration/ack_type_getter_test.dart deleted file mode 100644 index febee9cf..00000000 --- a/packages/ack_generator/test/integration/ack_type_getter_test.dart +++ /dev/null @@ -1,203 +0,0 @@ -import 'package:ack_generator/builder.dart'; -import 'package:build/build.dart'; -import 'package:build_test/build_test.dart'; -import 'package:test/test.dart'; - -import '../test_utils/generation_test_utils.dart'; -import '../test_utils/test_assets.dart'; - -void main() { - group('@AckType getter support', () { - test('generates extension types from expression-body getters', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -StringSchema get statusSchema => Ack.string(); - -@AckType() -ObjectSchema get userSchema => Ack.object({ - 'status': statusSchema, - 'aliases': Ack.list(statusSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type StatusType(String _value)'), - contains('extension type UserType(Map _data)'), - contains('StatusType get status'), - contains("StatusType(_data['status'] as String)"), - contains('List get aliases'), - contains('StatusType(e as String)'), - contains('return statusSchema.parseAs('), - contains('return userSchema.safeParseAs('), - isNot(contains('_\$ackParse<')), - isNot(contains('_\$ackSafeParse<')), - ]), - ), - }, - ); - }); - - test('supports block-body getters and custom names', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType(name: 'CustomAddress') -ObjectSchema get addressSchema { - return Ack.object({ - 'city': Ack.string(), - }); -} - -@AckType() -ObjectSchema get userSchema { - return Ack.object({ - 'address': addressSchema, - }); -} -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains( - 'extension type CustomAddressType(Map _data)', - ), - contains('extension type UserType(Map _data)'), - contains('CustomAddressType get address'), - contains( - "CustomAddressType(_data['address'] as Map)", - ), - ]), - ), - }, - ); - }); - - test('uses prefixed SchemaResult when Ack import is aliased', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart' as ack; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -ack.StringSchema get statusSchema => ack.Ack.string(); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type StatusType(String _value)'), - contains('ack.SchemaResult safeParse'), - contains('return statusSchema.parseAs('), - contains('return statusSchema.safeParseAs('), - isNot(contains('_\$ackParse<')), - isNot(contains('_\$ackSafeParse<')), - ]), - ), - }, - ); - }); - - test('uses shared collection cast helper for primitive lists', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -ObjectSchema get userSchema => Ack.object({ - 'tags': Ack.list(Ack.string()), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type UserType(Map _data)'), - contains( - "List get tags => _\$ackListCast(_data['tags'])", - ), - contains( - 'List _\$ackListCast(Object? value) => (value as List).cast();', - ), - ]), - ), - }, - ); - }); - - test('rejects nullable list element schemas', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -ObjectSchema get userSchema => Ack.object({ - 'tags': Ack.list(Ack.string().nullable()), -}); -''', - }, - expectedMessage: - 'Ack.list(...) does not support nullable element schemas', - ); - }); - - test('rejects nullable list element schema references', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -final tagSchema = Ack.string().nullable(); - -@AckType() -ObjectSchema get userSchema => Ack.object({ - 'tags': Ack.list(tagSchema), -}); -''', - }, - expectedMessage: - 'Ack.list(...) does not support nullable element schemas', - ); - }); - }); -} diff --git a/packages/ack_generator/test/integration/ack_type_golden_test.dart b/packages/ack_generator/test/integration/ack_type_golden_test.dart deleted file mode 100644 index 87f4e8c8..00000000 --- a/packages/ack_generator/test/integration/ack_type_golden_test.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:ack_generator/builder.dart'; -import 'package:build/build.dart'; -import 'package:build_test/build_test.dart'; -import 'package:test/test.dart'; - -import '../test_utils/test_assets.dart'; - -/// Full-output snapshot for one canonical `@AckType` object schema. -/// -/// The scattered `decodedMatches(contains(...))` assertions in the sibling -/// integration tests catch missing pieces, but not formatting drift, member -/// reordering, or unexpected additions. This single exact-match golden guards -/// the overall shape of generated code for a representative schema; update the -/// expected string deliberately when the emitter output is meant to change. -void main() { - test('emits stable extension-type output for an object schema', () async { - final builder = ackGenerator(BuilderOptions.empty); - - const expected = ''' -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ************************************************************************** -// AckSchemaGenerator -// ************************************************************************** - -part of 'schema.dart'; - -/// Extension type for User -extension type UserType(Map _data) - implements Map { - static UserType parse(Object? data) { - return userSchema.parseAs( - data, - (validated) => UserType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return userSchema.safeParseAs( - data, - (validated) => UserType(validated as Map), - ); - } - - String get name => _data['name'] as String; - - int get age => _data['age'] as int; -} -'''; - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - 'age': Ack.integer(), -}); -''', - }, - outputs: {'test_pkg|lib/schema.g.dart': decodedMatches(expected)}, - ); - }); -} diff --git a/packages/ack_generator/test/integration/ack_type_nested_schema_test.dart b/packages/ack_generator/test/integration/ack_type_nested_schema_test.dart deleted file mode 100644 index d8347a4d..00000000 --- a/packages/ack_generator/test/integration/ack_type_nested_schema_test.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'package:ack_generator/builder.dart'; -import 'package:build/build.dart'; -import 'package:build_test/build_test.dart'; -import 'package:test/test.dart'; - -import '../test_utils/test_assets.dart'; - -void main() { - group('@AckType nested schema references', () { - test( - 'generates typed getters for primitive and object schema refs', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final statusSchema = Ack.string(); - -@AckType() -final addressSchema = Ack.object({ - 'street': Ack.string(), - 'city': Ack.string(), -}); - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - 'status': statusSchema, - 'address': addressSchema, - 'aliases': Ack.list(statusSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type StatusType(String _value)'), - contains( - 'extension type AddressType(Map _data)', - ), - contains('extension type UserType(Map _data)'), - contains('StatusType get status'), - contains("StatusType(_data['status'] as String)"), - contains('AddressType get address'), - contains( - "AddressType(_data['address'] as Map)", - ), - contains('List get aliases'), - contains('StatusType(e as String)'), - ]), - ), - }, - ); - }, - ); - - test('resolves custom @AckType names for nested refs', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType(name: 'CustomStatus') -final statusSchema = Ack.string(); - -@AckType() -final orderSchema = Ack.object({ - 'status': statusSchema, -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type CustomStatusType(String _value)'), - contains('CustomStatusType get status'), - contains("CustomStatusType(_data['status'] as String)"), - ]), - ), - }, - ); - }); - - test('fails on anonymous inline object fields in strict mode', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final userSchema = Ack.object({ - 'profile': Ack.object({ - 'name': Ack.string(), - }), -}); -''', - }, - outputs: {'test_pkg|lib/schema.g.dart': anything}, - ), - // Generator emits: 'anonymous inline Ack.object(...). Strict typed generation requires a named schema reference.' - throwsA(isA()), - ); - }); - - test('fails on Ack.list(Ack.object(...)) in strict mode', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final userSchema = Ack.object({ - 'profiles': Ack.list(Ack.object({ - 'name': Ack.string(), - })), -}); -''', - }, - outputs: {'test_pkg|lib/schema.g.dart': anything}, - ), - // Generator emits: 'anonymous inline Ack.object(...). Strict typed generation requires a named schema reference.' - throwsA(isA()), - ); - }); - }); -} diff --git a/packages/ack_generator/test/integration/ack_type_transform_test.dart b/packages/ack_generator/test/integration/ack_type_transform_test.dart deleted file mode 100644 index a03713a2..00000000 --- a/packages/ack_generator/test/integration/ack_type_transform_test.dart +++ /dev/null @@ -1,326 +0,0 @@ -import 'package:ack_generator/builder.dart'; -import 'package:build/build.dart'; -import 'package:build_test/build_test.dart'; -import 'package:test/test.dart'; - -import '../test_utils/test_assets.dart'; - -void main() { - group('@AckType transform support', () { - test( - 'supports top-level transformed schemas and direct transformed factories', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -class TagList { - final List value; - const TagList(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); - -@AckType() -final aliasColorSchema = colorSchema.transform((value) => value); - -@AckType() -final uriSchema = Ack.uri(); - -@AckType() -final dateSchema = Ack.date(); - -@AckType() -final datetimeSchema = Ack.datetime(); - -@AckType() -final durationSchema = Ack.duration(); - -@AckType() -final validatedStringSchema = Ack.string().uri(); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type ColorType(Color _value)'), - contains('extension type AliasColorType(Color _value)'), - contains('extension type UriType(Uri _value)'), - contains('extension type DateType(DateTime _value)'), - contains('extension type DatetimeType(DateTime _value)'), - contains('extension type DurationType(Duration _value)'), - contains('extension type ValidatedStringType(String _value)'), - ]), - ), - }, - ); - }, - ); - - test('supports nested transformed fields, refs, and list elements', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); - -final baseColorSchema = Ack.string(); - -@AckType() -final profileSchema = Ack.object({ - 'homepage': Ack.uri(), - 'birthday': Ack.date(), - 'lastLogin': Ack.datetime().optional().nullable(), - 'timeout': Ack.duration(), - 'links': Ack.list(Ack.uri()), - 'nestedLinks': Ack.list(Ack.list(Ack.uri())), - 'favoriteColor': Ack.string().transform((value) => Color(value)), - 'slug': Ack.string().transform((value) => value + '#'), - 'accent': colorSchema, - 'colors': Ack.list(colorSchema), - 'customColors': Ack.list( - baseColorSchema.transform((value) => Color(value)), - ), - 'tagList': Ack.list(Ack.string()).transform((value) => TagList(value)), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type ColorType(Color _value)'), - contains( - 'extension type ProfileType(Map _data)', - ), - contains('Uri get homepage => _data[\'homepage\'] as Uri'), - contains( - 'DateTime get birthday => _data[\'birthday\'] as DateTime', - ), - contains( - 'DateTime? get lastLogin => _data[\'lastLogin\'] as DateTime?', - ), - contains( - 'Duration get timeout => _data[\'timeout\'] as Duration', - ), - contains('List get links'), - contains('_\$ackListCast(_data[\'links\'])'), - contains('List> get nestedLinks'), - contains('_\$ackListCast>(_data[\'nestedLinks\'])'), - contains( - 'Color get favoriteColor => _data[\'favoriteColor\'] as Color', - ), - contains('String get slug => _data[\'slug\'] as String'), - contains('ColorType get accent'), - contains("ColorType(_data['accent'] as Color)"), - contains('List get colors'), - contains('ColorType(e as Color)'), - contains('List get customColors'), - contains('_\$ackListCast(_data[\'customColors\'])'), - contains('TagList get tagList => _data[\'tagList\'] as TagList'), - isNot(contains('TagList get tagList => _\$ackListCast')), - isNot(contains('Uri.parse(')), - isNot(contains('DateTime.parse(')), - isNot(contains('Duration(milliseconds:')), - ]), - ), - }, - ); - }); - - test('supports transformed getter schemas', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -AckSchema get baseColorSchema => Ack.string(); - -@AckType() -AckSchema get colorSchema => - baseColorSchema.transform((value) => Color(value)); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type ColorType(Color _value)'), - contains('return colorSchema.parseAs('), - ]), - ), - }, - ); - }); - - test('supports top-level list schema with chained modifiers', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final uniqueTagsSchema = Ack.list(Ack.string()).unique(); - -@AckType() -final describedTagsSchema = Ack.list(Ack.string()).describe('A list of tags'); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type UniqueTagsType(List _value)'), - contains('extension type DescribedTagsType(List _value)'), - ]), - ), - }, - ); - }); - - test('supports transformed refs through re-exported schemas', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/palette_schema_exports.dart': ''' -export 'palette_schemas.dart'; -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'palette_schema_exports.dart'; - -@AckType() -final themeSchema = Ack.object({ - 'accent': colorSchema, - 'colors': Ack.list(colorSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/palette_schemas.g.dart': decodedMatches( - contains('extension type ColorType(Color _value)'), - ), - 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( - allOf([ - contains('ColorType get accent'), - contains("ColorType(_data['accent'] as Color)"), - contains('List get colors'), - contains('ColorType(e as Color)'), - ]), - ), - }, - ); - }); - - test('rejects transform without an explicit output type', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - }, - outputs: {'test_pkg|lib/schema.g.dart': anything}, - ), - throwsA(isA()), - ); - }); - - test('rejects transformed object and discriminated schemas', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -@AckType() -final objectSchema = Ack.object({ - 'name': Ack.string(), -}).transform((value) => 'name'); - -@AckType() -final discriminatedSchema = Ack.discriminated( - discriminatorKey: 'type', - schemas: { - 'user': Ack.string(), - }, -).transform((value) => 'user'); -''', - }, - outputs: {'test_pkg|lib/schema.g.dart': anything}, - ), - throwsA(isA()), - ); - }); - }); -} diff --git a/packages/ack_generator/test/integration/example_folder_build_test.dart b/packages/ack_generator/test/integration/example_folder_build_test.dart index 7bc7380a..a3b15ae5 100644 --- a/packages/ack_generator/test/integration/example_folder_build_test.dart +++ b/packages/ack_generator/test/integration/example_folder_build_test.dart @@ -3,255 +3,136 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; -/// Integration test that verifies the example folder builds correctly -/// and has no analyze errors. The retained output set and structural checks -/// keep generated code stable and analyzer-clean. -void main() { - group('Example Folder Build Integration', () { - const expectedGeneratedFiles = [ - 'lib/args_getter_example.g.dart', - 'lib/pet.g.dart', - 'lib/schema_types_discriminated.g.dart', - 'lib/schema_types_edge_cases.g.dart', - 'lib/schema_types_primitives.g.dart', - 'lib/schema_types_simple.g.dart', - 'lib/schema_types_transforms.g.dart', - 'lib/user_with_color.g.dart', - ]; - late Directory projectRoot; - late Directory exampleDir; - - setUpAll(() { - // Find project root (go up from test directory) - var current = Directory.current; - - while (current.path.contains('packages')) { - current = current.parent; - } - projectRoot = current; - exampleDir = Directory(p.join(projectRoot.path, 'example')); - }); - - test( - 'example folder should build successfully with build_runner', - () async { - // Clean previous builds - final cleanResult = await Process.run('dart', [ - 'run', - 'build_runner', - 'clean', - ], workingDirectory: exampleDir.path); - - expect( - cleanResult.exitCode, - 0, - reason: - 'build_runner clean should succeed\n' - 'STDOUT: ${cleanResult.stdout}\n' - 'STDERR: ${cleanResult.stderr}', - ); - - // Run build_runner - final buildResult = await Process.run('dart', [ - 'run', - 'build_runner', - 'build', - ], workingDirectory: exampleDir.path); - - expect( - buildResult.exitCode, - 0, - reason: - 'build_runner should complete successfully\n' - 'STDOUT: ${buildResult.stdout}\n' - 'STDERR: ${buildResult.stderr}', - ); - - // Verify that generated files were created - final generatedFiles = exampleDir - .listSync(recursive: true) - .whereType() - .where((f) => f.path.endsWith('.g.dart')) - .toList(); - final generatedRelativePaths = - generatedFiles - .map((file) => p.relative(file.path, from: exampleDir.path)) - .toList() - ..sort(); - - expect( - generatedRelativePaths, - expectedGeneratedFiles, - reason: 'The retained AckType example output set should stay stable', - ); - }, - timeout: const Timeout(Duration(minutes: 2)), - ); +void _copyDirectory(Directory source, Directory destination) { + destination.createSync(recursive: true); + for (final entity in source.listSync()) { + final name = p.basename(entity.path); + if (name == '.dart_tool' || name == 'build' || name.endsWith('.g.dart')) { + continue; + } + final target = p.join(destination.path, name); + if (entity is Directory) { + _copyDirectory(entity, Directory(target)); + } else if (entity is File && !name.endsWith('.ack.dart')) { + entity.copySync(target); + } + } +} - test('example folder should have no dart analyze errors', () async { - final analyzeResult = await Process.run('dart', [ - 'analyze', - '--fatal-infos', - ], workingDirectory: exampleDir.path); +Future _run(Directory directory, List arguments) { + return Process.run('dart', arguments, workingDirectory: directory.path); +} - expect( - analyzeResult.exitCode, - 0, - reason: - 'dart analyze should find no issues\n' - 'STDOUT: ${analyzeResult.stdout}\n' - 'STDERR: ${analyzeResult.stderr}', - ); - }); +void _expectSuccess(ProcessResult result, String command) { + expect( + result.exitCode, + 0, + reason: + '$command failed\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}', + ); +} - test('generated files should match expected schema patterns', () async { - final generatedFiles = exampleDir +Map _generatedContents(Directory directory) => { + for (final file + in directory .listSync(recursive: true) .whereType() - .where((f) => f.path.endsWith('.g.dart')) - .toList(); + .where((file) => file.path.endsWith('.ack.dart'))) + p.relative(file.path, from: directory.path): file.readAsStringSync(), +}; - for (final file in generatedFiles) { - final content = await file.readAsString(); - final fileName = p.basename(file.path); - - // Verify basic structure - expect( - content, - contains('// GENERATED CODE - DO NOT MODIFY BY HAND'), - reason: '$fileName should have generation warning', - ); - - // AckType-only examples should emit extension types. - final hasExtensionType = RegExp( - r'extension type \w+Type', - ).hasMatch(content); +void main() { + test( + 'example clean-builds, analyzes, tests, and regenerates deterministically', + () async { + var projectRoot = Directory.current; + while (!Directory( + p.join(projectRoot.path, 'packages', 'ack_generator'), + ).existsSync()) { + projectRoot = projectRoot.parent; + } + final sourceExample = Directory(p.join(projectRoot.path, 'example')); + final temporaryRoot = await Directory.systemTemp.createTemp( + 'ack_generator_example_', + ); + final temporaryExample = Directory( + p.join(temporaryRoot.path, 'ack_example'), + ); - expect( - hasExtensionType, - isTrue, - reason: '$fileName should contain generated extension types', + try { + _copyDirectory(sourceExample, temporaryExample); + File( + p.join(temporaryExample.path, 'analysis_options.yaml'), + ).writeAsStringSync(''' +analyzer: + language: + strict-casts: true +'''); + File(p.join(temporaryExample.path, 'pubspec.yaml')).writeAsStringSync( + ''' +name: ack_example +publish_to: none +environment: + sdk: '>=3.9.0 <4.0.0' +dependencies: + ack: + path: ${p.join(projectRoot.path, 'packages', 'ack')} + ack_annotations: + path: ${p.join(projectRoot.path, 'packages', 'ack_annotations')} +dev_dependencies: + ack_generator: + path: ${p.join(projectRoot.path, 'packages', 'ack_generator')} + build_runner: ^2.15.0 + test: ^1.29.0 +dependency_overrides: + ack: + path: ${p.join(projectRoot.path, 'packages', 'ack')} + ack_annotations: + path: ${p.join(projectRoot.path, 'packages', 'ack_annotations')} +''', ); - // Verify it's a part file (generated files are parts of the main file) - expect( - content, - matches(RegExp(r"part of '.*\.dart';")), - reason: '$fileName should be a part file', + _expectSuccess( + await _run(temporaryExample, ['pub', 'get']), + 'dart pub get', ); - - // Verify the corresponding main file exists and imports ack - final mainFileName = fileName.replaceAll('.g.dart', '.dart'); - final mainFile = File(p.join(p.dirname(file.path), mainFileName)); - - expect( - mainFile.existsSync(), - isTrue, - reason: 'Main file $mainFileName should exist for $fileName', + _expectSuccess( + await _run(temporaryExample, ['run', 'build_runner', 'build']), + 'clean build_runner build', ); - final mainContent = await mainFile.readAsString(); - expect( - mainContent, - contains("import 'package:ack/ack.dart'"), - reason: 'Main file $mainFileName should import ack package', + final first = _generatedContents(temporaryExample); + expect(first.keys, { + 'lib/args_getter_example.ack.dart', + 'lib/pet.ack.dart', + 'lib/schema_types_discriminated.ack.dart', + 'lib/schema_types_edge_cases.ack.dart', + 'lib/schema_types_primitives.ack.dart', + 'lib/schema_types_simple.ack.dart', + 'lib/schema_types_transforms.ack.dart', + 'lib/user_with_color.ack.dart', + }); + for (final content in first.values) { + expect(content, contains('class ')); + expect(content, isNot(contains('extension type'))); + expect(content, isNot(contains('fromMap'))); + expect(content, isNot(contains('toMap'))); + } + + _expectSuccess( + await _run(temporaryExample, ['analyze', '--fatal-infos']), + 'dart analyze --fatal-infos', ); - - expect( - mainContent, - contains("part '$fileName'"), - reason: 'Main file $mainFileName should include part directive', + _expectSuccess(await _run(temporaryExample, ['test']), 'dart test'); + _expectSuccess( + await _run(temporaryExample, ['run', 'build_runner', 'build']), + 'second build_runner build', ); + expect(_generatedContents(temporaryExample), first); + } finally { + temporaryRoot.deleteSync(recursive: true); } - - final discriminatedFile = File( - p.join(exampleDir.path, 'lib', 'schema_types_discriminated.g.dart'), - ); - expect( - discriminatedFile.existsSync(), - isTrue, - reason: - 'schema_types_discriminated.g.dart should be generated in example/lib', - ); - - final discriminatedContent = await discriminatedFile.readAsString(); - expect( - discriminatedContent, - contains('extension type PetType(Map _data)'), - reason: - 'schema_types_discriminated.g.dart should include a discriminated base extension type', - ); - expect( - discriminatedContent, - contains('extension type CatType(Map _data)'), - reason: - 'schema_types_discriminated.g.dart should include discriminated subtype extension types', - ); - expect( - discriminatedContent, - contains('implements PetType, Map'), - reason: - 'schema_types_discriminated.g.dart discriminated subtypes should implement PetType and Map', - ); - - final transformsFile = File( - p.join(exampleDir.path, 'lib', 'schema_types_transforms.g.dart'), - ); - expect( - transformsFile.existsSync(), - isTrue, - reason: - 'schema_types_transforms.g.dart should be generated in example/lib', - ); - - final transformsContent = await transformsFile.readAsString(); - expect( - transformsContent, - contains('extension type ColorType(Color _value)'), - reason: - 'schema_types_transforms.g.dart should include the transformed Color extension type', - ); - expect( - transformsContent, - contains('extension type ProfileType(Map _data)'), - reason: - 'schema_types_transforms.g.dart should include the transform-backed object wrapper', - ); - expect( - transformsContent, - allOf([ - contains('Uri get homepage'), - contains('DateTime get birthday'), - contains('DateTime get lastLogin'), - contains('Duration get timeout'), - contains('List get links'), - contains('ColorType get accent'), - contains('List get colors'), - contains('List get customColors'), - contains('TagList get tagList'), - isNot(contains('Uri.parse(')), - isNot(contains('DateTime.parse(')), - isNot(contains('Duration(milliseconds:')), - ]), - reason: - 'schema_types_transforms.g.dart should emit transformed getters without unsafe reparsing', - ); - }); - - test('example folder pub get should succeed', () async { - final pubGetResult = await Process.run('dart', [ - 'pub', - 'get', - ], workingDirectory: exampleDir.path); - - expect( - pubGetResult.exitCode, - 0, - reason: - 'dart pub get should succeed\n' - 'STDOUT: ${pubGetResult.stdout}\n' - 'STDERR: ${pubGetResult.stderr}', - ); - }); - }); + }, + timeout: const Timeout(Duration(minutes: 3)), + ); } diff --git a/packages/ack_generator/test/integration/json_serializable_build_test.dart b/packages/ack_generator/test/integration/json_serializable_build_test.dart new file mode 100644 index 00000000..b11bae25 --- /dev/null +++ b/packages/ack_generator/test/integration/json_serializable_build_test.dart @@ -0,0 +1,132 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + test( + 'json_serializable resolves same-file and cross-file generated Ack models', + () async { + var projectRoot = Directory.current; + while (!Directory( + p.join(projectRoot.path, 'packages', 'ack_generator'), + ).existsSync()) { + projectRoot = projectRoot.parent; + } + final temporary = await Directory.systemTemp.createTemp( + 'ack_json_build_', + ); + try { + Directory(p.join(temporary.path, 'lib')).createSync(); + File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' +name: ack_json_build +publish_to: none +environment: + sdk: '>=3.9.0 <4.0.0' +dependencies: + ack: + path: ${p.join(projectRoot.path, 'packages', 'ack')} + ack_annotations: + path: ${p.join(projectRoot.path, 'packages', 'ack_annotations')} + json_annotation: ^4.12.0 +dev_dependencies: + ack_generator: + path: ${p.join(projectRoot.path, 'packages', 'ack_generator')} + build_runner: ^2.15.0 + json_serializable: ^6.14.1 +dependency_overrides: + ack: + path: ${p.join(projectRoot.path, 'packages', 'ack')} + ack_annotations: + path: ${p.join(projectRoot.path, 'packages', 'ack_annotations')} +'''); + File(p.join(temporary.path, 'lib', 'same.dart')).writeAsStringSync(r''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'same.ack.dart'; +part 'same.g.dart'; + +@AckType() +final userSchema = Ack.object({'name': Ack.string()}); + +@JsonSerializable(explicitToJson: true) +final class SameEnvelope { + const SameEnvelope(this.user); + factory SameEnvelope.fromJson(Map json) => + _$SameEnvelopeFromJson(json); + final User user; + Map toJson() => _$SameEnvelopeToJson(this); +} +'''); + File(p.join(temporary.path, 'lib', 'address.dart')).writeAsStringSync( + r''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'address.ack.dart'; + +@AckType() +final addressSchema = Ack.object({'city': Ack.string()}); +''', + ); + File(p.join(temporary.path, 'lib', 'cross.dart')).writeAsStringSync(r''' +import 'package:json_annotation/json_annotation.dart'; + +import 'address.dart'; + +part 'cross.g.dart'; + +@JsonSerializable(explicitToJson: true) +final class CrossEnvelope { + const CrossEnvelope(this.address); + factory CrossEnvelope.fromJson(Map json) => + _$CrossEnvelopeFromJson(json); + final Address address; + Map toJson() => _$CrossEnvelopeToJson(this); +} +'''); + + final pubGet = await Process.run('dart', [ + 'pub', + 'get', + ], workingDirectory: temporary.path); + expect( + pubGet.exitCode, + 0, + reason: '${pubGet.stdout}\n${pubGet.stderr}', + ); + final build = await Process.run('dart', [ + 'run', + 'build_runner', + 'build', + ], workingDirectory: temporary.path); + expect(build.exitCode, 0, reason: '${build.stdout}\n${build.stderr}'); + final analyze = await Process.run('dart', [ + 'analyze', + '--fatal-infos', + ], workingDirectory: temporary.path); + expect( + analyze.exitCode, + 0, + reason: '${analyze.stdout}\n${analyze.stderr}', + ); + + final sameJson = File( + p.join(temporary.path, 'lib', 'same.g.dart'), + ).readAsStringSync(); + final crossJson = File( + p.join(temporary.path, 'lib', 'cross.g.dart'), + ).readAsStringSync(); + expect(sameJson, contains('User.fromJson')); + expect(sameJson, contains('.toJson()')); + expect(crossJson, contains('Address.fromJson')); + expect(crossJson, contains('.toJson()')); + } finally { + temporary.deleteSync(recursive: true); + } + }, + timeout: const Timeout(Duration(minutes: 3)), + ); +} diff --git a/packages/ack_generator/test/integration/v2_contract_test.dart b/packages/ack_generator/test/integration/v2_contract_test.dart new file mode 100644 index 00000000..94b55301 --- /dev/null +++ b/packages/ack_generator/test/integration/v2_contract_test.dart @@ -0,0 +1,150 @@ +import 'package:ack_generator/src/builder.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; + +Future _generate( + String source, { + void Function(LogRecord log)? onLog, + Map? outputs, +}) async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + return testBuilder( + ackGenerator(BuilderOptions.empty), + {'test_pkg|lib/schema.dart': source}, + generateFor: const {'test_pkg|lib/schema.dart'}, + readerWriter: readerWriter, + outputs: outputs, + onLog: onLog, + ); +} + +const _imports = ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'schema.ack.dart'; +'''; + +void main() { + test('object models expose only the V2 parse and JSON contract', () async { + await _generate( + ''' +$_imports +@AckType() +final userSchema = Ack.object({ + 'name': Ack.string(), + 'nickname': Ack.string().optional(), + 'middleName': Ack.string().nullable(), + 'role': Ack.string().withDefault('member'), +}); +''', + outputs: { + 'test_pkg|lib/schema.ack.dart': decodedMatches( + allOf([ + contains('final class User'), + contains('factory User.parse(Object? input)'), + contains('factory User.fromJson(Map json)'), + contains(r'static final $ack = AckModelAdapter'), + contains('Map toJson()'), + contains('SchemaResult> safeToJson()'), + isNot(contains('fromMap')), + isNot(contains('toMap')), + isNot(contains('safeToMap')), + contains('required this.name'), + contains('this.nickname'), + contains('required this.middleName'), + contains('required this.role'), + contains("if (nickname != null) 'nickname': nickname"), + contains("'middleName': middleName"), + ]), + ), + }, + ); + }); + + test('value roots use their schema boundary type for JSON', () async { + await _generate( + ''' +$_imports +@AckType() +final occurredAtSchema = Ack.datetime(); +''', + outputs: { + 'test_pkg|lib/schema.ack.dart': decodedMatches( + allOf([ + contains('final DateTime value;'), + contains('factory OccurredAt.fromJson(String json)'), + contains('String toJson()'), + contains('SchemaResult safeToJson()'), + ]), + ), + }, + ); + }); + + test('derived and custom names are exact', () async { + await _generate( + ''' +$_imports +@AckType() +final memberTypeSchema = Ack.string(); + +@AckType(name: 'IntentionalType') +final customSchema = Ack.string(); +''', + outputs: { + 'test_pkg|lib/schema.ack.dart': decodedMatches( + allOf([ + contains('final class MemberType'), + contains('final class IntentionalType'), + isNot(contains('MemberTypeType')), + ]), + ), + }, + ); + }); + + test('rejects one-way transforms anywhere in the graph', () async { + var sawError = false; + await _generate( + ''' +$_imports +@AckType() +final userSchema = Ack.object({ + 'age': Ack.string().transform(int.parse), +}); +''', + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && + log.message.contains('userSchema.age') && + log.message.contains('.codec()')) { + sawError = true; + } + }, + ); + expect(sawError, isTrue); + }); + + test('rejects whitespace-altered custom names', () async { + var sawError = false; + await _generate( + ''' +$_imports +@AckType(name: ' User') +final userSchema = Ack.string(); +''', + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && + log.message.contains('UpperCamelCase')) { + sawError = true; + } + }, + ); + expect(sawError, isTrue); + }); +} diff --git a/packages/ack_generator/test/integration/v2_graph_test.dart b/packages/ack_generator/test/integration/v2_graph_test.dart new file mode 100644 index 00000000..1d45e69c --- /dev/null +++ b/packages/ack_generator/test/integration/v2_graph_test.dart @@ -0,0 +1,197 @@ +import 'package:ack_generator/src/builder.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; + +Future _expectOutput(String source, Matcher matcher) async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + ackGenerator(BuilderOptions.empty), + {'test_pkg|lib/schema.dart': source}, + generateFor: const {'test_pkg|lib/schema.dart'}, + readerWriter: readerWriter, + outputs: {'test_pkg|lib/schema.ack.dart': decodedMatches(matcher)}, + ); +} + +Future _expectFailure(String source, List messages) async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + final seen = {}; + await testBuilder( + ackGenerator(BuilderOptions.empty), + {'test_pkg|lib/schema.dart': source}, + generateFor: const {'test_pkg|lib/schema.dart'}, + readerWriter: readerWriter, + outputs: const {}, + onLog: (LogRecord log) { + if (log.level.name != 'SEVERE') return; + for (final message in messages) { + if (log.message.contains(message)) seen.add(message); + } + }, + ); + expect(seen, containsAll(messages)); +} + +const _head = ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'schema.ack.dart'; +'''; + +void main() { + test( + 'models custom bidirectional codecs from generic schema types', + () async { + await _expectOutput( + ''' +$_head +final class UserId { + const UserId(this.value); + final int value; +} + +final class TagList { + const TagList(this.values); + final List values; +} + +@AckType(name: 'UserIdModel') +final userIdSchema = Ack.integer().codec( + decode: UserId.new, + encode: (id) => id.value, +); + +@AckType() +final profileSchema = Ack.object({ + 'tags': Ack.list(Ack.string()).codec( + decode: TagList.new, + encode: (tags) => tags.values, + ), +}); +''', + allOf([ + contains('final class UserIdModel'), + contains('final UserId value;'), + contains('factory UserIdModel.fromJson(int json)'), + contains('int toJson()'), + contains('required this.tags'), + ]), + ); + }, + ); + + test('treats object roots with an outer codec as value models', () async { + await _expectOutput( + ''' +$_head +final class UserRecord { + const UserRecord(this.name); + final String name; +} + +@AckType() +final userSchema = Ack.object({ + 'name': Ack.string(), +}).codec( + decode: (value) => UserRecord(value['name'] as String), + encode: (user) => {'name': user.name}, +); +''', + allOf([ + contains('final class User'), + contains('User(this.value)'), + contains('final UserRecord value;'), + contains('factory User.fromJson(Map json)'), + contains('Map toJson()'), + ]), + ); + }); + + test('resolves named lazy self recursion through model adapters', () async { + await _expectOutput( + ''' +$_head +@AckType() +final AckSchema nodeSchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list( + Ack.lazy('node', () => nodeSchema), + ), +}); +''', + allOf([ + contains('final List children;'), + contains(r'Node.$ack.fromRuntime'), + contains(r'Node.$ack.toRuntime'), + ]), + ); + }); + + test('models mutually recursive lazy schemas', () async { + await _expectOutput( + ''' +$_head +@AckType() +final AckSchema authorSchema = Ack.object({ + 'books': Ack.list(Ack.lazy('book', () => bookSchema)), +}); + +@AckType() +final AckSchema bookSchema = Ack.object({ + 'author': Ack.lazy('author', () => authorSchema), +}); +''', + allOf([ + contains('final List books;'), + contains('final Author author;'), + ]), + ); + }); + + test('rejects unsupported dynamic roots with the declaration path', () async { + await _expectFailure( + ''' +$_head +@AckType() +final payloadSchema = Ack.any(); +''', + ['payloadSchema', 'Ack.any()'], + ); + }); + + for (final unsupported in { + 'Ack.anyOf([Ack.string(), Ack.integer()])': 'Ack.anyOf()', + 'Ack.instance()': 'bare Ack.instance()', + 'Ack.string().nullable()': 'nullable root', + }.entries) { + test('rejects unsupported root ${unsupported.key}', () async { + await _expectFailure( + ''' +$_head +@AckType() +final payloadSchema = ${unsupported.key}; +''', + [unsupported.value], + ); + }); + } + + test('rejects ordinary alias cycles', () async { + await _expectFailure( + ''' +$_head +@AckType() +final firstSchema = secondSchema; + +@AckType() +final secondSchema = firstSchema; +''', + ['alias cycle', 'firstSchema'], + ); + }); +} diff --git a/packages/ack_generator/test/integration/v2_models_test.dart b/packages/ack_generator/test/integration/v2_models_test.dart new file mode 100644 index 00000000..83224061 --- /dev/null +++ b/packages/ack_generator/test/integration/v2_models_test.dart @@ -0,0 +1,302 @@ +import 'package:ack_generator/src/builder.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; + +Future _build( + Map sources, { + required Map outputs, + void Function(LogRecord log)? onLog, +}) async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + ackGenerator(BuilderOptions.empty), + { + for (final entry in sources.entries) + 'test_pkg|lib/${entry.key}': entry.value, + }, + generateFor: {for (final path in sources.keys) 'test_pkg|lib/$path'}, + readerWriter: readerWriter, + outputs: outputs, + onLog: onLog, + ); +} + +const _imports = ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +'''; + +void main() { + test( + 'emits empty objects without malformed argument or map commas', + () async { + await _build( + { + 'empty.dart': + ''' +$_imports +part 'empty.ack.dart'; + +@AckType() +final emptySchema = Ack.object({}); +''', + }, + outputs: { + 'test_pkg|lib/empty.ack.dart': decodedMatches( + allOf([ + contains('Empty()'), + contains('return Empty();'), + contains('return {};'), + isNot(contains('\n ,')), + ]), + ), + }, + ); + }, + ); + + test( + 'emits enums, num, literals, codecs, and nested immutable lists', + () async { + await _build( + { + 'values.dart': + ''' +$_imports +part 'values.ack.dart'; + +enum Role { admin, member } + +@AckType() +final metricsSchema = Ack.object({ + 'amount': Ack.number(), + 'state': Ack.literal('ready'), + 'role': Ack.enumValues(Role.values), + 'dates': Ack.list(Ack.list(Ack.date())), +}); +''', + }, + outputs: { + 'test_pkg|lib/values.ack.dart': decodedMatches( + allOf([ + contains('required this.amount'), + contains('required this.state'), + contains('required this.role'), + contains('required List> dates'), + contains('(item) => List.unmodifiable(item.map'), + ]), + ), + }, + ); + }, + ); + + test('resolves direct, prefixed, and re-exported model references', () async { + await _build( + { + 'address.dart': + ''' +$_imports +part 'address.ack.dart'; + +@AckType() +final addressSchema = Ack.object({'city': Ack.string()}); +''', + 'exports.dart': "export 'address.dart';", + 'person.dart': + ''' +$_imports +import 'address.dart' as direct; +import 'exports.dart' as exported; +part 'person.ack.dart'; + +@AckType() +final personSchema = Ack.object({ + 'home': direct.addressSchema, + 'history': Ack.list(exported.addressSchema), +}); +''', + }, + outputs: { + 'test_pkg|lib/address.ack.dart': decodedMatches( + contains('final class Address'), + ), + 'test_pkg|lib/person.ack.dart': decodedMatches( + allOf([ + contains('required this.home'), + contains('required List history'), + contains(r'direct.Address.$ack.fromRuntime'), + contains(r'exported.Address.$ack.toRuntime'), + ]), + ), + }, + ); + }); + + test('emits sealed unions with final same-library branches', () async { + await _build( + { + 'pet.dart': + ''' +$_imports +part 'pet.ack.dart'; + +@AckType() +final catSchema = Ack.object({'kind': Ack.literal('cat'), 'lives': Ack.integer()}); + +@AckType() +final dogSchema = Ack.object({'bark': Ack.boolean()}).passthrough(); + +@AckType() +final petSchema = Ack.discriminated( + discriminatorKey: 'kind', + schemas: {'cat': catSchema, 'dog': dogSchema}, +); +''', + }, + outputs: { + 'test_pkg|lib/pet.ack.dart': decodedMatches( + allOf([ + contains('sealed class Pet'), + contains('final class Cat extends Pet'), + contains('final class Dog extends Pet'), + contains("String get kind => 'cat';"), + contains("'kind': 'dog'"), + contains('...additionalProperties'), + ]), + ), + }, + ); + }); + + test( + 'rejects anonymous objects and generated member collisions with paths', + () async { + final messages = {}; + await _build( + { + 'bad.dart': + ''' +$_imports +part 'bad.ack.dart'; + +@AckType() +final badSchema = Ack.object({'toJson': Ack.string()}); +''', + }, + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE') messages.add(log.message); + }, + ); + expect(messages.single, contains('badSchema.toJson')); + }, + ); + + test('rejects Dart keywords used as generated field names', () async { + final messages = {}; + await _build( + { + 'bad.dart': + ''' +$_imports +part 'bad.ack.dart'; + +@AckType() +final badSchema = Ack.object({'class': Ack.string()}); +''', + }, + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE') messages.add(log.message); + }, + ); + expect(messages.single, contains('badSchema.class')); + }); + + test( + 'rejects union discriminators that collide with generated APIs', + () async { + final messages = {}; + await _build( + { + 'bad.dart': + ''' +$_imports +part 'bad.ack.dart'; + +@AckType() +final catSchema = Ack.object({'lives': Ack.integer()}); + +@AckType() +final petSchema = Ack.discriminated( + discriminatorKey: 'toJson', + schemas: {'cat': catSchema}, +); +''', + }, + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE') messages.add(log.message); + }, + ); + expect(messages.single, contains('petSchema.toJson')); + }, + ); + + test('rejects broad union discriminator fields', () async { + final messages = {}; + await _build( + { + 'bad.dart': + ''' +$_imports +part 'bad.ack.dart'; + +@AckType() +final catSchema = Ack.object({ + 'kind': Ack.string(), + 'lives': Ack.integer(), +}); + +@AckType() +final petSchema = Ack.discriminated( + discriminatorKey: 'kind', + schemas: {'cat': catSchema}, +); +''', + }, + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE') messages.add(log.message); + }, + ); + expect(messages.single, contains('catSchema.kind')); + }); + + test('rejects passthrough helper namespace collisions', () async { + final messages = {}; + await _build( + { + 'bad.dart': + ''' +$_imports +part 'bad.ack.dart'; + +Object? _ackImmutableCopyValue(Object? value) => value; + +@AckType() +final bagSchema = Ack.object({}).passthrough(); +''', + }, + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE') messages.add(log.message); + }, + ); + expect(messages.single, contains('_ackImmutableCopyValue')); + }); +} diff --git a/packages/ack_generator/test/integration/v2_runtime_build_test.dart b/packages/ack_generator/test/integration/v2_runtime_build_test.dart new file mode 100644 index 00000000..35a928a7 --- /dev/null +++ b/packages/ack_generator/test/integration/v2_runtime_build_test.dart @@ -0,0 +1,267 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +Future _run(Directory directory, List arguments) => + Process.run('dart', arguments, workingDirectory: directory.path); + +void _expectSuccess(ProcessResult result, String command) { + expect( + result.exitCode, + 0, + reason: + '$command failed\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}', + ); +} + +void main() { + test( + 'clean generated models compile and preserve the V2 runtime contract', + () async { + var projectRoot = Directory.current; + while (!Directory( + p.join(projectRoot.path, 'packages', 'ack_generator'), + ).existsSync()) { + projectRoot = projectRoot.parent; + } + final temporary = await Directory.systemTemp.createTemp( + 'ack_v2_runtime_', + ); + try { + Directory(p.join(temporary.path, 'lib')).createSync(); + Directory(p.join(temporary.path, 'test')).createSync(); + File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' +name: ack_v2_runtime +publish_to: none +environment: + sdk: '>=3.9.0 <4.0.0' +dependencies: + ack: + path: ${p.join(projectRoot.path, 'packages', 'ack')} + ack_annotations: + path: ${p.join(projectRoot.path, 'packages', 'ack_annotations')} +dev_dependencies: + ack_generator: + path: ${p.join(projectRoot.path, 'packages', 'ack_generator')} + build_runner: ^2.15.0 + test: ^1.29.0 +dependency_overrides: + ack: + path: ${p.join(projectRoot.path, 'packages', 'ack')} + ack_annotations: + path: ${p.join(projectRoot.path, 'packages', 'ack_annotations')} +'''); + File(p.join(temporary.path, 'lib', 'models.dart')).writeAsStringSync( + r''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'models.ack.dart'; + +final class Box { + const Box(this.values); + final List values; +} + +final class RuntimeUser { + const RuntimeUser(this.name); + final String name; +} + +@AckType(name: 'UserRecord') +final userRecordSchema = Ack.object({ + 'name': Ack.string(), +}).codec( + decode: (value) => RuntimeUser(value['name'] as String), + encode: (user) => {'name': user.name}, +); + +@AckType() +final AckSchema nodeSchema = Ack.object({ + 'label': Ack.string(), + 'children': Ack.list( + Ack.lazy('node', () => nodeSchema), + ).optional(), +}); + +@AckType() +final AckSchema authorSchema = Ack.object({ + 'books': Ack.list(Ack.lazy('book', () => bookSchema)), +}); + +@AckType() +final AckSchema bookSchema = Ack.object({ + 'title': Ack.string(), + 'author': Ack.lazy('author', () => authorSchema).optional(), +}); + +@AckType() +final extrasSchema = Ack.object({ + 'name': Ack.string(), + 'maybe': Ack.string().nullable(), + 'nickname': Ack.string().optional(), + 'role': Ack.string().withDefault('member'), + 'numbers': Ack.list(Ack.list(Ack.integer())), + 'box': Ack.list(Ack.string()).codec( + decode: Box.new, + encode: (box) => box.values, + ), +}).passthrough(); + +@AckType() +final catSchema = Ack.object({'lives': Ack.integer()}); + +@AckType() +final dogSchema = Ack.object({'friendly': Ack.boolean()}); + +@AckType() +final petSchema = Ack.discriminated( + discriminatorKey: 'kind', + schemas: {'cat': catSchema, 'dog': dogSchema}, +); + +@AckType(name: 'MemberType') +final memberSchema = Ack.string(); +''', + ); + File(p.join(temporary.path, 'lib', 'address.dart')).writeAsStringSync( + r''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'address.ack.dart'; + +@AckType() +final addressSchema = Ack.object({'city': Ack.string()}); +''', + ); + File( + p.join(temporary.path, 'lib', 'exports.dart'), + ).writeAsStringSync("export 'address.dart';\n"); + File(p.join(temporary.path, 'lib', 'person.dart')).writeAsStringSync( + r''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +import 'address.dart' as direct; +import 'exports.dart' as exported; + +part 'person.ack.dart'; + +@AckType() +final personSchema = Ack.object({ + 'home': direct.addressSchema, + 'history': Ack.list(exported.addressSchema), +}); +''', + ); + File( + p.join(temporary.path, 'test', 'runtime_test.dart'), + ).writeAsStringSync(r''' +import 'package:ack_v2_runtime/models.dart'; +import 'package:ack_v2_runtime/person.dart'; +import 'package:test/test.dart'; + +void main() { + test('recursion and imported model references round-trip', () { + final node = Node.parse({ + 'label': 'root', + 'children': [ + {'label': 'leaf'}, + ], + }); + expect(node.children!.single, isA()); + expect(node.toJson(), { + 'label': 'root', + 'children': [ + {'label': 'leaf'}, + ], + }); + expect(() => node.children!.add(Node(label: 'other')), throwsUnsupportedError); + + final author = Author.parse({ + 'books': [ + {'title': 'Ack'}, + ], + }); + expect(author.books.single, isA()); + expect(author.books.single.author, isNull); + + final person = Person.parse({ + 'home': {'city': 'New York'}, + 'history': [ + {'city': 'Amsterdam'}, + ], + }); + expect(person.home.city, 'New York'); + expect(person.history.single.city, 'Amsterdam'); + }); + + test('field semantics, codecs, and recursive immutability hold', () { + final extras = Extras.parse({ + 'name': 'Ada', + 'maybe': null, + 'numbers': [ + [1, 2], + ], + 'box': ['a', 'b'], + 'dynamic': { + 'items': [1, 2], + }, + }); + expect(extras.role, 'member'); + expect(extras.nickname, isNull); + expect(extras.box.values, ['a', 'b']); + expect(() => extras.numbers.single.add(3), throwsUnsupportedError); + final dynamic = extras.additionalProperties['dynamic']! as Map; + expect(() => (dynamic['items']! as List).add(3), throwsUnsupportedError); + + final constructed = Extras( + name: 'declared', + maybe: null, + role: 'member', + numbers: const [ + [1], + ], + box: const Box(['x']), + additionalProperties: const {'name': 'extra'}, + ); + expect(constructed.toJson()['name'], 'declared'); + expect(constructed.toJson().containsKey('nickname'), isFalse); + expect(constructed.toJson()['maybe'], isNull); + }); + + test('unions and exact value-root names round-trip', () { + final pet = Pet.parse({'kind': 'cat', 'lives': 9}); + expect(pet, isA()); + expect(pet.toJson(), {'kind': 'cat', 'lives': 9}); + + final member = MemberType.parse('admin'); + expect(member.value, 'admin'); + expect(member.toJson(), 'admin'); + + final user = UserRecord.parse({'name': 'Ada'}); + expect(user.value.name, 'Ada'); + expect(user.toJson(), {'name': 'Ada'}); + }); +} +'''); + + _expectSuccess(await _run(temporary, ['pub', 'get']), 'dart pub get'); + _expectSuccess( + await _run(temporary, ['run', 'build_runner', 'build']), + 'build_runner build', + ); + _expectSuccess( + await _run(temporary, ['analyze', '--fatal-infos']), + 'dart analyze --fatal-infos', + ); + _expectSuccess(await _run(temporary, ['test']), 'dart test'); + } finally { + temporary.deleteSync(recursive: true); + } + }, + timeout: const Timeout(Duration(minutes: 3)), + ); +} diff --git a/packages/ack_generator/test/src/generator_test.dart b/packages/ack_generator/test/src/generator_test.dart index 0a0569cf..b5d2ac26 100644 --- a/packages/ack_generator/test/src/generator_test.dart +++ b/packages/ack_generator/test/src/generator_test.dart @@ -1,160 +1,95 @@ -import 'package:ack_generator/src/generator.dart'; +import 'package:ack_generator/src/builder.dart'; +import 'package:build/build.dart'; import 'package:build_test/build_test.dart'; -import 'package:source_gen/source_gen.dart'; +import 'package:logging/logging.dart'; import 'package:test/test.dart'; -import '../test_utils/test_assets.dart'; +Future _build( + String source, { + Map? outputs, + void Function(LogRecord log)? onLog, +}) async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + ackGenerator(BuilderOptions.empty), + {'test_pkg|lib/schema.dart': source}, + generateFor: const {'test_pkg|lib/schema.dart'}, + readerWriter: readerWriter, + outputs: outputs, + onLog: onLog, + ); +} void main() { - group('AckSchemaGenerator', () { - late AckSchemaGenerator generator; - - setUp(() { - generator = AckSchemaGenerator(); - }); - - test('generates immutable classes for annotated schemas', () async { - final builder = SharedPartBuilder([generator], 'ack'); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' + test( + 'builder writes a dedicated source part from workspace packages', + () async { + await _build( + ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), -}); +part 'schema.ack.dart'; -@AckType(name: 'Status') -AckSchema get statusSchema => Ack.string(); +@AckType() +final userSchema = Ack.object({'name': Ack.string()}); ''', - }, outputs: { - 'test_pkg|lib/schema.ack.g.part': decodedMatches( + 'test_pkg|lib/schema.ack.dart': decodedMatches( allOf([ + contains('// GENERATED CODE - DO NOT MODIFY BY HAND'), + contains("part of 'schema.dart';"), contains('final class User'), - contains('final String name;'), - contains('factory User.parse(Object? input)'), - contains('factory User.fromJson(Map json)'), - contains('Map toJson()'), - contains('final class Status'), - contains('final String value;'), - isNot(contains('extension type')), - isNot(contains('implements Map')), ]), ), }, ); - }); - - test('does not emit output when no AckType declarations exist', () async { - final builder = SharedPartBuilder([generator], 'ack'); - - await testBuilder(builder, { - ...allAssets, - 'test_pkg|lib/plain.dart': ''' -class PlainData { - final String id; - PlainData(this.id); -} -''', - }, outputs: const {}); - }); + }, + ); - test('leaves shared-part framing to source_gen', () async { - final builder = SharedPartBuilder([generator], 'ack'); + test('does not emit output without AckType declarations', () async { + await _build('final value = 1;', outputs: const {}); + }); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/model.dart': ''' + test('reports the exact required part directive', () async { + var sawError = false; + await _build( + ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @AckType() -final modelSchema = Ack.object({ - 'id': Ack.string(), -}); +final userSchema = Ack.string(); ''', - }, - outputs: { - 'test_pkg|lib/model.ack.g.part': decodedMatches( - allOf([ - contains('// AckSchemaGenerator'), - isNot(contains('// GENERATED CODE - DO NOT MODIFY BY HAND')), - isNot(contains("part of 'model.dart';")), - ]), - ), - }, - ); - }); - - test('reports invalid AckType placement on classes', () async { - final builder = SharedPartBuilder([generator], 'ack'); - var sawPlacementError = false; + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && + log.message.contains("part 'schema.ack.dart';")) { + sawError = true; + } + }, + ); + expect(sawError, isTrue); + }); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/bad.dart': ''' + test('rejects AckType on classes', () async { + var sawError = false; + await _build( + ''' import 'package:ack_annotations/ack_annotations.dart'; @AckType() -class BadSchema {} -''', - }, - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE') { - sawPlacementError = true; - expect( - log.message, - contains('top-level schema variables or getters'), - ); - } - }, - ); - - expect(sawPlacementError, isTrue); - }); - - test('reports invalid AckType placement on instance getters', () async { - final builder = SharedPartBuilder([generator], 'ack'); - var sawPlacementError = false; - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/bad.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -class BadSchema { - @AckType() - AckSchema get valueSchema => Ack.string(); -} +class InvalidSchema {} ''', - }, - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE') { - sawPlacementError = true; - expect( - log.message, - contains('top-level schema variables or getters'), - ); - } - }, - ); - - expect(sawPlacementError, isTrue); - }); + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && + log.message.contains('top-level schema variables or getters')) { + sawError = true; + } + }, + ); + expect(sawError, isTrue); }); } diff --git a/packages/ack_generator/test/src/test_utilities.dart b/packages/ack_generator/test/src/test_utilities.dart deleted file mode 100644 index 6aba3864..00000000 --- a/packages/ack_generator/test/src/test_utilities.dart +++ /dev/null @@ -1,229 +0,0 @@ -import 'package:ack_generator/src/models/constraint_info.dart'; -import 'package:ack_generator/src/models/field_info.dart'; -import 'package:analyzer/dart/element/element2.dart'; -import 'package:analyzer/dart/element/type.dart'; - -// Mock implementation of FieldInfo for testing -class MockFieldInfo implements FieldInfo { - @override - final String name; - - @override - final String jsonKey; - - @override - final bool isRequired; - - @override - final bool isNullable; - - @override - final List constraints; - - @override - final String? description; - - @override - final bool isPrimitive; - - @override - final bool isList; - - @override - final bool isSet; - - @override - final bool isGeneric; - - @override - final bool isEnum; - - @override - final List enumValues; - - @override - final bool isMap; - - @override - final String? listElementSchemaRef; - - @override - final String? nestedSchemaRef; - - @override - final String? displayTypeOverride; - - @override - final String? collectionElementDisplayTypeOverride; - - @override - final String? collectionElementCastTypeOverride; - - @override - final bool collectionElementIsCustomType; - - @override - final String? nestedSchemaCastTypeOverride; - - final String typeName; - final String? listItemTypeName; - final String? mapKeyTypeName; - final String? mapValueTypeName; - - MockFieldInfo({ - required this.name, - required this.typeName, - required this.isRequired, - required this.isNullable, - required this.constraints, - required this.isPrimitive, - required this.isList, - required this.isMap, - this.description, - this.isSet = false, - this.isGeneric = false, - this.isEnum = false, - this.enumValues = const [], - this.listItemTypeName, - this.mapKeyTypeName, - this.mapValueTypeName, - this.listElementSchemaRef, - this.nestedSchemaRef, - this.displayTypeOverride, - this.collectionElementDisplayTypeOverride, - this.collectionElementCastTypeOverride, - this.collectionElementIsCustomType = false, - this.nestedSchemaCastTypeOverride, - }) : jsonKey = name; - - @override - bool get isNestedSchema => !isPrimitive && !isList && !isMap; - - @override - DartType get type => MockDartType( - typeName, - listItemTypeName, - keyTypeName: mapKeyTypeName, - valueTypeName: mapValueTypeName, - ); -} - -// Mock DartType for testing -class MockDartType implements DartType { - final String typeName; - final String? itemTypeName; - final String? keyTypeName; - final String? valueTypeName; - - MockDartType( - this.typeName, - this.itemTypeName, { - this.keyTypeName, - this.valueTypeName, - }); - - @override - String getDisplayString({bool withNullability = true}) => typeName; - - @override - bool get isDartCoreList => typeName.startsWith('List<'); - - @override - bool get isDartCoreMap => typeName.startsWith('Map<'); - - @override - bool get isDartCoreString => typeName == 'String'; - @override - bool get isDartCoreInt => typeName == 'int'; - @override - bool get isDartCoreDouble => typeName == 'double'; - @override - bool get isDartCoreBool => typeName == 'bool'; - @override - bool get isDartCoreNum => typeName == 'num'; - @override - bool get isDartCoreObject => typeName == 'Object'; - @override - bool get isDartCoreSet => typeName.startsWith('Set<'); - - @override - Element2? get element3 => null; // Return null for mock types - - List get typeArguments { - // For Map types, return [keyType, valueType] - if (keyTypeName != null && valueTypeName != null) { - return [ - MockDartType(keyTypeName!, null), - MockDartType(valueTypeName!, null), - ]; - } - // For List/Set types, return [itemType] - if (itemTypeName != null) { - return [MockDartType(itemTypeName!, null)]; - } - return []; - } - - @override - String toString() => typeName; - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -// Helper to create a mock FieldInfo -FieldInfo createField( - String name, - String typeName, { - bool isRequired = false, - bool isNullable = false, - List constraints = const [], -}) { - return MockFieldInfo( - name: name, - typeName: typeName, - isRequired: isRequired, - isNullable: isNullable, - constraints: constraints, - isPrimitive: ['String', 'int', 'double', 'num', 'bool'].contains(typeName), - isList: false, - isMap: false, - ); -} - -FieldInfo createListField( - String name, - String itemTypeName, { - bool isNullable = false, -}) { - return MockFieldInfo( - name: name, - typeName: 'List<$itemTypeName>', - isRequired: !isNullable, - isNullable: isNullable, - constraints: [], - isPrimitive: false, - isList: true, - isMap: false, - listItemTypeName: itemTypeName, - ); -} - -FieldInfo createMapField( - String name, { - String keyType = 'String', - String valueType = 'dynamic', -}) { - return MockFieldInfo( - name: name, - typeName: 'Map<$keyType, $valueType>', - isRequired: true, - isNullable: false, - constraints: [], - isPrimitive: false, - isList: false, - isMap: true, - mapKeyTypeName: keyType, - mapValueTypeName: valueType, - ); -} diff --git a/packages/ack_generator/test/test_utils/analysis_utils.dart b/packages/ack_generator/test/test_utils/analysis_utils.dart deleted file mode 100644 index cd2358f3..00000000 --- a/packages/ack_generator/test/test_utils/analysis_utils.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:analyzer/dart/element/element2.dart'; -import 'package:source_gen/source_gen.dart'; - -/// Utilities for analyzing Dart code in tests -class AnalysisUtils { - /// Parse source code into an analyzer model for unit-test scope. - static Future resolveSource(String source) async { - throw UnimplementedError( - 'Use testBuilder with resolveSources for integration testing. ' - 'Use this utility only for analyzer unit tests.', - ); - } - - /// Get a class element by name from a library - static ClassElement2? getClass(LibraryElement2 library, String name) { - for (final type in library.classes) { - if (type.name3 == name) { - return type; - } - } - return null; - } - - /// Create a ConstantReader from an annotation value - static ConstantReader createAnnotationReader(Map values) { - // Keep a deterministic null-reader for tests that only validate parser wiring. - return ConstantReader(null); - } -} - -/// Helper to create mock ClassElement for testing -class MockClassElement { - static ClassElement2 create({ - required String name, - List fields = const [], - bool isAbstract = false, - }) { - // This would typically use a mocking framework - // For now, we'll rely on build_test which provides this functionality - throw UnimplementedError('Use testBuilder for integration testing'); - } -} - -/// Helper to create mock FieldElement for testing -class MockFieldElement { - final String name; - final String type; - final bool isRequired; - final bool isNullable; - final Map? ackFieldAnnotation; - - MockFieldElement({ - required this.name, - required this.type, - this.isRequired = false, - this.isNullable = false, - this.ackFieldAnnotation, - }); -} diff --git a/packages/ack_generator/test/test_utils/generation_test_utils.dart b/packages/ack_generator/test/test_utils/generation_test_utils.dart deleted file mode 100644 index 74c4b12d..00000000 --- a/packages/ack_generator/test/test_utils/generation_test_utils.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:build/build.dart'; -import 'package:build_test/build_test.dart'; -import 'package:test/test.dart'; - -Future expectGenerationFailure({ - required Builder builder, - required Map assets, - required String expectedMessage, - Map? expectedOutputs, -}) async { - var sawExpectedError = false; - await testBuilder( - builder, - assets, - outputs: expectedOutputs ?? const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && log.message.contains(expectedMessage)) { - sawExpectedError = true; - } - }, - ); - expect( - sawExpectedError, - isTrue, - reason: 'Expected SEVERE log containing "$expectedMessage"', - ); -} diff --git a/packages/ack_generator/test/test_utils/test_assets.dart b/packages/ack_generator/test/test_utils/test_assets.dart deleted file mode 100644 index 24a3e415..00000000 --- a/packages/ack_generator/test/test_utils/test_assets.dart +++ /dev/null @@ -1,356 +0,0 @@ -/// Test assets for ack_generator tests -/// Provides reusable mock file contents -library; - -const metaAssets = { - 'meta|lib/meta_meta.dart': ''' -enum TargetKind { - classType, - field, - function, - getter, - library, - method, - setter, - topLevelVariable, - type, - parameter, -} - -class Target { - final Set kinds; - const Target(this.kinds); -} -''', -}; - -const ackAnnotationsAsset = { - 'ack_annotations|lib/ack_annotations.dart': ''' -library ack_annotations; - -export 'src/ack_type.dart'; -''', - 'ack_annotations|lib/src/ack_type.dart': ''' -import 'package:meta/meta_meta.dart'; - -@Target({TargetKind.topLevelVariable, TargetKind.getter}) -class AckType { - final String? name; - const AckType({this.name}); -} -''', -}; - -const ackPackageAsset = { - 'ack|lib/ack.dart': ''' -library ack; - -export 'src/ack.dart'; -export 'src/schemas/schema_model.dart'; -export 'src/schemas/object_schema.dart'; -export 'src/validation/ack_exception.dart'; -export 'src/validation/schema_result.dart'; -''', - 'ack|lib/src/ack.dart': ''' -class Ack { - static StringSchema string() => const StringSchema(); - static IntegerSchema integer() => const IntegerSchema(); - static DoubleSchema double() => const DoubleSchema(); - static NumberSchema number() => const NumberSchema(); - static BooleanSchema boolean() => const BooleanSchema(); - static AnySchema any() => const AnySchema(); - static TransformedSchema uri() => TransformedSchema(const StringSchema()); - static TransformedSchema date() => TransformedSchema(const StringSchema()); - static TransformedSchema datetime() => TransformedSchema(const StringSchema()); - static TransformedSchema duration() => TransformedSchema(const IntegerSchema()); - - static ListSchema list(AckSchema itemSchema) => ListSchema(itemSchema); - static MapSchema map(AckSchema valueSchema) => MapSchema(valueSchema); - static ObjectSchema object( - Map properties, { - List? required, - bool additionalProperties = false, - }) => - ObjectSchema( - properties, - required: required, - additionalProperties: additionalProperties, - ); - - static DiscriminatedSchema discriminated({ - required String discriminatorKey, - required Map schemas, - }) => - DiscriminatedSchema( - discriminatorKey: discriminatorKey, - schemas: schemas, - ); - - static StringSchema literal(String value) => const StringSchema(); - static StringSchema enumString(List values) => const StringSchema(); - static EnumSchema enumValues(List values) => EnumSchema(); -} - -abstract class AckSchema { - TransformedSchema transform(R Function(T value) transformer) => - TransformedSchema(this); - Map toJsonSchema(); -} -class TransformedSchema extends AckSchema { - final AckSchema schema; - TransformedSchema(this.schema); - TransformedSchema nullable() => this; - TransformedSchema optional() => this; - TransformedSchema describe(String description) => this; - - @override - Map toJsonSchema() => schema.toJsonSchema(); -} -class StringSchema extends AckSchema { - const StringSchema(); - StringSchema email() => this; - StringSchema notEmpty() => this; - StringSchema minLength(int length) => this; - StringSchema maxLength(int length) => this; - StringSchema enumString(List values) => this; - StringSchema uri() => this; - StringSchema date() => this; - StringSchema datetime() => this; - StringSchema nullable() => this; - StringSchema optional() => this; - StringSchema describe(String description) => this; - StringSchema withDefault(String defaultValue) => this; - - @override - Map toJsonSchema() => {'type': 'string'}; -} -class IntegerSchema extends AckSchema { - const IntegerSchema(); - IntegerSchema min(int value) => this; - IntegerSchema max(int value) => this; - IntegerSchema positive() => this; - IntegerSchema nullable() => this; - IntegerSchema optional() => this; - IntegerSchema describe(String description) => this; - - @override - Map toJsonSchema() => {'type': 'integer'}; -} -class DoubleSchema extends AckSchema { - const DoubleSchema(); - DoubleSchema nullable() => this; - DoubleSchema optional() => this; - DoubleSchema describe(String description) => this; - - @override - Map toJsonSchema() => {'type': 'number'}; -} -class NumberSchema extends AckSchema { - const NumberSchema(); - NumberSchema nullable() => this; - NumberSchema optional() => this; - NumberSchema describe(String description) => this; - - @override - Map toJsonSchema() => {'type': 'number'}; -} -class BooleanSchema extends AckSchema { - const BooleanSchema(); - BooleanSchema nullable() => this; - BooleanSchema optional() => this; - BooleanSchema describe(String description) => this; - - @override - Map toJsonSchema() => {'type': 'boolean'}; -} -class AnySchema extends AckSchema { - const AnySchema(); - AnySchema nullable() => this; - AnySchema optional() => this; - - @override - Map toJsonSchema() => {}; -} -class ListSchema extends AckSchema> { - final AckSchema itemSchema; - const ListSchema(this.itemSchema); - ListSchema nullable() => this; - ListSchema optional() => this; - ListSchema unique() => this; - ListSchema describe(String description) => this; - - @override - Map toJsonSchema() => { - 'type': 'array', - 'items': itemSchema.toJsonSchema(), - }; -} -class MapSchema extends AckSchema> { - final AckSchema valueSchema; - const MapSchema(this.valueSchema); - MapSchema nullable() => this; - MapSchema optional() => this; - MapSchema describe(String description) => this; - - @override - Map toJsonSchema() => { - 'type': 'object', - 'additionalProperties': valueSchema.toJsonSchema(), - }; -} -class ObjectSchema extends AckSchema> { - final Map properties; - final List? required; - final bool additionalProperties; - const ObjectSchema(this.properties, {this.required, this.additionalProperties = false}); - - ObjectSchema copyWith({ - Map? properties, - List? required, - bool? additionalProperties, - }) { - return ObjectSchema( - properties ?? this.properties, - required: required ?? this.required, - additionalProperties: additionalProperties ?? this.additionalProperties, - ); - } - - Map toJsonSchema() { - return { - 'type': 'object', - 'properties': properties.map((k, v) => MapEntry(k, v.toJsonSchema())), - if (required != null && required!.isNotEmpty) 'required': required, - 'additionalProperties': additionalProperties, - }; - } -} - -extension ObjectSchemaExtensions on ObjectSchema { - ObjectSchema passthrough() => copyWith(additionalProperties: true); -} -class EnumSchema extends AckSchema { - EnumSchema(); - EnumSchema nullable() => this; - EnumSchema optional() => this; - EnumSchema describe(String description) => this; - - @override - Map toJsonSchema() => {'type': 'string', 'enum': []}; -} -class DiscriminatedSchema extends AckSchema> { - final String discriminatorKey; - final Map schemas; - const DiscriminatedSchema({required this.discriminatorKey, required this.schemas}); - - // Test stub no-op: generator nullability checks inspect the AST chain. - DiscriminatedSchema nullable() => this; - - @override - Map toJsonSchema() => { - 'oneOf': schemas.values.map((schema) => schema.toJsonSchema()).toList(), - 'discriminator': {'propertyName': discriminatorKey}, - }; -} -''', - 'ack|lib/src/schemas/schema_model.dart': ''' -import 'package:meta/meta.dart'; - -abstract class SchemaModel { - final Map? _data; - - const SchemaModel() : _data = null; - - @protected - const SchemaModel.validated(Map data) : _data = data; - - @protected - ObjectSchema get schema; - - bool get hasData => _data != null; - - SchemaModel parse(Object? input) { - // Parse test input into the schema model contract used by test fixtures. - return createValidated(input as Map); - } - - SchemaModel? tryParse(Object? input) { - try { - return parse(input); - } catch (_) { - return null; - } - } - - @protected - SchemaModel createValidated(Map data); - - T createFromMap(Map map); - - @protected - TValue getValue(String key) { - if (_data == null) { - throw StateError('No data available - use parse() first'); - } - return _data![key] as TValue; - } - - @protected - TValue? getValueOrNull(String key) { - if (_data == null) return null; - return _data![key] as TValue?; - } - - Map toMap() { - if (_data == null) return const {}; - return Map.unmodifiable(_data!); - } - - Map toJsonSchema() { - return schema.toJsonSchema(); - } -} -''', - 'ack|lib/src/validation/schema_result.dart': ''' -sealed class SchemaResult { - const SchemaResult(); - - factory SchemaResult.ok(T value) = SchemaSuccess; - factory SchemaResult.fail(String error) = SchemaFailure; - - R match({ - required R Function(T value) onOk, - required R Function(String error) onFail, - }); -} - -class SchemaSuccess extends SchemaResult { - final T value; - const SchemaSuccess(this.value); - - @override - R match({ - required R Function(T value) onOk, - required R Function(String error) onFail, - }) => onOk(value); -} - -class SchemaFailure extends SchemaResult { - final String error; - const SchemaFailure(this.error); - - @override - R match({ - required R Function(T value) onOk, - required R Function(String error) onFail, - }) => onFail(error); -} -''', -}; - -/// Combine all assets for easy use in tests -Map get allAssets => { - ...metaAssets, - ...ackAnnotationsAsset, - ...ackPackageAsset, -}; From 00a8813cc79f54a5c9565efc07a2f0bed13c785a Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 21 Aug 2026 18:32:48 -0400 Subject: [PATCH 3/7] feat(generator): integrate json_serializable for AckType field mapping Ack now emits both .ack.dart model parts and .g.dart JSON helpers via an internal builder that delegates structural mapping to json_serializable while keeping schema validation and codecs in the source Ack schema. Co-authored-by: Cursor --- README.md | 1 + docs/api-reference/index.mdx | 2 +- docs/architecture/acktype-model-generation.md | 68 ++- docs/core-concepts/json-serialization.mdx | 6 +- docs/core-concepts/typesafe-schemas.mdx | 16 +- docs/getting-started/installation.mdx | 4 +- example/README.md | 3 +- example/lib/args_getter_example.ack.dart | 147 ++++-- example/lib/args_getter_example.dart | 1 + example/lib/args_getter_example.g.dart | 80 ++++ example/lib/pet.ack.dart | 34 +- example/lib/pet.dart | 1 + example/lib/pet.g.dart | 21 + .../lib/schema_types_discriminated.ack.dart | 53 ++- example/lib/schema_types_discriminated.dart | 1 + example/lib/schema_types_discriminated.g.dart | 28 ++ example/lib/schema_types_edge_cases.ack.dart | 446 ++++++++++-------- example/lib/schema_types_edge_cases.dart | 1 + example/lib/schema_types_edge_cases.g.dart | 159 +++++++ example/lib/schema_types_primitives.ack.dart | 143 ++++-- example/lib/schema_types_primitives.dart | 1 + example/lib/schema_types_primitives.g.dart | 108 +++++ example/lib/schema_types_simple.ack.dart | 28 +- example/lib/schema_types_simple.dart | 1 + example/lib/schema_types_simple.g.dart | 19 + example/lib/schema_types_transforms.ack.dart | 111 +++-- example/lib/schema_types_transforms.dart | 1 + example/lib/schema_types_transforms.g.dart | 41 ++ example/lib/user_with_color.ack.dart | 122 +++-- example/lib/user_with_color.dart | 1 + example/lib/user_with_color.g.dart | 51 ++ packages/ack/CHANGELOG.md | 4 +- packages/ack_annotations/CHANGELOG.md | 5 +- packages/ack_annotations/README.md | 6 +- .../ack_annotations/lib/ack_annotations.dart | 2 +- .../lib/ack_generator_support.dart | 8 + .../lib/src/ack_generated_json.dart | 20 + .../ack_annotations/lib/src/ack_type.dart | 16 +- packages/ack_annotations/pubspec.yaml | 4 +- .../ack_annotations/test/ack_type_test.dart | 23 + packages/ack_generator/CHANGELOG.md | 6 +- packages/ack_generator/README.md | 12 +- packages/ack_generator/build.yaml | 16 +- packages/ack_generator/lib/ack_generator.dart | 4 +- .../analyzer/schema_model_graph_builder.dart | 89 +++- packages/ack_generator/lib/src/builder.dart | 11 + .../lib/src/builders/model_emitter.dart | 310 +++++++++--- packages/ack_generator/lib/src/generator.dart | 37 +- .../lib/src/json/ack_json_generator.dart | 41 ++ .../lib/src/json/ack_runtime_type_helper.dart | 32 ++ .../lib/src/json/helper_names.dart | 31 ++ packages/ack_generator/pubspec.yaml | 5 +- .../example_folder_build_test.dart | 36 +- .../json_serializable_build_test.dart | 207 +++++--- .../test/integration/v2_contract_test.dart | 17 +- .../test/integration/v2_graph_test.dart | 1 + .../test/integration/v2_models_test.dart | 93 +++- .../integration/v2_runtime_build_test.dart | 103 ++++ .../test/src/generator_test.dart | 56 ++- .../test/src/json_builder_test.dart | 48 ++ 60 files changed, 2321 insertions(+), 621 deletions(-) create mode 100644 example/lib/args_getter_example.g.dart create mode 100644 example/lib/pet.g.dart create mode 100644 example/lib/schema_types_discriminated.g.dart create mode 100644 example/lib/schema_types_edge_cases.g.dart create mode 100644 example/lib/schema_types_primitives.g.dart create mode 100644 example/lib/schema_types_simple.g.dart create mode 100644 example/lib/schema_types_transforms.g.dart create mode 100644 example/lib/user_with_color.g.dart create mode 100644 packages/ack_annotations/lib/ack_generator_support.dart create mode 100644 packages/ack_annotations/lib/src/ack_generated_json.dart create mode 100644 packages/ack_annotations/test/ack_type_test.dart create mode 100644 packages/ack_generator/lib/src/json/ack_json_generator.dart create mode 100644 packages/ack_generator/lib/src/json/ack_runtime_type_helper.dart create mode 100644 packages/ack_generator/lib/src/json/helper_names.dart create mode 100644 packages/ack_generator/test/src/json_builder_test.dart diff --git a/README.md b/README.md index ec4dfc16..9a386c5b 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'user.ack.dart'; +part 'user.g.dart'; @AckType() final userSchema = Ack.object({ diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index 58b8dce5..f498fb17 100644 --- a/docs/api-reference/index.mdx +++ b/docs/api-reference/index.mdx @@ -264,7 +264,7 @@ Schema reference for recursive object graphs. ## Code generation annotations -Use the [`ack_generator`](https://pub.dev/packages/ack_generator) builder to turn annotated top-level schemas into immutable model classes. After adding the annotation and a matching `.ack.dart` part directive, run: +Use the [`ack_generator`](https://pub.dev/packages/ack_generator) builder to turn annotated top-level schemas into immutable model classes. After adding the annotation plus matching `.ack.dart` and `.g.dart` part directives, run: ```bash dart run build_runner build diff --git a/docs/architecture/acktype-model-generation.md b/docs/architecture/acktype-model-generation.md index 8359b65c..035bab91 100644 --- a/docs/architecture/acktype-model-generation.md +++ b/docs/architecture/acktype-model-generation.md @@ -2,22 +2,42 @@ `@AckType()` generates immutable Dart classes while the source Ack schema remains responsible for validation, defaults, codecs, and serialization. +`json_serializable` generates only the structural runtime-map field mapping. ```text -boundary input -> Ack parse -> runtime value -> generated model -generated model -> runtime value -> Ack encode -> boundary output +PUBLIC DECODE +boundary input + -> AckModelAdapter.parse + -> source Ack schema validates, applies defaults, and decodes codecs + -> validated Ack runtime value/map + -> _$ClassFromJson generated by json_serializable + -> public immutable model constructor + +PUBLIC ENCODE +model + -> _$ClassToJson generated by json_serializable + -> Ack runtime value/map + -> source Ack schema validates and encodes codecs + -> boundary output ``` ## Build contract -Each annotated library declares a dedicated part such as -`part 'user.ack.dart';`. A `PartBuilder` writes that source file and runs before -`json_serializable`. Libraries using both builders declare both `.ack.dart` and -`.g.dart`; the outputs aren't combined. +Each annotated library declares both generated parts: -The dedicated source output lets later builders resolve Ack-generated model -classes. Clean-build tests cover same-library and cross-library -`json_serializable` consumers. +```dart +part 'user.ack.dart'; +part 'user.g.dart'; +``` + +Ack writes the dedicated `.ack.dart` model part first. An internal shared +builder then delegates marked classes to `json_serializable` and contributes a +cache fragment that `source_gen`'s combining builder merges into `.g.dart`. + +Ack-only consumers do not add `json_annotation` or `json_serializable`. The +generator package activates both phases. Applications that already use ordinary +`json_serializable` keep those dependencies; both fragments combine in one +JSON part without duplicate Ack helpers. ## Public model contract @@ -73,9 +93,10 @@ Defaulted fields stay required in the unchecked constructor because arbitrary schema defaults can't become Dart parameter defaults safely. Every represented list, set, and map is recursively copied into an unmodifiable -collection. Passthrough objects store unknown values in an unmodifiable -`additionalProperties` map. Encoding writes additional entries first and -declared fields second, so unknown data can't replace a declared property. +collection by the public constructor. Passthrough objects store unknown values +in an unmodifiable `additionalProperties` map. Encoding writes additional +entries first and declared fields second, so unknown data can't replace a +declared property. ## Unsupported shapes @@ -94,19 +115,24 @@ immutable generated model. A one-way transform can usually migrate to a custom ## Emission -The emitter reads only the normalized graph and uses `code_builder` for -declarations and type references. Runtime-to-model, model-to-runtime, and -immutable-copy operations share structural type traversal. Empty objects are -emitted structurally rather than through comma-sensitive templates. +The Ack emitter reads only the normalized graph and uses `code_builder` for +declarations, adapters, constructor snapshots, and per-field runtime bridges. +Direct map reads and writes are not emitted. Object, value, and concrete union +branch classes receive `@AckType.jsonSerializable`, a generator marker that +wraps a fixed `JsonSerializable(includeIfNull: false)` configuration. + +The internal JSON builder recognizes that marker, ignores consumer +`json_serializable` options, and inserts an Ack runtime `TypeHelper` ahead of +the default helpers so every stored field goes through the generated bridges. -Discriminated unions become a sealed base plus final same-library branches. -The base dispatches by discriminator; each branch has a constant discriminator -and uses the union's effective branch schema for its adapter. +Discriminated unions keep a sealed base plus final same-library branches. The +base dispatches by discriminator; each branch delegates only its stored fields +and Ack adds the discriminator to the runtime map. ## Validation The generator suite uses real workspace Ack package sources. Process fixtures build temporary packages from no generated output, run strict analysis and -runtime tests, verify current `json_serializable` interoperability, then rebuild +runtime tests, prove an Ack-only consumer produces both outputs, then rebuild and compare generated bytes for determinism. The checked example package keeps -its generated `.ack.dart` files as reviewable fixtures. +its generated `.ack.dart` and `.g.dart` files as reviewable fixtures. diff --git a/docs/core-concepts/json-serialization.mdx b/docs/core-concepts/json-serialization.mdx index a5d89581..8f669bd6 100644 --- a/docs/core-concepts/json-serialization.mdx +++ b/docs/core-concepts/json-serialization.mdx @@ -77,7 +77,7 @@ if (result.isOk) { // Option 2: Pass the validated map into your own model layer // - Constructor: User(name: validData['name'], age: validData['age']) - // - json_serializable: User.fromJson(Map.from(validData)) + // - AckType model: User.parse(validData) // - freezed: User.fromJson(Map.from(validData)) // - dart_mappable: UserMapper.fromMap(validData) // - Manual factory: User.fromMap(validData) @@ -86,7 +86,7 @@ if (result.isOk) { ## Parsing JSON into generated models -Once a schema is annotated with `@AckType()` (see [TypeSafe Schemas](./typesafe-schemas.mdx) for setup), parse JSON into a generated immutable model: +Once a schema is annotated with `@AckType()` (see [TypeSafe Schemas](./typesafe-schemas.mdx) for setup), parse JSON into a generated immutable model. Public `fromJson` / `toJson` still cross the source Ack schema; `json_serializable` only maps the already-validated runtime value onto stored fields. ```dart import 'dart:convert'; @@ -149,4 +149,4 @@ conversions. validates values and encodes codec runtime values back to their boundary representation. - **Type safety:** `jsonDecode` produces `dynamic`, but successful validation guarantees the structure and types of the resulting `Map`. -- **Model conversion:** After validation, how you convert the validated map into an app model is up to you. Ack keeps validation and wrapper generation separate from your model layer. +- **Model conversion:** After validation, how you convert the validated map into an app model is up to you. `@AckType()` models still parse and encode through the source schema; `json_serializable` only maps that runtime value onto stored fields. diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index 2a7417ca..5ca59caf 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -13,6 +13,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'user_schema.ack.dart'; +part 'user_schema.g.dart'; @AckType() final addressSchema = Ack.object({ @@ -120,23 +121,24 @@ payloads include the discriminator. A branch may omit that field or declare a compatible literal; the generated branch exposes a constant discriminator and encodes it safely. -## Using json_serializable +## Generated JSON mapping -Ack owns `.ack.dart`; `json_serializable` continues to own `.g.dart`. Declare -both when a library uses both generators: +Ack owns schema validation and the public model API. `json_serializable` +generates the structural `_$ClassFromJson` / `_$ClassToJson` helpers. Declare +both parts on every annotated library: ```dart part 'account.ack.dart'; part 'account.g.dart'; ``` -Ack runs first, so `json_serializable` can resolve generated models in the same -library. The generated `fromJson` and `toJson` methods also work when an Ack -model is imported from another library. +Ack-only consumers do not add `json_annotation` or `json_serializable`. If a +library also has hand-written `@JsonSerializable` classes, those fragments +combine into the same `.g.dart` without duplicating Ack helpers. ## Build checklist 1. Add `ack` and `ack_annotations` to dependencies. 2. Add `ack_generator` and `build_runner` to dev dependencies. -3. Add `part '.ack.dart';` to each annotated library. +3. Add `part '.ack.dart';` and `part '.g.dart';` to each annotated library. 4. Run `dart run build_runner build`. diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index b57f3068..68e9bbb3 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -53,6 +53,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'user.ack.dart'; +part 'user.g.dart'; @AckType() final userSchema = Ack.object({ @@ -75,4 +76,5 @@ Import `package:ack/ack.dart` and you're ready — the [Quickstart Tutorial](./q ## Requirements -- Dart SDK: `>=3.8.0 <4.0.0` +- Dart SDK: `>=3.8.0 <4.0.0` for core `ack` +- Dart SDK: `>=3.9.0 <4.0.0` when using `ack_annotations` / `ack_generator` diff --git a/example/README.md b/example/README.md index 9bde3325..9c204a5f 100644 --- a/example/README.md +++ b/example/README.md @@ -1,7 +1,8 @@ # Ack Example Package This package demonstrates Ack schemas built directly in source and converted to -immutable models with `@AckType()`. +immutable models with `@AckType()`. Annotated examples declare both +`.ack.dart` and `.g.dart` parts. ## Included examples diff --git a/example/lib/args_getter_example.ack.dart b/example/lib/args_getter_example.ack.dart index 8b7f8b46..eb6c3e0b 100644 --- a/example/lib/args_getter_example.ack.dart +++ b/example/lib/args_getter_example.ack.dart @@ -8,6 +8,7 @@ part of 'args_getter_example.dart'; // ************************************************************************** /// Immutable model generated from `userConfigSchema`. +@AckType.jsonSerializable final class UserConfig { UserConfig({ required this.username, @@ -44,29 +45,40 @@ final class UserConfig { SchemaResult> safeToJson() => $ack.safeEncode(this); static UserConfig _fromAckRuntime(Map value) { - return UserConfig( - username: value['username'] as String, - email: value['email'] as String, - additionalProperties: _ackImmutableCopyMap( - Map.fromEntries( - value.entries.where( - (entry) => !const {'username', 'email'}.contains(entry.key), - ), - ), + const declared = {'username', 'email'}; + return _$UserConfigFromJson({ + ...value, + 'additionalProperties': Map.fromEntries( + value.entries.where((entry) => !declared.contains(entry.key)), ), - ); + }); } Map _toAckRuntime() { - return { - ...additionalProperties, - 'username': username, - 'email': email, - }; + final result = {..._$UserConfigToJson(this)}; + result.remove('additionalProperties'); + return {...additionalProperties, ...result}; } + + static String _ackFromRuntimeUsername(Object? value) => value as String; + + static Object? _ackToRuntimeUsername(String value) => value; + + static String _ackFromRuntimeEmail(Object? value) => value as String; + + static Object? _ackToRuntimeEmail(String value) => value; + + static Map? _ackFromRuntimeAdditionalProperties( + Object? value, + ) => value as Map?; + + static Object? _ackToRuntimeAdditionalProperties( + Map value, + ) => value; } /// Immutable model generated from `apiRequestSchema`. +@AckType.jsonSerializable final class ApiRequest { ApiRequest({ required this.method, @@ -103,29 +115,40 @@ final class ApiRequest { SchemaResult> safeToJson() => $ack.safeEncode(this); static ApiRequest _fromAckRuntime(Map value) { - return ApiRequest( - method: value['method'] as String, - url: value['url'] as String, - additionalProperties: _ackImmutableCopyMap( - Map.fromEntries( - value.entries.where( - (entry) => !const {'method', 'url'}.contains(entry.key), - ), - ), + const declared = {'method', 'url'}; + return _$ApiRequestFromJson({ + ...value, + 'additionalProperties': Map.fromEntries( + value.entries.where((entry) => !declared.contains(entry.key)), ), - ); + }); } Map _toAckRuntime() { - return { - ...additionalProperties, - 'method': method, - 'url': url, - }; + final result = {..._$ApiRequestToJson(this)}; + result.remove('additionalProperties'); + return {...additionalProperties, ...result}; } + + static String _ackFromRuntimeMethod(Object? value) => value as String; + + static Object? _ackToRuntimeMethod(String value) => value; + + static String _ackFromRuntimeUrl(Object? value) => value as String; + + static Object? _ackToRuntimeUrl(String value) => value; + + static Map? _ackFromRuntimeAdditionalProperties( + Object? value, + ) => value as Map?; + + static Object? _ackToRuntimeAdditionalProperties( + Map value, + ) => value; } /// Immutable model generated from `featureFlagsSchema`. +@AckType.jsonSerializable final class FeatureFlags { FeatureFlags({ required this.appVersion, @@ -162,32 +185,40 @@ final class FeatureFlags { SchemaResult> safeToJson() => $ack.safeEncode(this); static FeatureFlags _fromAckRuntime(Map value) { - return FeatureFlags( - appVersion: value['appVersion'] as String, - environment: value['environment'] as String, - additionalProperties: _ackImmutableCopyMap( - Map.fromEntries( - value.entries.where( - (entry) => !const { - 'appVersion', - 'environment', - }.contains(entry.key), - ), - ), + const declared = {'appVersion', 'environment'}; + return _$FeatureFlagsFromJson({ + ...value, + 'additionalProperties': Map.fromEntries( + value.entries.where((entry) => !declared.contains(entry.key)), ), - ); + }); } Map _toAckRuntime() { - return { - ...additionalProperties, - 'appVersion': appVersion, - 'environment': environment, - }; + final result = {..._$FeatureFlagsToJson(this)}; + result.remove('additionalProperties'); + return {...additionalProperties, ...result}; } + + static String _ackFromRuntimeAppVersion(Object? value) => value as String; + + static Object? _ackToRuntimeAppVersion(String value) => value; + + static String _ackFromRuntimeEnvironment(Object? value) => value as String; + + static Object? _ackToRuntimeEnvironment(String value) => value; + + static Map? _ackFromRuntimeAdditionalProperties( + Object? value, + ) => value as Map?; + + static Object? _ackToRuntimeAdditionalProperties( + Map value, + ) => value; } /// Immutable model generated from `dynamicDataSchema`. +@AckType.jsonSerializable final class DynamicData { DynamicData({Map additionalProperties = const {}}) : additionalProperties = _ackImmutableCopyMap(additionalProperties); @@ -217,12 +248,28 @@ final class DynamicData { SchemaResult> safeToJson() => $ack.safeEncode(this); static DynamicData _fromAckRuntime(Map value) { - return DynamicData(additionalProperties: _ackImmutableCopyMap(value)); + const declared = {}; + return _$DynamicDataFromJson({ + ...value, + 'additionalProperties': Map.fromEntries( + value.entries.where((entry) => !declared.contains(entry.key)), + ), + }); } Map _toAckRuntime() { - return {...additionalProperties}; + final result = {..._$DynamicDataToJson(this)}; + result.remove('additionalProperties'); + return {...additionalProperties, ...result}; } + + static Map? _ackFromRuntimeAdditionalProperties( + Object? value, + ) => value as Map?; + + static Object? _ackToRuntimeAdditionalProperties( + Map value, + ) => value; } Object? _ackImmutableCopyValue(Object? value) => switch (value) { diff --git a/example/lib/args_getter_example.dart b/example/lib/args_getter_example.dart index ce9c7d8e..8e70aacf 100644 --- a/example/lib/args_getter_example.dart +++ b/example/lib/args_getter_example.dart @@ -6,6 +6,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'args_getter_example.ack.dart'; +part 'args_getter_example.g.dart'; /// Example 1: User configuration with additional metadata /// The generated model has `additionalProperties`, which contains diff --git a/example/lib/args_getter_example.g.dart b/example/lib/args_getter_example.g.dart new file mode 100644 index 00000000..764f8880 --- /dev/null +++ b/example/lib/args_getter_example.g.dart @@ -0,0 +1,80 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'args_getter_example.dart'; + +// ************************************************************************** +// AckJsonSerializableGenerator +// ************************************************************************** + +UserConfig _$UserConfigFromJson(Map json) => UserConfig( + username: UserConfig._ackFromRuntimeUsername(json['username']), + email: UserConfig._ackFromRuntimeEmail(json['email']), + additionalProperties: + UserConfig._ackFromRuntimeAdditionalProperties( + json['additionalProperties'], + ) ?? + const {}, +); + +Map _$UserConfigToJson(UserConfig instance) => + { + 'username': UserConfig._ackToRuntimeUsername(instance.username), + 'email': UserConfig._ackToRuntimeEmail(instance.email), + 'additionalProperties': UserConfig._ackToRuntimeAdditionalProperties( + instance.additionalProperties, + ), + }; + +ApiRequest _$ApiRequestFromJson(Map json) => ApiRequest( + method: ApiRequest._ackFromRuntimeMethod(json['method']), + url: ApiRequest._ackFromRuntimeUrl(json['url']), + additionalProperties: + ApiRequest._ackFromRuntimeAdditionalProperties( + json['additionalProperties'], + ) ?? + const {}, +); + +Map _$ApiRequestToJson(ApiRequest instance) => + { + 'method': ApiRequest._ackToRuntimeMethod(instance.method), + 'url': ApiRequest._ackToRuntimeUrl(instance.url), + 'additionalProperties': ApiRequest._ackToRuntimeAdditionalProperties( + instance.additionalProperties, + ), + }; + +FeatureFlags _$FeatureFlagsFromJson(Map json) => FeatureFlags( + appVersion: FeatureFlags._ackFromRuntimeAppVersion(json['appVersion']), + environment: FeatureFlags._ackFromRuntimeEnvironment(json['environment']), + additionalProperties: + FeatureFlags._ackFromRuntimeAdditionalProperties( + json['additionalProperties'], + ) ?? + const {}, +); + +Map _$FeatureFlagsToJson( + FeatureFlags instance, +) => { + 'appVersion': FeatureFlags._ackToRuntimeAppVersion(instance.appVersion), + 'environment': FeatureFlags._ackToRuntimeEnvironment(instance.environment), + 'additionalProperties': FeatureFlags._ackToRuntimeAdditionalProperties( + instance.additionalProperties, + ), +}; + +DynamicData _$DynamicDataFromJson(Map json) => DynamicData( + additionalProperties: + DynamicData._ackFromRuntimeAdditionalProperties( + json['additionalProperties'], + ) ?? + const {}, +); + +Map _$DynamicDataToJson(DynamicData instance) => + { + 'additionalProperties': DynamicData._ackToRuntimeAdditionalProperties( + instance.additionalProperties, + ), + }; diff --git a/example/lib/pet.ack.dart b/example/lib/pet.ack.dart index 13c235c1..45788823 100644 --- a/example/lib/pet.ack.dart +++ b/example/lib/pet.ack.dart @@ -44,6 +44,7 @@ sealed class Pet { } /// Discriminated model branch generated from `catSchema`. +@AckType.jsonSerializable final class Cat extends Pet { Cat({required this.lives}); @@ -68,17 +69,22 @@ final class Cat extends Pet { @override String get type => 'cat'; - static Cat _fromAckRuntime(Map value) { - return Cat(lives: value['lives'] as int); - } + static Cat _fromAckRuntime(Map value) => + _$CatFromJson(Map.from(value)); @override - Map _toAckRuntime() { - return {'type': 'cat', 'lives': lives}; - } + Map _toAckRuntime() => { + 'type': 'cat', + ..._$CatToJson(this), + }; + + static int _ackFromRuntimeLives(Object? value) => value as int; + + static Object? _ackToRuntimeLives(int value) => value; } /// Discriminated model branch generated from `dogSchema`. +@AckType.jsonSerializable final class Dog extends Pet { Dog({required this.breed}); @@ -103,12 +109,16 @@ final class Dog extends Pet { @override String get type => 'dog'; - static Dog _fromAckRuntime(Map value) { - return Dog(breed: value['breed'] as String); - } + static Dog _fromAckRuntime(Map value) => + _$DogFromJson(Map.from(value)); @override - Map _toAckRuntime() { - return {'type': 'dog', 'breed': breed}; - } + Map _toAckRuntime() => { + 'type': 'dog', + ..._$DogToJson(this), + }; + + static String _ackFromRuntimeBreed(Object? value) => value as String; + + static Object? _ackToRuntimeBreed(String value) => value; } diff --git a/example/lib/pet.dart b/example/lib/pet.dart index 496212b7..e3105d42 100644 --- a/example/lib/pet.dart +++ b/example/lib/pet.dart @@ -2,6 +2,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'pet.ack.dart'; +part 'pet.g.dart'; /// Pet schemas: discriminated by 'type' @AckType() diff --git a/example/lib/pet.g.dart b/example/lib/pet.g.dart new file mode 100644 index 00000000..c030a97c --- /dev/null +++ b/example/lib/pet.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'pet.dart'; + +// ************************************************************************** +// AckJsonSerializableGenerator +// ************************************************************************** + +Cat _$CatFromJson(Map json) => + Cat(lives: Cat._ackFromRuntimeLives(json['lives'])); + +Map _$CatToJson(Cat instance) => { + 'lives': Cat._ackToRuntimeLives(instance.lives), +}; + +Dog _$DogFromJson(Map json) => + Dog(breed: Dog._ackFromRuntimeBreed(json['breed'])); + +Map _$DogToJson(Dog instance) => { + 'breed': Dog._ackToRuntimeBreed(instance.breed), +}; diff --git a/example/lib/schema_types_discriminated.ack.dart b/example/lib/schema_types_discriminated.ack.dart index 1caf1cb8..3abe1ce4 100644 --- a/example/lib/schema_types_discriminated.ack.dart +++ b/example/lib/schema_types_discriminated.ack.dart @@ -44,6 +44,7 @@ sealed class Pet { } /// Discriminated model branch generated from `catSchema`. +@AckType.jsonSerializable final class Cat extends Pet { Cat({required this.lives}); @@ -68,17 +69,22 @@ final class Cat extends Pet { @override String get kind => 'cat'; - static Cat _fromAckRuntime(Map value) { - return Cat(lives: value['lives'] as int); - } + static Cat _fromAckRuntime(Map value) => + _$CatFromJson(Map.from(value)); @override - Map _toAckRuntime() { - return {'kind': 'cat', 'lives': lives}; - } + Map _toAckRuntime() => { + 'kind': 'cat', + ..._$CatToJson(this), + }; + + static int _ackFromRuntimeLives(Object? value) => value as int; + + static Object? _ackToRuntimeLives(int value) => value; } /// Discriminated model branch generated from `dogSchema`. +@AckType.jsonSerializable final class Dog extends Pet { Dog({ required this.bark, @@ -110,26 +116,33 @@ final class Dog extends Pet { String get kind => 'dog'; static Dog _fromAckRuntime(Map value) { - return Dog( - bark: value['bark'] as bool, - additionalProperties: _ackImmutableCopyMap( - Map.fromEntries( - value.entries.where( - (entry) => !const {'kind', 'bark'}.contains(entry.key), - ), - ), + const declared = {'kind', 'bark'}; + return _$DogFromJson({ + ...value, + 'additionalProperties': Map.fromEntries( + value.entries.where((entry) => !declared.contains(entry.key)), ), - ); + }); } @override Map _toAckRuntime() { - return { - ...additionalProperties, - 'kind': 'dog', - 'bark': bark, - }; + final result = {..._$DogToJson(this)}; + result.remove('additionalProperties'); + return {...additionalProperties, 'kind': 'dog', ...result}; } + + static bool _ackFromRuntimeBark(Object? value) => value as bool; + + static Object? _ackToRuntimeBark(bool value) => value; + + static Map? _ackFromRuntimeAdditionalProperties( + Object? value, + ) => value as Map?; + + static Object? _ackToRuntimeAdditionalProperties( + Map value, + ) => value; } Object? _ackImmutableCopyValue(Object? value) => switch (value) { diff --git a/example/lib/schema_types_discriminated.dart b/example/lib/schema_types_discriminated.dart index b5995db7..e087e709 100644 --- a/example/lib/schema_types_discriminated.dart +++ b/example/lib/schema_types_discriminated.dart @@ -2,6 +2,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'schema_types_discriminated.ack.dart'; +part 'schema_types_discriminated.g.dart'; /// Discriminated schema example for @AckType extension generation. @AckType() diff --git a/example/lib/schema_types_discriminated.g.dart b/example/lib/schema_types_discriminated.g.dart new file mode 100644 index 00000000..0daaf9c0 --- /dev/null +++ b/example/lib/schema_types_discriminated.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'schema_types_discriminated.dart'; + +// ************************************************************************** +// AckJsonSerializableGenerator +// ************************************************************************** + +Cat _$CatFromJson(Map json) => + Cat(lives: Cat._ackFromRuntimeLives(json['lives'])); + +Map _$CatToJson(Cat instance) => { + 'lives': Cat._ackToRuntimeLives(instance.lives), +}; + +Dog _$DogFromJson(Map json) => Dog( + bark: Dog._ackFromRuntimeBark(json['bark']), + additionalProperties: + Dog._ackFromRuntimeAdditionalProperties(json['additionalProperties']) ?? + const {}, +); + +Map _$DogToJson(Dog instance) => { + 'bark': Dog._ackToRuntimeBark(instance.bark), + 'additionalProperties': Dog._ackToRuntimeAdditionalProperties( + instance.additionalProperties, + ), +}; diff --git a/example/lib/schema_types_edge_cases.ack.dart b/example/lib/schema_types_edge_cases.ack.dart index 5771f9a7..2c2135d0 100644 --- a/example/lib/schema_types_edge_cases.ack.dart +++ b/example/lib/schema_types_edge_cases.ack.dart @@ -8,6 +8,7 @@ part of 'schema_types_edge_cases.dart'; // ************************************************************************** /// Immutable model generated from `productSchema`. +@AckType.jsonSerializable final class Product { Product({ required this.name, @@ -47,32 +48,38 @@ final class Product { SchemaResult> safeToJson() => $ack.safeEncode(this); - static Product _fromAckRuntime(Map value) { - return Product( - name: value['name'] as String, - tags: List.unmodifiable( - (value['tags'] as List).map((item) => item as String), - ), - scores: List.unmodifiable( - (value['scores'] as List).map((item) => item as int), - ), - flags: List.unmodifiable( - (value['flags'] as List).map((item) => item as bool), - ), - ); - } + static Product _fromAckRuntime(Map value) => + _$ProductFromJson(Map.from(value)); - Map _toAckRuntime() { - return { - 'name': name, - 'tags': tags.map((item) => item).toList(growable: false), - 'scores': scores.map((item) => item).toList(growable: false), - 'flags': flags.map((item) => item).toList(growable: false), - }; - } + Map _toAckRuntime() => { + ..._$ProductToJson(this), + }; + + static String _ackFromRuntimeName(Object? value) => value as String; + + static Object? _ackToRuntimeName(String value) => value; + + static List _ackFromRuntimeTags(Object? value) => + (value as List).map((item) => item as String).toList(); + + static Object? _ackToRuntimeTags(List value) => + value.map((item) => item).toList(growable: false); + + static List _ackFromRuntimeScores(Object? value) => + (value as List).map((item) => item as int).toList(); + + static Object? _ackToRuntimeScores(List value) => + value.map((item) => item).toList(growable: false); + + static List _ackFromRuntimeFlags(Object? value) => + (value as List).map((item) => item as bool).toList(); + + static Object? _ackToRuntimeFlags(List value) => + value.map((item) => item).toList(growable: false); } /// Immutable model generated from `gridSchema`. +@AckType.jsonSerializable final class Grid { Grid({required this.name, required List> matrix}) : matrix = List>.unmodifiable( @@ -103,29 +110,28 @@ final class Grid { SchemaResult> safeToJson() => $ack.safeEncode(this); - static Grid _fromAckRuntime(Map value) { - return Grid( - name: value['name'] as String, - matrix: List>.unmodifiable( - (value['matrix'] as List).map( - (item) => - List.unmodifiable((item as List).map((item) => item as int)), - ), - ), - ); - } + static Grid _fromAckRuntime(Map value) => + _$GridFromJson(Map.from(value)); - Map _toAckRuntime() { - return { - 'name': name, - 'matrix': matrix - .map((item) => item.map((item) => item).toList(growable: false)) - .toList(growable: false), - }; - } + Map _toAckRuntime() => { + ..._$GridToJson(this), + }; + + static String _ackFromRuntimeName(Object? value) => value as String; + + static Object? _ackToRuntimeName(String value) => value; + + static List> _ackFromRuntimeMatrix(Object? value) => (value as List) + .map((item) => (item as List).map((item) => item as int).toList()) + .toList(); + + static Object? _ackToRuntimeMatrix(List> value) => value + .map((item) => item.map((item) => item).toList(growable: false)) + .toList(growable: false); } /// Immutable model generated from `addressSchema`. +@AckType.jsonSerializable final class Address { Address({ required this.street, @@ -163,26 +169,32 @@ final class Address { SchemaResult> safeToJson() => $ack.safeEncode(this); - static Address _fromAckRuntime(Map value) { - return Address( - street: value['street'] as String, - city: value['city'] as String, - zipCode: value['zipCode'] as String, - country: value['country'] as String, - ); - } + static Address _fromAckRuntime(Map value) => + _$AddressFromJson(Map.from(value)); - Map _toAckRuntime() { - return { - 'street': street, - 'city': city, - 'zipCode': zipCode, - 'country': country, - }; - } + Map _toAckRuntime() => { + ..._$AddressToJson(this), + }; + + static String _ackFromRuntimeStreet(Object? value) => value as String; + + static Object? _ackToRuntimeStreet(String value) => value; + + static String _ackFromRuntimeCity(Object? value) => value as String; + + static Object? _ackToRuntimeCity(String value) => value; + + static String _ackFromRuntimeZipCode(Object? value) => value as String; + + static Object? _ackToRuntimeZipCode(String value) => value; + + static String _ackFromRuntimeCountry(Object? value) => value as String; + + static Object? _ackToRuntimeCountry(String value) => value; } /// Immutable model generated from `personSchema`. +@AckType.jsonSerializable final class Person { Person({ required this.name, @@ -219,28 +231,34 @@ final class Person { SchemaResult> safeToJson() => $ack.safeEncode(this); - static Person _fromAckRuntime(Map value) { - return Person( - name: value['name'] as String, - email: value['email'] as String, - address: Address.$ack.fromRuntime( - value['address'] as Map, - ), - age: value['age'] as int, - ); - } + static Person _fromAckRuntime(Map value) => + _$PersonFromJson(Map.from(value)); - Map _toAckRuntime() { - return { - 'name': name, - 'email': email, - 'address': Address.$ack.toRuntime(address), - 'age': age, - }; - } + Map _toAckRuntime() => { + ..._$PersonToJson(this), + }; + + static String _ackFromRuntimeName(Object? value) => value as String; + + static Object? _ackToRuntimeName(String value) => value; + + static String _ackFromRuntimeEmail(Object? value) => value as String; + + static Object? _ackToRuntimeEmail(String value) => value; + + static Address _ackFromRuntimeAddress(Object? value) => + Address.$ack.fromRuntime(value as Map); + + static Object? _ackToRuntimeAddress(Address value) => + Address.$ack.toRuntime(value); + + static int _ackFromRuntimeAge(Object? value) => value as int; + + static Object? _ackToRuntimeAge(int value) => value; } /// Immutable model generated from `employeeSchema`. +@AckType.jsonSerializable final class Employee { Employee({ required this.name, @@ -278,30 +296,36 @@ final class Employee { SchemaResult> safeToJson() => $ack.safeEncode(this); - static Employee _fromAckRuntime(Map value) { - return Employee( - name: value['name'] as String, - employeeId: value['employeeId'] as String, - homeAddress: Address.$ack.fromRuntime( - value['homeAddress'] as Map, - ), - workAddress: Address.$ack.fromRuntime( - value['workAddress'] as Map, - ), - ); - } + static Employee _fromAckRuntime(Map value) => + _$EmployeeFromJson(Map.from(value)); - Map _toAckRuntime() { - return { - 'name': name, - 'employeeId': employeeId, - 'homeAddress': Address.$ack.toRuntime(homeAddress), - 'workAddress': Address.$ack.toRuntime(workAddress), - }; - } + Map _toAckRuntime() => { + ..._$EmployeeToJson(this), + }; + + static String _ackFromRuntimeName(Object? value) => value as String; + + static Object? _ackToRuntimeName(String value) => value; + + static String _ackFromRuntimeEmployeeId(Object? value) => value as String; + + static Object? _ackToRuntimeEmployeeId(String value) => value; + + static Address _ackFromRuntimeHomeAddress(Object? value) => + Address.$ack.fromRuntime(value as Map); + + static Object? _ackToRuntimeHomeAddress(Address value) => + Address.$ack.toRuntime(value); + + static Address _ackFromRuntimeWorkAddress(Object? value) => + Address.$ack.fromRuntime(value as Map); + + static Object? _ackToRuntimeWorkAddress(Address value) => + Address.$ack.toRuntime(value); } /// Immutable model generated from `modifierSchema`. +@AckType.jsonSerializable final class Modifier { Modifier({ required this.requiredField, @@ -342,28 +366,44 @@ final class Modifier { SchemaResult> safeToJson() => $ack.safeEncode(this); - static Modifier _fromAckRuntime(Map value) { - return Modifier( - requiredField: value['requiredField'] as String, - optionalField: value['optionalField'] as String?, - nullableField: value['nullableField'] as String?, - optionalNullable: value['optionalNullable'] as String?, - nullableOptional: value['nullableOptional'] as String?, - ); - } + static Modifier _fromAckRuntime(Map value) => + _$ModifierFromJson(Map.from(value)); Map _toAckRuntime() { - return { - 'requiredField': requiredField, - if (optionalField != null) 'optionalField': optionalField!, - 'nullableField': nullableField, - if (optionalNullable != null) 'optionalNullable': optionalNullable!, - if (nullableOptional != null) 'nullableOptional': nullableOptional!, - }; + final result = {..._$ModifierToJson(this)}; + if (nullableField == null) { + result['nullableField'] = null; + } + return {...result}; } + + static String _ackFromRuntimeRequiredField(Object? value) => value as String; + + static Object? _ackToRuntimeRequiredField(String value) => value; + + static String? _ackFromRuntimeOptionalField(Object? value) => + value as String?; + + static Object? _ackToRuntimeOptionalField(String? value) => value; + + static String? _ackFromRuntimeNullableField(Object? value) => + value as String?; + + static Object? _ackToRuntimeNullableField(String? value) => value; + + static String? _ackFromRuntimeOptionalNullable(Object? value) => + value as String?; + + static Object? _ackToRuntimeOptionalNullable(String? value) => value; + + static String? _ackFromRuntimeNullableOptional(Object? value) => + value as String?; + + static Object? _ackToRuntimeNullableOptional(String? value) => value; } /// Immutable model generated from `taggedItemSchema`. +@AckType.jsonSerializable final class TaggedItem { TaggedItem({ required this.name, @@ -415,45 +455,58 @@ final class TaggedItem { SchemaResult> safeToJson() => $ack.safeEncode(this); - static TaggedItem _fromAckRuntime(Map value) { - return TaggedItem( - name: value['name'] as String, - requiredTags: List.unmodifiable( - (value['requiredTags'] as List).map((item) => item as String), - ), - optionalTags: switch (value['optionalTags']) { + static TaggedItem _fromAckRuntime(Map value) => + _$TaggedItemFromJson(Map.from(value)); + + Map _toAckRuntime() { + final result = {..._$TaggedItemToJson(this)}; + if (nullableTags == null) { + result['nullableTags'] = null; + } + return {...result}; + } + + static String _ackFromRuntimeName(Object? value) => value as String; + + static Object? _ackToRuntimeName(String value) => value; + + static List _ackFromRuntimeRequiredTags(Object? value) => + (value as List).map((item) => item as String).toList(); + + static Object? _ackToRuntimeRequiredTags(List value) => + value.map((item) => item).toList(growable: false); + + static List? _ackFromRuntimeOptionalTags(Object? value) => + switch (value) { null => null, - final fieldValue => List.unmodifiable( - (fieldValue as List).map((item) => item as String), - ), - }, - nullableTags: switch (value['nullableTags']) { + final fieldValue => + (fieldValue as List).map((item) => item as String).toList(), + }; + + static Object? _ackToRuntimeOptionalTags(List? value) => + switch (value) { null => null, - final fieldValue => List.unmodifiable( - (fieldValue as List).map((item) => item as String), - ), - }, - ); - } + final fieldValue => + fieldValue.map((item) => item).toList(growable: false), + }; - Map _toAckRuntime() { - return { - 'name': name, - 'requiredTags': requiredTags.map((item) => item).toList(growable: false), - if (optionalTags != null) - 'optionalTags': optionalTags! - .map((item) => item) - .toList(growable: false), - 'nullableTags': switch (nullableTags) { + static List? _ackFromRuntimeNullableTags(Object? value) => + switch (value) { + null => null, + final fieldValue => + (fieldValue as List).map((item) => item as String).toList(), + }; + + static Object? _ackToRuntimeNullableTags(List? value) => + switch (value) { null => null, final fieldValue => fieldValue.map((item) => item).toList(growable: false), - }, - }; - } + }; } /// Immutable model generated from `contactListSchema`. +@AckType.jsonSerializable final class ContactList { ContactList({required this.name, required List
addresses}) : addresses = List
.unmodifiable(addresses.map((item) => item)); @@ -483,28 +536,28 @@ final class ContactList { SchemaResult> safeToJson() => $ack.safeEncode(this); - static ContactList _fromAckRuntime(Map value) { - return ContactList( - name: value['name'] as String, - addresses: List
.unmodifiable( - (value['addresses'] as List).map( - (item) => Address.$ack.fromRuntime(item as Map), - ), - ), - ); - } + static ContactList _fromAckRuntime(Map value) => + _$ContactListFromJson(Map.from(value)); - Map _toAckRuntime() { - return { - 'name': name, - 'addresses': addresses - .map((item) => Address.$ack.toRuntime(item)) - .toList(growable: false), - }; - } + Map _toAckRuntime() => { + ..._$ContactListToJson(this), + }; + + static String _ackFromRuntimeName(Object? value) => value as String; + + static Object? _ackToRuntimeName(String value) => value; + + static List
_ackFromRuntimeAddresses(Object? value) => + (value as List) + .map((item) => Address.$ack.fromRuntime(item as Map)) + .toList(); + + static Object? _ackToRuntimeAddresses(List
value) => + value.map((item) => Address.$ack.toRuntime(item)).toList(growable: false); } /// Immutable model generated from `emptySchema`. +@AckType.jsonSerializable final class Empty { Empty(); @@ -528,16 +581,16 @@ final class Empty { SchemaResult> safeToJson() => $ack.safeEncode(this); - static Empty _fromAckRuntime(Map value) { - return Empty(); - } + static Empty _fromAckRuntime(Map value) => + _$EmptyFromJson(Map.from(value)); - Map _toAckRuntime() { - return {}; - } + Map _toAckRuntime() => { + ..._$EmptyToJson(this), + }; } /// Immutable model generated from `minimalSchema`. +@AckType.jsonSerializable final class Minimal { Minimal({required this.id}); @@ -564,16 +617,20 @@ final class Minimal { SchemaResult> safeToJson() => $ack.safeEncode(this); - static Minimal _fromAckRuntime(Map value) { - return Minimal(id: value['id'] as String); - } + static Minimal _fromAckRuntime(Map value) => + _$MinimalFromJson(Map.from(value)); - Map _toAckRuntime() { - return {'id': id}; - } + Map _toAckRuntime() => { + ..._$MinimalToJson(this), + }; + + static String _ackFromRuntimeId(Object? value) => value as String; + + static Object? _ackToRuntimeId(String value) => value; } /// Immutable model generated from `namedItemSchema`. +@AckType.jsonSerializable final class NamedItem { NamedItem({required this.name}); @@ -600,16 +657,20 @@ final class NamedItem { SchemaResult> safeToJson() => $ack.safeEncode(this); - static NamedItem _fromAckRuntime(Map value) { - return NamedItem(name: value['name'] as String); - } + static NamedItem _fromAckRuntime(Map value) => + _$NamedItemFromJson(Map.from(value)); - Map _toAckRuntime() { - return {'name': name}; - } + Map _toAckRuntime() => { + ..._$NamedItemToJson(this), + }; + + static String _ackFromRuntimeName(Object? value) => value as String; + + static Object? _ackToRuntimeName(String value) => value; } /// Immutable model generated from `item`. +@AckType.jsonSerializable final class Item { Item({required this.id}); @@ -635,16 +696,20 @@ final class Item { SchemaResult> safeToJson() => $ack.safeEncode(this); - static Item _fromAckRuntime(Map value) { - return Item(id: value['id'] as String); - } + static Item _fromAckRuntime(Map value) => + _$ItemFromJson(Map.from(value)); - Map _toAckRuntime() { - return {'id': id}; - } + Map _toAckRuntime() => { + ..._$ItemToJson(this), + }; + + static String _ackFromRuntimeId(Object? value) => value as String; + + static Object? _ackToRuntimeId(String value) => value; } /// Immutable model generated from `myCustomSchema123`. +@AckType.jsonSerializable final class MyCustomSchema123 { MyCustomSchema123({required this.value}); @@ -671,11 +736,14 @@ final class MyCustomSchema123 { SchemaResult> safeToJson() => $ack.safeEncode(this); - static MyCustomSchema123 _fromAckRuntime(Map value) { - return MyCustomSchema123(value: value['value'] as String); - } + static MyCustomSchema123 _fromAckRuntime(Map value) => + _$MyCustomSchema123FromJson(Map.from(value)); - Map _toAckRuntime() { - return {'value': value}; - } + Map _toAckRuntime() => { + ..._$MyCustomSchema123ToJson(this), + }; + + static String _ackFromRuntimeValue(Object? value) => value as String; + + static Object? _ackToRuntimeValue(String value) => value; } diff --git a/example/lib/schema_types_edge_cases.dart b/example/lib/schema_types_edge_cases.dart index ea861046..10352e08 100644 --- a/example/lib/schema_types_edge_cases.dart +++ b/example/lib/schema_types_edge_cases.dart @@ -13,6 +13,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'schema_types_edge_cases.ack.dart'; +part 'schema_types_edge_cases.g.dart'; // ============================================================================ // EDGE CASE 1: List Type Extraction diff --git a/example/lib/schema_types_edge_cases.g.dart b/example/lib/schema_types_edge_cases.g.dart new file mode 100644 index 00000000..29538fc5 --- /dev/null +++ b/example/lib/schema_types_edge_cases.g.dart @@ -0,0 +1,159 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'schema_types_edge_cases.dart'; + +// ************************************************************************** +// AckJsonSerializableGenerator +// ************************************************************************** + +Product _$ProductFromJson(Map json) => Product( + name: Product._ackFromRuntimeName(json['name']), + tags: Product._ackFromRuntimeTags(json['tags']), + scores: Product._ackFromRuntimeScores(json['scores']), + flags: Product._ackFromRuntimeFlags(json['flags']), +); + +Map _$ProductToJson(Product instance) => { + 'name': Product._ackToRuntimeName(instance.name), + 'tags': Product._ackToRuntimeTags(instance.tags), + 'scores': Product._ackToRuntimeScores(instance.scores), + 'flags': Product._ackToRuntimeFlags(instance.flags), +}; + +Grid _$GridFromJson(Map json) => Grid( + name: Grid._ackFromRuntimeName(json['name']), + matrix: Grid._ackFromRuntimeMatrix(json['matrix']), +); + +Map _$GridToJson(Grid instance) => { + 'name': Grid._ackToRuntimeName(instance.name), + 'matrix': Grid._ackToRuntimeMatrix(instance.matrix), +}; + +Address _$AddressFromJson(Map json) => Address( + street: Address._ackFromRuntimeStreet(json['street']), + city: Address._ackFromRuntimeCity(json['city']), + zipCode: Address._ackFromRuntimeZipCode(json['zipCode']), + country: Address._ackFromRuntimeCountry(json['country']), +); + +Map _$AddressToJson(Address instance) => { + 'street': Address._ackToRuntimeStreet(instance.street), + 'city': Address._ackToRuntimeCity(instance.city), + 'zipCode': Address._ackToRuntimeZipCode(instance.zipCode), + 'country': Address._ackToRuntimeCountry(instance.country), +}; + +Person _$PersonFromJson(Map json) => Person( + name: Person._ackFromRuntimeName(json['name']), + email: Person._ackFromRuntimeEmail(json['email']), + address: Person._ackFromRuntimeAddress(json['address']), + age: Person._ackFromRuntimeAge(json['age']), +); + +Map _$PersonToJson(Person instance) => { + 'name': Person._ackToRuntimeName(instance.name), + 'email': Person._ackToRuntimeEmail(instance.email), + 'address': Person._ackToRuntimeAddress(instance.address), + 'age': Person._ackToRuntimeAge(instance.age), +}; + +Employee _$EmployeeFromJson(Map json) => Employee( + name: Employee._ackFromRuntimeName(json['name']), + employeeId: Employee._ackFromRuntimeEmployeeId(json['employeeId']), + homeAddress: Employee._ackFromRuntimeHomeAddress(json['homeAddress']), + workAddress: Employee._ackFromRuntimeWorkAddress(json['workAddress']), +); + +Map _$EmployeeToJson(Employee instance) => { + 'name': Employee._ackToRuntimeName(instance.name), + 'employeeId': Employee._ackToRuntimeEmployeeId(instance.employeeId), + 'homeAddress': Employee._ackToRuntimeHomeAddress(instance.homeAddress), + 'workAddress': Employee._ackToRuntimeWorkAddress(instance.workAddress), +}; + +Modifier _$ModifierFromJson(Map json) => Modifier( + requiredField: Modifier._ackFromRuntimeRequiredField(json['requiredField']), + optionalField: Modifier._ackFromRuntimeOptionalField(json['optionalField']), + nullableField: Modifier._ackFromRuntimeNullableField(json['nullableField']), + optionalNullable: Modifier._ackFromRuntimeOptionalNullable( + json['optionalNullable'], + ), + nullableOptional: Modifier._ackFromRuntimeNullableOptional( + json['nullableOptional'], + ), +); + +Map _$ModifierToJson(Modifier instance) => { + 'requiredField': Modifier._ackToRuntimeRequiredField(instance.requiredField), + 'optionalField': ?Modifier._ackToRuntimeOptionalField(instance.optionalField), + 'nullableField': ?Modifier._ackToRuntimeNullableField(instance.nullableField), + 'optionalNullable': ?Modifier._ackToRuntimeOptionalNullable( + instance.optionalNullable, + ), + 'nullableOptional': ?Modifier._ackToRuntimeNullableOptional( + instance.nullableOptional, + ), +}; + +TaggedItem _$TaggedItemFromJson(Map json) => TaggedItem( + name: TaggedItem._ackFromRuntimeName(json['name']), + requiredTags: TaggedItem._ackFromRuntimeRequiredTags(json['requiredTags']), + optionalTags: TaggedItem._ackFromRuntimeOptionalTags(json['optionalTags']), + nullableTags: TaggedItem._ackFromRuntimeNullableTags(json['nullableTags']), +); + +Map _$TaggedItemToJson( + TaggedItem instance, +) => { + 'name': TaggedItem._ackToRuntimeName(instance.name), + 'requiredTags': TaggedItem._ackToRuntimeRequiredTags(instance.requiredTags), + 'optionalTags': ?TaggedItem._ackToRuntimeOptionalTags(instance.optionalTags), + 'nullableTags': ?TaggedItem._ackToRuntimeNullableTags(instance.nullableTags), +}; + +ContactList _$ContactListFromJson(Map json) => ContactList( + name: ContactList._ackFromRuntimeName(json['name']), + addresses: ContactList._ackFromRuntimeAddresses(json['addresses']), +); + +Map _$ContactListToJson(ContactList instance) => + { + 'name': ContactList._ackToRuntimeName(instance.name), + 'addresses': ContactList._ackToRuntimeAddresses(instance.addresses), + }; + +Empty _$EmptyFromJson(Map json) => Empty(); + +Map _$EmptyToJson(Empty instance) => {}; + +Minimal _$MinimalFromJson(Map json) => + Minimal(id: Minimal._ackFromRuntimeId(json['id'])); + +Map _$MinimalToJson(Minimal instance) => { + 'id': Minimal._ackToRuntimeId(instance.id), +}; + +NamedItem _$NamedItemFromJson(Map json) => + NamedItem(name: NamedItem._ackFromRuntimeName(json['name'])); + +Map _$NamedItemToJson(NamedItem instance) => { + 'name': NamedItem._ackToRuntimeName(instance.name), +}; + +Item _$ItemFromJson(Map json) => + Item(id: Item._ackFromRuntimeId(json['id'])); + +Map _$ItemToJson(Item instance) => { + 'id': Item._ackToRuntimeId(instance.id), +}; + +MyCustomSchema123 _$MyCustomSchema123FromJson(Map json) => + MyCustomSchema123( + value: MyCustomSchema123._ackFromRuntimeValue(json['value']), + ); + +Map _$MyCustomSchema123ToJson(MyCustomSchema123 instance) => + { + 'value': MyCustomSchema123._ackToRuntimeValue(instance.value), + }; diff --git a/example/lib/schema_types_primitives.ack.dart b/example/lib/schema_types_primitives.ack.dart index 9c35f3a1..4bf18267 100644 --- a/example/lib/schema_types_primitives.ack.dart +++ b/example/lib/schema_types_primitives.ack.dart @@ -8,6 +8,7 @@ part of 'schema_types_primitives.dart'; // ************************************************************************** /// Immutable value model generated from `passwordSchema`. +@AckType.jsonSerializable final class Password { Password(this.value); @@ -34,12 +35,18 @@ final class Password { SchemaResult safeToJson() => $ack.safeEncode(this); - static Password _fromAckRuntime(String value) => Password(value); + static Password _fromAckRuntime(String value) => + _$PasswordFromJson({'value': value}); - String _toAckRuntime() => value; + String _toAckRuntime() => _$PasswordToJson(this)['value'] as String; + + static String _ackFromRuntimeValue(Object? value) => value as String; + + static Object? _ackToRuntimeValue(String value) => value; } /// Immutable value model generated from `ageSchema`. +@AckType.jsonSerializable final class Age { Age(this.value); @@ -65,12 +72,18 @@ final class Age { SchemaResult safeToJson() => $ack.safeEncode(this); - static Age _fromAckRuntime(int value) => Age(value); + static Age _fromAckRuntime(int value) => + _$AgeFromJson({'value': value}); + + int _toAckRuntime() => _$AgeToJson(this)['value'] as int; + + static int _ackFromRuntimeValue(Object? value) => value as int; - int _toAckRuntime() => value; + static Object? _ackToRuntimeValue(int value) => value; } /// Immutable value model generated from `priceSchema`. +@AckType.jsonSerializable final class Price { Price(this.value); @@ -96,12 +109,18 @@ final class Price { SchemaResult safeToJson() => $ack.safeEncode(this); - static Price _fromAckRuntime(double value) => Price(value); + static Price _fromAckRuntime(double value) => + _$PriceFromJson({'value': value}); - double _toAckRuntime() => value; + double _toAckRuntime() => _$PriceToJson(this)['value'] as double; + + static double _ackFromRuntimeValue(Object? value) => value as double; + + static Object? _ackToRuntimeValue(double value) => value; } /// Immutable value model generated from `activeSchema`. +@AckType.jsonSerializable final class Active { Active(this.value); @@ -127,12 +146,18 @@ final class Active { SchemaResult safeToJson() => $ack.safeEncode(this); - static Active _fromAckRuntime(bool value) => Active(value); + static Active _fromAckRuntime(bool value) => + _$ActiveFromJson({'value': value}); + + bool _toAckRuntime() => _$ActiveToJson(this)['value'] as bool; - bool _toAckRuntime() => value; + static bool _ackFromRuntimeValue(Object? value) => value as bool; + + static Object? _ackToRuntimeValue(bool value) => value; } /// Immutable value model generated from `tagsSchema`. +@AckType.jsonSerializable final class Tags { Tags(List value) : value = List.unmodifiable(value.map((item) => item)); @@ -159,12 +184,20 @@ final class Tags { SchemaResult> safeToJson() => $ack.safeEncode(this); - static Tags _fromAckRuntime(List value) => Tags(value); + static Tags _fromAckRuntime(List value) => + _$TagsFromJson({'value': value}); + + List _toAckRuntime() => _$TagsToJson(this)['value'] as List; - List _toAckRuntime() => value; + static List _ackFromRuntimeValue(Object? value) => + (value as List).map((item) => item as String).toList(); + + static Object? _ackToRuntimeValue(List value) => + value.map((item) => item).toList(growable: false); } /// Immutable value model generated from `scoresSchema`. +@AckType.jsonSerializable final class Scores { Scores(List value) : value = List.unmodifiable(value.map((item) => item)); @@ -191,12 +224,20 @@ final class Scores { SchemaResult> safeToJson() => $ack.safeEncode(this); - static Scores _fromAckRuntime(List value) => Scores(value); + static Scores _fromAckRuntime(List value) => + _$ScoresFromJson({'value': value}); + + List _toAckRuntime() => _$ScoresToJson(this)['value'] as List; - List _toAckRuntime() => value; + static List _ackFromRuntimeValue(Object? value) => + (value as List).map((item) => item as int).toList(); + + static Object? _ackToRuntimeValue(List value) => + value.map((item) => item).toList(growable: false); } /// Immutable value model generated from `statusSchema`. +@AckType.jsonSerializable final class StatusLiteral { StatusLiteral(this.value); @@ -223,12 +264,18 @@ final class StatusLiteral { SchemaResult safeToJson() => $ack.safeEncode(this); - static StatusLiteral _fromAckRuntime(String value) => StatusLiteral(value); + static StatusLiteral _fromAckRuntime(String value) => + _$StatusLiteralFromJson({'value': value}); + + String _toAckRuntime() => _$StatusLiteralToJson(this)['value'] as String; + + static String _ackFromRuntimeValue(Object? value) => value as String; - String _toAckRuntime() => value; + static Object? _ackToRuntimeValue(String value) => value; } /// Immutable value model generated from `roleSchema`. +@AckType.jsonSerializable final class Role { Role(this.value); @@ -254,12 +301,18 @@ final class Role { SchemaResult safeToJson() => $ack.safeEncode(this); - static Role _fromAckRuntime(String value) => Role(value); + static Role _fromAckRuntime(String value) => + _$RoleFromJson({'value': value}); - String _toAckRuntime() => value; + String _toAckRuntime() => _$RoleToJson(this)['value'] as String; + + static String _ackFromRuntimeValue(Object? value) => value as String; + + static Object? _ackToRuntimeValue(String value) => value; } /// Immutable value model generated from `userRoleSchema`. +@AckType.jsonSerializable final class UserRoleModel { UserRoleModel(this.value); @@ -286,12 +339,18 @@ final class UserRoleModel { SchemaResult safeToJson() => $ack.safeEncode(this); - static UserRoleModel _fromAckRuntime(UserRole value) => UserRoleModel(value); + static UserRoleModel _fromAckRuntime(UserRole value) => + _$UserRoleModelFromJson({'value': value}); + + UserRole _toAckRuntime() => _$UserRoleModelToJson(this)['value'] as UserRole; + + static UserRole _ackFromRuntimeValue(Object? value) => value as UserRole; - UserRole _toAckRuntime() => value; + static Object? _ackToRuntimeValue(UserRole value) => value; } /// Immutable value model generated from `statusEnumSchema`. +@AckType.jsonSerializable final class StatusEnum { StatusEnum(this.value); @@ -318,12 +377,18 @@ final class StatusEnum { SchemaResult safeToJson() => $ack.safeEncode(this); - static StatusEnum _fromAckRuntime(Status value) => StatusEnum(value); + static StatusEnum _fromAckRuntime(Status value) => + _$StatusEnumFromJson({'value': value}); - Status _toAckRuntime() => value; + Status _toAckRuntime() => _$StatusEnumToJson(this)['value'] as Status; + + static Status _ackFromRuntimeValue(Object? value) => value as Status; + + static Object? _ackToRuntimeValue(Status value) => value; } /// Immutable value model generated from `optionalStatusSchema`. +@AckType.jsonSerializable final class OptionalStatus { OptionalStatus(this.value); @@ -350,12 +415,18 @@ final class OptionalStatus { SchemaResult safeToJson() => $ack.safeEncode(this); - static OptionalStatus _fromAckRuntime(String value) => OptionalStatus(value); + static OptionalStatus _fromAckRuntime(String value) => + _$OptionalStatusFromJson({'value': value}); + + String _toAckRuntime() => _$OptionalStatusToJson(this)['value'] as String; - String _toAckRuntime() => value; + static String _ackFromRuntimeValue(Object? value) => value as String; + + static Object? _ackToRuntimeValue(String value) => value; } /// Immutable value model generated from `defaultedEnumSchema`. +@AckType.jsonSerializable final class DefaultedEnum { DefaultedEnum(this.value); @@ -382,12 +453,18 @@ final class DefaultedEnum { SchemaResult safeToJson() => $ack.safeEncode(this); - static DefaultedEnum _fromAckRuntime(UserRole value) => DefaultedEnum(value); + static DefaultedEnum _fromAckRuntime(UserRole value) => + _$DefaultedEnumFromJson({'value': value}); + + UserRole _toAckRuntime() => _$DefaultedEnumToJson(this)['value'] as UserRole; - UserRole _toAckRuntime() => value; + static UserRole _ackFromRuntimeValue(Object? value) => value as UserRole; + + static Object? _ackToRuntimeValue(UserRole value) => value; } /// Immutable value model generated from `chainedEnumStringSchema`. +@AckType.jsonSerializable final class ChainedEnumString { ChainedEnumString(this.value); @@ -415,12 +492,17 @@ final class ChainedEnumString { SchemaResult safeToJson() => $ack.safeEncode(this); static ChainedEnumString _fromAckRuntime(String value) => - ChainedEnumString(value); + _$ChainedEnumStringFromJson({'value': value}); + + String _toAckRuntime() => _$ChainedEnumStringToJson(this)['value'] as String; - String _toAckRuntime() => value; + static String _ackFromRuntimeValue(Object? value) => value as String; + + static Object? _ackToRuntimeValue(String value) => value; } /// Immutable value model generated from `refinedAgeSchema`. +@AckType.jsonSerializable final class RefinedAge { RefinedAge(this.value); @@ -447,7 +529,12 @@ final class RefinedAge { SchemaResult safeToJson() => $ack.safeEncode(this); - static RefinedAge _fromAckRuntime(int value) => RefinedAge(value); + static RefinedAge _fromAckRuntime(int value) => + _$RefinedAgeFromJson({'value': value}); + + int _toAckRuntime() => _$RefinedAgeToJson(this)['value'] as int; + + static int _ackFromRuntimeValue(Object? value) => value as int; - int _toAckRuntime() => value; + static Object? _ackToRuntimeValue(int value) => value; } diff --git a/example/lib/schema_types_primitives.dart b/example/lib/schema_types_primitives.dart index 68e84bea..e04e7f36 100644 --- a/example/lib/schema_types_primitives.dart +++ b/example/lib/schema_types_primitives.dart @@ -2,6 +2,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'schema_types_primitives.ack.dart'; +part 'schema_types_primitives.g.dart'; // Primitive schemas generate immutable value models while the schema remains // available directly for parse() and safeParse(). diff --git a/example/lib/schema_types_primitives.g.dart b/example/lib/schema_types_primitives.g.dart new file mode 100644 index 00000000..795bed3f --- /dev/null +++ b/example/lib/schema_types_primitives.g.dart @@ -0,0 +1,108 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'schema_types_primitives.dart'; + +// ************************************************************************** +// AckJsonSerializableGenerator +// ************************************************************************** + +Password _$PasswordFromJson(Map json) => + Password(Password._ackFromRuntimeValue(json['value'])); + +Map _$PasswordToJson(Password instance) => { + 'value': Password._ackToRuntimeValue(instance.value), +}; + +Age _$AgeFromJson(Map json) => + Age(Age._ackFromRuntimeValue(json['value'])); + +Map _$AgeToJson(Age instance) => { + 'value': Age._ackToRuntimeValue(instance.value), +}; + +Price _$PriceFromJson(Map json) => + Price(Price._ackFromRuntimeValue(json['value'])); + +Map _$PriceToJson(Price instance) => { + 'value': Price._ackToRuntimeValue(instance.value), +}; + +Active _$ActiveFromJson(Map json) => + Active(Active._ackFromRuntimeValue(json['value'])); + +Map _$ActiveToJson(Active instance) => { + 'value': Active._ackToRuntimeValue(instance.value), +}; + +Tags _$TagsFromJson(Map json) => + Tags(Tags._ackFromRuntimeValue(json['value'])); + +Map _$TagsToJson(Tags instance) => { + 'value': Tags._ackToRuntimeValue(instance.value), +}; + +Scores _$ScoresFromJson(Map json) => + Scores(Scores._ackFromRuntimeValue(json['value'])); + +Map _$ScoresToJson(Scores instance) => { + 'value': Scores._ackToRuntimeValue(instance.value), +}; + +StatusLiteral _$StatusLiteralFromJson(Map json) => + StatusLiteral(StatusLiteral._ackFromRuntimeValue(json['value'])); + +Map _$StatusLiteralToJson(StatusLiteral instance) => + { + 'value': StatusLiteral._ackToRuntimeValue(instance.value), + }; + +Role _$RoleFromJson(Map json) => + Role(Role._ackFromRuntimeValue(json['value'])); + +Map _$RoleToJson(Role instance) => { + 'value': Role._ackToRuntimeValue(instance.value), +}; + +UserRoleModel _$UserRoleModelFromJson(Map json) => + UserRoleModel(UserRoleModel._ackFromRuntimeValue(json['value'])); + +Map _$UserRoleModelToJson(UserRoleModel instance) => + { + 'value': UserRoleModel._ackToRuntimeValue(instance.value), + }; + +StatusEnum _$StatusEnumFromJson(Map json) => + StatusEnum(StatusEnum._ackFromRuntimeValue(json['value'])); + +Map _$StatusEnumToJson(StatusEnum instance) => + {'value': StatusEnum._ackToRuntimeValue(instance.value)}; + +OptionalStatus _$OptionalStatusFromJson(Map json) => + OptionalStatus(OptionalStatus._ackFromRuntimeValue(json['value'])); + +Map _$OptionalStatusToJson(OptionalStatus instance) => + { + 'value': OptionalStatus._ackToRuntimeValue(instance.value), + }; + +DefaultedEnum _$DefaultedEnumFromJson(Map json) => + DefaultedEnum(DefaultedEnum._ackFromRuntimeValue(json['value'])); + +Map _$DefaultedEnumToJson(DefaultedEnum instance) => + { + 'value': DefaultedEnum._ackToRuntimeValue(instance.value), + }; + +ChainedEnumString _$ChainedEnumStringFromJson(Map json) => + ChainedEnumString(ChainedEnumString._ackFromRuntimeValue(json['value'])); + +Map _$ChainedEnumStringToJson(ChainedEnumString instance) => + { + 'value': ChainedEnumString._ackToRuntimeValue(instance.value), + }; + +RefinedAge _$RefinedAgeFromJson(Map json) => + RefinedAge(RefinedAge._ackFromRuntimeValue(json['value'])); + +Map _$RefinedAgeToJson(RefinedAge instance) => + {'value': RefinedAge._ackToRuntimeValue(instance.value)}; diff --git a/example/lib/schema_types_simple.ack.dart b/example/lib/schema_types_simple.ack.dart index f0587bce..d743dd54 100644 --- a/example/lib/schema_types_simple.ack.dart +++ b/example/lib/schema_types_simple.ack.dart @@ -8,6 +8,7 @@ part of 'schema_types_simple.dart'; // ************************************************************************** /// Immutable model generated from `userSchema`. +@AckType.jsonSerializable final class User { User({required this.name, required this.age, required this.active}); @@ -37,15 +38,22 @@ final class User { SchemaResult> safeToJson() => $ack.safeEncode(this); - static User _fromAckRuntime(Map value) { - return User( - name: value['name'] as String, - age: value['age'] as int, - active: value['active'] as bool, - ); - } + static User _fromAckRuntime(Map value) => + _$UserFromJson(Map.from(value)); - Map _toAckRuntime() { - return {'name': name, 'age': age, 'active': active}; - } + Map _toAckRuntime() => { + ..._$UserToJson(this), + }; + + static String _ackFromRuntimeName(Object? value) => value as String; + + static Object? _ackToRuntimeName(String value) => value; + + static int _ackFromRuntimeAge(Object? value) => value as int; + + static Object? _ackToRuntimeAge(int value) => value; + + static bool _ackFromRuntimeActive(Object? value) => value as bool; + + static Object? _ackToRuntimeActive(bool value) => value; } diff --git a/example/lib/schema_types_simple.dart b/example/lib/schema_types_simple.dart index 551dcc04..2bb55f13 100644 --- a/example/lib/schema_types_simple.dart +++ b/example/lib/schema_types_simple.dart @@ -2,6 +2,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'schema_types_simple.ack.dart'; +part 'schema_types_simple.g.dart'; /// Simple example: Basic primitives @AckType() diff --git a/example/lib/schema_types_simple.g.dart b/example/lib/schema_types_simple.g.dart new file mode 100644 index 00000000..418e9924 --- /dev/null +++ b/example/lib/schema_types_simple.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'schema_types_simple.dart'; + +// ************************************************************************** +// AckJsonSerializableGenerator +// ************************************************************************** + +User _$UserFromJson(Map json) => User( + name: User._ackFromRuntimeName(json['name']), + age: User._ackFromRuntimeAge(json['age']), + active: User._ackFromRuntimeActive(json['active']), +); + +Map _$UserToJson(User instance) => { + 'name': User._ackToRuntimeName(instance.name), + 'age': User._ackToRuntimeAge(instance.age), + 'active': User._ackToRuntimeActive(instance.active), +}; diff --git a/example/lib/schema_types_transforms.ack.dart b/example/lib/schema_types_transforms.ack.dart index cf0bb714..d73071ea 100644 --- a/example/lib/schema_types_transforms.ack.dart +++ b/example/lib/schema_types_transforms.ack.dart @@ -8,6 +8,7 @@ part of 'schema_types_transforms.dart'; // ************************************************************************** /// Immutable value model generated from `colorSchema`. +@AckType.jsonSerializable final class ColorModel { ColorModel(this.value); @@ -34,12 +35,18 @@ final class ColorModel { SchemaResult safeToJson() => $ack.safeEncode(this); - static ColorModel _fromAckRuntime(Color value) => ColorModel(value); + static ColorModel _fromAckRuntime(Color value) => + _$ColorModelFromJson({'value': value}); - Color _toAckRuntime() => value; + Color _toAckRuntime() => _$ColorModelToJson(this)['value'] as Color; + + static Color _ackFromRuntimeValue(Object? value) => value as Color; + + static Object? _ackToRuntimeValue(Color value) => value; } /// Immutable model generated from `profileSchema`. +@AckType.jsonSerializable final class Profile { Profile({ required this.homepage, @@ -102,45 +109,65 @@ final class Profile { SchemaResult> safeToJson() => $ack.safeEncode(this); - static Profile _fromAckRuntime(Map value) { - return Profile( - homepage: value['homepage'] as Uri, - birthday: value['birthday'] as DateTime, - lastLogin: value['lastLogin'] as DateTime, - timeout: value['timeout'] as Duration, - links: List.unmodifiable( - (value['links'] as List).map((item) => item as Uri), - ), - favoriteColor: value['favoriteColor'] as Color, - slug: value['slug'] as String, - accent: ColorModel.$ack.fromRuntime(value['accent'] as Color), - colors: List.unmodifiable( - (value['colors'] as List).map( - (item) => ColorModel.$ack.fromRuntime(item as Color), - ), - ), - customColors: List.unmodifiable( - (value['customColors'] as List).map((item) => item as Color), - ), - tagList: value['tagList'] as TagList, - ); - } + static Profile _fromAckRuntime(Map value) => + _$ProfileFromJson(Map.from(value)); - Map _toAckRuntime() { - return { - 'homepage': homepage, - 'birthday': birthday, - 'lastLogin': lastLogin, - 'timeout': timeout, - 'links': links.map((item) => item).toList(growable: false), - 'favoriteColor': favoriteColor, - 'slug': slug, - 'accent': ColorModel.$ack.toRuntime(accent), - 'colors': colors - .map((item) => ColorModel.$ack.toRuntime(item)) - .toList(growable: false), - 'customColors': customColors.map((item) => item).toList(growable: false), - 'tagList': tagList, - }; - } + Map _toAckRuntime() => { + ..._$ProfileToJson(this), + }; + + static Uri _ackFromRuntimeHomepage(Object? value) => value as Uri; + + static Object? _ackToRuntimeHomepage(Uri value) => value; + + static DateTime _ackFromRuntimeBirthday(Object? value) => value as DateTime; + + static Object? _ackToRuntimeBirthday(DateTime value) => value; + + static DateTime _ackFromRuntimeLastLogin(Object? value) => value as DateTime; + + static Object? _ackToRuntimeLastLogin(DateTime value) => value; + + static Duration _ackFromRuntimeTimeout(Object? value) => value as Duration; + + static Object? _ackToRuntimeTimeout(Duration value) => value; + + static List _ackFromRuntimeLinks(Object? value) => + (value as List).map((item) => item as Uri).toList(); + + static Object? _ackToRuntimeLinks(List value) => + value.map((item) => item).toList(growable: false); + + static Color _ackFromRuntimeFavoriteColor(Object? value) => value as Color; + + static Object? _ackToRuntimeFavoriteColor(Color value) => value; + + static String _ackFromRuntimeSlug(Object? value) => value as String; + + static Object? _ackToRuntimeSlug(String value) => value; + + static ColorModel _ackFromRuntimeAccent(Object? value) => + ColorModel.$ack.fromRuntime(value as Color); + + static Object? _ackToRuntimeAccent(ColorModel value) => + ColorModel.$ack.toRuntime(value); + + static List _ackFromRuntimeColors(Object? value) => + (value as List) + .map((item) => ColorModel.$ack.fromRuntime(item as Color)) + .toList(); + + static Object? _ackToRuntimeColors(List value) => value + .map((item) => ColorModel.$ack.toRuntime(item)) + .toList(growable: false); + + static List _ackFromRuntimeCustomColors(Object? value) => + (value as List).map((item) => item as Color).toList(); + + static Object? _ackToRuntimeCustomColors(List value) => + value.map((item) => item).toList(growable: false); + + static TagList _ackFromRuntimeTagList(Object? value) => value as TagList; + + static Object? _ackToRuntimeTagList(TagList value) => value; } diff --git a/example/lib/schema_types_transforms.dart b/example/lib/schema_types_transforms.dart index 147cce5b..9e6440ba 100644 --- a/example/lib/schema_types_transforms.dart +++ b/example/lib/schema_types_transforms.dart @@ -2,6 +2,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'schema_types_transforms.ack.dart'; +part 'schema_types_transforms.g.dart'; class Color { final String value; diff --git a/example/lib/schema_types_transforms.g.dart b/example/lib/schema_types_transforms.g.dart new file mode 100644 index 00000000..9f1ebe5a --- /dev/null +++ b/example/lib/schema_types_transforms.g.dart @@ -0,0 +1,41 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'schema_types_transforms.dart'; + +// ************************************************************************** +// AckJsonSerializableGenerator +// ************************************************************************** + +ColorModel _$ColorModelFromJson(Map json) => + ColorModel(ColorModel._ackFromRuntimeValue(json['value'])); + +Map _$ColorModelToJson(ColorModel instance) => + {'value': ColorModel._ackToRuntimeValue(instance.value)}; + +Profile _$ProfileFromJson(Map json) => Profile( + homepage: Profile._ackFromRuntimeHomepage(json['homepage']), + birthday: Profile._ackFromRuntimeBirthday(json['birthday']), + lastLogin: Profile._ackFromRuntimeLastLogin(json['lastLogin']), + timeout: Profile._ackFromRuntimeTimeout(json['timeout']), + links: Profile._ackFromRuntimeLinks(json['links']), + favoriteColor: Profile._ackFromRuntimeFavoriteColor(json['favoriteColor']), + slug: Profile._ackFromRuntimeSlug(json['slug']), + accent: Profile._ackFromRuntimeAccent(json['accent']), + colors: Profile._ackFromRuntimeColors(json['colors']), + customColors: Profile._ackFromRuntimeCustomColors(json['customColors']), + tagList: Profile._ackFromRuntimeTagList(json['tagList']), +); + +Map _$ProfileToJson(Profile instance) => { + 'homepage': Profile._ackToRuntimeHomepage(instance.homepage), + 'birthday': Profile._ackToRuntimeBirthday(instance.birthday), + 'lastLogin': Profile._ackToRuntimeLastLogin(instance.lastLogin), + 'timeout': Profile._ackToRuntimeTimeout(instance.timeout), + 'links': Profile._ackToRuntimeLinks(instance.links), + 'favoriteColor': Profile._ackToRuntimeFavoriteColor(instance.favoriteColor), + 'slug': Profile._ackToRuntimeSlug(instance.slug), + 'accent': Profile._ackToRuntimeAccent(instance.accent), + 'colors': Profile._ackToRuntimeColors(instance.colors), + 'customColors': Profile._ackToRuntimeCustomColors(instance.customColors), + 'tagList': Profile._ackToRuntimeTagList(instance.tagList), +}; diff --git a/example/lib/user_with_color.ack.dart b/example/lib/user_with_color.ack.dart index df12873e..4a87c3d1 100644 --- a/example/lib/user_with_color.ack.dart +++ b/example/lib/user_with_color.ack.dart @@ -8,6 +8,7 @@ part of 'user_with_color.dart'; // ************************************************************************** /// Immutable value model generated from `colorSchema`. +@AckType.jsonSerializable final class ColorModel { ColorModel(this.value); @@ -34,12 +35,18 @@ final class ColorModel { SchemaResult safeToJson() => $ack.safeEncode(this); - static ColorModel _fromAckRuntime(Color value) => ColorModel(value); + static ColorModel _fromAckRuntime(Color value) => + _$ColorModelFromJson({'value': value}); - Color _toAckRuntime() => value; + Color _toAckRuntime() => _$ColorModelToJson(this)['value'] as Color; + + static Color _ackFromRuntimeValue(Object? value) => value as Color; + + static Object? _ackToRuntimeValue(Color value) => value; } /// Immutable model generated from `profileSchema`. +@AckType.jsonSerializable final class Profile { Profile({required this.bio, this.website}); @@ -68,22 +75,24 @@ final class Profile { SchemaResult> safeToJson() => $ack.safeEncode(this); - static Profile _fromAckRuntime(Map value) { - return Profile( - bio: value['bio'] as String, - website: value['website'] as Uri?, - ); - } + static Profile _fromAckRuntime(Map value) => + _$ProfileFromJson(Map.from(value)); - Map _toAckRuntime() { - return { - 'bio': bio, - if (website != null) 'website': website!, - }; - } + Map _toAckRuntime() => { + ..._$ProfileToJson(this), + }; + + static String _ackFromRuntimeBio(Object? value) => value as String; + + static Object? _ackToRuntimeBio(String value) => value; + + static Uri? _ackFromRuntimeWebsite(Object? value) => value as Uri?; + + static Object? _ackToRuntimeWebsite(Uri? value) => value; } /// Immutable model generated from `userWithColorSchema`. +@AckType.jsonSerializable final class UserWithColor { UserWithColor({ required this.firstName, @@ -133,41 +142,58 @@ final class UserWithColor { SchemaResult> safeToJson() => $ack.safeEncode(this); - static UserWithColor _fromAckRuntime(Map value) { - return UserWithColor( - firstName: value['firstName'] as String, - lastName: value['lastName'] as String, - age: value['age'] as int, - profile: Profile.$ack.fromRuntime( - value['profile'] as Map, - ), - color: ColorModel.$ack.fromRuntime(value['color'] as Color), - favoriteColor: switch (value['favoriteColor']) { + static UserWithColor _fromAckRuntime(Map value) => + _$UserWithColorFromJson(Map.from(value)); + + Map _toAckRuntime() => { + ..._$UserWithColorToJson(this), + }; + + static String _ackFromRuntimeFirstName(Object? value) => value as String; + + static Object? _ackToRuntimeFirstName(String value) => value; + + static String _ackFromRuntimeLastName(Object? value) => value as String; + + static Object? _ackToRuntimeLastName(String value) => value; + + static int _ackFromRuntimeAge(Object? value) => value as int; + + static Object? _ackToRuntimeAge(int value) => value; + + static Profile _ackFromRuntimeProfile(Object? value) => + Profile.$ack.fromRuntime(value as Map); + + static Object? _ackToRuntimeProfile(Profile value) => + Profile.$ack.toRuntime(value); + + static ColorModel _ackFromRuntimeColor(Object? value) => + ColorModel.$ack.fromRuntime(value as Color); + + static Object? _ackToRuntimeColor(ColorModel value) => + ColorModel.$ack.toRuntime(value); + + static ColorModel? _ackFromRuntimeFavoriteColor(Object? value) => + switch (value) { null => null, final fieldValue => ColorModel.$ack.fromRuntime(fieldValue as Color), - }, - pet: Pet.$ack.fromRuntime(value['pet'] as Map), - pets: List.unmodifiable( - (value['pets'] as List).map( - (item) => Pet.$ack.fromRuntime(item as Map), - ), - ), - ); - } + }; - Map _toAckRuntime() { - return { - 'firstName': firstName, - 'lastName': lastName, - 'age': age, - 'profile': Profile.$ack.toRuntime(profile), - 'color': ColorModel.$ack.toRuntime(color), - if (favoriteColor != null) - 'favoriteColor': ColorModel.$ack.toRuntime(favoriteColor!), - 'pet': Pet.$ack.toRuntime(pet), - 'pets': pets - .map((item) => Pet.$ack.toRuntime(item)) - .toList(growable: false), - }; - } + static Object? _ackToRuntimeFavoriteColor(ColorModel? value) => + switch (value) { + null => null, + final fieldValue => ColorModel.$ack.toRuntime(fieldValue), + }; + + static Pet _ackFromRuntimePet(Object? value) => + Pet.$ack.fromRuntime(value as Map); + + static Object? _ackToRuntimePet(Pet value) => Pet.$ack.toRuntime(value); + + static List _ackFromRuntimePets(Object? value) => (value as List) + .map((item) => Pet.$ack.fromRuntime(item as Map)) + .toList(); + + static Object? _ackToRuntimePets(List value) => + value.map((item) => Pet.$ack.toRuntime(item)).toList(growable: false); } diff --git a/example/lib/user_with_color.dart b/example/lib/user_with_color.dart index 0f2044a8..e1cac6dc 100644 --- a/example/lib/user_with_color.dart +++ b/example/lib/user_with_color.dart @@ -4,6 +4,7 @@ import 'package:ack_annotations/ack_annotations.dart'; import 'pet.dart'; part 'user_with_color.ack.dart'; +part 'user_with_color.g.dart'; class Color { final int value; diff --git a/example/lib/user_with_color.g.dart b/example/lib/user_with_color.g.dart new file mode 100644 index 00000000..b5e2dbc5 --- /dev/null +++ b/example/lib/user_with_color.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_with_color.dart'; + +// ************************************************************************** +// AckJsonSerializableGenerator +// ************************************************************************** + +ColorModel _$ColorModelFromJson(Map json) => + ColorModel(ColorModel._ackFromRuntimeValue(json['value'])); + +Map _$ColorModelToJson(ColorModel instance) => + {'value': ColorModel._ackToRuntimeValue(instance.value)}; + +Profile _$ProfileFromJson(Map json) => Profile( + bio: Profile._ackFromRuntimeBio(json['bio']), + website: Profile._ackFromRuntimeWebsite(json['website']), +); + +Map _$ProfileToJson(Profile instance) => { + 'bio': Profile._ackToRuntimeBio(instance.bio), + 'website': ?Profile._ackToRuntimeWebsite(instance.website), +}; + +UserWithColor _$UserWithColorFromJson(Map json) => + UserWithColor( + firstName: UserWithColor._ackFromRuntimeFirstName(json['firstName']), + lastName: UserWithColor._ackFromRuntimeLastName(json['lastName']), + age: UserWithColor._ackFromRuntimeAge(json['age']), + profile: UserWithColor._ackFromRuntimeProfile(json['profile']), + color: UserWithColor._ackFromRuntimeColor(json['color']), + favoriteColor: UserWithColor._ackFromRuntimeFavoriteColor( + json['favoriteColor'], + ), + pet: UserWithColor._ackFromRuntimePet(json['pet']), + pets: UserWithColor._ackFromRuntimePets(json['pets']), + ); + +Map _$UserWithColorToJson(UserWithColor instance) => + { + 'firstName': UserWithColor._ackToRuntimeFirstName(instance.firstName), + 'lastName': UserWithColor._ackToRuntimeLastName(instance.lastName), + 'age': UserWithColor._ackToRuntimeAge(instance.age), + 'profile': UserWithColor._ackToRuntimeProfile(instance.profile), + 'color': UserWithColor._ackToRuntimeColor(instance.color), + 'favoriteColor': ?UserWithColor._ackToRuntimeFavoriteColor( + instance.favoriteColor, + ), + 'pet': UserWithColor._ackToRuntimePet(instance.pet), + 'pets': UserWithColor._ackToRuntimePets(instance.pets), + }; diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index c266778e..97f1594b 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -3,7 +3,9 @@ ### Added * Add `AckModelAdapter` as the non-nullable runtime bridge used by generated - immutable Ack models. + immutable Ack models. The adapter keeps schema parse/encode around model + mapping so public JSON methods stay schema-backed while structural field + mapping can be generated separately. ## 1.1.0 diff --git a/packages/ack_annotations/CHANGELOG.md b/packages/ack_annotations/CHANGELOG.md index e0827d53..6cde00c2 100644 --- a/packages/ack_annotations/CHANGELOG.md +++ b/packages/ack_annotations/CHANGELOG.md @@ -4,7 +4,10 @@ * Define `@AckType()` as immutable model-class generation. Custom names are exact, generated class names no longer add `Type`, and annotated libraries - declare a dedicated `.ack.dart` part. + declare both `.ack.dart` and `.g.dart` parts. +* Add the generator-support `AckType.jsonSerializable` marker and raise the + minimum SDK to Dart 3.9 so the nested `JsonSerializable` configuration can + live in this package. The public barrel still exports only `@AckType()`. ## 1.1.0 diff --git a/packages/ack_annotations/README.md b/packages/ack_annotations/README.md index f33f3499..052ae9d4 100644 --- a/packages/ack_annotations/README.md +++ b/packages/ack_annotations/README.md @@ -24,6 +24,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'user.ack.dart'; +part 'user.g.dart'; @AckType() final userSchema = Ack.object({ @@ -33,7 +34,10 @@ final userSchema = Ack.object({ ``` `ack_generator` emits an immutable `User` class with typed fields, an unchecked -constructor, parsing helpers, JSON methods, and a public `$ack` adapter. +constructor, parsing helpers, JSON methods, and a public `$ack` adapter. The +Ack part owns those declarations; `json_serializable` writes the structural +field-mapping helpers into `user.g.dart`. Ack-only apps do not add JSON +packages for generated models. The annotation package requires Dart 3.9. Generate the wrapper with: diff --git a/packages/ack_annotations/lib/ack_annotations.dart b/packages/ack_annotations/lib/ack_annotations.dart index 9e784086..768ec8d1 100644 --- a/packages/ack_annotations/lib/ack_annotations.dart +++ b/packages/ack_annotations/lib/ack_annotations.dart @@ -1,4 +1,4 @@ -/// Annotation library for Ack schema extension-type generation. +/// Annotation library for Ack schema model-class generation. /// /// Import this library to use `@AckType()` on top-level schema variables and /// getters that are processed by `ack_generator`. diff --git a/packages/ack_annotations/lib/ack_generator_support.dart b/packages/ack_annotations/lib/ack_generator_support.dart new file mode 100644 index 00000000..18cc8029 --- /dev/null +++ b/packages/ack_annotations/lib/ack_generator_support.dart @@ -0,0 +1,8 @@ +/// Generator-support types for `ack_generator`. +/// +/// Application code should import `package:ack_annotations/ack_annotations.dart` +/// and use `@AckType()` only. This library exists so the generator can inspect +/// the internal JSON marker. +library; + +export 'src/ack_generated_json.dart'; diff --git a/packages/ack_annotations/lib/src/ack_generated_json.dart b/packages/ack_annotations/lib/src/ack_generated_json.dart new file mode 100644 index 00000000..0b914f25 --- /dev/null +++ b/packages/ack_annotations/lib/src/ack_generated_json.dart @@ -0,0 +1,20 @@ +import 'package:json_annotation/json_annotation.dart'; + +/// Generator-support marker applied to Ack-generated model classes. +/// +/// This is not part of the user-facing annotation API. `ack_generator` emits +/// [AckType.jsonSerializable] so the internal JSON builder can delegate +/// structural mapping to `json_serializable` without a literal +/// `@JsonSerializable` annotation that the ordinary builder would also claim. +class AckGeneratedJson { + /// Creates the internal JSON-mapping marker. + const AckGeneratedJson({ + this.config = const JsonSerializable(includeIfNull: false), + }); + + /// Fixed `json_serializable` configuration owned by Ack. + /// + /// `includeIfNull` is always `false` so optional null fields stay absent. + /// Required nullable keys are restored by generated Ack glue. + final JsonSerializable config; +} diff --git a/packages/ack_annotations/lib/src/ack_type.dart b/packages/ack_annotations/lib/src/ack_type.dart index 31e899bb..c2b2f8c7 100644 --- a/packages/ack_annotations/lib/src/ack_type.dart +++ b/packages/ack_annotations/lib/src/ack_type.dart @@ -1,5 +1,7 @@ import 'package:meta/meta_meta.dart'; +import 'ack_generated_json.dart'; + /// Marks a top-level Ack schema for immutable model-class generation. /// /// Apply `@AckType()` to a top-level schema variable or getter: @@ -12,10 +14,10 @@ import 'package:meta/meta_meta.dart'; /// }); /// ``` /// -/// The declaring library must include its dedicated generated part, for -/// example `part 'user.ack.dart';`. `ack_generator` emits a real Dart class -/// with stored typed fields plus `parse`, `safeParse`, `fromJson`, `toJson`, -/// `safeToJson`, and a public static `$ack` adapter. +/// The declaring library must include both generated parts, for example +/// `part 'user.ack.dart';` and `part 'user.g.dart';`. `ack_generator` emits +/// the model class, public parse/JSON API, and runtime bridges in the Ack +/// part. Structural field mapping is generated into the combined JSON part. /// Ack remains responsible for validation and codec-aware serialization. /// /// Supported targets: @@ -31,6 +33,12 @@ import 'package:meta/meta_meta.dart'; /// - Local variables @Target({TargetKind.topLevelVariable, TargetKind.getter}) class AckType { + /// Internal marker used on generated model classes. + /// + /// Typed as [Object] so generated code only needs a constant annotation + /// expression. The generator inspects the actual [AckGeneratedJson] type. + static const Object jsonSerializable = AckGeneratedJson(); + /// Optional exact name for the generated model class. /// /// If omitted, the class name is derived from the schema declaration: diff --git a/packages/ack_annotations/pubspec.yaml b/packages/ack_annotations/pubspec.yaml index 14937558..1bc52dd8 100644 --- a/packages/ack_annotations/pubspec.yaml +++ b/packages/ack_annotations/pubspec.yaml @@ -5,9 +5,11 @@ repository: https://github.com/btwld/ack resolution: workspace environment: - sdk: '>=3.8.0 <4.0.0' + sdk: '>=3.9.0 <4.0.0' dependencies: + json_annotation: ^4.12.0 meta: ^1.15.0 dev_dependencies: + test: ^1.25.15 diff --git a/packages/ack_annotations/test/ack_type_test.dart b/packages/ack_annotations/test/ack_type_test.dart new file mode 100644 index 00000000..12ee4b9b --- /dev/null +++ b/packages/ack_annotations/test/ack_type_test.dart @@ -0,0 +1,23 @@ +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack_annotations/ack_generator_support.dart'; +import 'package:test/test.dart'; + +void main() { + test('public barrel exposes AckType', () { + const annotation = AckType(); + expect(annotation.name, isNull); + expect(AckType.jsonSerializable, isA()); + }); + + test('support barrel exposes the marker with fixed null omission', () { + const marker = AckGeneratedJson(); + expect(marker.config.includeIfNull, isFalse); + + final generated = AckType.jsonSerializable as AckGeneratedJson; + expect(generated.config.includeIfNull, isFalse); + expect( + identical(AckType.jsonSerializable, const AckGeneratedJson()), + isTrue, + ); + }); +} diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index af765457..f30e7e7b 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -17,8 +17,10 @@ ### Changed -* Generate dedicated `.ack.dart` source parts before `json_serializable`, which - keeps Ack declarations resolvable by later builders. +* Generate dedicated `.ack.dart` source parts and an internal JSON phase that + delegates structural mapping to `json_serializable`. Annotated libraries + declare both `.ack.dart` and `.g.dart`. Ack still owns schema validation, + codecs, defaults, and public parse/JSON methods. * Reject parse-only transforms and schema shapes without a static model form. * Support named recursion, cross-file references, custom codecs, additional properties, and sealed discriminated model hierarchies. diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index 1ff27d2d..03bd92d5 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -10,6 +10,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'user_schema.ack.dart'; +part 'user_schema.g.dart'; @AckType() final userSchema = Ack.object({ @@ -68,17 +69,18 @@ values aren't parsed twice. ## JSON serialization -Ack writes a dedicated `.ack.dart` part before `json_serializable`. A library -using both generators declares both parts: +Every annotated library declares both parts: ```dart part 'account.ack.dart'; part 'account.g.dart'; ``` -Generated Ack models provide the conventional `fromJson` and `toJson` methods -that `json_serializable` uses for custom nested types, in the same library or -across imports. +Ack owns schema validation, defaults, codecs, union dispatch, and the public +`parse` / `fromJson` / `toJson` methods. `json_serializable` generates the +structural `_$ClassFromJson` / `_$ClassToJson` helpers into the combined JSON +part. Ack-only apps do not add `json_annotation` or `json_serializable`; +`ack_generator` activates that second phase itself. ## Supported declarations diff --git a/packages/ack_generator/build.yaml b/packages/ack_generator/build.yaml index 6ca5fa30..653454ad 100644 --- a/packages/ack_generator/build.yaml +++ b/packages/ack_generator/build.yaml @@ -5,4 +5,18 @@ builders: build_extensions: {".dart": [".ack.dart"]} auto_apply: dependents build_to: source - runs_before: ["json_serializable"] + runs_before: + - ack_generator|ack_json_serializable + - json_serializable|json_serializable + applies_builders: + - ack_generator|ack_json_serializable + + ack_json_serializable: + import: "package:ack_generator/builder.dart" + builder_factories: ["ackJsonSerializableBuilder"] + build_extensions: {".dart": [".ack_json_serializable.g.part"]} + auto_apply: none + build_to: cache + required_inputs: [".ack.dart"] + applies_builders: + - source_gen|combining_builder diff --git a/packages/ack_generator/lib/ack_generator.dart b/packages/ack_generator/lib/ack_generator.dart index 5335499b..61bcd6ec 100644 --- a/packages/ack_generator/lib/ack_generator.dart +++ b/packages/ack_generator/lib/ack_generator.dart @@ -1,5 +1,5 @@ -// Export the builder for build.yaml -export 'src/builder.dart' show ackGenerator; +// Export the builders for build.yaml +export 'src/builder.dart' show ackGenerator, ackJsonSerializableBuilder; // Re-export AckType for convenience alongside the builder entrypoint. export 'package:ack_annotations/ack_annotations.dart'; diff --git a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart index 70d4028f..4fa37272 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart @@ -6,6 +6,7 @@ import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:source_gen/source_gen.dart'; +import '../json/helper_names.dart'; import '../models/schema_model_graph.dart'; final class _Declaration { @@ -190,6 +191,7 @@ final class SchemaModelGraphBuilder { ); } _validateGeneratedHelperNames(); + _validateDelegatedHelperNames(); return _graph; } @@ -993,10 +995,7 @@ final class SchemaModelGraphBuilder { } if (passthroughNode == null) return; - final localNames = { - for (final element in library.allElements) - if (element.name case final name?) name, - }; + final localNames = _localDeclarationNames(); for (final helperName in _generatedHelperNames) { if (!localNames.contains(helperName)) continue; throw InvalidGenerationSource( @@ -1006,6 +1005,88 @@ final class SchemaModelGraphBuilder { } } + void _validateDelegatedHelperNames() { + final localNames = _localDeclarationNames(); + for (final node in _graph.nodes) { + final declaration = _declarationsById[node.id]; + if (declaration == null) continue; + switch (node) { + case AckUnionModelNode(): + continue; + case AckValueModelNode(): + _validateClassHelperNames( + className: node.className, + fieldNames: const ['value'], + path: node.id.declarationName, + element: declaration.element, + localNames: localNames, + ); + case AckObjectModelNode(): + _validateClassHelperNames( + className: node.className, + fieldNames: [ + for (final field in node.fields) + if (field.jsonKey != node.discriminatorKey) field.dartName, + if (node.additionalProperties) 'additionalProperties', + ], + path: node.id.declarationName, + element: declaration.element, + localNames: localNames, + ); + } + } + } + + void _validateClassHelperNames({ + required String className, + required List fieldNames, + required String path, + required Element element, + required Set localNames, + }) { + final ownerByBridge = {}; + for (final fieldName in fieldNames) { + for (final bridgeName in ackFieldBridgeNames(fieldName)) { + final owner = ownerByBridge[bridgeName]; + if (owner != null) { + throw InvalidGenerationSource( + '$path.$fieldName generates helper "$bridgeName" that conflicts ' + 'with $path.$owner.', + element: element, + ); + } + if (fieldNames.contains(bridgeName)) { + throw InvalidGenerationSource( + '$path.$fieldName generates helper "$bridgeName" that conflicts ' + 'with a stored field.', + element: element, + ); + } + if (_reservedMembers.contains(bridgeName)) { + throw InvalidGenerationSource( + '$path.$fieldName generates helper "$bridgeName" that conflicts ' + 'with a generated member.', + element: element, + ); + } + ownerByBridge[bridgeName] = fieldName; + } + } + + for (final helperName in ackJsonHelperNames(className)) { + if (!localNames.contains(helperName)) continue; + throw InvalidGenerationSource( + 'Generated helper "$helperName" conflicts with a local declaration.', + element: element, + ); + } + } + + Set _localDeclarationNames() => { + for (final element in library.allElements) + if (element.name case final name?) name, + }; + void _validateUnionBranchDiscriminator( _Declaration branch, String discriminatorKey, diff --git a/packages/ack_generator/lib/src/builder.dart b/packages/ack_generator/lib/src/builder.dart index e6c8a5c6..77e47042 100644 --- a/packages/ack_generator/lib/src/builder.dart +++ b/packages/ack_generator/lib/src/builder.dart @@ -2,8 +2,19 @@ import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart'; import 'generator.dart'; +import 'json/ack_json_generator.dart'; /// Creates the dedicated-part builder for Ack model generation. Builder ackGenerator(BuilderOptions options) { return PartBuilder([AckSchemaGenerator()], '.ack.dart', options: options); } + +/// Creates the cache-only JSON fragment builder for Ack-marked models. +/// +/// [options] are ignored so consumer `json_serializable` settings cannot +/// change Ack runtime-map semantics. +Builder ackJsonSerializableBuilder(BuilderOptions options) { + return SharedPartBuilder([ + AckJsonSerializableGenerator(), + ], 'ack_json_serializable'); +} diff --git a/packages/ack_generator/lib/src/builders/model_emitter.dart b/packages/ack_generator/lib/src/builders/model_emitter.dart index bde25c54..4eaf54af 100644 --- a/packages/ack_generator/lib/src/builders/model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/model_emitter.dart @@ -1,12 +1,14 @@ import 'package:code_builder/code_builder.dart'; +import '../json/helper_names.dart'; import '../models/schema_model_graph.dart'; /// Emits immutable model declarations solely from a normalized model graph. final class AckModelEmitter { - AckModelEmitter({this.ackPrefix}); + AckModelEmitter({this.ackPrefix, this.ackTypePrefix}); final String? ackPrefix; + final String? ackTypePrefix; List emit(AckModelGraph graph) { final nodes = {for (final node in graph.nodes) node.id: node}; @@ -39,18 +41,20 @@ final class AckModelEmitter { } Class _object(AckObjectModelNode node) { + final fields = _storedFields(node); return Class( (b) => b ..name = node.className ..modifier = ClassModifier.final$ + ..annotations.add(_jsonMarker()) ..docs.addAll(_docs(node, 'Immutable model')) ..fields.addAll([ - for (final field in node.fields) _field(field), + for (final field in fields) _field(field), if (node.additionalProperties) _additionalPropertiesField(), _adapter(node, node.id.declarationName), ]) ..constructors.addAll([ - _objectConstructor(node.fields, node.additionalProperties), + _objectConstructor(fields, node.additionalProperties), _parseFactory(), _fromJsonFactory(_objectJsonType), ]) @@ -58,8 +62,10 @@ final class AckModelEmitter { _safeParse(node.className), _objectToJson(), _objectSafeToJson(), - _objectFromRuntime(node), - _objectToRuntime(node), + _objectFromRuntime(node, fields: fields), + _objectToRuntime(node, fields: fields), + ..._fieldBridges(fields), + if (node.additionalProperties) ..._additionalPropertyBridges(), ]), ); } @@ -71,6 +77,7 @@ final class AckModelEmitter { (b) => b ..name = node.className ..modifier = ClassModifier.final$ + ..annotations.add(_jsonMarker()) ..docs.addAll(_docs(node, 'Immutable value model')) ..fields.addAll([ Field( @@ -115,15 +122,20 @@ final class AckModelEmitter { ), ) ..lambda = true - ..body = Code('${node.className}(value)'), + ..body = Code( + '${jsonFromHelperName(node.className)}({\'value\': value})', + ), ), Method( (m) => m ..name = '_toAckRuntime' ..returns = refer(runtimeRef) ..lambda = true - ..body = const Code('value'), + ..body = Code( + '${jsonToHelperName(node.className)}(this)[\'value\'] as $runtimeRef', + ), ), + ..._valueBridges(node), ]), ); } @@ -189,14 +201,13 @@ return switch (value[${_literal(node.discriminatorKey)}]) { Class _branch(AckObjectModelNode node, AckUnionModelNode union) { final discriminator = node.discriminatorKey!; final value = node.discriminatorValue!; - final fields = node.fields - .where((field) => field.jsonKey != discriminator) - .toList(); + final fields = _storedFields(node); return Class( (b) => b ..name = node.className ..modifier = ClassModifier.final$ ..extend = refer(union.className) + ..annotations.add(_jsonMarker()) ..docs.addAll(_docs(node, 'Discriminated model branch')) ..fields.addAll([ for (final field in fields) _field(field), @@ -233,6 +244,8 @@ return switch (value[${_literal(node.discriminatorKey)}]) { leadingEntries: {discriminator: _literal(value)}, isOverride: true, ), + ..._fieldBridges(fields), + if (node.additionalProperties) ..._additionalPropertyBridges(), ]), ); } @@ -403,13 +416,30 @@ ${_ack('AckModelAdapter')}( List? fields, Set additionalKnownKeys = const {}, }) { - final effectiveFields = fields ?? node.fields; - final arguments = [ - for (final field in effectiveFields) - '${field.dartName}: ${_decodeField(field)}', - if (node.additionalProperties) - 'additionalProperties: ${_additionalPropertiesDecode(effectiveFields, additionalKnownKeys)}', - ]; + final helper = jsonFromHelperName(node.className); + if (!node.additionalProperties) { + return Method( + (m) => m + ..name = '_fromAckRuntime' + ..static = true + ..returns = refer(node.className) + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..type = refer(_runtimeMapType), + ), + ) + ..lambda = true + ..body = Code('$helper(Map.from(value))'), + ); + } + + final effectiveFields = fields ?? _storedFields(node); + final keys = { + ...additionalKnownKeys, + for (final field in effectiveFields) field.jsonKey, + }; return Method( (m) => m ..name = '_fromAckRuntime' @@ -423,9 +453,13 @@ ${_ack('AckModelAdapter')}( ), ) ..body = Code(''' -return ${node.className}( - ${arguments.join(',\n ')}${arguments.isEmpty ? '' : ','} -);'''), +const declared = {${keys.map(_literal).join(', ')}}; +return $helper({ + ...value, + 'additionalProperties': Map.fromEntries( + value.entries.where((entry) => !declared.contains(entry.key)), + ), +});'''), ); } @@ -435,67 +469,193 @@ return ${node.className}( Map leadingEntries = const {}, bool isOverride = false, }) { - final effectiveFields = fields ?? node.fields; - final entries = [ - if (node.additionalProperties) '...additionalProperties', - for (final entry in leadingEntries.entries) - '${_literal(entry.key)}: ${entry.value}', - for (final field in effectiveFields) _encodeField(field), + final effectiveFields = fields ?? _storedFields(node); + final requiredNulls = [ + for (final field in effectiveFields) + if (field.isRequired && field.nullable) field, ]; + final helper = jsonToHelperName(node.className); + final needsBlock = node.additionalProperties || requiredNulls.isNotEmpty; + return Method((m) { m ..name = '_toAckRuntime' - ..returns = refer(_runtimeMapType) - ..body = Code(''' -return { - ${entries.join(',\n ')}${entries.isEmpty ? '' : ','} -};'''); + ..returns = refer(_runtimeMapType); if (isOverride) m.annotations.add(refer('override')); + + if (!needsBlock) { + final entries = [ + for (final entry in leadingEntries.entries) + '${_literal(entry.key)}: ${entry.value}', + '...$helper(this)', + ]; + m + ..lambda = true + ..body = Code('$_runtimeMapLiteral{${entries.join(', ')}}'); + return; + } + + final lines = [ + 'final result = $_runtimeMapLiteral{...$helper(this)};', + ]; + if (node.additionalProperties) { + lines.add("result.remove('additionalProperties');"); + } + for (final field in requiredNulls) { + lines.add( + 'if (${field.dartName} == null) {' + ' result[${_literal(field.jsonKey)}] = null;' + ' }', + ); + } + final returnEntries = [ + if (node.additionalProperties) '...additionalProperties', + for (final entry in leadingEntries.entries) + '${_literal(entry.key)}: ${entry.value}', + '...result', + ]; + lines.add( + 'return $_runtimeMapLiteral{\n ${returnEntries.join(',\n ')},\n};', + ); + m.body = Code(lines.join('\n')); }); } - String _decodeField(AckFieldNode field) { - final read = 'value[${_literal(field.jsonKey)}]'; - final decoded = _fromRuntime(field.runtimeRef, read); - if (field.isRequired && !field.nullable) return decoded; + List _fieldBridges(List fields) => [ + for (final field in fields) ...[_fromBridge(field), _toBridge(field)], + ]; + + List _valueBridges(AckValueModelNode node) { + final type = _type(node.runtimeRef); + return [ + Method( + (m) => m + ..name = ackFromRuntimeBridgeName('value') + ..static = true + ..returns = refer(type) + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..type = refer('Object?'), + ), + ) + ..lambda = true + ..body = Code(_fromRuntime(node.runtimeRef, 'value')), + ), + Method( + (m) => m + ..name = ackToRuntimeBridgeName('value') + ..static = true + ..returns = refer('Object?') + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..type = refer(type), + ), + ) + ..lambda = true + ..body = Code(_toRuntime(node.runtimeRef, 'value')), + ), + ]; + } + + List _additionalPropertyBridges() => [ + Method( + (m) => m + ..name = ackFromRuntimeBridgeName('additionalProperties') + ..static = true + ..returns = refer('$_runtimeMapType?') + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..type = refer('Object?'), + ), + ) + ..lambda = true + ..body = const Code('value as Map?'), + ), + Method( + (m) => m + ..name = ackToRuntimeBridgeName('additionalProperties') + ..static = true + ..returns = refer('Object?') + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..type = refer(_runtimeMapType), + ), + ) + ..lambda = true + ..body = const Code('value'), + ), + ]; + Method _fromBridge(AckFieldNode field) { final runtimeRef = _nonNullable(field.runtimeRef); - if (!_requiresRuntimeConversion(runtimeRef)) { - return '$read as ${_type(runtimeRef)}?'; + final needsNullGuard = !field.isRequired || field.nullable; + late final String body; + if (!needsNullGuard) { + body = _fromRuntime(runtimeRef, 'value'); + } else if (!_requiresRuntimeConversion(runtimeRef)) { + body = 'value as ${_type(runtimeRef)}?'; + } else { + body = + 'switch (value) {' + ' null => null,' + ' final fieldValue => ${_fromRuntime(runtimeRef, 'fieldValue')},' + ' }'; } - return 'switch ($read) {' - ' null => null,' - ' final fieldValue => ${_fromRuntime(runtimeRef, 'fieldValue')},' - ' }'; + return Method( + (m) => m + ..name = ackFromRuntimeBridgeName(field.dartName) + ..static = true + ..returns = refer(_fieldType(field)) + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..type = refer('Object?'), + ), + ) + ..lambda = true + ..body = Code(body), + ); } - String _encodeField(AckFieldNode field) { + Method _toBridge(AckFieldNode field) { final runtimeRef = _nonNullable(field.runtimeRef); - if (field.presence == AckFieldPresence.optional) { - return 'if (${field.dartName} != null) ${_literal(field.jsonKey)}: ${_toRuntime(runtimeRef, '${field.dartName}!')}'; - } - if (field.nullable && _requiresRuntimeConversion(runtimeRef)) { - return '${_literal(field.jsonKey)}: switch (${field.dartName}) {' + final needsNullGuard = !field.isRequired || field.nullable; + late final String body; + if (!needsNullGuard) { + body = _toRuntime(runtimeRef, 'value'); + } else if (!_requiresRuntimeConversion(runtimeRef)) { + body = 'value'; + } else { + body = + 'switch (value) {' ' null => null,' ' final fieldValue => ${_toRuntime(runtimeRef, 'fieldValue')},' ' }'; } - return '${_literal(field.jsonKey)}: ${_toRuntime(runtimeRef, field.dartName)}'; - } - - String _additionalPropertiesDecode( - List fields, - Set additionalKnownKeys, - ) { - final keys = { - ...additionalKnownKeys, - for (final field in fields) field.jsonKey, - }; - if (keys.isEmpty) return '_ackImmutableCopyMap(value)'; - return '_ackImmutableCopyMap(Map.fromEntries(' - 'value.entries.where((entry) => !const {' - '${keys.map(_literal).join(', ')}' - '}.contains(entry.key))))'; + return Method( + (m) => m + ..name = ackToRuntimeBridgeName(field.dartName) + ..static = true + ..returns = refer('Object?') + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'value' + ..type = refer(_fieldType(field)), + ), + ) + ..lambda = true + ..body = Code(body), + ); } String _fromRuntime(AckTypeRef type, String expression) { @@ -505,11 +665,11 @@ return { AckModelTypeRef(:final runtimeRef, :final visibleName) => '$visibleName.\$ack.fromRuntime($expression as ${_type(runtimeRef)})', AckListTypeRef(:final elementType) => - 'List<${_type(elementType)}>.unmodifiable(($expression as List).map((item) => ${_fromRuntime(elementType, 'item')}))', + '($expression as List).map((item) => ${_fromRuntime(elementType, 'item')}).toList()', AckSetTypeRef(:final elementType) => - 'Set<${_type(elementType)}>.unmodifiable(($expression as Set).map((item) => ${_fromRuntime(elementType, 'item')}))', + '($expression as Set).map((item) => ${_fromRuntime(elementType, 'item')}).toSet()', AckMapTypeRef(:final valueType) => - 'Map.unmodifiable(($expression as Map).map((key, item) => MapEntry(key as String, ${_fromRuntime(valueType, 'item')})))', + '($expression as Map).map((key, item) => MapEntry(key as String, ${_fromRuntime(valueType, 'item')}))', _ => '$expression as ${_type(type)}', }; } @@ -550,6 +710,15 @@ return { return field.runtimeRef is AckNullableTypeRef ? base : '$base?'; } + List _storedFields(AckObjectModelNode node) { + final discriminator = node.discriminatorKey; + if (discriminator == null) return node.fields; + return [ + for (final field in node.fields) + if (field.jsonKey != discriminator) field, + ]; + } + AckTypeRef _nonNullable(AckTypeRef type) => switch (type) { AckNullableTypeRef(:final inner) => inner, _ => type, @@ -633,6 +802,14 @@ Map.unmodifiable( if (node.description != null) '/// ${node.description}', ]; + Expression _jsonMarker() { + final prefix = ackTypePrefix; + final typeName = prefix == null || prefix.isEmpty + ? 'AckType' + : '$prefix.AckType'; + return refer(typeName).property('jsonSerializable'); + } + String _ack(String symbol) { final prefix = ackPrefix; return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; @@ -647,5 +824,6 @@ Map.unmodifiable( } static const _runtimeMapType = 'Map'; + static const _runtimeMapLiteral = ''; static const _objectJsonType = 'Map'; } diff --git a/packages/ack_generator/lib/src/generator.dart b/packages/ack_generator/lib/src/generator.dart index 7b047ecd..7cb99ed1 100644 --- a/packages/ack_generator/lib/src/generator.dart +++ b/packages/ack_generator/lib/src/generator.dart @@ -11,6 +11,9 @@ import 'builders/model_emitter.dart'; /// Generates immutable model classes for top-level schemas annotated with /// `@AckType`. final class AckSchemaGenerator extends Generator { + static const _ackAnnotationsUri = + 'package:ack_annotations/ack_annotations.dart'; + @override Future generate(LibraryReader library, BuildStep buildStep) async { final annotated = []; @@ -48,11 +51,12 @@ final class AckSchemaGenerator extends Generator { } if (annotated.isEmpty) return ''; - await _requireAckPartDirective(buildStep, annotated.first); + await _requirePartDirectives(buildStep, annotated.first); final graph = await SchemaModelGraphBuilder(library).build(annotated); final specs = AckModelEmitter( - ackPrefix: _ackImportPrefix(library), + ackPrefix: _importPrefix(library, 'package:ack/ack.dart'), + ackTypePrefix: _importPrefix(library, _ackAnnotationsUri), ).emit(graph); return Library((b) => b.body.addAll(specs)) .accept( @@ -68,30 +72,35 @@ final class AckSchemaGenerator extends Generator { bool _hasAckType(Element element) => TypeChecker.typeNamed(AckType).hasAnnotationOfExact(element); - Future _requireAckPartDirective( + Future _requirePartDirectives( BuildStep buildStep, Element annotatedElement, ) async { final inputName = buildStep.inputId.pathSegments.last; final baseName = inputName.substring(0, inputName.length - '.dart'.length); - final expectedPart = '$baseName.ack.dart'; + final expectedAckPart = '$baseName.ack.dart'; + final expectedJsonPart = '$baseName.g.dart'; final unit = await buildStep.resolver.compilationUnitFor(buildStep.inputId); - final hasExpectedPart = unit.directives.whereType().any( - (directive) => directive.uri.stringValue == expectedPart, - ); - if (hasExpectedPart) return; + final parts = { + for (final directive in unit.directives.whereType()) + if (directive.uri.stringValue case final uri?) uri, + }; + if (parts.contains(expectedAckPart) && parts.contains(expectedJsonPart)) { + return; + } throw InvalidGenerationSource( - "Ack model generation requires `part '$expectedPart';` in this library.", + "Ack model generation requires `part '$expectedAckPart';` and " + "`part '$expectedJsonPart';` in this library.", element: annotatedElement, - todo: "Add `part '$expectedPart';` next to the library's directives.", + todo: + "Add `part '$expectedAckPart';` and `part '$expectedJsonPart';` " + "next to the library's directives.", ); } - String? _ackImportPrefix(LibraryReader library) { + String? _importPrefix(LibraryReader library, String uri) { for (final import in library.element.firstFragment.libraryImports) { - if (import.importedLibrary?.uri.toString() != 'package:ack/ack.dart') { - continue; - } + if (import.importedLibrary?.uri.toString() != uri) continue; return import.prefix?.element.name; } return null; diff --git a/packages/ack_generator/lib/src/json/ack_json_generator.dart b/packages/ack_generator/lib/src/json/ack_json_generator.dart new file mode 100644 index 00000000..8d4cf5c7 --- /dev/null +++ b/packages/ack_generator/lib/src/json/ack_json_generator.dart @@ -0,0 +1,41 @@ +import 'package:ack_annotations/ack_generator_support.dart'; +import 'package:build/build.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:json_serializable/json_serializable.dart'; +import 'package:source_gen/source_gen.dart'; + +import 'ack_runtime_type_helper.dart'; + +/// Delegates Ack-marked model classes to json_serializable. +/// +/// Consumer builder options are ignored. The class configuration comes from +/// the Ack-owned marker (`includeIfNull: false`) plus the same fixed +/// generator default. +final class AckJsonSerializableGenerator extends Generator { + AckJsonSerializableGenerator() + : _delegate = JsonSerializableGenerator.withDefaultHelpers(const [ + AckRuntimeTypeHelper(), + ], config: const JsonSerializable(includeIfNull: false)); + + final JsonSerializableGenerator _delegate; + + static final _marker = TypeChecker.typeNamed(AckGeneratedJson); + + @override + String generate(LibraryReader library, BuildStep buildStep) { + final annotated = library.annotatedWith(_marker).toList(); + if (annotated.isEmpty) return ''; + + final output = []; + for (final item in annotated) { + output.addAll( + _delegate.generateForAnnotatedElement( + item.element, + item.annotation.read('config'), + buildStep, + ), + ); + } + return output.join('\n\n'); + } +} diff --git a/packages/ack_generator/lib/src/json/ack_runtime_type_helper.dart b/packages/ack_generator/lib/src/json/ack_runtime_type_helper.dart new file mode 100644 index 00000000..c5341b9b --- /dev/null +++ b/packages/ack_generator/lib/src/json/ack_runtime_type_helper.dart @@ -0,0 +1,32 @@ +import 'package:analyzer/dart/element/type.dart'; +import 'package:json_serializable/type_helper.dart'; + +import 'helper_names.dart'; + +/// Routes every stored Ack field through generated runtime bridge methods. +/// +/// The input is an already-validated Ack runtime value, so this helper must +/// claim scalars as well as nested models. Default json_serializable codecs +/// would otherwise decode DateTime, Uri, enums, or nested JSON a second time. +final class AckRuntimeTypeHelper extends TypeHelper { + const AckRuntimeTypeHelper(); + + @override + Object? serialize( + DartType targetType, + String expression, + TypeHelperContext context, + ) { + return '${context.classElement.name}.${ackToRuntimeBridgeName(context.fieldElement.name!)}($expression)'; + } + + @override + Object? deserialize( + DartType targetType, + String expression, + TypeHelperContext context, + bool defaultProvided, + ) { + return '${context.classElement.name}.${ackFromRuntimeBridgeName(context.fieldElement.name!)}($expression)'; + } +} diff --git a/packages/ack_generator/lib/src/json/helper_names.dart b/packages/ack_generator/lib/src/json/helper_names.dart new file mode 100644 index 00000000..5439682b --- /dev/null +++ b/packages/ack_generator/lib/src/json/helper_names.dart @@ -0,0 +1,31 @@ +/// Pascal-case first letter used by per-field Ack runtime bridges. +String ackBridgePascal(String fieldName) { + if (fieldName.isEmpty) return fieldName; + return '${fieldName[0].toUpperCase()}${fieldName.substring(1)}'; +} + +/// Decode-side runtime bridge for a stored field. +String ackFromRuntimeBridgeName(String fieldName) => + '_ackFromRuntime${ackBridgePascal(fieldName)}'; + +/// Encode-side runtime bridge for a stored field. +String ackToRuntimeBridgeName(String fieldName) => + '_ackToRuntime${ackBridgePascal(fieldName)}'; + +/// json_serializable `fromJson` helper for a generated model class. +String jsonFromHelperName(String className) => '_\$${className}FromJson'; + +/// json_serializable `toJson` helper for a generated model class. +String jsonToHelperName(String className) => '_\$${className}ToJson'; + +/// All per-field bridges derived from [fieldName]. +Iterable ackFieldBridgeNames(String fieldName) sync* { + yield ackFromRuntimeBridgeName(fieldName); + yield ackToRuntimeBridgeName(fieldName); +} + +/// All top-level JSON helpers derived from [className]. +Iterable ackJsonHelperNames(String className) sync* { + yield jsonFromHelperName(className); + yield jsonToHelperName(className); +} diff --git a/packages/ack_generator/pubspec.yaml b/packages/ack_generator/pubspec.yaml index 1adaa1c9..192ee1b7 100644 --- a/packages/ack_generator/pubspec.yaml +++ b/packages/ack_generator/pubspec.yaml @@ -12,8 +12,11 @@ dependencies: # Core code generation dependencies analyzer: ">=10.0.0 <15.0.0" build: ^4.0.0 + build_config: ^1.3.0 source_gen: ^4.2.4 code_builder: ^4.10.0 + json_annotation: ^4.12.0 + json_serializable: ^6.14.1 # Ack packages (versions are overridden locally by Melos) ack: ^1.0.0 @@ -27,8 +30,6 @@ dependencies: dev_dependencies: build_runner: ^2.1.7 build_test: ^3.1.0 - json_annotation: ^4.12.0 - json_serializable: ^6.14.1 test: ^1.25.15 path: ^1.9.0 # Code quality diff --git a/packages/ack_generator/test/integration/example_folder_build_test.dart b/packages/ack_generator/test/integration/example_folder_build_test.dart index a3b15ae5..2280b9c2 100644 --- a/packages/ack_generator/test/integration/example_folder_build_test.dart +++ b/packages/ack_generator/test/integration/example_folder_build_test.dart @@ -7,13 +7,15 @@ void _copyDirectory(Directory source, Directory destination) { destination.createSync(recursive: true); for (final entity in source.listSync()) { final name = p.basename(entity.path); - if (name == '.dart_tool' || name == 'build' || name.endsWith('.g.dart')) { + if (name == '.dart_tool' || name == 'build') { continue; } final target = p.join(destination.path, name); if (entity is Directory) { _copyDirectory(entity, Directory(target)); - } else if (entity is File && !name.endsWith('.ack.dart')) { + } else if (entity is File && + !name.endsWith('.ack.dart') && + !name.endsWith('.g.dart')) { entity.copySync(target); } } @@ -37,7 +39,11 @@ Map _generatedContents(Directory directory) => { in directory .listSync(recursive: true) .whereType() - .where((file) => file.path.endsWith('.ack.dart'))) + .where( + (file) => + file.path.endsWith('.ack.dart') || + file.path.endsWith('.g.dart'), + )) p.relative(file.path, from: directory.path): file.readAsStringSync(), }; @@ -104,19 +110,33 @@ dependency_overrides: final first = _generatedContents(temporaryExample); expect(first.keys, { 'lib/args_getter_example.ack.dart', + 'lib/args_getter_example.g.dart', 'lib/pet.ack.dart', + 'lib/pet.g.dart', 'lib/schema_types_discriminated.ack.dart', + 'lib/schema_types_discriminated.g.dart', 'lib/schema_types_edge_cases.ack.dart', + 'lib/schema_types_edge_cases.g.dart', 'lib/schema_types_primitives.ack.dart', + 'lib/schema_types_primitives.g.dart', 'lib/schema_types_simple.ack.dart', + 'lib/schema_types_simple.g.dart', 'lib/schema_types_transforms.ack.dart', + 'lib/schema_types_transforms.g.dart', 'lib/user_with_color.ack.dart', + 'lib/user_with_color.g.dart', }); - for (final content in first.values) { - expect(content, contains('class ')); - expect(content, isNot(contains('extension type'))); - expect(content, isNot(contains('fromMap'))); - expect(content, isNot(contains('toMap'))); + for (final entry in first.entries) { + if (entry.key.endsWith('.ack.dart')) { + expect(entry.value, contains('class ')); + expect(entry.value, contains('jsonSerializable')); + expect(entry.value, isNot(contains('extension type'))); + expect(entry.value, isNot(contains('fromMap'))); + expect(entry.value, isNot(contains('toMap'))); + } else { + expect(entry.value, contains('JsonSerializableGenerator')); + expect(entry.value, contains('_ackFromRuntime')); + } } _expectSuccess( diff --git a/packages/ack_generator/test/integration/json_serializable_build_test.dart b/packages/ack_generator/test/integration/json_serializable_build_test.dart index b11bae25..33426076 100644 --- a/packages/ack_generator/test/integration/json_serializable_build_test.dart +++ b/packages/ack_generator/test/integration/json_serializable_build_test.dart @@ -3,23 +3,120 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; +Directory _projectRoot() { + var projectRoot = Directory.current; + while (!Directory( + p.join(projectRoot.path, 'packages', 'ack_generator'), + ).existsSync()) { + projectRoot = projectRoot.parent; + } + return projectRoot; +} + +Future _run(Directory directory, List arguments) { + return Process.run('dart', arguments, workingDirectory: directory.path); +} + +void _expectSuccess(ProcessResult result, String command) { + expect( + result.exitCode, + 0, + reason: + '$command failed\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}', + ); +} + +int _helperPairCount(String source, String className) { + return RegExp('_\\\$${className}FromJson').allMatches(source).length; +} + void main() { test( - 'json_serializable resolves same-file and cross-file generated Ack models', + 'ack_generator alone generates json_serializable Ack helpers', () async { - var projectRoot = Directory.current; - while (!Directory( - p.join(projectRoot.path, 'packages', 'ack_generator'), - ).existsSync()) { - projectRoot = projectRoot.parent; + final projectRoot = _projectRoot(); + final temporary = await Directory.systemTemp.createTemp( + 'ack_json_ack_only_', + ); + try { + Directory(p.join(temporary.path, 'lib')).createSync(); + File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' +name: ack_json_ack_only +publish_to: none +environment: + sdk: '>=3.9.0 <4.0.0' +dependencies: + ack: + path: ${p.join(projectRoot.path, 'packages', 'ack')} + ack_annotations: + path: ${p.join(projectRoot.path, 'packages', 'ack_annotations')} +dev_dependencies: + ack_generator: + path: ${p.join(projectRoot.path, 'packages', 'ack_generator')} + build_runner: ^2.15.0 +dependency_overrides: + ack: + path: ${p.join(projectRoot.path, 'packages', 'ack')} + ack_annotations: + path: ${p.join(projectRoot.path, 'packages', 'ack_annotations')} +'''); + File(p.join(temporary.path, 'lib', 'user.dart')).writeAsStringSync(r''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'user.ack.dart'; +part 'user.g.dart'; + +@AckType() +final userSchema = Ack.object({ + 'name': Ack.string(), + 'createdAt': Ack.datetime(), +}); +'''); + + _expectSuccess(await _run(temporary, ['pub', 'get']), 'dart pub get'); + _expectSuccess( + await _run(temporary, ['run', 'build_runner', 'build']), + 'clean build_runner build', + ); + + final ackPart = File( + p.join(temporary.path, 'lib', 'user.ack.dart'), + ).readAsStringSync(); + final jsonPart = File( + p.join(temporary.path, 'lib', 'user.g.dart'), + ).readAsStringSync(); + + expect(ackPart, contains('@AckType.jsonSerializable')); + expect(ackPart, contains(r'_$UserFromJson')); + expect(ackPart, contains('_ackFromRuntimeCreatedAt')); + expect(jsonPart, contains('JsonSerializableGenerator')); + expect(jsonPart, contains('User._ackFromRuntimeName(json[\'name\'])')); + expect( + jsonPart, + contains('User._ackFromRuntimeCreatedAt(json[\'createdAt\'])'), + ); + expect(jsonPart, contains('User._ackToRuntimeName(instance.name)')); + expect(_helperPairCount(jsonPart, 'User'), 1); + expect(jsonPart, isNot(contains("value['name']"))); + } finally { + temporary.deleteSync(recursive: true); } + }, + timeout: const Timeout(Duration(minutes: 3)), + ); + + test( + 'ordinary json_serializable coexists without duplicate Ack helpers', + () async { + final projectRoot = _projectRoot(); final temporary = await Directory.systemTemp.createTemp( - 'ack_json_build_', + 'ack_json_coexist_', ); try { Directory(p.join(temporary.path, 'lib')).createSync(); File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' -name: ack_json_build +name: ack_json_coexist publish_to: none environment: sdk: '>=3.9.0 <4.0.0' @@ -39,6 +136,14 @@ dependency_overrides: path: ${p.join(projectRoot.path, 'packages', 'ack')} ack_annotations: path: ${p.join(projectRoot.path, 'packages', 'ack_annotations')} +'''); + File(p.join(temporary.path, 'build.yaml')).writeAsStringSync(''' +targets: + \$default: + builders: + json_serializable: + options: + include_if_null: true '''); File(p.join(temporary.path, 'lib', 'same.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; @@ -49,80 +154,44 @@ part 'same.ack.dart'; part 'same.g.dart'; @AckType() -final userSchema = Ack.object({'name': Ack.string()}); +final userSchema = Ack.object({ + 'name': Ack.string(), + 'nickname': Ack.string().optional(), +}); -@JsonSerializable(explicitToJson: true) +@JsonSerializable() final class SameEnvelope { - const SameEnvelope(this.user); + const SameEnvelope({required this.count, this.label}); factory SameEnvelope.fromJson(Map json) => _$SameEnvelopeFromJson(json); - final User user; + final int count; + final String? label; Map toJson() => _$SameEnvelopeToJson(this); } -'''); - File(p.join(temporary.path, 'lib', 'address.dart')).writeAsStringSync( - r''' -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; - -part 'address.ack.dart'; - -@AckType() -final addressSchema = Ack.object({'city': Ack.string()}); -''', - ); - File(p.join(temporary.path, 'lib', 'cross.dart')).writeAsStringSync(r''' -import 'package:json_annotation/json_annotation.dart'; - -import 'address.dart'; - -part 'cross.g.dart'; - -@JsonSerializable(explicitToJson: true) -final class CrossEnvelope { - const CrossEnvelope(this.address); - factory CrossEnvelope.fromJson(Map json) => - _$CrossEnvelopeFromJson(json); - final Address address; - Map toJson() => _$CrossEnvelopeToJson(this); -} '''); - final pubGet = await Process.run('dart', [ - 'pub', - 'get', - ], workingDirectory: temporary.path); - expect( - pubGet.exitCode, - 0, - reason: '${pubGet.stdout}\n${pubGet.stderr}', + _expectSuccess(await _run(temporary, ['pub', 'get']), 'dart pub get'); + _expectSuccess( + await _run(temporary, ['run', 'build_runner', 'build']), + 'build_runner build', ); - final build = await Process.run('dart', [ - 'run', - 'build_runner', - 'build', - ], workingDirectory: temporary.path); - expect(build.exitCode, 0, reason: '${build.stdout}\n${build.stderr}'); - final analyze = await Process.run('dart', [ - 'analyze', - '--fatal-infos', - ], workingDirectory: temporary.path); - expect( - analyze.exitCode, - 0, - reason: '${analyze.stdout}\n${analyze.stderr}', + _expectSuccess( + await _run(temporary, ['analyze', '--fatal-infos']), + 'dart analyze --fatal-infos', ); - final sameJson = File( + final combined = File( p.join(temporary.path, 'lib', 'same.g.dart'), ).readAsStringSync(); - final crossJson = File( - p.join(temporary.path, 'lib', 'cross.g.dart'), - ).readAsStringSync(); - expect(sameJson, contains('User.fromJson')); - expect(sameJson, contains('.toJson()')); - expect(crossJson, contains('Address.fromJson')); - expect(crossJson, contains('.toJson()')); + expect(_helperPairCount(combined, 'User'), 1); + expect(_helperPairCount(combined, 'SameEnvelope'), 1); + expect(combined, contains('User._ackFromRuntimeName(json[\'name\'])')); + expect(combined, contains('JsonSerializableGenerator')); + expect( + combined.contains("'label': instance.label") || + combined.contains("'label': ?instance.label"), + isTrue, + ); } finally { temporary.deleteSync(recursive: true); } diff --git a/packages/ack_generator/test/integration/v2_contract_test.dart b/packages/ack_generator/test/integration/v2_contract_test.dart index 94b55301..2a56a4da 100644 --- a/packages/ack_generator/test/integration/v2_contract_test.dart +++ b/packages/ack_generator/test/integration/v2_contract_test.dart @@ -26,6 +26,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'schema.ack.dart'; +part 'schema.g.dart'; '''; void main() { @@ -45,6 +46,7 @@ final userSchema = Ack.object({ 'test_pkg|lib/schema.ack.dart': decodedMatches( allOf([ contains('final class User'), + contains('@AckType.jsonSerializable'), contains('factory User.parse(Object? input)'), contains('factory User.fromJson(Map json)'), contains(r'static final $ack = AckModelAdapter'), @@ -57,8 +59,14 @@ final userSchema = Ack.object({ contains('this.nickname'), contains('required this.middleName'), contains('required this.role'), - contains("if (nickname != null) 'nickname': nickname"), - contains("'middleName': middleName"), + contains(r'_$UserFromJson'), + contains(r'_$UserToJson'), + contains('_ackFromRuntimeName'), + contains('_ackToRuntimeName'), + contains('if (middleName == null)'), + contains("result['middleName'] = null"), + isNot(contains("if (nickname != null) 'nickname': nickname")), + isNot(contains("value['name']")), ]), ), }, @@ -76,9 +84,14 @@ final occurredAtSchema = Ack.datetime(); 'test_pkg|lib/schema.ack.dart': decodedMatches( allOf([ contains('final DateTime value;'), + contains('@AckType.jsonSerializable'), contains('factory OccurredAt.fromJson(String json)'), contains('String toJson()'), contains('SchemaResult safeToJson()'), + contains( + r"_$OccurredAtFromJson({'value': value})", + ), + contains(r"_$OccurredAtToJson(this)['value'] as DateTime"), ]), ), }, diff --git a/packages/ack_generator/test/integration/v2_graph_test.dart b/packages/ack_generator/test/integration/v2_graph_test.dart index 1d45e69c..ea8c7af8 100644 --- a/packages/ack_generator/test/integration/v2_graph_test.dart +++ b/packages/ack_generator/test/integration/v2_graph_test.dart @@ -41,6 +41,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'schema.ack.dart'; +part 'schema.g.dart'; '''; void main() { diff --git a/packages/ack_generator/test/integration/v2_models_test.dart b/packages/ack_generator/test/integration/v2_models_test.dart index 83224061..a1b62e11 100644 --- a/packages/ack_generator/test/integration/v2_models_test.dart +++ b/packages/ack_generator/test/integration/v2_models_test.dart @@ -39,6 +39,7 @@ void main() { ''' $_imports part 'empty.ack.dart'; +part 'empty.g.dart'; @AckType() final emptySchema = Ack.object({}); @@ -48,8 +49,9 @@ final emptySchema = Ack.object({}); 'test_pkg|lib/empty.ack.dart': decodedMatches( allOf([ contains('Empty()'), - contains('return Empty();'), - contains('return {};'), + contains('@AckType.jsonSerializable'), + contains(r'_$EmptyFromJson'), + contains(r'_$EmptyToJson'), isNot(contains('\n ,')), ]), ), @@ -67,6 +69,7 @@ final emptySchema = Ack.object({}); ''' $_imports part 'values.ack.dart'; +part 'values.g.dart'; enum Role { admin, member } @@ -101,6 +104,7 @@ final metricsSchema = Ack.object({ ''' $_imports part 'address.ack.dart'; +part 'address.g.dart'; @AckType() final addressSchema = Ack.object({'city': Ack.string()}); @@ -112,6 +116,7 @@ $_imports import 'address.dart' as direct; import 'exports.dart' as exported; part 'person.ack.dart'; +part 'person.g.dart'; @AckType() final personSchema = Ack.object({ @@ -128,6 +133,8 @@ final personSchema = Ack.object({ allOf([ contains('required this.home'), contains('required List history'), + contains(r'_$PersonFromJson'), + contains('_ackFromRuntimeHome'), contains(r'direct.Address.$ack.fromRuntime'), contains(r'exported.Address.$ack.toRuntime'), ]), @@ -143,6 +150,7 @@ final personSchema = Ack.object({ ''' $_imports part 'pet.ack.dart'; +part 'pet.g.dart'; @AckType() final catSchema = Ack.object({'kind': Ack.literal('cat'), 'lives': Ack.integer()}); @@ -163,9 +171,13 @@ final petSchema = Ack.discriminated( contains('sealed class Pet'), contains('final class Cat extends Pet'), contains('final class Dog extends Pet'), + isNot(contains('@AckType.jsonSerializable\nsealed class Pet')), + contains('@AckType.jsonSerializable\nfinal class Cat extends Pet'), contains("String get kind => 'cat';"), contains("'kind': 'dog'"), contains('...additionalProperties'), + contains(r'_$CatFromJson'), + contains(r'_$DogToJson'), ]), ), }, @@ -182,6 +194,7 @@ final petSchema = Ack.discriminated( ''' $_imports part 'bad.ack.dart'; +part 'bad.g.dart'; @AckType() final badSchema = Ack.object({'toJson': Ack.string()}); @@ -204,6 +217,7 @@ final badSchema = Ack.object({'toJson': Ack.string()}); ''' $_imports part 'bad.ack.dart'; +part 'bad.g.dart'; @AckType() final badSchema = Ack.object({'class': Ack.string()}); @@ -227,6 +241,7 @@ final badSchema = Ack.object({'class': Ack.string()}); ''' $_imports part 'bad.ack.dart'; +part 'bad.g.dart'; @AckType() final catSchema = Ack.object({'lives': Ack.integer()}); @@ -255,6 +270,7 @@ final petSchema = Ack.discriminated( ''' $_imports part 'bad.ack.dart'; +part 'bad.g.dart'; @AckType() final catSchema = Ack.object({ @@ -285,6 +301,7 @@ final petSchema = Ack.discriminated( ''' $_imports part 'bad.ack.dart'; +part 'bad.g.dart'; Object? _ackImmutableCopyValue(Object? value) => value; @@ -299,4 +316,76 @@ final bagSchema = Ack.object({}).passthrough(); ); expect(messages.single, contains('_ackImmutableCopyValue')); }); + + test('preserves a prefixed AckType qualifier on the JSON marker', () async { + await _build( + { + 'schema.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart' as annotations; + +part 'schema.ack.dart'; +part 'schema.g.dart'; + +@annotations.AckType() +final userSchema = Ack.object({'name': Ack.string()}); +''', + }, + outputs: { + 'test_pkg|lib/schema.ack.dart': decodedMatches( + contains('@annotations.AckType.jsonSerializable'), + ), + }, + ); + }); + + test('rejects field names that collide after bridge derivation', () async { + final messages = {}; + await _build( + { + 'bad.dart': + ''' +$_imports +part 'bad.ack.dart'; +part 'bad.g.dart'; + +@AckType() +final badSchema = Ack.object({ + 'name': Ack.string(), + 'Name': Ack.string(), +}); +''', + }, + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE') messages.add(log.message); + }, + ); + expect(messages.single, contains('badSchema.Name')); + expect(messages.single, contains('_ackFromRuntimeName')); + }); + + test('rejects top-level JSON helper name collisions', () async { + final messages = {}; + await _build( + { + 'bad.dart': + ''' +$_imports +part 'bad.ack.dart'; +part 'bad.g.dart'; + +void _\$UserFromJson() {} + +@AckType() +final userSchema = Ack.object({'name': Ack.string()}); +''', + }, + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE') messages.add(log.message); + }, + ); + expect(messages.single, contains(r'_$UserFromJson')); + }); } diff --git a/packages/ack_generator/test/integration/v2_runtime_build_test.dart b/packages/ack_generator/test/integration/v2_runtime_build_test.dart index 35a928a7..29e4af91 100644 --- a/packages/ack_generator/test/integration/v2_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/v2_runtime_build_test.dart @@ -58,6 +58,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'models.ack.dart'; +part 'models.g.dart'; final class Box { const Box(this.values); @@ -123,6 +124,41 @@ final petSchema = Ack.discriminated( @AckType(name: 'MemberType') final memberSchema = Ack.string(); + +@AckType() +final emptySchema = Ack.object({}); + +@AckType() +final scoresSchema = Ack.list(Ack.integer()); + +final class Counted { + Counted(this.value); + final String value; + static var decodes = 0; + static var encodes = 0; + static Counted decode(String value) { + decodes += 1; + return Counted(value); + } + static String encode(Counted value) { + encodes += 1; + return value.value; + } +} + +@AckType(name: 'CountedModel') +final countedSchema = Ack.object({ + 'item': Ack.string().codec( + decode: Counted.decode, + encode: Counted.encode, + ), + 'items': Ack.list( + Ack.string().codec( + decode: Counted.decode, + encode: Counted.encode, + ), + ), +}); ''', ); File(p.join(temporary.path, 'lib', 'address.dart')).writeAsStringSync( @@ -131,6 +167,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'address.ack.dart'; +part 'address.g.dart'; @AckType() final addressSchema = Ack.object({'city': Ack.string()}); @@ -148,6 +185,7 @@ import 'address.dart' as direct; import 'exports.dart' as exported; part 'person.ack.dart'; +part 'person.g.dart'; @AckType() final personSchema = Ack.object({ @@ -244,6 +282,28 @@ void main() { final user = UserRecord.parse({'name': 'Ada'}); expect(user.value.name, 'Ada'); expect(user.toJson(), {'name': 'Ada'}); + + expect(Empty.parse({}).toJson(), isEmpty); + final scores = Scores.parse([1, 2]); + expect(scores.value, [1, 2]); + expect(scores.toJson(), [1, 2]); + expect(() => scores.value.add(3), throwsUnsupportedError); + + Counted.decodes = 0; + Counted.encodes = 0; + final counted = CountedModel.parse({ + 'item': 'a', + 'items': ['b', 'c'], + }); + expect(Counted.decodes, 3); + expect(counted.item.value, 'a'); + expect(counted.items.map((item) => item.value), ['b', 'c']); + expect(counted.toJson(), { + 'item': 'a', + 'items': ['b', 'c'], + }); + expect(Counted.encodes, 3); + expect(Counted.decodes, 3); }); } '''); @@ -253,11 +313,54 @@ void main() { await _run(temporary, ['run', 'build_runner', 'build']), 'build_runner build', ); + + final generated = { + for (final file + in temporary + .listSync(recursive: true) + .whereType() + .where( + (file) => + file.path.endsWith('.ack.dart') || + file.path.endsWith('.g.dart'), + )) + p.relative(file.path, from: temporary.path): file + .readAsStringSync(), + }; + expect( + generated.keys, + containsAll(['lib/models.ack.dart', 'lib/models.g.dart']), + ); + expect(generated['lib/models.g.dart'], contains(r'_$ExtrasFromJson')); + expect( + generated['lib/models.g.dart'], + contains('Extras._ackFromRuntimeName'), + ); + expect(generated['lib/models.ack.dart'], contains(r'_$ExtrasFromJson')); + _expectSuccess( await _run(temporary, ['analyze', '--fatal-infos']), 'dart analyze --fatal-infos', ); _expectSuccess(await _run(temporary, ['test']), 'dart test'); + _expectSuccess( + await _run(temporary, ['run', 'build_runner', 'build']), + 'second build_runner build', + ); + final rebuilt = { + for (final file + in temporary + .listSync(recursive: true) + .whereType() + .where( + (file) => + file.path.endsWith('.ack.dart') || + file.path.endsWith('.g.dart'), + )) + p.relative(file.path, from: temporary.path): file + .readAsStringSync(), + }; + expect(rebuilt, generated); } finally { temporary.deleteSync(recursive: true); } diff --git a/packages/ack_generator/test/src/generator_test.dart b/packages/ack_generator/test/src/generator_test.dart index b5d2ac26..1441efe2 100644 --- a/packages/ack_generator/test/src/generator_test.dart +++ b/packages/ack_generator/test/src/generator_test.dart @@ -31,6 +31,7 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'schema.ack.dart'; +part 'schema.g.dart'; @AckType() final userSchema = Ack.object({'name': Ack.string()}); @@ -52,7 +53,7 @@ final userSchema = Ack.object({'name': Ack.string()}); await _build('final value = 1;', outputs: const {}); }); - test('reports the exact required part directive', () async { + test('reports the exact required part directives', () async { var sawError = false; await _build( ''' @@ -65,7 +66,58 @@ final userSchema = Ack.string(); outputs: const {}, onLog: (log) { if (log.level.name == 'SEVERE' && - log.message.contains("part 'schema.ack.dart';")) { + log.message.contains("part 'schema.ack.dart';") && + log.message.contains("part 'schema.g.dart';")) { + sawError = true; + } + }, + ); + expect(sawError, isTrue); + }); + + test( + 'rejects a missing JSON part even when the Ack part is present', + () async { + var sawError = false; + await _build( + ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'schema.ack.dart'; + +@AckType() +final userSchema = Ack.string(); +''', + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && + log.message.contains("part 'schema.g.dart';")) { + sawError = true; + } + }, + ); + expect(sawError, isTrue); + }, + ); + + test('rejects a JSON part that does not match the basename', () async { + var sawError = false; + await _build( + ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'schema.ack.dart'; +part 'other.g.dart'; + +@AckType() +final userSchema = Ack.string(); +''', + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && + log.message.contains("part 'schema.g.dart';")) { sawError = true; } }, diff --git a/packages/ack_generator/test/src/json_builder_test.dart b/packages/ack_generator/test/src/json_builder_test.dart new file mode 100644 index 00000000..1c5967a4 --- /dev/null +++ b/packages/ack_generator/test/src/json_builder_test.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:ack_generator/src/builder.dart'; +import 'package:ack_generator/src/json/helper_names.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + test('build.yaml encodes the two-phase Ack JSON contract', () { + var directory = Directory.current; + if (!File(p.join(directory.path, 'build.yaml')).existsSync()) { + directory = Directory( + p.join(directory.path, 'packages', 'ack_generator'), + ); + } + final yaml = File(p.join(directory.path, 'build.yaml')).readAsStringSync(); + expect(yaml, contains('builder_factories: ["ackGenerator"]')); + expect(yaml, contains('builder_factories: ["ackJsonSerializableBuilder"]')); + expect(yaml, contains('.ack_json_serializable.g.part')); + expect(yaml, contains('required_inputs: [".ack.dart"]')); + expect(yaml, contains('source_gen|combining_builder')); + expect(yaml, contains('ack_generator|ack_json_serializable')); + expect(yaml, contains('json_serializable|json_serializable')); + expect(yaml, isNot(contains('jsonSerializableBuilder'))); + }); + + test('derived helper names stay deterministic', () { + expect(ackFromRuntimeBridgeName('createdAt'), '_ackFromRuntimeCreatedAt'); + expect(ackToRuntimeBridgeName('createdAt'), '_ackToRuntimeCreatedAt'); + expect(jsonFromHelperName('User'), r'_$UserFromJson'); + expect(jsonToHelperName('User'), r'_$UserToJson'); + expect(ackBridgePascal('name'), ackBridgePascal('Name')); + }); + + test('JSON builder exits immediately for ordinary libraries', () async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + ackJsonSerializableBuilder(BuilderOptions.empty), + {'test_pkg|lib/plain.dart': 'final value = 1;'}, + generateFor: const {'test_pkg|lib/plain.dart'}, + readerWriter: readerWriter, + outputs: const {}, + ); + }); +} From aee176739705d3159c7b3e42ccc8e4cf5ce2bb62 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 22 Aug 2026 15:38:11 -0400 Subject: [PATCH 4/7] fix(generator): preserve AckType qualifiers and field precedence --- example/lib/args_getter_example.ack.dart | 28 ++++- .../lib/schema_types_discriminated.ack.dart | 8 +- .../lib/src/builders/model_emitter.dart | 35 ++++-- packages/ack_generator/lib/src/generator.dart | 47 ++++++- .../json_serializable_build_test.dart | 119 ++++++++++++++++-- .../test/integration/v2_models_test.dart | 103 ++++++++++++++- .../integration/v2_runtime_build_test.dart | 23 +++- 7 files changed, 333 insertions(+), 30 deletions(-) diff --git a/example/lib/args_getter_example.ack.dart b/example/lib/args_getter_example.ack.dart index eb6c3e0b..771676b8 100644 --- a/example/lib/args_getter_example.ack.dart +++ b/example/lib/args_getter_example.ack.dart @@ -55,9 +55,14 @@ final class UserConfig { } Map _toAckRuntime() { + const declared = {'username', 'email'}; final result = {..._$UserConfigToJson(this)}; result.remove('additionalProperties'); - return {...additionalProperties, ...result}; + return { + for (final entry in additionalProperties.entries) + if (!declared.contains(entry.key)) entry.key: entry.value, + ...result, + }; } static String _ackFromRuntimeUsername(Object? value) => value as String; @@ -125,9 +130,14 @@ final class ApiRequest { } Map _toAckRuntime() { + const declared = {'method', 'url'}; final result = {..._$ApiRequestToJson(this)}; result.remove('additionalProperties'); - return {...additionalProperties, ...result}; + return { + for (final entry in additionalProperties.entries) + if (!declared.contains(entry.key)) entry.key: entry.value, + ...result, + }; } static String _ackFromRuntimeMethod(Object? value) => value as String; @@ -195,9 +205,14 @@ final class FeatureFlags { } Map _toAckRuntime() { + const declared = {'appVersion', 'environment'}; final result = {..._$FeatureFlagsToJson(this)}; result.remove('additionalProperties'); - return {...additionalProperties, ...result}; + return { + for (final entry in additionalProperties.entries) + if (!declared.contains(entry.key)) entry.key: entry.value, + ...result, + }; } static String _ackFromRuntimeAppVersion(Object? value) => value as String; @@ -258,9 +273,14 @@ final class DynamicData { } Map _toAckRuntime() { + const declared = {}; final result = {..._$DynamicDataToJson(this)}; result.remove('additionalProperties'); - return {...additionalProperties, ...result}; + return { + for (final entry in additionalProperties.entries) + if (!declared.contains(entry.key)) entry.key: entry.value, + ...result, + }; } static Map? _ackFromRuntimeAdditionalProperties( diff --git a/example/lib/schema_types_discriminated.ack.dart b/example/lib/schema_types_discriminated.ack.dart index 3abe1ce4..413efb4b 100644 --- a/example/lib/schema_types_discriminated.ack.dart +++ b/example/lib/schema_types_discriminated.ack.dart @@ -127,9 +127,15 @@ final class Dog extends Pet { @override Map _toAckRuntime() { + const declared = {'kind', 'bark'}; final result = {..._$DogToJson(this)}; result.remove('additionalProperties'); - return {...additionalProperties, 'kind': 'dog', ...result}; + return { + for (final entry in additionalProperties.entries) + if (!declared.contains(entry.key)) entry.key: entry.value, + 'kind': 'dog', + ...result, + }; } static bool _ackFromRuntimeBark(Object? value) => value as bool; diff --git a/packages/ack_generator/lib/src/builders/model_emitter.dart b/packages/ack_generator/lib/src/builders/model_emitter.dart index 4eaf54af..0f1537b3 100644 --- a/packages/ack_generator/lib/src/builders/model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/model_emitter.dart @@ -436,10 +436,10 @@ ${_ack('AckModelAdapter')}( } final effectiveFields = fields ?? _storedFields(node); - final keys = { - ...additionalKnownKeys, - for (final field in effectiveFields) field.jsonKey, - }; + final keys = _declaredJsonKeys( + effectiveFields, + additionalKeys: additionalKnownKeys, + ); return Method( (m) => m ..name = '_fromAckRuntime' @@ -453,7 +453,7 @@ ${_ack('AckModelAdapter')}( ), ) ..body = Code(''' -const declared = {${keys.map(_literal).join(', ')}}; +const declared = ${_declaredKeysLiteral(keys)}; return $helper({ ...value, 'additionalProperties': Map.fromEntries( @@ -476,6 +476,14 @@ return $helper({ ]; final helper = jsonToHelperName(node.className); final needsBlock = node.additionalProperties || requiredNulls.isNotEmpty; + final declaredLiteral = node.additionalProperties + ? _declaredKeysLiteral( + _declaredJsonKeys( + effectiveFields, + additionalKeys: leadingEntries.keys, + ), + ) + : null; return Method((m) { m @@ -496,11 +504,10 @@ return $helper({ } final lines = [ + if (declaredLiteral != null) 'const declared = $declaredLiteral;', 'final result = $_runtimeMapLiteral{...$helper(this)};', + if (declaredLiteral != null) "result.remove('additionalProperties');", ]; - if (node.additionalProperties) { - lines.add("result.remove('additionalProperties');"); - } for (final field in requiredNulls) { lines.add( 'if (${field.dartName} == null) {' @@ -509,7 +516,9 @@ return $helper({ ); } final returnEntries = [ - if (node.additionalProperties) '...additionalProperties', + if (declaredLiteral != null) + 'for (final entry in additionalProperties.entries)\n' + ' if (!declared.contains(entry.key)) entry.key: entry.value', for (final entry in leadingEntries.entries) '${_literal(entry.key)}: ${entry.value}', '...result', @@ -719,6 +728,14 @@ return $helper({ ]; } + Set _declaredJsonKeys( + Iterable fields, { + Iterable additionalKeys = const [], + }) => {...additionalKeys, for (final field in fields) field.jsonKey}; + + String _declaredKeysLiteral(Set keys) => + '{${keys.map(_literal).join(', ')}}'; + AckTypeRef _nonNullable(AckTypeRef type) => switch (type) { AckNullableTypeRef(:final inner) => inner, _ => type, diff --git a/packages/ack_generator/lib/src/generator.dart b/packages/ack_generator/lib/src/generator.dart index 7cb99ed1..6e04fe8f 100644 --- a/packages/ack_generator/lib/src/generator.dart +++ b/packages/ack_generator/lib/src/generator.dart @@ -11,8 +11,7 @@ import 'builders/model_emitter.dart'; /// Generates immutable model classes for top-level schemas annotated with /// `@AckType`. final class AckSchemaGenerator extends Generator { - static const _ackAnnotationsUri = - 'package:ack_annotations/ack_annotations.dart'; + static const _ackTypeChecker = TypeChecker.typeNamed(AckType); @override Future generate(LibraryReader library, BuildStep buildStep) async { @@ -56,7 +55,7 @@ final class AckSchemaGenerator extends Generator { final graph = await SchemaModelGraphBuilder(library).build(annotated); final specs = AckModelEmitter( ackPrefix: _importPrefix(library, 'package:ack/ack.dart'), - ackTypePrefix: _importPrefix(library, _ackAnnotationsUri), + ackTypePrefix: _ackTypeQualifier(library, annotated.first), ).emit(graph); return Library((b) => b.body.addAll(specs)) .accept( @@ -70,7 +69,7 @@ final class AckSchemaGenerator extends Generator { } bool _hasAckType(Element element) => - TypeChecker.typeNamed(AckType).hasAnnotationOfExact(element); + _ackTypeChecker.hasAnnotationOfExact(element); Future _requirePartDirectives( BuildStep buildStep, @@ -105,4 +104,44 @@ final class AckSchemaGenerator extends Generator { } return null; } + + /// Resolves the visible `AckType` qualifier for generated JSON markers. + /// + /// Uses import namespaces so barrel re-exports and `show` combinators work. + /// Prefixed imports win over unprefixed ones, in import order. + String? _ackTypeQualifier(LibraryReader library, Element annotatedElement) { + String? prefixed; + var hasUnprefixed = false; + for (final import in library.element.firstFragment.libraryImports) { + if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) { + continue; + } + final prefix = import.prefix?.element.name; + final candidate = prefix == null + ? import.namespace.get2('AckType') + : import.namespace.getPrefixed2(prefix, 'AckType'); + if (candidate == null || !_ackTypeChecker.isExactly(candidate)) { + continue; + } + if (prefix != null && prefix.isNotEmpty) { + prefixed ??= prefix; + } else { + hasUnprefixed = true; + } + } + if (prefixed != null) { + return prefixed; + } + if (hasUnprefixed) { + return null; + } + throw InvalidGenerationSource( + 'Generated @AckType.jsonSerializable requires a visible exact AckType ' + 'import in this library.', + element: annotatedElement, + todo: + 'Import AckType from ack_annotations, using the same prefix as ' + '@AckType() when one is present.', + ); + } } diff --git a/packages/ack_generator/test/integration/json_serializable_build_test.dart b/packages/ack_generator/test/integration/json_serializable_build_test.dart index 33426076..f4a86a8e 100644 --- a/packages/ack_generator/test/integration/json_serializable_build_test.dart +++ b/packages/ack_generator/test/integration/json_serializable_build_test.dart @@ -26,8 +26,11 @@ void _expectSuccess(ProcessResult result, String command) { ); } -int _helperPairCount(String source, String className) { - return RegExp('_\\\$${className}FromJson').allMatches(source).length; +int _helperDefinitionCount(String source, String className, String suffix) { + return RegExp( + '^[^\\n]*_\\\$$className$suffix\\(', + multiLine: true, + ).allMatches(source).length; } void main() { @@ -97,7 +100,8 @@ final userSchema = Ack.object({ contains('User._ackFromRuntimeCreatedAt(json[\'createdAt\'])'), ); expect(jsonPart, contains('User._ackToRuntimeName(instance.name)')); - expect(_helperPairCount(jsonPart, 'User'), 1); + expect(_helperDefinitionCount(jsonPart, 'User', 'FromJson'), 1); + expect(_helperDefinitionCount(jsonPart, 'User', 'ToJson'), 1); expect(jsonPart, isNot(contains("value['name']"))); } finally { temporary.deleteSync(recursive: true); @@ -115,6 +119,7 @@ final userSchema = Ack.object({ ); try { Directory(p.join(temporary.path, 'lib')).createSync(); + Directory(p.join(temporary.path, 'test')).createSync(); File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' name: ack_json_coexist publish_to: none @@ -131,6 +136,7 @@ dev_dependencies: path: ${p.join(projectRoot.path, 'packages', 'ack_generator')} build_runner: ^2.15.0 json_serializable: ^6.14.1 + test: ^1.29.0 dependency_overrides: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} @@ -168,6 +174,22 @@ final class SameEnvelope { final String? label; Map toJson() => _$SameEnvelopeToJson(this); } +'''); + File( + p.join(temporary.path, 'test', 'runtime_test.dart'), + ).writeAsStringSync(r''' +import 'package:ack_json_coexist/same.dart'; +import 'package:test/test.dart'; + +void main() { + test('Ack optional null stays omitted when consumer include_if_null is true', () { + expect(User(name: 'Ada').toJson(), {'name': 'Ada'}); + expect(const SameEnvelope(count: 1).toJson(), { + 'count': 1, + 'label': null, + }); + }); +} '''); _expectSuccess(await _run(temporary, ['pub', 'get']), 'dart pub get'); @@ -183,15 +205,94 @@ final class SameEnvelope { final combined = File( p.join(temporary.path, 'lib', 'same.g.dart'), ).readAsStringSync(); - expect(_helperPairCount(combined, 'User'), 1); - expect(_helperPairCount(combined, 'SameEnvelope'), 1); + expect(_helperDefinitionCount(combined, 'User', 'FromJson'), 1); + expect(_helperDefinitionCount(combined, 'User', 'ToJson'), 1); + expect(_helperDefinitionCount(combined, 'SameEnvelope', 'FromJson'), 1); + expect(_helperDefinitionCount(combined, 'SameEnvelope', 'ToJson'), 1); expect(combined, contains('User._ackFromRuntimeName(json[\'name\'])')); expect(combined, contains('JsonSerializableGenerator')); - expect( - combined.contains("'label': instance.label") || - combined.contains("'label': ?instance.label"), - isTrue, + _expectSuccess(await _run(temporary, ['test']), 'dart test'); + } finally { + temporary.deleteSync(recursive: true); + } + }, + timeout: const Timeout(Duration(minutes: 3)), + ); + + test( + 'prefixed barrel AckType imports compile through the JSON phase', + () async { + final projectRoot = _projectRoot(); + final temporary = await Directory.systemTemp.createTemp( + 'ack_json_barrel_', + ); + try { + Directory(p.join(temporary.path, 'lib')).createSync(); + Directory(p.join(temporary.path, 'test')).createSync(); + File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' +name: ack_json_barrel +publish_to: none +environment: + sdk: '>=3.9.0 <4.0.0' +dependencies: + ack: + path: ${p.join(projectRoot.path, 'packages', 'ack')} + ack_annotations: + path: ${p.join(projectRoot.path, 'packages', 'ack_annotations')} +dev_dependencies: + ack_generator: + path: ${p.join(projectRoot.path, 'packages', 'ack_generator')} + build_runner: ^2.15.0 + test: ^1.29.0 +dependency_overrides: + ack: + path: ${p.join(projectRoot.path, 'packages', 'ack')} + ack_annotations: + path: ${p.join(projectRoot.path, 'packages', 'ack_annotations')} +'''); + File( + p.join(temporary.path, 'lib', 'annotations.dart'), + ).writeAsStringSync( + "export 'package:ack_annotations/ack_annotations.dart';\n", + ); + File(p.join(temporary.path, 'lib', 'user.dart')).writeAsStringSync(r''' +import 'package:ack/ack.dart'; +import 'annotations.dart' as annotations show AckType; + +part 'user.ack.dart'; +part 'user.g.dart'; + +@annotations.AckType() +final userSchema = Ack.object({'name': Ack.string()}); +'''); + File( + p.join(temporary.path, 'test', 'runtime_test.dart'), + ).writeAsStringSync(r''' +import 'package:ack_json_barrel/user.dart'; +import 'package:test/test.dart'; + +void main() { + test('prefixed barrel models compile and round-trip', () { + expect(User.parse({'name': 'Ada'}).toJson(), {'name': 'Ada'}); + }); +} +'''); + + _expectSuccess(await _run(temporary, ['pub', 'get']), 'dart pub get'); + _expectSuccess( + await _run(temporary, ['run', 'build_runner', 'build']), + 'clean build_runner build', ); + _expectSuccess( + await _run(temporary, ['analyze', '--fatal-infos']), + 'dart analyze --fatal-infos', + ); + + final ackPart = File( + p.join(temporary.path, 'lib', 'user.ack.dart'), + ).readAsStringSync(); + expect(ackPart, contains('@annotations.AckType.jsonSerializable')); + _expectSuccess(await _run(temporary, ['test']), 'dart test'); } finally { temporary.deleteSync(recursive: true); } diff --git a/packages/ack_generator/test/integration/v2_models_test.dart b/packages/ack_generator/test/integration/v2_models_test.dart index a1b62e11..d55a154e 100644 --- a/packages/ack_generator/test/integration/v2_models_test.dart +++ b/packages/ack_generator/test/integration/v2_models_test.dart @@ -175,7 +175,7 @@ final petSchema = Ack.discriminated( contains('@AckType.jsonSerializable\nfinal class Cat extends Pet'), contains("String get kind => 'cat';"), contains("'kind': 'dog'"), - contains('...additionalProperties'), + contains('additionalProperties.entries'), contains(r'_$CatFromJson'), contains(r'_$DogToJson'), ]), @@ -339,6 +339,107 @@ final userSchema = Ack.object({'name': Ack.string()}); ); }); + test('preserves a direct AckType qualifier on the JSON marker', () async { + await _build( + { + 'schema.dart': + ''' +$_imports +part 'schema.ack.dart'; +part 'schema.g.dart'; + +@AckType() +final userSchema = Ack.object({'name': Ack.string()}); +''', + }, + outputs: { + 'test_pkg|lib/schema.ack.dart': decodedMatches( + contains('@AckType.jsonSerializable'), + ), + }, + ); + }); + + test( + 'preserves an unprefixed barrel AckType qualifier on the JSON marker', + () async { + await _build( + { + 'annotations.dart': + "export 'package:ack_annotations/ack_annotations.dart';", + 'schema.dart': ''' +import 'package:ack/ack.dart'; +import 'annotations.dart'; + +part 'schema.ack.dart'; +part 'schema.g.dart'; + +@AckType() +final userSchema = Ack.object({'name': Ack.string()}); +''', + }, + outputs: { + 'test_pkg|lib/schema.ack.dart': decodedMatches( + contains('@AckType.jsonSerializable'), + ), + }, + ); + }, + ); + + test( + 'prefers a prefixed AckType qualifier when both imports are visible', + () async { + await _build( + { + 'schema.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack_annotations/ack_annotations.dart' as annotations; + +part 'schema.ack.dart'; +part 'schema.g.dart'; + +@AckType() +final userSchema = Ack.object({'name': Ack.string()}); +''', + }, + outputs: { + 'test_pkg|lib/schema.ack.dart': decodedMatches( + contains('@annotations.AckType.jsonSerializable'), + ), + }, + ); + }, + ); + + test( + 'preserves a prefixed barrel AckType qualifier on the JSON marker', + () async { + await _build( + { + 'annotations.dart': + "export 'package:ack_annotations/ack_annotations.dart';", + 'schema.dart': ''' +import 'package:ack/ack.dart'; +import 'annotations.dart' as annotations show AckType; + +part 'schema.ack.dart'; +part 'schema.g.dart'; + +@annotations.AckType() +final userSchema = Ack.object({'name': Ack.string()}); +''', + }, + outputs: { + 'test_pkg|lib/schema.ack.dart': decodedMatches( + contains('@annotations.AckType.jsonSerializable'), + ), + }, + ); + }, + ); + test('rejects field names that collide after bridge derivation', () async { final messages = {}; await _build( diff --git a/packages/ack_generator/test/integration/v2_runtime_build_test.dart b/packages/ack_generator/test/integration/v2_runtime_build_test.dart index 29e4af91..84fb0352 100644 --- a/packages/ack_generator/test/integration/v2_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/v2_runtime_build_test.dart @@ -114,7 +114,7 @@ final extrasSchema = Ack.object({ final catSchema = Ack.object({'lives': Ack.integer()}); @AckType() -final dogSchema = Ack.object({'friendly': Ack.boolean()}); +final dogSchema = Ack.object({'friendly': Ack.boolean()}).passthrough(); @AckType() final petSchema = Ack.discriminated( @@ -263,11 +263,30 @@ void main() { [1], ], box: const Box(['x']), - additionalProperties: const {'name': 'extra'}, + additionalProperties: const { + 'name': 'extra', + 'nickname': 'injected', + 'maybe': 'spoofed', + }, ); + expect(constructed.additionalProperties, { + 'name': 'extra', + 'nickname': 'injected', + 'maybe': 'spoofed', + }); expect(constructed.toJson()['name'], 'declared'); expect(constructed.toJson().containsKey('nickname'), isFalse); expect(constructed.toJson()['maybe'], isNull); + expect(constructed.toJson().containsKey('maybe'), isTrue); + }); + + test('union discriminators cannot be spoofed through extras', () { + final dog = Dog( + friendly: true, + additionalProperties: const {'kind': 'cat', 'lives': 9}, + ); + expect(dog.additionalProperties['kind'], 'cat'); + expect(dog.toJson(), {'lives': 9, 'kind': 'dog', 'friendly': true}); }); test('unions and exact value-root names round-trip', () { From f0c6c1f457bc3cc37204a8d6b929480d53e92aa8 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 22 Aug 2026 16:17:04 -0400 Subject: [PATCH 5/7] fix(generator): harden v2 migration cleanup --- .github/copilot-instructions.md | 2 +- .github/workflows/docs.yml | 4 +- PUBLISHING.md | 2 +- README.md | 20 +- SECURITY.md | 2 +- SUPPORT.md | 12 +- docs.json | 6 +- docs/architecture/acktype-model-generation.md | 32 ++- docs/core-concepts/configuration.mdx | 2 +- docs/core-concepts/json-serialization.mdx | 4 +- docs/core-concepts/typesafe-schemas.mdx | 4 +- docs/getting-started/installation.mdx | 4 +- .../creating-schema-converter-packages.mdx | 14 +- docs/guides/schema-converter-quickstart.mdx | 4 +- docs/llms.txt.mdx | 2 +- example/README.md | 2 +- ...=> additional_properties_example.ack.dart} | 2 +- ...art => additional_properties_example.dart} | 4 +- ...t => additional_properties_example.g.dart} | 2 +- example/lib/schema_types_discriminated.dart | 2 +- example/lib/schema_types_edge_cases.dart | 16 +- ...> additional_properties_example_test.dart} | 2 +- example/test/enum_literal_types_test.dart | 4 +- example/test/primitive_types_test.dart | 4 +- example/test/verify_implements_works.dart | 36 --- llms.txt | 191 ++++++---------- packages/ack/README.md | 12 +- packages/ack/pubspec.yaml | 6 +- ...epts_json_serialization_examples_test.dart | 4 +- .../documentation/example_test_suite.dart | 216 ------------------ packages/ack_annotations/README.md | 2 +- packages/ack_annotations/pubspec.yaml | 4 +- packages/ack_firebase_ai/pubspec.yaml | 4 +- packages/ack_generator/CHANGELOG.md | 17 +- .../analyzer/schema_model_graph_builder.dart | 60 +++-- packages/ack_generator/lib/src/generator.dart | 84 ++++--- .../lib/src/models/schema_model_graph.dart | 75 ------ .../lib/src/utils/doc_comment_utils.dart | 48 ---- packages/ack_generator/pubspec.yaml | 10 +- .../example_folder_build_test.dart | 4 +- .../json_serializable_build_test.dart | 28 ++- .../test/integration/v2_models_test.dart | 79 +++++++ packages/ack_json_schema_builder/README.md | 4 +- packages/ack_json_schema_builder/pubspec.yaml | 4 +- scripts/update_release_changelog.dart | 2 +- tools/package.json | 2 +- 46 files changed, 386 insertions(+), 658 deletions(-) rename example/lib/{args_getter_example.ack.dart => additional_properties_example.ack.dart} (99%) rename example/lib/{args_getter_example.dart => additional_properties_example.dart} (92%) rename example/lib/{args_getter_example.g.dart => additional_properties_example.g.dart} (98%) rename example/test/{args_getter_example_test.dart => additional_properties_example_test.dart} (95%) delete mode 100644 example/test/verify_implements_works.dart delete mode 100644 packages/ack/test/documentation/example_test_suite.dart delete mode 100644 packages/ack_generator/lib/src/utils/doc_comment_utils.dart diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f43782cb..8a70ffc5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,4 +1,4 @@ -# Copilot instructions for `btwld/ack` +# Copilot instructions for `conceptadev/ack` ## Start here first - Read `/llms.txt` before making code changes. It is the canonical API reference and should be updated in the same PR when public API changes. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0edc3e15..03ee64dc 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -34,7 +34,7 @@ jobs: echo "llms.txt is missing or empty." exit 1 } - grep -q '^redirect: https://raw.githubusercontent.com/btwld/ack/main/llms.txt$' docs/llms.txt.mdx || { + grep -q '^redirect: https://raw.githubusercontent.com/conceptadev/ack/main/llms.txt$' docs/llms.txt.mdx || { echo "docs/llms.txt.mdx must redirect to the canonical raw llms.txt URL." exit 1 } @@ -49,7 +49,7 @@ jobs: steps: - name: Notify docs.page update run: | - echo "Documentation has been updated and is now available at https://docs.page/btwld/ack" + echo "Documentation has been updated and is now available at https://concepta.dev/ack" # You could add additional notification steps here, such as: # - Sending a Slack message # - Creating a GitHub issue diff --git a/PUBLISHING.md b/PUBLISHING.md index e7e744a7..7af205de 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -24,7 +24,7 @@ Before creating a release: ### 2. Create a GitHub Release -1. Go to the [Releases page](https://github.com/btwld/ack/releases) in the repository +1. Go to the [Releases page](https://github.com/conceptadev/ack/releases) in the repository 2. Click "Draft a new release" 3. Create a new tag in the format `v0.2.0` (must start with "v") 4. Add a title, e.g., "Release v0.2.0" diff --git a/README.md b/README.md index 9a386c5b..824a84cf 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ # Ack -[![CI/CD](https://github.com/btwld/ack/actions/workflows/ci.yml/badge.svg)](https://github.com/btwld/ack/actions/workflows/ci.yml) -[![docs.page](https://img.shields.io/badge/docs.page-documentation-blue)](https://docs.page/btwld/ack) +[![CI/CD](https://github.com/conceptadev/ack/actions/workflows/ci.yml/badge.svg)](https://github.com/conceptadev/ack/actions/workflows/ci.yml) +[![Documentation](https://img.shields.io/badge/docs-documentation-blue)](https://concepta.dev/ack) [![pub package](https://img.shields.io/pub/v/ack.svg)](https://pub.dev/packages/ack) -[![llms.txt](https://img.shields.io/badge/llms.txt-available-8A2BE2)](https://docs.page/btwld/ack/llms.txt) +[![llms.txt](https://img.shields.io/badge/llms.txt-available-8A2BE2)](https://concepta.dev/documentation/ack/reference/llms-txt) Ack is a schema validation library for Dart and Flutter. It validates data with a fluent API. Ack is short for "acknowledge". -For AI agents: start at [`/llms.txt`](https://docs.page/btwld/ack/llms.txt). +For AI agents: start at [`/llms.txt`](https://concepta.dev/documentation/ack/reference/llms-txt). ## Why use Ack? - **Validate external payloads**: Guard API and user inputs by validating required fields, types, and constraints at boundaries - **Single source of truth**: Define data structures and rules in one place - **Less boilerplate**: Minimize repetitive validation and JSON conversion code -- **Type safety**: Generate typed wrappers for hand-written Ack schemas with `@AckType()` +- **Type safety**: Generate immutable models for hand-written Ack schemas with `@AckType()` ## Packages @@ -155,7 +155,7 @@ print(user.toJson()); // {'name': 'Alice', 'email': 'alice@example.com'} `@AckType()` supports objects, primitives, lists, enums, bidirectional codecs, named recursion, and discriminated unions. One-way transforms are rejected -because a generated model must be encodable. See the [TypeSafe Schemas guide](https://docs.page/btwld/ack/core-concepts/typesafe-schemas). +because a generated model must be encodable. See the [TypeSafe Schemas guide](https://concepta.dev/documentation/ack/advanced/typesafe-schemas). ## Codecs @@ -181,13 +181,13 @@ csv.encode(['a', 'b', 'c']); // 'a,b,c' ``` Use `.transform(...)` for one-way (parse-only) conversions. See the -[Codecs guide](https://docs.page/btwld/ack/core-concepts/codecs). +[Codecs guide](https://concepta.dev/documentation/ack/advanced/codecs). ## Documentation -- Human docs: [docs.page/btwld/ack](https://docs.page/btwld/ack) -- AI agent index: [docs.page/btwld/ack/llms.txt](https://docs.page/btwld/ack/llms.txt) -- Canonical plaintext source: [raw.githubusercontent.com/btwld/ack/main/llms.txt](https://raw.githubusercontent.com/btwld/ack/main/llms.txt) +- Human docs: [concepta.dev/ack](https://concepta.dev/ack) +- AI agent index: [AI & llms.txt](https://concepta.dev/documentation/ack/reference/llms-txt) +- Canonical plaintext source: [raw.githubusercontent.com/conceptadev/ack/main/llms.txt](https://raw.githubusercontent.com/conceptadev/ack/main/llms.txt) ## Development diff --git a/SECURITY.md b/SECURITY.md index 94ae2d3b..3715df3e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,4 +31,4 @@ reporter unless they prefer to remain anonymous. Security reports should describe a concrete confidentiality, integrity, or availability impact. General bugs, unexpected validation results, and feature -requests belong in the public [issue tracker](https://github.com/btwld/ack/issues). +requests belong in the public [issue tracker](https://github.com/conceptadev/ack/issues). diff --git a/SUPPORT.md b/SUPPORT.md index 4862c501..0b25c488 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -2,23 +2,23 @@ ## Start with the documentation -- [Ack documentation](https://docs.page/btwld/ack) -- [Quickstart tutorial](https://docs.page/btwld/ack/getting-started/quickstart-tutorial) -- [API quick reference](https://docs.page/btwld/ack/api-reference/) +- [Ack documentation](https://concepta.dev/ack) +- [Quickstart tutorial](https://concepta.dev/documentation/ack/getting-started/quickstart-tutorial) +- [API quick reference](https://concepta.dev/documentation/ack/reference/api-reference) - [Generated API documentation](https://pub.dev/documentation/ack/latest/ack/) -Search existing [GitHub issues](https://github.com/btwld/ack/issues) before +Search existing [GitHub issues](https://github.com/conceptadev/ack/issues) before opening a new one; your question or bug may already have an answer. ## Ask a question -Open a [GitHub issue](https://github.com/btwld/ack/issues/new) and apply the +Open a [GitHub issue](https://github.com/conceptadev/ack/issues/new) and apply the `question` label. Include the Ack package and version, your Dart or Flutter version, what you are trying to accomplish, and a small reproducible example. ## Report a bug or request a feature -Use the [issue tracker](https://github.com/btwld/ack/issues) with the `bug` or +Use the [issue tracker](https://github.com/conceptadev/ack/issues) with the `bug` or `enhancement` label. A minimal reproduction and the full error output make an issue much easier to investigate. diff --git a/docs.json b/docs.json index eba30e5a..68ffcf16 100644 --- a/docs.json +++ b/docs.json @@ -13,7 +13,7 @@ "showGitHubCard": true }, "social": { - "github": "btwld/ack" + "github": "conceptadev/ack" }, "seo": { "noindex": false @@ -88,8 +88,8 @@ ], "variables": { "versions": { - "default": "1.1.0", - "isPrerelease": false + "default": "2.0.0", + "isPrerelease": true } }, "search": {}, diff --git a/docs/architecture/acktype-model-generation.md b/docs/architecture/acktype-model-generation.md index 035bab91..ad6b6d46 100644 --- a/docs/architecture/acktype-model-generation.md +++ b/docs/architecture/acktype-model-generation.md @@ -68,11 +68,11 @@ the adapter's generic bounds preserve that invariant. Analysis produces one graph consumed directly by the emitter. Nodes carry: -- model identity and source location; +- model identity; - structural boundary and runtime type references; - object, value, and discriminated-union shape; - field presence separately from nullability; -- encode capability and named model references. +- named model references. All annotated declarations are registered before resolution. Resolution uses `unseen`, `visiting`, and `resolved` states: named `Ack.lazy` edges may point to @@ -93,10 +93,11 @@ Defaulted fields stay required in the unchecked constructor because arbitrary schema defaults can't become Dart parameter defaults safely. Every represented list, set, and map is recursively copied into an unmodifiable -collection by the public constructor. Passthrough objects store unknown values -in an unmodifiable `additionalProperties` map. Encoding writes additional -entries first and declared fields second, so unknown data can't replace a -declared property. +collection by the public constructor. Generated map runtime types must use +`String` keys because the generator's structural map contract is string-keyed. +Passthrough objects store unknown values in an unmodifiable +`additionalProperties` map. Encoding writes additional entries first and +declared fields second, so unknown data can't replace a declared property. ## Unsupported shapes @@ -106,6 +107,7 @@ Generation reports a located error for: - nullable roots; - `Ack.any()`, `Ack.anyOf()`, and bare `Ack.instance()`; - anonymous inline objects and unresolved dynamic schema factories; +- runtime maps whose key type is not `String`; - invalid custom names and namespace/member collisions; - cross-library discriminated branches. @@ -136,3 +138,21 @@ build temporary packages from no generated output, run strict analysis and runtime tests, prove an Ack-only consumer produces both outputs, then rebuild and compare generated bytes for determinism. The checked example package keeps its generated `.ack.dart` and `.g.dart` files as reviewable fixtures. + +## Migration from extension-based generation + +The immutable model generator is a breaking replacement for the previous +map-backed extension types: + +| Previous API | Immutable model API | +| --- | --- | +| only `part '.g.dart';` | both `.ack.dart` and `.g.dart` parts | +| generated `UserType` | generated `User` | +| model implements `Map`, `List`, or a scalar interface | stored object fields or a `.value` field | +| passthrough `.args` | `.additionalProperties` | +| generated `fromMap` / `toMap` | `fromJson` / `toJson` | +| one-way `.transform()` | bidirectional `.codec()` | + +After updating source imports and calls, delete stale generated outputs and run +`dart run build_runner build`. This is a major-version migration for +`ack_generator`; consumers should not adopt it as a compatible 1.x update. diff --git a/docs/core-concepts/configuration.mdx b/docs/core-concepts/configuration.mdx index d44467d2..a24a90dd 100644 --- a/docs/core-concepts/configuration.mdx +++ b/docs/core-concepts/configuration.mdx @@ -91,7 +91,7 @@ Use `.constrain()` for reusable value-level checks and `.refine()` for cross-fie ## Code generation -Annotate a top-level schema with `@AckType()` to generate a typed wrapper — see [TypeSafe Schemas](./typesafe-schemas.mdx) for setup and supported shapes. +Annotate a top-level schema with `@AckType()` to generate an immutable model — see [TypeSafe Schemas](./typesafe-schemas.mdx) for setup and supported shapes. ## Related guides diff --git a/docs/core-concepts/json-serialization.mdx b/docs/core-concepts/json-serialization.mdx index 8f669bd6..a7d09ab4 100644 --- a/docs/core-concepts/json-serialization.mdx +++ b/docs/core-concepts/json-serialization.mdx @@ -45,7 +45,7 @@ void processApiResponse(String jsonString) { print('Valid JSON received: $validDataMap'); // Pass the validated map to your own model layer, - // or use an AckType-generated wrapper (see next section). + // or use an AckType-generated immutable model (see next section). } else { // Handle validation errors (see the Error Handling guide). print('Invalid JSON data: ${result.getError()}'); @@ -63,7 +63,7 @@ processApiResponse('not valid json'); // Decoding error ## Working with validated data -After successful validation, `result.getOrThrow()` returns a `Map` whose structure and types match your schema. You can work with it directly, pass it into a model class, or use a generated typed wrapper: +After successful validation, `result.getOrThrow()` returns a `Map` whose structure and types match your schema. You can work with it directly, pass it into your own model class, or parse it into a generated immutable model: ```dart final result = userSchema.safeParse(jsonData); diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index 5ca59caf..90de2154 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -68,7 +68,7 @@ Generated models don't implement `Map`. Use `toJson()` when a map is needed. `@AckType()` supports: - objects and empty objects; -- scalar roots, `num`, literals, enums, lists, sets, and typed maps; +- scalar roots, `num`, literals, enums, lists, sets, and string-keyed typed maps; - built-in codecs such as `Ack.datetime()`, plus custom bidirectional codecs; - named nested models, aliases, defaults, and additional properties; - direct, prefixed, and re-exported model references; @@ -81,6 +81,8 @@ back to its boundary value. Replace them with `.codec(decode: ..., encode: ...)` The generator also rejects nullable roots, `Ack.any()`, `Ack.anyOf()`, bare `Ack.instance()`, anonymous inline object fields, unresolved dynamic schema factories, name/member collisions, and cross-library discriminated branches. +Runtime maps with key types other than `String` are also rejected because the +generator's structural map contract is string-keyed. ## Nested models and recursion diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 68e9bbb3..8990964b 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -2,7 +2,7 @@ title: Installing Ack --- -Ack is a pure-Dart package with no required build step. Add the core library — and, optionally, the code generator for typed wrappers. +Ack is a pure-Dart package with no required build step. Add the core library — and, optionally, the code generator for immutable models. ## Add to your project @@ -25,7 +25,7 @@ dependencies: ## Code generator (`@AckType()`) -To generate typed wrappers for hand-written schemas, add the annotation and generator packages alongside `ack`: +To generate immutable models for hand-written schemas, add the annotation and generator packages alongside `ack`: ```bash dart pub add ack ack_annotations diff --git a/docs/guides/creating-schema-converter-packages.mdx b/docs/guides/creating-schema-converter-packages.mdx index 4136d3bf..073f8a11 100644 --- a/docs/guides/creating-schema-converter-packages.mdx +++ b/docs/guides/creating-schema-converter-packages.mdx @@ -132,8 +132,8 @@ touch .pubignore name: ack_ description: schema converter for Ack validation library version: 1.0.0-beta.1 -repository: https://github.com/btwld/ack -issue_tracker: https://github.com/btwld/ack/issues +repository: https://github.com/conceptadev/ack +issue_tracker: https://github.com/conceptadev/ack/issues environment: sdk: '>=3.8.0 <4.0.0' @@ -1027,7 +1027,7 @@ dependencies: ### Compatibility -Requires `: >=x.y.z : >=x.y.z -v1.0.0-beta.1 +[1.0.0-beta.1]: https://github.com/conceptadev/ack/releases/tag/ack_-v1.0.0-beta.1 ``` --- @@ -1512,6 +1512,6 @@ class GraphQlSchemaConverter { - Document limitations as you discover them **For help**: -- Create GitHub issue: https://github.com/btwld/ack/issues +- Create GitHub issue: https://github.com/conceptadev/ack/issues - Reference this guide - Ask specific questions about target system diff --git a/docs/guides/schema-converter-quickstart.mdx b/docs/guides/schema-converter-quickstart.mdx index ff0a52f1..81b58df9 100644 --- a/docs/guides/schema-converter-quickstart.mdx +++ b/docs/guides/schema-converter-quickstart.mdx @@ -34,7 +34,7 @@ touch .pubignore name: ack_ description: schema converter for Ack validation library version: 1.0.0-beta.1 -repository: https://github.com/btwld/ack +repository: https://github.com/conceptadev/ack environment: sdk: '>=3.8.0 <4.0.0' @@ -348,7 +348,7 @@ final targetSchema = schema.toSchema(); ## License -Part of the [Ack](https://github.com/btwld/ack) monorepo. +Part of the [Ack](https://github.com/conceptadev/ack) monorepo. ``` ## 9. Verify Setup (2 minutes) diff --git a/docs/llms.txt.mdx b/docs/llms.txt.mdx index dc8cb83f..4a4025e1 100644 --- a/docs/llms.txt.mdx +++ b/docs/llms.txt.mdx @@ -1,6 +1,6 @@ --- title: llms.txt -redirect: https://raw.githubusercontent.com/btwld/ack/main/llms.txt +redirect: https://raw.githubusercontent.com/conceptadev/ack/main/llms.txt --- This route redirects to the canonical static `llms.txt` file. diff --git a/example/README.md b/example/README.md index 9c204a5f..0a19a18e 100644 --- a/example/README.md +++ b/example/README.md @@ -12,7 +12,7 @@ immutable models with `@AckType()`. Annotated examples declare both - Built-in and custom codec schemas in `lib/schema_types_transforms.dart` - Edge cases and strict resolution in `lib/schema_types_edge_cases.dart` - Cross-schema object models in `lib/pet.dart`, `lib/user_with_color.dart`, - and `lib/args_getter_example.dart` + and `lib/additional_properties_example.dart` - Codecs (built-in and custom) in `lib/codecs_example.dart` ## Running the examples diff --git a/example/lib/args_getter_example.ack.dart b/example/lib/additional_properties_example.ack.dart similarity index 99% rename from example/lib/args_getter_example.ack.dart rename to example/lib/additional_properties_example.ack.dart index 771676b8..56433049 100644 --- a/example/lib/args_getter_example.ack.dart +++ b/example/lib/additional_properties_example.ack.dart @@ -1,7 +1,7 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // dart format width=80 -part of 'args_getter_example.dart'; +part of 'additional_properties_example.dart'; // ************************************************************************** // AckSchemaGenerator diff --git a/example/lib/args_getter_example.dart b/example/lib/additional_properties_example.dart similarity index 92% rename from example/lib/args_getter_example.dart rename to example/lib/additional_properties_example.dart index 8e70aacf..1944060c 100644 --- a/example/lib/args_getter_example.dart +++ b/example/lib/additional_properties_example.dart @@ -5,8 +5,8 @@ library; import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -part 'args_getter_example.ack.dart'; -part 'args_getter_example.g.dart'; +part 'additional_properties_example.ack.dart'; +part 'additional_properties_example.g.dart'; /// Example 1: User configuration with additional metadata /// The generated model has `additionalProperties`, which contains diff --git a/example/lib/args_getter_example.g.dart b/example/lib/additional_properties_example.g.dart similarity index 98% rename from example/lib/args_getter_example.g.dart rename to example/lib/additional_properties_example.g.dart index 764f8880..89757d9d 100644 --- a/example/lib/args_getter_example.g.dart +++ b/example/lib/additional_properties_example.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'args_getter_example.dart'; +part of 'additional_properties_example.dart'; // ************************************************************************** // AckJsonSerializableGenerator diff --git a/example/lib/schema_types_discriminated.dart b/example/lib/schema_types_discriminated.dart index e087e709..c4022768 100644 --- a/example/lib/schema_types_discriminated.dart +++ b/example/lib/schema_types_discriminated.dart @@ -4,7 +4,7 @@ import 'package:ack_annotations/ack_annotations.dart'; part 'schema_types_discriminated.ack.dart'; part 'schema_types_discriminated.g.dart'; -/// Discriminated schema example for @AckType extension generation. +/// Discriminated schema example for immutable model generation with @AckType. @AckType() final catSchema = Ack.object({'lives': Ack.integer()}); diff --git a/example/lib/schema_types_edge_cases.dart b/example/lib/schema_types_edge_cases.dart index 10352e08..ffc97b15 100644 --- a/example/lib/schema_types_edge_cases.dart +++ b/example/lib/schema_types_edge_cases.dart @@ -28,9 +28,9 @@ part 'schema_types_edge_cases.g.dart'; @AckType() final productSchema = Ack.object({ 'name': Ack.string(), - 'tags': Ack.list(Ack.string()), // Should generate: List get tags - 'scores': Ack.list(Ack.integer()), // Should generate: List get scores - 'flags': Ack.list(Ack.boolean()), // Should generate: List get flags + 'tags': Ack.list(Ack.string()), // Generates: final List tags + 'scores': Ack.list(Ack.integer()), // Generates: final List scores + 'flags': Ack.list(Ack.boolean()), // Generates: final List flags }); /// Schema with nested lists (matrix/grid data) @@ -42,7 +42,7 @@ final gridSchema = Ack.object({ 'name': Ack.string(), 'matrix': Ack.list( Ack.list(Ack.integer()), - ), // Should generate: List> get matrix + ), // Generates: final List> matrix }); // ============================================================================ @@ -62,7 +62,7 @@ final addressSchema = Ack.object({ /// /// EXPECTED BEHAVIOR: /// - address field should NOT be null/missing -/// - Should generate: AddressType get address (or `Map`) +/// - Generates: `final Address address` @AckType() final personSchema = Ack.object({ 'name': Ack.string(), @@ -123,7 +123,7 @@ final taggedItemSchema = Ack.object({ /// Schema with list of nested objects /// /// EXPECTED BEHAVIOR: -/// - addresses: `List` (eager list with typed elements) +/// - addresses: `List
` (eager list with typed elements) @AckType() final contactListSchema = Ack.object({ 'name': Ack.string(), @@ -150,11 +150,11 @@ final minimalSchema = Ack.object({'id': Ack.string()}); // EDGE CASE 6: Naming Variations // ============================================================================ -/// Schema with 'Schema' suffix (should generate NamedType) +/// Schema with 'Schema' suffix (generates `NamedItem`) @AckType() final namedItemSchema = Ack.object({'name': Ack.string()}); -/// Schema without 'Schema' suffix (should generate ItemType) +/// Schema without 'Schema' suffix (generates `Item`) @AckType() final item = Ack.object({'id': Ack.string()}); diff --git a/example/test/args_getter_example_test.dart b/example/test/additional_properties_example_test.dart similarity index 95% rename from example/test/args_getter_example_test.dart rename to example/test/additional_properties_example_test.dart index d8aa0eac..b61497d9 100644 --- a/example/test/args_getter_example_test.dart +++ b/example/test/additional_properties_example_test.dart @@ -1,6 +1,6 @@ import 'package:test/test.dart'; -import 'package:ack_example/args_getter_example.dart'; +import 'package:ack_example/additional_properties_example.dart'; void main() { group('Additional properties examples', () { diff --git a/example/test/enum_literal_types_test.dart b/example/test/enum_literal_types_test.dart index 5daa5686..9b11bed3 100644 --- a/example/test/enum_literal_types_test.dart +++ b/example/test/enum_literal_types_test.dart @@ -4,8 +4,8 @@ import 'package:test/test.dart'; /// Tests for enum, literal, and string-enum schemas. /// -/// Note: Extension types are generated for non-nullable primitive schemas, -/// but these tests use the schema directly via `safeParse()` or `parse()`. +/// Generated value models are available for annotated primitive schemas, while +/// these tests exercise the underlying schemas directly. void main() { group('Literal Schema (via safeParse)', () { test('statusSchema validates literal value', () { diff --git a/example/test/primitive_types_test.dart b/example/test/primitive_types_test.dart index 7cbe683b..a15f439d 100644 --- a/example/test/primitive_types_test.dart +++ b/example/test/primitive_types_test.dart @@ -4,8 +4,8 @@ import 'package:test/test.dart'; /// Tests for primitive schemas. /// -/// Extension types are generated for primitive schemas, but the schema can -/// still be used directly via `safeParse()` or `parse()`. +/// Generated value models are available for annotated primitive schemas, while +/// these tests exercise the underlying schemas directly. void main() { group('Primitive Schemas', () { test('passwordSchema validates and returns String', () { diff --git a/example/test/verify_implements_works.dart b/example/test/verify_implements_works.dart deleted file mode 100644 index 6fa68e00..00000000 --- a/example/test/verify_implements_works.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:ack_example/schema_types_simple.dart'; -import 'package:test/test.dart'; - -void main() { - group('Generated immutable model', () { - test('exposes typed fields and an explicit JSON boundary', () { - final user = User.parse({'name': 'John', 'age': 30, 'active': true}); - - expect(user.name, 'John'); - expect(user.age, 30); - expect(user.active, true); - expect(user.toJson(), {'name': 'John', 'age': 30, 'active': true}); - }); - - test('safeParse returns SchemaResult', () { - final result = User.safeParse({ - 'name': 'John', - 'age': 30, - 'active': true, - }); - - expect(result.isOk, true); - final user = result.getOrNull(); - expect(user, isA()); - expect(user?.name, 'John'); - expect(user?.age, 30); - }); - - test('safeParse failure preserves the typed result contract', () { - final result = User.safeParse({'name': 'John'}); - - expect(result.isFail, true); - expect(result.getOrNull(), isNull); - }); - }); -} diff --git a/llms.txt b/llms.txt index 7c4e756c..76eba499 100644 --- a/llms.txt +++ b/llms.txt @@ -1,16 +1,16 @@ # Ack -> A schema validation library for Dart and Flutter with a fluent runtime API and `@AckType()`-driven extension-type generation. Version 1.1.0. +> A schema validation library for Dart and Flutter with a fluent runtime API, bidirectional codecs, and `@AckType()`-driven immutable model generation. -Ack validates external data with hand-written schemas built using the `Ack` -factory. When you want typed wrappers over validated values, annotate top-level -schema variables or getters with `@AckType()` and run `ack_generator`. +Ack validates untrusted boundary data with schemas built through the `Ack` +factory. Annotate a top-level schema variable or getter with `@AckType()` when +you also want a generated immutable Dart model with stored typed fields. ## Packages -1. `ack`: core runtime validation library +1. `ack`: core runtime validation, codecs, and generated-model support 2. `ack_annotations`: exposes `@AckType()` -3. `ack_generator`: generates extension types for annotated top-level schemas +3. `ack_generator`: generates immutable models from annotated schemas 4. `ack_firebase_ai`: converts Ack schemas to Firebase AI structured-output schemas 5. `ack_json_schema_builder`: converts Ack schemas to `json_schema_builder` schemas @@ -33,34 +33,26 @@ final result = userSchema.safeParse({ ## Codecs -Codecs decode a boundary value (wire shape) into a runtime value and encode it -back. `parse`/`safeParse` decode; `encode`/`safeEncode` encode. +Codecs decode a boundary value into a runtime value and encode it back. +`parse` / `safeParse` decode; `encode` / `safeEncode` encode. -- Built-in: `Ack.date()` (ISO `YYYY-MM-DD` <-> local-midnight `DateTime`), - `Ack.datetime()` (ISO 8601 <-> UTC `DateTime`; rejects leap-second strings - because Dart cannot represent them), `Ack.uri()` (`String` <-> `Uri`), - `Ack.duration()` (milliseconds `int` <-> `Duration`), `Ack.enumCodec(values)` - (enum-name `String` <-> enum value). +- Built in: `Ack.date()`, `Ack.datetime()`, `Ack.uri()`, `Ack.duration()`, and + `Ack.enumCodec(values)`. - Custom: `Ack.codec(input: ..., decode: ..., encode: ...)`, or `schema.codec(decode: ..., encode: ...)` on an existing schema. -- `schema.transform(fn)` is one-way (parse only); encoding it fails. -- A codec exports the JSON Schema of its boundary (input) schema. +- `schema.transform(fn)` is one-way. It works for runtime parsing, but it is + rejected by generated models because they must encode back to the boundary. +- A codec exports the JSON Schema of its boundary schema. ## AckType generation -`@AckType()` is supported only on: - -- top-level schema variables -- top-level schema getters - -It is not supported on classes or instance members. - -Example: +Every annotated library declares both generated parts: ```dart import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; +part 'user_schema.ack.dart'; part 'user_schema.g.dart'; @AckType() @@ -76,126 +68,85 @@ final userSchema = Ack.object({ }); ``` -Generated capabilities: +Run `dart run build_runner build`. `addressSchema` and `userSchema` generate +`Address` and `User`. A custom `@AckType(name: 'Member')` value is used exactly. -- `UserType.parse(data)` -- `UserType.safeParse(data)` -- typed getters such as `String get name` and `AddressType get address` +Generated object models provide: -## Supported AckType schema shapes +- an unchecked constructor with stored typed fields; +- `User.parse(data)` and `User.safeParse(data)` for schema validation; +- `User.fromJson(json)`, `toJson()`, and `safeToJson()` for the JSON boundary; +- a public static `User.$ack` adapter for nested generated models; +- an unmodifiable `additionalProperties` map for passthrough objects. -- `Ack.object(...)` -- `Ack.string()` -- `Ack.integer()` -- `Ack.double()` -- `Ack.boolean()` -- `Ack.list(...)` -- `Ack.literal(...)` -- `Ack.enumString(...)` -- `Ack.enumValues(...)` -- explicit transforms such as `.transform(...)` -- `Ack.discriminated(...)` with the constraints below +Generated models do not implement `Map`, `List`, or scalar interfaces. Scalar +and collection roots are value models with a `.value` field. Use model fields +and `toJson()` instead of treating a model as its old boundary representation. -Not supported for extension-type generation: +## Supported generated-model shapes -- `Ack.any()` -- `Ack.anyOf()` -- transformed object schemas -- transformed discriminated schemas +- objects and empty objects; +- string, integer, double, number, boolean, list, literal, and enum roots; +- built-in and custom bidirectional codecs; +- lists, sets, and string-keyed `Map` runtime values; +- named nested models, aliases, defaults, and additional properties; +- direct, prefixed, and re-exported model and runtime type references; +- named `Ack.lazy` self-recursion and mutual recursion; +- same-library discriminated unions. -## Discriminated AckType schemas +Generation rejects nullable roots, one-way transforms, `Ack.any()`, +`Ack.anyOf()`, bare `Ack.instance()`, anonymous inline object fields, +non-string map keys, unresolved dynamic factories, name collisions, and +cross-library discriminated branches. -`Ack.discriminated(...)` works with `@AckType()` when: +## Discriminated generated models -- the base schema is a top-level `@AckType()` declaration -- `schemas` is a non-empty map literal -- each branch is a top-level schema variable/getter reference -- each branch is an `@AckType()` object schema -- each branch is non-nullable -- each branch is declared in the same library as the base -- branch schemas normally omit the discriminator field -- if a branch includes the discriminator field, it must be `Ack.literal(...)` - matching the branch key or `Ack.enumString(...)` containing the branch key +`Ack.discriminated(...)` works with `@AckType()` when: -Example: +- `schemas` is a non-empty map literal; +- each branch is a top-level `@AckType()` object schema in the same library; +- each branch is non-nullable; +- branch schemas normally omit the discriminator field; +- an included discriminator is an exact matching `Ack.literal(...)` or an + `Ack.enumString(...)` containing the branch key. ```dart @AckType() -final catSchema = Ack.object({ - 'lives': Ack.integer(), -}); +final catSchema = Ack.object({'lives': Ack.integer()}); @AckType() -final dogSchema = Ack.object({ - 'breed': Ack.string(), -}); +final dogSchema = Ack.object({'breed': Ack.string()}); @AckType() final petSchema = Ack.discriminated( discriminatorKey: 'type', - schemas: { - 'cat': catSchema, - 'dog': dogSchema, - }, + schemas: {'cat': catSchema, 'dog': dogSchema}, ); ``` -`Ack.discriminated(...)` owns the discriminator property. Boundary payloads -must still include the discriminator key, but branch schemas should usually -omit it. If a branch schema includes the discriminator field, it must be -an exact literal or enum containing the branch map key: +This generates a sealed `Pet` base and final `Cat` and `Dog` branches. +Boundary payloads include the discriminator. Generated subtype parsing +validates through the union's effective branch. -```dart -@AckType() -final catSchema = Ack.object({ - 'type': Ack.literal('cat'), // allowed, but usually unnecessary - 'lives': Ack.integer(), -}); -``` - -Conflicting discriminator fields are rejected. Exported and generated schemas -treat the discriminator as an exact literal for each branch. Broad -`Ack.string()`, transformed/refined discriminator fields, and restrictive -chains are rejected. Generated subtype `parse()` / `safeParse()` methods -validate through the union's effective branch. +## Migration from the previous generator -## Resolution rules - -AckType generation is intentionally strict: - -- nested object fields must reference named top-level schemas -- inline anonymous object schemas are rejected for typed generation -- cross-file direct imports, prefixed imports, and re-exports are supported -- unannotated object schema references fail generation instead of silently falling back to raw maps -- circular schema alias/reference chains fail generation +- Add both `.ack.dart` and `.g.dart` part directives. +- Rename generated `UserType` usages to `User` unless a custom name is set. +- Replace map/list/scalar interface access with stored fields or `.value`. +- Replace passthrough `.args` access with `.additionalProperties`. +- Replace generated `fromMap` / `toMap` calls with `fromJson` / `toJson`. +- Replace one-way transforms used by generated models with bidirectional codecs. +- Regenerate all checked-in outputs with `dart run build_runner build`. ## Runtime API reminders -- `schema.parse(data)` throws on invalid input -- `schema.safeParse(data)` returns `SchemaResult` -- `safeParse` turns invalid input and recoverable `Exception` values from - constraint/refinement callbacks into contextual failures -- `Error` values from constraint/refinement callbacks are rethrown with their - original stack trace -- codec/transform decoders and `safeParseAs` mappers turn thrown values, - including `Error` values, into `SchemaTransformError` failures -- `SchemaResult.getOrThrow()` returns the validated value or throws `AckException` -- `.optional()` allows a field to be omitted -- `.nullable()` allows a present field to hold `null` -- object schemas support `additionalProperties: true` -- `schema.toSchemaModel()` returns `AckSchemaModel`, the canonical boundary/export model for adapters -- `schema.toJsonSchema()` renders `schema.toSchemaModel().toJsonSchema()` -- adapter packages should render from `AckSchemaModel`, not by traversing `AckSchema` subclasses -- `Ack.list(...)` does not support nullable item schemas; make the list itself nullable when needed -- `Ack.lazy(...)` defaults `maxDepth` to `100` for recursive parsing, runtime - validation, and encoding; the runtime-only limit is omitted from exported schemas with a warning -- `Ack.object`, `Ack.anyOf`, and enum factories snapshot their input collections; - unions and enum value lists must be non-empty, and enum values must be unique -- lengths and item counts must be non-negative; numeric bounds must be finite; - `multipleOf` must be finite and greater than zero - -## Build command - -```bash -dart run build_runner build -``` +- `schema.parse(data)` throws on invalid input. +- `schema.safeParse(data)` returns `SchemaResult`. +- `SchemaResult.getOrThrow()` returns the value or throws `AckException`. +- `.optional()` allows an object field to be omitted. +- `.nullable()` allows a present value to be null. +- `schema.toSchemaModel()` returns the canonical adapter/export model. +- `schema.toJsonSchema()` renders that model as JSON Schema. +- `Ack.list(...)` does not support nullable item schemas. +- `Ack.lazy(...)` defaults `maxDepth` to 100. +- Ack snapshots schema factory collections, and collection bounds must be valid. diff --git a/packages/ack/README.md b/packages/ack/README.md index 199a4f14..e7bb3ea8 100644 --- a/packages/ack/README.md +++ b/packages/ack/README.md @@ -1,8 +1,8 @@ # Ack [![pub package](https://img.shields.io/pub/v/ack.svg)](https://pub.dev/packages/ack) -[![CI/CD](https://github.com/btwld/ack/actions/workflows/ci.yml/badge.svg)](https://github.com/btwld/ack/actions/workflows/ci.yml) -[![docs.page](https://img.shields.io/badge/docs.page-documentation-blue)](https://docs.page/btwld/ack) +[![CI/CD](https://github.com/conceptadev/ack/actions/workflows/ci.yml/badge.svg)](https://github.com/conceptadev/ack/actions/workflows/ci.yml) +[![Documentation](https://img.shields.io/badge/docs-documentation-blue)](https://concepta.dev/ack) Ack is a schema validation library for Dart and Flutter that helps you validate data with a simple, fluent API. Ack is short for "acknowledge". @@ -12,7 +12,7 @@ Ack is a schema validation library for Dart and Flutter that helps you validate - **Validate external payloads**: Guard API and user inputs by validating required fields, types, and constraints at boundaries - **Single Source of Truth**: Define data structures and rules in one place - **Reduce Boilerplate**: Minimize repetitive code for validation and JSON conversion -- **Type Safety**: Generate typed wrappers for hand-written Ack schemas +- **Type Safety**: Generate immutable models for hand-written Ack schemas ## Quick Start @@ -50,11 +50,11 @@ Use `.optional()` when a field may be omitted entirely. Chain `.nullable()` if a ## Documentation -- [Full documentation](https://docs.page/btwld/ack) -- [AI agent index (llms.txt)](https://docs.page/btwld/ack/llms.txt) +- [Full documentation](https://concepta.dev/ack) +- [AI agent index (llms.txt)](https://concepta.dev/documentation/ack/reference/llms-txt) ## Related Packages -- [ack_generator](https://pub.dev/packages/ack_generator) — Code generator for typed wrappers from `@AckType()` schemas +- [ack_generator](https://pub.dev/packages/ack_generator) — Code generator for immutable models from `@AckType()` schemas - [ack_firebase_ai](https://pub.dev/packages/ack_firebase_ai) — Firebase AI (Gemini) schema converter - [ack_json_schema_builder](https://pub.dev/packages/ack_json_schema_builder) — JSON Schema converter diff --git a/packages/ack/pubspec.yaml b/packages/ack/pubspec.yaml index b94181ea..73063517 100644 --- a/packages/ack/pubspec.yaml +++ b/packages/ack/pubspec.yaml @@ -1,9 +1,9 @@ name: ack description: A simple validation library for Dart version: 1.1.0 -repository: https://github.com/btwld/ack -issue_tracker: https://github.com/btwld/ack/issues -homepage: https://docs.page/btwld/ack +repository: https://github.com/conceptadev/ack +issue_tracker: https://github.com/conceptadev/ack/issues +homepage: https://concepta.dev/ack resolution: workspace diff --git a/packages/ack/test/documentation/core_concepts_json_serialization_examples_test.dart b/packages/ack/test/documentation/core_concepts_json_serialization_examples_test.dart index bef463d2..44462801 100644 --- a/packages/ack/test/documentation/core_concepts_json_serialization_examples_test.dart +++ b/packages/ack/test/documentation/core_concepts_json_serialization_examples_test.dart @@ -114,7 +114,7 @@ void main() { expect(user['email'], equals('alice@example.com')); }); - test('doc copy focuses on validated data and typed wrappers', () async { + test('doc copy focuses on validated data and immutable models', () async { final content = await File( '../../docs/core-concepts/json-serialization.mdx', ).readAsString(); @@ -125,7 +125,7 @@ void main() { contains('Ack schemas convert between Dart models and JSON data'), ), ); - expect(content, contains('typed wrapper')); + expect(content, contains('generated immutable model')); }); }); } diff --git a/packages/ack/test/documentation/example_test_suite.dart b/packages/ack/test/documentation/example_test_suite.dart deleted file mode 100644 index fb110fa7..00000000 --- a/packages/ack/test/documentation/example_test_suite.dart +++ /dev/null @@ -1,216 +0,0 @@ -import 'dart:io'; - -import 'package:ack/ack.dart'; -import 'package:test/test.dart'; - -void main() { - group('Documentation Example Test Suite', () { - group('Example Package Validation', () { - const retainedExampleSources = [ - 'args_getter_example.dart', - 'pet.dart', - 'schema_types_discriminated.dart', - 'schema_types_edge_cases.dart', - 'schema_types_primitives.dart', - 'schema_types_simple.dart', - 'schema_types_transforms.dart', - 'user_with_color.dart', - ]; - const retainedGeneratedFiles = [ - 'args_getter_example.g.dart', - 'pet.g.dart', - 'schema_types_discriminated.g.dart', - 'schema_types_edge_cases.g.dart', - 'schema_types_primitives.g.dart', - 'schema_types_simple.g.dart', - 'schema_types_transforms.g.dart', - 'user_with_color.g.dart', - ]; - - test('example package should exist and be properly structured', () { - final exampleDir = Directory('../../example'); - expect(exampleDir.existsSync(), isTrue); - - expect(Directory('../../example/lib').existsSync(), isTrue); - expect(Directory('../../example/test').existsSync(), isTrue); - }); - - test( - 'example package should have the full retained AckType source set', - () async { - final exampleLib = Directory('../../example/lib'); - final actualSources = []; - - for (final entry in exampleLib.listSync()) { - if (entry is! File || !entry.path.endsWith('.dart')) { - continue; - } - - final fileName = entry.uri.pathSegments.last; - if (fileName.endsWith('.g.dart')) { - continue; - } - - final content = await entry.readAsString(); - if (content.contains('@AckType()')) { - actualSources.add(fileName); - } - } - - actualSources.sort(); - expect(actualSources, retainedExampleSources); - }, - ); - - test('generated AckType example files should match the retained set', () { - final actualGeneratedFiles = - Directory('../../example/lib') - .listSync() - .whereType() - .map((file) => file.uri.pathSegments.last) - .where((fileName) => fileName.endsWith('.g.dart')) - .toList() - ..sort(); - - expect(actualGeneratedFiles, retainedGeneratedFiles); - }); - }); - - group('Example Code Compilation', () { - test('example package should compile without major errors', () async { - final result = await Process.run('dart', [ - 'analyze', - ], workingDirectory: '../../example'); - - expect( - result.exitCode, - lessThanOrEqualTo(3), - reason: - 'Example package should not have major errors:\n${result.stderr}', - ); - }); - - test( - 'example package should compile and run tests successfully', - () async { - final result = await Process.run('dart', [ - 'test', - ], workingDirectory: '../../example'); - - expect( - result.exitCode, - equals(0), - reason: 'Example tests should pass:\n${result.stderr}', - ); - }, - ); - }); - - group('Build System Integration', () { - test('build.yaml should be properly configured', () async { - final buildFile = File('../../example/build.yaml'); - expect(buildFile.existsSync(), isTrue); - - final content = await buildFile.readAsString(); - expect(content, contains('ack_generator')); - }); - - test('pubspec.yaml should have correct dependencies', () async { - final pubspecFile = File('../../example/pubspec.yaml'); - expect(pubspecFile.existsSync(), isTrue); - - final content = await pubspecFile.readAsString(); - expect(content, contains('ack:')); - expect(content, contains('ack_generator:')); - expect(content, contains('build_runner:')); - }); - }); - - group('Generated Code Quality', () { - test('generated files should contain extension types', () async { - final generatedFiles = [ - '../../example/lib/args_getter_example.g.dart', - '../../example/lib/pet.g.dart', - '../../example/lib/schema_types_simple.g.dart', - ]; - - for (final filePath in generatedFiles) { - final content = await File(filePath).readAsString(); - expect(content, contains('// GENERATED CODE')); - expect(content, contains('extension type')); - } - }); - - test('README should describe AckType-based examples', () async { - final readme = await File('../../example/README.md').readAsString(); - expect(readme, contains('@AckType')); - expect(readme, isNot(contains('annotated classes'))); - }); - - test('ack_annotations README documents runnable AckType setup', () async { - final readme = await File( - '../../packages/ack_annotations/README.md', - ).readAsString(); - - expect(readme, contains('ack_generator')); - expect(readme, contains('build_runner')); - expect(readme, contains("import 'package:ack/ack.dart'")); - }); - - test( - 'api reference links to the current discriminated schemas anchor', - () async { - final content = await File( - '../../docs/api-reference/index.mdx', - ).readAsString(); - - expect(content, contains('#discriminated-schemas')); - expect( - content, - isNot( - contains('#ackdiscriminated-with-acktype-current-constraints'), - ), - ); - }, - ); - - test('ack_annotations library has a library-level doc comment', () async { - final content = await File( - '../../packages/ack_annotations/lib/ack_annotations.dart', - ).readAsString(); - - expect(content.trimLeft(), startsWith('///')); - }); - }); - - group('Example Functionality', () { - test( - 'args getter example should demonstrate passthrough access', - () async { - final content = await File( - '../../example/lib/args_getter_example.dart', - ).readAsString(); - - expect(content, contains('@AckType()')); - expect(content, contains('additionalProperties: true')); - }, - ); - }); - - group('Cross-Platform Compatibility', () { - test('schema JSON roundtrip produces consistent output', () { - final schema = Ack.object({ - 'name': Ack.string().minLength(1), - 'age': Ack.integer().min(0), - }); - - final jsonSchema1 = schema.toJsonSchema(); - final jsonSchema2 = schema.toJsonSchema(); - - expect(jsonSchema1, equals(jsonSchema2)); - expect(jsonSchema1, containsPair('type', 'object')); - expect(jsonSchema1, contains('properties')); - }); - }); - }); -} diff --git a/packages/ack_annotations/README.md b/packages/ack_annotations/README.md index 052ae9d4..2f5c6a1d 100644 --- a/packages/ack_annotations/README.md +++ b/packages/ack_annotations/README.md @@ -39,7 +39,7 @@ Ack part owns those declarations; `json_serializable` writes the structural field-mapping helpers into `user.g.dart`. Ack-only apps do not add JSON packages for generated models. The annotation package requires Dart 3.9. -Generate the wrapper with: +Generate the model with: ```bash dart run build_runner build diff --git a/packages/ack_annotations/pubspec.yaml b/packages/ack_annotations/pubspec.yaml index 1bc52dd8..1bccadf9 100644 --- a/packages/ack_annotations/pubspec.yaml +++ b/packages/ack_annotations/pubspec.yaml @@ -1,7 +1,7 @@ name: ack_annotations -description: AckType annotation for schema extension-type generation +description: AckType annotation for immutable Ack model generation version: 1.1.0 -repository: https://github.com/btwld/ack +repository: https://github.com/conceptadev/ack resolution: workspace environment: diff --git a/packages/ack_firebase_ai/pubspec.yaml b/packages/ack_firebase_ai/pubspec.yaml index e83c3d8a..9d274177 100644 --- a/packages/ack_firebase_ai/pubspec.yaml +++ b/packages/ack_firebase_ai/pubspec.yaml @@ -1,8 +1,8 @@ name: ack_firebase_ai description: Firebase AI (Gemini) schema converter for ACK validation library version: 1.1.0 -repository: https://github.com/btwld/ack -issue_tracker: https://github.com/btwld/ack/issues +repository: https://github.com/conceptadev/ack +issue_tracker: https://github.com/conceptadev/ack/issues resolution: workspace environment: diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index f30e7e7b..fdaf94aa 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -4,7 +4,13 @@ * Replace map-backed `@AckType()` extension types with immutable Dart model classes. Generated names no longer receive a `Type` suffix. -* Generated models no longer implement `Map`. +* Generated models no longer implement `Map`, `List`, or scalar interfaces. + Collection and scalar roots expose a `.value` field. +* Replace passthrough `.args` with `.additionalProperties`, and replace + generated `fromMap` / `toMap` with `fromJson` / `toJson`. +* Require annotated libraries to declare both `.ack.dart` and `.g.dart` parts. +* Reject one-way transforms and non-string runtime map keys; generated models + require a bidirectional, statically encodable contract. ### Added @@ -25,6 +31,15 @@ * Support named recursion, cross-file references, custom codecs, additional properties, and sealed discriminated model hierarchies. +### Migration + +* Rename generated `UserType` references to `User` unless `@AckType(name: ...)` + supplies an exact custom name. +* Use stored fields or `.value` instead of treating models as maps, lists, or + scalars. Use `.additionalProperties` for passthrough data. +* Replace `fromMap` / `toMap` calls with `fromJson` / `toJson`, add both part + directives, convert required transforms to codecs, and regenerate outputs. + ## 1.1.0 ### Changed diff --git a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart index 4fa37272..45730a6c 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart @@ -308,8 +308,6 @@ final class SchemaModelGraphBuilder { context: declaration.element, throughLazy: false, ), - encodeCapability: AckEncodeCapability.bidirectional, - sourceLocation: _location(declaration.element), description: _description(expression), ); } @@ -338,8 +336,6 @@ final class SchemaModelGraphBuilder { className: declaration.className, boundaryType: source.boundaryType, runtimeRef: source.runtimeRef, - encodeCapability: source.encodeCapability, - sourceLocation: _location(declaration.element), fields: source.fields, additionalProperties: source.additionalProperties, description: source.description, @@ -357,8 +353,6 @@ final class SchemaModelGraphBuilder { className: declaration.className, boundaryType: source.boundaryType, runtimeRef: source.runtimeRef, - encodeCapability: source.encodeCapability, - sourceLocation: _location(declaration.element), description: source.description, ); } @@ -443,8 +437,6 @@ final class SchemaModelGraphBuilder { className: declaration.className, boundaryType: types.boundary, runtimeRef: types.runtime, - encodeCapability: AckEncodeCapability.bidirectional, - sourceLocation: _location(declaration.element), fields: fields, additionalProperties: additionalProperties, description: _description(declaration.expression), @@ -525,8 +517,6 @@ final class SchemaModelGraphBuilder { className: branch.className, boundaryType: branch.boundaryType, runtimeRef: branch.runtimeRef, - encodeCapability: branch.encodeCapability, - sourceLocation: branch.sourceLocation, fields: branch.fields, additionalProperties: branch.additionalProperties, unionId: declaration.id, @@ -553,8 +543,6 @@ final class SchemaModelGraphBuilder { className: declaration.className, boundaryType: types.boundary, runtimeRef: types.runtime, - encodeCapability: AckEncodeCapability.bidirectional, - sourceLocation: _location(declaration.element), discriminatorKey: discriminatorKey, branches: branches, description: _description(declaration.expression), @@ -757,10 +745,7 @@ final class SchemaModelGraphBuilder { return const AckNullableTypeRef(AckScalarTypeRef('Object')); } if (type is TypeParameterType) { - return AckExternalTypeRef( - name: type.element.name ?? 'Object', - libraryUri: context.library?.uri ?? Uri.parse('dart:core'), - ); + return AckExternalTypeRef(name: type.element.name ?? 'Object'); } if (type is! InterfaceType) { throw InvalidGenerationSource( @@ -776,6 +761,17 @@ final class SchemaModelGraphBuilder { } else if (type.isDartCoreSet && type.typeArguments.length == 1) { result = AckSetTypeRef(_typeRef(type.typeArguments.single, context)); } else if (type.isDartCoreMap && type.typeArguments.length == 2) { + final keyType = type.typeArguments.first; + if (keyType is! InterfaceType || !keyType.isDartCoreString) { + throw InvalidGenerationSource( + 'Generated Ack models support only Map runtime types; ' + 'received ${type.getDisplayString()}.', + element: context, + todo: + 'Use a string-keyed map or codec the value to a supported ' + 'runtime type before generating the model.', + ); + } result = AckMapTypeRef(_typeRef(type.typeArguments[1], context)); } else if (type.element.library.uri.toString() == 'dart:core' && const { @@ -788,11 +784,9 @@ final class SchemaModelGraphBuilder { }.contains(name)) { result = AckScalarTypeRef(name); } else { - final owner = type.element.library.uri; result = AckExternalTypeRef( name: name, - libraryUri: owner, - importPrefix: _visiblePrefix(owner), + importPrefix: _visiblePrefix(type.element), typeArguments: [ for (final argument in type.typeArguments) _typeRef(argument, context), @@ -802,12 +796,24 @@ final class SchemaModelGraphBuilder { return nullable ? AckNullableTypeRef(result) : result; } - String? _visiblePrefix(Uri target) { + String? _visiblePrefix(InterfaceElement target) { + final name = target.name; + if (name == null) return null; + String? prefixed; for (final import in library.element.firstFragment.libraryImports) { - if (import.importedLibrary?.uri != target) continue; - return import.prefix?.element.name; + if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) { + continue; + } + final prefix = import.prefix?.element.name; + final candidate = prefix == null + ? import.namespace.get2(name) + : import.namespace.getPrefixed2(prefix, name); + if (candidate != target) continue; + if (prefix != null && prefix.isNotEmpty) { + prefixed ??= prefix; + } } - return null; + return prefixed; } _SchemaChain _chain(Expression expression) { @@ -1233,12 +1239,4 @@ final class SchemaModelGraphBuilder { return (name: name, expression: expression); } } - - AckSourceLocation _location(Element element) { - return AckSourceLocation( - libraryUri: element.library?.uri ?? library.element.uri, - offset: element.firstFragment.offset, - length: element.name?.length ?? 0, - ); - } } diff --git a/packages/ack_generator/lib/src/generator.dart b/packages/ack_generator/lib/src/generator.dart index 6e04fe8f..a63dd976 100644 --- a/packages/ack_generator/lib/src/generator.dart +++ b/packages/ack_generator/lib/src/generator.dart @@ -1,4 +1,5 @@ import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart' show AckModelAdapter, SchemaResult; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:build/build.dart'; @@ -12,6 +13,8 @@ import 'builders/model_emitter.dart'; /// `@AckType`. final class AckSchemaGenerator extends Generator { static const _ackTypeChecker = TypeChecker.typeNamed(AckType); + static const _ackModelAdapterChecker = TypeChecker.typeNamed(AckModelAdapter); + static const _schemaResultChecker = TypeChecker.typeNamed(SchemaResult); @override Future generate(LibraryReader library, BuildStep buildStep) async { @@ -54,7 +57,7 @@ final class AckSchemaGenerator extends Generator { final graph = await SchemaModelGraphBuilder(library).build(annotated); final specs = AckModelEmitter( - ackPrefix: _importPrefix(library, 'package:ack/ack.dart'), + ackPrefix: _ackRuntimeQualifier(library, annotated.first), ackTypePrefix: _ackTypeQualifier(library, annotated.first), ).emit(graph); return Library((b) => b.body.addAll(specs)) @@ -97,19 +100,54 @@ final class AckSchemaGenerator extends Generator { ); } - String? _importPrefix(LibraryReader library, String uri) { - for (final import in library.element.firstFragment.libraryImports) { - if (import.importedLibrary?.uri.toString() != uri) continue; - return import.prefix?.element.name; - } - return null; - } + /// Resolves the qualifier that exposes Ack's generated-model support types. + /// + /// Looking through import namespaces supports package barrels, local barrels, + /// prefixes, and combinators without tying generation to one exact URI. + String? _ackRuntimeQualifier( + LibraryReader library, + Element annotatedElement, + ) => _visibleQualifier( + library, + annotatedElement, + requiredTypes: const { + 'AckModelAdapter': _ackModelAdapterChecker, + 'SchemaResult': _schemaResultChecker, + }, + message: + 'Generated Ack models require visible exact AckModelAdapter and ' + 'SchemaResult imports in this library.', + todo: + 'Import package:ack/ack.dart, directly or through a barrel, and ' + 'ensure AckModelAdapter and SchemaResult are exposed.', + ); /// Resolves the visible `AckType` qualifier for generated JSON markers. /// /// Uses import namespaces so barrel re-exports and `show` combinators work. /// Prefixed imports win over unprefixed ones, in import order. - String? _ackTypeQualifier(LibraryReader library, Element annotatedElement) { + String? _ackTypeQualifier( + LibraryReader library, + Element annotatedElement, + ) => _visibleQualifier( + library, + annotatedElement, + requiredTypes: const {'AckType': _ackTypeChecker}, + message: + 'Generated @AckType.jsonSerializable requires a visible exact AckType ' + 'import in this library.', + todo: + 'Import AckType from ack_annotations, using the same prefix as ' + '@AckType() when one is present.', + ); + + String? _visibleQualifier( + LibraryReader library, + Element annotatedElement, { + required Map requiredTypes, + required String message, + required String todo, + }) { String? prefixed; var hasUnprefixed = false; for (final import in library.element.firstFragment.libraryImports) { @@ -117,31 +155,25 @@ final class AckSchemaGenerator extends Generator { continue; } final prefix = import.prefix?.element.name; - final candidate = prefix == null - ? import.namespace.get2('AckType') - : import.namespace.getPrefixed2(prefix, 'AckType'); - if (candidate == null || !_ackTypeChecker.isExactly(candidate)) { - continue; - } + final exposesRequiredTypes = requiredTypes.entries.every((entry) { + final candidate = prefix == null + ? import.namespace.get2(entry.key) + : import.namespace.getPrefixed2(prefix, entry.key); + return candidate != null && entry.value.isExactly(candidate); + }); + if (!exposesRequiredTypes) continue; if (prefix != null && prefix.isNotEmpty) { prefixed ??= prefix; } else { hasUnprefixed = true; } } - if (prefixed != null) { - return prefixed; - } - if (hasUnprefixed) { - return null; - } + if (prefixed != null) return prefixed; + if (hasUnprefixed) return null; throw InvalidGenerationSource( - 'Generated @AckType.jsonSerializable requires a visible exact AckType ' - 'import in this library.', + message, element: annotatedElement, - todo: - 'Import AckType from ack_annotations, using the same prefix as ' - '@AckType() when one is present.', + todo: todo, ); } } diff --git a/packages/ack_generator/lib/src/models/schema_model_graph.dart b/packages/ack_generator/lib/src/models/schema_model_graph.dart index 1f48963c..8a78ebc8 100644 --- a/packages/ack_generator/lib/src/models/schema_model_graph.dart +++ b/packages/ack_generator/lib/src/models/schema_model_graph.dart @@ -19,10 +19,6 @@ final class AckSchemaId { String toString() => '$libraryUri::$declarationName'; } -/// Whether every value in a generated model graph can be encoded back to the -/// schema boundary. -enum AckEncodeCapability { bidirectional, parseOnly } - /// Input-presence semantics for an object field. /// /// Presence and nullability are deliberately separate. A field can be required @@ -35,8 +31,6 @@ enum AckFieldPresence { required, optional, defaulted } /// analysis layer. Emitters consume this structural representation instead. sealed class AckTypeRef { const AckTypeRef(); - - Iterable get modelDependencies => const []; } /// A nullable structural type reference. @@ -44,9 +38,6 @@ final class AckNullableTypeRef extends AckTypeRef { const AckNullableTypeRef(this.inner); final AckTypeRef inner; - - @override - Iterable get modelDependencies => inner.modelDependencies; } /// A core scalar such as `String`, `int`, `double`, `bool`, or `num`. @@ -60,13 +51,11 @@ final class AckScalarTypeRef extends AckTypeRef { final class AckExternalTypeRef extends AckTypeRef { const AckExternalTypeRef({ required this.name, - required this.libraryUri, this.importPrefix, this.typeArguments = const [], }); final String name; - final Uri libraryUri; final String? importPrefix; final List typeArguments; @@ -74,13 +63,6 @@ final class AckExternalTypeRef extends AckTypeRef { final prefix = importPrefix; return prefix == null || prefix.isEmpty ? name : '$prefix.$name'; } - - @override - Iterable get modelDependencies sync* { - for (final argument in typeArguments) { - yield* argument.modelDependencies; - } - } } /// A reference to another generated Ack model. @@ -101,36 +83,24 @@ final class AckModelTypeRef extends AckTypeRef { final prefix = importPrefix; return prefix == null || prefix.isEmpty ? className : '$prefix.$className'; } - - @override - Iterable get modelDependencies => [schemaId]; } final class AckListTypeRef extends AckTypeRef { const AckListTypeRef(this.elementType); final AckTypeRef elementType; - - @override - Iterable get modelDependencies => elementType.modelDependencies; } final class AckSetTypeRef extends AckTypeRef { const AckSetTypeRef(this.elementType); final AckTypeRef elementType; - - @override - Iterable get modelDependencies => elementType.modelDependencies; } final class AckMapTypeRef extends AckTypeRef { const AckMapTypeRef(this.valueType); final AckTypeRef valueType; - - @override - Iterable get modelDependencies => valueType.modelDependencies; } /// A field in a normalized object model. @@ -154,19 +124,6 @@ final class AckFieldNode { bool get isRequired => presence != AckFieldPresence.optional; } -/// Stable source position for diagnostics without leaking analyzer objects. -final class AckSourceLocation { - const AckSourceLocation({ - required this.libraryUri, - required this.offset, - required this.length, - }); - - final Uri libraryUri; - final int offset; - final int length; -} - /// Base node for a generated class or value object. sealed class AckModelNode { const AckModelNode({ @@ -174,8 +131,6 @@ sealed class AckModelNode { required this.className, required this.boundaryType, required this.runtimeRef, - required this.encodeCapability, - required this.sourceLocation, this.description, }); @@ -183,11 +138,7 @@ sealed class AckModelNode { final String className; final AckTypeRef boundaryType; final AckTypeRef runtimeRef; - final AckEncodeCapability encodeCapability; - final AckSourceLocation sourceLocation; final String? description; - - Iterable get dependencies; } /// A regular immutable class generated from `Ack.object(...)`. @@ -197,8 +148,6 @@ final class AckObjectModelNode extends AckModelNode { required super.className, required super.boundaryType, required super.runtimeRef, - required super.encodeCapability, - required super.sourceLocation, required Iterable fields, this.additionalProperties = false, this.unionId, @@ -212,13 +161,6 @@ final class AckObjectModelNode extends AckModelNode { final AckSchemaId? unionId; final String? discriminatorKey; final String? discriminatorValue; - - @override - Iterable get dependencies sync* { - for (final field in fields) { - yield* field.runtimeRef.modelDependencies; - } - } } /// A value class generated from primitive, codec, list, or map root schemas. @@ -228,13 +170,8 @@ final class AckValueModelNode extends AckModelNode { required super.className, required super.boundaryType, required super.runtimeRef, - required super.encodeCapability, - required super.sourceLocation, super.description, }); - - @override - Iterable get dependencies => runtimeRef.modelDependencies; } /// A sealed class generated from `Ack.discriminated(...)`. @@ -244,8 +181,6 @@ final class AckUnionModelNode extends AckModelNode { required super.className, required super.boundaryType, required super.runtimeRef, - required super.encodeCapability, - required super.sourceLocation, required this.discriminatorKey, required Map branches, super.description, @@ -253,9 +188,6 @@ final class AckUnionModelNode extends AckModelNode { final String discriminatorKey; final Map branches; - - @override - Iterable get dependencies => branches.values; } /// Resolution state used while building recursive model graphs. @@ -313,11 +245,4 @@ final class AckModelGraph { } _nodes[node.id] = node; } - - /// Returns source-stable dependencies for diagnostics and tests. - List dependenciesOf(AckSchemaId id) { - final node = _nodes[id]; - if (node == null) return const []; - return List.unmodifiable(node.dependencies.toSet()); - } } diff --git a/packages/ack_generator/lib/src/utils/doc_comment_utils.dart b/packages/ack_generator/lib/src/utils/doc_comment_utils.dart deleted file mode 100644 index 5d707ecb..00000000 --- a/packages/ack_generator/lib/src/utils/doc_comment_utils.dart +++ /dev/null @@ -1,48 +0,0 @@ -/// Utilities for parsing Dart documentation comments. -library; - -/// Parses a Dart documentation comment into a clean description string. -/// -/// Supports `///` and `/** ... */` doc comments, including multi-line variants, -/// and returns cleaned text as a single string. -/// -/// Returns `null` if the comment is empty or cannot be parsed. -/// -/// Example: -/// ```dart -/// final description = parseDocComment('/// User name field'); -/// // Returns: 'User name field' -/// ``` -String? parseDocComment(String? docComment) { - if (docComment == null || docComment.isEmpty) { - return null; - } - - // Handle /// style comments (check startsWith to avoid false matches) - if (docComment.startsWith('///')) { - final lines = docComment - .split('\n') - .map((line) => line.replaceFirst(RegExp(r'^\s*///\s?'), '')) - .where((line) => line.isNotEmpty) - .toList(); - - if (lines.isEmpty) return null; - return lines.join(' ').trim(); - } - - // Handle /** */ style comments - if (docComment.startsWith('/**')) { - final content = docComment - .replaceFirst(RegExp(r'^/\*\*\s*'), '') - .replaceFirst(RegExp(r'\s*\*/$'), '') - .split('\n') - .map((line) => line.replaceFirst(RegExp(r'^\s*\*\s?'), '')) - .where((line) => line.isNotEmpty) - .join(' ') - .trim(); - - return content.isEmpty ? null : content; - } - - return null; -} diff --git a/packages/ack_generator/pubspec.yaml b/packages/ack_generator/pubspec.yaml index 192ee1b7..9685baf6 100644 --- a/packages/ack_generator/pubspec.yaml +++ b/packages/ack_generator/pubspec.yaml @@ -1,8 +1,8 @@ name: ack_generator description: Code generator for immutable model classes from Ack schemas version: 1.1.0 -repository: https://github.com/btwld/ack -issue_tracker: https://github.com/btwld/ack/issues +repository: https://github.com/conceptadev/ack +issue_tracker: https://github.com/conceptadev/ack/issues resolution: workspace environment: @@ -22,14 +22,10 @@ dependencies: ack: ^1.0.0 ack_annotations: ^1.0.0 - # Utilities - collection: ^1.18.0 - logging: ^1.3.0 - meta: ^1.15.0 - dev_dependencies: build_runner: ^2.1.7 build_test: ^3.1.0 + logging: ^1.3.0 test: ^1.25.15 path: ^1.9.0 # Code quality diff --git a/packages/ack_generator/test/integration/example_folder_build_test.dart b/packages/ack_generator/test/integration/example_folder_build_test.dart index 2280b9c2..7b7f6a92 100644 --- a/packages/ack_generator/test/integration/example_folder_build_test.dart +++ b/packages/ack_generator/test/integration/example_folder_build_test.dart @@ -109,8 +109,8 @@ dependency_overrides: final first = _generatedContents(temporaryExample); expect(first.keys, { - 'lib/args_getter_example.ack.dart', - 'lib/args_getter_example.g.dart', + 'lib/additional_properties_example.ack.dart', + 'lib/additional_properties_example.g.dart', 'lib/pet.ack.dart', 'lib/pet.g.dart', 'lib/schema_types_discriminated.ack.dart', diff --git a/packages/ack_generator/test/integration/json_serializable_build_test.dart b/packages/ack_generator/test/integration/json_serializable_build_test.dart index f4a86a8e..1d6672b0 100644 --- a/packages/ack_generator/test/integration/json_serializable_build_test.dart +++ b/packages/ack_generator/test/integration/json_serializable_build_test.dart @@ -251,19 +251,24 @@ dependency_overrides: path: ${p.join(projectRoot.path, 'packages', 'ack_annotations')} '''); File( - p.join(temporary.path, 'lib', 'annotations.dart'), - ).writeAsStringSync( - "export 'package:ack_annotations/ack_annotations.dart';\n", + p.join(temporary.path, 'lib', 'role.dart'), + ).writeAsStringSync('enum Role { admin, member }\n'); + File(p.join(temporary.path, 'lib', 'support.dart')).writeAsStringSync( + "export 'package:ack/ack.dart';\n" + "export 'package:ack_annotations/ack_annotations.dart';\n" + "export 'role.dart';\n", ); File(p.join(temporary.path, 'lib', 'user.dart')).writeAsStringSync(r''' -import 'package:ack/ack.dart'; -import 'annotations.dart' as annotations show AckType; +import 'support.dart' as support; part 'user.ack.dart'; part 'user.g.dart'; -@annotations.AckType() -final userSchema = Ack.object({'name': Ack.string()}); +@support.AckType() +final userSchema = support.Ack.object({ + 'name': support.Ack.string(), + 'role': support.Ack.enumValues(support.Role.values), +}); '''); File( p.join(temporary.path, 'test', 'runtime_test.dart'), @@ -273,7 +278,10 @@ import 'package:test/test.dart'; void main() { test('prefixed barrel models compile and round-trip', () { - expect(User.parse({'name': 'Ada'}).toJson(), {'name': 'Ada'}); + expect(User.parse({'name': 'Ada', 'role': 'admin'}).toJson(), { + 'name': 'Ada', + 'role': 'admin', + }); }); } '''); @@ -291,7 +299,9 @@ void main() { final ackPart = File( p.join(temporary.path, 'lib', 'user.ack.dart'), ).readAsStringSync(); - expect(ackPart, contains('@annotations.AckType.jsonSerializable')); + expect(ackPart, contains('@support.AckType.jsonSerializable')); + expect(ackPart, contains('support.AckModelAdapter')); + expect(ackPart, contains('final support.Role role;')); _expectSuccess(await _run(temporary, ['test']), 'dart test'); } finally { temporary.deleteSync(recursive: true); diff --git a/packages/ack_generator/test/integration/v2_models_test.dart b/packages/ack_generator/test/integration/v2_models_test.dart index d55a154e..5eb80180 100644 --- a/packages/ack_generator/test/integration/v2_models_test.dart +++ b/packages/ack_generator/test/integration/v2_models_test.dart @@ -143,6 +143,32 @@ final personSchema = Ack.object({ ); }); + test('preserves external type qualifiers through prefixed barrels', () async { + await _build( + { + 'role.dart': 'enum Role { admin, member }', + 'types.dart': "export 'role.dart';", + 'user.dart': + ''' +$_imports +import 'types.dart' as types; +part 'user.ack.dart'; +part 'user.g.dart'; + +@AckType() +final userSchema = Ack.object({ + 'role': Ack.enumValues(types.Role.values), +}); +''', + }, + outputs: { + 'test_pkg|lib/user.ack.dart': decodedMatches( + contains('final types.Role role;'), + ), + }, + ); + }); + test('emits sealed unions with final same-library branches', () async { await _build( { @@ -440,6 +466,59 @@ final userSchema = Ack.object({'name': Ack.string()}); }, ); + test('preserves Ack runtime qualifiers through prefixed barrels', () async { + await _build( + { + 'support.dart': ''' +export 'package:ack/ack.dart'; +export 'package:ack_annotations/ack_annotations.dart'; +''', + 'schema.dart': ''' +import 'support.dart' as support; + +part 'schema.ack.dart'; +part 'schema.g.dart'; + +@support.AckType() +final userSchema = support.Ack.object({'name': support.Ack.string()}); +''', + }, + outputs: { + 'test_pkg|lib/schema.ack.dart': decodedMatches( + allOf([ + contains('support.AckModelAdapter'), + contains('support.SchemaResult'), + ]), + ), + }, + ); + }); + + test('rejects non-string map keys in generated runtime types', () async { + final messages = {}; + await _build( + { + 'bad.dart': + ''' +$_imports +part 'bad.ack.dart'; +part 'bad.g.dart'; + +@AckType() +final valuesSchema = Ack.string().codec>( + decode: (value) => {1: value}, + encode: (value) => value.values.single, +); +''', + }, + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE') messages.add(log.message); + }, + ); + expect(messages.single, contains('Map')); + }); + test('rejects field names that collide after bridge derivation', () async { final messages = {}; await _build( diff --git a/packages/ack_json_schema_builder/README.md b/packages/ack_json_schema_builder/README.md index 4402d40d..70c0af83 100644 --- a/packages/ack_json_schema_builder/README.md +++ b/packages/ack_json_schema_builder/README.md @@ -19,7 +19,7 @@ dependencies: ### Compatibility -Requires `json_schema_builder: >=0.1.3 <1.0.0` as a peer dependency. Report [compatibility issues](https://github.com/btwld/ack/issues). +Requires `json_schema_builder: >=0.1.3 <1.0.0` as a peer dependency. Report [compatibility issues](https://github.com/conceptadev/ack/issues). ## Conversion Model @@ -137,7 +137,7 @@ For contribution guidelines, see [CONTRIBUTING.md](../../CONTRIBUTING.md) in the ## License -This package is part of the [ACK](https://github.com/btwld/ack) monorepo. +This package is part of the [ACK](https://github.com/conceptadev/ack) monorepo. ## Related Packages diff --git a/packages/ack_json_schema_builder/pubspec.yaml b/packages/ack_json_schema_builder/pubspec.yaml index c41f08ca..1ffb5a21 100644 --- a/packages/ack_json_schema_builder/pubspec.yaml +++ b/packages/ack_json_schema_builder/pubspec.yaml @@ -1,8 +1,8 @@ name: ack_json_schema_builder description: JSON Schema Builder converter for ACK validation library version: 1.1.0 -repository: https://github.com/btwld/ack -issue_tracker: https://github.com/btwld/ack/issues +repository: https://github.com/conceptadev/ack +issue_tracker: https://github.com/conceptadev/ack/issues resolution: workspace environment: diff --git a/scripts/update_release_changelog.dart b/scripts/update_release_changelog.dart index 6dac3920..37211654 100644 --- a/scripts/update_release_changelog.dart +++ b/scripts/update_release_changelog.dart @@ -32,7 +32,7 @@ void main(List args) { ? args[1].trim() : 'v$version'; - final releaseUrl = 'https://github.com/btwld/ack/releases/tag/$tag'; + final releaseUrl = 'https://github.com/conceptadev/ack/releases/tag/$tag'; final changelogPaths = publishableAckPackages .map((p) => 'packages/$p/CHANGELOG.md') .toList(); diff --git a/tools/package.json b/tools/package.json index 480b1d69..b459e158 100644 --- a/tools/package.json +++ b/tools/package.json @@ -33,7 +33,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/btwld/ack.git", + "url": "https://github.com/conceptadev/ack.git", "directory": "tools" } } From 7600c0570a32439cdd20645ff4faf696e84a2a49 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sun, 23 Aug 2026 15:40:53 -0400 Subject: [PATCH 6/7] fix(generator): reject unsupported AckType shapes by schema identity Follow referenced schema variables to their initializer and stop swallowing Errors in safeParseAs so unsupported models fail at generate time instead of producing unencodable classes. Co-authored-by: Cursor --- docs/api-reference/index.mdx | 8 +- docs/architecture/acktype-model-generation.md | 8 +- docs/core-concepts/typesafe-schemas.mdx | 5 +- example/lib/schema_types_primitives.ack.dart | 4 +- .../additional_properties_example_test.dart | 14 + example/test/primitive_types_test.dart | 8 +- .../test/schema_types_discriminated_test.dart | 14 + .../test/schema_types_edge_cases_test.dart | 14 + .../ack/lib/src/schemas/codec_schema.dart | 3 + packages/ack/lib/src/schemas/schema.dart | 12 +- .../test/models/ack_model_adapter_test.dart | 24 ++ .../ack/test/schemas/core_schema_test.dart | 53 +++- .../lib/src/ack_generated_json.dart | 6 + packages/ack_generator/CHANGELOG.md | 2 - packages/ack_generator/README.md | 3 +- .../analyzer/schema_model_graph_builder.dart | 249 ++++++++++++++++-- packages/ack_generator/lib/src/builder.dart | 8 + .../lib/src/builders/model_emitter.dart | 12 +- packages/ack_generator/lib/src/generator.dart | 18 +- .../lib/src/json/ack_json_generator.dart | 5 +- .../example_folder_build_test.dart | 101 ++++++- .../test/integration/v2_graph_test.dart | 208 ++++++++++++++- .../test/integration/v2_models_test.dart | 76 +++++- .../test/src/generator_test.dart | 24 ++ .../test/src/one_way_wrappers_test.dart | 26 ++ 25 files changed, 848 insertions(+), 57 deletions(-) create mode 100644 packages/ack_generator/test/src/one_way_wrappers_test.dart diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index f498fb17..b1bba945 100644 --- a/docs/api-reference/index.mdx +++ b/docs/api-reference/index.mdx @@ -288,10 +288,16 @@ Annotate a top-level schema variable or getter. The schema stays in your source **Unsupported:** nullable roots, one-way transforms, `Ack.any()`, `Ack.anyOf()`, bare `Ack.instance()`, and anonymous inline objects For `Ack.discriminated(...)` constraints with `@AckType`, see -[Type-safe Schemas](../core-concepts/typesafe-schemas.mdx#discriminated-schemas). +[Type-safe Schemas](../core-concepts/typesafe-schemas.mdx#discriminated-unions). **Example:** ```dart +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'user.ack.dart'; +part 'user.g.dart'; + @AckType() final userSchema = Ack.object({ 'name': Ack.string(), diff --git a/docs/architecture/acktype-model-generation.md b/docs/architecture/acktype-model-generation.md index ad6b6d46..708e4a32 100644 --- a/docs/architecture/acktype-model-generation.md +++ b/docs/architecture/acktype-model-generation.md @@ -98,6 +98,12 @@ collection by the public constructor. Generated map runtime types must use Passthrough objects store unknown values in an unmodifiable `additionalProperties` map. Encoding writes additional entries first and declared fields second, so unknown data can't replace a declared property. +`toJson()` returns a fresh top-level collection; nested values are the +schema's encode output. + +Referenced schema variables are followed to their initializer so unsupported +shapes are rejected regardless of whether they were written inline or assigned +to a local. ## Unsupported shapes @@ -136,7 +142,7 @@ and Ack adds the discriminator to the runtime map. The generator suite uses real workspace Ack package sources. Process fixtures build temporary packages from no generated output, run strict analysis and runtime tests, prove an Ack-only consumer produces both outputs, then rebuild -and compare generated bytes for determinism. The checked example package keeps +and compare generated output for determinism. The checked example package keeps its generated `.ack.dart` and `.g.dart` files as reviewable fixtures. ## Migration from extension-based generation diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index 90de2154..71f0e7fd 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -76,7 +76,10 @@ Generated models don't implement `Map`. Use `toJson()` when a map is needed. - same-library discriminated unions. One-way `.transform()` calls are rejected because a generated model must encode -back to its boundary value. Replace them with `.codec(decode: ..., encode: ...)`. +back to its boundary value. `.trim()`, `.toLowerCase()`, and `.toUpperCase()` +are one-way wrappers around `transform()` and must become `.codec()` for +`@AckType` schemas. Replace parse-only transforms with +`.codec(decode: ..., encode: ...)`. The generator also rejects nullable roots, `Ack.any()`, `Ack.anyOf()`, bare `Ack.instance()`, anonymous inline object fields, unresolved dynamic schema diff --git a/example/lib/schema_types_primitives.ack.dart b/example/lib/schema_types_primitives.ack.dart index 4bf18267..5ddea706 100644 --- a/example/lib/schema_types_primitives.ack.dart +++ b/example/lib/schema_types_primitives.ack.dart @@ -180,7 +180,7 @@ final class Tags { static SchemaResult safeParse(Object? input) => $ack.safeParse(input); - List toJson() => $ack.encode(this); + List toJson() => List.of($ack.encode(this)); SchemaResult> safeToJson() => $ack.safeEncode(this); @@ -220,7 +220,7 @@ final class Scores { static SchemaResult safeParse(Object? input) => $ack.safeParse(input); - List toJson() => $ack.encode(this); + List toJson() => List.of($ack.encode(this)); SchemaResult> safeToJson() => $ack.safeEncode(this); diff --git a/example/test/additional_properties_example_test.dart b/example/test/additional_properties_example_test.dart index b61497d9..53840b08 100644 --- a/example/test/additional_properties_example_test.dart +++ b/example/test/additional_properties_example_test.dart @@ -17,6 +17,20 @@ void main() { expect(config.additionalProperties, {'theme': 'dark', 'retries': 3}); }); + test('encode writes extras first and declared fields win', () { + final config = UserConfig( + username: 'leo', + email: 'leo@example.com', + additionalProperties: {'theme': 'dark', 'username': 'intruder'}, + ); + final json = config.toJson(); + + expect(json['username'], 'leo'); + expect(json['email'], 'leo@example.com'); + expect(json['theme'], 'dark'); + expect(json.containsKey('additionalProperties'), isFalse); + }); + test('passthrough additional properties are preserved', () { final request = ApiRequest.parse({ 'method': 'POST', diff --git a/example/test/primitive_types_test.dart b/example/test/primitive_types_test.dart index a15f439d..966f79f8 100644 --- a/example/test/primitive_types_test.dart +++ b/example/test/primitive_types_test.dart @@ -1,6 +1,6 @@ import 'package:ack/ack.dart'; import 'package:ack_example/schema_types_primitives.dart'; -import 'package:test/test.dart'; +import 'package:test/test.dart' hide Tags; /// Tests for primitive schemas. /// @@ -117,6 +117,12 @@ void main() { final uppercaseTags = tags.map((t) => t.toUpperCase()).toList(); expect(uppercaseTags, ['DART', 'FLUTTER', 'VALIDATION']); }); + + test('Tags.toJson returns a mutable top-level list', () { + final json = Tags.parse(['dart', 'flutter']).toJson(); + json.add('ack'); + expect(json, ['dart', 'flutter', 'ack']); + }); }); group('EnumValues Schema', () { diff --git a/example/test/schema_types_discriminated_test.dart b/example/test/schema_types_discriminated_test.dart index d00e8e86..379d2cb3 100644 --- a/example/test/schema_types_discriminated_test.dart +++ b/example/test/schema_types_discriminated_test.dart @@ -1,3 +1,4 @@ +import 'package:ack/ack.dart'; import 'package:ack_example/pet.dart' as explicit; import 'package:ack_example/schema_types_discriminated.dart' as omitted; import 'package:test/test.dart'; @@ -41,5 +42,18 @@ void main() { throwsA(anything), ); }); + + test('unknown discriminator fails via the schema', () { + expect( + () => explicit.Pet.parse({'type': 'fish'}), + throwsA(isA()), + ); + }); + + test('unchecked constructor encode fails schema constraints', () { + final cat = explicit.Cat(lives: 99); + expect(cat.toJson, throwsA(isA())); + expect(cat.safeToJson().isFail, isTrue); + }); }); } diff --git a/example/test/schema_types_edge_cases_test.dart b/example/test/schema_types_edge_cases_test.dart index aca517b8..848f5685 100644 --- a/example/test/schema_types_edge_cases_test.dart +++ b/example/test/schema_types_edge_cases_test.dart @@ -54,6 +54,20 @@ void main() { expect(modifier.nullableOptional, isNull); }); + test('required nullable encodes null and optional null is omitted', () { + final modifier = Modifier( + requiredField: 'value', + nullableField: null, + ); + final json = modifier.toJson(); + + expect(json.containsKey('nullableField'), isTrue); + expect(json['nullableField'], isNull); + expect(json.containsKey('optionalField'), isFalse); + expect(json.containsKey('optionalNullable'), isFalse); + expect(json.containsKey('nullableOptional'), isFalse); + }); + test('empty and minimal schemas still parse', () { final empty = Empty.parse({}); final minimal = Minimal.parse({'id': 'abc-123'}); diff --git a/packages/ack/lib/src/schemas/codec_schema.dart b/packages/ack/lib/src/schemas/codec_schema.dart index 46cfb33b..8c4418b1 100644 --- a/packages/ack/lib/src/schemas/codec_schema.dart +++ b/packages/ack/lib/src/schemas/codec_schema.dart @@ -144,6 +144,9 @@ final class CodecSchema try { intermediate = encode(runtime); } catch (e, st) { + if (e is Error) { + Error.throwWithStackTrace(e, st); + } return SchemaResult.fail( SchemaEncodeError.encoderThrew( message: 'Codec encode failed: ${e.toString()}', diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 7e6e3f63..3cab73e0 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -203,9 +203,7 @@ abstract class AckSchema { // a programmer or runtime defect rather than invalid input. Preserve its // identity and original stack trace instead of disguising it as a failed // validation result. - if (error is Error) { - Error.throwWithStackTrace(error, stackTrace); - } + _rethrowIfError(error, stackTrace); return SchemaResult.fail( SchemaValidationError( message: message, @@ -405,6 +403,7 @@ abstract class AckSchema { try { return SchemaResult.ok(map(validated)); } catch (e, st) { + _rethrowIfError(e, st); return SchemaResult.fail( SchemaTransformError( message: 'Transformation failed: ${e.toString()}', @@ -439,6 +438,7 @@ abstract class AckSchema { try { return encodeWithContext(value, context); } catch (e, st) { + _rethrowIfError(e, st); return SchemaResult.fail( SchemaEncodeError.encoderThrew( message: 'Encoder threw: ${e.toString()}', @@ -450,6 +450,12 @@ abstract class AckSchema { } } + void _rethrowIfError(Object error, StackTrace stackTrace) { + if (error is Error) { + Error.throwWithStackTrace(error, stackTrace); + } + } + /// Encodes a runtime value to a boundary value, throwing on failure. Boundary? encode(Runtime? value, {String? debugName}) { final result = safeEncode(value, debugName: debugName); diff --git a/packages/ack/test/models/ack_model_adapter_test.dart b/packages/ack/test/models/ack_model_adapter_test.dart index a761afbc..0666eae0 100644 --- a/packages/ack/test/models/ack_model_adapter_test.dart +++ b/packages/ack/test/models/ack_model_adapter_test.dart @@ -9,6 +9,12 @@ final _userAdapter = AckModelAdapter( toRuntime: (user) => user.toRuntime(), ); +final _boomAdapter = AckModelAdapter( + schema: () => _userSchema, + fromRuntime: (_) => throw TypeError(), + toRuntime: (user) => user.toRuntime(), +); + final class _User { const _User({required this.name, required this.age}); @@ -49,5 +55,23 @@ void main() { expect(result.isFail, isTrue); }); + + test('parse wraps validation failures as AckException', () { + expect( + () => _userAdapter.parse({'name': 'Ada', 'age': '36'}), + throwsA(isA()), + ); + }); + + test('fromRuntime TypeError propagates from parse and safeParse', () { + expect( + () => _boomAdapter.parse({'name': 'Ada', 'age': 36}), + throwsA(isA()), + ); + expect( + () => _boomAdapter.safeParse({'name': 'Ada', 'age': 36}), + throwsA(isA()), + ); + }); }); } diff --git a/packages/ack/test/schemas/core_schema_test.dart b/packages/ack/test/schemas/core_schema_test.dart index c5155081..839a85ee 100644 --- a/packages/ack/test/schemas/core_schema_test.dart +++ b/packages/ack/test/schemas/core_schema_test.dart @@ -148,12 +148,12 @@ void main() { expect(result.getError(), isA()); }); - test('safeParseAs wraps mapper exceptions as SchemaTransformError', () { + test('safeParseAs wraps mapper Exceptions as SchemaTransformError', () { final schema = Ack.integer(); final result = schema.safeParseAs( 5, - (_) => throw StateError('mapper failed'), + (_) => throw FormatException('mapper failed'), debugName: 'numberAdapter', ); @@ -165,17 +165,26 @@ void main() { expect(error.schema, same(schema)); expect( error.message, - contains('Transformation failed: Bad state: mapper failed'), + contains('Transformation failed: FormatException: mapper failed'), ); }); - test('parseAs throws AckException for mapper exceptions', () { + test('safeParseAs lets mapper Errors propagate', () { + final schema = Ack.integer(); + + expect( + () => schema.safeParseAs(5, (_) => throw StateError('mapper failed')), + throwsA(isA()), + ); + }); + + test('parseAs throws AckException for mapper Exceptions', () { final schema = Ack.integer(); expect( () => schema.parseAs( 7, - (_) => throw StateError('mapper exploded'), + (_) => throw FormatException('mapper exploded'), debugName: 'mapperDebugName', ), throwsA( @@ -191,6 +200,40 @@ void main() { ), ); }); + + test('parseAs lets mapper Errors propagate', () { + final schema = Ack.integer(); + + expect( + () => schema.parseAs(7, (_) => throw StateError('mapper exploded')), + throwsA(isA()), + ); + }); + + test('safeEncode wraps encoder Exceptions as SchemaEncodeError', () { + final schema = Ack.string().codec( + decode: int.parse, + encode: (_) => throw FormatException('encoder failed'), + ); + + final result = schema.safeEncode(1); + expect(result.isFail, isTrue); + final error = result.getError(); + expect(error, isA()); + expect( + (error as SchemaEncodeError).kind, + SchemaEncodeFailureKind.encoderThrew, + ); + }); + + test('safeEncode lets encoder Errors propagate', () { + final schema = Ack.string().codec( + decode: int.parse, + encode: (_) => throw StateError('encoder failed'), + ); + + expect(() => schema.safeEncode(1), throwsA(isA())); + }); }); group('ListSchema', () { diff --git a/packages/ack_annotations/lib/src/ack_generated_json.dart b/packages/ack_annotations/lib/src/ack_generated_json.dart index 0b914f25..d8490ec1 100644 --- a/packages/ack_annotations/lib/src/ack_generated_json.dart +++ b/packages/ack_annotations/lib/src/ack_generated_json.dart @@ -6,6 +6,12 @@ import 'package:json_annotation/json_annotation.dart'; /// [AckType.jsonSerializable] so the internal JSON builder can delegate /// structural mapping to `json_serializable` without a literal /// `@JsonSerializable` annotation that the ordinary builder would also claim. +/// +/// The marker must hold a real [JsonSerializable] constant because +/// `JsonSerializableGenerator.generateForAnnotatedElement` → `mergeConfig` +/// reads annotation fields via `ConstantReader`, and source_gen's null reader +/// throws `UnsupportedError`. That is why `json_annotation` is a runtime +/// dependency of this package. class AckGeneratedJson { /// Creates the internal JSON-mapping marker. const AckGeneratedJson({ diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index fdaf94aa..087701d1 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -16,8 +16,6 @@ * Generate `parse`, `safeParse`, `fromJson`, `toJson`, `safeToJson`, unchecked constructors, and public `$ack` adapters for model classes. -* Add `AckModelAdapter` for codec-safe conversion between Ack runtime values and - generated models. * Add a normalized schema graph foundation for imported, recursive, and discriminated model dependencies. diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index 03bd92d5..bcfaf627 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -57,7 +57,8 @@ are copied recursively into unmodifiable collections. Generation rejects shapes without a useful static, encodable model contract: -- one-way `.transform()` calls; use `.codec()` with an encoder; +- one-way `.transform()` calls, including `.trim()`, `.toLowerCase()`, and + `.toUpperCase()`; use `.codec()` with an encoder; - nullable roots; - `Ack.any()`, `Ack.anyOf()`, and bare `Ack.instance()`; - anonymous inline object fields and unresolved dynamic schema factories; diff --git a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart index 45730a6c..2e83dd03 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart @@ -1,3 +1,5 @@ +import 'package:ack/ack.dart' + show AckSchema, AnyOfSchema, AnySchema, InstanceSchema; import 'package:ack_annotations/ack_annotations.dart'; import 'package:analyzer/dart/analysis/results.dart'; import 'package:analyzer/dart/ast/ast.dart'; @@ -138,11 +140,43 @@ final class SchemaModelGraphBuilder { '_ackImmutableCopyMap', }; + static const _oneWayTransformMethods = { + 'transform', + 'trim', + 'toLowerCase', + 'toUpperCase', + }; + + static const _maxReferenceDepth = 16; + + static const _ackTypeChecker = TypeChecker.typeNamed( + AckType, + inPackage: 'ack_annotations', + ); + static const _ackSchemaChecker = TypeChecker.typeNamed( + AckSchema, + inPackage: 'ack', + ); + static const _anySchemaChecker = TypeChecker.typeNamed( + AnySchema, + inPackage: 'ack', + ); + static const _anyOfSchemaChecker = TypeChecker.typeNamed( + AnyOfSchema, + inPackage: 'ack', + ); + static const _instanceSchemaChecker = TypeChecker.typeNamed( + InstanceSchema, + inPackage: 'ack', + ); + final LibraryReader library; final AckModelGraph _graph = AckModelGraph(); final Map _declarationsByElement = {}; final Map _declarationsById = {}; final Map _unionOwnerByBranch = {}; + ResolvedLibraryResult? _inputResolved; + final Map _resolvedByUri = {}; Future build(List annotatedElements) async { final libraryElement = library.element; @@ -154,6 +188,8 @@ final class SchemaModelGraphBuilder { 'Could not resolve ${libraryElement.uri} for Ack model generation.', ); } + _inputResolved = resolved; + _resolvedByUri[libraryElement.uri] = resolved; for (final element in annotatedElements) { final expression = _declarationExpression(resolved, element); @@ -226,6 +262,26 @@ final class SchemaModelGraphBuilder { return null; } + Future _resolvedLibraryFor( + LibraryElement libraryElement, + ) async { + if (identical(libraryElement, library.element) && _inputResolved != null) { + return _inputResolved!; + } + final cached = _resolvedByUri[libraryElement.uri]; + if (cached != null) return cached; + final resolved = await libraryElement.session.getResolvedLibraryByElement( + libraryElement, + ); + if (resolved is! ResolvedLibraryResult) { + throw InvalidGenerationSource( + 'Could not resolve ${libraryElement.uri} for Ack model generation.', + ); + } + _resolvedByUri[libraryElement.uri] = resolved; + return resolved; + } + Future _resolve( _Declaration declaration, { required bool throughLazy, @@ -264,7 +320,8 @@ final class SchemaModelGraphBuilder { node = await _objectNode(declaration, chain, path); } else if (baseName == 'discriminated') { node = await _unionNode(declaration, chain, path); - } else if (chain.reference != null) { + } else if (chain.reference != null && + _localDeclaration(chain.reference!) != null) { node = await _aliasNode(declaration, chain.reference!, path); } else { const supportedValueRoots = { @@ -284,7 +341,7 @@ final class SchemaModelGraphBuilder { 'codec', 'lazy', }; - if (!supportedValueRoots.contains(baseName)) { + if (baseName != null && !supportedValueRoots.contains(baseName)) { _rejectUnsupportedRoot(baseName, path, declaration.element); } node = await _valueNode(declaration, path); @@ -381,13 +438,7 @@ final class SchemaModelGraphBuilder { ); } final jsonKey = (entry.key as SimpleStringLiteral).value; - if (!RegExp(r'^[A-Za-z_$][A-Za-z0-9_$]*$').hasMatch(jsonKey) || - _dartKeywords.contains(jsonKey)) { - throw InvalidGenerationSource( - '$path.$jsonKey cannot be represented as a Dart field name.', - element: declaration.element, - ); - } + _rejectInvalidMemberName(jsonKey, path, declaration.element); if (_reservedMembers.contains(jsonKey)) { throw InvalidGenerationSource( '$path.$jsonKey conflicts with generated/Object member "$jsonKey".', @@ -468,8 +519,8 @@ final class SchemaModelGraphBuilder { element: declaration.element, ); } + _rejectInvalidMemberName(discriminatorKey, path, declaration.element); if (_reservedMembers.contains(discriminatorKey) || - _dartKeywords.contains(discriminatorKey) || discriminatorKey == 'additionalProperties') { throw InvalidGenerationSource( '$path.$discriminatorKey conflicts with a generated member or Dart ' @@ -554,6 +605,9 @@ final class SchemaModelGraphBuilder { required String path, required Element context, required bool throughLazy, + Set? visited, + int depth = 0, + String? followedName, }) async { final chain = _chain(expression); _rejectTransform(chain, path, context); @@ -563,6 +617,13 @@ final class SchemaModelGraphBuilder { final baseName = chain.base?.methodName.name; switch (baseName) { case 'object': + if (followedName != null) { + throw InvalidGenerationSource( + "$path references '$followedName', an Ack.object schema without " + '@AckType. Annotate it to generate a model.', + element: context, + ); + } throw InvalidGenerationSource( '$path uses an anonymous inline Ack.object(...).', element: context, @@ -585,6 +646,8 @@ final class SchemaModelGraphBuilder { path: '$path[]', context: context, throughLazy: throughLazy, + visited: visited, + depth: depth, ), ); case 'enumValues': @@ -602,6 +665,13 @@ final class SchemaModelGraphBuilder { case 'lazy': return _lazyType(chain.base!, path, context); case 'discriminated': + if (followedName != null) { + throw InvalidGenerationSource( + "$path references '$followedName', an Ack.discriminated schema " + 'without @AckType. Annotate it to generate a model.', + element: context, + ); + } throw InvalidGenerationSource( '$path uses an anonymous discriminated union.', element: context, @@ -617,10 +687,76 @@ final class SchemaModelGraphBuilder { throughLazy: throughLazy, ); if (model != null) return model; + if (chain.base == null) { + final followed = await _followUnannotatedReference( + reference, + path: path, + context: context, + throughLazy: throughLazy, + visited: visited ?? {}, + depth: depth, + ); + if (followed != null) return followed; + } + } + + _rejectUnsupportedSchemaType(expression, path, context); + if (chain.base == null) { + _rejectUnsupportedRoot(null, path, context); } return _schemaTypes(expression, path, context).runtime; } + Future _followUnannotatedReference( + Expression reference, { + required String path, + required Element context, + required bool throughLazy, + required Set visited, + required int depth, + }) async { + final element = _referencedElement(reference); + if (element == null) return null; + if (element is! TopLevelVariableElement && element is! GetterElement) { + return null; + } + if (_hasAckType(element)) return null; + if (depth >= _maxReferenceDepth) { + throw InvalidGenerationSource( + '$path exceeds schema reference depth $_maxReferenceDepth.', + element: context, + ); + } + final canonical = element.baseElement; + if (visited.contains(canonical)) { + throw InvalidGenerationSource( + "$path follows a cyclic schema reference through '${element.name}'.", + element: context, + ); + } + final declaration = _propertyDeclaration(element); + final owningLibrary = declaration.library; + if (owningLibrary == null) return null; + final resolved = await _resolvedLibraryFor(owningLibrary); + final initializer = _declarationExpression(resolved, declaration); + if (initializer == null) { + throw InvalidGenerationSource( + "$path references '${element.name}', which has no statically " + 'resolvable initializer.', + element: context, + ); + } + return _runtimeRefForSchema( + initializer, + path: '$path(→ ${element.name})', + context: context, + throughLazy: throughLazy, + visited: {...visited, canonical}, + depth: depth + 1, + followedName: element.name, + ); + } + Future _lazyType( MethodInvocation invocation, String path, @@ -735,9 +871,7 @@ final class SchemaModelGraphBuilder { } bool _isAckSchema(InterfaceType type) { - return type.element.name == 'AckSchema' && - type.element.library.uri.toString() == - 'package:ack/src/schemas/schema.dart'; + return _ackSchemaChecker.isExactlyType(type); } AckTypeRef _typeRef(DartType type, Element context) { @@ -841,7 +975,7 @@ final class SchemaModelGraphBuilder { optional |= name == 'optional'; nullable |= name == 'nullable'; defaulted |= name == 'withDefault'; - transform |= name == 'transform'; + transform |= _oneWayTransformMethods.contains(name); codec |= name == 'codec'; final target = current.target; if (_isAckTarget(target)) { @@ -912,15 +1046,13 @@ final class SchemaModelGraphBuilder { } bool _hasAckType(Element element) { - return TypeChecker.typeNamed( - AckType, - ).hasAnnotationOfExact(_propertyDeclaration(element)); + return _ackTypeChecker.hasAnnotationOfExact(_propertyDeclaration(element)); } String? _annotationName(Element element) { - final annotation = TypeChecker.typeNamed( - AckType, - ).firstAnnotationOfExact(_propertyDeclaration(element)); + final annotation = _ackTypeChecker.firstAnnotationOfExact( + _propertyDeclaration(element), + ); final field = annotation == null ? null : ConstantReader(annotation).peek('name'); @@ -1137,6 +1269,40 @@ final class SchemaModelGraphBuilder { }; } + void _rejectInvalidMemberName(String jsonKey, String path, Element element) { + if (jsonKey.startsWith('_')) { + throw InvalidGenerationSource( + "$path.$jsonKey cannot start with '_' (private Dart member).", + element: element, + ); + } + if (!RegExp(r'^[A-Za-z$][A-Za-z0-9_$]*$').hasMatch(jsonKey) || + _dartKeywords.contains(jsonKey)) { + throw InvalidGenerationSource( + '$path.$jsonKey cannot be represented as a Dart field name.', + element: element, + ); + } + } + + void _rejectUnsupportedSchemaType( + Expression expression, + String path, + Element element, + ) { + final type = expression.staticType; + if (type == null) return; + if (_anySchemaChecker.isAssignableFromType(type)) { + _rejectUnsupportedRoot('any', path, element); + } + if (_anyOfSchemaChecker.isAssignableFromType(type)) { + _rejectUnsupportedRoot('anyOf', path, element); + } + if (_instanceSchemaChecker.isAssignableFromType(type)) { + _rejectUnsupportedRoot('instance', path, element); + } + } + void _rejectTransform(_SchemaChain chain, String path, Element element) { if (!chain.hasTransform) return; throw InvalidGenerationSource( @@ -1193,6 +1359,12 @@ final class SchemaModelGraphBuilder { /// Normalizes analyzer 10's expression arguments and analyzer 13's /// dedicated argument nodes into the expression API used by the graph. + /// + /// The `dynamic` shim exists because analyzer 10 represents arguments as + /// [Expression] / [NamedExpression], while analyzer 13+ wraps them in an + /// `Argument` interface that is not a compile-time type in analyzer 10. + /// Keeping `analyzer: ">=10.0.0 <15.0.0"` matches json_serializable 6.14.1 + /// so consumers are not forced onto a narrower resolver. List _argumentExpressions(ArgumentList argumentList) => argumentList.arguments .map((argument) => _argumentExpression(argument)) @@ -1204,12 +1376,22 @@ final class SchemaModelGraphBuilder { if (argument is Expression) return argument; final dynamic dynamicArgument = argument; - // Analyzer 13+ wraps positional expressions in the Argument interface. - // ignore: avoid_dynamic_calls - return dynamicArgument.argumentExpression as Expression; + try { + // Analyzer 13+ wraps positional expressions in the Argument interface. + // ignore: avoid_dynamic_calls + return dynamicArgument.argumentExpression as Expression; + } on Object { + throw InvalidGenerationSource( + 'Unsupported analyzer argument node ${argument.runtimeType}; ' + 'ack_generator supports analyzer 10–14', + ); + } } ({String name, Expression expression})? _namedArgument(AstNode argument) { + // NamedExpression (analyzer 10) and Argument (analyzer 13+) are not a + // shared compile-time type across analyzer 10–14. The build script is + // AOT-compiled against the resolved analyzer, so this must stay dynamic. final dynamic dynamicArgument = argument; String? name; try { @@ -1222,7 +1404,11 @@ final class SchemaModelGraphBuilder { // ignore: avoid_dynamic_calls name = dynamicArgument.name.label.name as String?; } on Object { - return null; + if (argument is Expression) return null; + throw InvalidGenerationSource( + 'Unsupported analyzer argument node ${argument.runtimeType}; ' + 'ack_generator supports analyzer 10–14', + ); } } if (name == null) return null; @@ -1233,10 +1419,17 @@ final class SchemaModelGraphBuilder { final expression = dynamicArgument.argumentExpression as Expression; return (name: name, expression: expression); } on Object { - // Analyzer 10 uses NamedExpression.expression. - // ignore: avoid_dynamic_calls - final expression = dynamicArgument.expression as Expression; - return (name: name, expression: expression); + try { + // Analyzer 10 uses NamedExpression.expression. + // ignore: avoid_dynamic_calls + final expression = dynamicArgument.expression as Expression; + return (name: name, expression: expression); + } on Object { + throw InvalidGenerationSource( + 'Unsupported analyzer argument node ${argument.runtimeType}; ' + 'ack_generator supports analyzer 10–14', + ); + } } } } diff --git a/packages/ack_generator/lib/src/builder.dart b/packages/ack_generator/lib/src/builder.dart index 77e47042..f05f9a41 100644 --- a/packages/ack_generator/lib/src/builder.dart +++ b/packages/ack_generator/lib/src/builder.dart @@ -11,6 +11,14 @@ Builder ackGenerator(BuilderOptions options) { /// Creates the cache-only JSON fragment builder for Ack-marked models. /// +/// Phase 2 delegates structural field mapping to json_serializable so generated +/// models keep the familiar `.g.dart` / `_$XFromJson` contract and can coexist +/// with a consumer's own json_serializable usage. The cost is a second resolve +/// pass, required-nullable restore in Ack glue, passthrough strip/re-add for +/// `additionalProperties`, and value-root boxing through a synthetic `value` +/// map. A future simplification pass should not undo that split without +/// replacing those contracts. +/// /// [options] are ignored so consumer `json_serializable` settings cannot /// change Ack runtime-map semantics. Builder ackJsonSerializableBuilder(BuilderOptions options) { diff --git a/packages/ack_generator/lib/src/builders/model_emitter.dart b/packages/ack_generator/lib/src/builders/model_emitter.dart index 0f1537b3..6186e25e 100644 --- a/packages/ack_generator/lib/src/builders/model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/model_emitter.dart @@ -100,7 +100,7 @@ final class AckModelEmitter { ..name = 'toJson' ..returns = refer(boundaryType) ..lambda = true - ..body = const Code(r'$ack.encode(this)'), + ..body = Code(_valueToJsonBody(node.boundaryType)), ), Method( (m) => m @@ -756,6 +756,16 @@ return $helper({ _ => false, }; + String _valueToJsonBody(AckTypeRef boundaryType) { + return switch (boundaryType) { + AckListTypeRef(:final elementType) => + 'List<${_type(elementType)}>.of(\$ack.encode(this))', + AckSetTypeRef(:final elementType) => + 'Set<${_type(elementType)}>.of(\$ack.encode(this))', + _ => r'$ack.encode(this)', + }; + } + String _type(AckTypeRef type) { return switch (type) { AckNullableTypeRef(:final inner) => '${_type(inner)}?', diff --git a/packages/ack_generator/lib/src/generator.dart b/packages/ack_generator/lib/src/generator.dart index a63dd976..e01a1ac9 100644 --- a/packages/ack_generator/lib/src/generator.dart +++ b/packages/ack_generator/lib/src/generator.dart @@ -12,9 +12,18 @@ import 'builders/model_emitter.dart'; /// Generates immutable model classes for top-level schemas annotated with /// `@AckType`. final class AckSchemaGenerator extends Generator { - static const _ackTypeChecker = TypeChecker.typeNamed(AckType); - static const _ackModelAdapterChecker = TypeChecker.typeNamed(AckModelAdapter); - static const _schemaResultChecker = TypeChecker.typeNamed(SchemaResult); + static const _ackTypeChecker = TypeChecker.typeNamed( + AckType, + inPackage: 'ack_annotations', + ); + static const _ackModelAdapterChecker = TypeChecker.typeNamed( + AckModelAdapter, + inPackage: 'ack', + ); + static const _schemaResultChecker = TypeChecker.typeNamed( + SchemaResult, + inPackage: 'ack', + ); @override Future generate(LibraryReader library, BuildStep buildStep) async { @@ -85,7 +94,8 @@ final class AckSchemaGenerator extends Generator { final unit = await buildStep.resolver.compilationUnitFor(buildStep.inputId); final parts = { for (final directive in unit.directives.whereType()) - if (directive.uri.stringValue case final uri?) uri, + if (directive.uri.stringValue case final uri?) + Uri.parse(uri).pathSegments.last, }; if (parts.contains(expectedAckPart) && parts.contains(expectedJsonPart)) { return; diff --git a/packages/ack_generator/lib/src/json/ack_json_generator.dart b/packages/ack_generator/lib/src/json/ack_json_generator.dart index 8d4cf5c7..c50dd5d0 100644 --- a/packages/ack_generator/lib/src/json/ack_json_generator.dart +++ b/packages/ack_generator/lib/src/json/ack_json_generator.dart @@ -19,7 +19,10 @@ final class AckJsonSerializableGenerator extends Generator { final JsonSerializableGenerator _delegate; - static final _marker = TypeChecker.typeNamed(AckGeneratedJson); + static const _marker = TypeChecker.typeNamed( + AckGeneratedJson, + inPackage: 'ack_annotations', + ); @override String generate(LibraryReader library, BuildStep buildStep) { diff --git a/packages/ack_generator/test/integration/example_folder_build_test.dart b/packages/ack_generator/test/integration/example_folder_build_test.dart index 7b7f6a92..49cb3195 100644 --- a/packages/ack_generator/test/integration/example_folder_build_test.dart +++ b/packages/ack_generator/test/integration/example_folder_build_test.dart @@ -3,7 +3,11 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; -void _copyDirectory(Directory source, Directory destination) { +void _copyDirectory( + Directory source, + Directory destination, { + bool keepGenerated = false, +}) { destination.createSync(recursive: true); for (final entity in source.listSync()) { final name = p.basename(entity.path); @@ -12,10 +16,10 @@ void _copyDirectory(Directory source, Directory destination) { } final target = p.join(destination.path, name); if (entity is Directory) { - _copyDirectory(entity, Directory(target)); + _copyDirectory(entity, Directory(target), keepGenerated: keepGenerated); } else if (entity is File && - !name.endsWith('.ack.dart') && - !name.endsWith('.g.dart')) { + (keepGenerated || + (!name.endsWith('.ack.dart') && !name.endsWith('.g.dart')))) { entity.copySync(target); } } @@ -155,4 +159,93 @@ dependency_overrides: }, timeout: const Timeout(Duration(minutes: 3)), ); + + test( + 'rebuilds when generated outputs are already present', + () async { + var projectRoot = Directory.current; + while (!Directory( + p.join(projectRoot.path, 'packages', 'ack_generator'), + ).existsSync()) { + projectRoot = projectRoot.parent; + } + final sourceExample = Directory(p.join(projectRoot.path, 'example')); + final temporaryRoot = await Directory.systemTemp.createTemp( + 'ack_generator_example_present_', + ); + final temporaryExample = Directory( + p.join(temporaryRoot.path, 'ack_example'), + ); + + try { + _copyDirectory(sourceExample, temporaryExample, keepGenerated: true); + File( + p.join(temporaryExample.path, 'analysis_options.yaml'), + ).writeAsStringSync(''' +analyzer: + language: + strict-casts: true +'''); + File(p.join(temporaryExample.path, 'pubspec.yaml')).writeAsStringSync( + ''' +name: ack_example +publish_to: none +environment: + sdk: '>=3.9.0 <4.0.0' +dependencies: + ack: + path: ${p.join(projectRoot.path, 'packages', 'ack')} + ack_annotations: + path: ${p.join(projectRoot.path, 'packages', 'ack_annotations')} +dev_dependencies: + ack_generator: + path: ${p.join(projectRoot.path, 'packages', 'ack_generator')} + build_runner: ^2.15.0 + test: ^1.29.0 +dependency_overrides: + ack: + path: ${p.join(projectRoot.path, 'packages', 'ack')} + ack_annotations: + path: ${p.join(projectRoot.path, 'packages', 'ack_annotations')} +''', + ); + + _expectSuccess( + await _run(temporaryExample, ['pub', 'get']), + 'dart pub get', + ); + + final schemaFile = File( + p.join(temporaryExample.path, 'lib', 'schema_types_simple.dart'), + ); + schemaFile.writeAsStringSync( + schemaFile.readAsStringSync().replaceFirst( + "'name': Ack.string(),", + "'name': Ack.string(),\n 'nickname': Ack.string().optional(),", + ), + ); + final generatedFile = File( + p.join(temporaryExample.path, 'lib', 'schema_types_simple.ack.dart'), + ); + final before = generatedFile.readAsStringSync(); + + _expectSuccess( + await _run(temporaryExample, ['run', 'build_runner', 'build']), + 'outputs-present build_runner build', + ); + + final after = generatedFile.readAsStringSync(); + expect(after, isNot(equals(before))); + expect(after, contains('nickname')); + _expectSuccess( + await _run(temporaryExample, ['analyze', '--fatal-infos']), + 'dart analyze --fatal-infos', + ); + _expectSuccess(await _run(temporaryExample, ['test']), 'dart test'); + } finally { + temporaryRoot.deleteSync(recursive: true); + } + }, + timeout: const Timeout(Duration(minutes: 3)), + ); } diff --git a/packages/ack_generator/test/integration/v2_graph_test.dart b/packages/ack_generator/test/integration/v2_graph_test.dart index ea8c7af8..f2bcbd4d 100644 --- a/packages/ack_generator/test/integration/v2_graph_test.dart +++ b/packages/ack_generator/test/integration/v2_graph_test.dart @@ -177,11 +177,217 @@ $_head @AckType() final payloadSchema = ${unsupported.key}; ''', - [unsupported.value], + ['payloadSchema', unsupported.value], ); }); } + test('rejects a .trim() field with the declaration path', () async { + await _expectFailure( + ''' +$_head +@AckType() +final userSchema = Ack.object({ + 'nick': Ack.string().trim(), +}); +''', + ['userSchema.nick', '.transform()'], + ); + }); + + test('rejects a local one-way transform field and names the variable', () async { + await _expectFailure( + ''' +$_head +final ageFromString = Ack.string().transform(int.parse); + +@AckType() +final userSchema = Ack.object({ + 'age': ageFromString, +}); +''', + ['userSchema.age', 'ageFromString', '.transform()'], + ); + }); + + test('rejects a local one-way transform used as an annotated root', () async { + await _expectFailure( + ''' +$_head +final ageFromString = Ack.string().transform(int.parse); + +@AckType() +final ageSchema = ageFromString; +''', + ['ageSchema', 'ageFromString', '.transform()'], + ); + }); + + test('rejects a local Ack.any() field by following the variable', () async { + await _expectFailure( + ''' +$_head +final payloadAny = Ack.any(); + +@AckType() +final userSchema = Ack.object({ + 'payload': payloadAny, +}); +''', + ['userSchema.payload', 'payloadAny', 'Ack.any()'], + ); + }); + + test('rejects an unannotated named Ack.object field', () async { + await _expectFailure( + ''' +$_head +final address = Ack.object({'city': Ack.string()}); + +@AckType() +final userSchema = Ack.object({ + 'home': address, +}); +''', + ['userSchema.home', "'address'", '@AckType'], + ); + }); + + test('rejects a leading-underscore JSON key', () async { + await _expectFailure( + ''' +$_head +@AckType() +final userSchema = Ack.object({ + '_id': Ack.string(), +}); +''', + ['userSchema._id', "cannot start with '_'"], + ); + }); + + test('rejects a leading-underscore discriminator key', () async { + await _expectFailure( + ''' +$_head +@AckType() +final catSchema = Ack.object({'lives': Ack.integer()}); + +@AckType() +final petSchema = Ack.discriminated( + discriminatorKey: '_kind', + schemas: {'cat': catSchema}, +); +''', + ['petSchema._kind', "cannot start with '_'"], + ); + }); + + test('rejects an anonymous inline object field with the path', () async { + await _expectFailure( + ''' +$_head +@AckType() +final userSchema = Ack.object({ + 'home': Ack.object({'city': Ack.string()}), +}); +''', + ['userSchema.home', 'anonymous inline'], + ); + }); + + test('rejects an anonymous inline object inside Ack.list', () async { + await _expectFailure( + ''' +$_head +@AckType() +final bagSchema = Ack.object({ + 'items': Ack.list(Ack.object({'n': Ack.string()})), +}); +''', + ['bagSchema.items[]', 'anonymous inline'], + ); + }); + + test('rejects a dynamic factory root with the declaration path', () async { + await _expectFailure( + ''' +$_head +AckSchema make() => Ack.string(); + +@AckType() +final payloadSchema = make(); +''', + ['payloadSchema', 'unresolvable dynamic schema factory'], + ); + }); + + test('rejects a generated-class-name collision', () async { + await _expectFailure( + ''' +$_head +@AckType(name: 'User') +final firstSchema = Ack.object({'a': Ack.string()}); + +@AckType(name: 'User') +final secondSchema = Ack.object({'b': Ack.string()}); +''', + ['User', 'Multiple @AckType'], + ); + }); + + test('follows local bidirectional codec and list variables', () async { + await _expectOutput( + ''' +$_head +final class Color { + const Color(this.value); + final String value; +} + +final color = Ack.string().codec( + decode: Color.new, + encode: (c) => c.value, +); + +final tags = Ack.list(Ack.string()); + +@AckType() +final profileSchema = Ack.object({ + 'color': color, + 'tags': tags, +}); +''', + allOf([ + contains('required this.color'), + contains('final Color color'), + contains('required List tags'), + contains('final List tags'), + ]), + ); + }); + + test('generates codec fields whose outputSchema is InstanceSchema', () async { + await _expectOutput( + ''' +$_head +final class Color { + const Color(this.value); + final String value; +} + +@AckType() +final userSchema = Ack.object({ + 'color': Ack.string().codec( + decode: Color.new, + encode: (c) => c.value, + ), +}); +''', + allOf([contains('required this.color'), contains('final Color color')]), + ); + }); + test('rejects ordinary alias cycles', () async { await _expectFailure( ''' diff --git a/packages/ack_generator/test/integration/v2_models_test.dart b/packages/ack_generator/test/integration/v2_models_test.dart index 5eb80180..042084c9 100644 --- a/packages/ack_generator/test/integration/v2_models_test.dart +++ b/packages/ack_generator/test/integration/v2_models_test.dart @@ -211,7 +211,7 @@ final petSchema = Ack.discriminated( }); test( - 'rejects anonymous objects and generated member collisions with paths', + 'rejects generated member collisions with paths', () async { final messages = {}; await _build( @@ -568,4 +568,78 @@ final userSchema = Ack.object({'name': Ack.string()}); ); expect(messages.single, contains(r'_$UserFromJson')); }); + + test('rejects a cross-library unannotated schema variable', () async { + final messages = {}; + await _build( + { + 'other.dart': + ''' +import 'package:ack/ack.dart'; + +final payloadAny = Ack.any(); +''', + 'user.dart': + ''' +$_imports +import 'other.dart'; +part 'user.ack.dart'; +part 'user.g.dart'; + +@AckType() +final userSchema = Ack.object({ + 'payload': payloadAny, +}); +''', + }, + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE') messages.add(log.message); + }, + ); + expect(messages.single, contains('userSchema.payload')); + expect(messages.single, contains('payloadAny')); + }); + + test('rejects a cross-library discriminated branch with the path', () async { + final messages = {}; + await _build( + { + 'cat.dart': + ''' +$_imports +part 'cat.ack.dart'; +part 'cat.g.dart'; + +@AckType() +final catSchema = Ack.object({ + 'kind': Ack.literal('cat'), + 'lives': Ack.integer(), +}); +''', + 'pet.dart': + ''' +$_imports +import 'cat.dart'; +part 'pet.ack.dart'; +part 'pet.g.dart'; + +@AckType() +final petSchema = Ack.discriminated( + discriminatorKey: 'kind', + schemas: {'cat': catSchema}, +); +''', + }, + outputs: { + 'test_pkg|lib/cat.ack.dart': decodedMatches(contains('final class Cat')), + }, + onLog: (log) { + if (log.level.name == 'SEVERE') messages.add(log.message); + }, + ); + expect(messages, isNotEmpty); + expect(messages.join('\n'), contains('petSchema.cat')); + expect(messages.join('\n'), contains('cross-library')); + }); } diff --git a/packages/ack_generator/test/src/generator_test.dart b/packages/ack_generator/test/src/generator_test.dart index 1441efe2..5f869695 100644 --- a/packages/ack_generator/test/src/generator_test.dart +++ b/packages/ack_generator/test/src/generator_test.dart @@ -101,6 +101,30 @@ final userSchema = Ack.string(); }, ); + test('does not reject part directives with a leading ./', () async { + var sawOurPartError = false; + await _build( + ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part './schema.ack.dart'; +part './schema.g.dart'; + +@AckType() +final userSchema = Ack.object({'name': Ack.string()}); +''', + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && + log.message.contains('Ack model generation requires')) { + sawOurPartError = true; + } + }, + ); + expect(sawOurPartError, isFalse); + }); + test('rejects a JSON part that does not match the basename', () async { var sawError = false; await _build( diff --git a/packages/ack_generator/test/src/one_way_wrappers_test.dart b/packages/ack_generator/test/src/one_way_wrappers_test.dart new file mode 100644 index 00000000..beab1795 --- /dev/null +++ b/packages/ack_generator/test/src/one_way_wrappers_test.dart @@ -0,0 +1,26 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'string convenience wrappers are one-way transforms', + () { + final wrappers = + Function(StringSchema)>{ + 'trim': (schema) => schema.trim(), + 'toLowerCase': (schema) => schema.toLowerCase(), + 'toUpperCase': (schema) => schema.toUpperCase(), + }; + + for (final entry in wrappers.entries) { + final result = entry.value(Ack.string()).safeEncode('X'); + expect(result.isFail, isTrue, reason: entry.key); + expect( + (result.getError() as SchemaEncodeError).kind, + SchemaEncodeFailureKind.oneWayTransform, + reason: entry.key, + ); + } + }, + ); +} From c545cc94cb9c6b1fcec76b836cc7480a15943c34 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 24 Aug 2026 09:45:57 -0400 Subject: [PATCH 7/7] fix(generator): reject cross-library AckType alias roots and nested part paths A root reference to a cross-library @AckType schema previously slipped into the value-model path and emitted a wrapper whose runtime bridge cast the runtime map to the referenced model type. Reject it with a located error instead; same-library aliases still clone the model. Part-directive matching now strips only './' segments, so a nested 'sub/x.ack.dart' directive no longer satisfies the sibling-part requirement the builder can never fulfill. --- .../analyzer/schema_model_graph_builder.dart | 14 +++++++ packages/ack_generator/lib/src/generator.dart | 13 ++++++- .../test/integration/v2_models_test.dart | 38 +++++++++++++++++++ .../test/src/generator_test.dart | 25 ++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart index 2e83dd03..87618834 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart @@ -323,6 +323,13 @@ final class SchemaModelGraphBuilder { } else if (chain.reference != null && _localDeclaration(chain.reference!) != null) { node = await _aliasNode(declaration, chain.reference!, path); + } else if (chain.reference != null && + _isCrossLibraryAckType(chain.reference!)) { + throw InvalidGenerationSource( + '$path aliases a cross-library @AckType schema. Use the original ' + 'model directly.', + element: declaration.element, + ); } else { const supportedValueRoots = { 'string', @@ -1037,6 +1044,13 @@ final class SchemaModelGraphBuilder { return element == null ? null : _declarationsByElement[element.baseElement]; } + bool _isCrossLibraryAckType(Expression expression) { + final element = _referencedElement(expression); + if (element == null) return false; + if (_declarationsByElement[element.baseElement] != null) return false; + return _hasAckType(element); + } + String? _expressionPrefix(Expression expression) { if (expression is PrefixedIdentifier) return expression.prefix.name; if (expression is MethodInvocation && expression.target != null) { diff --git a/packages/ack_generator/lib/src/generator.dart b/packages/ack_generator/lib/src/generator.dart index e01a1ac9..818c90c5 100644 --- a/packages/ack_generator/lib/src/generator.dart +++ b/packages/ack_generator/lib/src/generator.dart @@ -83,6 +83,16 @@ final class AckSchemaGenerator extends Generator { bool _hasAckType(Element element) => _ackTypeChecker.hasAnnotationOfExact(element); + /// Strips `./` segments so `part './user.ack.dart'` matches the file next to + /// the input, without treating `part 'sub/user.ack.dart'` as the same path. + String _normalizedPartUri(String uri) { + final segments = [ + for (final segment in Uri.parse(uri).pathSegments) + if (segment.isNotEmpty && segment != '.') segment, + ]; + return segments.join('/'); + } + Future _requirePartDirectives( BuildStep buildStep, Element annotatedElement, @@ -94,8 +104,7 @@ final class AckSchemaGenerator extends Generator { final unit = await buildStep.resolver.compilationUnitFor(buildStep.inputId); final parts = { for (final directive in unit.directives.whereType()) - if (directive.uri.stringValue case final uri?) - Uri.parse(uri).pathSegments.last, + if (directive.uri.stringValue case final uri?) _normalizedPartUri(uri), }; if (parts.contains(expectedAckPart) && parts.contains(expectedJsonPart)) { return; diff --git a/packages/ack_generator/test/integration/v2_models_test.dart b/packages/ack_generator/test/integration/v2_models_test.dart index 042084c9..29164e42 100644 --- a/packages/ack_generator/test/integration/v2_models_test.dart +++ b/packages/ack_generator/test/integration/v2_models_test.dart @@ -601,6 +601,44 @@ final userSchema = Ack.object({ expect(messages.single, contains('payloadAny')); }); + test('rejects a cross-library @AckType alias root', () async { + final messages = {}; + await _build( + { + 'user.dart': + ''' +$_imports +part 'user.ack.dart'; +part 'user.g.dart'; + +@AckType() +final userSchema = Ack.object({'name': Ack.string()}); +''', + 'admin.dart': + ''' +$_imports +import 'user.dart' as other; +part 'admin.ack.dart'; +part 'admin.g.dart'; + +@AckType() +final adminSchema = other.userSchema; +''', + }, + outputs: { + 'test_pkg|lib/user.ack.dart': decodedMatches( + contains('final class User'), + ), + }, + onLog: (log) { + if (log.level.name == 'SEVERE') messages.add(log.message); + }, + ); + expect(messages, isNotEmpty); + expect(messages.join('\n'), contains('adminSchema')); + expect(messages.join('\n'), contains('cross-library')); + }); + test('rejects a cross-library discriminated branch with the path', () async { final messages = {}; await _build( diff --git a/packages/ack_generator/test/src/generator_test.dart b/packages/ack_generator/test/src/generator_test.dart index 5f869695..da04e963 100644 --- a/packages/ack_generator/test/src/generator_test.dart +++ b/packages/ack_generator/test/src/generator_test.dart @@ -125,6 +125,31 @@ final userSchema = Ack.object({'name': Ack.string()}); expect(sawOurPartError, isFalse); }); + test('rejects a part directive that points at a nested relative path', () async { + var sawError = false; + await _build( + ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'sub/schema.ack.dart'; +part 'sub/schema.g.dart'; + +@AckType() +final userSchema = Ack.string(); +''', + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && + log.message.contains("part 'schema.ack.dart';") && + log.message.contains("part 'schema.g.dart';")) { + sawError = true; + } + }, + ); + expect(sawError, isTrue); + }); + test('rejects a JSON part that does not match the basename', () async { var sawError = false; await _build(