From 6600f7856f3c4c3ef68def1ee3df13773479fdab Mon Sep 17 00:00:00 2001 From: Andrii Ivanov Date: Fri, 4 Sep 2026 17:27:52 +0300 Subject: [PATCH 01/10] Fix ThemeGen lerp for nullable instance-lerp fields, drop dead generator branches analyzer: ">=13.0.0 <15.0.0" sdk: ">=3.13.0 <4.0.0" --- .github/workflows/dart.yml | 6 +- .../example/analysis_options.yaml | 2 +- .../example/pubspec.lock | 98 +++--- .../example/pubspec.yaml | 4 +- .../lib/src/generator/common.dart | 15 +- .../theme_extensions/code_builder.dart | 269 ++++++-------- .../src/generator/theme_gen/code_builder.dart | 328 ++++++++---------- .../theme_extensions_builder/pubspec.yaml | 10 +- .../test/generator/code_builder_test.dart | 124 +++++++ .../complex_theme_extension.dart | 2 + .../complex_theme_extension.g.theme.dart | 12 +- .../complex_theme_extension.g.theme.dart | 26 +- .../test/theme_gen/complex_theme.g.theme.dart | 12 +- .../goldens/complex_theme.g.theme.dart | 14 +- .../complex_theme_internal.g.theme.dart | 14 +- .../widget_state_property_theme.g.theme.dart | 6 +- .../widget_state_property_theme.dart | 6 +- .../widget_state_property_theme.g.theme.dart | 6 +- .../pubspec.yaml | 6 +- 19 files changed, 514 insertions(+), 446 deletions(-) create mode 100644 packages/theme_extensions_builder/test/generator/code_builder_test.dart diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 9427423..9a5db6a 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -36,7 +36,11 @@ jobs: - name: Run formatter check (builder) working-directory: packages/theme_extensions_builder - run: dart format --set-exit-if-changed . + # Goldens hold byte-exact source_gen_test output, so they cannot be + # reformatted. They are excluded from the analyzer for the same reason. + run: | + dart format --set-exit-if-changed \ + $(git ls-files '*.dart' | grep -v '/goldens/') - name: Run tests (builder) working-directory: packages/theme_extensions_builder diff --git a/packages/theme_extensions_builder/example/analysis_options.yaml b/packages/theme_extensions_builder/example/analysis_options.yaml index 1c64636..198794d 100644 --- a/packages/theme_extensions_builder/example/analysis_options.yaml +++ b/packages/theme_extensions_builder/example/analysis_options.yaml @@ -1,4 +1,4 @@ -include: package:pro_lints/common.yaml +include: package:pro_lints/recommended.yaml formatter: trailing_commas: preserve diff --git a/packages/theme_extensions_builder/example/pubspec.lock b/packages/theme_extensions_builder/example/pubspec.lock index b3407fb..bb9421f 100644 --- a/packages/theme_extensions_builder/example/pubspec.lock +++ b/packages/theme_extensions_builder/example/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b + sha256: fe18c7e37d5acb3b43224fa255281dec007970d6fd01f6d76fcd88ed3777a117 url: "https://pub.dev" source: hosted - version: "100.0.0" + version: "107.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" + sha256: f2dde9e50c23fc95b846b4ef334e98c3ae8c0effd5d2453b02fc9fb3f8939791 url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.3.0" args: dependency: transitive description: @@ -45,34 +45,34 @@ packages: dependency: transitive description: name: build - sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + sha256: "8a5c5761af8e31748bba3c82f68925ace40f3225c3eea25be9beb57eca7cd7a8" url: "https://pub.dev" source: hosted - version: "4.0.6" + version: "4.0.11" build_config: dependency: transitive description: name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.3" build_daemon: dependency: transitive description: name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.6" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + sha256: "894c243f6bc32015fec466ce30a6925bd537a77a426ee5bf481120477eb3de67" url: "https://pub.dev" source: hosted - version: "2.15.0" + version: "2.16.1" built_collection: dependency: transitive description: @@ -85,10 +85,10 @@ packages: dependency: transitive description: name: built_value - sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 url: "https://pub.dev" source: hosted - version: "8.12.6" + version: "8.13.0" characters: dependency: transitive description: @@ -109,10 +109,10 @@ packages: dependency: transitive description: name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + sha256: aa5932e94c6c39c2f9ec4e5e06dfdd11a9430a61f6c41b6ba75b28ce0c481baf url: "https://pub.dev" source: hosted - version: "4.11.1" + version: "4.12.0" collection: dependency: transitive description: @@ -141,10 +141,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60" + sha256: "82ade9fc4273f29ed673e33166944465225b4f7fc5d4aaef48605cc751c18fc1" url: "https://pub.dev" source: hosted - version: "3.1.9" + version: "3.1.13" file: dependency: transitive description: @@ -170,10 +170,10 @@ packages: dependency: transitive description: name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" graphs: dependency: transitive description: @@ -202,10 +202,10 @@ packages: dependency: transitive description: name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.1.0" json_annotation: dependency: transitive description: @@ -242,26 +242,26 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" package_config: dependency: transitive description: name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.0" path: dependency: transitive description: @@ -274,34 +274,34 @@ packages: dependency: transitive description: name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "1.5.3" pro_lints: dependency: "direct dev" description: name: pro_lints - sha256: "70377f7bbffad1ab57b04403f003bf0713910034c3b2d3d185d2870643277b9a" + sha256: "4e63089b1d0aeebfb4836acbeff612a27519916832b2384e687dcc9941532cfc" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.2.0" pub_semver: dependency: transitive description: name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.6.0" shelf: dependency: transitive description: @@ -327,10 +327,10 @@ packages: dependency: transitive description: name: source_gen - sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + sha256: "11b6047da8e4eb6c643ccac3d0fda3f9c9e11651695f7da24a85ade8aff15a44" url: "https://pub.dev" source: hosted - version: "4.2.3" + version: "4.3.0" source_span: dependency: transitive description: @@ -343,10 +343,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.2" stream_channel: dependency: transitive description: @@ -359,10 +359,10 @@ packages: dependency: transitive description: name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: @@ -383,10 +383,10 @@ packages: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "0a10344e901e5b2e63819567951cb6a06673ed6b84f40462188ff5a0c41f371f" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.14" theme_extensions_builder: dependency: "direct dev" description: @@ -413,10 +413,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" watcher: dependency: transitive description: @@ -453,9 +453,9 @@ packages: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" sdks: - dart: ">=3.11.5 <4.0.0" + dart: ">=3.12.0 <4.0.0" diff --git a/packages/theme_extensions_builder/example/pubspec.yaml b/packages/theme_extensions_builder/example/pubspec.yaml index 8349533..5e7c341 100644 --- a/packages/theme_extensions_builder/example/pubspec.yaml +++ b/packages/theme_extensions_builder/example/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: 'none' version: 1.0.0 environment: - sdk: ">=3.10.0 <4.0.0" + sdk: ">=3.13.0 <4.0.0" dependencies: flutter: @@ -13,7 +13,7 @@ dependencies: dev_dependencies: build_runner: ^2.15.0 - pro_lints: ^6.1.0 + pro_lints: ^6.2.0 theme_extensions_builder: ^7.3.0 flutter: diff --git a/packages/theme_extensions_builder/lib/src/generator/common.dart b/packages/theme_extensions_builder/lib/src/generator/common.dart index fe08de5..8f9fe68 100644 --- a/packages/theme_extensions_builder/lib/src/generator/common.dart +++ b/packages/theme_extensions_builder/lib/src/generator/common.dart @@ -121,24 +121,19 @@ Method hashMethod(BaseConfig config) => Method((m) { }); }); -/// Generates an if-else statement as code. +/// Generates an if statement as code. /// /// Creates a code block with the given [condition], executing [ifBlock] when -/// true and optionally [elseBlock] when false. +/// true. /// /// This is a utility function for generating conditional code when using -/// code_builder, as it doesn't provide a built-in if-else construct. -Code ifStatement(Expression condition, Block ifBlock, [Block? elseBlock]) { +/// code_builder, as it doesn't provide a built-in if construct. +Code ifStatement(Expression condition, Block ifBlock) { final visiter = DartEmitter(); final conditionV = condition.accept(visiter); final ifBlockV = ifBlock.accept(visiter); - final elseBlockV = elseBlock?.accept(visiter); - final ifElse = - 'if($conditionV){$ifBlockV}' - '${elseBlockV != null ? 'else {$elseBlockV}' : ''}'; - - return Code(ifElse); + return Code('if($conditionV){$ifBlockV}'); } /// A wrapper around [Reference] that guarantees a non-null symbol. diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart index 038a785..fdd6fb6 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart @@ -155,168 +155,127 @@ Method lerpMethod(ThemeExtensionsConfig config) => Method((m) { for (final field in fields) { final tProp = '_this'.ref.prop(field.name); final oProp = 'other'.ref.prop(field.name); - - // Handle NoLerp with double field - if (field.lerp case NoLerp() when field.isDouble) { - // lerpDouble$(_this.field, other.field, t) or - // lerpDouble$(_this.field, other.field, t)! - final expression = r'lerpDouble$'.ref([tProp, oProp, 't'.ref]); - - args[field.name] = field.isNullable - ? expression - : expression.nullChecked; - continue; - } - - // Handle NoLerp with duration field - if (field.lerp case NoLerp() when field.isDuration) { - // lerpDuration$(_this.field, other.field, t) or - // lerpDuration$(_this.field, other.field, t)! - final expression = r'lerpDuration$'.ref([tProp, oProp, 't'.ref]); - - args[field.name] = field.isNullable - ? expression - : expression.nullChecked; - continue; - } - - if (field.lerp case NoLerp()) { - // Default conditional expression - - args[field.name] = 't'.ref - .lessThan(literalNum(0.5)) - .conditional( - '_this'.ref.prop(field.name), - 'other'.ref.prop(field.name), - ); - - continue; - } - final sLerp = field.typeName.ref.prop('lerp'); - // Handle StaticLerp with non-nullable signature and optional - // field - if (field.lerp case StaticLerp( - isNullableSignature: false, - ) when field.isNullable) { - // _this.side == null - // ? other.side - // : other.side == null - // ? _this.side - // : Side.lerp(_this.side!, other.side!, t), - args[field.name] = tProp - .equalTo(literalNull) - .conditional( - oProp, - oProp - .equalTo(literalNull) - .conditional( - tProp, - sLerp([tProp.nullChecked, oProp.nullChecked, 't'.ref]), - ), - ); - continue; - } - // Handle StaticLerp with non-nullable signature and - // non-optional field - if (field.lerp case StaticLerp( - isNullableSignature: false, - ) when !field.isNullable) { - // FieldType.lerp(_this.field, other.field, t) - args[field.name] = sLerp([tProp, oProp, 't'.ref]); - continue; - } - - // Handle StaticLerp with nullable signature and - // non-optional field - if (field.lerp case StaticLerp( - isNullableSignature: true, - ) when !field.isNullable) { - // FieldType.lerp(_this.field!, other.field!, t)! - args[field.name] = sLerp([tProp, oProp, 't'.ref]).nullChecked; - continue; - } - - // Handle StaticLerp with nullable signature and optional - // field - if (field.lerp case StaticLerp( - isNullableSignature: true, - ) when field.isNullable) { - // FieldType.lerp(_this.field, other.field, t) - args[field.name] = sLerp([tProp, oProp, 't'.ref]); - continue; - } + switch (field.lerp) { + // Handle NoLerp with double field + case NoLerp() when field.isDouble: + // lerpDouble$(_this.field, other.field, t) or + // lerpDouble$(_this.field, other.field, t)! + final expression = r'lerpDouble$'.ref([tProp, oProp, 't'.ref]); - // Handle InstanceLerp with optional field - if (field.lerp case InstanceLerp( - optionalResult: true, - ) when field.isNullable) { - // _this.field?.lerp(other.field, t) - args[field.name] = tProp.prop('lerp', nullSafe: true)([ - oProp, - 't'.ref, - ]); - continue; - } + args[field.name] = field.isNullable + ? expression + : expression.nullChecked; - // Handle InstanceLerp with non-optional result and nullable field - if (field.lerp case InstanceLerp( - optionalResult: false, - ) when field.isNullable) { - // _this.field?.lerp(other.field, t) as FieldType? - args[field.name] = tProp - .prop('lerp', nullSafe: true)([oProp, 't'.ref]) - .asA(field.typeName.typeRef(isNullable: true)); - continue; - } + // Handle NoLerp with duration field + case NoLerp() when field.isDuration: + // lerpDuration$(_this.field, other.field, t) or + // lerpDuration$(_this.field, other.field, t)! + final expression = r'lerpDuration$'.ref([tProp, oProp, 't'.ref]); - // Handle InstanceLerp with non-optional field - if (field.lerp case InstanceLerp() when !field.isNullable) { - // _this.field.lerp(other.field, t) as FieldType - args[field.name] = tProp - .prop('lerp')([oProp, 't'.ref]) - .asA(field.typeName.typeRef()); - continue; - } + args[field.name] = field.isNullable + ? expression + : expression.nullChecked; - // Handle WidgetStateProperty lerp with inner lerp function - if (field.lerp case WidgetStatePropertyLerp( - :final baseTypeName, - :final genericType, - :final isNullableGeneric, - :final genericIsDouble, - :final genericIsDuration, - )) { - // Get the inner lerp function reference - final innerLerpFn = genericIsDouble - ? r'lerpDouble$'.ref - : genericIsDuration - ? r'lerpDuration$'.ref - : genericType.ref.prop('lerp'); - - // WidgetStateProperty.lerp( - // _this.field, - // other.field, - // t, - // Color.lerp - // ) - final expression = baseTypeName.ref.prop('lerp')( - [tProp, oProp, 't'.ref, innerLerpFn], - {}, - [genericType.typeRef(isNullable: isNullableGeneric)], - ); - - args[field.name] = field.isNullable - ? expression - : expression.nullChecked; - - continue; + // Default conditional expression + case NoLerp(): + // t < 0.5 ? _this.field : other.field + args[field.name] = 't'.ref + .lessThan(literalNum(0.5)) + .conditional(tProp, oProp); + + // Handle StaticLerp with non-nullable signature and optional + // field + case StaticLerp(isNullableSignature: false) when field.isNullable: + // _this.side == null + // ? other.side + // : other.side == null + // ? _this.side + // : Side.lerp(_this.side!, other.side!, t), + args[field.name] = tProp + .equalTo(literalNull) + .conditional( + oProp, + oProp + .equalTo(literalNull) + .conditional( + tProp, + sLerp([tProp.nullChecked, oProp.nullChecked, 't'.ref]), + ), + ); + + // Handle StaticLerp with non-nullable signature and + // non-optional field + case StaticLerp(isNullableSignature: false): + // FieldType.lerp(_this.field, other.field, t) + args[field.name] = sLerp([tProp, oProp, 't'.ref]); + + // Handle StaticLerp with nullable signature and + // non-optional field + case StaticLerp() when !field.isNullable: + // FieldType.lerp(_this.field, other.field, t)! + args[field.name] = sLerp([tProp, oProp, 't'.ref]).nullChecked; + + // Handle StaticLerp with nullable signature and optional + // field + case StaticLerp(): + // FieldType.lerp(_this.field, other.field, t) + args[field.name] = sLerp([tProp, oProp, 't'.ref]); + + // Handle InstanceLerp with optional result and optional field + case InstanceLerp(optionalResult: true) when field.isNullable: + // _this.field?.lerp(other.field, t) + args[field.name] = tProp.prop('lerp', nullSafe: true)([ + oProp, + 't'.ref, + ]); + + // Handle InstanceLerp with non-optional result and nullable field + case InstanceLerp(optionalResult: false) when field.isNullable: + // _this.field?.lerp(other.field, t) as FieldType? + args[field.name] = tProp + .prop('lerp', nullSafe: true)([oProp, 't'.ref]) + .asA(field.typeName.typeRef(isNullable: true)); + + // Handle InstanceLerp with non-optional field + case InstanceLerp(): + // _this.field.lerp(other.field, t) as FieldType + args[field.name] = tProp + .prop('lerp')([oProp, 't'.ref]) + .asA(field.typeName.typeRef()); + + // Handle WidgetStateProperty lerp with inner lerp function + case WidgetStatePropertyLerp( + :final baseTypeName, + :final genericType, + :final isNullableGeneric, + :final genericIsDouble, + :final genericIsDuration, + ): + // Get the inner lerp function reference + final innerLerpFn = genericIsDouble + ? r'lerpDouble$'.ref + : genericIsDuration + ? r'lerpDuration$'.ref + : genericType.ref.prop('lerp'); + + // WidgetStateProperty.lerp( + // _this.field, + // other.field, + // t, + // Color.lerp + // ) + final expression = baseTypeName.ref.prop('lerp')( + [tProp, oProp, 't'.ref, innerLerpFn], + {}, + [genericType.typeRef(isNullable: isNullableGeneric)], + ); + + args[field.name] = field.isNullable + ? expression + : expression.nullChecked; } - - throw UnimplementedError( - 'Lerp method not implemented for field: ${field.name}', - ); } b.addExpression( diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart index 9ef85a3..6b2c9b5 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart @@ -1,6 +1,5 @@ import 'package:code_builder/code_builder.dart'; -import '../../common/symbols/field_info.dart'; import '../../common/symbols/lerp_info.dart'; import '../../common/symbols/merge_info.dart'; import '../../config/config.dart'; @@ -150,47 +149,36 @@ Method merge(ThemeGenConfig config) => Method((m) { final instanceMerge = thisProp.prop('merge'); // Handle different merge strategies based on field configuration - - // No merge method, just take the other property - // `property: other.property` - if (field.merge case NoMerge()) { - args[field.name] = otherProp; - continue; - } - - // Static merge method with optional field - if (field.merge case StaticMerge() when field.isNullable) { - args[field.name] = thisProp - .notEqualTo(literalNull) - .and(otherProp.notEqualTo(literalNull)) - .conditional( - staticMerge([thisProp.nullChecked, otherProp.nullChecked]), - otherProp, - ); - continue; - } - - // Static merge method with non-optional field - if (field.merge case StaticMerge() when !field.isNullable) { - args[field.name] = staticMerge([thisProp, otherProp]); - continue; - } - - // Instance merge method with optional field - if (field.merge case InstanceMerge() when field.isNullable) { - args[field.name] = thisProp - .nullSafeProperty('merge')([otherProp]) - .ifNullThen(otherProp); - continue; - } - - // Instance merge method with non-optional field - if (field.merge case InstanceMerge() when !field.isNullable) { - args[field.name] = instanceMerge([otherProp]); - continue; + switch (field.merge) { + // No merge method, just take the other property + // `property: other.property` + case NoMerge(): + args[field.name] = otherProp; + + // Static merge method with optional field + case StaticMerge() when field.isNullable: + args[field.name] = thisProp + .notEqualTo(literalNull) + .and(otherProp.notEqualTo(literalNull)) + .conditional( + staticMerge([thisProp.nullChecked, otherProp.nullChecked]), + otherProp, + ); + + // Static merge method with non-optional field + case StaticMerge(): + args[field.name] = staticMerge([thisProp, otherProp]); + + // Instance merge method with optional field + case InstanceMerge() when field.isNullable: + args[field.name] = thisProp + .nullSafeProperty('merge')([otherProp]) + .ifNullThen(otherProp); + + // Instance merge method with non-optional field + case InstanceMerge(): + args[field.name] = instanceMerge([otherProp]); } - - throw StateError('Unsupported merge info for field ${field.name}'); } b.addExpression('copyWith'.ref([], args).returned); @@ -275,151 +263,121 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { final lerp = field.typeName.ref.prop('lerp'); // Handle different lerp strategies based on field configuration - - // Non-nullable field with non-nullable lerp signature - if (field.lerp case StaticLerp( - isNullableSignature: false, - ) when !field.isNullable) { - // value: Class.lerp(a.field, b.field, t) - argsResult[field.name] = lerp([aProp, bProp, 't'.ref]); - - continue; - } - - // Non-nullable field with nullable lerp signature - if (field.lerp case StaticLerp( - isNullableSignature: true, - ) when !field.isNullable) { - // value: Class.lerp(a.field, b.field, t)! - argsResult[field.name] = lerp([aProp, bProp, 't'.ref]).nullChecked; - continue; - } - - // Nullable field with non-nullable lerp signature - if (field.lerp case StaticLerp( - isNullableSignature: false, - ) when field.isNullable) { - // value: a.field == null - // ? b.field - // : b.field == null - // ? a.field - // : Class.lerp(a.field!, b.field!, t) - argsResult[field.name] = aProp - .equalTo(literalNull) - .conditional( - bProp, - bProp - .equalTo(literalNull) - .conditional( - aProp, - lerp([aProp.nullChecked, bProp.nullChecked, 't'.ref]), - ), - ); - - continue; - } - - // Nullable field with nullable lerp signature - if (field.lerp case StaticLerp( - isNullableSignature: true, - ) when field.isNullable) { - // value: Class.lerp(a.field, b.field, t) - argsResult[field.name] = lerp([aProp, bProp, 't'.ref]); - - continue; - } - - // Instance lerp method with optional result - if (field.lerp case InstanceLerp( - optionalResult: true, - ) when field.isNullable) { - // value: a.field?.lerp(b.field, t) - argsResult[field.name] = aProp.prop('lerp', nullSafe: true)([ - bProp, - 't'.ref, - ]); - - continue; - } - // Instance lerp method with non-optional result - if (field.lerp case InstanceLerp( - optionalResult: false, - ) when !field.isNullable) { - // value: a.field.lerp(b.field, t) - argsResult[field.name] = aProp - .prop('lerp')([bProp, 't'.ref]) - .asA(field.typeName.typeRef()); - - continue; - } - - // WidgetStateProperty lerp with inner lerp function - if (field.lerp case WidgetStatePropertyLerp( - :final baseTypeName, - :final genericType, - :final isNullableGeneric, - :final genericIsDouble, - :final genericIsDuration, - )) { - // Get the inner lerp function reference - final innerLerpFn = genericIsDouble - ? r'lerpDouble$'.ref - : genericIsDuration - ? r'lerpDuration$'.ref - : genericType.ref.prop('lerp'); - - // WidgetStateProperty.lerp( - // a.field, - // b.field, - // t, - // Color.lerp - // ) - - final expression = baseTypeName.ref.prop('lerp')( - [aProp, bProp, 't'.ref, innerLerpFn], - {}, - [genericType.typeRef(isNullable: isNullableGeneric)], - ); - - argsResult[field.name] = field.isNullable - ? expression - : expression.nullChecked; - - continue; - } - - // When the field is of type double - if (field case FieldInfo(isDouble: true) when field.lerp is NoLerp) { - final expression = r'lerpDouble$'.ref([aProp, bProp, 't'.ref]); - - argsResult[field.name] = field.isNullable - ? expression - : expression.nullChecked; - - continue; - } - - // When the field is of type Duration - if (field case FieldInfo(isDuration: true) when field.lerp is NoLerp) { - final expression = r'lerpDuration$'.ref([aProp, bProp, 't'.ref]); - - argsResult[field.name] = field.isNullable - ? expression - : expression.nullChecked; - - continue; - } - - // Special case for canMerge field - if (field.name == 'canMerge') { - argsResult[field.name] = bProp; - continue; + switch (field.lerp) { + // Non-nullable field with non-nullable lerp signature + case StaticLerp(isNullableSignature: false) when !field.isNullable: + // value: Class.lerp(a.field, b.field, t) + argsResult[field.name] = lerp([aProp, bProp, 't'.ref]); + + // Non-nullable field with nullable lerp signature + case StaticLerp() when !field.isNullable: + // value: Class.lerp(a.field, b.field, t)! + argsResult[field.name] = lerp([aProp, bProp, 't'.ref]).nullChecked; + + // Nullable field with non-nullable lerp signature + case StaticLerp(isNullableSignature: false): + // value: a.field == null + // ? b.field + // : b.field == null + // ? a.field + // : Class.lerp(a.field!, b.field!, t) + argsResult[field.name] = aProp + .equalTo(literalNull) + .conditional( + bProp, + bProp + .equalTo(literalNull) + .conditional( + aProp, + lerp([aProp.nullChecked, bProp.nullChecked, 't'.ref]), + ), + ); + + // Nullable field with nullable lerp signature + case StaticLerp(): + // value: Class.lerp(a.field, b.field, t) + argsResult[field.name] = lerp([aProp, bProp, 't'.ref]); + + // Instance lerp method with optional result and nullable field + case InstanceLerp(optionalResult: true) when field.isNullable: + // value: a.field?.lerp(b.field, t) + argsResult[field.name] = aProp.prop('lerp', nullSafe: true)([ + bProp, + 't'.ref, + ]); + + // Instance lerp method with non-optional result and nullable field + case InstanceLerp(optionalResult: false) when field.isNullable: + // value: a.field?.lerp(b.field, t) as Class? + argsResult[field.name] = aProp + .prop('lerp', nullSafe: true)([bProp, 't'.ref]) + .asA(field.typeName.typeRef(isNullable: true)); + + // Instance lerp method with non-nullable field + case InstanceLerp(): + // value: a.field.lerp(b.field, t) as Class + argsResult[field.name] = aProp + .prop('lerp')([bProp, 't'.ref]) + .asA(field.typeName.typeRef()); + + // WidgetStateProperty lerp with inner lerp function + case WidgetStatePropertyLerp( + :final baseTypeName, + :final genericType, + :final isNullableGeneric, + :final genericIsDouble, + :final genericIsDuration, + ): + // Get the inner lerp function reference + final innerLerpFn = genericIsDouble + ? r'lerpDouble$'.ref + : genericIsDuration + ? r'lerpDuration$'.ref + : genericType.ref.prop('lerp'); + + // WidgetStateProperty.lerp( + // a.field, + // b.field, + // t, + // Color.lerp + // ) + final expression = baseTypeName.ref.prop('lerp')( + [aProp, bProp, 't'.ref, innerLerpFn], + {}, + [genericType.typeRef(isNullable: isNullableGeneric)], + ); + + argsResult[field.name] = field.isNullable + ? expression + : expression.nullChecked; + + // When the field is of type double + case NoLerp() when field.isDouble: + final expression = r'lerpDouble$'.ref([aProp, bProp, 't'.ref]); + + argsResult[field.name] = field.isNullable + ? expression + : expression.nullChecked; + + // When the field is of type Duration + case NoLerp() when field.isDuration: + final expression = r'lerpDuration$'.ref([aProp, bProp, 't'.ref]); + + argsResult[field.name] = field.isNullable + ? expression + : expression.nullChecked; + + // Special case for canMerge field + case NoLerp() when field.name == 'canMerge': + argsResult[field.name] = bProp; + + // Fallback to a simple conditional expression: + // t < 0.5 ? a.field : b.field + case NoLerp(): + argsResult[field.name] = 't'.ref + .lessThan(literalNum(0.5)) + .conditional(aProp, bProp); } - // Fallback to a simple conditional expression: - // t < 0.5 ? a.field : b.field - argsResult[field.name] = 't'.ref - .lessThan(literalNum(0.5)) - .conditional(aProp, bProp); } b.addExpression( diff --git a/packages/theme_extensions_builder/pubspec.yaml b/packages/theme_extensions_builder/pubspec.yaml index 739106a..4cd14fb 100644 --- a/packages/theme_extensions_builder/pubspec.yaml +++ b/packages/theme_extensions_builder/pubspec.yaml @@ -8,7 +8,7 @@ issue_tracker: https://github.com/pro100andrey/theme_extensions_builder/issues homepage: https://github.com/pro100andrey/theme_extensions_builder documentation: https://github.com/pro100andrey/theme_extensions_builder/blob/main/packages/theme_extensions_builder/README.md -version: 7.4.0 +version: 7.5.0 platforms: android: @@ -25,13 +25,13 @@ topics: - codegen environment: - sdk: ">=3.10.0 <4.0.0" + sdk: ">=3.13.0 <4.0.0" dependencies: - analyzer: ">=9.0.0 <14.0.0" + analyzer: ">=13.0.0 <15.0.0" build: ">=3.0.0 <5.0.0" code_builder: ^4.11.1 - collection: ^1.19.1 + collection: ^1.15.0 meta: ^1.16.0 source_gen: ">=4.2.3 <5.0.0" theme_extensions_builder_annotation: ^7.4.0 @@ -39,7 +39,7 @@ dependencies: dev_dependencies: build_test: "^3.5.15" path: ^1.9.1 - pro_lints: ^6.1.0 + pro_lints: ^6.2.0 source_gen_test: ^1.3.6 test: ^1.31.1 \ No newline at end of file diff --git a/packages/theme_extensions_builder/test/generator/code_builder_test.dart b/packages/theme_extensions_builder/test/generator/code_builder_test.dart new file mode 100644 index 0000000..a4d8d81 --- /dev/null +++ b/packages/theme_extensions_builder/test/generator/code_builder_test.dart @@ -0,0 +1,124 @@ +import 'package:test/test.dart'; +import 'package:theme_extensions_builder/src/common/symbols/field_info.dart'; +import 'package:theme_extensions_builder/src/common/symbols/lerp_info.dart'; +import 'package:theme_extensions_builder/src/common/symbols/merge_info.dart'; +import 'package:theme_extensions_builder/src/common/symbols/parameter_info.dart'; +import 'package:theme_extensions_builder/src/config/config.dart'; +import 'package:theme_extensions_builder/src/generator/theme_gen/code_builder.dart'; + +/// Code paths that cannot be reached through the golden fixtures, either +/// because they need more fields than a readable fixture can hold, or because +/// they need a shape the mock classes don't provide. +void main() { + group('hashCode strategy', () { + test('no fields use runtimeType.hashCode', () { + final code = _generate(const []); + + expect(code, contains('return runtimeType.hashCode;')); + }); + + test('19 fields use Object.hash', () { + final code = _generate(_fields(19)); + + expect(code, contains('Object.hash(')); + expect(code, isNot(contains('Object.hashAll('))); + }); + + test('20 fields use Object.hashAll', () { + final code = _generate(_fields(20)); + + expect(code, contains('Object.hashAll([')); + expect(code, isNot(contains('Object.hash('))); + }); + }); + + group('lerp', () { + test('canMerge field takes the value of b', () { + final code = _generate([ + _field('canMerge', typeName: 'bool'), + ]); + + expect(code, contains('canMerge: b.canMerge')); + }); + + test('instance lerp with optional result on a non-nullable field', () { + final code = _generate([ + _field( + 'value', + typeName: 'Lerpable', + lerp: const InstanceLerp(optionalResult: true, args: [_nullableArg]), + ), + ]); + + expect(code, contains('(a.value.lerp(b.value, t) as Lerpable)')); + }); + + test('instance lerp with non-optional result on a nullable field', () { + final code = _generate([ + _field( + 'value', + typeName: 'Lerpable', + isNullable: true, + lerp: const InstanceLerp( + optionalResult: false, + args: [_nonNullableArg], + ), + ), + ]); + + expect(code, contains('(a.value?.lerp(b.value, t) as Lerpable?)')); + }); + }); +} + +const _nullableArg = ParameterInfo( + name: 'other', + type: 'Lerpable', + isNullable: true, +); + +const _nonNullableArg = ParameterInfo( + name: 'other', + type: 'Lerpable', + isNullable: false, +); + +/// Generates the mixin for [fields] and normalizes the emitter output. +/// +/// The code builder emits unformatted code, so whitespace and the trailing +/// commas code_builder adds before a closing paren are collapsed to keep the +/// expectations readable. +String _generate(List fields) { + final code = const ThemeGenCodeBuilder().generate( + ThemeGenConfig( + fields: fields, + className: 'Theme', + constructor: null, + constConstructor: true, + ), + ); + + return code + .replaceAll(RegExp(r',\s*\)'), ')') + .replaceAll(RegExp(r'\s+'), ' '); +} + +List _fields(int count) => [ + for (var i = 0; i < count; i++) _field('field$i'), +]; + +FieldInfo _field( + String name, { + String typeName = 'int', + bool isNullable = false, + LerpInfo lerp = const NoLerp(), +}) => FieldInfo( + name: name, + typeName: typeName, + isNullable: isNullable, + isDouble: false, + isDuration: false, + merge: const NoMerge(), + lerp: lerp, + isStatic: false, +); diff --git a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.dart b/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.dart index 3943f9e..c7cfcce 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.dart +++ b/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.dart @@ -129,6 +129,7 @@ final class ComplexThemeExtension extends ThemeExtension required this.optionalTheme, required this.optionalThemeExtension, + this.optionalLerpableWithOptionalResult, this.computedValue = 'computed', }); @@ -153,6 +154,7 @@ final class ComplexThemeExtension extends ThemeExtension final BorderSide? optionalBorderSide; final EmptyTheme? optionalTheme; final EmptyThemeExtension? optionalThemeExtension; + final LerpableWithOptionalResult? optionalLerpableWithOptionalResult; @ignore final String computedValue; diff --git a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart index 5f3bc68..c91f51a 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart @@ -192,6 +192,7 @@ mixin _$ComplexThemeExtension on ThemeExtension { BorderSide? optionalBorderSide, EmptyTheme? optionalTheme, EmptyThemeExtension? optionalThemeExtension, + LerpableWithOptionalResult? optionalLerpableWithOptionalResult, }) { final _this = (this as ComplexThemeExtension); @@ -216,6 +217,9 @@ mixin _$ComplexThemeExtension on ThemeExtension { optionalTheme: optionalTheme ?? _this.optionalTheme, optionalThemeExtension: optionalThemeExtension ?? _this.optionalThemeExtension, + optionalLerpableWithOptionalResult: + optionalLerpableWithOptionalResult ?? + _this.optionalLerpableWithOptionalResult, ); } @@ -289,6 +293,9 @@ mixin _$ComplexThemeExtension on ThemeExtension { optionalThemeExtension: (_this.optionalThemeExtension?.lerp(other.optionalThemeExtension, t) as EmptyThemeExtension?), + optionalLerpableWithOptionalResult: _this + .optionalLerpableWithOptionalResult + ?.lerp(other.optionalLerpableWithOptionalResult, t), ); } @@ -322,7 +329,9 @@ mixin _$ComplexThemeExtension on ThemeExtension { _other.optionalColor == _this.optionalColor && _other.optionalBorderSide == _this.optionalBorderSide && _other.optionalTheme == _this.optionalTheme && - _other.optionalThemeExtension == _this.optionalThemeExtension; + _other.optionalThemeExtension == _this.optionalThemeExtension && + _other.optionalLerpableWithOptionalResult == + _this.optionalLerpableWithOptionalResult; } @override @@ -349,6 +358,7 @@ mixin _$ComplexThemeExtension on ThemeExtension { _this.optionalBorderSide, _this.optionalTheme, _this.optionalThemeExtension, + _this.optionalLerpableWithOptionalResult, ); } } diff --git a/packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension.g.theme.dart index f10056f..75d3160 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension.g.theme.dart @@ -21,6 +21,7 @@ mixin _$ComplexThemeExtension on ThemeExtension { BorderSide? optionalBorderSide, EmptyTheme? optionalTheme, EmptyThemeExtension? optionalThemeExtension, + LerpableWithOptionalResult? optionalLerpableWithOptionalResult, }) { final _this = (this as ComplexThemeExtension); @@ -45,6 +46,9 @@ mixin _$ComplexThemeExtension on ThemeExtension { optionalTheme: optionalTheme ?? _this.optionalTheme, optionalThemeExtension: optionalThemeExtension ?? _this.optionalThemeExtension, + optionalLerpableWithOptionalResult: + optionalLerpableWithOptionalResult ?? + _this.optionalLerpableWithOptionalResult, ); } @@ -84,9 +88,10 @@ mixin _$ComplexThemeExtension on ThemeExtension { other.requiredTheme, t, )!, - requiredThemeExtension: - (_this.requiredThemeExtension.lerp(other.requiredThemeExtension, t) - as EmptyThemeExtension), + requiredThemeExtension: (_this.requiredThemeExtension.lerp( + other.requiredThemeExtension, + t, + ) as EmptyThemeExtension), optionalInt: t < 0.5 ? _this.optionalInt : other.optionalInt, optionalDouble: lerpDouble$( _this.optionalDouble, @@ -115,9 +120,13 @@ mixin _$ComplexThemeExtension on ThemeExtension { other.optionalTheme, t, ), - optionalThemeExtension: - (_this.optionalThemeExtension?.lerp(other.optionalThemeExtension, t) - as EmptyThemeExtension?), + optionalThemeExtension: (_this.optionalThemeExtension?.lerp( + other.optionalThemeExtension, + t, + ) as EmptyThemeExtension?), + optionalLerpableWithOptionalResult: _this + .optionalLerpableWithOptionalResult + ?.lerp(other.optionalLerpableWithOptionalResult, t), ); } @@ -151,7 +160,9 @@ mixin _$ComplexThemeExtension on ThemeExtension { _other.optionalColor == _this.optionalColor && _other.optionalBorderSide == _this.optionalBorderSide && _other.optionalTheme == _this.optionalTheme && - _other.optionalThemeExtension == _this.optionalThemeExtension; + _other.optionalThemeExtension == _this.optionalThemeExtension && + _other.optionalLerpableWithOptionalResult == + _this.optionalLerpableWithOptionalResult; } @override @@ -178,6 +189,7 @@ mixin _$ComplexThemeExtension on ThemeExtension { _this.optionalBorderSide, _this.optionalTheme, _this.optionalThemeExtension, + _this.optionalLerpableWithOptionalResult, ); } } diff --git a/packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart index 33d5ff9..77d00a7 100644 --- a/packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart @@ -65,9 +65,9 @@ mixin _$ComplexThemeInternal { ? a.optionalBorderSide : BorderSide.lerp(a.optionalBorderSide!, b.optionalBorderSide!, t), optionalTheme: EmptyTheme.lerp(a.optionalTheme, b.optionalTheme, t), - optionalThemeExtension: t < 0.5 - ? a.optionalThemeExtension - : b.optionalThemeExtension, + optionalThemeExtension: + (a.optionalThemeExtension?.lerp(b.optionalThemeExtension, t) + as EmptyThemeExtension?), optionalLerpableWithOptionalResult: a.optionalLerpableWithOptionalResult ?.lerp(b.optionalLerpableWithOptionalResult, t), ); @@ -285,9 +285,9 @@ mixin _$ComplexTheme { ? a.optionalBorderSide : BorderSide.lerp(a.optionalBorderSide!, b.optionalBorderSide!, t), optionalTheme: EmptyTheme.lerp(a.optionalTheme, b.optionalTheme, t), - optionalThemeExtension: t < 0.5 - ? a.optionalThemeExtension - : b.optionalThemeExtension, + optionalThemeExtension: + (a.optionalThemeExtension?.lerp(b.optionalThemeExtension, t) + as EmptyThemeExtension?), optionalLerpableWithOptionalResult: a.optionalLerpableWithOptionalResult ?.lerp(b.optionalLerpableWithOptionalResult, t), ); diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme.g.theme.dart index a975110..69fa192 100644 --- a/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme.g.theme.dart @@ -33,9 +33,10 @@ mixin _$ComplexTheme { t, ), requiredTheme: EmptyTheme.lerp(a.requiredTheme, b.requiredTheme, t)!, - requiredThemeExtension: - (a.requiredThemeExtension.lerp(b.requiredThemeExtension, t) - as EmptyThemeExtension), + requiredThemeExtension: (a.requiredThemeExtension.lerp( + b.requiredThemeExtension, + t, + ) as EmptyThemeExtension), optionalInt: t < 0.5 ? a.optionalInt : b.optionalInt, optionalDouble: lerpDouble$(a.optionalDouble, b.optionalDouble, t), optionalString: t < 0.5 ? a.optionalString : b.optionalString, @@ -52,9 +53,10 @@ mixin _$ComplexTheme { ? a.optionalBorderSide : BorderSide.lerp(a.optionalBorderSide!, b.optionalBorderSide!, t), optionalTheme: EmptyTheme.lerp(a.optionalTheme, b.optionalTheme, t), - optionalThemeExtension: t < 0.5 - ? a.optionalThemeExtension - : b.optionalThemeExtension, + optionalThemeExtension: (a.optionalThemeExtension?.lerp( + b.optionalThemeExtension, + t, + ) as EmptyThemeExtension?), optionalLerpableWithOptionalResult: a.optionalLerpableWithOptionalResult ?.lerp(b.optionalLerpableWithOptionalResult, t), ); diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme_internal.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme_internal.g.theme.dart index 693bb72..60fab44 100644 --- a/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme_internal.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme_internal.g.theme.dart @@ -37,9 +37,10 @@ mixin _$ComplexThemeInternal { t, ), requiredTheme: EmptyTheme.lerp(a.requiredTheme, b.requiredTheme, t)!, - requiredThemeExtension: - (a.requiredThemeExtension.lerp(b.requiredThemeExtension, t) - as EmptyThemeExtension), + requiredThemeExtension: (a.requiredThemeExtension.lerp( + b.requiredThemeExtension, + t, + ) as EmptyThemeExtension), optionalInt: t < 0.5 ? a.optionalInt : b.optionalInt, optionalDouble: lerpDouble$(a.optionalDouble, b.optionalDouble, t), optionalString: t < 0.5 ? a.optionalString : b.optionalString, @@ -56,9 +57,10 @@ mixin _$ComplexThemeInternal { ? a.optionalBorderSide : BorderSide.lerp(a.optionalBorderSide!, b.optionalBorderSide!, t), optionalTheme: EmptyTheme.lerp(a.optionalTheme, b.optionalTheme, t), - optionalThemeExtension: t < 0.5 - ? a.optionalThemeExtension - : b.optionalThemeExtension, + optionalThemeExtension: (a.optionalThemeExtension?.lerp( + b.optionalThemeExtension, + t, + ) as EmptyThemeExtension?), optionalLerpableWithOptionalResult: a.optionalLerpableWithOptionalResult ?.lerp(b.optionalLerpableWithOptionalResult, t), ); diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/widget_state_property_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/goldens/widget_state_property_theme.g.theme.dart index 44f4dad..4a77bf0 100644 --- a/packages/theme_extensions_builder/test/theme_gen/goldens/widget_state_property_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/goldens/widget_state_property_theme.g.theme.dart @@ -39,19 +39,19 @@ mixin _$WidgetStatePropertyTheme { b.optionalColor, t, Color.lerp, - )!, + ), optionalWidth: WidgetStateProperty.lerp( a.optionalWidth, b.optionalWidth, t, lerpDouble$, - )!, + ), optionalDuration: WidgetStateProperty.lerp( a.optionalDuration, b.optionalDuration, t, lerpDuration$, - )!, + ), ); } diff --git a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart b/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart index 157e93a..9af7d0a 100644 --- a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart @@ -25,9 +25,9 @@ final class WidgetStatePropertyTheme with _$WidgetStatePropertyTheme { final WidgetStateProperty width; final WidgetStateProperty duration; - final WidgetStateProperty optionalColor; - final WidgetStateProperty optionalWidth; - final WidgetStateProperty optionalDuration; + final WidgetStateProperty? optionalColor; + final WidgetStateProperty? optionalWidth; + final WidgetStateProperty? optionalDuration; @override bool get canMerge => true; diff --git a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.g.theme.dart index 6880fb9..855ec2e 100644 --- a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.g.theme.dart @@ -48,19 +48,19 @@ mixin _$WidgetStatePropertyTheme { b.optionalColor, t, Color.lerp, - )!, + ), optionalWidth: WidgetStateProperty.lerp( a.optionalWidth, b.optionalWidth, t, lerpDouble$, - )!, + ), optionalDuration: WidgetStateProperty.lerp( a.optionalDuration, b.optionalDuration, t, lerpDuration$, - )!, + ), ); } diff --git a/packages/theme_extensions_builder_annotation/pubspec.yaml b/packages/theme_extensions_builder_annotation/pubspec.yaml index b590f74..b4b67c1 100644 --- a/packages/theme_extensions_builder_annotation/pubspec.yaml +++ b/packages/theme_extensions_builder_annotation/pubspec.yaml @@ -8,7 +8,7 @@ issue_tracker: https://github.com/pro100andrey/theme_extensions_builder/issues homepage: https://github.com/pro100andrey/theme_extensions_builder documentation: https://github.com/pro100andrey/theme_extensions_builder/blob/main/packages/theme_extensions_builder/README.md -version: 7.4.0 +version: 7.5.0 topics: - theme @@ -25,11 +25,11 @@ platforms: windows: environment: - sdk: ">=3.10.0 <4.0.0" + sdk: ">=3.13.0 <4.0.0" dependencies: meta: ^1.16.0 dev_dependencies: - pro_lints: ^6.1.0 + pro_lints: ^6.2.0 test: ^1.31.1 From f97eb516bd720f98a00a230e04b1a11cf56f2b33 Mon Sep 17 00:00:00 2001 From: Andrii Ivanov Date: Sat, 5 Sep 2026 12:54:04 +0300 Subject: [PATCH 02/10] - **Fixed**: `@ThemeGen` now interpolates a nullable field whose type has an instance `lerp` method, instead of falling back to `t < 0.5 ? a : b`. - **Fixed**: `@ThemeGen` now generates compilable `merge` code for a nullable field whose type has a `merge` method with a non-nullable parameter. - **Fixed**: A field type declaring an unrelated `lerp` or `merge` method no longer fails the build: an unknown signature falls back to no interpolation / no merge, and optional or named parameters no longer hide a supported signature. - **Fixed**: A `WidgetStateProperty` field with a non-nullable generic now reports an `InvalidGenerationSourceError` pointing at the field instead of a bare `StateError`. - **Fixed**: `Duration` is detected by element rather than by name, and a field redeclared by a subclass no longer appears twice in the generated `copyWith`. - **Code Quality**: Exhaustive switches over `LerpInfo`/`MergeInfo`, `==`/`hashCode` contract fixed for `StaticLerp` and `InstanceLerp`, dead code removed (`getMixinsNames`, `BaseClassVisitor`, unused builder options and lerp-lookup flag). --- .../theme_extensions_builder/CHANGELOG.md | 9 ++ .../example/pubspec.lock | 6 +- .../theme_extensions_builder/lib/builder.dart | 5 +- .../lib/src/common/analysis.dart | 115 +++++++----------- .../lib/src/common/base_class_visiter.dart | 83 ------------- ...ields_visiter.dart => fields_visitor.dart} | 43 +++++-- .../lib/src/common/fields_visitor_config.dart | 40 ++---- .../lib/src/common/symbols/lerp_info.dart | 6 +- .../lib/src/common/symbols/merge_info.dart | 31 ++--- .../lib/src/extensions/string.dart | 17 ++- .../lib/src/generator/common.dart | 6 +- .../generator/theme_extensions/generator.dart | 26 +--- .../src/generator/theme_gen/code_builder.dart | 27 +++- .../src/generator/theme_gen/generator.dart | 25 +--- .../test/generator/invalid_test.dart | 17 +++ .../test/generator/theme_gen_test.dart | 9 ++ .../test/invalid/invalid_theme.dart | 22 ++++ .../test/invalid/mock.dart | 1 + .../runtime/fields_visitor_config_test.dart | 62 ++-------- .../test/runtime/symbols_test.dart | 100 ++++++++++++--- .../test/runtime/theme_gen_lookup_test.dart | 55 +++++++++ .../goldens/lookup_theme.g.theme.dart | 97 +++++++++++++++ .../test/theme_gen/lookup_theme.dart | 57 +++++++++ .../test/theme_gen/lookup_theme.g.theme.dart | 106 ++++++++++++++++ 24 files changed, 632 insertions(+), 333 deletions(-) delete mode 100644 packages/theme_extensions_builder/lib/src/common/base_class_visiter.dart rename packages/theme_extensions_builder/lib/src/common/{fields_visiter.dart => fields_visitor.dart} (63%) create mode 100644 packages/theme_extensions_builder/test/generator/invalid_test.dart create mode 100644 packages/theme_extensions_builder/test/invalid/invalid_theme.dart create mode 120000 packages/theme_extensions_builder/test/invalid/mock.dart create mode 100644 packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart create mode 100644 packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart create mode 100644 packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart create mode 100644 packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart diff --git a/packages/theme_extensions_builder/CHANGELOG.md b/packages/theme_extensions_builder/CHANGELOG.md index 5a47fb3..6d7996b 100644 --- a/packages/theme_extensions_builder/CHANGELOG.md +++ b/packages/theme_extensions_builder/CHANGELOG.md @@ -1,3 +1,12 @@ +## 7.5.0 + +- **Fixed**: `@ThemeGen` now interpolates a nullable field whose type has an instance `lerp` method, instead of falling back to `t < 0.5 ? a : b`. +- **Fixed**: `@ThemeGen` now generates compilable `merge` code for a nullable field whose type has a `merge` method with a non-nullable parameter. +- **Fixed**: A field type declaring an unrelated `lerp` or `merge` method no longer fails the build: an unknown signature falls back to no interpolation / no merge, and optional or named parameters no longer hide a supported signature. +- **Fixed**: A `WidgetStateProperty` field with a non-nullable generic now reports an `InvalidGenerationSourceError` pointing at the field instead of a bare `StateError`. +- **Fixed**: `Duration` is detected by element rather than by name, and a field redeclared by a subclass no longer appears twice in the generated `copyWith`. +- **Code Quality**: Exhaustive switches over `LerpInfo`/`MergeInfo`, `==`/`hashCode` contract fixed for `StaticLerp` and `InstanceLerp`, dead code removed (`getMixinsNames`, `BaseClassVisitor`, unused builder options and lerp-lookup flag). + ## 7.4.0 - *Updated*: Analyzer dependency to ">=9.0.0 <14.0.0" diff --git a/packages/theme_extensions_builder/example/pubspec.lock b/packages/theme_extensions_builder/example/pubspec.lock index bb9421f..2332c13 100644 --- a/packages/theme_extensions_builder/example/pubspec.lock +++ b/packages/theme_extensions_builder/example/pubspec.lock @@ -393,14 +393,14 @@ packages: path: ".." relative: true source: path - version: "7.4.0" + version: "7.5.0" theme_extensions_builder_annotation: dependency: "direct main" description: path: "../../theme_extensions_builder_annotation" relative: true source: path - version: "7.4.0" + version: "7.5.0" typed_data: dependency: transitive description: @@ -458,4 +458,4 @@ packages: source: hosted version: "3.1.4" sdks: - dart: ">=3.12.0 <4.0.0" + dart: ">=3.13.0 <4.0.0" diff --git a/packages/theme_extensions_builder/lib/builder.dart b/packages/theme_extensions_builder/lib/builder.dart index 0d58fdf..baa331d 100644 --- a/packages/theme_extensions_builder/lib/builder.dart +++ b/packages/theme_extensions_builder/lib/builder.dart @@ -6,10 +6,7 @@ import 'src/generator/theme_gen/generator.dart'; /// Function used by the build runner Builder themeExtensionsBuilder(BuilderOptions options) => PartBuilder( - [ - ThemeExtensionsGenerator(builderOptions: options), - ThemeGenGenerator(builderOptions: options), - ], + [const ThemeExtensionsGenerator(), const ThemeGenGenerator()], '.g.theme.dart', header: ''' // coverage:ignore-file diff --git a/packages/theme_extensions_builder/lib/src/common/analysis.dart b/packages/theme_extensions_builder/lib/src/common/analysis.dart index 9520642..d6d705f 100644 --- a/packages/theme_extensions_builder/lib/src/common/analysis.dart +++ b/packages/theme_extensions_builder/lib/src/common/analysis.dart @@ -2,8 +2,6 @@ library; -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/type.dart'; import 'package:source_gen/source_gen.dart'; @@ -17,13 +15,8 @@ import 'symbols/parameter_info.dart'; /// Creates a [FieldInfo] from the given [element]. /// -/// The [config] parameter controls what information should be collected: -/// - When [FieldsVisitorConfig.includeLerpLookup] is `false`, lerp method -/// lookups are skipped -/// - When [FieldsVisitorConfig.includeMergeLookup] is `false`, merge method -/// lookups are skipped -/// -/// Skipping unnecessary lookups can significantly improve performance. +/// When [FieldsVisitorConfig.includeMergeLookup] is `false`, the merge method +/// lookup is skipped, which speeds up generators that don't emit `merge`. FieldInfo fieldSymbol( FieldElement element, { FieldsVisitorConfig config = const FieldsVisitorConfig(), @@ -45,18 +38,20 @@ FieldInfo fieldSymbol( merge: config.includeMergeLookup ? _mergeInfo(elementType) : const NoMerge(), - lerp: config.includeLerpLookup - ? _lerpInfo(elementType, element) - : const NoLerp(), + lerp: _lerpInfo(elementType, element), ); } /// Gets information about the lerp method for the given [type]. /// -/// Returns information about static or instance lerp methods, or [NoLerp] -/// if no suitable lerp method is found or if the type is not an interface. +/// Returns information about static or instance lerp methods, or [NoLerp] if +/// the type is not an interface, has no lerp method, or has one whose +/// signature we cannot call. A type is free to declare an unrelated `lerp` +/// method, so an unknown signature falls back to [NoLerp] rather than failing +/// the build. /// -/// Throws [StateError] if a lerp method exists but has an invalid signature. +/// Throws [InvalidGenerationSourceError] for a `WidgetStateProperty` field +/// with a non-nullable generic, which is a mistake we can point at. LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { final typeElement = type.element; @@ -70,7 +65,12 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { return const NoLerp(); } - final params = method.formalParameters; + // Optional and named parameters take no part in the signature check: a + // method stays callable the way we expect when it has extra defaulted + // parameters. + final params = method.formalParameters + .where((p) => p.isRequiredPositional) + .toList(growable: false); // WidgetStateProperty and WidgetStateColor use a different signature for // lerp. Check for 4-parameter version first, as WidgetStateProperty has @@ -81,6 +81,7 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { // - third parameter should be double // - fourth parameter is a lerp function for the inner type when type is InterfaceType && + type.typeArguments.length == 1 && method.isStatic && p3.type.isDartCoreDouble && _checkSubtype(p1, type) && @@ -111,13 +112,16 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { if (!innerType.hasNullableSuffix) { final typeName = type.getDisplayString(); final innerTypeName = innerType.getDisplayString(); - throw StateError( - 'WidgetStateProperty must have a nullable generic type for field ' - '${fieldElement.name}. Found: $typeName\n' - 'The generic type must be nullable because WidgetStateProperty.lerp ' - 'requires a lerp function with nullable parameters.\n' - 'Change the field type from $typeName to ' - 'WidgetStateProperty<$innerTypeName?> to fix this issue.', + final baseName = type.element.displayName; + + throw InvalidGenerationSourceError( + '$baseName must have a nullable generic type, because ' + '$baseName.lerp requires a lerp function with nullable parameters. ' + 'Found: $typeName', + element: fieldElement, + todo: + 'Change the type of ${fieldElement.displayName} to ' + '$baseName<$innerTypeName?>', ); } @@ -162,11 +166,8 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { ); } - throw StateError( - 'Lerp method has invalid signature for type ' - '${type.getDisplayString()} of field ${fieldElement.name} ' - 'method: ${method.displayName} isStatic: ${method.isStatic} ', - ); + // The type declares a `lerp` we don't know how to call. + return const NoLerp(); } /// Checks if a parameter type is a subtype of the given [type]. @@ -260,7 +261,8 @@ MethodElement? _lookupMethod(InterfaceElement typeElement, String name) { /// Gets information about the merge method for the given [type]. /// -/// This can improve performance when merge details aren't needed. +/// Returns [NoMerge] when the type is not an interface, has no merge method, +/// or declares one whose signature we cannot call. MergeInfo _mergeInfo(DartType type) { final typeElement = type.element; @@ -274,6 +276,7 @@ MergeInfo _mergeInfo(DartType type) { // phase. const themeGenChecker = TypeChecker.typeNamed(ThemeGen); if (themeGenChecker.hasAnnotationOfExact(typeElement)) { + // The generated merge method takes a nullable argument. return const InstanceMerge(); } @@ -282,7 +285,9 @@ MergeInfo _mergeInfo(DartType type) { return const NoMerge(); } - final params = method.formalParameters; + final params = method.formalParameters + .where((p) => p.isRequiredPositional) + .toList(growable: false); if (params case [final p1, final p2] // Check for static merge method @@ -297,10 +302,11 @@ MergeInfo _mergeInfo(DartType type) { // - should have only one parameter // - parameter type should match the class type when !method.isStatic && p1.type.baseType == type.baseType) { - return const InstanceMerge(); + return InstanceMerge(isNullableParameter: p1.type.hasNullableSuffix); } - throw StateError('Merge method not found'); + // The type declares a `merge` we don't know how to call. + return const NoMerge(); } extension DartTypeExtension on DartType { @@ -317,45 +323,12 @@ extension DartTypeExtension on DartType { /// Returns true if the type has a nullable suffix. bool get hasNullableSuffix => nullabilitySuffix == .question; - /// Returns true if the type is Duration. - bool get isDuration => baseType == 'Duration'; -} - -/// Gets the names of mixins applied to the given [element]. -List getMixinsNames({required ClassElement element}) { - final library = element.library.session.getParsedLibraryByElement( - element.library, - ); - - if (library is! ParsedLibraryResult) { - throw StateError('Could not get parsed library for element'); - } - - ClassDeclaration? classDeclaration; - - outerLoop: - for (final unit in library.units) { - for (final decl in unit.unit.declarations) { - if (decl is ClassDeclaration && - decl.namePart.typeName.lexeme == element.displayName) { - classDeclaration = decl; - break outerLoop; - } - } - } - - final withClause = classDeclaration?.withClause; + /// Returns true if the type is `Duration` from `dart:core`. + bool get isDuration { + final typeElement = element; - if (withClause == null) { - throw StateError( - 'Mixin clause is missing for class ${element.displayName}. ' - 'Try adding "with _\$${element.displayName}" to the class declaration.', - ); + return typeElement != null && + typeElement.displayName == 'Duration' && + (typeElement.library?.isDartCore ?? false); } - - final result = withClause.mixinTypes - .map((e) => e.name.lexeme) - .toList(growable: false); - - return result; } diff --git a/packages/theme_extensions_builder/lib/src/common/base_class_visiter.dart b/packages/theme_extensions_builder/lib/src/common/base_class_visiter.dart deleted file mode 100644 index 6b0be22..0000000 --- a/packages/theme_extensions_builder/lib/src/common/base_class_visiter.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:analyzer/dart/element/element.dart'; - -/// Base class for visiting Dart element nodes in the AST. -/// -/// This class extends [ElementVisitor2] and provides empty implementations -/// for all visitor methods. Subclasses should override specific methods -/// to implement custom visiting behavior. -/// -/// Commonly used by code generators to traverse class elements and extract -/// information about fields, methods, and other class members. -class BaseClassVisitor extends ElementVisitor2 { - @override - void visitFieldElement(FieldElement element) {} - - @override - void visitClassElement(ClassElement element) {} - - @override - void visitConstructorElement(ConstructorElement element) {} - - @override - void visitEnumElement(EnumElement element) {} - - @override - void visitExtensionElement(ExtensionElement element) {} - - @override - void visitExtensionTypeElement(ExtensionTypeElement element) {} - - @override - void visitFieldFormalParameterElement(FieldFormalParameterElement element) {} - - @override - void visitFormalParameterElement(FormalParameterElement element) {} - - @override - void visitGenericFunctionTypeElement(GenericFunctionTypeElement element) {} - - @override - void visitGetterElement(GetterElement element) {} - - @override - void visitLabelElement(LabelElement element) {} - - @override - void visitLibraryElement(LibraryElement element) {} - - @override - void visitLocalFunctionElement(LocalFunctionElement element) {} - - @override - void visitLocalVariableElement(LocalVariableElement element) {} - - @override - void visitMethodElement(MethodElement element) {} - - @override - void visitMixinElement(MixinElement element) {} - - @override - void visitMultiplyDefinedElement(MultiplyDefinedElement element) {} - - @override - void visitPrefixElement(PrefixElement element) {} - - @override - void visitSetterElement(SetterElement element) {} - - @override - void visitSuperFormalParameterElement(SuperFormalParameterElement element) {} - - @override - void visitTopLevelFunctionElement(TopLevelFunctionElement element) {} - - @override - void visitTopLevelVariableElement(TopLevelVariableElement element) {} - - @override - void visitTypeAliasElement(TypeAliasElement element) {} - - @override - void visitTypeParameterElement(TypeParameterElement element) {} -} diff --git a/packages/theme_extensions_builder/lib/src/common/fields_visiter.dart b/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart similarity index 63% rename from packages/theme_extensions_builder/lib/src/common/fields_visiter.dart rename to packages/theme_extensions_builder/lib/src/common/fields_visitor.dart index 28920dd..0afc553 100644 --- a/packages/theme_extensions_builder/lib/src/common/fields_visiter.dart +++ b/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart @@ -1,12 +1,35 @@ import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/visitor2.dart'; import 'package:source_gen/source_gen.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; import 'analysis.dart'; -import 'base_class_visiter.dart'; import 'fields_visitor_config.dart'; import 'symbols/field_info.dart'; +/// Collects the fields of [element] together with the fields of all its +/// supertypes. +/// +/// Supertypes are visited first, so a field redeclared by [element] replaces +/// the inherited one. The [config] controls what information is collected for +/// each field. +List collectFields( + ClassElement element, { + FieldsVisitorConfig config = const FieldsVisitorConfig(), +}) { + final visitor = FieldsVisitor(config: config); + + for (final supertype in element.allSupertypes) { + if (!supertype.isDartCoreObject) { + supertype.element.visitChildren(visitor); + } + } + + element.visitChildren(visitor); + + return visitor.fields; +} + /// A visitor that collects field information from a class element. /// /// This visitor traverses class elements and extracts information about their @@ -23,7 +46,7 @@ import 'symbols/field_info.dart'; /// classElement.visitChildren(visitor); /// final fields = visitor.fields; /// ``` -class FieldsVisitor extends BaseClassVisitor { +class FieldsVisitor extends SimpleElementVisitor2 { /// Creates a [FieldsVisitor] with the specified [config]. /// /// The [config] controls what information should be collected during field @@ -35,14 +58,17 @@ class FieldsVisitor extends BaseClassVisitor { /// See [FieldsVisitorConfig] for available options and presets. final FieldsVisitorConfig config; - /// Internal set to store unique field information. - final Set _fields = {}; + /// Collected field information, keyed by field name. + /// + /// Keying by name means a field redeclared by a subclass replaces the one + /// declared by its supertype instead of being collected twice. + final Map _fields = {}; /// Returns an immutable list of collected field information. /// - /// The list is created from the internal set, ensuring no duplicates - /// and preventing external modification. - List get fields => _fields.toList(growable: false); + /// The list is created from the internal map, preserving the order in which + /// the fields were visited and preventing external modification. + List get fields => _fields.values.toList(growable: false); /// Type checker used to identify fields annotated with `@ignore`. /// @@ -66,8 +92,7 @@ class FieldsVisitor extends BaseClassVisitor { // Only process non-synthetic fields (explicitly declared in source code) if (element.isOriginDeclaration) { - final field = fieldSymbol(element, config: config); - _fields.add(field); + _fields[element.displayName] = fieldSymbol(element, config: config); } } } diff --git a/packages/theme_extensions_builder/lib/src/common/fields_visitor_config.dart b/packages/theme_extensions_builder/lib/src/common/fields_visitor_config.dart index 9b088be..1db2f74 100644 --- a/packages/theme_extensions_builder/lib/src/common/fields_visitor_config.dart +++ b/packages/theme_extensions_builder/lib/src/common/fields_visitor_config.dart @@ -1,4 +1,4 @@ -/// @docImport 'fields_visiter.dart'; +/// @docImport 'fields_visitor.dart'; library; @@ -12,33 +12,18 @@ class FieldsVisitorConfig { /// /// Example usage: /// ```dart - /// // For ThemeExtensions (only needs lerp) - /// final config = FieldsVisitorConfig( - /// includeMerge: false, - /// includeMergeLookup: false, - /// ); + /// // For ThemeGen, which generates a merge method + /// const config = FieldsVisitorConfig(); /// - /// // For ThemeGen (needs both lerp and merge) - /// final config = FieldsVisitorConfig.full(); - /// - /// // Minimal config (skip all lookups) - /// final config = FieldsVisitorConfig.minimal(); + /// // For ThemeExtensions, which does not + /// const config = FieldsVisitorConfig(includeMergeLookup: false); /// ``` - const FieldsVisitorConfig({ - this.includeLerpLookup = true, - this.includeMergeLookup = true, - }); - - /// Whether to perform method lookups for lerp methods. - /// - /// When `false`, skips expensive method lookups in _lerpInfo. - /// The lerp info will still be collected but without method lookup details. - final bool includeLerpLookup; + const FieldsVisitorConfig({this.includeMergeLookup = true}); - /// Whether to perform method lookups for merge methods. + /// Whether to look up merge methods on field types. /// - /// When `false`, skips expensive method lookups in _mergeInfo. - /// Only relevant when [includeMergeLookup] is `true`. + /// When `false`, the lookup is skipped and every field is reported as + /// `NoMerge`. Use it for generators that don't emit a `merge` method. final bool includeMergeLookup; @override @@ -46,15 +31,12 @@ class FieldsVisitorConfig { identical(this, other) || other is FieldsVisitorConfig && runtimeType == other.runtimeType && - includeLerpLookup == other.includeLerpLookup && includeMergeLookup == other.includeMergeLookup; @override - int get hashCode => includeLerpLookup.hashCode ^ includeMergeLookup.hashCode; + int get hashCode => Object.hash(runtimeType, includeMergeLookup); @override String toString() => - 'FieldsVisitorConfig(' - 'includeLerpLookup: $includeLerpLookup, ' - 'includeMergeLookup: $includeMergeLookup)'; + 'FieldsVisitorConfig(includeMergeLookup: $includeMergeLookup)'; } diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart index 31dc28e..61fea48 100644 --- a/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart +++ b/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart @@ -47,7 +47,8 @@ final class StaticLerp extends LerpInfo { _listEquality.equals(args, other.args); @override - int get hashCode => Object.hash(runtimeType, optionalResult); + int get hashCode => + Object.hash(runtimeType, optionalResult, _listEquality.hash(args)); @override String toString() => @@ -82,7 +83,8 @@ final class InstanceLerp extends LerpInfo { _listEquality.equals(args, other.args); @override - int get hashCode => Object.hash(runtimeType, optionalResult, args); + int get hashCode => + Object.hash(runtimeType, optionalResult, _listEquality.hash(args)); @override String toString() => diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart index 8c355da..f6cb344 100644 --- a/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart +++ b/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart @@ -4,16 +4,6 @@ /// instances together. sealed class MergeInfo { const MergeInfo(); - - @override - bool operator ==(Object other) => - identical(this, other) || other.runtimeType == runtimeType; - - @override - int get hashCode => runtimeType.hashCode; - - @override - String toString() => 'MergeInfo()'; } /// Indicates that no merge method is available for the field type. @@ -53,18 +43,29 @@ final class StaticMerge extends MergeInfo { } /// Represents an instance merge method with the signature: -/// `T merge(T other)` +/// `T merge(T other)` or `T merge(T? other)` final class InstanceMerge extends MergeInfo { /// Creates an [InstanceMerge] instance. - const InstanceMerge(); + const InstanceMerge({this.isNullableParameter = true}); + + /// Whether the merge method accepts a nullable argument. + /// + /// Methods generated by `@ThemeGen` do, so this defaults to `true`. A method + /// that doesn't has to be guarded against a null `other` at the call site. + final bool isNullableParameter; @override bool operator ==(Object other) => - identical(this, other) || other.runtimeType == runtimeType; + identical(this, other) || + other is InstanceMerge && + runtimeType == other.runtimeType && + isNullableParameter == other.isNullableParameter; @override - int get hashCode => runtimeType.hashCode; + int get hashCode => Object.hash(runtimeType, isNullableParameter); @override - String toString() => 'InstanceMerge()'; + String toString() => + 'InstanceMerge(isNullableParameter: ' + '$isNullableParameter)'; } diff --git a/packages/theme_extensions_builder/lib/src/extensions/string.dart b/packages/theme_extensions_builder/lib/src/extensions/string.dart index ee06494..d87d9c6 100644 --- a/packages/theme_extensions_builder/lib/src/extensions/string.dart +++ b/packages/theme_extensions_builder/lib/src/extensions/string.dart @@ -1,19 +1,18 @@ /// Extension for converting strings to camelCase format. extension StringCamelCase on String { - /// Converts the string to camelCase, with special handling for 'Extension' - /// suffix. + /// Converts the string to camelCase, optionally dropping a suffix. /// - /// This getter: - /// - Returns empty string if the input is empty + /// This method: + /// - Returns an empty string if the input is empty /// - Converts the first character to lowercase - /// - Removes 'Extension' suffix if present (e.g., 'MyExtension' → 'my') + /// - Removes [suffixToRemove] from the end when it is present /// /// Examples: /// ```dart - /// 'HelloWorld'.camelCase // 'helloWorld' - /// 'MyThemeExtension'.camelCase // 'myTheme' - /// 'theme'.camelCase // 'theme' - /// ''.camelCase // '' + /// 'HelloWorld'.camelCase() // 'helloWorld' + /// 'theme'.camelCase() // 'theme' + /// ''.camelCase() // '' + /// 'MyThemeExtension'.camelCase(suffixToRemove: 'Extension') // 'myTheme' /// ``` String camelCase({String? suffixToRemove}) { if (isEmpty) { diff --git a/packages/theme_extensions_builder/lib/src/generator/common.dart b/packages/theme_extensions_builder/lib/src/generator/common.dart index 8f9fe68..47d7795 100644 --- a/packages/theme_extensions_builder/lib/src/generator/common.dart +++ b/packages/theme_extensions_builder/lib/src/generator/common.dart @@ -129,9 +129,9 @@ Method hashMethod(BaseConfig config) => Method((m) { /// This is a utility function for generating conditional code when using /// code_builder, as it doesn't provide a built-in if construct. Code ifStatement(Expression condition, Block ifBlock) { - final visiter = DartEmitter(); - final conditionV = condition.accept(visiter); - final ifBlockV = ifBlock.accept(visiter); + final visitor = DartEmitter(); + final conditionV = condition.accept(visitor); + final ifBlockV = ifBlock.accept(visitor); return Code('if($conditionV){$ifBlockV}'); } diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart index a8ac9d0..afdb425 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart @@ -3,7 +3,7 @@ import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import '../../common/fields_visiter.dart'; +import '../../common/fields_visitor.dart'; import '../../common/fields_visitor_config.dart'; import '../../config/config.dart'; import 'code_builder.dart'; @@ -26,11 +26,8 @@ import 'code_builder.dart'; /// } /// ``` class ThemeExtensionsGenerator extends GeneratorForAnnotation { - /// Creates a [ThemeExtensionsGenerator] with optional [builderOptions]. - const ThemeExtensionsGenerator({this.builderOptions}); - - /// Optional build configuration options. - final BuilderOptions? builderOptions; + /// Creates a [ThemeExtensionsGenerator]. + const ThemeExtensionsGenerator(); @override Future generateForAnnotatedElement( @@ -57,28 +54,17 @@ class ThemeExtensionsGenerator extends GeneratorForAnnotation { annotation.read('contextAccessorName').literalValue as String?; // ThemeExtensions needs lerp but doesn't generate merge methods - final fieldsVisiter = FieldsVisitor( + final fields = collectFields( + element, config: const FieldsVisitorConfig(includeMergeLookup: false), ); - // Get all supertypes to visit their fields as well - final allSupertypes = element.allSupertypes; - - for (final supertype in allSupertypes) { - final superElement = supertype.element; - - if (!supertype.isDartCoreObject) { - superElement.visitChildren(fieldsVisiter); - } - } - - element.visitChildren(fieldsVisiter); // Use naming convention instead of expensive AST parsing // Assume the mixin follows the standard pattern: _$ClassName final mixinName = '_\$${element.displayName}'; final generatorConfig = ThemeExtensionsConfig( - fields: fieldsVisiter.fields, + fields: fields, className: element.displayName, contextAccessorName: contextAccessorName, buildContextExtension: buildContextExtension, diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart index 6b2c9b5..42b7f4b 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart @@ -169,12 +169,35 @@ Method merge(ThemeGenConfig config) => Method((m) { case StaticMerge(): args[field.name] = staticMerge([thisProp, otherProp]); - // Instance merge method with optional field - case InstanceMerge() when field.isNullable: + // Instance merge method taking a nullable argument, optional field + case InstanceMerge(isNullableParameter: true) when field.isNullable: + // _this.field?.merge(other.field) ?? other.field args[field.name] = thisProp .nullSafeProperty('merge')([otherProp]) .ifNullThen(otherProp); + // Instance merge method taking a non-nullable argument, optional + // field + case InstanceMerge() when field.isNullable: + // _this.field == null + // ? other.field + // : other.field == null + // ? _this.field + // : _this.field!.merge(other.field!) + args[field.name] = thisProp + .equalTo(literalNull) + .conditional( + otherProp, + otherProp + .equalTo(literalNull) + .conditional( + thisProp, + thisProp.nullChecked.property('merge')([ + otherProp.nullChecked, + ]), + ), + ); + // Instance merge method with non-optional field case InstanceMerge(): args[field.name] = instanceMerge([otherProp]); diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart b/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart index a0605c6..3000dca 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart @@ -3,7 +3,7 @@ import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import '../../common/fields_visiter.dart'; +import '../../common/fields_visitor.dart'; import '../../config/config.dart'; import 'code_builder.dart'; @@ -26,11 +26,8 @@ import 'code_builder.dart'; /// } /// ``` class ThemeGenGenerator extends GeneratorForAnnotation { - /// Creates a [ThemeGenGenerator] with optional [builderOptions]. - const ThemeGenGenerator({this.builderOptions}); - - /// Optional build configuration options. - final BuilderOptions? builderOptions; + /// Creates a [ThemeGenGenerator]. + const ThemeGenGenerator(); @override Future generateForAnnotatedElement( @@ -49,22 +46,10 @@ class ThemeGenGenerator extends GeneratorForAnnotation { final constructor = annotation.read('constructor').literalValue as String?; final constConstructor = element.constructors.any((c) => c.isConst); - final fieldsVisiter = FieldsVisitor(); - // Get all supertypes to visit their fields as well - final allSupertypes = element.allSupertypes; - - for (final supertype in allSupertypes) { - final superElement = supertype.element; - - if (!supertype.isDartCoreObject) { - superElement.visitChildren(fieldsVisiter); - } - } - // Finally, visit the original class to get its own fields - element.visitChildren(fieldsVisiter); + final fields = collectFields(element); final generatorConfig = ThemeGenConfig( - fields: fieldsVisiter.fields, + fields: fields, className: element.displayName, constructor: constructor, constConstructor: constConstructor, diff --git a/packages/theme_extensions_builder/test/generator/invalid_test.dart b/packages/theme_extensions_builder/test/generator/invalid_test.dart new file mode 100644 index 0000000..192594d --- /dev/null +++ b/packages/theme_extensions_builder/test/generator/invalid_test.dart @@ -0,0 +1,17 @@ +import 'package:source_gen_test/source_gen_test.dart'; +import 'package:test/test.dart'; +import 'package:theme_extensions_builder/src/generator/theme_gen/generator.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +Future main() async { + initializeBuildLogTracking(); + + final reader = await initializeLibraryReaderForDirectory( + 'test/invalid', + 'invalid_theme.dart', + ); + + group('Invalid', () { + testAnnotatedElements(reader, const ThemeGenGenerator()); + }); +} diff --git a/packages/theme_extensions_builder/test/generator/theme_gen_test.dart b/packages/theme_extensions_builder/test/generator/theme_gen_test.dart index 3db934e..5e18a4c 100644 --- a/packages/theme_extensions_builder/test/generator/theme_gen_test.dart +++ b/packages/theme_extensions_builder/test/generator/theme_gen_test.dart @@ -34,4 +34,13 @@ Future main() async { group('WidgetStateProperty', () { testAnnotatedElements(wspReader, generator); }); + + final lookupReader = await initializeLibraryReaderForDirectory( + 'test/theme_gen', + 'lookup_theme.dart', + ); + + group('MethodLookup', () { + testAnnotatedElements(lookupReader, generator); + }); } diff --git a/packages/theme_extensions_builder/test/invalid/invalid_theme.dart b/packages/theme_extensions_builder/test/invalid/invalid_theme.dart new file mode 100644 index 0000000..20aba87 --- /dev/null +++ b/packages/theme_extensions_builder/test/invalid/invalid_theme.dart @@ -0,0 +1,22 @@ +import 'package:source_gen_test/source_gen_test.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import 'mock.dart'; + +/// `WidgetStateProperty.lerp` needs a lerp function with nullable parameters, +/// so the generic of a `WidgetStateProperty` field has to be nullable. +/// +/// This directory is not part of `generate_for` in `build.yaml`, so the +/// failing generation is only exercised by the test. +@ShouldThrow( + 'WidgetStateProperty must have a nullable generic type, because ' + 'WidgetStateProperty.lerp requires a lerp function with nullable ' + 'parameters. Found: WidgetStateProperty', + todo: 'Change the type of color to WidgetStateProperty', +) +@themeGen +final class NonNullableWidgetStatePropertyTheme { + const NonNullableWidgetStatePropertyTheme({required this.color}); + + final WidgetStateProperty color; +} diff --git a/packages/theme_extensions_builder/test/invalid/mock.dart b/packages/theme_extensions_builder/test/invalid/mock.dart new file mode 120000 index 0000000..15c95ff --- /dev/null +++ b/packages/theme_extensions_builder/test/invalid/mock.dart @@ -0,0 +1 @@ +../mock/mock.dart \ No newline at end of file diff --git a/packages/theme_extensions_builder/test/runtime/fields_visitor_config_test.dart b/packages/theme_extensions_builder/test/runtime/fields_visitor_config_test.dart index 23b1d8f..c5b184d 100644 --- a/packages/theme_extensions_builder/test/runtime/fields_visitor_config_test.dart +++ b/packages/theme_extensions_builder/test/runtime/fields_visitor_config_test.dart @@ -3,79 +3,43 @@ import 'package:theme_extensions_builder/src/common/fields_visitor_config.dart'; void main() { group('FieldsVisitorConfig', () { - test('default config has all lookups enabled', () { + test('default config looks up merge methods', () { const config = FieldsVisitorConfig(); - expect(config.includeLerpLookup, isTrue); - expect(config.includeMergeLookup, isTrue); - }); - test('custom config with both lookups disabled', () { - const config = FieldsVisitorConfig( - includeLerpLookup: false, - includeMergeLookup: false, - ); - expect(config.includeLerpLookup, isFalse); - expect(config.includeMergeLookup, isFalse); + expect(config.includeMergeLookup, isTrue); }); - test('custom config with only lerp lookup enabled', () { + test('merge lookup can be disabled', () { const config = FieldsVisitorConfig(includeMergeLookup: false); - expect(config.includeLerpLookup, isTrue); - expect(config.includeMergeLookup, isFalse); - }); - test('custom config with only merge lookup enabled', () { - const config = FieldsVisitorConfig(includeLerpLookup: false); - expect(config.includeLerpLookup, isFalse); - expect(config.includeMergeLookup, isTrue); + expect(config.includeMergeLookup, isFalse); }); test('equality works correctly', () { const config1 = FieldsVisitorConfig(); const config2 = FieldsVisitorConfig(); - const config3 = FieldsVisitorConfig( - includeLerpLookup: false, - includeMergeLookup: false, - ); + const config3 = FieldsVisitorConfig(includeMergeLookup: false); expect(config1, equals(config2)); expect(config1, isNot(equals(config3))); }); - test('hashCode is consistent for equal configs', () { + test('equal configs have equal hashCodes', () { const config1 = FieldsVisitorConfig(); const config2 = FieldsVisitorConfig(); - const config3 = FieldsVisitorConfig(); + const config3 = FieldsVisitorConfig(includeMergeLookup: false); - // Equal configs must have equal hashCodes expect(config1.hashCode, equals(config2.hashCode)); - expect(config1.hashCode, equals(config3.hashCode)); - }); - - test('hashCode is deterministic', () { - const config = FieldsVisitorConfig( - includeLerpLookup: false, - includeMergeLookup: false, - ); - - // Same config should always return the same hashCode - expect(config.hashCode, equals(config.hashCode)); - - // Different instance with same values should have same hashCode - const config2 = FieldsVisitorConfig( - includeLerpLookup: false, - includeMergeLookup: false, - ); - expect(config.hashCode, equals(config2.hashCode)); + expect(config1.hashCode, isNot(equals(config3.hashCode))); }); test('toString provides readable output', () { - const config = FieldsVisitorConfig(includeLerpLookup: false); + const config = FieldsVisitorConfig(includeMergeLookup: false); - final string = config.toString(); - expect(string, contains('FieldsVisitorConfig')); - expect(string, contains('includeLerpLookup: false')); - expect(string, contains('includeMergeLookup: true')); + expect( + config.toString(), + 'FieldsVisitorConfig(includeMergeLookup: false)', + ); }); }); } diff --git a/packages/theme_extensions_builder/test/runtime/symbols_test.dart b/packages/theme_extensions_builder/test/runtime/symbols_test.dart index f6a556f..aa534dd 100644 --- a/packages/theme_extensions_builder/test/runtime/symbols_test.dart +++ b/packages/theme_extensions_builder/test/runtime/symbols_test.dart @@ -1,3 +1,8 @@ +// Several tests build values without `const` on purpose: two identical const +// expressions are canonicalized into the same object, which would make the +// equality checks trivially true. +// ignore_for_file: prefer_const_constructors + import 'package:test/test.dart'; import 'package:theme_extensions_builder/src/common/symbols/field_info.dart'; import 'package:theme_extensions_builder/src/common/symbols/lerp_info.dart'; @@ -114,21 +119,35 @@ void main() { }); test('equality works correctly', () { - const lerp1 = StaticLerp( + // Built without `const` so the instances are not canonicalized and + // operator== is actually exercised. + final lerp1 = StaticLerp( optionalResult: true, args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], ); - const lerp2 = StaticLerp( + final lerp2 = StaticLerp( optionalResult: true, args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], ); - const lerp3 = StaticLerp( + final lerp3 = StaticLerp( + optionalResult: true, + args: [ParameterInfo(name: 'b', type: 'int', isNullable: false)], + ); + final lerp4 = StaticLerp( optionalResult: false, args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], ); expect(lerp1, equals(lerp2)); + expect(lerp1.hashCode, equals(lerp2.hashCode)); expect(lerp1, isNot(equals(lerp3))); + expect(lerp1, isNot(equals(lerp4))); + }); + + test('toString returns correct format', () { + const lerp = StaticLerp(optionalResult: true, args: []); + + expect(lerp.toString(), 'StaticLerp(optionalResult: true, args: [])'); }); }); @@ -177,16 +196,60 @@ void main() { }); test('equality works correctly', () { - const lerp1 = InstanceLerp( + final lerp1 = InstanceLerp( optionalResult: true, args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], ); - const lerp2 = InstanceLerp( + final lerp2 = InstanceLerp( optionalResult: true, args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], ); + final lerp3 = InstanceLerp( + optionalResult: false, + args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], + ); expect(lerp1, equals(lerp2)); + expect(lerp1.hashCode, equals(lerp2.hashCode)); + expect(lerp1, isNot(equals(lerp3))); + }); + + test('toString returns correct format', () { + const lerp = InstanceLerp(optionalResult: false, args: []); + + expect(lerp.toString(), 'InstanceLerp(optionalResult: false, args: [])'); + }); + }); + + group('WidgetStatePropertyLerp', () { + WidgetStatePropertyLerp build({String genericType = 'Color'}) => + WidgetStatePropertyLerp( + baseTypeName: 'WidgetStateProperty', + genericType: genericType, + isNullableGeneric: true, + ); + + test('reports the generic type', () { + expect(build().genericIsDouble, isFalse); + expect(build().genericIsDuration, isFalse); + expect(build(genericType: 'double').genericIsDouble, isTrue); + expect(build(genericType: 'Duration').genericIsDuration, isTrue); + }); + + test('equality works correctly', () { + expect(build(), equals(build())); + expect(build().hashCode, equals(build().hashCode)); + expect(build(), isNot(equals(build(genericType: 'double')))); + }); + + test('toString returns correct format', () { + expect( + build().toString(), + 'WidgetStatePropertyLerp(' + 'baseTypeName: WidgetStateProperty, ' + 'genericType: Color, ' + ')', + ); }); }); @@ -197,10 +260,11 @@ void main() { }); test('equality works correctly', () { - const lerp1 = NoLerp(); - const lerp2 = NoLerp(); + final lerp1 = NoLerp(); + final lerp2 = NoLerp(); expect(lerp1, equals(lerp2)); + expect(lerp1.hashCode, equals(lerp2.hashCode)); }); test('toString returns correct format', () { @@ -211,24 +275,29 @@ void main() { group('MergeInfo', () { test('NoMerge equality works', () { - const merge1 = NoMerge(); - const merge2 = NoMerge(); + final merge1 = NoMerge(); + final merge2 = NoMerge(); expect(merge1, equals(merge2)); + expect(merge1.hashCode, equals(merge2.hashCode)); }); test('StaticMerge equality works', () { - const merge1 = StaticMerge(); - const merge2 = StaticMerge(); + final merge1 = StaticMerge(); + final merge2 = StaticMerge(); expect(merge1, equals(merge2)); + expect(merge1.hashCode, equals(merge2.hashCode)); }); test('InstanceMerge equality works', () { - const merge1 = InstanceMerge(); - const merge2 = InstanceMerge(); + final merge1 = InstanceMerge(); + final merge2 = InstanceMerge(); + const merge3 = InstanceMerge(isNullableParameter: false); expect(merge1, equals(merge2)); + expect(merge1.hashCode, equals(merge2.hashCode)); + expect(merge1, isNot(equals(merge3))); }); test('different merge methods are not equal', () { @@ -248,7 +317,10 @@ void main() { expect(noMerge.toString(), 'NoMerge()'); expect(staticMerge.toString(), 'StaticMerge()'); - expect(instanceMerge.toString(), 'InstanceMerge()'); + expect( + instanceMerge.toString(), + 'InstanceMerge(isNullableParameter: true)', + ); }); }); diff --git a/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart new file mode 100644 index 0000000..b568d45 --- /dev/null +++ b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart @@ -0,0 +1,55 @@ +import 'package:test/test.dart'; + +import '../theme_gen/lookup_theme.dart'; + +void main() { + const a = LookupTheme( + curve: Curve(0), + settings: Settings(1), + optionalSettings: Settings(1), + flags: Flags(1), + ); + + const b = LookupTheme( + curve: Curve(10), + settings: Settings(2), + optionalSettings: Settings(2), + flags: Flags(2), + ); + + group('LookupTheme', () { + test('a type with an unrelated lerp method falls back to a switch', () { + expect(LookupTheme.lerp(a, b, 0.4)!.curve, same(a.curve)); + expect(LookupTheme.lerp(a, b, 0.6)!.curve, same(b.curve)); + }); + + test('a type with an unrelated merge method takes the other value', () { + expect(a.merge(b).flags, same(b.flags)); + }); + + test('an instance merge method is called for both nullabilities', () { + final merged = a.merge(b); + + expect(merged.settings.value, 3); + expect(merged.optionalSettings!.value, 3); + }); + + test('a null field on either side skips the merge method', () { + const withoutSettings = LookupTheme( + curve: Curve(0), + settings: Settings(1), + optionalSettings: null, + flags: Flags(1), + ); + + expect( + withoutSettings.merge(b).optionalSettings, + same(b.optionalSettings), + ); + expect( + a.merge(withoutSettings).optionalSettings, + same(a.optionalSettings), + ); + }); + }); +} diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart new file mode 100644 index 0000000..d883dc5 --- /dev/null +++ b/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart @@ -0,0 +1,97 @@ +part of '../lookup_theme.dart'; + +mixin _$LookupTheme { + bool get canMerge => true; + + static LookupTheme? lerp(LookupTheme? a, LookupTheme? b, double t) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return LookupTheme( + curve: t < 0.5 ? a.curve : b.curve, + settings: t < 0.5 ? a.settings : b.settings, + optionalSettings: t < 0.5 ? a.optionalSettings : b.optionalSettings, + flags: t < 0.5 ? a.flags : b.flags, + ); + } + + LookupTheme copyWith({ + Curve? curve, + Settings? settings, + Settings? optionalSettings, + Flags? flags, + }) { + final _this = (this as LookupTheme); + + return LookupTheme( + curve: curve ?? _this.curve, + settings: settings ?? _this.settings, + optionalSettings: optionalSettings ?? _this.optionalSettings, + flags: flags ?? _this.flags, + ); + } + + LookupTheme merge(LookupTheme? other) { + final _this = (this as LookupTheme); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + curve: other.curve, + settings: _this.settings.merge(other.settings), + optionalSettings: _this.optionalSettings == null + ? other.optionalSettings + : other.optionalSettings == null + ? _this.optionalSettings + : _this.optionalSettings!.merge(other.optionalSettings!), + flags: other.flags, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as LookupTheme); + final _other = (other as LookupTheme); + + return _other.curve == _this.curve && + _other.settings == _this.settings && + _other.optionalSettings == _this.optionalSettings && + _other.flags == _this.flags; + } + + @override + int get hashCode { + final _this = (this as LookupTheme); + + return Object.hash( + runtimeType, + _this.curve, + _this.settings, + _this.optionalSettings, + _this.flags, + ); + } +} diff --git a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart new file mode 100644 index 0000000..5830642 --- /dev/null +++ b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart @@ -0,0 +1,57 @@ +import 'package:source_gen_test/source_gen_test.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'lookup_theme.g.theme.dart'; + +/// A plain class that happens to declare an unrelated `lerp` method. +class Curve { + const Curve(this.value); + + final double value; + + double lerp(double t) => value * t; +} + +/// A plain class with an instance `merge` method that also takes an +/// optional argument. +class Settings { + const Settings(this.value); + + final int value; + + Settings merge(Settings other, {bool deep = false}) => + Settings(value + other.value); +} + +/// A plain class that happens to declare an unrelated `merge` method. +class Flags { + const Flags(this.value); + + final int value; + + int merge() => value; +} + +/// Theme whose field types are inspected by method lookup rather than by an +/// annotation: only [Settings] offers a signature the generator can call. +@ShouldGenerateFile('goldens/lookup_theme.g.theme.dart', partOfCurrent: true) +@themeGen +final class LookupTheme with _$LookupTheme { + const LookupTheme({ + required this.curve, + required this.settings, + required this.optionalSettings, + required this.flags, + }); + + final Curve curve; + final Settings settings; + final Settings? optionalSettings; + final Flags flags; + + @override + bool get canMerge => true; + + static LookupTheme? lerp(LookupTheme? a, LookupTheme? b, double t) => + _$LookupTheme.lerp(a, b, t); +} diff --git a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart new file mode 100644 index 0000000..3a97099 --- /dev/null +++ b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart @@ -0,0 +1,106 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'lookup_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$LookupTheme { + bool get canMerge => true; + + static LookupTheme? lerp(LookupTheme? a, LookupTheme? b, double t) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return LookupTheme( + curve: t < 0.5 ? a.curve : b.curve, + settings: t < 0.5 ? a.settings : b.settings, + optionalSettings: t < 0.5 ? a.optionalSettings : b.optionalSettings, + flags: t < 0.5 ? a.flags : b.flags, + ); + } + + LookupTheme copyWith({ + Curve? curve, + Settings? settings, + Settings? optionalSettings, + Flags? flags, + }) { + final _this = (this as LookupTheme); + + return LookupTheme( + curve: curve ?? _this.curve, + settings: settings ?? _this.settings, + optionalSettings: optionalSettings ?? _this.optionalSettings, + flags: flags ?? _this.flags, + ); + } + + LookupTheme merge(LookupTheme? other) { + final _this = (this as LookupTheme); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + curve: other.curve, + settings: _this.settings.merge(other.settings), + optionalSettings: _this.optionalSettings == null + ? other.optionalSettings + : other.optionalSettings == null + ? _this.optionalSettings + : _this.optionalSettings!.merge(other.optionalSettings!), + flags: other.flags, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as LookupTheme); + final _other = (other as LookupTheme); + + return _other.curve == _this.curve && + _other.settings == _this.settings && + _other.optionalSettings == _this.optionalSettings && + _other.flags == _this.flags; + } + + @override + int get hashCode { + final _this = (this as LookupTheme); + + return Object.hash( + runtimeType, + _this.curve, + _this.settings, + _this.optionalSettings, + _this.flags, + ); + } +} From 0f6ab4e7f7dee8bee637af7890bfdd6a9e561174 Mon Sep 17 00:00:00 2001 From: Andrii Ivanov Date: Sat, 5 Sep 2026 13:58:49 +0300 Subject: [PATCH 03/10] wip: improvements --- .../theme_extensions_builder/CHANGELOG.md | 10 +- .../lib/src/common/analysis.dart | 145 ++++++++++++------ .../lib/src/common/fields_visitor.dart | 23 ++- .../lib/src/common/symbols/lerp_info.dart | 36 ++--- .../theme_extensions/code_builder.dart | 72 ++++++--- .../src/generator/theme_gen/code_builder.dart | 58 +++++-- .../test/generator/code_builder_test.dart | 100 +++++++++++- .../test/runtime/symbols_test.dart | 97 ++++++------ .../theme_extensions_complex_test.dart | 14 +- .../test/runtime/theme_gen_lookup_test.dart | 18 +++ .../complex_theme_extension.g.theme.dart | 14 +- .../test/theme_gen/complex_theme.g.theme.dart | 28 ++-- .../goldens/lookup_theme.g.theme.dart | 20 ++- .../test/theme_gen/lookup_theme.dart | 48 +++++- .../test/theme_gen/lookup_theme.g.theme.dart | 20 ++- 15 files changed, 494 insertions(+), 209 deletions(-) diff --git a/packages/theme_extensions_builder/CHANGELOG.md b/packages/theme_extensions_builder/CHANGELOG.md index 6d7996b..3bdebad 100644 --- a/packages/theme_extensions_builder/CHANGELOG.md +++ b/packages/theme_extensions_builder/CHANGELOG.md @@ -1,11 +1,11 @@ ## 7.5.0 - **Fixed**: `@ThemeGen` now interpolates a nullable field whose type has an instance `lerp` method, instead of falling back to `t < 0.5 ? a : b`. -- **Fixed**: `@ThemeGen` now generates compilable `merge` code for a nullable field whose type has a `merge` method with a non-nullable parameter. -- **Fixed**: A field type declaring an unrelated `lerp` or `merge` method no longer fails the build: an unknown signature falls back to no interpolation / no merge, and optional or named parameters no longer hide a supported signature. -- **Fixed**: A `WidgetStateProperty` field with a non-nullable generic now reports an `InvalidGenerationSourceError` pointing at the field instead of a bare `StateError`. -- **Fixed**: `Duration` is detected by element rather than by name, and a field redeclared by a subclass no longer appears twice in the generated `copyWith`. -- **Code Quality**: Exhaustive switches over `LerpInfo`/`MergeInfo`, `==`/`hashCode` contract fixed for `StaticLerp` and `InstanceLerp`, dead code removed (`getMixinsNames`, `BaseClassVisitor`, unused builder options and lerp-lookup flag). +- **Fixed**: A nullable field whose type has a `lerp` or `merge` method with a non-nullable parameter is now guarded, instead of generating code that does not compile. +- **Fixed**: A field type declaring an unrelated `lerp` or `merge` method no longer fails the build, and is no longer mistaken for a supported one: parameter and return types are checked against the field type, a required named parameter disqualifies the method, and optional parameters no longer hide a supported signature. +- **Fixed**: A `WidgetStateProperty` field with a non-nullable generic now reports an `InvalidGenerationSourceError` pointing at the field instead of a bare `StateError`, and an unrelated generic type with a four parameter `lerp` is no longer treated as a `WidgetStateProperty`. +- **Fixed**: `Duration` is detected by element rather than by name, and a field narrowed by a subclass keeps the narrowed type in the generated code. +- **Code Quality**: Exhaustive switches over `LerpInfo`/`MergeInfo`, `==`/`hashCode` contract fixed for the `LerpInfo` classes, dead code removed (`getMixinsNames`, `BaseClassVisitor`, unused builder options and lerp-lookup flag). ## 7.4.0 diff --git a/packages/theme_extensions_builder/lib/src/common/analysis.dart b/packages/theme_extensions_builder/lib/src/common/analysis.dart index d6d705f..6296a1f 100644 --- a/packages/theme_extensions_builder/lib/src/common/analysis.dart +++ b/packages/theme_extensions_builder/lib/src/common/analysis.dart @@ -65,74 +65,87 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { return const NoLerp(); } - // Optional and named parameters take no part in the signature check: a - // method stays callable the way we expect when it has extra defaulted - // parameters. + // A required named parameter cannot be filled in by the generated call. + if (method.formalParameters.any((p) => p.isRequiredNamed)) { + return const NoLerp(); + } + + // Optional parameters take no part in the signature check: a method stays + // callable the way we expect when it has extra defaulted parameters. final params = method.formalParameters .where((p) => p.isRequiredPositional) .toList(growable: false); // WidgetStateProperty and WidgetStateColor use a different signature for - // lerp. Check for 4-parameter version first, as WidgetStateProperty has - //both 3 and 4 parameter versions + // lerp. Check for the 4-parameter version first, as WidgetStateProperty has + // both 3 and 4 parameter versions if (params case [final p1, final p2, final p3, final p4] // Check for static lerp method with 4 parameters // - first two parameters should have the same type as the class type // - third parameter should be double // - fourth parameter is a lerp function for the inner type when type is InterfaceType && - type.typeArguments.length == 1 && method.isStatic && p3.type.isDartCoreDouble && _checkSubtype(p1, type) && _checkSubtype(p2, type)) { - // Check p4 is a function type having signature: - // R Function(T? a, T? b, double t) - if (p4.type case FunctionType( - formalParameters: [final f1, final f2, final f3], - )) { - // For generic functions like T? Function(T?, T?, double), we can't easily - // check exact type compatibility without type substitution. - // Just verify the structure: 3 parameters where the third is double. - // The first two parameters should be nullable to match the lerp pattern. - final isValidSignature = - f1.type.nullabilitySuffix == .question && - f2.type.nullabilitySuffix == .question && - f3.type.isDartCoreDouble; - - if (!isValidSignature) { - // Unsupported lerp function signature + // The fourth parameter has to be a lerp function itself, with the + // signature `R Function(T? a, T? b, double t)`. + // + // For generic functions like T? Function(T?, T?, double) we can't easily + // check exact type compatibility without type substitution, so only the + // structure is verified. + if (p4.type + case FunctionType( + formalParameters: [final f1, final f2, final f3], + ) + when f1.type.hasNullableSuffix && + f2.type.hasNullableSuffix && + f3.type.isDartCoreDouble) { + // The generic is read from the declaring type rather than from the field + // type, so that a non-generic subclass such as `WidgetStateColor` + // resolves to `WidgetStateProperty`. + final declaringElement = p1.type.element; + + if (declaringElement is! InterfaceElement) { + return const NoLerp(); + } + + final declaringType = type.asInstanceOf(declaringElement); + + if (declaringType == null || declaringType.typeArguments.length != 1) { return const NoLerp(); } - } - final innerType = type.typeArguments.single; - - // Check that the generic type is nullable - if (!innerType.hasNullableSuffix) { - final typeName = type.getDisplayString(); - final innerTypeName = innerType.getDisplayString(); - final baseName = type.element.displayName; - - throw InvalidGenerationSourceError( - '$baseName must have a nullable generic type, because ' - '$baseName.lerp requires a lerp function with nullable parameters. ' - 'Found: $typeName', - element: fieldElement, - todo: - 'Change the type of ${fieldElement.displayName} to ' - '$baseName<$innerTypeName?>', + final baseTypeName = declaringElement.displayName; + final innerType = declaringType.typeArguments.single; + + // Check that the generic type is nullable + if (!innerType.hasNullableSuffix) { + final typeName = type.getDisplayString(); + final innerTypeName = innerType.getDisplayString(); + + throw InvalidGenerationSourceError( + '$baseTypeName must have a nullable generic type, because ' + '$baseTypeName.lerp requires a lerp function with nullable ' + 'parameters. Found: $typeName', + element: fieldElement, + todo: + 'Change the type of ${fieldElement.displayName} to ' + '$baseTypeName<$innerTypeName?>', + ); + } + + return WidgetStatePropertyLerp( + baseTypeName: baseTypeName, + genericType: innerType.baseType, + isNullableGeneric: innerType.hasNullableSuffix, ); } - final baseTypeName = type.element.displayName; - final genericType = innerType.baseType; - - return WidgetStatePropertyLerp( - baseTypeName: baseTypeName, - genericType: genericType, - isNullableGeneric: innerType.hasNullableSuffix, - ); + // A four parameter lerp whose last parameter isn't a lerp function is not + // something we know how to call. + return const NoLerp(); } if (params case [final p1, final p2, final p3] @@ -210,6 +223,24 @@ bool _checkSubtype(FormalParameterElement param, DartType type) { return typeSystem.isSubtypeOf(nonNullType, supertypeInstance); } +/// Checks that [method] returns something usable where [type] is expected. +/// +/// Nullability is ignored on both sides: a `T? merge(T other)` is still a +/// merge method, the generated code just has to cope with the null. +bool _returnsSubtypeOf(MethodElement method, DartType type) { + final typeElement = type.element; + if (typeElement is! InterfaceElement) { + return false; + } + + final typeSystem = typeElement.library.typeSystem; + + return typeSystem.isSubtypeOf( + typeSystem.promoteToNonNull(method.returnType), + typeSystem.promoteToNonNull(type), + ); +} + /// Maps a list of [parameters] to a list of [ParameterInfo] symbols. List _mapArgs(List parameters) => parameters.map(_mapArg).toList(growable: false); @@ -285,6 +316,11 @@ MergeInfo _mergeInfo(DartType type) { return const NoMerge(); } + // A required named parameter cannot be filled in by the generated call. + if (method.formalParameters.any((p) => p.isRequiredNamed)) { + return const NoMerge(); + } + final params = method.formalParameters .where((p) => p.isRequiredPositional) .toList(growable: false); @@ -292,16 +328,23 @@ MergeInfo _mergeInfo(DartType type) { if (params case [final p1, final p2] // Check for static merge method // - should have two parameters - // - both parameters should have the same type as the class type - when method.isStatic && p1.type.baseType == p2.type.baseType) { + // - both parameters should accept the class type + // - the result should be usable as the class type + when method.isStatic && + _checkSubtype(p1, type) && + _checkSubtype(p2, type) && + _returnsSubtypeOf(method, type)) { return const StaticMerge(); } if (params case [final p1] // Check for instance merge method: // - should have only one parameter - // - parameter type should match the class type - when !method.isStatic && p1.type.baseType == type.baseType) { + // - parameter type should accept the class type + // - the result should be usable as the class type + when !method.isStatic && + _checkSubtype(p1, type) && + _returnsSubtypeOf(method, type)) { return InstanceMerge(isNullableParameter: p1.type.hasNullableSuffix); } diff --git a/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart b/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart index 0afc553..ae70b89 100644 --- a/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart +++ b/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart @@ -10,23 +10,26 @@ import 'symbols/field_info.dart'; /// Collects the fields of [element] together with the fields of all its /// supertypes. /// -/// Supertypes are visited first, so a field redeclared by [element] replaces -/// the inherited one. The [config] controls what information is collected for -/// each field. +/// The first declaration of a name wins, so [element] is visited before its +/// supertypes, and supertypes are visited nearest first. A field redeclared +/// by a subclass therefore keeps the subclass' type instead of being +/// overwritten by the inherited declaration. +/// +/// The [config] controls what information is collected for each field. List collectFields( ClassElement element, { FieldsVisitorConfig config = const FieldsVisitorConfig(), }) { final visitor = FieldsVisitor(config: config); + element.visitChildren(visitor); + for (final supertype in element.allSupertypes) { if (!supertype.isDartCoreObject) { supertype.element.visitChildren(visitor); } } - element.visitChildren(visitor); - return visitor.fields; } @@ -60,8 +63,9 @@ class FieldsVisitor extends SimpleElementVisitor2 { /// Collected field information, keyed by field name. /// - /// Keying by name means a field redeclared by a subclass replaces the one - /// declared by its supertype instead of being collected twice. + /// Keying by name means a redeclared field is collected once. The first + /// declaration seen wins; see [collectFields] for the visiting order that + /// makes the nearest declaration the first one. final Map _fields = {}; /// Returns an immutable list of collected field information. @@ -92,7 +96,10 @@ class FieldsVisitor extends SimpleElementVisitor2 { // Only process non-synthetic fields (explicitly declared in source code) if (element.isOriginDeclaration) { - _fields[element.displayName] = fieldSymbol(element, config: config); + _fields.putIfAbsent( + element.displayName, + () => fieldSymbol(element, config: config), + ); } } } diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart index 61fea48..0d0e5e5 100644 --- a/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart +++ b/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart @@ -27,16 +27,12 @@ final class StaticLerp extends LerpInfo { /// Whether the return type of the lerp method is nullable. final bool optionalResult; - /// Returns `true` if the lerp method signature accepts nullable parameters - /// and returns a nullable result. + /// Returns `true` if the lerp method accepts nullable arguments. /// - /// This is determined by checking if the result is optional and the first - /// two arguments are nullable. - bool get isNullableSignature => - optionalResult && - args.length >= 2 && - args[0].isNullable && - args[1].isNullable; + /// A method that doesn't has to be guarded against null arguments at the + /// call site. + bool get isNullableParameter => + args.length >= 2 && args[0].isNullable && args[1].isNullable; @override bool operator ==(Object other) => @@ -69,10 +65,11 @@ final class InstanceLerp extends LerpInfo { /// Whether the return type of the lerp method is nullable. final bool optionalResult; - /// Returns `true` if the lerp method signature accepts a nullable parameter - /// and returns a nullable result. - bool get isNullableSignature => - optionalResult && args.isNotEmpty && args[0].isNullable; + /// Returns `true` if the lerp method accepts a nullable argument. + /// + /// A method that doesn't has to be guarded against a null `other` at the + /// call site. + bool get isNullableParameter => args.isNotEmpty && args[0].isNullable; @override bool operator ==(Object other) => @@ -119,22 +116,19 @@ final class WidgetStatePropertyLerp extends LerpInfo { other is WidgetStatePropertyLerp && runtimeType == other.runtimeType && baseTypeName == other.baseTypeName && - genericType == other.genericType; + genericType == other.genericType && + isNullableGeneric == other.isNullableGeneric; @override - int get hashCode => Object.hash( - runtimeType, - - baseTypeName, - genericType, - ); + int get hashCode => + Object.hash(runtimeType, baseTypeName, genericType, isNullableGeneric); @override String toString() => 'WidgetStatePropertyLerp(' 'baseTypeName: $baseTypeName, ' 'genericType: $genericType, ' - ')'; + 'isNullableGeneric: $isNullableGeneric)'; } /// Indicates that no lerp method is available for the field type. diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart index fdd6fb6..48e0383 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart @@ -185,9 +185,25 @@ Method lerpMethod(ThemeExtensionsConfig config) => Method((m) { .lessThan(literalNum(0.5)) .conditional(tProp, oProp); - // Handle StaticLerp with non-nullable signature and optional - // field - case StaticLerp(isNullableSignature: false) when field.isNullable: + // Handle StaticLerp on a non-optional field, returning an + // optional result + case StaticLerp(optionalResult: true) when !field.isNullable: + // FieldType.lerp(_this.field, other.field, t)! + args[field.name] = sLerp([tProp, oProp, 't'.ref]).nullChecked; + + // Handle StaticLerp on a non-optional field, returning a + // non-optional result + case StaticLerp() when !field.isNullable: + // FieldType.lerp(_this.field, other.field, t) + args[field.name] = sLerp([tProp, oProp, 't'.ref]); + + // Handle StaticLerp taking nullable arguments, optional field + case StaticLerp(isNullableParameter: true): + // FieldType.lerp(_this.field, other.field, t) + args[field.name] = sLerp([tProp, oProp, 't'.ref]); + + // Handle StaticLerp taking non-nullable arguments, optional field + case StaticLerp(): // _this.side == null // ? other.side // : other.side == null @@ -205,39 +221,45 @@ Method lerpMethod(ThemeExtensionsConfig config) => Method((m) { ), ); - // Handle StaticLerp with non-nullable signature and - // non-optional field - case StaticLerp(isNullableSignature: false): - // FieldType.lerp(_this.field, other.field, t) - args[field.name] = sLerp([tProp, oProp, 't'.ref]); - - // Handle StaticLerp with nullable signature and - // non-optional field - case StaticLerp() when !field.isNullable: - // FieldType.lerp(_this.field, other.field, t)! - args[field.name] = sLerp([tProp, oProp, 't'.ref]).nullChecked; - - // Handle StaticLerp with nullable signature and optional - // field - case StaticLerp(): - // FieldType.lerp(_this.field, other.field, t) - args[field.name] = sLerp([tProp, oProp, 't'.ref]); - - // Handle InstanceLerp with optional result and optional field - case InstanceLerp(optionalResult: true) when field.isNullable: + // Handle InstanceLerp taking a nullable argument, with an optional + // result and an optional field + case InstanceLerp(optionalResult: true, isNullableParameter: true) + when field.isNullable: // _this.field?.lerp(other.field, t) args[field.name] = tProp.prop('lerp', nullSafe: true)([ oProp, 't'.ref, ]); - // Handle InstanceLerp with non-optional result and nullable field - case InstanceLerp(optionalResult: false) when field.isNullable: + // Handle InstanceLerp taking a nullable argument, with a + // non-optional result and a nullable field + case InstanceLerp(isNullableParameter: true) when field.isNullable: // _this.field?.lerp(other.field, t) as FieldType? args[field.name] = tProp .prop('lerp', nullSafe: true)([oProp, 't'.ref]) .asA(field.typeName.typeRef(isNullable: true)); + // Handle InstanceLerp taking a non-nullable argument, nullable field + case InstanceLerp() when field.isNullable: + // _this.field == null + // ? other.field + // : other.field == null + // ? _this.field + // : _this.field!.lerp(other.field!, t) as FieldType? + args[field.name] = tProp + .equalTo(literalNull) + .conditional( + oProp, + oProp + .equalTo(literalNull) + .conditional( + tProp, + tProp.nullChecked + .property('lerp')([oProp.nullChecked, 't'.ref]) + .asA(field.typeName.typeRef(isNullable: true)), + ), + ); + // Handle InstanceLerp with non-optional field case InstanceLerp(): // _this.field.lerp(other.field, t) as FieldType diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart index 42b7f4b..90b7826 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart @@ -287,18 +287,23 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { // Handle different lerp strategies based on field configuration switch (field.lerp) { - // Non-nullable field with non-nullable lerp signature - case StaticLerp(isNullableSignature: false) when !field.isNullable: + // Non-nullable field, lerp returning an optional result + case StaticLerp(optionalResult: true) when !field.isNullable: + // value: Class.lerp(a.field, b.field, t)! + argsResult[field.name] = lerp([aProp, bProp, 't'.ref]).nullChecked; + + // Non-nullable field, lerp returning a non-optional result + case StaticLerp() when !field.isNullable: // value: Class.lerp(a.field, b.field, t) argsResult[field.name] = lerp([aProp, bProp, 't'.ref]); - // Non-nullable field with nullable lerp signature - case StaticLerp() when !field.isNullable: - // value: Class.lerp(a.field, b.field, t)! - argsResult[field.name] = lerp([aProp, bProp, 't'.ref]).nullChecked; + // Nullable field, lerp taking nullable arguments + case StaticLerp(isNullableParameter: true): + // value: Class.lerp(a.field, b.field, t) + argsResult[field.name] = lerp([aProp, bProp, 't'.ref]); - // Nullable field with non-nullable lerp signature - case StaticLerp(isNullableSignature: false): + // Nullable field, lerp taking non-nullable arguments + case StaticLerp(): // value: a.field == null // ? b.field // : b.field == null @@ -316,26 +321,45 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { ), ); - // Nullable field with nullable lerp signature - case StaticLerp(): - // value: Class.lerp(a.field, b.field, t) - argsResult[field.name] = lerp([aProp, bProp, 't'.ref]); - - // Instance lerp method with optional result and nullable field - case InstanceLerp(optionalResult: true) when field.isNullable: + // Instance lerp taking a nullable argument, with an optional + // result and a nullable field + case InstanceLerp(optionalResult: true, isNullableParameter: true) + when field.isNullable: // value: a.field?.lerp(b.field, t) argsResult[field.name] = aProp.prop('lerp', nullSafe: true)([ bProp, 't'.ref, ]); - // Instance lerp method with non-optional result and nullable field - case InstanceLerp(optionalResult: false) when field.isNullable: + // Instance lerp taking a nullable argument, with a non-optional + // result and a nullable field + case InstanceLerp(isNullableParameter: true) when field.isNullable: // value: a.field?.lerp(b.field, t) as Class? argsResult[field.name] = aProp .prop('lerp', nullSafe: true)([bProp, 't'.ref]) .asA(field.typeName.typeRef(isNullable: true)); + // Instance lerp taking a non-nullable argument, nullable field + case InstanceLerp() when field.isNullable: + // value: a.field == null + // ? b.field + // : b.field == null + // ? a.field + // : a.field!.lerp(b.field!, t) as Class? + argsResult[field.name] = aProp + .equalTo(literalNull) + .conditional( + bProp, + bProp + .equalTo(literalNull) + .conditional( + aProp, + aProp.nullChecked + .property('lerp')([bProp.nullChecked, 't'.ref]) + .asA(field.typeName.typeRef(isNullable: true)), + ), + ); + // Instance lerp method with non-nullable field case InstanceLerp(): // value: a.field.lerp(b.field, t) as Class diff --git a/packages/theme_extensions_builder/test/generator/code_builder_test.dart b/packages/theme_extensions_builder/test/generator/code_builder_test.dart index a4d8d81..c3f16b4 100644 --- a/packages/theme_extensions_builder/test/generator/code_builder_test.dart +++ b/packages/theme_extensions_builder/test/generator/code_builder_test.dart @@ -4,6 +4,7 @@ import 'package:theme_extensions_builder/src/common/symbols/lerp_info.dart'; import 'package:theme_extensions_builder/src/common/symbols/merge_info.dart'; import 'package:theme_extensions_builder/src/common/symbols/parameter_info.dart'; import 'package:theme_extensions_builder/src/config/config.dart'; +import 'package:theme_extensions_builder/src/generator/theme_extensions/code_builder.dart'; import 'package:theme_extensions_builder/src/generator/theme_gen/code_builder.dart'; /// Code paths that cannot be reached through the golden fixtures, either @@ -54,6 +55,19 @@ void main() { }); test('instance lerp with non-optional result on a nullable field', () { + final code = _generate([ + _field( + 'value', + typeName: 'Lerpable', + isNullable: true, + lerp: const InstanceLerp(optionalResult: false, args: [_nullableArg]), + ), + ]); + + expect(code, contains('(a.value?.lerp(b.value, t) as Lerpable?)')); + }); + + test('instance lerp taking a non-nullable argument is guarded', () { final code = _generate([ _field( 'value', @@ -66,7 +80,66 @@ void main() { ), ]); - expect(code, contains('(a.value?.lerp(b.value, t) as Lerpable?)')); + expect( + code, + contains( + 'a.value == null ? b.value : b.value == null ? a.value : ' + '(a.value!.lerp(b.value!, t) as Lerpable?)', + ), + ); + }); + + test('static lerp with a nullable result is null checked', () { + final code = _generate([ + _field( + 'value', + typeName: 'Lerpable', + lerp: const StaticLerp( + optionalResult: true, + args: [_nonNullableArg, _nonNullableArg], + ), + ), + ]); + + expect(code, contains('Lerpable.lerp(a.value, b.value, t)!')); + }); + + test('static lerp with a non-nullable result is not null checked', () { + final code = _generate([ + _field( + 'value', + typeName: 'Lerpable', + lerp: const StaticLerp( + optionalResult: false, + args: [_nullableArg, _nullableArg], + ), + ), + ]); + + expect(code, contains('value: Lerpable.lerp(a.value, b.value, t)')); + expect(code, isNot(contains('Lerpable.lerp(a.value, b.value, t)!'))); + }); + + test('the same guard is emitted for a theme extension', () { + final code = _generateExtension([ + _field( + 'value', + typeName: 'Lerpable', + isNullable: true, + lerp: const InstanceLerp( + optionalResult: false, + args: [_nonNullableArg], + ), + ), + ]); + + expect( + code, + contains( + '_this.value == null ? other.value : other.value == null ? ' + '_this.value : (_this.value!.lerp(other.value!, t) as Lerpable?)', + ), + ); }); }); } @@ -98,11 +171,30 @@ String _generate(List fields) { ), ); - return code - .replaceAll(RegExp(r',\s*\)'), ')') - .replaceAll(RegExp(r'\s+'), ' '); + return _normalize(code); } +/// Collapses whitespace and the trailing commas code_builder adds before a +/// closing paren, so the expectations stay readable. +String _normalize(String code) => + code.replaceAll(RegExp(r',\s*\)'), ')').replaceAll(RegExp(r'\s+'), ' '); + +/// Generates the mixin for a theme extension and normalizes the output the +/// same way [_generate] does. +String _generateExtension(List fields) => _normalize( + const ThemeExtensionsCodeBuilder().generate( + ThemeExtensionsConfig( + fields: fields, + className: 'Theme', + constructor: null, + buildContextExtension: false, + contextAccessorName: null, + themeExtensionMixinName: r'_$Theme', + constConstructor: true, + ), + ), +); + List _fields(int count) => [ for (var i = 0; i < count; i++) _field('field$i'), ]; diff --git a/packages/theme_extensions_builder/test/runtime/symbols_test.dart b/packages/theme_extensions_builder/test/runtime/symbols_test.dart index aa534dd..b9690fa 100644 --- a/packages/theme_extensions_builder/test/runtime/symbols_test.dart +++ b/packages/theme_extensions_builder/test/runtime/symbols_test.dart @@ -64,33 +64,21 @@ void main() { expect(lerp.args.length, 3); }); - test('isNullableSignature returns true when all conditions met', () { - const lerp = StaticLerp( - optionalResult: true, - args: [ - ParameterInfo(name: 'a', type: 'Color', isNullable: true), - ParameterInfo(name: 'b', type: 'Color', isNullable: true), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ], - ); - - expect(lerp.isNullableSignature, true); - }); + test('isNullableParameter is independent of the result nullability', () { + const nullableArgs = [ + ParameterInfo(name: 'a', type: 'Color', isNullable: true), + ParameterInfo(name: 'b', type: 'Color', isNullable: true), + ParameterInfo(name: 't', type: 'double', isNullable: false), + ]; - test('isNullableSignature returns false when optionalResult is false', () { - const lerp = StaticLerp( - optionalResult: false, - args: [ - ParameterInfo(name: 'a', type: 'Color', isNullable: true), - ParameterInfo(name: 'b', type: 'Color', isNullable: true), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ], - ); + const optional = StaticLerp(optionalResult: true, args: nullableArgs); + const nonOptional = StaticLerp(optionalResult: false, args: nullableArgs); - expect(lerp.isNullableSignature, false); + expect(optional.isNullableParameter, true); + expect(nonOptional.isNullableParameter, true); }); - test('isNullableSignature returns false when first arg not nullable', () { + test('isNullableParameter returns false when first arg not nullable', () { const lerp = StaticLerp( optionalResult: true, args: [ @@ -100,22 +88,22 @@ void main() { ], ); - expect(lerp.isNullableSignature, false); + expect(lerp.isNullableParameter, false); }); - test('isNullableSignature handles empty args safely', () { + test('isNullableParameter handles empty args safely', () { const lerp = StaticLerp(optionalResult: true, args: []); - expect(lerp.isNullableSignature, false); + expect(lerp.isNullableParameter, false); }); - test('isNullableSignature handles single arg safely', () { + test('isNullableParameter handles single arg safely', () { const lerp = StaticLerp( optionalResult: true, args: [ParameterInfo(name: 'a', type: 'Color', isNullable: true)], ); - expect(lerp.isNullableSignature, false); + expect(lerp.isNullableParameter, false); }); test('equality works correctly', () { @@ -165,34 +153,38 @@ void main() { expect(lerp.args.length, 2); }); - test('isNullableSignature returns true when all conditions met', () { - const lerp = InstanceLerp( - optionalResult: true, - args: [ - ParameterInfo(name: 'other', type: 'Color', isNullable: true), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ], + test('isNullableParameter is independent of the result nullability', () { + const nullableArgs = [ + ParameterInfo(name: 'other', type: 'Color', isNullable: true), + ParameterInfo(name: 't', type: 'double', isNullable: false), + ]; + + const optional = InstanceLerp(optionalResult: true, args: nullableArgs); + const nonOptional = InstanceLerp( + optionalResult: false, + args: nullableArgs, ); - expect(lerp.isNullableSignature, true); + expect(optional.isNullableParameter, true); + expect(nonOptional.isNullableParameter, true); }); - test('isNullableSignature returns false when optionalResult is false', () { + test('isNullableParameter returns false for a non-nullable argument', () { const lerp = InstanceLerp( - optionalResult: false, + optionalResult: true, args: [ - ParameterInfo(name: 'other', type: 'Color', isNullable: true), + ParameterInfo(name: 'other', type: 'Color', isNullable: false), ParameterInfo(name: 't', type: 'double', isNullable: false), ], ); - expect(lerp.isNullableSignature, false); + expect(lerp.isNullableParameter, false); }); - test('isNullableSignature handles empty args safely', () { + test('isNullableParameter handles empty args safely', () { const lerp = InstanceLerp(optionalResult: true, args: []); - expect(lerp.isNullableSignature, false); + expect(lerp.isNullableParameter, false); }); test('equality works correctly', () { @@ -222,12 +214,14 @@ void main() { }); group('WidgetStatePropertyLerp', () { - WidgetStatePropertyLerp build({String genericType = 'Color'}) => - WidgetStatePropertyLerp( - baseTypeName: 'WidgetStateProperty', - genericType: genericType, - isNullableGeneric: true, - ); + WidgetStatePropertyLerp build({ + String genericType = 'Color', + bool isNullableGeneric = true, + }) => WidgetStatePropertyLerp( + baseTypeName: 'WidgetStateProperty', + genericType: genericType, + isNullableGeneric: isNullableGeneric, + ); test('reports the generic type', () { expect(build().genericIsDouble, isFalse); @@ -240,6 +234,7 @@ void main() { expect(build(), equals(build())); expect(build().hashCode, equals(build().hashCode)); expect(build(), isNot(equals(build(genericType: 'double')))); + expect(build(), isNot(equals(build(isNullableGeneric: false)))); }); test('toString returns correct format', () { @@ -248,7 +243,7 @@ void main() { 'WidgetStatePropertyLerp(' 'baseTypeName: WidgetStateProperty, ' 'genericType: Color, ' - ')', + 'isNullableGeneric: true)', ); }); }); @@ -480,7 +475,7 @@ void main() { ], ); - expect(lerp.isNullableSignature, true); + expect(lerp.isNullableParameter, true); }); test('StaticLerp with 3+ args checks first two', () { @@ -494,7 +489,7 @@ void main() { ], ); - expect(lerp.isNullableSignature, true); + expect(lerp.isNullableParameter, true); }); test('FieldInfo with isDouble true', () { diff --git a/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart b/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart index 225d9b7..e1588de 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart @@ -275,9 +275,10 @@ void main() { optionalThemeExtension: EmptyThemeExtension(), ); - final copied = - theme.copyWith(requiredInt: 999, requiredString: 'updated') - as ComplexThemeExtension; + final copied = theme.copyWith( + requiredInt: 999, + requiredString: 'updated', + ) as ComplexThemeExtension; expect(copied.requiredInt, equals(999)); expect(copied.requiredString, equals('updated')); @@ -306,9 +307,10 @@ void main() { optionalThemeExtension: EmptyThemeExtension(), ); - final copied = - theme.copyWith(optionalInt: 500, optionalString: 'new') - as ComplexThemeExtension; + final copied = theme.copyWith( + optionalInt: 500, + optionalString: 'new', + ) as ComplexThemeExtension; expect(copied.optionalInt, equals(500)); expect(copied.optionalString, equals('new')); diff --git a/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart index b568d45..713a4be 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart @@ -8,6 +8,9 @@ void main() { settings: Settings(1), optionalSettings: Settings(1), flags: Flags(1), + clamped: Clamped(1), + unrelated: Unrelated(1), + narrowed: 1, ); const b = LookupTheme( @@ -15,6 +18,9 @@ void main() { settings: Settings(2), optionalSettings: Settings(2), flags: Flags(2), + clamped: Clamped(2), + unrelated: Unrelated(2), + narrowed: 2, ); group('LookupTheme', () { @@ -34,12 +40,24 @@ void main() { expect(merged.optionalSettings!.value, 3); }); + test('an uncallable lerp or merge signature is ignored', () { + expect(LookupTheme.lerp(a, b, 0.4)!.clamped, same(a.clamped)); + expect(a.merge(b).unrelated, same(b.unrelated)); + }); + + test('a field narrowed by a superclass keeps the narrowed type', () { + expect(a.copyWith(narrowed: 7).narrowed, 7); + }); + test('a null field on either side skips the merge method', () { const withoutSettings = LookupTheme( curve: Curve(0), settings: Settings(1), optionalSettings: null, flags: Flags(1), + clamped: Clamped(1), + unrelated: Unrelated(1), + narrowed: 1, ); expect( diff --git a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart index c91f51a..b824769 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart @@ -259,9 +259,10 @@ mixin _$ComplexThemeExtension on ThemeExtension { other.requiredTheme, t, )!, - requiredThemeExtension: - (_this.requiredThemeExtension.lerp(other.requiredThemeExtension, t) - as EmptyThemeExtension), + requiredThemeExtension: (_this.requiredThemeExtension.lerp( + other.requiredThemeExtension, + t, + ) as EmptyThemeExtension), optionalInt: t < 0.5 ? _this.optionalInt : other.optionalInt, optionalDouble: lerpDouble$( _this.optionalDouble, @@ -290,9 +291,10 @@ mixin _$ComplexThemeExtension on ThemeExtension { other.optionalTheme, t, ), - optionalThemeExtension: - (_this.optionalThemeExtension?.lerp(other.optionalThemeExtension, t) - as EmptyThemeExtension?), + optionalThemeExtension: (_this.optionalThemeExtension?.lerp( + other.optionalThemeExtension, + t, + ) as EmptyThemeExtension?), optionalLerpableWithOptionalResult: _this .optionalLerpableWithOptionalResult ?.lerp(other.optionalLerpableWithOptionalResult, t), diff --git a/packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart index 77d00a7..98b7adb 100644 --- a/packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart @@ -46,9 +46,10 @@ mixin _$ComplexThemeInternal { t, ), requiredTheme: EmptyTheme.lerp(a.requiredTheme, b.requiredTheme, t)!, - requiredThemeExtension: - (a.requiredThemeExtension.lerp(b.requiredThemeExtension, t) - as EmptyThemeExtension), + requiredThemeExtension: (a.requiredThemeExtension.lerp( + b.requiredThemeExtension, + t, + ) as EmptyThemeExtension), optionalInt: t < 0.5 ? a.optionalInt : b.optionalInt, optionalDouble: lerpDouble$(a.optionalDouble, b.optionalDouble, t), optionalString: t < 0.5 ? a.optionalString : b.optionalString, @@ -65,9 +66,10 @@ mixin _$ComplexThemeInternal { ? a.optionalBorderSide : BorderSide.lerp(a.optionalBorderSide!, b.optionalBorderSide!, t), optionalTheme: EmptyTheme.lerp(a.optionalTheme, b.optionalTheme, t), - optionalThemeExtension: - (a.optionalThemeExtension?.lerp(b.optionalThemeExtension, t) - as EmptyThemeExtension?), + optionalThemeExtension: (a.optionalThemeExtension?.lerp( + b.optionalThemeExtension, + t, + ) as EmptyThemeExtension?), optionalLerpableWithOptionalResult: a.optionalLerpableWithOptionalResult ?.lerp(b.optionalLerpableWithOptionalResult, t), ); @@ -266,9 +268,10 @@ mixin _$ComplexTheme { t, ), requiredTheme: EmptyTheme.lerp(a.requiredTheme, b.requiredTheme, t)!, - requiredThemeExtension: - (a.requiredThemeExtension.lerp(b.requiredThemeExtension, t) - as EmptyThemeExtension), + requiredThemeExtension: (a.requiredThemeExtension.lerp( + b.requiredThemeExtension, + t, + ) as EmptyThemeExtension), optionalInt: t < 0.5 ? a.optionalInt : b.optionalInt, optionalDouble: lerpDouble$(a.optionalDouble, b.optionalDouble, t), optionalString: t < 0.5 ? a.optionalString : b.optionalString, @@ -285,9 +288,10 @@ mixin _$ComplexTheme { ? a.optionalBorderSide : BorderSide.lerp(a.optionalBorderSide!, b.optionalBorderSide!, t), optionalTheme: EmptyTheme.lerp(a.optionalTheme, b.optionalTheme, t), - optionalThemeExtension: - (a.optionalThemeExtension?.lerp(b.optionalThemeExtension, t) - as EmptyThemeExtension?), + optionalThemeExtension: (a.optionalThemeExtension?.lerp( + b.optionalThemeExtension, + t, + ) as EmptyThemeExtension?), optionalLerpableWithOptionalResult: a.optionalLerpableWithOptionalResult ?.lerp(b.optionalLerpableWithOptionalResult, t), ); diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart index d883dc5..ad1cb57 100644 --- a/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart @@ -21,6 +21,9 @@ mixin _$LookupTheme { settings: t < 0.5 ? a.settings : b.settings, optionalSettings: t < 0.5 ? a.optionalSettings : b.optionalSettings, flags: t < 0.5 ? a.flags : b.flags, + clamped: t < 0.5 ? a.clamped : b.clamped, + unrelated: t < 0.5 ? a.unrelated : b.unrelated, + narrowed: t < 0.5 ? a.narrowed : b.narrowed, ); } @@ -29,6 +32,9 @@ mixin _$LookupTheme { Settings? settings, Settings? optionalSettings, Flags? flags, + Clamped? clamped, + Unrelated? unrelated, + int? narrowed, }) { final _this = (this as LookupTheme); @@ -37,6 +43,9 @@ mixin _$LookupTheme { settings: settings ?? _this.settings, optionalSettings: optionalSettings ?? _this.optionalSettings, flags: flags ?? _this.flags, + clamped: clamped ?? _this.clamped, + unrelated: unrelated ?? _this.unrelated, + narrowed: narrowed ?? _this.narrowed, ); } @@ -60,6 +69,9 @@ mixin _$LookupTheme { ? _this.optionalSettings : _this.optionalSettings!.merge(other.optionalSettings!), flags: other.flags, + clamped: other.clamped, + unrelated: other.unrelated, + narrowed: other.narrowed, ); } @@ -79,7 +91,10 @@ mixin _$LookupTheme { return _other.curve == _this.curve && _other.settings == _this.settings && _other.optionalSettings == _this.optionalSettings && - _other.flags == _this.flags; + _other.flags == _this.flags && + _other.clamped == _this.clamped && + _other.unrelated == _this.unrelated && + _other.narrowed == _this.narrowed; } @override @@ -92,6 +107,9 @@ mixin _$LookupTheme { _this.settings, _this.optionalSettings, _this.flags, + _this.clamped, + _this.unrelated, + _this.narrowed, ); } } diff --git a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart index 5830642..7f93854 100644 --- a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart @@ -32,22 +32,68 @@ class Flags { int merge() => value; } +/// A plain class whose `lerp` cannot be called with positional arguments only. +class Clamped { + const Clamped(this.value); + + final double value; + + static Clamped? lerp( + Clamped? a, + Clamped? b, + double t, { + required bool clamp, + }) => clamp ? a : b; +} + +/// A plain class with a static `merge` that has nothing to do with the class. +class Unrelated { + const Unrelated(this.value); + + final int value; + + static double merge(double a, double b) => a + b; +} + +/// Base class declaring a field that [LookupTheme]'s superclass narrows. +class Base { + const Base({required this.narrowed}); + + final num narrowed; +} + +/// Narrows [Base.narrowed] to `int`, which is the declaration the generated +/// code has to use. +class Middle extends Base { + const Middle({required this.narrowed}) : super(narrowed: narrowed); + + @override + // Narrowing the inherited field is the point of this fixture. + // ignore: overridden_fields + final int narrowed; +} + /// Theme whose field types are inspected by method lookup rather than by an /// annotation: only [Settings] offers a signature the generator can call. @ShouldGenerateFile('goldens/lookup_theme.g.theme.dart', partOfCurrent: true) @themeGen -final class LookupTheme with _$LookupTheme { +final class LookupTheme extends Middle with _$LookupTheme { const LookupTheme({ required this.curve, required this.settings, required this.optionalSettings, required this.flags, + required this.clamped, + required this.unrelated, + required super.narrowed, }); final Curve curve; final Settings settings; final Settings? optionalSettings; final Flags flags; + final Clamped clamped; + final Unrelated unrelated; @override bool get canMerge => true; diff --git a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart index 3a97099..1f8fd86 100644 --- a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart @@ -30,6 +30,9 @@ mixin _$LookupTheme { settings: t < 0.5 ? a.settings : b.settings, optionalSettings: t < 0.5 ? a.optionalSettings : b.optionalSettings, flags: t < 0.5 ? a.flags : b.flags, + clamped: t < 0.5 ? a.clamped : b.clamped, + unrelated: t < 0.5 ? a.unrelated : b.unrelated, + narrowed: t < 0.5 ? a.narrowed : b.narrowed, ); } @@ -38,6 +41,9 @@ mixin _$LookupTheme { Settings? settings, Settings? optionalSettings, Flags? flags, + Clamped? clamped, + Unrelated? unrelated, + int? narrowed, }) { final _this = (this as LookupTheme); @@ -46,6 +52,9 @@ mixin _$LookupTheme { settings: settings ?? _this.settings, optionalSettings: optionalSettings ?? _this.optionalSettings, flags: flags ?? _this.flags, + clamped: clamped ?? _this.clamped, + unrelated: unrelated ?? _this.unrelated, + narrowed: narrowed ?? _this.narrowed, ); } @@ -69,6 +78,9 @@ mixin _$LookupTheme { ? _this.optionalSettings : _this.optionalSettings!.merge(other.optionalSettings!), flags: other.flags, + clamped: other.clamped, + unrelated: other.unrelated, + narrowed: other.narrowed, ); } @@ -88,7 +100,10 @@ mixin _$LookupTheme { return _other.curve == _this.curve && _other.settings == _this.settings && _other.optionalSettings == _this.optionalSettings && - _other.flags == _this.flags; + _other.flags == _this.flags && + _other.clamped == _this.clamped && + _other.unrelated == _this.unrelated && + _other.narrowed == _this.narrowed; } @override @@ -101,6 +116,9 @@ mixin _$LookupTheme { _this.settings, _this.optionalSettings, _this.flags, + _this.clamped, + _this.unrelated, + _this.narrowed, ); } } From 9efaaa9fb333cc98de5cea4df726f13d1c907487 Mon Sep 17 00:00:00 2001 From: Andrii Ivanov Date: Sat, 5 Sep 2026 15:03:19 +0300 Subject: [PATCH 04/10] **Fixed**: An interpolation that cannot run on a null now keeps the endpoints: `lerp(a, b, 0)` returns `a`'s value and `lerp(a, b, 1)` returns `b`'s value even when the other side is null. Previously a null on the `a` side either discarded `b` entirely or was taken at any `t`. --- .github/README.md | 4 +- docs/installation.md | 4 +- .../theme_extensions_builder/CHANGELOG.md | 6 +- packages/theme_extensions_builder/README.md | 4 +- .../theme/extensions/app_theme.g.theme.dart | 9 +- .../lib/src/common/analysis.dart | 107 +++++++++++----- .../lib/src/common/symbols/field_info.dart | 14 +++ .../lib/src/common/symbols/lerp_info.dart | 6 - .../theme_extensions/code_builder.dart | 96 +++++++-------- .../src/generator/theme_gen/code_builder.dart | 115 +++++++++--------- .../test/generator/code_builder_test.dart | 42 ++++--- .../test/runtime/symbols_test.dart | 34 ------ .../test/runtime/theme_gen_complex_test.dart | 33 ++++- .../test/runtime/theme_gen_lookup_test.dart | 12 ++ .../complex_theme_extension.g.theme.dart | 36 ++++-- .../complex_theme_extension.g.theme.dart | 36 ++++-- .../test/theme_gen/complex_theme.g.theme.dart | 86 ++++++++----- .../goldens/complex_theme.g.theme.dart | 43 ++++--- .../complex_theme_internal.g.theme.dart | 43 ++++--- .../goldens/lookup_theme.g.theme.dart | 18 +++ .../test/theme_gen/lookup_theme.dart | 83 ++++++++++++- .../test/theme_gen/lookup_theme.g.theme.dart | 18 +++ .../CHANGELOG.md | 4 + .../README.md | 2 +- 24 files changed, 554 insertions(+), 301 deletions(-) diff --git a/.github/README.md b/.github/README.md index 2d8fe06..9ef47a6 100644 --- a/.github/README.md +++ b/.github/README.md @@ -28,11 +28,11 @@ Or add to `pubspec.yaml`: ```yaml dependencies: - theme_extensions_builder_annotation: ^7.3.0 + theme_extensions_builder_annotation: ^7.5.0 dev_dependencies: build_runner: ^2.13.0 - theme_extensions_builder: ^7.3.0 + theme_extensions_builder: ^7.5.0 ``` ## 🚀 Quick Start diff --git a/docs/installation.md b/docs/installation.md index d605493..21e9e0a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -22,11 +22,11 @@ Or edit `pubspec.yaml` manually: ```yaml dependencies: - theme_extensions_builder_annotation: ^7.3.0 + theme_extensions_builder_annotation: ^7.5.0 dev_dependencies: build_runner: ^2.13.0 - theme_extensions_builder: ^7.3.0 + theme_extensions_builder: ^7.5.0 ``` Then fetch dependencies: diff --git a/packages/theme_extensions_builder/CHANGELOG.md b/packages/theme_extensions_builder/CHANGELOG.md index 3bdebad..8a1655a 100644 --- a/packages/theme_extensions_builder/CHANGELOG.md +++ b/packages/theme_extensions_builder/CHANGELOG.md @@ -1,10 +1,14 @@ ## 7.5.0 - **Fixed**: `@ThemeGen` now interpolates a nullable field whose type has an instance `lerp` method, instead of falling back to `t < 0.5 ? a : b`. +- **Fixed**: An interpolation that cannot run on a null now keeps the endpoints: `lerp(a, b, 0)` returns `a`'s value and `lerp(a, b, 1)` returns `b`'s value even when the other side is null. Previously a null on the `a` side either discarded `b` entirely or was taken at any `t`. +- **Fixed**: `merge` now keeps the current value when the incoming one is null. The static and instance merge branches disagreed on this; the instance behaviour was the correct one. - **Fixed**: A nullable field whose type has a `lerp` or `merge` method with a non-nullable parameter is now guarded, instead of generating code that does not compile. -- **Fixed**: A field type declaring an unrelated `lerp` or `merge` method no longer fails the build, and is no longer mistaken for a supported one: parameter and return types are checked against the field type, a required named parameter disqualifies the method, and optional parameters no longer hide a supported signature. +- **Fixed**: A field type declaring an unrelated `lerp` or `merge` method no longer fails the build, and is no longer mistaken for a supported one: parameter and return types are checked against the field type, a required named parameter disqualifies the method, and a non-generic method is matched with its type arguments. An unusable method is reported as a build warning. +- **Fixed**: A static `lerp` or `merge` is now called on the class rather than on an instantiation of it, so a generic field type no longer generates `Box.lerp(...)`. - **Fixed**: A `WidgetStateProperty` field with a non-nullable generic now reports an `InvalidGenerationSourceError` pointing at the field instead of a bare `StateError`, and an unrelated generic type with a four parameter `lerp` is no longer treated as a `WidgetStateProperty`. - **Fixed**: `Duration` is detected by element rather than by name, and a field narrowed by a subclass keeps the narrowed type in the generated code. +- **Updated**: Analyzer dependency to ">=13.0.0 <15.0.0" and Dart SDK constraint to ">=3.13.0 <4.0.0". - **Code Quality**: Exhaustive switches over `LerpInfo`/`MergeInfo`, `==`/`hashCode` contract fixed for the `LerpInfo` classes, dead code removed (`getMixinsNames`, `BaseClassVisitor`, unused builder options and lerp-lookup flag). ## 7.4.0 diff --git a/packages/theme_extensions_builder/README.md b/packages/theme_extensions_builder/README.md index 23ae7b7..caade4e 100644 --- a/packages/theme_extensions_builder/README.md +++ b/packages/theme_extensions_builder/README.md @@ -29,11 +29,11 @@ Or add manually to `pubspec.yaml`: ```yaml dependencies: - theme_extensions_builder_annotation: ^7.3.0 + theme_extensions_builder_annotation: ^7.5.0 dev_dependencies: build_runner: ^2.13.0 - theme_extensions_builder: ^7.4.0 + theme_extensions_builder: ^7.5.0 ``` ## 🚀 Quick Start diff --git a/packages/theme_extensions_builder/example/lib/theme/extensions/app_theme.g.theme.dart b/packages/theme_extensions_builder/example/lib/theme/extensions/app_theme.g.theme.dart index 17fbee9..d57d57a 100644 --- a/packages/theme_extensions_builder/example/lib/theme/extensions/app_theme.g.theme.dart +++ b/packages/theme_extensions_builder/example/lib/theme/extensions/app_theme.g.theme.dart @@ -49,10 +49,11 @@ mixin _$AppThemeExtension on ThemeExtension { )!, layoutMode: t < 0.5 ? _this.layoutMode : other.layoutMode, borderSide: BorderSide.lerp(_this.borderSide, other.borderSide, t), - optionalBorderSide: _this.optionalBorderSide == null - ? other.optionalBorderSide - : other.optionalBorderSide == null - ? _this.optionalBorderSide + optionalBorderSide: + _this.optionalBorderSide == null || other.optionalBorderSide == null + ? t < 0.5 + ? _this.optionalBorderSide + : other.optionalBorderSide : BorderSide.lerp( _this.optionalBorderSide!, other.optionalBorderSide!, diff --git a/packages/theme_extensions_builder/lib/src/common/analysis.dart b/packages/theme_extensions_builder/lib/src/common/analysis.dart index 6296a1f..5bff294 100644 --- a/packages/theme_extensions_builder/lib/src/common/analysis.dart +++ b/packages/theme_extensions_builder/lib/src/common/analysis.dart @@ -4,6 +4,7 @@ library; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/type.dart'; +import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; @@ -36,7 +37,7 @@ FieldInfo fieldSymbol( isDuration: isDuration, isStatic: element.isStatic, merge: config.includeMergeLookup - ? _mergeInfo(elementType) + ? _mergeInfo(elementType, element) : const NoMerge(), lerp: _lerpInfo(elementType, element), ); @@ -67,6 +68,8 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { // A required named parameter cannot be filled in by the generated call. if (method.formalParameters.any((p) => p.isRequiredNamed)) { + _warnUnsupported('lerp', type, fieldElement); + return const NoLerp(); } @@ -76,6 +79,11 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { .where((p) => p.isRequiredPositional) .toList(growable: false); + // A generic method's parameter types mention its own type parameters, which + // can't be substituted here, so those are matched on the declaring class + // only. + final strictSignature = method.typeParameters.isEmpty; + // WidgetStateProperty and WidgetStateColor use a different signature for // lerp. Check for the 4-parameter version first, as WidgetStateProperty has // both 3 and 4 parameter versions @@ -87,8 +95,8 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { when type is InterfaceType && method.isStatic && p3.type.isDartCoreDouble && - _checkSubtype(p1, type) && - _checkSubtype(p2, type)) { + _checkSubtype(p1, type, strict: strictSignature) && + _checkSubtype(p2, type, strict: strictSignature)) { // The fourth parameter has to be a lerp function itself, with the // signature `R Function(T? a, T? b, double t)`. // @@ -106,18 +114,17 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { // type, so that a non-generic subclass such as `WidgetStateColor` // resolves to `WidgetStateProperty`. final declaringElement = p1.type.element; - - if (declaringElement is! InterfaceElement) { - return const NoLerp(); - } - - final declaringType = type.asInstanceOf(declaringElement); + final declaringType = declaringElement is InterfaceElement + ? type.asInstanceOf(declaringElement) + : null; if (declaringType == null || declaringType.typeArguments.length != 1) { + _warnUnsupported('lerp', type, fieldElement); + return const NoLerp(); } - final baseTypeName = declaringElement.displayName; + final baseTypeName = declaringType.element.displayName; final innerType = declaringType.typeArguments.single; // Check that the generic type is nullable @@ -145,6 +152,8 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { // A four parameter lerp whose last parameter isn't a lerp function is not // something we know how to call. + _warnUnsupported('lerp', type, fieldElement); + return const NoLerp(); } @@ -155,8 +164,8 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { // - third parameter should be double when method.isStatic && p3.type.isDartCoreDouble && - _checkSubtype(p1, type) && - _checkSubtype(p2, type)) { + _checkSubtype(p1, type, strict: strictSignature) && + _checkSubtype(p2, type, strict: strictSignature)) { final args = _mapArgs(params); return StaticLerp( @@ -170,7 +179,7 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { // - second parameter should be double when !method.isStatic && p2.type.isDartCoreDouble && - _checkSubtype(p1, type)) { + _checkSubtype(p1, type, strict: strictSignature)) { final args = _mapArgs(params); return InstanceLerp( @@ -180,26 +189,44 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { } // The type declares a `lerp` we don't know how to call. + _warnUnsupported('lerp', type, fieldElement); + return const NoLerp(); } -/// Checks if a parameter type is a subtype of the given [type]. +/// Reports a [methodName] method that exists but cannot be called. /// -/// This function performs a type compatibility check between a formal parameter -/// and a target type. It handles nullability by promoting the type to non-null -/// before checking subtype relationships. +/// A type is free to declare an unrelated `lerp` or `merge`, so this is not an +/// error, but it is worth saying out loud: without the warning "the type has +/// no such method" and "the method is not one I can call" look the same in the +/// generated code. +void _warnUnsupported( + String methodName, + DartType type, + FieldElement fieldElement, +) => log.warning( + 'The `$methodName` method of ${type.getDisplayString()} has an unsupported ' + 'signature, so the field `${fieldElement.displayName}` is left out of ' + '`$methodName`.', +); + +/// Checks that a value of [type] can be passed to [param]. /// -/// Returns `true` if: -/// - Both [FormalParameterElement.type] and [type] are interface types -/// - [type] can be used as an instance of the parameter's type -/// - The non-null version of [type] is a subtype of that instance +/// Nullability is ignored on both sides: a lerp method taking `T?` accepts a +/// non-nullable field, and a nullable field is null checked at the call site. +/// +/// When [strict] is `true` the parameter type is compared as written, type +/// arguments included. It has to be `false` for a generic method, whose +/// parameter type mentions type parameters we cannot substitute here; only +/// the declaring class is checked then. /// /// Returns `false` if either type is not an interface type or if the subtype /// relationship doesn't hold. -/// -/// This is primarily used to validate lerp method signatures, ensuring that -/// parameters accept the correct types for interpolation. -bool _checkSubtype(FormalParameterElement param, DartType type) { +bool _checkSubtype( + FormalParameterElement param, + DartType type, { + required bool strict, +}) { final typeElement = type.element; if (typeElement is! InterfaceElement) { return false; @@ -212,14 +239,21 @@ bool _checkSubtype(FormalParameterElement param, DartType type) { return false; } + final typeSystem = typeElement.library.typeSystem; + final nonNullType = typeSystem.promoteToNonNull(type); + + if (strict) { + return typeSystem.isSubtypeOf( + nonNullType, + typeSystem.promoteToNonNull(parameterType), + ); + } + final supertypeInstance = type.asInstanceOf(paramTypeElement); if (supertypeInstance == null) { return false; } - final typeSystem = typeElement.library.typeSystem; - final nonNullType = typeSystem.promoteToNonNull(type); - return typeSystem.isSubtypeOf(nonNullType, supertypeInstance); } @@ -294,7 +328,7 @@ MethodElement? _lookupMethod(InterfaceElement typeElement, String name) { /// /// Returns [NoMerge] when the type is not an interface, has no merge method, /// or declares one whose signature we cannot call. -MergeInfo _mergeInfo(DartType type) { +MergeInfo _mergeInfo(DartType type, FieldElement fieldElement) { final typeElement = type.element; if (typeElement is! InterfaceElement) { @@ -318,6 +352,8 @@ MergeInfo _mergeInfo(DartType type) { // A required named parameter cannot be filled in by the generated call. if (method.formalParameters.any((p) => p.isRequiredNamed)) { + _warnUnsupported('merge', type, fieldElement); + return const NoMerge(); } @@ -325,14 +361,19 @@ MergeInfo _mergeInfo(DartType type) { .where((p) => p.isRequiredPositional) .toList(growable: false); + // A generic method's parameter types mention its own type parameters, which + // can't be substituted here, so those are matched on the declaring class + // only. + final strictSignature = method.typeParameters.isEmpty; + if (params case [final p1, final p2] // Check for static merge method // - should have two parameters // - both parameters should accept the class type // - the result should be usable as the class type when method.isStatic && - _checkSubtype(p1, type) && - _checkSubtype(p2, type) && + _checkSubtype(p1, type, strict: strictSignature) && + _checkSubtype(p2, type, strict: strictSignature) && _returnsSubtypeOf(method, type)) { return const StaticMerge(); } @@ -343,12 +384,14 @@ MergeInfo _mergeInfo(DartType type) { // - parameter type should accept the class type // - the result should be usable as the class type when !method.isStatic && - _checkSubtype(p1, type) && + _checkSubtype(p1, type, strict: strictSignature) && _returnsSubtypeOf(method, type)) { return InstanceMerge(isNullableParameter: p1.type.hasNullableSuffix); } // The type declares a `merge` we don't know how to call. + _warnUnsupported('merge', type, fieldElement); + return const NoMerge(); } diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/field_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/field_info.dart index fd86e9f..ffc8e7b 100644 --- a/packages/theme_extensions_builder/lib/src/common/symbols/field_info.dart +++ b/packages/theme_extensions_builder/lib/src/common/symbols/field_info.dart @@ -23,8 +23,22 @@ final class FieldInfo { final String name; /// The type name of the field without nullability suffix. + /// + /// Type arguments are part of it, so this is the name to declare a variable + /// or write a cast with. Use [baseTypeName] to call a static member. final String typeName; + /// The type name without type arguments. + /// + /// A static member is reached through the class, not through an + /// instantiation of it: `Box.lerp(...)` is valid where `Box.lerp(...)` + /// is not. + String get baseTypeName { + final index = typeName.indexOf('<'); + + return index == -1 ? typeName : typeName.substring(0, index); + } + /// Whether the field type is nullable. final bool isNullable; diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart index 0d0e5e5..87055ce 100644 --- a/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart +++ b/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart @@ -65,12 +65,6 @@ final class InstanceLerp extends LerpInfo { /// Whether the return type of the lerp method is nullable. final bool optionalResult; - /// Returns `true` if the lerp method accepts a nullable argument. - /// - /// A method that doesn't has to be guarded against a null `other` at the - /// call site. - bool get isNullableParameter => args.isNotEmpty && args[0].isNullable; - @override bool operator ==(Object other) => identical(this, other) || diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart index 48e0383..4ba3557 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart @@ -155,7 +155,7 @@ Method lerpMethod(ThemeExtensionsConfig config) => Method((m) { for (final field in fields) { final tProp = '_this'.ref.prop(field.name); final oProp = 'other'.ref.prop(field.name); - final sLerp = field.typeName.ref.prop('lerp'); + final sLerp = field.baseTypeName.ref.prop('lerp'); switch (field.lerp) { // Handle NoLerp with double field @@ -204,61 +204,39 @@ Method lerpMethod(ThemeExtensionsConfig config) => Method((m) { // Handle StaticLerp taking non-nullable arguments, optional field case StaticLerp(): - // _this.side == null - // ? other.side - // : other.side == null - // ? _this.side - // : Side.lerp(_this.side!, other.side!, t), - args[field.name] = tProp - .equalTo(literalNull) - .conditional( - oProp, - oProp - .equalTo(literalNull) - .conditional( - tProp, - sLerp([tProp.nullChecked, oProp.nullChecked, 't'.ref]), - ), - ); - - // Handle InstanceLerp taking a nullable argument, with an optional - // result and an optional field - case InstanceLerp(optionalResult: true, isNullableParameter: true) - when field.isNullable: - // _this.field?.lerp(other.field, t) - args[field.name] = tProp.prop('lerp', nullSafe: true)([ + // _this.side == null || other.side == null + // ? (t < 0.5 ? _this.side : other.side) + // : Side.lerp(_this.side!, other.side!, t) + args[field.name] = _nullGuardedLerp( + tProp, oProp, - 't'.ref, - ]); + sLerp([tProp.nullChecked, oProp.nullChecked, 't'.ref]), + ); - // Handle InstanceLerp taking a nullable argument, with a - // non-optional result and a nullable field - case InstanceLerp(isNullableParameter: true) when field.isNullable: - // _this.field?.lerp(other.field, t) as FieldType? - args[field.name] = tProp - .prop('lerp', nullSafe: true)([oProp, 't'.ref]) - .asA(field.typeName.typeRef(isNullable: true)); + // Handle InstanceLerp with an optional field, returning an + // optional result that needs no cast + case InstanceLerp(optionalResult: true) when field.isNullable: + // _this.field == null || other.field == null + // ? (t < 0.5 ? _this.field : other.field) + // : _this.field!.lerp(other.field!, t) + args[field.name] = _nullGuardedLerp( + tProp, + oProp, + tProp.nullChecked.property('lerp')([oProp.nullChecked, 't'.ref]), + ); - // Handle InstanceLerp taking a non-nullable argument, nullable field + // Handle InstanceLerp with an optional field case InstanceLerp() when field.isNullable: - // _this.field == null - // ? other.field - // : other.field == null - // ? _this.field - // : _this.field!.lerp(other.field!, t) as FieldType? - args[field.name] = tProp - .equalTo(literalNull) - .conditional( - oProp, - oProp - .equalTo(literalNull) - .conditional( - tProp, - tProp.nullChecked - .property('lerp')([oProp.nullChecked, 't'.ref]) - .asA(field.typeName.typeRef(isNullable: true)), - ), - ); + // _this.field == null || other.field == null + // ? (t < 0.5 ? _this.field : other.field) + // : _this.field!.lerp(other.field!, t) as FieldType? + args[field.name] = _nullGuardedLerp( + tProp, + oProp, + tProp.nullChecked + .property('lerp')([oProp.nullChecked, 't'.ref]) + .asA(field.typeName.typeRef(isNullable: true)), + ); // Handle InstanceLerp with non-optional field case InstanceLerp(): @@ -314,6 +292,20 @@ Method lerpMethod(ThemeExtensionsConfig config) => Method((m) { ); }); }); + +/// Wraps [lerpCall] so that it only runs when both sides are present. +/// +/// An interpolation that cannot accept a null falls back to the value the +/// timeline is closest to, which keeps `t == 0` on [a] and `t == 1` on [b]. +Expression _nullGuardedLerp(Expression a, Expression b, Expression lerpCall) => + a + .equalTo(literalNull) + .or(b.equalTo(literalNull)) + .conditional( + 't'.ref.lessThan(literalNum(0.5)).conditional(a, b), + lerpCall, + ); + // Returns a type reference for `ThemeExtension` based on [config]. TypeReference _buildThemeExtensionRef( ThemeExtensionsConfig config, { diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart index 90b7826..3ef3793 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart @@ -98,6 +98,19 @@ Method copyWith(ThemeGenConfig config) => Method((m) { }); }); +/// Wraps [lerpCall] so that it only runs when both sides are present. +/// +/// An interpolation that cannot accept a null falls back to the value the +/// timeline is closest to, which keeps `t == 0` on [a] and `t == 1` on [b]. +Expression _nullGuardedLerp(Expression a, Expression b, Expression lerpCall) => + a + .equalTo(literalNull) + .or(b.equalTo(literalNull)) + .conditional( + 't'.ref.lessThan(literalNum(0.5)).conditional(a, b), + lerpCall, + ); + /// Generates a `merge` method for the theme class. Method merge(ThemeGenConfig config) => Method((m) { m @@ -145,7 +158,7 @@ Method merge(ThemeGenConfig config) => Method((m) { final thisProp = '_this'.ref.prop(field.name); final otherProp = 'other'.ref.prop(field.name); - final staticMerge = field.typeName.ref.prop('merge'); + final staticMerge = field.baseTypeName.ref.prop('merge'); final instanceMerge = thisProp.prop('merge'); // Handle different merge strategies based on field configuration @@ -157,12 +170,24 @@ Method merge(ThemeGenConfig config) => Method((m) { // Static merge method with optional field case StaticMerge() when field.isNullable: + // _this.field == null + // ? other.field + // : other.field == null + // ? _this.field + // : Class.merge(_this.field!, other.field!) args[field.name] = thisProp - .notEqualTo(literalNull) - .and(otherProp.notEqualTo(literalNull)) + .equalTo(literalNull) .conditional( - staticMerge([thisProp.nullChecked, otherProp.nullChecked]), otherProp, + otherProp + .equalTo(literalNull) + .conditional( + thisProp, + staticMerge([ + thisProp.nullChecked, + otherProp.nullChecked, + ]), + ), ); // Static merge method with non-optional field @@ -283,7 +308,7 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { for (final field in fields) { final aProp = 'a'.ref.prop(field.name); final bProp = 'b'.ref.prop(field.name); - final lerp = field.typeName.ref.prop('lerp'); + final lerp = field.baseTypeName.ref.prop('lerp'); // Handle different lerp strategies based on field configuration switch (field.lerp) { @@ -304,61 +329,39 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { // Nullable field, lerp taking non-nullable arguments case StaticLerp(): - // value: a.field == null - // ? b.field - // : b.field == null - // ? a.field - // : Class.lerp(a.field!, b.field!, t) - argsResult[field.name] = aProp - .equalTo(literalNull) - .conditional( - bProp, - bProp - .equalTo(literalNull) - .conditional( - aProp, - lerp([aProp.nullChecked, bProp.nullChecked, 't'.ref]), - ), - ); - - // Instance lerp taking a nullable argument, with an optional - // result and a nullable field - case InstanceLerp(optionalResult: true, isNullableParameter: true) - when field.isNullable: - // value: a.field?.lerp(b.field, t) - argsResult[field.name] = aProp.prop('lerp', nullSafe: true)([ + // value: a.field == null || b.field == null + // ? (t < 0.5 ? a.field : b.field) + // : Class.lerp(a.field!, b.field!, t) + argsResult[field.name] = _nullGuardedLerp( + aProp, bProp, - 't'.ref, - ]); + lerp([aProp.nullChecked, bProp.nullChecked, 't'.ref]), + ); - // Instance lerp taking a nullable argument, with a non-optional - // result and a nullable field - case InstanceLerp(isNullableParameter: true) when field.isNullable: - // value: a.field?.lerp(b.field, t) as Class? - argsResult[field.name] = aProp - .prop('lerp', nullSafe: true)([bProp, 't'.ref]) - .asA(field.typeName.typeRef(isNullable: true)); + // Instance lerp with a nullable field, returning an optional + // result that needs no cast + case InstanceLerp(optionalResult: true) when field.isNullable: + // value: a.field == null || b.field == null + // ? (t < 0.5 ? a.field : b.field) + // : a.field!.lerp(b.field!, t) + argsResult[field.name] = _nullGuardedLerp( + aProp, + bProp, + aProp.nullChecked.property('lerp')([bProp.nullChecked, 't'.ref]), + ); - // Instance lerp taking a non-nullable argument, nullable field + // Instance lerp with a nullable field case InstanceLerp() when field.isNullable: - // value: a.field == null - // ? b.field - // : b.field == null - // ? a.field - // : a.field!.lerp(b.field!, t) as Class? - argsResult[field.name] = aProp - .equalTo(literalNull) - .conditional( - bProp, - bProp - .equalTo(literalNull) - .conditional( - aProp, - aProp.nullChecked - .property('lerp')([bProp.nullChecked, 't'.ref]) - .asA(field.typeName.typeRef(isNullable: true)), - ), - ); + // value: a.field == null || b.field == null + // ? (t < 0.5 ? a.field : b.field) + // : a.field!.lerp(b.field!, t) as Class? + argsResult[field.name] = _nullGuardedLerp( + aProp, + bProp, + aProp.nullChecked + .property('lerp')([bProp.nullChecked, 't'.ref]) + .asA(field.typeName.typeRef(isNullable: true)), + ); // Instance lerp method with non-nullable field case InstanceLerp(): diff --git a/packages/theme_extensions_builder/test/generator/code_builder_test.dart b/packages/theme_extensions_builder/test/generator/code_builder_test.dart index c3f16b4..4183d32 100644 --- a/packages/theme_extensions_builder/test/generator/code_builder_test.dart +++ b/packages/theme_extensions_builder/test/generator/code_builder_test.dart @@ -54,7 +54,7 @@ void main() { expect(code, contains('(a.value.lerp(b.value, t) as Lerpable)')); }); - test('instance lerp with non-optional result on a nullable field', () { + test('instance lerp on a nullable field keeps the endpoints', () { final code = _generate([ _field( 'value', @@ -64,26 +64,11 @@ void main() { ), ]); - expect(code, contains('(a.value?.lerp(b.value, t) as Lerpable?)')); - }); - - test('instance lerp taking a non-nullable argument is guarded', () { - final code = _generate([ - _field( - 'value', - typeName: 'Lerpable', - isNullable: true, - lerp: const InstanceLerp( - optionalResult: false, - args: [_nonNullableArg], - ), - ), - ]); - expect( code, contains( - 'a.value == null ? b.value : b.value == null ? a.value : ' + 'a.value == null || b.value == null ? ' + 't < 0.5 ? a.value : b.value : ' '(a.value!.lerp(b.value!, t) as Lerpable?)', ), ); @@ -136,11 +121,28 @@ void main() { expect( code, contains( - '_this.value == null ? other.value : other.value == null ? ' - '_this.value : (_this.value!.lerp(other.value!, t) as Lerpable?)', + '_this.value == null || other.value == null ? ' + 't < 0.5 ? _this.value : other.value : ' + '(_this.value!.lerp(other.value!, t) as Lerpable?)', ), ); }); + + test('a static call receiver drops the type arguments', () { + final code = _generate([ + _field( + 'value', + typeName: 'Box', + lerp: const StaticLerp( + optionalResult: true, + args: [_nullableArg, _nullableArg], + ), + ), + ]); + + expect(code, contains('Box.lerp(a.value, b.value, t)!')); + expect(code, contains('Box? value')); + }); }); } diff --git a/packages/theme_extensions_builder/test/runtime/symbols_test.dart b/packages/theme_extensions_builder/test/runtime/symbols_test.dart index b9690fa..55b9da1 100644 --- a/packages/theme_extensions_builder/test/runtime/symbols_test.dart +++ b/packages/theme_extensions_builder/test/runtime/symbols_test.dart @@ -153,40 +153,6 @@ void main() { expect(lerp.args.length, 2); }); - test('isNullableParameter is independent of the result nullability', () { - const nullableArgs = [ - ParameterInfo(name: 'other', type: 'Color', isNullable: true), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ]; - - const optional = InstanceLerp(optionalResult: true, args: nullableArgs); - const nonOptional = InstanceLerp( - optionalResult: false, - args: nullableArgs, - ); - - expect(optional.isNullableParameter, true); - expect(nonOptional.isNullableParameter, true); - }); - - test('isNullableParameter returns false for a non-nullable argument', () { - const lerp = InstanceLerp( - optionalResult: true, - args: [ - ParameterInfo(name: 'other', type: 'Color', isNullable: false), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ], - ); - - expect(lerp.isNullableParameter, false); - }); - - test('isNullableParameter handles empty args safely', () { - const lerp = InstanceLerp(optionalResult: true, args: []); - - expect(lerp.isNullableParameter, false); - }); - test('equality works correctly', () { final lerp1 = InstanceLerp( optionalResult: true, diff --git a/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart b/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart index 86216f1..87b7f43 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart @@ -242,7 +242,7 @@ void main() { }); test( - 'lerps instance lerp with optional result when first value is null', + 'keeps the endpoints when the first instance lerp value is null', () { // Create themes where one has null for the lerpable field const themeWithNull = ComplexTheme( @@ -289,11 +289,32 @@ void main() { optionalLerpableWithOptionalResult: LerpableWithOptionalResult(8), ); - final result = ComplexTheme.lerp(themeWithNull, themeWithValue, 0.5); - - expect(result, isNotNull); - // When lerp is called on null with ?.lerp, it returns null - expect(result!.optionalLerpableWithOptionalResult, isNull); + // An instance lerp cannot run on a null receiver, so the value the + // timeline is closest to is taken instead. The endpoints still hold. + expect( + ComplexTheme.lerp( + themeWithNull, + themeWithValue, + 0, + )!.optionalLerpableWithOptionalResult, + isNull, + ); + expect( + ComplexTheme.lerp( + themeWithNull, + themeWithValue, + 1, + )!.optionalLerpableWithOptionalResult, + same(themeWithValue.optionalLerpableWithOptionalResult), + ); + expect( + ComplexTheme.lerp( + themeWithNull, + themeWithValue, + 0.5, + )!.optionalLerpableWithOptionalResult, + same(themeWithValue.optionalLerpableWithOptionalResult), + ); }, ); }); diff --git a/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart index 713a4be..4f08faa 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart @@ -10,6 +10,9 @@ void main() { flags: Flags(1), clamped: Clamped(1), unrelated: Unrelated(1), + mode: Mode(1), + pair: Pair(1), + strict: Strict(1), narrowed: 1, ); @@ -20,6 +23,9 @@ void main() { flags: Flags(2), clamped: Clamped(2), unrelated: Unrelated(2), + mode: Mode(2), + pair: Pair(2), + strict: Strict(2), narrowed: 2, ); @@ -42,7 +48,10 @@ void main() { test('an uncallable lerp or merge signature is ignored', () { expect(LookupTheme.lerp(a, b, 0.4)!.clamped, same(a.clamped)); + expect(LookupTheme.lerp(a, b, 0.4)!.mode, same(a.mode)); + expect(LookupTheme.lerp(a, b, 0.4)!.pair, same(a.pair)); expect(a.merge(b).unrelated, same(b.unrelated)); + expect(a.merge(b).strict, same(b.strict)); }); test('a field narrowed by a superclass keeps the narrowed type', () { @@ -57,6 +66,9 @@ void main() { flags: Flags(1), clamped: Clamped(1), unrelated: Unrelated(1), + mode: Mode(1), + pair: Pair(1), + strict: Strict(1), narrowed: 1, ); diff --git a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart index b824769..e99a606 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart @@ -277,10 +277,11 @@ mixin _$ComplexThemeExtension on ThemeExtension { t, ), optionalColor: Color.lerp(_this.optionalColor, other.optionalColor, t), - optionalBorderSide: _this.optionalBorderSide == null - ? other.optionalBorderSide - : other.optionalBorderSide == null - ? _this.optionalBorderSide + optionalBorderSide: + _this.optionalBorderSide == null || other.optionalBorderSide == null + ? t < 0.5 + ? _this.optionalBorderSide + : other.optionalBorderSide : BorderSide.lerp( _this.optionalBorderSide!, other.optionalBorderSide!, @@ -291,13 +292,26 @@ mixin _$ComplexThemeExtension on ThemeExtension { other.optionalTheme, t, ), - optionalThemeExtension: (_this.optionalThemeExtension?.lerp( - other.optionalThemeExtension, - t, - ) as EmptyThemeExtension?), - optionalLerpableWithOptionalResult: _this - .optionalLerpableWithOptionalResult - ?.lerp(other.optionalLerpableWithOptionalResult, t), + optionalThemeExtension: + _this.optionalThemeExtension == null || + other.optionalThemeExtension == null + ? t < 0.5 + ? _this.optionalThemeExtension + : other.optionalThemeExtension + : (_this.optionalThemeExtension!.lerp( + other.optionalThemeExtension!, + t, + ) as EmptyThemeExtension?), + optionalLerpableWithOptionalResult: + _this.optionalLerpableWithOptionalResult == null || + other.optionalLerpableWithOptionalResult == null + ? t < 0.5 + ? _this.optionalLerpableWithOptionalResult + : other.optionalLerpableWithOptionalResult + : _this.optionalLerpableWithOptionalResult!.lerp( + other.optionalLerpableWithOptionalResult!, + t, + ), ); } diff --git a/packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension.g.theme.dart index 75d3160..523f622 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension.g.theme.dart @@ -106,10 +106,11 @@ mixin _$ComplexThemeExtension on ThemeExtension { t, ), optionalColor: Color.lerp(_this.optionalColor, other.optionalColor, t), - optionalBorderSide: _this.optionalBorderSide == null - ? other.optionalBorderSide - : other.optionalBorderSide == null - ? _this.optionalBorderSide + optionalBorderSide: + _this.optionalBorderSide == null || other.optionalBorderSide == null + ? t < 0.5 + ? _this.optionalBorderSide + : other.optionalBorderSide : BorderSide.lerp( _this.optionalBorderSide!, other.optionalBorderSide!, @@ -120,13 +121,26 @@ mixin _$ComplexThemeExtension on ThemeExtension { other.optionalTheme, t, ), - optionalThemeExtension: (_this.optionalThemeExtension?.lerp( - other.optionalThemeExtension, - t, - ) as EmptyThemeExtension?), - optionalLerpableWithOptionalResult: _this - .optionalLerpableWithOptionalResult - ?.lerp(other.optionalLerpableWithOptionalResult, t), + optionalThemeExtension: + _this.optionalThemeExtension == null || + other.optionalThemeExtension == null + ? t < 0.5 + ? _this.optionalThemeExtension + : other.optionalThemeExtension + : (_this.optionalThemeExtension!.lerp( + other.optionalThemeExtension!, + t, + ) as EmptyThemeExtension?), + optionalLerpableWithOptionalResult: + _this.optionalLerpableWithOptionalResult == null || + other.optionalLerpableWithOptionalResult == null + ? t < 0.5 + ? _this.optionalLerpableWithOptionalResult + : other.optionalLerpableWithOptionalResult + : _this.optionalLerpableWithOptionalResult!.lerp( + other.optionalLerpableWithOptionalResult!, + t, + ), ); } diff --git a/packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart index 98b7adb..746adac 100644 --- a/packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart @@ -60,18 +60,30 @@ mixin _$ComplexThemeInternal { t, ), optionalColor: Color.lerp(a.optionalColor, b.optionalColor, t), - optionalBorderSide: a.optionalBorderSide == null - ? b.optionalBorderSide - : b.optionalBorderSide == null - ? a.optionalBorderSide + optionalBorderSide: + a.optionalBorderSide == null || b.optionalBorderSide == null + ? t < 0.5 + ? a.optionalBorderSide + : b.optionalBorderSide : BorderSide.lerp(a.optionalBorderSide!, b.optionalBorderSide!, t), optionalTheme: EmptyTheme.lerp(a.optionalTheme, b.optionalTheme, t), - optionalThemeExtension: (a.optionalThemeExtension?.lerp( - b.optionalThemeExtension, - t, - ) as EmptyThemeExtension?), - optionalLerpableWithOptionalResult: a.optionalLerpableWithOptionalResult - ?.lerp(b.optionalLerpableWithOptionalResult, t), + optionalThemeExtension: + a.optionalThemeExtension == null || b.optionalThemeExtension == null + ? t < 0.5 + ? a.optionalThemeExtension + : b.optionalThemeExtension + : (a.optionalThemeExtension!.lerp(b.optionalThemeExtension!, t) + as EmptyThemeExtension?), + optionalLerpableWithOptionalResult: + a.optionalLerpableWithOptionalResult == null || + b.optionalLerpableWithOptionalResult == null + ? t < 0.5 + ? a.optionalLerpableWithOptionalResult + : b.optionalLerpableWithOptionalResult + : a.optionalLerpableWithOptionalResult!.lerp( + b.optionalLerpableWithOptionalResult!, + t, + ), ); } @@ -155,13 +167,14 @@ mixin _$ComplexThemeInternal { optionalBool: other.optionalBool, optionalDuration: other.optionalDuration, optionalColor: other.optionalColor, - optionalBorderSide: - _this.optionalBorderSide != null && other.optionalBorderSide != null - ? BorderSide.merge( + optionalBorderSide: _this.optionalBorderSide == null + ? other.optionalBorderSide + : other.optionalBorderSide == null + ? _this.optionalBorderSide + : BorderSide.merge( _this.optionalBorderSide!, other.optionalBorderSide!, - ) - : other.optionalBorderSide, + ), optionalTheme: _this.optionalTheme?.merge(other.optionalTheme) ?? other.optionalTheme, @@ -282,18 +295,30 @@ mixin _$ComplexTheme { t, ), optionalColor: Color.lerp(a.optionalColor, b.optionalColor, t), - optionalBorderSide: a.optionalBorderSide == null - ? b.optionalBorderSide - : b.optionalBorderSide == null - ? a.optionalBorderSide + optionalBorderSide: + a.optionalBorderSide == null || b.optionalBorderSide == null + ? t < 0.5 + ? a.optionalBorderSide + : b.optionalBorderSide : BorderSide.lerp(a.optionalBorderSide!, b.optionalBorderSide!, t), optionalTheme: EmptyTheme.lerp(a.optionalTheme, b.optionalTheme, t), - optionalThemeExtension: (a.optionalThemeExtension?.lerp( - b.optionalThemeExtension, - t, - ) as EmptyThemeExtension?), - optionalLerpableWithOptionalResult: a.optionalLerpableWithOptionalResult - ?.lerp(b.optionalLerpableWithOptionalResult, t), + optionalThemeExtension: + a.optionalThemeExtension == null || b.optionalThemeExtension == null + ? t < 0.5 + ? a.optionalThemeExtension + : b.optionalThemeExtension + : (a.optionalThemeExtension!.lerp(b.optionalThemeExtension!, t) + as EmptyThemeExtension?), + optionalLerpableWithOptionalResult: + a.optionalLerpableWithOptionalResult == null || + b.optionalLerpableWithOptionalResult == null + ? t < 0.5 + ? a.optionalLerpableWithOptionalResult + : b.optionalLerpableWithOptionalResult + : a.optionalLerpableWithOptionalResult!.lerp( + b.optionalLerpableWithOptionalResult!, + t, + ), ); } @@ -377,13 +402,14 @@ mixin _$ComplexTheme { optionalBool: other.optionalBool, optionalDuration: other.optionalDuration, optionalColor: other.optionalColor, - optionalBorderSide: - _this.optionalBorderSide != null && other.optionalBorderSide != null - ? BorderSide.merge( + optionalBorderSide: _this.optionalBorderSide == null + ? other.optionalBorderSide + : other.optionalBorderSide == null + ? _this.optionalBorderSide + : BorderSide.merge( _this.optionalBorderSide!, other.optionalBorderSide!, - ) - : other.optionalBorderSide, + ), optionalTheme: _this.optionalTheme?.merge(other.optionalTheme) ?? other.optionalTheme, diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme.g.theme.dart index 69fa192..fe17a62 100644 --- a/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme.g.theme.dart @@ -47,18 +47,30 @@ mixin _$ComplexTheme { t, ), optionalColor: Color.lerp(a.optionalColor, b.optionalColor, t), - optionalBorderSide: a.optionalBorderSide == null - ? b.optionalBorderSide - : b.optionalBorderSide == null - ? a.optionalBorderSide + optionalBorderSide: + a.optionalBorderSide == null || b.optionalBorderSide == null + ? t < 0.5 + ? a.optionalBorderSide + : b.optionalBorderSide : BorderSide.lerp(a.optionalBorderSide!, b.optionalBorderSide!, t), optionalTheme: EmptyTheme.lerp(a.optionalTheme, b.optionalTheme, t), - optionalThemeExtension: (a.optionalThemeExtension?.lerp( - b.optionalThemeExtension, - t, - ) as EmptyThemeExtension?), - optionalLerpableWithOptionalResult: a.optionalLerpableWithOptionalResult - ?.lerp(b.optionalLerpableWithOptionalResult, t), + optionalThemeExtension: + a.optionalThemeExtension == null || b.optionalThemeExtension == null + ? t < 0.5 + ? a.optionalThemeExtension + : b.optionalThemeExtension + : (a.optionalThemeExtension!.lerp(b.optionalThemeExtension!, t) + as EmptyThemeExtension?), + optionalLerpableWithOptionalResult: + a.optionalLerpableWithOptionalResult == null || + b.optionalLerpableWithOptionalResult == null + ? t < 0.5 + ? a.optionalLerpableWithOptionalResult + : b.optionalLerpableWithOptionalResult + : a.optionalLerpableWithOptionalResult!.lerp( + b.optionalLerpableWithOptionalResult!, + t, + ), ); } @@ -142,13 +154,14 @@ mixin _$ComplexTheme { optionalBool: other.optionalBool, optionalDuration: other.optionalDuration, optionalColor: other.optionalColor, - optionalBorderSide: - _this.optionalBorderSide != null && other.optionalBorderSide != null - ? BorderSide.merge( + optionalBorderSide: _this.optionalBorderSide == null + ? other.optionalBorderSide + : other.optionalBorderSide == null + ? _this.optionalBorderSide + : BorderSide.merge( _this.optionalBorderSide!, other.optionalBorderSide!, - ) - : other.optionalBorderSide, + ), optionalTheme: _this.optionalTheme?.merge(other.optionalTheme) ?? other.optionalTheme, diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme_internal.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme_internal.g.theme.dart index 60fab44..c61c8bd 100644 --- a/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme_internal.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme_internal.g.theme.dart @@ -51,18 +51,30 @@ mixin _$ComplexThemeInternal { t, ), optionalColor: Color.lerp(a.optionalColor, b.optionalColor, t), - optionalBorderSide: a.optionalBorderSide == null - ? b.optionalBorderSide - : b.optionalBorderSide == null - ? a.optionalBorderSide + optionalBorderSide: + a.optionalBorderSide == null || b.optionalBorderSide == null + ? t < 0.5 + ? a.optionalBorderSide + : b.optionalBorderSide : BorderSide.lerp(a.optionalBorderSide!, b.optionalBorderSide!, t), optionalTheme: EmptyTheme.lerp(a.optionalTheme, b.optionalTheme, t), - optionalThemeExtension: (a.optionalThemeExtension?.lerp( - b.optionalThemeExtension, - t, - ) as EmptyThemeExtension?), - optionalLerpableWithOptionalResult: a.optionalLerpableWithOptionalResult - ?.lerp(b.optionalLerpableWithOptionalResult, t), + optionalThemeExtension: + a.optionalThemeExtension == null || b.optionalThemeExtension == null + ? t < 0.5 + ? a.optionalThemeExtension + : b.optionalThemeExtension + : (a.optionalThemeExtension!.lerp(b.optionalThemeExtension!, t) + as EmptyThemeExtension?), + optionalLerpableWithOptionalResult: + a.optionalLerpableWithOptionalResult == null || + b.optionalLerpableWithOptionalResult == null + ? t < 0.5 + ? a.optionalLerpableWithOptionalResult + : b.optionalLerpableWithOptionalResult + : a.optionalLerpableWithOptionalResult!.lerp( + b.optionalLerpableWithOptionalResult!, + t, + ), ); } @@ -146,13 +158,14 @@ mixin _$ComplexThemeInternal { optionalBool: other.optionalBool, optionalDuration: other.optionalDuration, optionalColor: other.optionalColor, - optionalBorderSide: - _this.optionalBorderSide != null && other.optionalBorderSide != null - ? BorderSide.merge( + optionalBorderSide: _this.optionalBorderSide == null + ? other.optionalBorderSide + : other.optionalBorderSide == null + ? _this.optionalBorderSide + : BorderSide.merge( _this.optionalBorderSide!, other.optionalBorderSide!, - ) - : other.optionalBorderSide, + ), optionalTheme: _this.optionalTheme?.merge(other.optionalTheme) ?? other.optionalTheme, diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart index ad1cb57..22ebde0 100644 --- a/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart @@ -23,6 +23,9 @@ mixin _$LookupTheme { flags: t < 0.5 ? a.flags : b.flags, clamped: t < 0.5 ? a.clamped : b.clamped, unrelated: t < 0.5 ? a.unrelated : b.unrelated, + mode: t < 0.5 ? a.mode : b.mode, + pair: t < 0.5 ? a.pair : b.pair, + strict: t < 0.5 ? a.strict : b.strict, narrowed: t < 0.5 ? a.narrowed : b.narrowed, ); } @@ -34,6 +37,9 @@ mixin _$LookupTheme { Flags? flags, Clamped? clamped, Unrelated? unrelated, + Mode? mode, + Pair? pair, + Strict? strict, int? narrowed, }) { final _this = (this as LookupTheme); @@ -45,6 +51,9 @@ mixin _$LookupTheme { flags: flags ?? _this.flags, clamped: clamped ?? _this.clamped, unrelated: unrelated ?? _this.unrelated, + mode: mode ?? _this.mode, + pair: pair ?? _this.pair, + strict: strict ?? _this.strict, narrowed: narrowed ?? _this.narrowed, ); } @@ -71,6 +80,9 @@ mixin _$LookupTheme { flags: other.flags, clamped: other.clamped, unrelated: other.unrelated, + mode: other.mode, + pair: other.pair, + strict: other.strict, narrowed: other.narrowed, ); } @@ -94,6 +106,9 @@ mixin _$LookupTheme { _other.flags == _this.flags && _other.clamped == _this.clamped && _other.unrelated == _this.unrelated && + _other.mode == _this.mode && + _other.pair == _this.pair && + _other.strict == _this.strict && _other.narrowed == _this.narrowed; } @@ -109,6 +124,9 @@ mixin _$LookupTheme { _this.flags, _this.clamped, _this.unrelated, + _this.mode, + _this.pair, + _this.strict, _this.narrowed, ); } diff --git a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart index 7f93854..4dd7fca 100644 --- a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart @@ -55,6 +55,41 @@ class Unrelated { static double merge(double a, double b) => a + b; } +/// A plain class with a four parameter `lerp` whose last parameter is not a +/// lerp function. +class Mode { + const Mode(this.value); + + final int value; + + static Mode? lerp(Mode? a, Mode? b, double t, int mode) => a; +} + +/// A plain class with a `WidgetStateProperty` shaped `lerp` but no generic to +/// interpolate. +class Pair { + const Pair(this.value); + + final int value; + + static Pair? lerp( + Pair? a, + Pair? b, + double t, + int? Function(int?, int?, double) lerpValue, + ) => a; +} + +/// A plain class whose `merge` cannot be called with positional arguments +/// only. +class Strict { + const Strict(this.value); + + final int value; + + Strict merge(Strict other, {required bool deep}) => other; +} + /// Base class declaring a field that [LookupTheme]'s superclass narrows. class Base { const Base({required this.narrowed}); @@ -73,9 +108,49 @@ class Middle extends Base { final int narrowed; } +const _curveWarning = + 'The `lerp` method of Curve has an unsupported signature, so the field ' + '`curve` is left out of `lerp`.'; + +const _flagsWarning = + 'The `merge` method of Flags has an unsupported signature, so the field ' + '`flags` is left out of `merge`.'; + +const _clampedWarning = + 'The `lerp` method of Clamped has an unsupported signature, so the field ' + '`clamped` is left out of `lerp`.'; + +const _unrelatedWarning = + 'The `merge` method of Unrelated has an unsupported signature, so the ' + 'field `unrelated` is left out of `merge`.'; + +const _modeWarning = + 'The `lerp` method of Mode has an unsupported signature, so the field ' + '`mode` is left out of `lerp`.'; + +const _pairWarning = + 'The `lerp` method of Pair has an unsupported signature, so the field ' + '`pair` is left out of `lerp`.'; + +const _strictWarning = + 'The `merge` method of Strict has an unsupported signature, so the field ' + '`strict` is left out of `merge`.'; + /// Theme whose field types are inspected by method lookup rather than by an /// annotation: only [Settings] offers a signature the generator can call. -@ShouldGenerateFile('goldens/lookup_theme.g.theme.dart', partOfCurrent: true) +@ShouldGenerateFile( + 'goldens/lookup_theme.g.theme.dart', + partOfCurrent: true, + expectedLogItems: [ + _curveWarning, + _flagsWarning, + _clampedWarning, + _unrelatedWarning, + _modeWarning, + _pairWarning, + _strictWarning, + ], +) @themeGen final class LookupTheme extends Middle with _$LookupTheme { const LookupTheme({ @@ -85,6 +160,9 @@ final class LookupTheme extends Middle with _$LookupTheme { required this.flags, required this.clamped, required this.unrelated, + required this.mode, + required this.pair, + required this.strict, required super.narrowed, }); @@ -94,6 +172,9 @@ final class LookupTheme extends Middle with _$LookupTheme { final Flags flags; final Clamped clamped; final Unrelated unrelated; + final Mode mode; + final Pair pair; + final Strict strict; @override bool get canMerge => true; diff --git a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart index 1f8fd86..189b70a 100644 --- a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart @@ -32,6 +32,9 @@ mixin _$LookupTheme { flags: t < 0.5 ? a.flags : b.flags, clamped: t < 0.5 ? a.clamped : b.clamped, unrelated: t < 0.5 ? a.unrelated : b.unrelated, + mode: t < 0.5 ? a.mode : b.mode, + pair: t < 0.5 ? a.pair : b.pair, + strict: t < 0.5 ? a.strict : b.strict, narrowed: t < 0.5 ? a.narrowed : b.narrowed, ); } @@ -43,6 +46,9 @@ mixin _$LookupTheme { Flags? flags, Clamped? clamped, Unrelated? unrelated, + Mode? mode, + Pair? pair, + Strict? strict, int? narrowed, }) { final _this = (this as LookupTheme); @@ -54,6 +60,9 @@ mixin _$LookupTheme { flags: flags ?? _this.flags, clamped: clamped ?? _this.clamped, unrelated: unrelated ?? _this.unrelated, + mode: mode ?? _this.mode, + pair: pair ?? _this.pair, + strict: strict ?? _this.strict, narrowed: narrowed ?? _this.narrowed, ); } @@ -80,6 +89,9 @@ mixin _$LookupTheme { flags: other.flags, clamped: other.clamped, unrelated: other.unrelated, + mode: other.mode, + pair: other.pair, + strict: other.strict, narrowed: other.narrowed, ); } @@ -103,6 +115,9 @@ mixin _$LookupTheme { _other.flags == _this.flags && _other.clamped == _this.clamped && _other.unrelated == _this.unrelated && + _other.mode == _this.mode && + _other.pair == _this.pair && + _other.strict == _this.strict && _other.narrowed == _this.narrowed; } @@ -118,6 +133,9 @@ mixin _$LookupTheme { _this.flags, _this.clamped, _this.unrelated, + _this.mode, + _this.pair, + _this.strict, _this.narrowed, ); } diff --git a/packages/theme_extensions_builder_annotation/CHANGELOG.md b/packages/theme_extensions_builder_annotation/CHANGELOG.md index f902214..fba878f 100644 --- a/packages/theme_extensions_builder_annotation/CHANGELOG.md +++ b/packages/theme_extensions_builder_annotation/CHANGELOG.md @@ -1,3 +1,7 @@ +## 7.5.0 + +- **Updated**: Dart SDK constraint to ">=3.13.0 <4.0.0". No API changes; released alongside `theme_extensions_builder` 7.5.0. + ## 7.4.0 - *Updated*: Dependencies. diff --git a/packages/theme_extensions_builder_annotation/README.md b/packages/theme_extensions_builder_annotation/README.md index ad1de4b..5795694 100644 --- a/packages/theme_extensions_builder_annotation/README.md +++ b/packages/theme_extensions_builder_annotation/README.md @@ -21,7 +21,7 @@ Or manually in `pubspec.yaml`: ```yaml dependencies: - theme_extensions_builder_annotation: ^7.4.0 + theme_extensions_builder_annotation: ^7.5.0 ``` **Note**: You also need to add `theme_extensions_builder` as a dev dependency. See the [theme_extensions_builder documentation](https://pub.dev/packages/theme_extensions_builder) for complete setup instructions. From 43456eb89d97659b80a5729616c272f3beb73d8f Mon Sep 17 00:00:00 2001 From: Andrii Ivanov Date: Sat, 5 Sep 2026 15:17:49 +0300 Subject: [PATCH 05/10] added inputs showcase --- .../example/lib/pages/home_page.dart | 36 +++ .../lib/pages/widgets/input_showcase.dart | 240 ++++++++++++++++++ .../lib/pages/widgets/lerp_showcase.dart | 211 +++++++++++++++ .../example/lib/theme/dark_theme.dart | 35 ++- .../theme/extensions/widgets/input_theme.dart | 52 ++++ .../widgets/input_theme.g.theme.dart | 156 ++++++++++++ .../example/lib/theme/light_theme.dart | 29 +++ 7 files changed, 758 insertions(+), 1 deletion(-) create mode 100644 packages/theme_extensions_builder/example/lib/pages/widgets/input_showcase.dart create mode 100644 packages/theme_extensions_builder/example/lib/pages/widgets/lerp_showcase.dart create mode 100644 packages/theme_extensions_builder/example/lib/theme/extensions/widgets/input_theme.dart create mode 100644 packages/theme_extensions_builder/example/lib/theme/extensions/widgets/input_theme.g.theme.dart diff --git a/packages/theme_extensions_builder/example/lib/pages/home_page.dart b/packages/theme_extensions_builder/example/lib/pages/home_page.dart index c7fe7c4..f4c85ba 100644 --- a/packages/theme_extensions_builder/example/lib/pages/home_page.dart +++ b/packages/theme_extensions_builder/example/lib/pages/home_page.dart @@ -6,6 +6,8 @@ import '../theme/extensions/spacing_theme.dart'; import 'widgets/base_card.dart'; import 'widgets/button_showcase.dart'; import 'widgets/custom_button.dart'; +import 'widgets/input_showcase.dart'; +import 'widgets/lerp_showcase.dart'; import 'widgets/typography_showcase.dart'; class HomePage extends StatefulWidget { @@ -46,6 +48,8 @@ class _HomePageState extends State { _buildButtonsPage(context), _buildTypographyPage(context), _buildCardsPage(context), + _buildInputsPage(context), + _buildLerpPage(context), ], ), bottomNavigationBar: NavigationBar( @@ -76,6 +80,16 @@ class _HomePageState extends State { selectedIcon: Icon(Icons.dashboard), label: 'Cards', ), + NavigationDestination( + icon: Icon(Icons.edit_outlined), + selectedIcon: Icon(Icons.edit), + label: 'Inputs', + ), + NavigationDestination( + icon: Icon(Icons.animation_outlined), + selectedIcon: Icon(Icons.animation), + label: 'Lerp', + ), ], ), ); @@ -189,6 +203,28 @@ class _HomePageState extends State { ); } + Widget _buildInputsPage(BuildContext context) { + final spacing = context.spacingTheme; + return SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: spacing.pageHorizontal, + vertical: spacing.pageVertical, + ), + child: const InputShowcase(), + ); + } + + Widget _buildLerpPage(BuildContext context) { + final spacing = context.spacingTheme; + return SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: spacing.pageHorizontal, + vertical: spacing.pageVertical, + ), + child: const LerpShowcase(), + ); + } + Widget _buildCardsPage(BuildContext context) { final spacing = context.spacingTheme; return SingleChildScrollView( diff --git a/packages/theme_extensions_builder/example/lib/pages/widgets/input_showcase.dart b/packages/theme_extensions_builder/example/lib/pages/widgets/input_showcase.dart new file mode 100644 index 0000000..9afe9bb --- /dev/null +++ b/packages/theme_extensions_builder/example/lib/pages/widgets/input_showcase.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; + +import '../../theme/extensions/spacing_theme.dart'; +import '../../theme/extensions/widgets/input_theme.dart'; + +/// Text inputs driven entirely by [InputThemeExtension]. +/// +/// Every visible part of a field comes from the extension: the border colour +/// and the fill are `WidgetStateProperty` resolved against the state +/// of the field, and the border width, radius, padding, text styles and the +/// duration of the focus animation are plain fields. Toggle the theme to see +/// all of them interpolate at once. +class InputShowcase extends StatefulWidget { + const InputShowcase({super.key}); + + @override + State createState() => _InputShowcaseState(); +} + +class _InputShowcaseState extends State { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(text: 'hunter2'); + final _searchController = TextEditingController(); + final _notesController = TextEditingController(); + final _invalidController = TextEditingController(text: 'not-an-email'); + + var _obscurePassword = true; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _searchController.dispose(); + _notesController.dispose(); + _invalidController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final spacing = context.spacingTheme; + final textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Inputs', style: textTheme.headlineMedium), + SizedBox(height: spacing.sm), + Text( + 'Border and fill colours are WidgetStateProperty, resolved ' + 'against the state of each field. Focus one to see the state change, ' + 'toggle the theme to see the whole set interpolate.', + style: textTheme.bodyMedium, + ), + SizedBox(height: spacing.lg), + ThemedTextField( + label: 'Email', + hint: 'you@example.com', + helperText: 'We only use it to send the newsletter.', + controller: _emailController, + keyboardType: TextInputType.emailAddress, + prefixIcon: Icons.alternate_email, + ), + SizedBox(height: spacing.md), + ThemedTextField( + label: 'Password', + hint: 'At least 8 characters', + controller: _passwordController, + obscureText: _obscurePassword, + prefixIcon: Icons.lock_outline, + suffix: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + size: 20, + ), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), + tooltip: _obscurePassword ? 'Show password' : 'Hide password', + ), + ), + SizedBox(height: spacing.md), + ThemedTextField( + label: 'Search', + hint: 'Type to filter', + controller: _searchController, + prefixIcon: Icons.search, + ), + SizedBox(height: spacing.md), + ThemedTextField( + label: 'Email (error state)', + controller: _invalidController, + errorText: 'Enter a valid email address', + prefixIcon: Icons.alternate_email, + ), + SizedBox(height: spacing.md), + const ThemedTextField( + label: 'Account id (disabled)', + hint: 'Assigned automatically', + enabled: false, + prefixIcon: Icons.badge_outlined, + ), + SizedBox(height: spacing.md), + ThemedTextField( + label: 'Notes', + hint: 'Anything else we should know?', + controller: _notesController, + maxLines: 4, + ), + ], + ); + } +} + +/// A text field painted from [InputThemeExtension] rather than from the +/// Material input decoration theme. +class ThemedTextField extends StatefulWidget { + const ThemedTextField({ + required this.label, + this.hint, + this.helperText, + this.errorText, + this.controller, + this.keyboardType, + this.prefixIcon, + this.suffix, + this.obscureText = false, + this.enabled = true, + this.maxLines = 1, + super.key, + }); + + final String label; + final String? hint; + final String? helperText; + final String? errorText; + final TextEditingController? controller; + final TextInputType? keyboardType; + final IconData? prefixIcon; + final Widget? suffix; + final bool obscureText; + final bool enabled; + final int maxLines; + + @override + State createState() => _ThemedTextFieldState(); +} + +class _ThemedTextFieldState extends State { + final _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _focusNode.addListener(() => setState(() {})); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + /// The states the theme resolves its colours against. + Set get _states => { + if (!widget.enabled) WidgetState.disabled, + if (widget.errorText != null) WidgetState.error, + if (_focusNode.hasFocus) WidgetState.focused, + }; + + String _describe(Set states) => + states.isEmpty ? '{}' : states.map((state) => state.name).join(', '); + + @override + Widget build(BuildContext context) { + final theme = context.inputTheme; + final spacing = context.spacingTheme; + final states = _states; + + final borderColor = theme.borderColor.resolve(states); + final labelColor = theme.labelColor.resolve(states); + final isFocused = states.contains(WidgetState.focused); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.label, + style: theme.labelStyle.copyWith(color: labelColor), + ), + SizedBox(height: spacing.xs), + AnimatedContainer( + duration: theme.focusDuration, + curve: Curves.easeOut, + padding: theme.contentPadding, + decoration: BoxDecoration( + color: theme.fillColor.resolve(states), + borderRadius: theme.borderRadius, + border: Border.all( + color: borderColor ?? Colors.transparent, + width: isFocused ? theme.focusedBorderWidth : theme.borderWidth, + ), + ), + child: Row( + spacing: spacing.sm, + children: [ + if (widget.prefixIcon != null) + Icon(widget.prefixIcon, size: 20, color: labelColor), + Expanded( + child: TextField( + controller: widget.controller, + focusNode: _focusNode, + enabled: widget.enabled, + obscureText: widget.obscureText, + keyboardType: widget.keyboardType, + maxLines: widget.maxLines, + decoration: InputDecoration.collapsed( + hintText: widget.hint, + hintStyle: TextStyle(color: theme.hintColor), + ), + ), + ), + if (widget.suffix != null) widget.suffix!, + ], + ), + ), + if (widget.errorText != null || widget.helperText != null) ...[ + SizedBox(height: spacing.xs), + Text( + widget.errorText ?? widget.helperText!, + style: widget.errorText != null + ? theme.errorStyle + : theme.helperStyle, + ), + ], + SizedBox(height: spacing.xs), + Text('states: ${_describe(states)}', style: theme.helperStyle), + ], + ); + } +} diff --git a/packages/theme_extensions_builder/example/lib/pages/widgets/lerp_showcase.dart b/packages/theme_extensions_builder/example/lib/pages/widgets/lerp_showcase.dart new file mode 100644 index 0000000..2105252 --- /dev/null +++ b/packages/theme_extensions_builder/example/lib/pages/widgets/lerp_showcase.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; + +import '../../app.dart'; +import '../../theme/dark_theme.dart'; +import '../../theme/extensions/app_theme.dart'; +import '../../theme/extensions/spacing_theme.dart'; +import '../../theme/light_theme.dart'; +import 'custom_button.dart'; + +/// Shows what `lerp` produces for a nullable field at every point of a theme +/// transition. +/// +/// `AppThemeExtension.optionalBorderSide` is set in the dark theme and absent +/// in the light one, so one side of the interpolation is always null. An +/// instance of `BorderSide` cannot be interpolated with nothing, so the +/// generated code has to pick a side; this page shows where it picks it, next +/// to what the generator emitted before 7.5.0. +class LerpShowcase extends StatefulWidget { + const LerpShowcase({super.key}); + + @override + State createState() => _LerpShowcaseState(); +} + +class _LerpShowcaseState extends State + with SingleTickerProviderStateMixin { + late final _controller = AnimationController( + vsync: this, + duration: kThemeAnimationDuration, + )..addListener(() => setState(() {})); + + late final AppThemeExtension _light = lightTheme + .extension()!; + late final AppThemeExtension _dark = darkTheme + .extension()!; + + var _toDark = true; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + AppThemeExtension get _from => _toDark ? _light : _dark; + + AppThemeExtension get _to => _toDark ? _dark : _light; + + /// What the generator emits today. + AppThemeExtension get _current => + _from.lerp(_to, _controller.value) as AppThemeExtension; + + /// What the generator emitted before 7.5.0: the null side won outright, at + /// every `t` including the endpoints. + BorderSide? get _previousBorderSide { + final a = _from.optionalBorderSide; + final b = _to.optionalBorderSide; + + if (a == null) { + return b; + } + + if (b == null) { + return a; + } + + return BorderSide.lerp(a, b, _controller.value); + } + + @override + Widget build(BuildContext context) { + final spacing = context.spacingTheme; + final textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Nullable field interpolation', style: textTheme.headlineMedium), + SizedBox(height: spacing.sm), + Text( + 'optionalBorderSide is a BorderSide? that only the dark theme sets. ' + 'Drag t, or press play to run it at the real theme animation speed.', + style: textTheme.bodyMedium, + ), + SizedBox(height: spacing.lg), + SegmentedButton( + segments: const [ + ButtonSegment(value: true, label: Text('light → dark')), + ButtonSegment(value: false, label: Text('dark → light')), + ], + selected: {_toDark}, + onSelectionChanged: (selection) => + setState(() => _toDark = selection.first), + ), + SizedBox(height: spacing.md), + Row( + children: [ + IconButton.filledTonal( + onPressed: () => _controller + ..reset() + ..forward(), + icon: const Icon(Icons.play_arrow), + tooltip: 'Play the transition', + ), + Expanded( + child: Slider( + value: _controller.value, + label: _controller.value.toStringAsFixed(2), + divisions: 100, + onChanged: (value) => _controller.value = value, + ), + ), + SizedBox( + width: 56, + child: Text( + 't = ${_controller.value.toStringAsFixed(2)}', + style: textTheme.labelMedium, + ), + ), + ], + ), + SizedBox(height: spacing.md), + Row( + spacing: spacing.md, + children: [ + Expanded( + child: _BorderPreview( + label: 'Generated now', + side: _current.optionalBorderSide, + fill: _current.primaryColor, + ), + ), + Expanded( + child: _BorderPreview( + label: 'Before 7.5.0', + side: _previousBorderSide, + fill: _current.primaryColor, + ), + ), + ], + ), + SizedBox(height: spacing.lg), + Text( + 'The fill is primaryColor, a non-nullable Color: it interpolates ' + 'smoothly and is identical in both boxes. Only the border differs, ' + 'and only because one side of it is null.', + style: textTheme.bodySmall, + ), + SizedBox(height: spacing.sectionSpacing), + Text('Live theme', style: textTheme.titleLarge), + SizedBox(height: spacing.sm), + Text( + 'The same field, read from the real theme. Toggle and watch when the ' + 'border shows up: it lands halfway through the transition rather ' + 'than on its first frame.', + style: textTheme.bodyMedium, + ), + SizedBox(height: spacing.md), + _BorderPreview( + label: 'context.appTheme', + side: context.appTheme.optionalBorderSide, + fill: context.appTheme.primaryColor, + ), + SizedBox(height: spacing.md), + CustomButton( + label: 'Toggle Dark/Light Theme', + icon: Icons.brightness_6, + onPressed: () => context.appState.toggleTheme(), + ), + ], + ); + } +} + +class _BorderPreview extends StatelessWidget { + const _BorderPreview({ + required this.label, + required this.side, + required this.fill, + }); + + final String label; + final BorderSide? side; + final Color fill; + + @override + Widget build(BuildContext context) { + final spacing = context.spacingTheme; + final textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + height: 96, + decoration: BoxDecoration( + color: fill, + borderRadius: BorderRadius.circular(12), + border: side == null ? null : Border.fromBorderSide(side!), + ), + ), + SizedBox(height: spacing.sm), + Text(label, style: textTheme.labelLarge), + Text( + side == null ? 'null' : 'width ${side!.width.toStringAsFixed(1)}', + style: textTheme.bodySmall, + ), + ], + ); + } +} diff --git a/packages/theme_extensions_builder/example/lib/theme/dark_theme.dart b/packages/theme_extensions_builder/example/lib/theme/dark_theme.dart index 5685d59..2c8ed2f 100644 --- a/packages/theme_extensions_builder/example/lib/theme/dark_theme.dart +++ b/packages/theme_extensions_builder/example/lib/theme/dark_theme.dart @@ -5,6 +5,7 @@ import 'extensions/spacing_theme.dart'; import 'extensions/typography_theme.dart'; import 'extensions/widgets/button_theme.dart'; import 'extensions/widgets/card_theme.dart'; +import 'extensions/widgets/input_theme.dart'; ThemeData get darkTheme => ThemeData( brightness: .dark, @@ -18,7 +19,9 @@ ThemeData get darkTheme => ThemeData( backgroundColor: Colors.black, layoutMode: .expanded, borderSide: BorderSide.none, - optionalBorderSide: null, + // Present in the dark theme only, so the lerp showcase has a field that + // appears on one side of the transition and is absent on the other. + optionalBorderSide: BorderSide(color: Colors.tealAccent, width: 4), ), CardThemeExtension( borderRadius: const .all(.circular(16)), @@ -77,6 +80,36 @@ ThemeData get darkTheme => ThemeData( backgroundColor: .fromRGBO(33, 33, 33, 1), ), ), + const InputThemeExtension( + borderColor: WidgetStateProperty.fromMap({ + WidgetState.disabled: Colors.white12, + WidgetState.error: Colors.redAccent, + WidgetState.focused: Colors.tealAccent, + WidgetState.any: Colors.white24, + }), + fillColor: WidgetStateProperty.fromMap({ + WidgetState.disabled: Color(0x0AFFFFFF), + WidgetState.focused: Color(0x1400BFA5), + WidgetState.any: Color(0xFF121212), + }), + labelColor: WidgetStateProperty.fromMap({ + WidgetState.disabled: Colors.white38, + WidgetState.error: Colors.redAccent, + WidgetState.focused: Colors.tealAccent, + WidgetState.any: Colors.white70, + }), + borderRadius: BorderRadius.all(Radius.circular(16)), + borderWidth: 1, + focusedBorderWidth: 3, + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 18), + labelStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + helperStyle: TextStyle(fontSize: 12, color: Colors.white70), + errorStyle: TextStyle(fontSize: 12, color: Colors.redAccent), + focusDuration: Duration(milliseconds: 250), + // hintColor is left at its default null here, so it also shows a + // nullable field being interpolated: Color.lerp accepts nulls, so the + // hint fades instead of switching. + ), const SpacingThemeExtension( xs: 4, sm: 8, diff --git a/packages/theme_extensions_builder/example/lib/theme/extensions/widgets/input_theme.dart b/packages/theme_extensions_builder/example/lib/theme/extensions/widgets/input_theme.dart new file mode 100644 index 0000000..6f0122a --- /dev/null +++ b/packages/theme_extensions_builder/example/lib/theme/extensions/widgets/input_theme.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'input_theme.g.theme.dart'; + +/// Theme for text inputs. +/// +/// The colours that change with the state of the field are declared as +/// `WidgetStateProperty`, which the generator interpolates through +/// `WidgetStateProperty.lerp`. The generic has to be nullable for that: the +/// lerp function it is handed takes nullable arguments. +@themeExtensions +class InputThemeExtension extends ThemeExtension + with _$InputThemeExtension { + const InputThemeExtension({ + required this.borderColor, + required this.fillColor, + required this.labelColor, + required this.borderRadius, + required this.borderWidth, + required this.focusedBorderWidth, + required this.contentPadding, + required this.labelStyle, + required this.helperStyle, + required this.errorStyle, + required this.focusDuration, + this.hintColor, + }); + + /// Border colour per state: focused, error, disabled, or plain. + final WidgetStateProperty borderColor; + + /// Background of the field, also per state. + final WidgetStateProperty fillColor; + + /// Colour of the floating label, also per state. + final WidgetStateProperty labelColor; + + final BorderRadius borderRadius; + final double borderWidth; + final double focusedBorderWidth; + final EdgeInsets contentPadding; + final TextStyle labelStyle; + final TextStyle helperStyle; + final TextStyle errorStyle; + + /// How long the border takes to move between states. + final Duration focusDuration; + + /// Optional, so it also shows a nullable field being interpolated. + final Color? hintColor; +} diff --git a/packages/theme_extensions_builder/example/lib/theme/extensions/widgets/input_theme.g.theme.dart b/packages/theme_extensions_builder/example/lib/theme/extensions/widgets/input_theme.g.theme.dart new file mode 100644 index 0000000..d110d11 --- /dev/null +++ b/packages/theme_extensions_builder/example/lib/theme/extensions/widgets/input_theme.g.theme.dart @@ -0,0 +1,156 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'input_theme.dart'; + +// ************************************************************************** +// ThemeExtensionsGenerator +// ************************************************************************** + +mixin _$InputThemeExtension on ThemeExtension { + @override + ThemeExtension copyWith({ + WidgetStateProperty? borderColor, + WidgetStateProperty? fillColor, + WidgetStateProperty? labelColor, + BorderRadius? borderRadius, + double? borderWidth, + double? focusedBorderWidth, + EdgeInsets? contentPadding, + TextStyle? labelStyle, + TextStyle? helperStyle, + TextStyle? errorStyle, + Duration? focusDuration, + Color? hintColor, + }) { + final _this = (this as InputThemeExtension); + + return InputThemeExtension( + borderColor: borderColor ?? _this.borderColor, + fillColor: fillColor ?? _this.fillColor, + labelColor: labelColor ?? _this.labelColor, + borderRadius: borderRadius ?? _this.borderRadius, + borderWidth: borderWidth ?? _this.borderWidth, + focusedBorderWidth: focusedBorderWidth ?? _this.focusedBorderWidth, + contentPadding: contentPadding ?? _this.contentPadding, + labelStyle: labelStyle ?? _this.labelStyle, + helperStyle: helperStyle ?? _this.helperStyle, + errorStyle: errorStyle ?? _this.errorStyle, + focusDuration: focusDuration ?? _this.focusDuration, + hintColor: hintColor ?? _this.hintColor, + ); + } + + @override + ThemeExtension lerp( + ThemeExtension? other, + double t, + ) { + if (other is! InputThemeExtension) { + return this; + } + + final _this = (this as InputThemeExtension); + + return InputThemeExtension( + borderColor: WidgetStateProperty.lerp( + _this.borderColor, + other.borderColor, + t, + Color.lerp, + )!, + fillColor: WidgetStateProperty.lerp( + _this.fillColor, + other.fillColor, + t, + Color.lerp, + )!, + labelColor: WidgetStateProperty.lerp( + _this.labelColor, + other.labelColor, + t, + Color.lerp, + )!, + borderRadius: BorderRadius.lerp( + _this.borderRadius, + other.borderRadius, + t, + )!, + borderWidth: lerpDouble$(_this.borderWidth, other.borderWidth, t)!, + focusedBorderWidth: lerpDouble$( + _this.focusedBorderWidth, + other.focusedBorderWidth, + t, + )!, + contentPadding: EdgeInsets.lerp( + _this.contentPadding, + other.contentPadding, + t, + )!, + labelStyle: TextStyle.lerp(_this.labelStyle, other.labelStyle, t)!, + helperStyle: TextStyle.lerp(_this.helperStyle, other.helperStyle, t)!, + errorStyle: TextStyle.lerp(_this.errorStyle, other.errorStyle, t)!, + focusDuration: lerpDuration$( + _this.focusDuration, + other.focusDuration, + t, + )!, + hintColor: Color.lerp(_this.hintColor, other.hintColor, t), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as InputThemeExtension); + final _other = (other as InputThemeExtension); + + return _other.borderColor == _this.borderColor && + _other.fillColor == _this.fillColor && + _other.labelColor == _this.labelColor && + _other.borderRadius == _this.borderRadius && + _other.borderWidth == _this.borderWidth && + _other.focusedBorderWidth == _this.focusedBorderWidth && + _other.contentPadding == _this.contentPadding && + _other.labelStyle == _this.labelStyle && + _other.helperStyle == _this.helperStyle && + _other.errorStyle == _this.errorStyle && + _other.focusDuration == _this.focusDuration && + _other.hintColor == _this.hintColor; + } + + @override + int get hashCode { + final _this = (this as InputThemeExtension); + + return Object.hash( + runtimeType, + _this.borderColor, + _this.fillColor, + _this.labelColor, + _this.borderRadius, + _this.borderWidth, + _this.focusedBorderWidth, + _this.contentPadding, + _this.labelStyle, + _this.helperStyle, + _this.errorStyle, + _this.focusDuration, + _this.hintColor, + ); + } +} + +extension InputThemeExtensionBuildContext on BuildContext { + InputThemeExtension get inputTheme => + Theme.of(this).extension()!; +} diff --git a/packages/theme_extensions_builder/example/lib/theme/light_theme.dart b/packages/theme_extensions_builder/example/lib/theme/light_theme.dart index 08f0b9c..116d046 100644 --- a/packages/theme_extensions_builder/example/lib/theme/light_theme.dart +++ b/packages/theme_extensions_builder/example/lib/theme/light_theme.dart @@ -5,6 +5,7 @@ import 'extensions/spacing_theme.dart'; import 'extensions/typography_theme.dart'; import 'extensions/widgets/button_theme.dart'; import 'extensions/widgets/card_theme.dart'; +import 'extensions/widgets/input_theme.dart'; ThemeData get lightTheme => ThemeData( brightness: .light, @@ -93,6 +94,34 @@ ThemeData get lightTheme => ThemeData( backgroundColor: .fromRGBO(245, 245, 245, 1), ), ), + const InputThemeExtension( + borderColor: WidgetStateProperty.fromMap({ + WidgetState.disabled: Colors.black12, + WidgetState.error: Colors.redAccent, + WidgetState.focused: Colors.orange, + WidgetState.any: Colors.black26, + }), + fillColor: WidgetStateProperty.fromMap({ + WidgetState.disabled: Color(0x0A000000), + WidgetState.focused: Color(0x14FF9800), + WidgetState.any: Colors.white, + }), + labelColor: WidgetStateProperty.fromMap({ + WidgetState.disabled: Colors.black26, + WidgetState.error: Colors.redAccent, + WidgetState.focused: Colors.orange, + WidgetState.any: Colors.black54, + }), + borderRadius: BorderRadius.all(Radius.circular(8)), + borderWidth: 1, + focusedBorderWidth: 2, + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14), + labelStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + helperStyle: TextStyle(fontSize: 12, color: Colors.black54), + errorStyle: TextStyle(fontSize: 12, color: Colors.redAccent), + focusDuration: Duration(milliseconds: 150), + hintColor: Colors.black38, + ), const SpacingThemeExtension( xs: 4, sm: 8, From 6cf16c57962899d8d70a7921c39ae6ac53d5f7c7 Mon Sep 17 00:00:00 2001 From: Andrii Ivanov Date: Sat, 5 Sep 2026 16:41:21 +0300 Subject: [PATCH 06/10] Resolve theme methods through the instantiated type, tighten field collection --- .github/workflows/dart.yml | 6 +- .../theme_extensions_builder/CHANGELOG.md | 10 +- .../lib/pages/widgets/lerp_showcase.dart | 4 +- .../example/pubspec.yaml | 4 +- .../lib/src/common/analysis.dart | 155 +++++++++++------- .../lib/src/common/fields_visitor.dart | 68 ++++++-- .../lib/src/common/symbols/lerp_info.dart | 51 +++++- .../lib/src/common/symbols/merge_info.dart | 4 +- .../lib/src/generator/common.dart | 12 ++ .../theme_extensions/code_builder.dart | 48 +++--- .../src/generator/theme_gen/code_builder.dart | 48 +++--- .../test/generator/code_builder_test.dart | 40 ++++- .../test/generator/theme_gen_test.dart | 9 + .../test/runtime/symbols_test.dart | 30 +++- .../test/runtime/theme_gen_lookup_test.dart | 25 +++ .../goldens/inherited_theme.g.theme.dart | 64 ++++++++ .../goldens/lookup_theme.g.theme.dart | 28 ++++ .../widget_state_property_theme.g.theme.dart | 8 +- .../test/theme_gen/inherited_theme.dart | 53 ++++++ .../theme_gen/inherited_theme.g.theme.dart | 73 +++++++++ .../test/theme_gen/lookup_theme.dart | 84 +++++++++- .../test/theme_gen/lookup_theme.g.theme.dart | 28 ++++ .../widget_state_property_theme.dart | 9 + .../widget_state_property_theme.g.theme.dart | 8 +- 24 files changed, 703 insertions(+), 166 deletions(-) create mode 100644 packages/theme_extensions_builder/test/theme_gen/goldens/inherited_theme.g.theme.dart create mode 100644 packages/theme_extensions_builder/test/theme_gen/inherited_theme.dart create mode 100644 packages/theme_extensions_builder/test/theme_gen/inherited_theme.g.theme.dart diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 9a5db6a..9427423 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -36,11 +36,7 @@ jobs: - name: Run formatter check (builder) working-directory: packages/theme_extensions_builder - # Goldens hold byte-exact source_gen_test output, so they cannot be - # reformatted. They are excluded from the analyzer for the same reason. - run: | - dart format --set-exit-if-changed \ - $(git ls-files '*.dart' | grep -v '/goldens/') + run: dart format --set-exit-if-changed . - name: Run tests (builder) working-directory: packages/theme_extensions_builder diff --git a/packages/theme_extensions_builder/CHANGELOG.md b/packages/theme_extensions_builder/CHANGELOG.md index 8a1655a..6cc9be3 100644 --- a/packages/theme_extensions_builder/CHANGELOG.md +++ b/packages/theme_extensions_builder/CHANGELOG.md @@ -3,11 +3,11 @@ - **Fixed**: `@ThemeGen` now interpolates a nullable field whose type has an instance `lerp` method, instead of falling back to `t < 0.5 ? a : b`. - **Fixed**: An interpolation that cannot run on a null now keeps the endpoints: `lerp(a, b, 0)` returns `a`'s value and `lerp(a, b, 1)` returns `b`'s value even when the other side is null. Previously a null on the `a` side either discarded `b` entirely or was taken at any `t`. - **Fixed**: `merge` now keeps the current value when the incoming one is null. The static and instance merge branches disagreed on this; the instance behaviour was the correct one. -- **Fixed**: A nullable field whose type has a `lerp` or `merge` method with a non-nullable parameter is now guarded, instead of generating code that does not compile. -- **Fixed**: A field type declaring an unrelated `lerp` or `merge` method no longer fails the build, and is no longer mistaken for a supported one: parameter and return types are checked against the field type, a required named parameter disqualifies the method, and a non-generic method is matched with its type arguments. An unusable method is reported as a build warning. -- **Fixed**: A static `lerp` or `merge` is now called on the class rather than on an instantiation of it, so a generic field type no longer generates `Box.lerp(...)`. -- **Fixed**: A `WidgetStateProperty` field with a non-nullable generic now reports an `InvalidGenerationSourceError` pointing at the field instead of a bare `StateError`, and an unrelated generic type with a four parameter `lerp` is no longer treated as a `WidgetStateProperty`. -- **Fixed**: `Duration` is detected by element rather than by name, and a field narrowed by a subclass keeps the narrowed type in the generated code. +- **Fixed**: Several shapes that used to generate code that does not compile: a nullable field whose `lerp` or `merge` takes a non-nullable parameter is now guarded; a static `lerp` or `merge` is called on the class rather than on an instantiation of it, so a generic field type no longer generates `Box.lerp(...)`; a result is cast back to the field type only when the method returns a supertype; and a generic field type resolves its methods through the instantiated type, so `Box` matches a `lerp` declared as `Box lerp(Box other, double t)`. +- **Fixed**: A field type declaring an unrelated `lerp` or `merge` method no longer fails the build, and is no longer mistaken for a supported one: parameter and return types are checked against the field type, a required named parameter disqualifies the method, and a non-generic method is matched with its type arguments. An unusable method is reported as a build warning naming the field and what happens to it instead. +- **Fixed**: `WidgetStateProperty` fields are validated further: the generic has to offer a static `lerp` that accepts nulls, the inner call drops the generic's own type arguments, `double` and `Duration` generics are recognised by element rather than by name, an unrelated generic type with a four parameter `lerp` is no longer treated as a `WidgetStateProperty`, and a non-nullable generic reports an `InvalidGenerationSourceError` pointing at the field instead of a bare `StateError`. +- **Fixed**: Field collection follows Dart's own resolution: a field narrowed by a subclass keeps the narrowed type, a mixin wins over the superclass chain, an interface reached through `implements` contributes nothing, private and static fields are left out, and `@ignore` on a redeclaration also suppresses the inherited one. Inherited fields now come after the class' own, which changes `hashCode` values for classes that inherit fields. +- **Fixed**: `Duration` is detected by element rather than by name, so a user type of the same name is no longer interpolated as a duration. - **Updated**: Analyzer dependency to ">=13.0.0 <15.0.0" and Dart SDK constraint to ">=3.13.0 <4.0.0". - **Code Quality**: Exhaustive switches over `LerpInfo`/`MergeInfo`, `==`/`hashCode` contract fixed for the `LerpInfo` classes, dead code removed (`getMixinsNames`, `BaseClassVisitor`, unused builder options and lerp-lookup flag). diff --git a/packages/theme_extensions_builder/example/lib/pages/widgets/lerp_showcase.dart b/packages/theme_extensions_builder/example/lib/pages/widgets/lerp_showcase.dart index 2105252..f27b4de 100644 --- a/packages/theme_extensions_builder/example/lib/pages/widgets/lerp_showcase.dart +++ b/packages/theme_extensions_builder/example/lib/pages/widgets/lerp_showcase.dart @@ -50,8 +50,8 @@ class _LerpShowcaseState extends State AppThemeExtension get _current => _from.lerp(_to, _controller.value) as AppThemeExtension; - /// What the generator emitted before 7.5.0: the null side won outright, at - /// every `t` including the endpoints. + /// What the generator emitted before 7.5.0: whichever side was not null won + /// outright, at every `t` including the endpoints. BorderSide? get _previousBorderSide { final a = _from.optionalBorderSide; final b = _to.optionalBorderSide; diff --git a/packages/theme_extensions_builder/example/pubspec.yaml b/packages/theme_extensions_builder/example/pubspec.yaml index 5e7c341..a6a5d0f 100644 --- a/packages/theme_extensions_builder/example/pubspec.yaml +++ b/packages/theme_extensions_builder/example/pubspec.yaml @@ -9,12 +9,12 @@ environment: dependencies: flutter: sdk: flutter - theme_extensions_builder_annotation: ^7.3.0 + theme_extensions_builder_annotation: ^7.5.0 dev_dependencies: build_runner: ^2.15.0 pro_lints: ^6.2.0 - theme_extensions_builder: ^7.3.0 + theme_extensions_builder: ^7.5.0 flutter: uses-material-design: true diff --git a/packages/theme_extensions_builder/lib/src/common/analysis.dart b/packages/theme_extensions_builder/lib/src/common/analysis.dart index 5bff294..617e319 100644 --- a/packages/theme_extensions_builder/lib/src/common/analysis.dart +++ b/packages/theme_extensions_builder/lib/src/common/analysis.dart @@ -29,6 +29,22 @@ FieldInfo fieldSymbol( final isDouble = elementType.isDartCoreDouble; final isDuration = elementType.isDuration; + // A static field is dropped by `BaseConfig.filteredFields`, so looking up + // how to interpolate or merge it would only produce noise, or fail the + // build over a field that is never emitted. + if (element.isStatic) { + return FieldInfo( + name: name, + typeName: baseType, + isNullable: isNullable, + isDouble: isDouble, + isDuration: isDuration, + isStatic: true, + merge: const NoMerge(), + lerp: const NoLerp(), + ); + } + return FieldInfo( name: name, typeName: baseType, @@ -54,13 +70,11 @@ FieldInfo fieldSymbol( /// Throws [InvalidGenerationSourceError] for a `WidgetStateProperty` field /// with a non-nullable generic, which is a mistake we can point at. LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { - final typeElement = type.element; - - if (typeElement is! InterfaceElement) { + if (type is! InterfaceType) { return const NoLerp(); } - final method = _lookupMethod(typeElement, 'lerp'); + final method = _lookupMethod(type, 'lerp'); if (method == null) { return const NoLerp(); @@ -92,8 +106,7 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { // - first two parameters should have the same type as the class type // - third parameter should be double // - fourth parameter is a lerp function for the inner type - when type is InterfaceType && - method.isStatic && + when method.isStatic && p3.type.isDartCoreDouble && _checkSubtype(p1, type, strict: strictSignature) && _checkSubtype(p2, type, strict: strictSignature)) { @@ -143,10 +156,29 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { ); } + final genericIsDouble = innerType.isDartCoreDouble; + final genericIsDuration = innerType.isDuration; + + // Anything else is interpolated by a static lerp on the generic itself, + // which `WidgetStateProperty.lerp` calls with nullable arguments. + if (!genericIsDouble && !genericIsDuration) { + final innerLerp = _lerpInfo(innerType, fieldElement); + + if (innerLerp is! StaticLerp || + !innerLerp.optionalResult || + !innerLerp.isNullableParameter) { + _warnUnsupported('lerp', innerType, fieldElement); + + return const NoLerp(); + } + } + return WidgetStatePropertyLerp( baseTypeName: baseTypeName, genericType: innerType.baseType, isNullableGeneric: innerType.hasNullableSuffix, + genericIsDouble: genericIsDouble, + genericIsDuration: genericIsDuration, ); } @@ -166,6 +198,12 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { p3.type.isDartCoreDouble && _checkSubtype(p1, type, strict: strictSignature) && _checkSubtype(p2, type, strict: strictSignature)) { + if (!_isUsableAs(method.returnType, type, type)) { + _warnUnsupported('lerp', type, fieldElement); + + return const NoLerp(); + } + final args = _mapArgs(params); return StaticLerp( @@ -180,11 +218,23 @@ LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { when !method.isStatic && p2.type.isDartCoreDouble && _checkSubtype(p1, type, strict: strictSignature)) { + // A method declared on a supertype returns that supertype, which the + // generated code casts back to the field type. Anything else is not a + // result we can use. + final needsCast = !_isUsableAs(method.returnType, type, type); + + if (needsCast && !_isUsableAs(type, method.returnType, type)) { + _warnUnsupported('lerp', type, fieldElement); + + return const NoLerp(); + } + final args = _mapArgs(params); return InstanceLerp( optionalResult: method.returnType.hasNullableSuffix, args: args, + needsCast: needsCast, ); } @@ -204,11 +254,17 @@ void _warnUnsupported( String methodName, DartType type, FieldElement fieldElement, -) => log.warning( - 'The `$methodName` method of ${type.getDisplayString()} has an unsupported ' - 'signature, so the field `${fieldElement.displayName}` is left out of ' - '`$methodName`.', -); +) { + final fallback = methodName == 'lerp' + ? 'switches over at t = 0.5 instead of being interpolated' + : 'is overwritten instead of being merged'; + + log.warning( + 'The `$methodName` method of ${type.baseType} has an ' + 'unsupported signature, so the field `${fieldElement.displayName}` ' + '$fallback.', + ); +} /// Checks that a value of [type] can be passed to [param]. /// @@ -257,21 +313,16 @@ bool _checkSubtype( return typeSystem.isSubtypeOf(nonNullType, supertypeInstance); } -/// Checks that [method] returns something usable where [type] is expected. +/// Checks that a value of [subtype] can be used where [supertype] is expected. /// /// Nullability is ignored on both sides: a `T? merge(T other)` is still a /// merge method, the generated code just has to cope with the null. -bool _returnsSubtypeOf(MethodElement method, DartType type) { - final typeElement = type.element; - if (typeElement is! InterfaceElement) { - return false; - } - - final typeSystem = typeElement.library.typeSystem; +bool _isUsableAs(DartType subtype, DartType supertype, InterfaceType context) { + final typeSystem = context.element.library.typeSystem; return typeSystem.isSubtypeOf( - typeSystem.promoteToNonNull(method.returnType), - typeSystem.promoteToNonNull(type), + typeSystem.promoteToNonNull(subtype), + typeSystem.promoteToNonNull(supertype), ); } @@ -288,40 +339,34 @@ ParameterInfo _mapArg(FormalParameterElement parameter) { return ParameterInfo(name: name, type: type, isNullable: isNullable); } -/// Cache for method lookups to avoid repeated expensive lookups. +/// Cache for static method lookups to avoid repeated expensive lookups. +/// +/// Only the static lookup is cached: it reads the declaration off the element, +/// which is the same for every instantiation. The instance lookup goes through +/// the [InterfaceType] so that type arguments are substituted, and its result +/// differs between `Box` and `Box`. +/// /// Using Expando to avoid memory leaks - entries are automatically removed /// when InterfaceElement is garbage collected. -final _methodCache = Expando>('method_cache'); - -/// Looks up a method with the given [name] in the [typeElement]. -/// If the method is not found directly on the type, it looks up -/// inherited methods as well. -/// Results are cached to avoid repeated expensive lookups. -MethodElement? _lookupMethod(InterfaceElement typeElement, String name) { - var cache = _methodCache[typeElement]; - if (cache == null) { - cache = {}; - _methodCache[typeElement] = cache; - } - - if (cache.containsKey(name)) { - return cache[name]; - } - - final method = typeElement.getMethod(name); +final _staticMethodCache = Expando>('method_cache'); - if (method != null) { - cache[name] = method; - return method; +/// Looks up a method with the given [name] on [type]. +/// +/// Instance methods are resolved against the instantiated type, including +/// inherited ones, so their parameter and return types have the type +/// arguments of [type] substituted in. Static methods are neither inherited +/// nor substituted, so they are read off the element. +MethodElement? _lookupMethod(InterfaceType type, String name) { + final instanceMethod = type.lookUpMethod(name, type.element.library); + + if (instanceMethod != null) { + return instanceMethod; } - final inheritedMethod = typeElement.lookUpInheritedMethod( - methodName: name, - library: typeElement.library, - ); + final typeElement = type.element; + final cache = _staticMethodCache[typeElement] ??= {}; - cache[name] = inheritedMethod; - return inheritedMethod; + return cache.putIfAbsent(name, () => typeElement.getMethod(name)); } /// Gets information about the merge method for the given [type]. @@ -329,12 +374,12 @@ MethodElement? _lookupMethod(InterfaceElement typeElement, String name) { /// Returns [NoMerge] when the type is not an interface, has no merge method, /// or declares one whose signature we cannot call. MergeInfo _mergeInfo(DartType type, FieldElement fieldElement) { - final typeElement = type.element; - - if (typeElement is! InterfaceElement) { + if (type is! InterfaceType) { return const NoMerge(); } + final typeElement = type.element; + // Check if element or its supertypes have @ThemeGen annotation. // Using the annotation implies that the merge method exists, as it is // impossible to get information about the merge method during the build @@ -345,7 +390,7 @@ MergeInfo _mergeInfo(DartType type, FieldElement fieldElement) { return const InstanceMerge(); } - final method = _lookupMethod(typeElement, 'merge'); + final method = _lookupMethod(type, 'merge'); if (method == null) { return const NoMerge(); } @@ -374,7 +419,7 @@ MergeInfo _mergeInfo(DartType type, FieldElement fieldElement) { when method.isStatic && _checkSubtype(p1, type, strict: strictSignature) && _checkSubtype(p2, type, strict: strictSignature) && - _returnsSubtypeOf(method, type)) { + _isUsableAs(method.returnType, type, type)) { return const StaticMerge(); } @@ -385,7 +430,7 @@ MergeInfo _mergeInfo(DartType type, FieldElement fieldElement) { // - the result should be usable as the class type when !method.isStatic && _checkSubtype(p1, type, strict: strictSignature) && - _returnsSubtypeOf(method, type)) { + _isUsableAs(method.returnType, type, type)) { return InstanceMerge(isNullableParameter: p1.type.hasNullableSuffix); } diff --git a/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart b/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart index ae70b89..523c71a 100644 --- a/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart +++ b/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart @@ -1,4 +1,5 @@ import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; import 'package:analyzer/dart/element/visitor2.dart'; import 'package:source_gen/source_gen.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; @@ -7,13 +8,16 @@ import 'analysis.dart'; import 'fields_visitor_config.dart'; import 'symbols/field_info.dart'; -/// Collects the fields of [element] together with the fields of all its -/// supertypes. +/// Collects the fields of [element] together with the fields it inherits. /// -/// The first declaration of a name wins, so [element] is visited before its -/// supertypes, and supertypes are visited nearest first. A field redeclared -/// by a subclass therefore keeps the subclass' type instead of being -/// overwritten by the inherited declaration. +/// Only the superclass chain and the applied mixins contribute: an interface +/// reached through `implements` has to be satisfied by [element] itself, so +/// its declarations would shadow nothing and cannot be constructed. +/// +/// Types are visited in Dart's own resolution order — the class first, then +/// its mixins from last applied to first, then the superclass chain — and the +/// first declaration of a name wins, so the declaration actually in effect is +/// the one that is collected. /// /// The [config] controls what information is collected for each field. List collectFields( @@ -24,15 +28,28 @@ List collectFields( element.visitChildren(visitor); - for (final supertype in element.allSupertypes) { - if (!supertype.isDartCoreObject) { - supertype.element.visitChildren(visitor); - } + for (final inherited in _inheritedTypes(element.thisType)) { + inherited.element.visitChildren(visitor); } return visitor.fields; } +/// Yields the types [type] inherits members from, nearest first. +Iterable _inheritedTypes(InterfaceType type) sync* { + // A mixin is applied on top of the superclass, so a member it declares wins + // over the same member further up the chain. The last mixin applied wins + // over the ones before it. + yield* type.mixins.reversed; + + final superclass = type.superclass; + + if (superclass != null && !superclass.isDartCoreObject) { + yield superclass; + yield* _inheritedTypes(superclass); + } +} + /// A visitor that collects field information from a class element. /// /// This visitor traverses class elements and extracts information about their @@ -68,6 +85,12 @@ class FieldsVisitor extends SimpleElementVisitor2 { /// makes the nearest declaration the first one. final Map _fields = {}; + /// Names already decided on, including the ones that were skipped. + /// + /// An `@ignore` on a redeclaration has to suppress the inherited + /// declaration too, so a skipped name still claims its place. + final Set _claimed = {}; + /// Returns an immutable list of collected field information. /// /// The list is created from the internal map, preserving the order in which @@ -89,17 +112,28 @@ class FieldsVisitor extends SimpleElementVisitor2 { /// not be included in the collected field information. @override void visitFieldElement(FieldElement element) { + // Only process non-synthetic fields (explicitly declared in source code) + if (!element.isOriginDeclaration) { + return; + } + + // A private field cannot be passed to a generated constructor call, and a + // private name is not a valid named parameter either. + if (element.isPrivate) { + return; + } + + final name = element.displayName; + + if (!_claimed.add(name)) { + return; + } + // Skip fields annotated with @ignore if (ignoreAnnotationTypeChecker.hasAnnotationOf(element)) { return; } - // Only process non-synthetic fields (explicitly declared in source code) - if (element.isOriginDeclaration) { - _fields.putIfAbsent( - element.displayName, - () => fieldSymbol(element, config: config), - ); - } + _fields[name] = fieldSymbol(element, config: config); } } diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart index 87055ce..ee5c424 100644 --- a/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart +++ b/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart @@ -29,8 +29,7 @@ final class StaticLerp extends LerpInfo { /// Returns `true` if the lerp method accepts nullable arguments. /// - /// A method that doesn't has to be guarded against null arguments at the - /// call site. + /// When it doesn't, the call site has to guard against a null itself. bool get isNullableParameter => args.length >= 2 && args[0].isNullable && args[1].isNullable; @@ -57,7 +56,11 @@ final class StaticLerp extends LerpInfo { /// `T lerp(T other, double t)` final class InstanceLerp extends LerpInfo { /// Creates an [InstanceLerp] with the specified properties. - const InstanceLerp({required this.optionalResult, required this.args}); + const InstanceLerp({ + required this.optionalResult, + required this.args, + this.needsCast = false, + }); /// The parameters of the lerp method. final List args; @@ -65,21 +68,35 @@ final class InstanceLerp extends LerpInfo { /// Whether the return type of the lerp method is nullable. final bool optionalResult; + /// Whether the result has to be cast back to the field type. + /// + /// A method declared on a supertype returns that supertype: the generated + /// `lerp` of a theme extension returns `ThemeExtension`, not `T`. A + /// method that already returns the field type needs no cast, and adding one + /// would trip `unnecessary_cast` in the generated file. + final bool needsCast; + @override bool operator ==(Object other) => identical(this, other) || other is InstanceLerp && runtimeType == other.runtimeType && optionalResult == other.optionalResult && + needsCast == other.needsCast && _listEquality.equals(args, other.args); @override - int get hashCode => - Object.hash(runtimeType, optionalResult, _listEquality.hash(args)); + int get hashCode => Object.hash( + runtimeType, + optionalResult, + needsCast, + _listEquality.hash(args), + ); @override String toString() => - 'InstanceLerp(optionalResult: $optionalResult, args: $args)'; + 'InstanceLerp(optionalResult: $optionalResult, ' + 'needsCast: $needsCast, args: $args)'; } final class WidgetStatePropertyLerp extends LerpInfo { @@ -88,6 +105,8 @@ final class WidgetStatePropertyLerp extends LerpInfo { required this.baseTypeName, required this.genericType, required this.isNullableGeneric, + required this.genericIsDouble, + required this.genericIsDuration, }); /// The base type name without generics. @@ -100,9 +119,22 @@ final class WidgetStatePropertyLerp extends LerpInfo { final bool isNullableGeneric; - bool get genericIsDouble => genericType == 'double'; + /// Whether the generic is `double` from `dart:core`. + final bool genericIsDouble; - bool get genericIsDuration => genericType == 'Duration'; + /// Whether the generic is `Duration` from `dart:core`. + final bool genericIsDuration; + + /// The generic without its own type arguments. + /// + /// The inner lerp is reached through the class, so a generic generic — + /// `WidgetStateProperty?>` — has to call `Box.lerp`, not + /// `Box.lerp`. + String get genericBaseTypeName { + final index = genericType.indexOf('<'); + + return index == -1 ? genericType : genericType.substring(0, index); + } @override bool operator ==(Object other) => @@ -123,6 +155,9 @@ final class WidgetStatePropertyLerp extends LerpInfo { 'baseTypeName: $baseTypeName, ' 'genericType: $genericType, ' 'isNullableGeneric: $isNullableGeneric)'; + + // genericIsDouble and genericIsDuration follow from genericType, so they + // take no part in equality. } /// Indicates that no lerp method is available for the field type. diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart index f6cb344..9a54bd0 100644 --- a/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart +++ b/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart @@ -50,8 +50,8 @@ final class InstanceMerge extends MergeInfo { /// Whether the merge method accepts a nullable argument. /// - /// Methods generated by `@ThemeGen` do, so this defaults to `true`. A method - /// that doesn't has to be guarded against a null `other` at the call site. + /// Methods generated by `@ThemeGen` do, so this defaults to `true`. When a + /// method doesn't, the call site has to guard against a null `other`. final bool isNullableParameter; @override diff --git a/packages/theme_extensions_builder/lib/src/generator/common.dart b/packages/theme_extensions_builder/lib/src/generator/common.dart index 47d7795..b3c2f97 100644 --- a/packages/theme_extensions_builder/lib/src/generator/common.dart +++ b/packages/theme_extensions_builder/lib/src/generator/common.dart @@ -121,6 +121,18 @@ Method hashMethod(BaseConfig config) => Method((m) { }); }); +/// Wraps [lerpCall] so that it only runs when both sides are present. +/// +/// An interpolation that cannot accept a null falls back to the value the +/// timeline is closest to, which keeps `t == 0` on [a] and `t == 1` on [b]. +Expression nullGuardedLerp(Expression a, Expression b, Expression lerpCall) => a + .equalTo(literalNull) + .or(b.equalTo(literalNull)) + .conditional( + 't'.ref.lessThan(literalNum(0.5)).conditional(a, b), + lerpCall, + ); + /// Generates an if statement as code. /// /// Creates a code block with the given [condition], executing [ifBlock] when diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart index 4ba3557..da78418 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart @@ -207,48 +207,53 @@ Method lerpMethod(ThemeExtensionsConfig config) => Method((m) { // _this.side == null || other.side == null // ? (t < 0.5 ? _this.side : other.side) // : Side.lerp(_this.side!, other.side!, t) - args[field.name] = _nullGuardedLerp( + args[field.name] = nullGuardedLerp( tProp, oProp, sLerp([tProp.nullChecked, oProp.nullChecked, 't'.ref]), ); - // Handle InstanceLerp with an optional field, returning an - // optional result that needs no cast - case InstanceLerp(optionalResult: true) when field.isNullable: + // Handle InstanceLerp with an optional field, returning a supertype + case InstanceLerp(needsCast: true) when field.isNullable: // _this.field == null || other.field == null // ? (t < 0.5 ? _this.field : other.field) - // : _this.field!.lerp(other.field!, t) - args[field.name] = _nullGuardedLerp( + // : _this.field!.lerp(other.field!, t) as FieldType? + args[field.name] = nullGuardedLerp( tProp, oProp, - tProp.nullChecked.property('lerp')([oProp.nullChecked, 't'.ref]), + tProp.nullChecked + .property('lerp')([oProp.nullChecked, 't'.ref]) + .asA(field.typeName.typeRef(isNullable: true)), ); // Handle InstanceLerp with an optional field case InstanceLerp() when field.isNullable: // _this.field == null || other.field == null // ? (t < 0.5 ? _this.field : other.field) - // : _this.field!.lerp(other.field!, t) as FieldType? - args[field.name] = _nullGuardedLerp( + // : _this.field!.lerp(other.field!, t) + args[field.name] = nullGuardedLerp( tProp, oProp, - tProp.nullChecked - .property('lerp')([oProp.nullChecked, 't'.ref]) - .asA(field.typeName.typeRef(isNullable: true)), + tProp.nullChecked.property('lerp')([oProp.nullChecked, 't'.ref]), ); - // Handle InstanceLerp with non-optional field - case InstanceLerp(): + // Handle InstanceLerp returning a supertype, non-optional field + case InstanceLerp(needsCast: true): // _this.field.lerp(other.field, t) as FieldType args[field.name] = tProp .prop('lerp')([oProp, 't'.ref]) .asA(field.typeName.typeRef()); + // Handle InstanceLerp with non-optional field + case InstanceLerp(): + // _this.field.lerp(other.field, t) + args[field.name] = tProp.prop('lerp')([oProp, 't'.ref]); + // Handle WidgetStateProperty lerp with inner lerp function case WidgetStatePropertyLerp( :final baseTypeName, :final genericType, + :final genericBaseTypeName, :final isNullableGeneric, :final genericIsDouble, :final genericIsDuration, @@ -258,7 +263,7 @@ Method lerpMethod(ThemeExtensionsConfig config) => Method((m) { ? r'lerpDouble$'.ref : genericIsDuration ? r'lerpDuration$'.ref - : genericType.ref.prop('lerp'); + : genericBaseTypeName.ref.prop('lerp'); // WidgetStateProperty.lerp( // _this.field, @@ -293,19 +298,6 @@ Method lerpMethod(ThemeExtensionsConfig config) => Method((m) { }); }); -/// Wraps [lerpCall] so that it only runs when both sides are present. -/// -/// An interpolation that cannot accept a null falls back to the value the -/// timeline is closest to, which keeps `t == 0` on [a] and `t == 1` on [b]. -Expression _nullGuardedLerp(Expression a, Expression b, Expression lerpCall) => - a - .equalTo(literalNull) - .or(b.equalTo(literalNull)) - .conditional( - 't'.ref.lessThan(literalNum(0.5)).conditional(a, b), - lerpCall, - ); - // Returns a type reference for `ThemeExtension` based on [config]. TypeReference _buildThemeExtensionRef( ThemeExtensionsConfig config, { diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart index 3ef3793..052dd6a 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart @@ -98,19 +98,6 @@ Method copyWith(ThemeGenConfig config) => Method((m) { }); }); -/// Wraps [lerpCall] so that it only runs when both sides are present. -/// -/// An interpolation that cannot accept a null falls back to the value the -/// timeline is closest to, which keeps `t == 0` on [a] and `t == 1` on [b]. -Expression _nullGuardedLerp(Expression a, Expression b, Expression lerpCall) => - a - .equalTo(literalNull) - .or(b.equalTo(literalNull)) - .conditional( - 't'.ref.lessThan(literalNum(0.5)).conditional(a, b), - lerpCall, - ); - /// Generates a `merge` method for the theme class. Method merge(ThemeGenConfig config) => Method((m) { m @@ -332,48 +319,53 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { // value: a.field == null || b.field == null // ? (t < 0.5 ? a.field : b.field) // : Class.lerp(a.field!, b.field!, t) - argsResult[field.name] = _nullGuardedLerp( + argsResult[field.name] = nullGuardedLerp( aProp, bProp, lerp([aProp.nullChecked, bProp.nullChecked, 't'.ref]), ); - // Instance lerp with a nullable field, returning an optional - // result that needs no cast - case InstanceLerp(optionalResult: true) when field.isNullable: + // Instance lerp with a nullable field, returning a supertype + case InstanceLerp(needsCast: true) when field.isNullable: // value: a.field == null || b.field == null // ? (t < 0.5 ? a.field : b.field) - // : a.field!.lerp(b.field!, t) - argsResult[field.name] = _nullGuardedLerp( + // : a.field!.lerp(b.field!, t) as Class? + argsResult[field.name] = nullGuardedLerp( aProp, bProp, - aProp.nullChecked.property('lerp')([bProp.nullChecked, 't'.ref]), + aProp.nullChecked + .property('lerp')([bProp.nullChecked, 't'.ref]) + .asA(field.typeName.typeRef(isNullable: true)), ); // Instance lerp with a nullable field case InstanceLerp() when field.isNullable: // value: a.field == null || b.field == null // ? (t < 0.5 ? a.field : b.field) - // : a.field!.lerp(b.field!, t) as Class? - argsResult[field.name] = _nullGuardedLerp( + // : a.field!.lerp(b.field!, t) + argsResult[field.name] = nullGuardedLerp( aProp, bProp, - aProp.nullChecked - .property('lerp')([bProp.nullChecked, 't'.ref]) - .asA(field.typeName.typeRef(isNullable: true)), + aProp.nullChecked.property('lerp')([bProp.nullChecked, 't'.ref]), ); - // Instance lerp method with non-nullable field - case InstanceLerp(): + // Instance lerp returning a supertype, non-nullable field + case InstanceLerp(needsCast: true): // value: a.field.lerp(b.field, t) as Class argsResult[field.name] = aProp .prop('lerp')([bProp, 't'.ref]) .asA(field.typeName.typeRef()); + // Instance lerp method with non-nullable field + case InstanceLerp(): + // value: a.field.lerp(b.field, t) + argsResult[field.name] = aProp.prop('lerp')([bProp, 't'.ref]); + // WidgetStateProperty lerp with inner lerp function case WidgetStatePropertyLerp( :final baseTypeName, :final genericType, + :final genericBaseTypeName, :final isNullableGeneric, :final genericIsDouble, :final genericIsDuration, @@ -383,7 +375,7 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { ? r'lerpDouble$'.ref : genericIsDuration ? r'lerpDuration$'.ref - : genericType.ref.prop('lerp'); + : genericBaseTypeName.ref.prop('lerp'); // WidgetStateProperty.lerp( // a.field, diff --git a/packages/theme_extensions_builder/test/generator/code_builder_test.dart b/packages/theme_extensions_builder/test/generator/code_builder_test.dart index 4183d32..95690e6 100644 --- a/packages/theme_extensions_builder/test/generator/code_builder_test.dart +++ b/packages/theme_extensions_builder/test/generator/code_builder_test.dart @@ -47,20 +47,42 @@ void main() { _field( 'value', typeName: 'Lerpable', - lerp: const InstanceLerp(optionalResult: true, args: [_nullableArg]), + lerp: const InstanceLerp( + optionalResult: true, + args: [_nullableArg], + needsCast: true, + ), ), ]); expect(code, contains('(a.value.lerp(b.value, t) as Lerpable)')); }); + test('a result that already has the field type is not cast', () { + final code = _generate([ + _field( + 'value', + typeName: 'Lerpable', + isNullable: true, + lerp: const InstanceLerp(optionalResult: true, args: [_nullableArg]), + ), + ]); + + expect(code, contains('a.value!.lerp(b.value!, t)')); + expect(code, isNot(contains('as Lerpable?'))); + }); + test('instance lerp on a nullable field keeps the endpoints', () { final code = _generate([ _field( 'value', typeName: 'Lerpable', isNullable: true, - lerp: const InstanceLerp(optionalResult: false, args: [_nullableArg]), + lerp: const InstanceLerp( + optionalResult: false, + args: [_nullableArg], + needsCast: true, + ), ), ]); @@ -114,6 +136,7 @@ void main() { lerp: const InstanceLerp( optionalResult: false, args: [_nonNullableArg], + needsCast: true, ), ), ]); @@ -128,6 +151,19 @@ void main() { ); }); + test('a theme extension does not cast a result of the field type', () { + final code = _generateExtension([ + _field( + 'value', + typeName: 'Lerpable', + lerp: const InstanceLerp(optionalResult: false, args: [_nullableArg]), + ), + ]); + + expect(code, contains('value: _this.value.lerp(other.value, t)')); + expect(code, isNot(contains('as Lerpable'))); + }); + test('a static call receiver drops the type arguments', () { final code = _generate([ _field( diff --git a/packages/theme_extensions_builder/test/generator/theme_gen_test.dart b/packages/theme_extensions_builder/test/generator/theme_gen_test.dart index 5e18a4c..229c30b 100644 --- a/packages/theme_extensions_builder/test/generator/theme_gen_test.dart +++ b/packages/theme_extensions_builder/test/generator/theme_gen_test.dart @@ -43,4 +43,13 @@ Future main() async { group('MethodLookup', () { testAnnotatedElements(lookupReader, generator); }); + + final inheritedReader = await initializeLibraryReaderForDirectory( + 'test/theme_gen', + 'inherited_theme.dart', + ); + + group('Inheritance', () { + testAnnotatedElements(inheritedReader, generator); + }); } diff --git a/packages/theme_extensions_builder/test/runtime/symbols_test.dart b/packages/theme_extensions_builder/test/runtime/symbols_test.dart index 55b9da1..07c94ef 100644 --- a/packages/theme_extensions_builder/test/runtime/symbols_test.dart +++ b/packages/theme_extensions_builder/test/runtime/symbols_test.dart @@ -175,7 +175,22 @@ void main() { test('toString returns correct format', () { const lerp = InstanceLerp(optionalResult: false, args: []); - expect(lerp.toString(), 'InstanceLerp(optionalResult: false, args: [])'); + expect( + lerp.toString(), + 'InstanceLerp(optionalResult: false, needsCast: false, args: [])', + ); + }); + + test('needsCast takes part in equality', () { + final plain = InstanceLerp(optionalResult: true, args: []); + final cast = InstanceLerp( + optionalResult: true, + args: [], + needsCast: true, + ); + + expect(plain, isNot(equals(cast))); + expect(plain.hashCode, isNot(equals(cast.hashCode))); }); }); @@ -183,17 +198,26 @@ void main() { WidgetStatePropertyLerp build({ String genericType = 'Color', bool isNullableGeneric = true, + bool genericIsDouble = false, + bool genericIsDuration = false, }) => WidgetStatePropertyLerp( baseTypeName: 'WidgetStateProperty', genericType: genericType, isNullableGeneric: isNullableGeneric, + genericIsDouble: genericIsDouble, + genericIsDuration: genericIsDuration, ); test('reports the generic type', () { expect(build().genericIsDouble, isFalse); expect(build().genericIsDuration, isFalse); - expect(build(genericType: 'double').genericIsDouble, isTrue); - expect(build(genericType: 'Duration').genericIsDuration, isTrue); + expect(build(genericIsDouble: true).genericIsDouble, isTrue); + expect(build(genericIsDuration: true).genericIsDuration, isTrue); + }); + + test('the inner lerp receiver drops the type arguments', () { + expect(build().genericBaseTypeName, 'Color'); + expect(build(genericType: 'Box').genericBaseTypeName, 'Box'); }); test('equality works correctly', () { diff --git a/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart index 4f08faa..e574daf 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart @@ -13,6 +13,10 @@ void main() { mode: Mode(1), pair: Pair(1), strict: Strict(1), + box: Box(1), + special: Special(1), + fade: Fade(1), + ratio: Ratio(1), narrowed: 1, ); @@ -26,6 +30,10 @@ void main() { mode: Mode(2), pair: Pair(2), strict: Strict(2), + box: Box(2), + special: Special(2), + fade: Fade(2), + ratio: Ratio(2), narrowed: 2, ); @@ -54,6 +62,19 @@ void main() { expect(a.merge(b).strict, same(b.strict)); }); + test('a generic type resolves its methods through the instantiation', () { + expect(LookupTheme.lerp(a, b, 0.5)!.box.value, same(b.box.value)); + expect(a.merge(b).box.value, same(b.box.value)); + }); + + test('an inherited lerp returning a supertype is cast back', () { + expect(LookupTheme.lerp(a, b, 0.5)!.special, isA()); + }); + + test('a lerp returning an unrelated type is ignored', () { + expect(LookupTheme.lerp(a, b, 0.4)!.fade, same(a.fade)); + }); + test('a field narrowed by a superclass keeps the narrowed type', () { expect(a.copyWith(narrowed: 7).narrowed, 7); }); @@ -69,6 +90,10 @@ void main() { mode: Mode(1), pair: Pair(1), strict: Strict(1), + box: Box(1), + special: Special(1), + fade: Fade(1), + ratio: Ratio(1), narrowed: 1, ); diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/inherited_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/goldens/inherited_theme.g.theme.dart new file mode 100644 index 0000000..a3bcc8e --- /dev/null +++ b/packages/theme_extensions_builder/test/theme_gen/goldens/inherited_theme.g.theme.dart @@ -0,0 +1,64 @@ +part of '../inherited_theme.dart'; + +mixin _$InheritedTheme { + bool get canMerge => true; + + static InheritedTheme? lerp(InheritedTheme? a, InheritedTheme? b, double t) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return InheritedTheme(inherited: t < 0.5 ? a.inherited : b.inherited); + } + + InheritedTheme copyWith({int? inherited}) { + final _this = (this as InheritedTheme); + + return InheritedTheme(inherited: inherited ?? _this.inherited); + } + + InheritedTheme merge(InheritedTheme? other) { + final _this = (this as InheritedTheme); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(inherited: other.inherited); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as InheritedTheme); + final _other = (other as InheritedTheme); + + return _other.inherited == _this.inherited; + } + + @override + int get hashCode { + final _this = (this as InheritedTheme); + + return Object.hash(runtimeType, _this.inherited); + } +} diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart index 22ebde0..7efb151 100644 --- a/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart @@ -26,6 +26,14 @@ mixin _$LookupTheme { mode: t < 0.5 ? a.mode : b.mode, pair: t < 0.5 ? a.pair : b.pair, strict: t < 0.5 ? a.strict : b.strict, + box: a.box.lerp(b.box, t), + special: a.special == null || b.special == null + ? t < 0.5 + ? a.special + : b.special + : (a.special!.lerp(b.special!, t) as Special?), + fade: t < 0.5 ? a.fade : b.fade, + ratio: t < 0.5 ? a.ratio : b.ratio, narrowed: t < 0.5 ? a.narrowed : b.narrowed, ); } @@ -40,6 +48,10 @@ mixin _$LookupTheme { Mode? mode, Pair? pair, Strict? strict, + Box? box, + Special? special, + Fade? fade, + Ratio? ratio, int? narrowed, }) { final _this = (this as LookupTheme); @@ -54,6 +66,10 @@ mixin _$LookupTheme { mode: mode ?? _this.mode, pair: pair ?? _this.pair, strict: strict ?? _this.strict, + box: box ?? _this.box, + special: special ?? _this.special, + fade: fade ?? _this.fade, + ratio: ratio ?? _this.ratio, narrowed: narrowed ?? _this.narrowed, ); } @@ -83,6 +99,10 @@ mixin _$LookupTheme { mode: other.mode, pair: other.pair, strict: other.strict, + box: _this.box.merge(other.box), + special: other.special, + fade: other.fade, + ratio: other.ratio, narrowed: other.narrowed, ); } @@ -109,6 +129,10 @@ mixin _$LookupTheme { _other.mode == _this.mode && _other.pair == _this.pair && _other.strict == _this.strict && + _other.box == _this.box && + _other.special == _this.special && + _other.fade == _this.fade && + _other.ratio == _this.ratio && _other.narrowed == _this.narrowed; } @@ -127,6 +151,10 @@ mixin _$LookupTheme { _this.mode, _this.pair, _this.strict, + _this.box, + _this.special, + _this.fade, + _this.ratio, _this.narrowed, ); } diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/widget_state_property_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/goldens/widget_state_property_theme.g.theme.dart index 4a77bf0..4b3c99c 100644 --- a/packages/theme_extensions_builder/test/theme_gen/goldens/widget_state_property_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/goldens/widget_state_property_theme.g.theme.dart @@ -52,6 +52,7 @@ mixin _$WidgetStatePropertyTheme { t, lerpDuration$, ), + label: t < 0.5 ? a.label : b.label, ); } @@ -62,6 +63,7 @@ mixin _$WidgetStatePropertyTheme { WidgetStateProperty? optionalColor, WidgetStateProperty? optionalWidth, WidgetStateProperty? optionalDuration, + WidgetStateProperty? label, }) { final _this = (this as WidgetStatePropertyTheme); @@ -72,6 +74,7 @@ mixin _$WidgetStatePropertyTheme { optionalColor: optionalColor ?? _this.optionalColor, optionalWidth: optionalWidth ?? _this.optionalWidth, optionalDuration: optionalDuration ?? _this.optionalDuration, + label: label ?? _this.label, ); } @@ -93,6 +96,7 @@ mixin _$WidgetStatePropertyTheme { optionalColor: other.optionalColor, optionalWidth: other.optionalWidth, optionalDuration: other.optionalDuration, + label: other.label, ); } @@ -114,7 +118,8 @@ mixin _$WidgetStatePropertyTheme { _other.duration == _this.duration && _other.optionalColor == _this.optionalColor && _other.optionalWidth == _this.optionalWidth && - _other.optionalDuration == _this.optionalDuration; + _other.optionalDuration == _this.optionalDuration && + _other.label == _this.label; } @override @@ -129,6 +134,7 @@ mixin _$WidgetStatePropertyTheme { _this.optionalColor, _this.optionalWidth, _this.optionalDuration, + _this.label, ); } } diff --git a/packages/theme_extensions_builder/test/theme_gen/inherited_theme.dart b/packages/theme_extensions_builder/test/theme_gen/inherited_theme.dart new file mode 100644 index 0000000..e351965 --- /dev/null +++ b/packages/theme_extensions_builder/test/theme_gen/inherited_theme.dart @@ -0,0 +1,53 @@ +import 'package:source_gen_test/source_gen_test.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'inherited_theme.g.theme.dart'; + +/// Satisfied by [InheritedTheme] itself, so nothing it declares may be +/// collected: an `implements` clause carries no state to construct. +abstract class HasVersion { + int get version; +} + +/// Declares the fields [InheritedTheme] inherits. +class BaseTheme { + const BaseTheme({required this.inherited, required this.replaced}); + + final int inherited; + final String? replaced; +} + +/// Theme covering the field collection rules: an inherited field is kept, a +/// private one is not a valid named argument so it is left out, `@ignore` on a +/// redeclaration also drops the inherited declaration, and an interface +/// contributes nothing. +@ShouldGenerateFile( + 'goldens/inherited_theme.g.theme.dart', + partOfCurrent: true, +) +@themeGen +final class InheritedTheme extends BaseTheme + with _$InheritedTheme + implements HasVersion { + InheritedTheme({required super.inherited}) : super(replaced: null); + + final _hidden = 0; + + @ignore + @override + // Redeclaring the inherited field is the point of this fixture. + // ignore: overridden_fields + final String? replaced = null; + + @override + int get version => _hidden; + + @override + bool get canMerge => true; + + static InheritedTheme? lerp( + InheritedTheme? a, + InheritedTheme? b, + double t, + ) => _$InheritedTheme.lerp(a, b, t); +} diff --git a/packages/theme_extensions_builder/test/theme_gen/inherited_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/inherited_theme.g.theme.dart new file mode 100644 index 0000000..cd37b73 --- /dev/null +++ b/packages/theme_extensions_builder/test/theme_gen/inherited_theme.g.theme.dart @@ -0,0 +1,73 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'inherited_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$InheritedTheme { + bool get canMerge => true; + + static InheritedTheme? lerp(InheritedTheme? a, InheritedTheme? b, double t) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return InheritedTheme(inherited: t < 0.5 ? a.inherited : b.inherited); + } + + InheritedTheme copyWith({int? inherited}) { + final _this = (this as InheritedTheme); + + return InheritedTheme(inherited: inherited ?? _this.inherited); + } + + InheritedTheme merge(InheritedTheme? other) { + final _this = (this as InheritedTheme); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(inherited: other.inherited); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as InheritedTheme); + final _other = (other as InheritedTheme); + + return _other.inherited == _this.inherited; + } + + @override + int get hashCode { + final _this = (this as InheritedTheme); + + return Object.hash(runtimeType, _this.inherited); + } +} diff --git a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart index 4dd7fca..709a6b2 100644 --- a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart @@ -90,6 +90,50 @@ class Strict { Strict merge(Strict other, {required bool deep}) => other; } +/// Generic class whose `lerp` and `merge` are declared with the class' own +/// type parameter, so they only match once the type arguments are substituted. +class Box { + const Box(this.value); + + final T value; + + Box lerp(Box other, double t) => other; + + Box merge(Box other) => other; +} + +/// Declares the `lerp` that [Special] inherits, and returns this supertype. +class Animatable { + const Animatable(this.value); + + final int value; + + Animatable? lerp(Animatable other, double t) => other; +} + +/// Uses the inherited `lerp`, whose result has to be cast back. +class Special extends Animatable { + const Special(super.value); +} + +/// A `lerp` returning something unrelated to the class it is declared on. +class Fade { + const Fade(this.value); + + final double value; + + double? lerp(Fade? other, double t) => value; +} + +/// A static `lerp` whose result cannot stand in for the class. +class Ratio { + const Ratio(this.value); + + final double value; + + static double? lerp(Ratio? a, Ratio? b, double t) => a?.value; +} + /// Base class declaring a field that [LookupTheme]'s superclass narrows. class Base { const Base({required this.narrowed}); @@ -108,33 +152,45 @@ class Middle extends Base { final int narrowed; } +const _lerpFallback = 'switches over at t = 0.5 instead of being interpolated.'; + +const _mergeFallback = 'is overwritten instead of being merged.'; + const _curveWarning = 'The `lerp` method of Curve has an unsupported signature, so the field ' - '`curve` is left out of `lerp`.'; + '`curve` $_lerpFallback'; const _flagsWarning = 'The `merge` method of Flags has an unsupported signature, so the field ' - '`flags` is left out of `merge`.'; + '`flags` $_mergeFallback'; const _clampedWarning = 'The `lerp` method of Clamped has an unsupported signature, so the field ' - '`clamped` is left out of `lerp`.'; + '`clamped` $_lerpFallback'; const _unrelatedWarning = 'The `merge` method of Unrelated has an unsupported signature, so the ' - 'field `unrelated` is left out of `merge`.'; + 'field `unrelated` $_mergeFallback'; const _modeWarning = 'The `lerp` method of Mode has an unsupported signature, so the field ' - '`mode` is left out of `lerp`.'; + '`mode` $_lerpFallback'; const _pairWarning = 'The `lerp` method of Pair has an unsupported signature, so the field ' - '`pair` is left out of `lerp`.'; + '`pair` $_lerpFallback'; const _strictWarning = 'The `merge` method of Strict has an unsupported signature, so the field ' - '`strict` is left out of `merge`.'; + '`strict` $_mergeFallback'; + +const _fadeWarning = + 'The `lerp` method of Fade has an unsupported signature, so the field ' + '`fade` $_lerpFallback'; + +const _ratioWarning = + 'The `lerp` method of Ratio has an unsupported signature, so the field ' + '`ratio` $_lerpFallback'; /// Theme whose field types are inspected by method lookup rather than by an /// annotation: only [Settings] offers a signature the generator can call. @@ -149,6 +205,8 @@ const _strictWarning = _modeWarning, _pairWarning, _strictWarning, + _fadeWarning, + _ratioWarning, ], ) @themeGen @@ -163,9 +221,17 @@ final class LookupTheme extends Middle with _$LookupTheme { required this.mode, required this.pair, required this.strict, + required this.box, + required this.special, + required this.fade, + required this.ratio, required super.narrowed, }); + /// Static fields are left out of the generated code, so they are not + /// inspected either. + static const unused = Curve(0); + final Curve curve; final Settings settings; final Settings? optionalSettings; @@ -175,6 +241,10 @@ final class LookupTheme extends Middle with _$LookupTheme { final Mode mode; final Pair pair; final Strict strict; + final Box box; + final Special? special; + final Fade? fade; + final Ratio ratio; @override bool get canMerge => true; diff --git a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart index 189b70a..e7ba65a 100644 --- a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart @@ -35,6 +35,14 @@ mixin _$LookupTheme { mode: t < 0.5 ? a.mode : b.mode, pair: t < 0.5 ? a.pair : b.pair, strict: t < 0.5 ? a.strict : b.strict, + box: a.box.lerp(b.box, t), + special: a.special == null || b.special == null + ? t < 0.5 + ? a.special + : b.special + : (a.special!.lerp(b.special!, t) as Special?), + fade: t < 0.5 ? a.fade : b.fade, + ratio: t < 0.5 ? a.ratio : b.ratio, narrowed: t < 0.5 ? a.narrowed : b.narrowed, ); } @@ -49,6 +57,10 @@ mixin _$LookupTheme { Mode? mode, Pair? pair, Strict? strict, + Box? box, + Special? special, + Fade? fade, + Ratio? ratio, int? narrowed, }) { final _this = (this as LookupTheme); @@ -63,6 +75,10 @@ mixin _$LookupTheme { mode: mode ?? _this.mode, pair: pair ?? _this.pair, strict: strict ?? _this.strict, + box: box ?? _this.box, + special: special ?? _this.special, + fade: fade ?? _this.fade, + ratio: ratio ?? _this.ratio, narrowed: narrowed ?? _this.narrowed, ); } @@ -92,6 +108,10 @@ mixin _$LookupTheme { mode: other.mode, pair: other.pair, strict: other.strict, + box: _this.box.merge(other.box), + special: other.special, + fade: other.fade, + ratio: other.ratio, narrowed: other.narrowed, ); } @@ -118,6 +138,10 @@ mixin _$LookupTheme { _other.mode == _this.mode && _other.pair == _this.pair && _other.strict == _this.strict && + _other.box == _this.box && + _other.special == _this.special && + _other.fade == _this.fade && + _other.ratio == _this.ratio && _other.narrowed == _this.narrowed; } @@ -136,6 +160,10 @@ mixin _$LookupTheme { _this.mode, _this.pair, _this.strict, + _this.box, + _this.special, + _this.fade, + _this.ratio, _this.narrowed, ); } diff --git a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart b/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart index 9af7d0a..f98eed6 100644 --- a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart @@ -5,10 +5,15 @@ import 'mock.dart'; part 'widget_state_property_theme.g.theme.dart'; +const _labelWarning = + 'The `lerp` method of String has an unsupported signature, so the field ' + '`label` switches over at t = 0.5 instead of being interpolated.'; + /// Empty Theme - testing edge case with no fields @ShouldGenerateFile( 'goldens/widget_state_property_theme.g.theme.dart', partOfCurrent: true, + expectedLogItems: [_labelWarning], ) @themeGen final class WidgetStatePropertyTheme with _$WidgetStatePropertyTheme { @@ -19,6 +24,7 @@ final class WidgetStatePropertyTheme with _$WidgetStatePropertyTheme { required this.optionalColor, required this.optionalWidth, required this.optionalDuration, + required this.label, }); final WidgetStateProperty color; @@ -29,6 +35,9 @@ final class WidgetStatePropertyTheme with _$WidgetStatePropertyTheme { final WidgetStateProperty? optionalWidth; final WidgetStateProperty? optionalDuration; + /// `String` has no static lerp, so this one cannot be interpolated. + final WidgetStateProperty label; + @override bool get canMerge => true; diff --git a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.g.theme.dart index 855ec2e..f6cb2c0 100644 --- a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.g.theme.dart @@ -61,6 +61,7 @@ mixin _$WidgetStatePropertyTheme { t, lerpDuration$, ), + label: t < 0.5 ? a.label : b.label, ); } @@ -71,6 +72,7 @@ mixin _$WidgetStatePropertyTheme { WidgetStateProperty? optionalColor, WidgetStateProperty? optionalWidth, WidgetStateProperty? optionalDuration, + WidgetStateProperty? label, }) { final _this = (this as WidgetStatePropertyTheme); @@ -81,6 +83,7 @@ mixin _$WidgetStatePropertyTheme { optionalColor: optionalColor ?? _this.optionalColor, optionalWidth: optionalWidth ?? _this.optionalWidth, optionalDuration: optionalDuration ?? _this.optionalDuration, + label: label ?? _this.label, ); } @@ -102,6 +105,7 @@ mixin _$WidgetStatePropertyTheme { optionalColor: other.optionalColor, optionalWidth: other.optionalWidth, optionalDuration: other.optionalDuration, + label: other.label, ); } @@ -123,7 +127,8 @@ mixin _$WidgetStatePropertyTheme { _other.duration == _this.duration && _other.optionalColor == _this.optionalColor && _other.optionalWidth == _this.optionalWidth && - _other.optionalDuration == _this.optionalDuration; + _other.optionalDuration == _this.optionalDuration && + _other.label == _this.label; } @override @@ -138,6 +143,7 @@ mixin _$WidgetStatePropertyTheme { _this.optionalColor, _this.optionalWidth, _this.optionalDuration, + _this.label, ); } } From 64032490ad3b8b0f5835b52492c0921ecd450591 Mon Sep 17 00:00:00 2001 From: Andrii Ivanov Date: Sun, 6 Sep 2026 12:39:41 +0300 Subject: [PATCH 07/10] Cast inherited merge results, tighten the fixtures that pin them --- .../theme_extensions_builder/CHANGELOG.md | 2 +- .../lib/src/common/analysis.dart | 19 ++++++-- .../lib/src/common/symbols/merge_info.dart | 22 +++++++-- .../src/generator/theme_gen/code_builder.dart | 46 +++++++++++++++---- .../test/runtime/symbols_test.dart | 4 +- .../test/runtime/theme_gen_lookup_test.dart | 17 +++++-- .../goldens/lookup_theme.g.theme.dart | 12 ++++- .../test/theme_gen/inherited_theme.dart | 7 +-- .../test/theme_gen/lookup_theme.dart | 23 +++++++++- .../test/theme_gen/lookup_theme.g.theme.dart | 12 ++++- 10 files changed, 133 insertions(+), 31 deletions(-) diff --git a/packages/theme_extensions_builder/CHANGELOG.md b/packages/theme_extensions_builder/CHANGELOG.md index 6cc9be3..5a8811e 100644 --- a/packages/theme_extensions_builder/CHANGELOG.md +++ b/packages/theme_extensions_builder/CHANGELOG.md @@ -3,7 +3,7 @@ - **Fixed**: `@ThemeGen` now interpolates a nullable field whose type has an instance `lerp` method, instead of falling back to `t < 0.5 ? a : b`. - **Fixed**: An interpolation that cannot run on a null now keeps the endpoints: `lerp(a, b, 0)` returns `a`'s value and `lerp(a, b, 1)` returns `b`'s value even when the other side is null. Previously a null on the `a` side either discarded `b` entirely or was taken at any `t`. - **Fixed**: `merge` now keeps the current value when the incoming one is null. The static and instance merge branches disagreed on this; the instance behaviour was the correct one. -- **Fixed**: Several shapes that used to generate code that does not compile: a nullable field whose `lerp` or `merge` takes a non-nullable parameter is now guarded; a static `lerp` or `merge` is called on the class rather than on an instantiation of it, so a generic field type no longer generates `Box.lerp(...)`; a result is cast back to the field type only when the method returns a supertype; and a generic field type resolves its methods through the instantiated type, so `Box` matches a `lerp` declared as `Box lerp(Box other, double t)`. +- **Fixed**: Several shapes that used to generate code that does not compile: a nullable field whose `lerp` or `merge` takes a non-nullable parameter is now guarded; a static `lerp` or `merge` is called on the class rather than on an instantiation of it, so a generic field type no longer generates `Box.lerp(...)`; a `lerp` or `merge` result is cast back to the field type when the method is declared on a supertype and returns it, so an inherited `merge` is called instead of being reported unusable; and a generic field type resolves its methods through the instantiated type, so `Box` matches a `lerp` declared as `Box lerp(Box other, double t)`. - **Fixed**: A field type declaring an unrelated `lerp` or `merge` method no longer fails the build, and is no longer mistaken for a supported one: parameter and return types are checked against the field type, a required named parameter disqualifies the method, and a non-generic method is matched with its type arguments. An unusable method is reported as a build warning naming the field and what happens to it instead. - **Fixed**: `WidgetStateProperty` fields are validated further: the generic has to offer a static `lerp` that accepts nulls, the inner call drops the generic's own type arguments, `double` and `Duration` generics are recognised by element rather than by name, an unrelated generic type with a four parameter `lerp` is no longer treated as a `WidgetStateProperty`, and a non-nullable generic reports an `InvalidGenerationSourceError` pointing at the field instead of a bare `StateError`. - **Fixed**: Field collection follows Dart's own resolution: a field narrowed by a subclass keeps the narrowed type, a mixin wins over the superclass chain, an interface reached through `implements` contributes nothing, private and static fields are left out, and `@ignore` on a redeclaration also suppresses the inherited one. Inherited fields now come after the class' own, which changes `hashCode` values for classes that inherit fields. diff --git a/packages/theme_extensions_builder/lib/src/common/analysis.dart b/packages/theme_extensions_builder/lib/src/common/analysis.dart index 617e319..4497832 100644 --- a/packages/theme_extensions_builder/lib/src/common/analysis.dart +++ b/packages/theme_extensions_builder/lib/src/common/analysis.dart @@ -427,11 +427,22 @@ MergeInfo _mergeInfo(DartType type, FieldElement fieldElement) { // Check for instance merge method: // - should have only one parameter // - parameter type should accept the class type - // - the result should be usable as the class type when !method.isStatic && - _checkSubtype(p1, type, strict: strictSignature) && - _isUsableAs(method.returnType, type, type)) { - return InstanceMerge(isNullableParameter: p1.type.hasNullableSuffix); + _checkSubtype(p1, type, strict: strictSignature)) { + // As for lerp: a method declared on a supertype returns that supertype, + // which the generated code casts back to the field type. + final needsCast = !_isUsableAs(method.returnType, type, type); + + if (needsCast && !_isUsableAs(type, method.returnType, type)) { + _warnUnsupported('merge', type, fieldElement); + + return const NoMerge(); + } + + return InstanceMerge( + isNullableParameter: p1.type.hasNullableSuffix, + needsCast: needsCast, + ); } // The type declares a `merge` we don't know how to call. diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart index 9a54bd0..9351519 100644 --- a/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart +++ b/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart @@ -46,7 +46,18 @@ final class StaticMerge extends MergeInfo { /// `T merge(T other)` or `T merge(T? other)` final class InstanceMerge extends MergeInfo { /// Creates an [InstanceMerge] instance. - const InstanceMerge({this.isNullableParameter = true}); + const InstanceMerge({ + this.isNullableParameter = true, + this.needsCast = false, + }); + + /// Whether the result has to be cast back to the field type. + /// + /// A method declared on a supertype returns that supertype, so the value it + /// produces has to be narrowed before it can be passed on. A method that + /// already returns the field type needs no cast, and adding one would trip + /// `unnecessary_cast` in the generated file. + final bool needsCast; /// Whether the merge method accepts a nullable argument. /// @@ -59,13 +70,14 @@ final class InstanceMerge extends MergeInfo { identical(this, other) || other is InstanceMerge && runtimeType == other.runtimeType && - isNullableParameter == other.isNullableParameter; + isNullableParameter == other.isNullableParameter && + needsCast == other.needsCast; @override - int get hashCode => Object.hash(runtimeType, isNullableParameter); + int get hashCode => Object.hash(runtimeType, isNullableParameter, needsCast); @override String toString() => - 'InstanceMerge(isNullableParameter: ' - '$isNullableParameter)'; + 'InstanceMerge(isNullableParameter: $isNullableParameter, ' + 'needsCast: $needsCast)'; } diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart index 052dd6a..b14de14 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart @@ -1,5 +1,6 @@ import 'package:code_builder/code_builder.dart'; +import '../../common/symbols/field_info.dart'; import '../../common/symbols/lerp_info.dart'; import '../../common/symbols/merge_info.dart'; import '../../config/config.dart'; @@ -182,15 +183,19 @@ Method merge(ThemeGenConfig config) => Method((m) { args[field.name] = staticMerge([thisProp, otherProp]); // Instance merge method taking a nullable argument, optional field - case InstanceMerge(isNullableParameter: true) when field.isNullable: + case InstanceMerge(isNullableParameter: true, :final needsCast) + when field.isNullable: // _this.field?.merge(other.field) ?? other.field - args[field.name] = thisProp - .nullSafeProperty('merge')([otherProp]) - .ifNullThen(otherProp); + args[field.name] = _castIfNeeded( + thisProp.nullSafeProperty('merge')([otherProp]), + field, + needsCast: needsCast, + isNullable: true, + ).ifNullThen(otherProp); // Instance merge method taking a non-nullable argument, optional // field - case InstanceMerge() when field.isNullable: + case InstanceMerge(:final needsCast) when field.isNullable: // _this.field == null // ? other.field // : other.field == null @@ -204,15 +209,25 @@ Method merge(ThemeGenConfig config) => Method((m) { .equalTo(literalNull) .conditional( thisProp, - thisProp.nullChecked.property('merge')([ - otherProp.nullChecked, - ]), + _castIfNeeded( + thisProp.nullChecked.property('merge')([ + otherProp.nullChecked, + ]), + field, + needsCast: needsCast, + isNullable: true, + ), ), ); // Instance merge method with non-optional field - case InstanceMerge(): - args[field.name] = instanceMerge([otherProp]); + case InstanceMerge(:final needsCast): + args[field.name] = _castIfNeeded( + instanceMerge([otherProp]), + field, + needsCast: needsCast, + isNullable: false, + ); } } @@ -220,6 +235,17 @@ Method merge(ThemeGenConfig config) => Method((m) { }); }); +/// Casts [expression] back to the type of [field] when the method that +/// produced it is declared on a supertype. +Expression _castIfNeeded( + Expression expression, + FieldInfo field, { + required bool needsCast, + required bool isNullable, +}) => needsCast + ? expression.asA(field.typeName.typeRef(isNullable: isNullable)) + : expression; + /// Generates a static `lerp` method for interpolating between two theme /// instances. /// diff --git a/packages/theme_extensions_builder/test/runtime/symbols_test.dart b/packages/theme_extensions_builder/test/runtime/symbols_test.dart index 07c94ef..215fc07 100644 --- a/packages/theme_extensions_builder/test/runtime/symbols_test.dart +++ b/packages/theme_extensions_builder/test/runtime/symbols_test.dart @@ -279,10 +279,12 @@ void main() { final merge1 = InstanceMerge(); final merge2 = InstanceMerge(); const merge3 = InstanceMerge(isNullableParameter: false); + const merge4 = InstanceMerge(needsCast: true); expect(merge1, equals(merge2)); expect(merge1.hashCode, equals(merge2.hashCode)); expect(merge1, isNot(equals(merge3))); + expect(merge1, isNot(equals(merge4))); }); test('different merge methods are not equal', () { @@ -304,7 +306,7 @@ void main() { expect(staticMerge.toString(), 'StaticMerge()'); expect( instanceMerge.toString(), - 'InstanceMerge(isNullableParameter: true)', + 'InstanceMerge(isNullableParameter: true, needsCast: false)', ); }); }); diff --git a/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart index e574daf..a2c3ae6 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart @@ -17,6 +17,7 @@ void main() { special: Special(1), fade: Fade(1), ratio: Ratio(1), + counter: Counter(1), narrowed: 1, ); @@ -34,6 +35,7 @@ void main() { special: Special(2), fade: Fade(2), ratio: Ratio(2), + counter: Counter(2), narrowed: 2, ); @@ -60,15 +62,23 @@ void main() { expect(LookupTheme.lerp(a, b, 0.4)!.pair, same(a.pair)); expect(a.merge(b).unrelated, same(b.unrelated)); expect(a.merge(b).strict, same(b.strict)); + expect(a.merge(b).counter, same(b.counter)); }); test('a generic type resolves its methods through the instantiation', () { - expect(LookupTheme.lerp(a, b, 0.5)!.box.value, same(b.box.value)); - expect(a.merge(b).box.value, same(b.box.value)); + // Box.lerp returns `other`, which the t < 0.5 fallback would not do + // at 0.4. + expect(LookupTheme.lerp(a, b, 0.4)!.box, same(b.box)); }); test('an inherited lerp returning a supertype is cast back', () { - expect(LookupTheme.lerp(a, b, 0.5)!.special, isA()); + // Animatable.lerp also returns `other`, so the fallback would hand back + // a.special here. The cast is what keeps the result a Special. + final lerped = LookupTheme.lerp(a, b, 0.4)!.special; + + expect(lerped, same(b.special)); + expect(lerped, isA()); + expect(a.merge(b).special, isA()); }); test('a lerp returning an unrelated type is ignored', () { @@ -94,6 +104,7 @@ void main() { special: Special(1), fade: Fade(1), ratio: Ratio(1), + counter: Counter(1), narrowed: 1, ); diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart index 7efb151..2c4c6ed 100644 --- a/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart @@ -34,6 +34,7 @@ mixin _$LookupTheme { : (a.special!.lerp(b.special!, t) as Special?), fade: t < 0.5 ? a.fade : b.fade, ratio: t < 0.5 ? a.ratio : b.ratio, + counter: t < 0.5 ? a.counter : b.counter, narrowed: t < 0.5 ? a.narrowed : b.narrowed, ); } @@ -52,6 +53,7 @@ mixin _$LookupTheme { Special? special, Fade? fade, Ratio? ratio, + Counter? counter, int? narrowed, }) { final _this = (this as LookupTheme); @@ -70,6 +72,7 @@ mixin _$LookupTheme { special: special ?? _this.special, fade: fade ?? _this.fade, ratio: ratio ?? _this.ratio, + counter: counter ?? _this.counter, narrowed: narrowed ?? _this.narrowed, ); } @@ -100,9 +103,14 @@ mixin _$LookupTheme { pair: other.pair, strict: other.strict, box: _this.box.merge(other.box), - special: other.special, + special: _this.special == null + ? other.special + : other.special == null + ? _this.special + : (_this.special!.merge(other.special!) as Special?), fade: other.fade, ratio: other.ratio, + counter: other.counter, narrowed: other.narrowed, ); } @@ -133,6 +141,7 @@ mixin _$LookupTheme { _other.special == _this.special && _other.fade == _this.fade && _other.ratio == _this.ratio && + _other.counter == _this.counter && _other.narrowed == _this.narrowed; } @@ -155,6 +164,7 @@ mixin _$LookupTheme { _this.special, _this.fade, _this.ratio, + _this.counter, _this.narrowed, ); } diff --git a/packages/theme_extensions_builder/test/theme_gen/inherited_theme.dart b/packages/theme_extensions_builder/test/theme_gen/inherited_theme.dart index e351965..938549e 100644 --- a/packages/theme_extensions_builder/test/theme_gen/inherited_theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/inherited_theme.dart @@ -3,10 +3,11 @@ import 'package:theme_extensions_builder_annotation/theme_extensions_builder_ann part 'inherited_theme.g.theme.dart'; -/// Satisfied by [InheritedTheme] itself, so nothing it declares may be -/// collected: an `implements` clause carries no state to construct. +/// Satisfied by [InheritedTheme] itself. It carries a concrete field on +/// purpose: an `implements` clause brings no state the generated constructor +/// call could pass, so the field must not be collected. abstract class HasVersion { - int get version; + final version = 0; } /// Declares the fields [InheritedTheme] inherits. diff --git a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart index 709a6b2..d8d15a2 100644 --- a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart @@ -102,16 +102,19 @@ class Box { Box merge(Box other) => other; } -/// Declares the `lerp` that [Special] inherits, and returns this supertype. +/// Declares the `lerp` and `merge` that [Special] inherits, both returning +/// this supertype. class Animatable { const Animatable(this.value); final int value; Animatable? lerp(Animatable other, double t) => other; + + Animatable merge(Animatable other) => other; } -/// Uses the inherited `lerp`, whose result has to be cast back. +/// Uses the inherited `lerp` and `merge`, whose results have to be cast back. class Special extends Animatable { const Special(super.value); } @@ -125,6 +128,15 @@ class Fade { double? lerp(Fade? other, double t) => value; } +/// A `merge` whose result cannot stand in for the class. +class Counter { + const Counter(this.value); + + final int value; + + int merge(Counter other) => value + other.value; +} + /// A static `lerp` whose result cannot stand in for the class. class Ratio { const Ratio(this.value); @@ -192,6 +204,10 @@ const _ratioWarning = 'The `lerp` method of Ratio has an unsupported signature, so the field ' '`ratio` $_lerpFallback'; +const _counterWarning = + 'The `merge` method of Counter has an unsupported signature, so the field ' + '`counter` $_mergeFallback'; + /// Theme whose field types are inspected by method lookup rather than by an /// annotation: only [Settings] offers a signature the generator can call. @ShouldGenerateFile( @@ -207,6 +223,7 @@ const _ratioWarning = _strictWarning, _fadeWarning, _ratioWarning, + _counterWarning, ], ) @themeGen @@ -225,6 +242,7 @@ final class LookupTheme extends Middle with _$LookupTheme { required this.special, required this.fade, required this.ratio, + required this.counter, required super.narrowed, }); @@ -245,6 +263,7 @@ final class LookupTheme extends Middle with _$LookupTheme { final Special? special; final Fade? fade; final Ratio ratio; + final Counter counter; @override bool get canMerge => true; diff --git a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart index e7ba65a..ed1f257 100644 --- a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart @@ -43,6 +43,7 @@ mixin _$LookupTheme { : (a.special!.lerp(b.special!, t) as Special?), fade: t < 0.5 ? a.fade : b.fade, ratio: t < 0.5 ? a.ratio : b.ratio, + counter: t < 0.5 ? a.counter : b.counter, narrowed: t < 0.5 ? a.narrowed : b.narrowed, ); } @@ -61,6 +62,7 @@ mixin _$LookupTheme { Special? special, Fade? fade, Ratio? ratio, + Counter? counter, int? narrowed, }) { final _this = (this as LookupTheme); @@ -79,6 +81,7 @@ mixin _$LookupTheme { special: special ?? _this.special, fade: fade ?? _this.fade, ratio: ratio ?? _this.ratio, + counter: counter ?? _this.counter, narrowed: narrowed ?? _this.narrowed, ); } @@ -109,9 +112,14 @@ mixin _$LookupTheme { pair: other.pair, strict: other.strict, box: _this.box.merge(other.box), - special: other.special, + special: _this.special == null + ? other.special + : other.special == null + ? _this.special + : (_this.special!.merge(other.special!) as Special?), fade: other.fade, ratio: other.ratio, + counter: other.counter, narrowed: other.narrowed, ); } @@ -142,6 +150,7 @@ mixin _$LookupTheme { _other.special == _this.special && _other.fade == _this.fade && _other.ratio == _this.ratio && + _other.counter == _this.counter && _other.narrowed == _this.narrowed; } @@ -164,6 +173,7 @@ mixin _$LookupTheme { _this.special, _this.fade, _this.ratio, + _this.counter, _this.narrowed, ); } From 29de7603666b43948e7012989a4d1a401684fa71 Mon Sep 17 00:00:00 2001 From: Andrii Ivanov Date: Sun, 6 Sep 2026 13:53:57 +0300 Subject: [PATCH 08/10] Validate the annotated class, share lerp generation, move to a pub workspace Generator - Check the annotated class before generating: a missing constructor, a field without a named constructor parameter, a @ThemeExtensions class that does not extend ThemeExtension, and a contextAccessorName that is not an identifier now stop the build with an InvalidGenerationSourceError naming the class and the fix. - Decide `const` by the constructor named in `constructor:` rather than by any constructor of the class; treat `constructor: ''` as unnamed. - Match @ThemeGen and @ignore by package as well as by name. Code - Generate `lerp` and `copyWith` once, in generator/common.dart, for both generators. Output is unchanged. - Trim the symbol model to what the code builders read: drop ParameterInfo, lerp args, isStatic, filteredFields, FieldsVisitorConfig and the Ref extension type. Skip static fields in the visitor. - Split analysis.dart into per-method lookups, a DartType extension and field_symbol.dart. Drop the meta and collection dependencies. Tests - Fixtures for every new error, for ThemeGen and ThemeExtensions. - EmptyThemeNonConst was declared const; make it non-const and update its golden. - Replace the mock.dart symlinks with a flutter_stubs package. Repository - Turn the repository into a pub workspace with the example as a member; remove both pubspec_overrides.yaml. - CI: latest and lowest dependency matrix, analyze with --fatal-infos, regenerate fixtures and example and fail on stale .g.theme.dart, analyze the example against the real framework. - prepare_push.sh called the non-existent `dart pub update`; resolve the workspace once instead. Fix the cwd in .vscode/tasks.json. Docs - README: accurate build.yaml section, limitations, generation errors. troubleshooting.md lists each new message. CHANGELOG 7.5.0 extended. --- .github/README.md | 12 + .github/workflows/dart.yml | 93 +++- .gitignore | 3 + .vscode/tasks.json | 4 +- docs/troubleshooting.md | 13 + packages/flutter_stubs/.gitignore | 2 + packages/flutter_stubs/analysis_options.yaml | 4 + .../lib/flutter_stubs.dart} | 22 +- packages/flutter_stubs/pubspec.yaml | 16 + .../theme_extensions_builder/CHANGELOG.md | 5 + packages/theme_extensions_builder/README.md | 27 +- .../example/analysis_options.yaml | 6 + .../example/pubspec.lock | 461 ----------------- .../example/pubspec.yaml | 2 + .../example/pubspec_overrides.yaml | 6 - .../lib/src/common/analysis.dart | 476 ------------------ .../lib/src/common/dart_type_extension.dart | 31 ++ .../lib/src/common/field_symbol.dart | 26 + .../lib/src/common/fields_visitor.dart | 79 ++- .../lib/src/common/fields_visitor_config.dart | 42 -- .../lib/src/common/lookup/lerp_lookup.dart | 193 +++++++ .../lib/src/common/lookup/merge_lookup.dart | 77 +++ .../lib/src/common/lookup/method_lookup.dart | 120 +++++ .../lib/src/common/symbols/field_info.dart | 17 +- .../lib/src/common/symbols/lerp_info.dart | 84 ++-- .../src/common/symbols/parameter_info.dart | 37 -- .../lib/src/common/type_checkers.dart | 22 + .../lib/src/common/validation.dart | 138 +++++ .../lib/src/config/config.dart | 10 +- .../lib/src/generator/annotation_reader.dart | 22 + .../lib/src/generator/common.dart | 324 ++++++++---- .../theme_extensions/code_builder.dart | 294 ++--------- .../generator/theme_extensions/generator.dart | 45 +- .../src/generator/theme_gen/code_builder.dart | 409 ++++----------- .../src/generator/theme_gen/generator.dart | 19 +- .../theme_extensions_builder/pubspec.yaml | 8 +- .../pubspec_overrides.yaml | 3 - .../test/generator/code_builder_test.dart | 157 ++++-- .../test/generator/invalid_test.dart | 19 +- .../test/invalid/invalid_theme.dart | 76 ++- .../test/invalid/invalid_theme_extension.dart | 82 +++ .../test/invalid/mock.dart | 1 - .../runtime/fields_visitor_config_test.dart | 45 -- .../test/runtime/symbols_test.dart | 415 +++------------ .../theme_extensions_complex_test.dart | 2 +- .../test/runtime/theme_gen_complex_test.dart | 2 +- .../complex_theme_extension.dart | 2 +- .../empty_theme_extension.dart | 3 +- .../test/theme_extensions/mock.dart | 1 - ...widget_state_property_theme_extension.dart | 3 +- .../test/theme_gen/complex_theme.dart | 2 +- .../test/theme_gen/empty_theme.dart | 2 +- .../test/theme_gen/empty_theme.g.theme.dart | 4 +- .../test/theme_gen/empty_theme_extension.dart | 3 +- .../empty_theme_non_const.g.theme.dart | 4 +- .../test/theme_gen/mock.dart | 1 - .../widget_state_property_theme.dart | 3 +- .../pubspec.yaml | 2 + pubspec.yaml | 14 + scripts/prepare_push.sh | 18 +- 60 files changed, 1685 insertions(+), 2328 deletions(-) create mode 100644 .gitignore create mode 100644 packages/flutter_stubs/.gitignore create mode 100644 packages/flutter_stubs/analysis_options.yaml rename packages/{theme_extensions_builder/test/mock/mock.dart => flutter_stubs/lib/flutter_stubs.dart} (94%) create mode 100644 packages/flutter_stubs/pubspec.yaml delete mode 100644 packages/theme_extensions_builder/example/pubspec.lock delete mode 100644 packages/theme_extensions_builder/example/pubspec_overrides.yaml delete mode 100644 packages/theme_extensions_builder/lib/src/common/analysis.dart create mode 100644 packages/theme_extensions_builder/lib/src/common/dart_type_extension.dart create mode 100644 packages/theme_extensions_builder/lib/src/common/field_symbol.dart delete mode 100644 packages/theme_extensions_builder/lib/src/common/fields_visitor_config.dart create mode 100644 packages/theme_extensions_builder/lib/src/common/lookup/lerp_lookup.dart create mode 100644 packages/theme_extensions_builder/lib/src/common/lookup/merge_lookup.dart create mode 100644 packages/theme_extensions_builder/lib/src/common/lookup/method_lookup.dart delete mode 100644 packages/theme_extensions_builder/lib/src/common/symbols/parameter_info.dart create mode 100644 packages/theme_extensions_builder/lib/src/common/type_checkers.dart create mode 100644 packages/theme_extensions_builder/lib/src/common/validation.dart create mode 100644 packages/theme_extensions_builder/lib/src/generator/annotation_reader.dart delete mode 100644 packages/theme_extensions_builder/pubspec_overrides.yaml create mode 100644 packages/theme_extensions_builder/test/invalid/invalid_theme_extension.dart delete mode 120000 packages/theme_extensions_builder/test/invalid/mock.dart delete mode 100644 packages/theme_extensions_builder/test/runtime/fields_visitor_config_test.dart delete mode 120000 packages/theme_extensions_builder/test/theme_extensions/mock.dart delete mode 120000 packages/theme_extensions_builder/test/theme_gen/mock.dart create mode 100644 pubspec.yaml diff --git a/.github/README.md b/.github/README.md index 9ef47a6..6bc1f52 100644 --- a/.github/README.md +++ b/.github/README.md @@ -143,6 +143,18 @@ Check out the [example project](../packages/theme_extensions_builder/example) fo - **Custom Components**: Buttons, cards, and typography showcases - **Best Practices**: Real-world organization patterns +## 🛠️ Development + +The repository is a [pub workspace](https://dart.dev/tools/pub/workspaces): one `flutter pub get` at the root resolves every package, including the example app. + +```bash +flutter pub get +scripts/prepare_push.sh # format, analyze, build and test every package +scripts/update_goldens.sh # regenerate the golden files after a generator change +``` + +The generator tests run on the Dart SDK alone, against `packages/flutter_stubs`: small stand-ins for the Flutter classes the fixtures use, with the same `lerp` and `merge` signatures. The example app is where the generated code meets the real framework, so CI regenerates and analyzes it on every push. + ## 📄 License MIT License - see the [LICENSE](../LICENSE) file for details. diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 9427423..1107e72 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -1,43 +1,90 @@ -name: Dart CI +name: CI on: - # workflow_dispatch: # Only manual trigger push: - branches: [ "main" ] + branches: [main] pull_request: - branches: [ "main" ] + branches: [main] + +env: + BUILDER: packages/theme_extensions_builder + ANNOTATION: packages/theme_extensions_builder_annotation + STUBS: packages/flutter_stubs + EXAMPLE: packages/theme_extensions_builder/example jobs: test: + name: Test (${{ matrix.deps }} dependencies) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The builder advertises a range of analyzer versions; the lower + # bound is exercised by downgrading the analyzer stack. + deps: [latest, lowest] steps: - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 + # The example app is a workspace member, so the workspace is resolved + # with the Flutter SDK. Everything else runs on the Dart SDK it bundles. + - uses: subosito/flutter-action@v2 with: - sdk: stable + channel: stable + cache: true - - name: Install dependencies (annotation) - working-directory: packages/theme_extensions_builder_annotation - run: dart pub get --no-example + - name: Resolve the workspace + run: flutter pub get - - name: Run formatter check (annotation) - working-directory: packages/theme_extensions_builder_annotation - run: dart format --set-exit-if-changed . + - name: Downgrade the analyzer stack to the lower bounds + if: matrix.deps == 'lowest' + run: dart pub downgrade analyzer source_gen build - - name: Run tests (annotation) - working-directory: packages/theme_extensions_builder_annotation - run: dart test + - name: Check formatting + if: matrix.deps == 'latest' + run: dart format --set-exit-if-changed $BUILDER $ANNOTATION $STUBS - - name: Install dependencies (builder) - working-directory: packages/theme_extensions_builder - run: dart pub get --no-example + - name: Analyze + run: dart analyze --fatal-infos $BUILDER $ANNOTATION $STUBS - - name: Run formatter check (builder) - working-directory: packages/theme_extensions_builder - run: dart format --set-exit-if-changed . + - name: Test annotation + working-directory: ${{ env.ANNOTATION }} + run: dart test - - name: Run tests (builder) - working-directory: packages/theme_extensions_builder + - name: Test builder + working-directory: ${{ env.BUILDER }} run: dart test + + generated: + name: Generated files are up to date + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Resolve the workspace + run: flutter pub get + + # The runtime tests import the committed generated files, so a stale + # file would test yesterday's generator. + - name: Regenerate the builder fixtures + working-directory: ${{ env.BUILDER }} + run: dart run build_runner build + + - name: Regenerate the example + working-directory: ${{ env.EXAMPLE }} + run: dart run build_runner build + + # The generator tests run against stubs of the Flutter classes; the + # example is the one place the generated code meets the real framework. + - name: Analyze the example + working-directory: ${{ env.EXAMPLE }} + run: flutter analyze --fatal-infos + + - name: Fail on stale generated files + run: git diff --exit-code -- '*.g.theme.dart' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b4200a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Workspace resolution lives at the root; the lock file is not published. +.dart_tool/ +/pubspec.lock diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d53b137..07f8702 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -11,7 +11,7 @@ "clean" ], "options": { - "cwd": "packages/theme_extensions_builder/test" + "cwd": "packages/theme_extensions_builder" }, "group": "build", "presentation": { @@ -36,7 +36,7 @@ "--dart-jit-vm-arg=--disable-service-auth-codes" ], "options": { - "cwd": "packages/theme_extensions_builder/test" + "cwd": "packages/theme_extensions_builder" }, "dependsOn": "example: run build_runner clean", "group": "build", diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 05a06b9..83deea1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -51,6 +51,19 @@ dart run build_runner clean dart run build_runner build ``` +## The Build Stops With A Generator Error + +The generator checks the annotated class before writing anything. Each message names the class and what to change: + +- **`` `X` has no constructor named `_internal`. ``** The `constructor:` option names a constructor that does not exist. Declare it, or point the option at an existing one. +- **`` `X` has no unnamed constructor, which the generated code calls. ``** The class only has named constructors. Add `constructor: 'name'` to the annotation. +- **`` The constructor `X` has no named parameter for the field `y` ``** Every field is passed to the constructor by name. Add `this.y` to the constructor, or mark the field with `@ignore`. +- **`` `X` must extend `ThemeExtension` ``** `@ThemeExtensions` needs a class that extends `ThemeExtension` of itself. Extend it, or use `@ThemeGen` for a plain class. +- **`` `...` is not a valid Dart identifier ``** `contextAccessorName` is written into the generated code as a getter name. Use an identifier. +- **`` WidgetStateProperty must have a nullable generic type ``** `WidgetStateProperty.lerp` takes a lerp function with nullable parameters, so the generic has to be nullable: `WidgetStateProperty`. + +A warning such as `` The `lerp` method of X has an unsupported signature `` does not stop the build. The field type declares a `lerp` or `merge` the generator cannot call, so the field switches over at `t = 0.5` or is overwritten instead. Rename the method or give it a supported signature if it was meant to be used. + ## Analyzer Errors In Generated Files Checklist: diff --git a/packages/flutter_stubs/.gitignore b/packages/flutter_stubs/.gitignore new file mode 100644 index 0000000..a4f98c8 --- /dev/null +++ b/packages/flutter_stubs/.gitignore @@ -0,0 +1,2 @@ +.dart_tool/ +/pubspec.lock diff --git a/packages/flutter_stubs/analysis_options.yaml b/packages/flutter_stubs/analysis_options.yaml new file mode 100644 index 0000000..9f84222 --- /dev/null +++ b/packages/flutter_stubs/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:pro_lints/recommended.yaml + +formatter: + trailing_commas: preserve diff --git a/packages/theme_extensions_builder/test/mock/mock.dart b/packages/flutter_stubs/lib/flutter_stubs.dart similarity index 94% rename from packages/theme_extensions_builder/test/mock/mock.dart rename to packages/flutter_stubs/lib/flutter_stubs.dart index 40dc356..676a237 100644 --- a/packages/theme_extensions_builder/test/mock/mock.dart +++ b/packages/flutter_stubs/lib/flutter_stubs.dart @@ -1,18 +1,14 @@ -/// Mock implementations of Flutter framework classes for testing. +/// Minimal stand-ins for the Flutter classes the generator tests need. /// -/// This library provides minimal mock implementations of key Flutter classes -/// that are needed for testing theme extensions without pulling in the entire -/// Flutter SDK as a dependency. This keeps tests lightweight and fast. +/// The generator is tested on the Dart SDK alone, so the Flutter types the +/// fixtures use — [ThemeExtension], [Color], [BorderSide], +/// [WidgetStateProperty], [BuildContext] and [Theme] — are declared here with +/// the same `lerp` and `merge` signatures as the real ones. That is the only +/// thing the generator looks at. /// -/// The mocks implement the essential API surface that theme_extensions_builder -/// relies on, including: -/// - [ThemeExtension]: The base class for custom theme extensions -/// - [Color]: Color representation and interpolation -/// - [BorderSide]: Border styling with interpolation support -/// - [BuildContext] and [Theme]: Context and theme access stubs -/// -/// These implementations are intentionally simplified and should only be used -/// for testing the code generation output, not for production use. +/// Keep the signatures in step with Flutter: the example app in the +/// `theme_extensions_builder` package is what checks them against the real +/// framework. library; import 'dart:math' as math; diff --git a/packages/flutter_stubs/pubspec.yaml b/packages/flutter_stubs/pubspec.yaml new file mode 100644 index 0000000..5baffb7 --- /dev/null +++ b/packages/flutter_stubs/pubspec.yaml @@ -0,0 +1,16 @@ +name: flutter_stubs +description: > + Stand-ins for the Flutter classes the theme_extensions_builder tests need, + so the generator is tested on the Dart SDK alone. Not published. +publish_to: none + +environment: + sdk: ">=3.13.0 <4.0.0" + +resolution: workspace + +dependencies: + theme_extensions_builder_annotation: ^7.5.0 + +dev_dependencies: + pro_lints: ^6.2.0 diff --git a/packages/theme_extensions_builder/CHANGELOG.md b/packages/theme_extensions_builder/CHANGELOG.md index 5a8811e..761519a 100644 --- a/packages/theme_extensions_builder/CHANGELOG.md +++ b/packages/theme_extensions_builder/CHANGELOG.md @@ -10,6 +10,11 @@ - **Fixed**: `Duration` is detected by element rather than by name, so a user type of the same name is no longer interpolated as a duration. - **Updated**: Analyzer dependency to ">=13.0.0 <15.0.0" and Dart SDK constraint to ">=3.13.0 <4.0.0". - **Code Quality**: Exhaustive switches over `LerpInfo`/`MergeInfo`, `==`/`hashCode` contract fixed for the `LerpInfo` classes, dead code removed (`getMixinsNames`, `BaseClassVisitor`, unused builder options and lerp-lookup flag). +- **New**: The annotated class is checked before anything is generated. A missing constructor, a field without a named constructor parameter, a `@ThemeExtensions` class that does not extend `ThemeExtension`, and a `contextAccessorName` that is not an identifier now stop the build with an `InvalidGenerationSourceError` naming the class and the fix, instead of a compile error in the generated file. +- **Fixed**: `const` is decided by the constructor named in `constructor:` rather than by any constructor of the class, so an empty class with a const default constructor and a non-const named one no longer generates `const X._internal()`. +- **Fixed**: `constructor: ''` selects the unnamed constructor instead of emitting `X.()`. +- **Fixed**: The `@ThemeGen` and `@ignore` annotations are matched by package as well as by name, so a user class called `ThemeGen` is no longer taken for the annotation. +- **Code Quality**: The `lerp` and `copyWith` generation is shared between the two generators instead of being maintained twice. The symbol model keeps only what the code builders read, the method lookups live in one file each, and the `collection` and `meta` dependencies are gone. ## 7.4.0 diff --git a/packages/theme_extensions_builder/README.md b/packages/theme_extensions_builder/README.md index caade4e..2390a16 100644 --- a/packages/theme_extensions_builder/README.md +++ b/packages/theme_extensions_builder/README.md @@ -472,20 +472,35 @@ The example includes ready-to-use components and demonstrates best practices for ## 🔧 Build Configuration -### build.yaml (Optional) - -You can customize the build configuration: +The builder takes no options. What `build.yaml` can configure is which files it looks at, which keeps builds fast in a large project: ```yaml targets: $default: builders: theme_extensions_builder: - enabled: true - options: - # Add custom options here if needed + generate_for: + - lib/theme/**.dart ``` +## 🚧 Limitations + +- **Types are written by name.** The generated code refers to field types the way the analyzer displays them, without import prefixes. A theme file that imports Flutter `as m` gets a bare `Color` in the generated part, which does not resolve. Import Flutter without a prefix in the files that declare themes. +- **`copyWith` cannot set a nullable field to `null`.** Passing `null` means "keep the current value", as in Flutter's own theme classes. +- **Null handling in `lerp` depends on the type.** `double` and `Duration` treat a missing side as zero, like Flutter's `lerpDouble`. A type with a static `lerp` that accepts nulls, such as `Color`, decides for itself. A type whose `lerp` cannot take a null keeps the nearer side: `t < 0.5` gives the first value, otherwise the second. + +## 🚨 Generation Errors + +The generator checks the annotated class before it writes anything, and stops the build with a message pointing at the class when: + +- the constructor named in `constructor:` does not exist, or there is no unnamed constructor to fall back to; +- a field has no named parameter of the same name in that constructor (mark it `@ignore` if it is not part of the theme); +- a `@ThemeExtensions` class does not extend `ThemeExtension`; +- `contextAccessorName` is not a valid identifier; +- a `WidgetStateProperty` field has a non-nullable generic. + +A field type whose `lerp` or `merge` has a signature the generator cannot call is not an error. The build logs a warning naming the field, which then switches over at `t = 0.5` instead of being interpolated, or is overwritten instead of being merged. + ## ⚡ Tips and Best Practices 1. **Use descriptive names**: Name your theme extensions clearly (e.g., `ButtonTheme`, `CardTheme`) diff --git a/packages/theme_extensions_builder/example/analysis_options.yaml b/packages/theme_extensions_builder/example/analysis_options.yaml index 198794d..c3c9c71 100644 --- a/packages/theme_extensions_builder/example/analysis_options.yaml +++ b/packages/theme_extensions_builder/example/analysis_options.yaml @@ -2,4 +2,10 @@ include: package:pro_lints/recommended.yaml formatter: trailing_commas: preserve +analyzer: + exclude: + - android/** + - ios/** + - macos/** + - linux/** diff --git a/packages/theme_extensions_builder/example/pubspec.lock b/packages/theme_extensions_builder/example/pubspec.lock deleted file mode 100644 index 2332c13..0000000 --- a/packages/theme_extensions_builder/example/pubspec.lock +++ /dev/null @@ -1,461 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: fe18c7e37d5acb3b43224fa255281dec007970d6fd01f6d76fcd88ed3777a117 - url: "https://pub.dev" - source: hosted - version: "107.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: f2dde9e50c23fc95b846b4ef334e98c3ae8c0effd5d2453b02fc9fb3f8939791 - url: "https://pub.dev" - source: hosted - version: "14.3.0" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" - source: hosted - version: "2.13.1" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: "8a5c5761af8e31748bba3c82f68925ace40f3225c3eea25be9beb57eca7cd7a8" - url: "https://pub.dev" - source: hosted - version: "4.0.11" - build_config: - dependency: transitive - description: - name: build_config - sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d - url: "https://pub.dev" - source: hosted - version: "1.3.3" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 - url: "https://pub.dev" - source: hosted - version: "4.1.6" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: "894c243f6bc32015fec466ce30a6925bd537a77a426ee5bf481120477eb3de67" - url: "https://pub.dev" - source: hosted - version: "2.16.1" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 - url: "https://pub.dev" - source: hosted - version: "8.13.0" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: aa5932e94c6c39c2f9ec4e5e06dfdd11a9430a61f6c41b6ba75b28ce0c481baf - url: "https://pub.dev" - source: hosted - version: "4.12.0" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "82ade9fc4273f29ed673e33166944465225b4f7fc5d4aaef48605cc751c18fc1" - url: "https://pub.dev" - source: hosted - version: "3.1.13" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - io: - dependency: transitive - description: - name: io - sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" - url: "https://pub.dev" - source: hosted - version: "4.12.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" - url: "https://pub.dev" - source: hosted - version: "0.12.20" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" - url: "https://pub.dev" - source: hosted - version: "1.19.0" - mime: - dependency: transitive - description: - name: mime - sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 - url: "https://pub.dev" - source: hosted - version: "2.1.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d - url: "https://pub.dev" - source: hosted - version: "3.0.0" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - pool: - dependency: transitive - description: - name: pool - sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" - url: "https://pub.dev" - source: hosted - version: "1.5.3" - pro_lints: - dependency: "direct dev" - description: - name: pro_lints - sha256: "4e63089b1d0aeebfb4836acbeff612a27519916832b2384e687dcc9941532cfc" - url: "https://pub.dev" - source: hosted - version: "6.2.0" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" - url: "https://pub.dev" - source: hosted - version: "2.2.1" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 - url: "https://pub.dev" - source: hosted - version: "1.6.0" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "11b6047da8e4eb6c643ccac3d0fda3f9c9e11651695f7da24a85ade8aff15a44" - url: "https://pub.dev" - source: hosted - version: "4.3.0" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" - url: "https://pub.dev" - source: hosted - version: "1.12.2" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a - url: "https://pub.dev" - source: hosted - version: "2.1.2" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "0a10344e901e5b2e63819567951cb6a06673ed6b84f40462188ff5a0c41f371f" - url: "https://pub.dev" - source: hosted - version: "0.7.14" - theme_extensions_builder: - dependency: "direct dev" - description: - path: ".." - relative: true - source: path - version: "7.5.0" - theme_extensions_builder_annotation: - dependency: "direct main" - description: - path: "../../theme_extensions_builder_annotation" - relative: true - source: path - version: "7.5.0" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - watcher: - dependency: transitive - description: - name: watcher - sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - yaml: - dependency: transitive - description: - name: yaml - sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea - url: "https://pub.dev" - source: hosted - version: "3.1.4" -sdks: - dart: ">=3.13.0 <4.0.0" diff --git a/packages/theme_extensions_builder/example/pubspec.yaml b/packages/theme_extensions_builder/example/pubspec.yaml index a6a5d0f..f9360f2 100644 --- a/packages/theme_extensions_builder/example/pubspec.yaml +++ b/packages/theme_extensions_builder/example/pubspec.yaml @@ -6,6 +6,8 @@ version: 1.0.0 environment: sdk: ">=3.13.0 <4.0.0" +resolution: workspace + dependencies: flutter: sdk: flutter diff --git a/packages/theme_extensions_builder/example/pubspec_overrides.yaml b/packages/theme_extensions_builder/example/pubspec_overrides.yaml deleted file mode 100644 index c368267..0000000 --- a/packages/theme_extensions_builder/example/pubspec_overrides.yaml +++ /dev/null @@ -1,6 +0,0 @@ -dependency_overrides: - theme_extensions_builder_annotation: - path: ../../theme_extensions_builder_annotation - - theme_extensions_builder: - path: ../ diff --git a/packages/theme_extensions_builder/lib/src/common/analysis.dart b/packages/theme_extensions_builder/lib/src/common/analysis.dart deleted file mode 100644 index 4497832..0000000 --- a/packages/theme_extensions_builder/lib/src/common/analysis.dart +++ /dev/null @@ -1,476 +0,0 @@ -/// @docImport 'fields_visitor_config.dart'; - -library; - -import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer/dart/element/type.dart'; -import 'package:build/build.dart'; -import 'package:source_gen/source_gen.dart'; -import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; - -import 'fields_visitor_config.dart'; -import 'symbols/field_info.dart'; -import 'symbols/lerp_info.dart'; -import 'symbols/merge_info.dart'; -import 'symbols/parameter_info.dart'; - -/// Creates a [FieldInfo] from the given [element]. -/// -/// When [FieldsVisitorConfig.includeMergeLookup] is `false`, the merge method -/// lookup is skipped, which speeds up generators that don't emit `merge`. -FieldInfo fieldSymbol( - FieldElement element, { - FieldsVisitorConfig config = const FieldsVisitorConfig(), -}) { - final name = element.displayName; - final elementType = element.type; - final isNullable = elementType.nullabilitySuffix == .question; - final baseType = elementType.baseType; - final isDouble = elementType.isDartCoreDouble; - final isDuration = elementType.isDuration; - - // A static field is dropped by `BaseConfig.filteredFields`, so looking up - // how to interpolate or merge it would only produce noise, or fail the - // build over a field that is never emitted. - if (element.isStatic) { - return FieldInfo( - name: name, - typeName: baseType, - isNullable: isNullable, - isDouble: isDouble, - isDuration: isDuration, - isStatic: true, - merge: const NoMerge(), - lerp: const NoLerp(), - ); - } - - return FieldInfo( - name: name, - typeName: baseType, - isNullable: isNullable, - isDouble: isDouble, - isDuration: isDuration, - isStatic: element.isStatic, - merge: config.includeMergeLookup - ? _mergeInfo(elementType, element) - : const NoMerge(), - lerp: _lerpInfo(elementType, element), - ); -} - -/// Gets information about the lerp method for the given [type]. -/// -/// Returns information about static or instance lerp methods, or [NoLerp] if -/// the type is not an interface, has no lerp method, or has one whose -/// signature we cannot call. A type is free to declare an unrelated `lerp` -/// method, so an unknown signature falls back to [NoLerp] rather than failing -/// the build. -/// -/// Throws [InvalidGenerationSourceError] for a `WidgetStateProperty` field -/// with a non-nullable generic, which is a mistake we can point at. -LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { - if (type is! InterfaceType) { - return const NoLerp(); - } - - final method = _lookupMethod(type, 'lerp'); - - if (method == null) { - return const NoLerp(); - } - - // A required named parameter cannot be filled in by the generated call. - if (method.formalParameters.any((p) => p.isRequiredNamed)) { - _warnUnsupported('lerp', type, fieldElement); - - return const NoLerp(); - } - - // Optional parameters take no part in the signature check: a method stays - // callable the way we expect when it has extra defaulted parameters. - final params = method.formalParameters - .where((p) => p.isRequiredPositional) - .toList(growable: false); - - // A generic method's parameter types mention its own type parameters, which - // can't be substituted here, so those are matched on the declaring class - // only. - final strictSignature = method.typeParameters.isEmpty; - - // WidgetStateProperty and WidgetStateColor use a different signature for - // lerp. Check for the 4-parameter version first, as WidgetStateProperty has - // both 3 and 4 parameter versions - if (params case [final p1, final p2, final p3, final p4] - // Check for static lerp method with 4 parameters - // - first two parameters should have the same type as the class type - // - third parameter should be double - // - fourth parameter is a lerp function for the inner type - when method.isStatic && - p3.type.isDartCoreDouble && - _checkSubtype(p1, type, strict: strictSignature) && - _checkSubtype(p2, type, strict: strictSignature)) { - // The fourth parameter has to be a lerp function itself, with the - // signature `R Function(T? a, T? b, double t)`. - // - // For generic functions like T? Function(T?, T?, double) we can't easily - // check exact type compatibility without type substitution, so only the - // structure is verified. - if (p4.type - case FunctionType( - formalParameters: [final f1, final f2, final f3], - ) - when f1.type.hasNullableSuffix && - f2.type.hasNullableSuffix && - f3.type.isDartCoreDouble) { - // The generic is read from the declaring type rather than from the field - // type, so that a non-generic subclass such as `WidgetStateColor` - // resolves to `WidgetStateProperty`. - final declaringElement = p1.type.element; - final declaringType = declaringElement is InterfaceElement - ? type.asInstanceOf(declaringElement) - : null; - - if (declaringType == null || declaringType.typeArguments.length != 1) { - _warnUnsupported('lerp', type, fieldElement); - - return const NoLerp(); - } - - final baseTypeName = declaringType.element.displayName; - final innerType = declaringType.typeArguments.single; - - // Check that the generic type is nullable - if (!innerType.hasNullableSuffix) { - final typeName = type.getDisplayString(); - final innerTypeName = innerType.getDisplayString(); - - throw InvalidGenerationSourceError( - '$baseTypeName must have a nullable generic type, because ' - '$baseTypeName.lerp requires a lerp function with nullable ' - 'parameters. Found: $typeName', - element: fieldElement, - todo: - 'Change the type of ${fieldElement.displayName} to ' - '$baseTypeName<$innerTypeName?>', - ); - } - - final genericIsDouble = innerType.isDartCoreDouble; - final genericIsDuration = innerType.isDuration; - - // Anything else is interpolated by a static lerp on the generic itself, - // which `WidgetStateProperty.lerp` calls with nullable arguments. - if (!genericIsDouble && !genericIsDuration) { - final innerLerp = _lerpInfo(innerType, fieldElement); - - if (innerLerp is! StaticLerp || - !innerLerp.optionalResult || - !innerLerp.isNullableParameter) { - _warnUnsupported('lerp', innerType, fieldElement); - - return const NoLerp(); - } - } - - return WidgetStatePropertyLerp( - baseTypeName: baseTypeName, - genericType: innerType.baseType, - isNullableGeneric: innerType.hasNullableSuffix, - genericIsDouble: genericIsDouble, - genericIsDuration: genericIsDuration, - ); - } - - // A four parameter lerp whose last parameter isn't a lerp function is not - // something we know how to call. - _warnUnsupported('lerp', type, fieldElement); - - return const NoLerp(); - } - - if (params case [final p1, final p2, final p3] - // Check for static lerp method - // - should have three parameters - // - first two parameters should have the same type as the class type - // - third parameter should be double - when method.isStatic && - p3.type.isDartCoreDouble && - _checkSubtype(p1, type, strict: strictSignature) && - _checkSubtype(p2, type, strict: strictSignature)) { - if (!_isUsableAs(method.returnType, type, type)) { - _warnUnsupported('lerp', type, fieldElement); - - return const NoLerp(); - } - - final args = _mapArgs(params); - - return StaticLerp( - optionalResult: method.returnType.hasNullableSuffix, - args: args, - ); - } else if (params case [final p1, final p2] - // Check for instance lerp method: - // - should have only two parameters - // - first parameter type should match the class type - // - second parameter should be double - when !method.isStatic && - p2.type.isDartCoreDouble && - _checkSubtype(p1, type, strict: strictSignature)) { - // A method declared on a supertype returns that supertype, which the - // generated code casts back to the field type. Anything else is not a - // result we can use. - final needsCast = !_isUsableAs(method.returnType, type, type); - - if (needsCast && !_isUsableAs(type, method.returnType, type)) { - _warnUnsupported('lerp', type, fieldElement); - - return const NoLerp(); - } - - final args = _mapArgs(params); - - return InstanceLerp( - optionalResult: method.returnType.hasNullableSuffix, - args: args, - needsCast: needsCast, - ); - } - - // The type declares a `lerp` we don't know how to call. - _warnUnsupported('lerp', type, fieldElement); - - return const NoLerp(); -} - -/// Reports a [methodName] method that exists but cannot be called. -/// -/// A type is free to declare an unrelated `lerp` or `merge`, so this is not an -/// error, but it is worth saying out loud: without the warning "the type has -/// no such method" and "the method is not one I can call" look the same in the -/// generated code. -void _warnUnsupported( - String methodName, - DartType type, - FieldElement fieldElement, -) { - final fallback = methodName == 'lerp' - ? 'switches over at t = 0.5 instead of being interpolated' - : 'is overwritten instead of being merged'; - - log.warning( - 'The `$methodName` method of ${type.baseType} has an ' - 'unsupported signature, so the field `${fieldElement.displayName}` ' - '$fallback.', - ); -} - -/// Checks that a value of [type] can be passed to [param]. -/// -/// Nullability is ignored on both sides: a lerp method taking `T?` accepts a -/// non-nullable field, and a nullable field is null checked at the call site. -/// -/// When [strict] is `true` the parameter type is compared as written, type -/// arguments included. It has to be `false` for a generic method, whose -/// parameter type mentions type parameters we cannot substitute here; only -/// the declaring class is checked then. -/// -/// Returns `false` if either type is not an interface type or if the subtype -/// relationship doesn't hold. -bool _checkSubtype( - FormalParameterElement param, - DartType type, { - required bool strict, -}) { - final typeElement = type.element; - if (typeElement is! InterfaceElement) { - return false; - } - - final parameterType = param.type; - - final paramTypeElement = parameterType.element; - if (paramTypeElement is! InterfaceElement) { - return false; - } - - final typeSystem = typeElement.library.typeSystem; - final nonNullType = typeSystem.promoteToNonNull(type); - - if (strict) { - return typeSystem.isSubtypeOf( - nonNullType, - typeSystem.promoteToNonNull(parameterType), - ); - } - - final supertypeInstance = type.asInstanceOf(paramTypeElement); - if (supertypeInstance == null) { - return false; - } - - return typeSystem.isSubtypeOf(nonNullType, supertypeInstance); -} - -/// Checks that a value of [subtype] can be used where [supertype] is expected. -/// -/// Nullability is ignored on both sides: a `T? merge(T other)` is still a -/// merge method, the generated code just has to cope with the null. -bool _isUsableAs(DartType subtype, DartType supertype, InterfaceType context) { - final typeSystem = context.element.library.typeSystem; - - return typeSystem.isSubtypeOf( - typeSystem.promoteToNonNull(subtype), - typeSystem.promoteToNonNull(supertype), - ); -} - -/// Maps a list of [parameters] to a list of [ParameterInfo] symbols. -List _mapArgs(List parameters) => - parameters.map(_mapArg).toList(growable: false); - -/// Creates an [ParameterInfo] from the given [parameter]. -ParameterInfo _mapArg(FormalParameterElement parameter) { - final name = parameter.displayName; - final type = parameter.type.getDisplayString(); - final isNullable = parameter.type.nullabilitySuffix == .question; - - return ParameterInfo(name: name, type: type, isNullable: isNullable); -} - -/// Cache for static method lookups to avoid repeated expensive lookups. -/// -/// Only the static lookup is cached: it reads the declaration off the element, -/// which is the same for every instantiation. The instance lookup goes through -/// the [InterfaceType] so that type arguments are substituted, and its result -/// differs between `Box` and `Box`. -/// -/// Using Expando to avoid memory leaks - entries are automatically removed -/// when InterfaceElement is garbage collected. -final _staticMethodCache = Expando>('method_cache'); - -/// Looks up a method with the given [name] on [type]. -/// -/// Instance methods are resolved against the instantiated type, including -/// inherited ones, so their parameter and return types have the type -/// arguments of [type] substituted in. Static methods are neither inherited -/// nor substituted, so they are read off the element. -MethodElement? _lookupMethod(InterfaceType type, String name) { - final instanceMethod = type.lookUpMethod(name, type.element.library); - - if (instanceMethod != null) { - return instanceMethod; - } - - final typeElement = type.element; - final cache = _staticMethodCache[typeElement] ??= {}; - - return cache.putIfAbsent(name, () => typeElement.getMethod(name)); -} - -/// Gets information about the merge method for the given [type]. -/// -/// Returns [NoMerge] when the type is not an interface, has no merge method, -/// or declares one whose signature we cannot call. -MergeInfo _mergeInfo(DartType type, FieldElement fieldElement) { - if (type is! InterfaceType) { - return const NoMerge(); - } - - final typeElement = type.element; - - // Check if element or its supertypes have @ThemeGen annotation. - // Using the annotation implies that the merge method exists, as it is - // impossible to get information about the merge method during the build - // phase. - const themeGenChecker = TypeChecker.typeNamed(ThemeGen); - if (themeGenChecker.hasAnnotationOfExact(typeElement)) { - // The generated merge method takes a nullable argument. - return const InstanceMerge(); - } - - final method = _lookupMethod(type, 'merge'); - if (method == null) { - return const NoMerge(); - } - - // A required named parameter cannot be filled in by the generated call. - if (method.formalParameters.any((p) => p.isRequiredNamed)) { - _warnUnsupported('merge', type, fieldElement); - - return const NoMerge(); - } - - final params = method.formalParameters - .where((p) => p.isRequiredPositional) - .toList(growable: false); - - // A generic method's parameter types mention its own type parameters, which - // can't be substituted here, so those are matched on the declaring class - // only. - final strictSignature = method.typeParameters.isEmpty; - - if (params case [final p1, final p2] - // Check for static merge method - // - should have two parameters - // - both parameters should accept the class type - // - the result should be usable as the class type - when method.isStatic && - _checkSubtype(p1, type, strict: strictSignature) && - _checkSubtype(p2, type, strict: strictSignature) && - _isUsableAs(method.returnType, type, type)) { - return const StaticMerge(); - } - - if (params case [final p1] - // Check for instance merge method: - // - should have only one parameter - // - parameter type should accept the class type - when !method.isStatic && - _checkSubtype(p1, type, strict: strictSignature)) { - // As for lerp: a method declared on a supertype returns that supertype, - // which the generated code casts back to the field type. - final needsCast = !_isUsableAs(method.returnType, type, type); - - if (needsCast && !_isUsableAs(type, method.returnType, type)) { - _warnUnsupported('merge', type, fieldElement); - - return const NoMerge(); - } - - return InstanceMerge( - isNullableParameter: p1.type.hasNullableSuffix, - needsCast: needsCast, - ); - } - - // The type declares a `merge` we don't know how to call. - _warnUnsupported('merge', type, fieldElement); - - return const NoMerge(); -} - -extension DartTypeExtension on DartType { - /// Returns the base type name without nullability suffix. - String get baseType { - final displayString = getDisplayString(); - final result = nullabilitySuffix == .question - ? displayString.replaceFirst(RegExp(r'\?$'), '') - : displayString; - - return result; - } - - /// Returns true if the type has a nullable suffix. - bool get hasNullableSuffix => nullabilitySuffix == .question; - - /// Returns true if the type is `Duration` from `dart:core`. - bool get isDuration { - final typeElement = element; - - return typeElement != null && - typeElement.displayName == 'Duration' && - (typeElement.library?.isDartCore ?? false); - } -} diff --git a/packages/theme_extensions_builder/lib/src/common/dart_type_extension.dart b/packages/theme_extensions_builder/lib/src/common/dart_type_extension.dart new file mode 100644 index 0000000..bbe0388 --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/common/dart_type_extension.dart @@ -0,0 +1,31 @@ +import 'package:analyzer/dart/element/type.dart'; + +/// Helpers for reading a [DartType] the way the generators need it. +extension DartTypeExtension on DartType { + /// The display name of the type without its nullability suffix. + /// + /// This is the name the generated code declares variables and writes casts + /// with, type arguments included. + String get baseType { + final displayString = getDisplayString(); + + return hasNullableSuffix + ? displayString.substring(0, displayString.length - 1) + : displayString; + } + + /// Whether the type is written with a `?`. + bool get hasNullableSuffix => nullabilitySuffix == .question; + + /// Whether the type is `Duration` from `dart:core`. + /// + /// Checked by element rather than by name, so a user type that happens to + /// be called `Duration` is not interpolated as one. + bool get isDuration { + final typeElement = element; + + return typeElement != null && + typeElement.displayName == 'Duration' && + (typeElement.library?.isDartCore ?? false); + } +} diff --git a/packages/theme_extensions_builder/lib/src/common/field_symbol.dart b/packages/theme_extensions_builder/lib/src/common/field_symbol.dart new file mode 100644 index 0000000..5e57fd6 --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/common/field_symbol.dart @@ -0,0 +1,26 @@ +import 'package:analyzer/dart/element/element.dart'; + +import 'dart_type_extension.dart'; +import 'lookup/lerp_lookup.dart'; +import 'lookup/merge_lookup.dart'; +import 'symbols/field_info.dart'; +import 'symbols/merge_info.dart'; + +/// Creates a [FieldInfo] from the given [element]. +/// +/// When [includeMergeLookup] is `false`, the merge method lookup is skipped +/// and the field is reported as [NoMerge]. Use it for generators that don't +/// emit a `merge` method. +FieldInfo fieldSymbol(FieldElement element, {bool includeMergeLookup = true}) { + final type = element.type; + + return FieldInfo( + name: element.displayName, + typeName: type.baseType, + isNullable: type.hasNullableSuffix, + isDouble: type.isDartCoreDouble, + isDuration: type.isDuration, + merge: includeMergeLookup ? mergeInfo(type, element) : const NoMerge(), + lerp: lerpInfo(type, element), + ); +} diff --git a/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart b/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart index 523c71a..1d8adcd 100644 --- a/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart +++ b/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart @@ -1,12 +1,10 @@ import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:analyzer/dart/element/visitor2.dart'; -import 'package:source_gen/source_gen.dart'; -import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import 'analysis.dart'; -import 'fields_visitor_config.dart'; +import 'field_symbol.dart'; import 'symbols/field_info.dart'; +import 'type_checkers.dart'; /// Collects the fields of [element] together with the fields it inherits. /// @@ -19,12 +17,13 @@ import 'symbols/field_info.dart'; /// first declaration of a name wins, so the declaration actually in effect is /// the one that is collected. /// -/// The [config] controls what information is collected for each field. +/// When [includeMergeLookup] is `false`, no merge method is looked up; see +/// [fieldSymbol]. List collectFields( ClassElement element, { - FieldsVisitorConfig config = const FieldsVisitorConfig(), + bool includeMergeLookup = true, }) { - final visitor = FieldsVisitor(config: config); + final visitor = FieldsVisitor(includeMergeLookup: includeMergeLookup); element.visitChildren(visitor); @@ -52,31 +51,17 @@ Iterable _inheritedTypes(InterfaceType type) sync* { /// A visitor that collects field information from a class element. /// -/// This visitor traverses class elements and extracts information about their -/// fields, converting them into [FieldInfo] objects. Fields annotated with -/// `@ignore` are excluded from the collection. -/// -/// The visitor only processes non-synthetic fields (fields that are explicitly -/// declared in the source code, not generated by the compiler). -/// -/// Example usage: -/// ```dart -/// // FieldsVisitorConfig config (default) -/// final visitor = FieldsVisitor(); -/// classElement.visitChildren(visitor); -/// final fields = visitor.fields; -/// ``` +/// Only fields the generated code can pass to a constructor are collected: +/// explicitly declared instance fields that are public and not annotated with +/// `@ignore`. class FieldsVisitor extends SimpleElementVisitor2 { - /// Creates a [FieldsVisitor] with the specified [config]. + /// Creates a [FieldsVisitor]. /// - /// The [config] controls what information should be collected during field - /// visiting. - FieldsVisitor({this.config = const FieldsVisitorConfig()}); + /// When [includeMergeLookup] is `false`, no merge method is looked up. + FieldsVisitor({this.includeMergeLookup = true}); - /// Configuration controlling what information to collect. - /// - /// See [FieldsVisitorConfig] for available options and presets. - final FieldsVisitorConfig config; + /// Whether to look up merge methods on field types. + final bool includeMergeLookup; /// Collected field information, keyed by field name. /// @@ -91,32 +76,24 @@ class FieldsVisitor extends SimpleElementVisitor2 { /// declaration too, so a skipped name still claims its place. final Set _claimed = {}; - /// Returns an immutable list of collected field information. - /// - /// The list is created from the internal map, preserving the order in which - /// the fields were visited and preventing external modification. + /// The collected fields, in the order they were visited. List get fields => _fields.values.toList(growable: false); - /// Type checker used to identify fields annotated with `@ignore`. - /// - /// Fields with this annotation will be skipped during the visit. - final ignoreAnnotationTypeChecker = TypeChecker.typeNamed(ignore.runtimeType); - - /// Visits a field element and collects its information if applicable. - /// - /// The field is added to the collection if: - /// - It is not annotated with `@ignore` - /// - It is not synthetic (compiler-generated) - /// - /// Synthetic fields are typically generated for getters/setters and should - /// not be included in the collected field information. @override void visitFieldElement(FieldElement element) { - // Only process non-synthetic fields (explicitly declared in source code) + // Only explicitly declared fields: a synthetic field backs a getter or + // setter, and cannot be passed to a constructor. if (!element.isOriginDeclaration) { return; } + // A static field is not part of an instance. Dart forbids a static and an + // instance member of the same name in one hierarchy, so it cannot shadow + // an inherited field either. + if (element.isStatic) { + return; + } + // A private field cannot be passed to a generated constructor call, and a // private name is not a valid named parameter either. if (element.isPrivate) { @@ -129,11 +106,13 @@ class FieldsVisitor extends SimpleElementVisitor2 { return; } - // Skip fields annotated with @ignore - if (ignoreAnnotationTypeChecker.hasAnnotationOf(element)) { + if (ignoreChecker.hasAnnotationOf(element)) { return; } - _fields[name] = fieldSymbol(element, config: config); + _fields[name] = fieldSymbol( + element, + includeMergeLookup: includeMergeLookup, + ); } } diff --git a/packages/theme_extensions_builder/lib/src/common/fields_visitor_config.dart b/packages/theme_extensions_builder/lib/src/common/fields_visitor_config.dart deleted file mode 100644 index 1db2f74..0000000 --- a/packages/theme_extensions_builder/lib/src/common/fields_visitor_config.dart +++ /dev/null @@ -1,42 +0,0 @@ -/// @docImport 'fields_visitor.dart'; - -library; - -/// Configuration for [FieldsVisitor] behavior. -/// -/// This class controls what information should be collected during field -/// visiting. Disabling unnecessary lookups can significantly improve -/// performance for generators that don't use certain features. -class FieldsVisitorConfig { - /// Creates a [FieldsVisitorConfig] with the specified settings. - /// - /// Example usage: - /// ```dart - /// // For ThemeGen, which generates a merge method - /// const config = FieldsVisitorConfig(); - /// - /// // For ThemeExtensions, which does not - /// const config = FieldsVisitorConfig(includeMergeLookup: false); - /// ``` - const FieldsVisitorConfig({this.includeMergeLookup = true}); - - /// Whether to look up merge methods on field types. - /// - /// When `false`, the lookup is skipped and every field is reported as - /// `NoMerge`. Use it for generators that don't emit a `merge` method. - final bool includeMergeLookup; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is FieldsVisitorConfig && - runtimeType == other.runtimeType && - includeMergeLookup == other.includeMergeLookup; - - @override - int get hashCode => Object.hash(runtimeType, includeMergeLookup); - - @override - String toString() => - 'FieldsVisitorConfig(includeMergeLookup: $includeMergeLookup)'; -} diff --git a/packages/theme_extensions_builder/lib/src/common/lookup/lerp_lookup.dart b/packages/theme_extensions_builder/lib/src/common/lookup/lerp_lookup.dart new file mode 100644 index 0000000..ac8ba00 --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/common/lookup/lerp_lookup.dart @@ -0,0 +1,193 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:source_gen/source_gen.dart'; + +import '../dart_type_extension.dart'; +import '../symbols/lerp_info.dart'; +import 'method_lookup.dart'; + +/// Decides how a field of [type] is interpolated. +/// +/// Returns information about static or instance lerp methods, or [NoLerp] if +/// the type is not an interface, has no lerp method, or has one whose +/// signature we cannot call. A type is free to declare an unrelated `lerp` +/// method, so an unknown signature falls back to [NoLerp] rather than failing +/// the build. +/// +/// Throws [InvalidGenerationSourceError] for a `WidgetStateProperty` field +/// with a non-nullable generic, which is a mistake we can point at. +LerpInfo lerpInfo(DartType type, FieldElement fieldElement) { + if (type is! InterfaceType) { + return const NoLerp(); + } + + final method = lookupMethod(type, 'lerp'); + + if (method == null) { + return const NoLerp(); + } + + final params = callableParameters(method); + + if (params == null) { + warnUnsupported('lerp', type, fieldElement); + + return const NoLerp(); + } + + final strict = hasStrictSignature(method); + + // WidgetStateProperty and WidgetStateColor use a different signature for + // lerp. Check for the 4-parameter version first, as WidgetStateProperty has + // both 3 and 4 parameter versions. + if (params case [final p1, final p2, final p3, final p4] + // Check for static lerp method with 4 parameters + // - first two parameters should have the same type as the class type + // - third parameter should be double + // - fourth parameter is a lerp function for the inner type + when method.isStatic && + p3.type.isDartCoreDouble && + checkSubtype(p1, type, strict: strict) && + checkSubtype(p2, type, strict: strict)) { + return _widgetStatePropertyLerp(type, p1, p4, fieldElement); + } + + if (params case [final p1, final p2, final p3] + // Check for static lerp method + // - should have three parameters + // - first two parameters should have the same type as the class type + // - third parameter should be double + when method.isStatic && + p3.type.isDartCoreDouble && + checkSubtype(p1, type, strict: strict) && + checkSubtype(p2, type, strict: strict)) { + if (!isUsableAs(method.returnType, type, type)) { + warnUnsupported('lerp', type, fieldElement); + + return const NoLerp(); + } + + return StaticLerp( + optionalResult: method.returnType.hasNullableSuffix, + isNullableParameter: + p1.type.hasNullableSuffix && p2.type.hasNullableSuffix, + ); + } + + if (params case [final p1, final p2] + // Check for instance lerp method: + // - should have only two parameters + // - first parameter type should match the class type + // - second parameter should be double + when !method.isStatic && + p2.type.isDartCoreDouble && + checkSubtype(p1, type, strict: strict)) { + // A method declared on a supertype returns that supertype, which the + // generated code casts back to the field type. Anything else is not a + // result we can use. + final needsCast = !isUsableAs(method.returnType, type, type); + + if (needsCast && !isUsableAs(type, method.returnType, type)) { + warnUnsupported('lerp', type, fieldElement); + + return const NoLerp(); + } + + return InstanceLerp( + optionalResult: method.returnType.hasNullableSuffix, + needsCast: needsCast, + ); + } + + // The type declares a `lerp` we don't know how to call. + warnUnsupported('lerp', type, fieldElement); + + return const NoLerp(); +} + +/// Decides how a `WidgetStateProperty` shaped [type] is interpolated. +/// +/// [p1] is the first parameter of the four parameter `lerp`, which names the +/// declaring type, and [lerpFunction] is its last parameter. +LerpInfo _widgetStatePropertyLerp( + InterfaceType type, + FormalParameterElement p1, + FormalParameterElement lerpFunction, + FieldElement fieldElement, +) { + // The fourth parameter has to be a lerp function itself, with the + // signature `R Function(T? a, T? b, double t)`. + // + // For generic functions like T? Function(T?, T?, double) we can't easily + // check exact type compatibility without type substitution, so only the + // structure is verified. + if (lerpFunction.type + case FunctionType(formalParameters: [final f1, final f2, final f3]) + when f1.type.hasNullableSuffix && + f2.type.hasNullableSuffix && + f3.type.isDartCoreDouble) { + // The generic is read from the declaring type rather than from the field + // type, so that a non-generic subclass such as `WidgetStateColor` + // resolves to `WidgetStateProperty`. + final declaringElement = p1.type.element; + final declaringType = declaringElement is InterfaceElement + ? type.asInstanceOf(declaringElement) + : null; + + if (declaringType == null || declaringType.typeArguments.length != 1) { + warnUnsupported('lerp', type, fieldElement); + + return const NoLerp(); + } + + final baseTypeName = declaringType.element.displayName; + final innerType = declaringType.typeArguments.single; + + // Check that the generic type is nullable + if (!innerType.hasNullableSuffix) { + final typeName = type.getDisplayString(); + final innerTypeName = innerType.getDisplayString(); + + throw InvalidGenerationSourceError( + '$baseTypeName must have a nullable generic type, because ' + '$baseTypeName.lerp requires a lerp function with nullable ' + 'parameters. Found: $typeName', + element: fieldElement, + todo: + 'Change the type of ${fieldElement.displayName} to ' + '$baseTypeName<$innerTypeName?>', + ); + } + + final genericIsDouble = innerType.isDartCoreDouble; + final genericIsDuration = innerType.isDuration; + + // Anything else is interpolated by a static lerp on the generic itself, + // which `WidgetStateProperty.lerp` calls with nullable arguments. + if (!genericIsDouble && !genericIsDuration) { + final innerLerp = lerpInfo(innerType, fieldElement); + + if (innerLerp is! StaticLerp || + !innerLerp.optionalResult || + !innerLerp.isNullableParameter) { + warnUnsupported('lerp', innerType, fieldElement); + + return const NoLerp(); + } + } + + return WidgetStatePropertyLerp( + baseTypeName: baseTypeName, + genericType: innerType.baseType, + isNullableGeneric: innerType.hasNullableSuffix, + genericIsDouble: genericIsDouble, + genericIsDuration: genericIsDuration, + ); + } + + // A four parameter lerp whose last parameter isn't a lerp function is not + // something we know how to call. + warnUnsupported('lerp', type, fieldElement); + + return const NoLerp(); +} diff --git a/packages/theme_extensions_builder/lib/src/common/lookup/merge_lookup.dart b/packages/theme_extensions_builder/lib/src/common/lookup/merge_lookup.dart new file mode 100644 index 0000000..3e37f45 --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/common/lookup/merge_lookup.dart @@ -0,0 +1,77 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; + +import '../dart_type_extension.dart'; +import '../symbols/merge_info.dart'; +import '../type_checkers.dart'; +import 'method_lookup.dart'; + +/// Decides how a field of [type] is merged. +/// +/// Returns [NoMerge] when the type is not an interface, has no merge method, +/// or declares one whose signature we cannot call. +MergeInfo mergeInfo(DartType type, FieldElement fieldElement) { + if (type is! InterfaceType) { + return const NoMerge(); + } + + // A `@ThemeGen` class gets its `merge` from the generated mixin, which may + // not exist yet when this runs. The annotation is taken as the promise that + // it will: `T merge(T? other)`. + if (themeGenChecker.hasAnnotationOfExact(type.element)) { + return const InstanceMerge(); + } + + final method = lookupMethod(type, 'merge'); + if (method == null) { + return const NoMerge(); + } + + final params = callableParameters(method); + + if (params == null) { + warnUnsupported('merge', type, fieldElement); + + return const NoMerge(); + } + + final strict = hasStrictSignature(method); + + if (params case [final p1, final p2] + // Check for static merge method + // - should have two parameters + // - both parameters should accept the class type + // - the result should be usable as the class type + when method.isStatic && + checkSubtype(p1, type, strict: strict) && + checkSubtype(p2, type, strict: strict) && + isUsableAs(method.returnType, type, type)) { + return const StaticMerge(); + } + + if (params case [final p1] + // Check for instance merge method: + // - should have only one parameter + // - parameter type should accept the class type + when !method.isStatic && checkSubtype(p1, type, strict: strict)) { + // As for lerp: a method declared on a supertype returns that supertype, + // which the generated code casts back to the field type. + final needsCast = !isUsableAs(method.returnType, type, type); + + if (needsCast && !isUsableAs(type, method.returnType, type)) { + warnUnsupported('merge', type, fieldElement); + + return const NoMerge(); + } + + return InstanceMerge( + isNullableParameter: p1.type.hasNullableSuffix, + needsCast: needsCast, + ); + } + + // The type declares a `merge` we don't know how to call. + warnUnsupported('merge', type, fieldElement); + + return const NoMerge(); +} diff --git a/packages/theme_extensions_builder/lib/src/common/lookup/method_lookup.dart b/packages/theme_extensions_builder/lib/src/common/lookup/method_lookup.dart new file mode 100644 index 0000000..d731942 --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/common/lookup/method_lookup.dart @@ -0,0 +1,120 @@ +/// Shared pieces of the `lerp` and `merge` lookups. +library; + +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:build/build.dart'; + +import '../dart_type_extension.dart'; + +/// Looks up a method with the given [name] on [type]. +/// +/// Instance methods are resolved against the instantiated type, including +/// inherited ones, so their parameter and return types have the type +/// arguments of [type] substituted in. Static methods are neither inherited +/// nor substituted, so they are read off the element. +MethodElement? lookupMethod(InterfaceType type, String name) => + type.lookUpMethod(name, type.element.library) ?? + type.element.getMethod(name); + +/// The parameters the generated code has to fill in to call [method]. +/// +/// Optional parameters take no part in a signature check: a method stays +/// callable the way we expect when it has extra defaulted parameters. A +/// required named parameter cannot be filled in at all, which is reported as +/// `null`. +List? callableParameters(MethodElement method) { + if (method.formalParameters.any((p) => p.isRequiredNamed)) { + return null; + } + + return method.formalParameters + .where((p) => p.isRequiredPositional) + .toList(growable: false); +} + +/// Whether the parameter types of [method] can be compared as written. +/// +/// A generic method's parameter types mention its own type parameters, which +/// can't be substituted here, so those are matched on the declaring class +/// only. +bool hasStrictSignature(MethodElement method) => method.typeParameters.isEmpty; + +/// Checks that a value of [type] can be passed to [param]. +/// +/// Nullability is ignored on both sides: a lerp method taking `T?` accepts a +/// non-nullable field, and a nullable field is null checked at the call site. +/// +/// When [strict] is `true` the parameter type is compared as written, type +/// arguments included. It has to be `false` for a generic method, whose +/// parameter type mentions type parameters we cannot substitute here; only +/// the declaring class is checked then. +bool checkSubtype( + FormalParameterElement param, + DartType type, { + required bool strict, +}) { + final typeElement = type.element; + if (typeElement is! InterfaceElement) { + return false; + } + + final parameterType = param.type; + + final paramTypeElement = parameterType.element; + if (paramTypeElement is! InterfaceElement) { + return false; + } + + final typeSystem = typeElement.library.typeSystem; + final nonNullType = typeSystem.promoteToNonNull(type); + + if (strict) { + return typeSystem.isSubtypeOf( + nonNullType, + typeSystem.promoteToNonNull(parameterType), + ); + } + + final supertypeInstance = type.asInstanceOf(paramTypeElement); + if (supertypeInstance == null) { + return false; + } + + return typeSystem.isSubtypeOf(nonNullType, supertypeInstance); +} + +/// Checks that a value of [subtype] can be used where [supertype] is expected. +/// +/// Nullability is ignored on both sides: a `T? merge(T other)` is still a +/// merge method, the generated code just has to cope with the null. +bool isUsableAs(DartType subtype, DartType supertype, InterfaceType context) { + final typeSystem = context.element.library.typeSystem; + + return typeSystem.isSubtypeOf( + typeSystem.promoteToNonNull(subtype), + typeSystem.promoteToNonNull(supertype), + ); +} + +/// Reports a [methodName] method that exists but cannot be called. +/// +/// A type is free to declare an unrelated `lerp` or `merge`, so this is not an +/// error, but it is worth saying out loud: without the warning "the type has +/// no such method" and "the method is not one I can call" look the same in the +/// generated code. +void warnUnsupported( + String methodName, + DartType type, + FieldElement fieldElement, +) { + final fallback = methodName == 'lerp' + ? 'switches over at t = 0.5 instead of being interpolated' + : 'is overwritten instead of being merged'; + + log.warning( + 'The `$methodName` method of ${type.baseType} has an ' + 'unsupported signature, so the field `${fieldElement.displayName}` ' + '$fallback.', + ); +} diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/field_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/field_info.dart index ffc8e7b..6b692cc 100644 --- a/packages/theme_extensions_builder/lib/src/common/symbols/field_info.dart +++ b/packages/theme_extensions_builder/lib/src/common/symbols/field_info.dart @@ -1,11 +1,11 @@ import 'lerp_info.dart'; import 'merge_info.dart'; -/// Represents comprehensive information about a class field during code -/// generation. +/// What the generators need to know about one instance field. /// -/// This class stores all metadata needed to generate `copyWith`, `lerp`, -/// `merge`, `==`, and `hashCode` methods for theme extensions. +/// Carries everything needed to emit `copyWith`, `lerp`, `merge`, `==` and +/// `hashCode` for the field. Static and private fields, and fields marked +/// `@ignore`, are never turned into a [FieldInfo]. final class FieldInfo { /// Creates a [FieldInfo] with the specified properties. const FieldInfo({ @@ -16,7 +16,6 @@ final class FieldInfo { required this.isDuration, required this.merge, required this.lerp, - required this.isStatic, }); /// The name of the field. @@ -58,11 +57,6 @@ final class FieldInfo { /// Information about how to interpolate (lerp) this field type. final LerpInfo lerp; - /// Whether the field is static. - /// - /// Static fields are typically filtered out during code generation. - final bool isStatic; - @override bool operator ==(Object other) => identical(this, other) || @@ -73,7 +67,6 @@ final class FieldInfo { isNullable == other.isNullable && isDouble == other.isDouble && isDuration == other.isDuration && - isStatic == other.isStatic && merge == other.merge && lerp == other.lerp; @@ -85,7 +78,6 @@ final class FieldInfo { isNullable, isDouble, isDuration, - isStatic, merge, lerp, ); @@ -97,7 +89,6 @@ final class FieldInfo { 'isNullable: $isNullable, ' 'isDouble: $isDouble, ' 'isDuration: $isDuration, ' - 'isStatic: $isStatic, ' 'merge: $merge, ' 'lerp: $lerp)'; } diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart index ee5c424..50b7265 100644 --- a/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart +++ b/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart @@ -1,37 +1,26 @@ -import 'package:collection/collection.dart'; - -import 'parameter_info.dart'; - -const _listEquality = ListEquality(); - -/// Base sealed class representing information about a lerp (linear -/// interpolation) method. +/// How a field type is interpolated. /// -/// This is used during code generation to determine how to generate lerp -/// logic for different field types. +/// Decided once per field while the class is analysed, then switched over by +/// the code builders. sealed class LerpInfo { const LerpInfo(); } -/// Represents a static lerp method with specific signature requirements. -/// -/// Static lerp methods typically have the signature: -/// `static T? lerp(T? a, T? b, double t)` +/// A static `lerp` on the field type: `static T? lerp(T? a, T? b, double t)`. final class StaticLerp extends LerpInfo { /// Creates a [StaticLerp] with the specified properties. - const StaticLerp({required this.optionalResult, required this.args}); - - /// The parameters of the lerp method. - final List args; + const StaticLerp({ + required this.optionalResult, + required this.isNullableParameter, + }); /// Whether the return type of the lerp method is nullable. final bool optionalResult; - /// Returns `true` if the lerp method accepts nullable arguments. + /// Whether the method accepts a null on both sides. /// /// When it doesn't, the call site has to guard against a null itself. - bool get isNullableParameter => - args.length >= 2 && args[0].isNullable && args[1].isNullable; + final bool isNullableParameter; @override bool operator ==(Object other) => @@ -39,31 +28,26 @@ final class StaticLerp extends LerpInfo { other is StaticLerp && runtimeType == other.runtimeType && optionalResult == other.optionalResult && - _listEquality.equals(args, other.args); + isNullableParameter == other.isNullableParameter; @override int get hashCode => - Object.hash(runtimeType, optionalResult, _listEquality.hash(args)); + Object.hash(runtimeType, optionalResult, isNullableParameter); @override String toString() => - 'StaticLerp(optionalResult: $optionalResult, args: $args)'; + 'StaticLerp(optionalResult: $optionalResult, ' + 'isNullableParameter: $isNullableParameter)'; } -/// Represents an instance lerp method on a class. +/// An instance `lerp` on the field type: `T lerp(T other, double t)`. /// -/// Instance lerp methods typically have the signature: -/// `T lerp(T other, double t)` +/// The generated code never passes a null to it: a nullable field is guarded +/// at the call site whatever the parameter type is, so that `t == 0` keeps +/// `a` and `t == 1` keeps `b` when the other side is null. final class InstanceLerp extends LerpInfo { /// Creates an [InstanceLerp] with the specified properties. - const InstanceLerp({ - required this.optionalResult, - required this.args, - this.needsCast = false, - }); - - /// The parameters of the lerp method. - final List args; + const InstanceLerp({required this.optionalResult, this.needsCast = false}); /// Whether the return type of the lerp method is nullable. final bool optionalResult; @@ -82,23 +66,18 @@ final class InstanceLerp extends LerpInfo { other is InstanceLerp && runtimeType == other.runtimeType && optionalResult == other.optionalResult && - needsCast == other.needsCast && - _listEquality.equals(args, other.args); + needsCast == other.needsCast; @override - int get hashCode => Object.hash( - runtimeType, - optionalResult, - needsCast, - _listEquality.hash(args), - ); + int get hashCode => Object.hash(runtimeType, optionalResult, needsCast); @override String toString() => - 'InstanceLerp(optionalResult: $optionalResult, ' - 'needsCast: $needsCast, args: $args)'; + 'InstanceLerp(optionalResult: $optionalResult, needsCast: $needsCast)'; } +/// A `WidgetStateProperty` shaped field, interpolated through the four +/// parameter `WidgetStateProperty.lerp` with a lerp function for the generic. final class WidgetStatePropertyLerp extends LerpInfo { /// Creates a [WidgetStatePropertyLerp] with the specified properties. const WidgetStatePropertyLerp({ @@ -113,10 +92,11 @@ final class WidgetStatePropertyLerp extends LerpInfo { /// For `WidgetStateProperty` this is 'WidgetStateProperty'. final String baseTypeName; - /// The generic type with nullability. + /// The generic type without its nullability suffix. /// For `WidgetStateProperty` this is 'Color'. final String genericType; + /// Whether the generic type is nullable. final bool isNullableGeneric; /// Whether the generic is `double` from `dart:core`. @@ -136,6 +116,8 @@ final class WidgetStatePropertyLerp extends LerpInfo { return index == -1 ? genericType : genericType.substring(0, index); } + // genericIsDouble and genericIsDuration follow from genericType, so they + // take no part in equality. @override bool operator ==(Object other) => identical(this, other) || @@ -155,15 +137,13 @@ final class WidgetStatePropertyLerp extends LerpInfo { 'baseTypeName: $baseTypeName, ' 'genericType: $genericType, ' 'isNullableGeneric: $isNullableGeneric)'; - - // genericIsDouble and genericIsDuration follow from genericType, so they - // take no part in equality. } -/// Indicates that no lerp method is available for the field type. +/// No usable lerp method on the field type. /// -/// When this is used, the generator will fall back to a simple conditional -/// expression: `t < 0.5 ? a : b` +/// `double` and `Duration` fields are still interpolated, through +/// `lerpDouble$` and `lerpDuration$`. Anything else switches over at +/// `t < 0.5 ? a : b`. final class NoLerp extends LerpInfo { /// Creates a [NoLerp] instance. const NoLerp(); diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/parameter_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/parameter_info.dart deleted file mode 100644 index 0a85ddc..0000000 --- a/packages/theme_extensions_builder/lib/src/common/symbols/parameter_info.dart +++ /dev/null @@ -1,37 +0,0 @@ -/// Represents information about a method parameter during code generation. -/// -/// This class stores metadata about parameters in lerp and merge methods, -/// including the parameter name, type, and nullability. -final class ParameterInfo { - /// Creates a [ParameterInfo] with the specified properties. - const ParameterInfo({ - required this.name, - required this.type, - required this.isNullable, - }); - - /// The name of the parameter. - final String name; - - /// The type of the parameter as a string. - final String type; - - /// Whether the parameter type is nullable. - final bool isNullable; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ParameterInfo && - runtimeType == other.runtimeType && - name == other.name && - type == other.type && - isNullable == other.isNullable; - - @override - int get hashCode => Object.hash(runtimeType, name, type, isNullable); - - @override - String toString() => - 'ParameterInfo(name: $name, type: $type, isNullable: $isNullable)'; -} diff --git a/packages/theme_extensions_builder/lib/src/common/type_checkers.dart b/packages/theme_extensions_builder/lib/src/common/type_checkers.dart new file mode 100644 index 0000000..542c3f7 --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/common/type_checkers.dart @@ -0,0 +1,22 @@ +import 'package:source_gen/source_gen.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +/// The package the annotations come from. +/// +/// Every checker is restricted to it: a user class that happens to be called +/// `ThemeGen` must not be mistaken for the annotation. +const annotationPackage = 'theme_extensions_builder_annotation'; + +/// Matches the `@ThemeGen` annotation. +const themeGenChecker = TypeChecker.typeNamed( + ThemeGen, + inPackage: annotationPackage, +); + +/// Matches the `@ignore` annotation. +/// +/// The annotation class is private, so it is reached through the constant. +final ignoreChecker = TypeChecker.typeNamed( + ignore.runtimeType, + inPackage: annotationPackage, +); diff --git a/packages/theme_extensions_builder/lib/src/common/validation.dart b/packages/theme_extensions_builder/lib/src/common/validation.dart new file mode 100644 index 0000000..cfd794a --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/common/validation.dart @@ -0,0 +1,138 @@ +/// Checks on the annotated class that turn a cryptic error in the generated +/// file into an [InvalidGenerationSourceError] pointing at the cause. +library; + +import 'package:analyzer/dart/element/element.dart'; +import 'package:source_gen/source_gen.dart'; + +import 'symbols/field_info.dart'; + +/// Resolves the constructor the generated code instantiates [element] with. +/// +/// [name] is the `constructor` option of the annotation; `null` selects the +/// unnamed constructor. +ConstructorElement resolveConstructor(ClassElement element, String? name) { + final className = element.displayName; + final constructor = name == null + ? element.unnamedConstructor + : element.getNamedConstructor(name); + + if (constructor != null) { + return constructor; + } + + if (name == null) { + throw InvalidGenerationSourceError( + '`$className` has no unnamed constructor, which the generated code ' + 'calls.', + element: element, + todo: + 'Declare `$className({...})`, or point `constructor:` at the ' + 'constructor to use.', + ); + } + + throw InvalidGenerationSourceError( + '`$className` has no constructor named `$name`.', + element: element, + todo: + 'Declare `$className.$name({...})`, or point `constructor:` at an ' + 'existing constructor.', + ); +} + +/// Checks that every one of [fields] can be passed to [constructor] by name. +/// +/// The generated `copyWith`, `lerp` and `merge` build a new instance with one +/// named argument per field, so each field needs a named parameter of the +/// same name. +void checkConstructorParameters( + ClassElement element, + ConstructorElement constructor, + List fields, +) { + final named = { + for (final parameter in constructor.formalParameters) + if (parameter.isNamed) parameter.displayName, + }; + + final missing = [ + for (final field in fields) + if (!named.contains(field.name)) field.name, + ]; + + if (missing.isEmpty) { + return; + } + + final constructorName = constructor.name == 'new' + ? element.displayName + : '${element.displayName}.${constructor.name}'; + final fieldList = missing.map((name) => '`$name`').join(', '); + final plural = missing.length > 1; + + throw InvalidGenerationSourceError( + 'The constructor `$constructorName` has no named ' + '${plural ? 'parameters' : 'parameter'} for the ' + '${plural ? 'fields' : 'field'} $fieldList, which the generated code ' + 'passes to it.', + element: element, + todo: + 'Add `this.${missing.first}`${plural ? ' and the others' : ''} to ' + '`$constructorName`, or mark the ' + '${plural ? 'fields' : 'field'} with `@ignore`.', + ); +} + +/// Checks that [element] extends `ThemeExtension`. +/// +/// The generated mixin is declared `on ThemeExtension`, so it cannot be +/// applied to anything else. +void checkExtendsThemeExtension(ClassElement element) { + final className = element.displayName; + + final themeExtension = element.allSupertypes + .where((type) => type.element.name == 'ThemeExtension') + .firstOrNull; + + final typeArguments = themeExtension?.typeArguments; + + if (typeArguments != null && + typeArguments.length == 1 && + typeArguments.single.element == element) { + return; + } + + throw InvalidGenerationSourceError( + '`$className` must extend `ThemeExtension<$className>` to be annotated ' + 'with `@ThemeExtensions`.', + element: element, + todo: + 'Declare it as ' + '`class $className extends ThemeExtension<$className> ' + 'with _\$$className`, or use `@ThemeGen` for a plain class.', + ); +} + +final _identifier = RegExp(r'^[A-Za-z_$][A-Za-z0-9_$]*$'); + +/// Checks that [value], given as the annotation option [option], can be +/// written into the generated code as a name. +void checkIdentifier( + String value, { + required String option, + required ClassElement element, +}) { + if (_identifier.hasMatch(value)) { + return; + } + + throw InvalidGenerationSourceError( + '`$value` is not a valid Dart identifier, so it cannot be used as ' + '`$option`.', + element: element, + todo: + r'Use letters, digits, `_` and `$` only, and do not start with a ' + 'digit.', + ); +} diff --git a/packages/theme_extensions_builder/lib/src/config/config.dart b/packages/theme_extensions_builder/lib/src/config/config.dart index 2e0122f..62f6182 100644 --- a/packages/theme_extensions_builder/lib/src/config/config.dart +++ b/packages/theme_extensions_builder/lib/src/config/config.dart @@ -17,13 +17,9 @@ sealed class BaseConfig { required this.constConstructor, }); - /// The fields to be included in the generated theme extension. + /// The fields the generated code is built from. final List fields; - /// The fields that are supported for generation (non-static fields). - Iterable get filteredFields => - fields.where((field) => !field.isStatic); - /// The name of the class to be generated. final String className; @@ -31,7 +27,9 @@ sealed class BaseConfig { /// constructor will be used. final String? constructor; - /// Whether to generate a const constructor. + /// Whether [constructor] is `const`. + /// + /// A const constructor is invoked with `const` when it takes no arguments. final bool constConstructor; } diff --git a/packages/theme_extensions_builder/lib/src/generator/annotation_reader.dart b/packages/theme_extensions_builder/lib/src/generator/annotation_reader.dart new file mode 100644 index 0000000..cdd0886 --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/generator/annotation_reader.dart @@ -0,0 +1,22 @@ +import 'package:source_gen/source_gen.dart'; + +/// Reads the optional string options of an annotation. +extension AnnotationReader on ConstantReader { + /// The value of the string field [name], or `null` when it is null or + /// empty. + /// + /// An empty string means the same as leaving the option out, so that + /// `constructor: ''` selects the unnamed constructor rather than emitting + /// `ClassName.()`. + String? optionalString(String name) { + final value = read(name); + + if (value.isNull) { + return null; + } + + final string = value.stringValue; + + return string.isEmpty ? null : string; + } +} diff --git a/packages/theme_extensions_builder/lib/src/generator/common.dart b/packages/theme_extensions_builder/lib/src/generator/common.dart index b3c2f97..5184734 100644 --- a/packages/theme_extensions_builder/lib/src/generator/common.dart +++ b/packages/theme_extensions_builder/lib/src/generator/common.dart @@ -1,26 +1,231 @@ -/// Common code generation utilities for theme builders. -/// -/// This library provides shared code generation functions used by both -/// ThemeExtensions and ThemeGen generators, including equality operators, -/// hash codes, and utility extensions. +/// Code generation shared by the `@ThemeExtensions` and `@ThemeGen` +/// generators: `copyWith`, the per-field `lerp` expression, `==`, `hashCode`, +/// and the small helpers around `code_builder`. library; import 'package:code_builder/code_builder.dart'; -import 'package:meta/meta.dart'; +import '../common/symbols/field_info.dart'; +import '../common/symbols/lerp_info.dart'; import '../config/config.dart'; +/// The local the generated methods read the current instance through. +/// +/// The methods live in a mixin, so `this` is the mixin type; the local holds +/// it cast to the class, which is what the fields are declared on. +const thisAlias = '_this'; + +/// A reference to [thisAlias]. +Reference get thisRef => thisAlias.ref; + +/// The `t` parameter of a `lerp`. +Reference get tRef => 't'.ref; + +/// Emits the `DartEmitter` configuration every generator uses. +/// +/// The output is a part file, so nothing is ever imported and no allocator is +/// needed; only the null safety syntax matters. +DartEmitter partEmitter() => DartEmitter(useNullSafetySyntax: true); + +/// `final _this = (this as ClassName);` +Expression declareThis(BaseConfig config) => + declareFinal(thisAlias).assign('this'.ref.asA(config.className.ref)); + +/// Builds a new instance of the configured class with [args]. +/// +/// The call is `const` when the constructor allows it and there is nothing +/// to pass, which is the only case where the arguments are constant too. +Expression construct(BaseConfig config, Map args) => + (args.isEmpty && config.constConstructor + ? InvokeExpression.constOf + : InvokeExpression.newOf)( + config.className.ref, + [], + args, + [], + config.constructor, + ); + +/// Generates `copyWith`: every field as an optional named parameter that +/// falls back to the current value. +Method copyWithMethod( + BaseConfig config, { + required Reference returns, + bool isOverride = false, +}) => Method((m) { + final fields = config.fields; + + m + ..name = 'copyWith' + ..returns = returns + ..optionalParameters.addAll( + fields.map( + (field) => Parameter( + (p) => p + ..name = field.name + ..named = true + ..type = field.typeName.typeRef(isNullable: true), + ), + ), + ) + ..body = Block((b) { + if (fields.isNotEmpty) { + b + ..addExpression(declareThis(config)) + ..addEmptyLine(); + } + + b.addExpression( + construct(config, { + for (final field in fields) + field.name: field.name.ref.ifNullThen(thisRef.property(field.name)), + }).returned, + ); + }); + + if (isOverride) { + m.annotations.add(overrideAnnotation); + } +}); + +/// The `@override` annotation. +Reference get overrideAnnotation => 'override'.ref; + +/// The expression that interpolates [field] between [a] and [b] at `t`. +/// +/// [a] and [b] are the two values of the field, `a.field` and `b.field` or +/// `_this.field` and `other.field`, depending on the generator. +Expression lerpFieldExpression(FieldInfo field, Expression a, Expression b) { + final staticLerp = field.baseTypeName.ref.property('lerp'); + final fieldType = field.typeName.typeRef(isNullable: field.isNullable); + + // An interpolation that returns a nullable result still has to produce a + // value for a non-nullable field. + Expression nullCheckedUnlessNullable(Expression expression) => + field.isNullable ? expression : expression.nullChecked; + + return switch (field.lerp) { + // Non-nullable field, static lerp returning an optional result: + // Class.lerp(a.field, b.field, t)! + StaticLerp(optionalResult: true) when !field.isNullable => staticLerp([ + a, + b, + tRef, + ]).nullChecked, + + // Non-nullable field, static lerp returning a non-optional result: + // Class.lerp(a.field, b.field, t) + StaticLerp() when !field.isNullable => staticLerp([a, b, tRef]), + + // Nullable field, static lerp taking nullable arguments: + // Class.lerp(a.field, b.field, t) + StaticLerp(isNullableParameter: true) => staticLerp([a, b, tRef]), + + // Nullable field, static lerp taking non-nullable arguments: + // a.field == null || b.field == null + // ? (t < 0.5 ? a.field : b.field) + // : Class.lerp(a.field!, b.field!, t) + StaticLerp() => _nullGuarded( + a, + b, + staticLerp([a.nullChecked, b.nullChecked, tRef]), + ), + + // Nullable field, instance lerp declared on a supertype: + // a.field == null || b.field == null + // ? (t < 0.5 ? a.field : b.field) + // : (a.field!.lerp(b.field!, t) as Class?) + InstanceLerp(needsCast: true) when field.isNullable => _nullGuarded( + a, + b, + a.nullChecked.property('lerp')([b.nullChecked, tRef]).asA(fieldType), + ), + + // Nullable field, instance lerp: + // a.field == null || b.field == null + // ? (t < 0.5 ? a.field : b.field) + // : a.field!.lerp(b.field!, t) + InstanceLerp() when field.isNullable => _nullGuarded( + a, + b, + a.nullChecked.property('lerp')([b.nullChecked, tRef]), + ), + + // Non-nullable field, instance lerp declared on a supertype: + // (a.field.lerp(b.field, t) as Class) + InstanceLerp(needsCast: true) => + a.property('lerp')([b, tRef]).asA(fieldType), + + // Non-nullable field, instance lerp: + // a.field.lerp(b.field, t) + InstanceLerp() => a.property('lerp')([b, tRef]), + + // WidgetStateProperty.lerp(a.field, b.field, t, Color.lerp) + WidgetStatePropertyLerp( + :final baseTypeName, + :final genericType, + :final genericBaseTypeName, + :final isNullableGeneric, + :final genericIsDouble, + :final genericIsDuration, + ) => + nullCheckedUnlessNullable( + baseTypeName.ref.property('lerp')( + [ + a, + b, + tRef, + if (genericIsDouble) + r'lerpDouble$'.ref + else if (genericIsDuration) + r'lerpDuration$'.ref + else + genericBaseTypeName.ref.property('lerp'), + ], + {}, + [genericType.typeRef(isNullable: isNullableGeneric)], + ), + ), + + // lerpDouble$(a.field, b.field, t) + NoLerp() when field.isDouble => nullCheckedUnlessNullable( + r'lerpDouble$'.ref([a, b, tRef]), + ), + + // lerpDuration$(a.field, b.field, t) + NoLerp() when field.isDuration => nullCheckedUnlessNullable( + r'lerpDuration$'.ref([a, b, tRef]), + ), + + // t < 0.5 ? a.field : b.field + NoLerp() => _switchOver(a, b), + }; +} + +/// `t < 0.5 ? a : b` +Expression _switchOver(Expression a, Expression b) => + tRef.lessThan(literalNum(0.5)).conditional(a, b); + +/// Wraps [lerpCall] so that it only runs when both sides are present. +/// +/// An interpolation that cannot accept a null falls back to the value the +/// timeline is closest to, which keeps `t == 0` on [a] and `t == 1` on [b]. +Expression _nullGuarded(Expression a, Expression b, Expression lerpCall) => a + .equalTo(literalNull) + .or(b.equalTo(literalNull)) + .conditional(_switchOver(a, b), lerpCall); + /// Generates the equality operator (`==`) for theme classes. /// /// The generated method performs identity and type checks before comparing -/// all non-static fields from [config]. Returns `true` if all fields are equal, -/// `false` otherwise. +/// all fields from [config]. Returns `true` if all fields are equal, `false` +/// otherwise. Method equalOperator(BaseConfig config) => Method((m) { - final fields = config.filteredFields; + final fields = config.fields; final className = config.className; m ..name = 'operator ==' - ..annotations.add('override'.ref) + ..annotations.add(overrideAnnotation) ..returns = 'bool'.ref ..requiredParameters.add( Parameter( @@ -40,7 +245,7 @@ Method equalOperator(BaseConfig config) => Method((m) { ..addEmptyLine() ..statements.add( ifStatement( - 'other'.ref.prop('runtimeType').notEqualTo('runtimeType'.ref), + 'other'.ref.property('runtimeType').notEqualTo('runtimeType'.ref), Block((b) => b.addExpression(literalFalse.returned)), ), ) @@ -48,9 +253,7 @@ Method equalOperator(BaseConfig config) => Method((m) { if (fields.isNotEmpty) { b - ..addExpression( - declareFinal('_this').assign('this'.ref.asA(className.ref)), - ) + ..addExpression(declareThis(config)) ..addExpression( declareFinal('_other').assign('other'.ref.asA(className.ref)), ) @@ -59,8 +262,8 @@ Method equalOperator(BaseConfig config) => Method((m) { fields .map( (field) => '_other'.ref - .prop(field.name) - .equalTo('_this'.ref.prop(field.name)), + .property(field.name) + .equalTo(thisRef.property(field.name)), ) .reduce((a, b) => a.and(b)) .returned, @@ -78,61 +281,37 @@ Method equalOperator(BaseConfig config) => Method((m) { /// - **1-19 fields**: Uses `Object.hash()` for optimal performance /// - **20+ fields**: Uses `Object.hashAll()` for unlimited field support Method hashMethod(BaseConfig config) => Method((m) { - final fields = config.filteredFields; - final className = config.className; + final fields = config.fields; m ..name = 'hashCode' - ..annotations.add('override'.ref) + ..annotations.add(overrideAnnotation) ..returns = 'int'.ref ..type = MethodType.getter ..body = Block((b) { if (fields.isNotEmpty) { b - ..addExpression( - declareFinal('_this').assign('this'.ref.asA(className.ref)), - ) + ..addExpression(declareThis(config)) ..addEmptyLine(); } + final values = [ + 'runtimeType'.ref, + for (final field in fields) thisRef.property(field.name), + ]; + switch (fields.length) { case 0: - b.addExpression('runtimeType'.ref.prop('hashCode').returned); + b.addExpression('runtimeType'.ref.property('hashCode').returned); case <= 19: - b.addExpression( - 'Object'.ref - .prop('hash')([ - 'runtimeType'.ref, - for (final field in fields) '_this'.ref.prop(field.name), - ]) - .returned, - ); + b.addExpression('Object'.ref.property('hash')(values).returned); case _: b.addExpression( - 'Object'.ref - .prop('hashAll')([ - literalList([ - 'runtimeType'.ref, - for (final field in fields) '_this'.ref.prop(field.name), - ]), - ]) - .returned, + 'Object'.ref.property('hashAll')([literalList(values)]).returned, ); } }); }); -/// Wraps [lerpCall] so that it only runs when both sides are present. -/// -/// An interpolation that cannot accept a null falls back to the value the -/// timeline is closest to, which keeps `t == 0` on [a] and `t == 1` on [b]. -Expression nullGuardedLerp(Expression a, Expression b, Expression lerpCall) => a - .equalTo(literalNull) - .or(b.equalTo(literalNull)) - .conditional( - 't'.ref.lessThan(literalNum(0.5)).conditional(a, b), - lerpCall, - ); - /// Generates an if statement as code. /// /// Creates a code block with the given [condition], executing [ifBlock] when @@ -148,59 +327,30 @@ Code ifStatement(Expression condition, Block ifBlock) { return Code('if($conditionV){$ifBlockV}'); } -/// A wrapper around [Reference] that guarantees a non-null symbol. -/// -/// This extension type provides convenient access to the symbol property -/// without null checks, as it's guaranteed to be non-null when constructed -/// through the provided extensions. -extension type const Ref._(Reference ref) implements Reference { - /// Returns the non-null symbol from the underlying [Reference]. - @redeclare - String get symbol => ref.symbol!; -} - /// Extension providing shortcut methods for creating references from strings. extension StringRef on String { - /// Creates a [Ref] from this string as a symbol reference. + /// Creates a [Reference] from this string as a symbol reference. /// /// Example: /// ```dart /// 'MyClass'.ref // Ref to MyClass /// ``` - Ref get ref => Ref._(Reference(this)); + Reference get ref => Reference(this); - /// Creates a [Ref] representing a type reference. + /// Creates a [TypeReference] from this string. /// /// Example: /// ```dart /// 'String'.typeRef() // String /// 'int'.typeRef(isNullable: true) // int? /// ``` - Ref typeRef({bool isNullable = false}) => Ref._( - TypeReference( - (b) => b - ..isNullable = isNullable - ..symbol = this, - ), + TypeReference typeRef({bool isNullable = false}) => TypeReference( + (b) => b + ..isNullable = isNullable + ..symbol = this, ); } -/// Extension providing convenient property access for expressions. -extension ExpressionExtensions on Expression { - /// Accesses a property on this expression. - /// - /// When [nullSafe] is `true`, uses null-safe property access (`?.`). - /// Otherwise uses regular property access (`.`). - /// - /// Example: - /// ```dart - /// 'obj'.ref.prop('field') // obj.field - /// 'obj'.ref.prop('field', nullSafe: true) // obj?.field - /// ``` - BinaryExpression prop(String name, {bool nullSafe = false}) => - (nullSafe ? nullSafeProperty(name) : property(name)) as BinaryExpression; -} - /// Extension providing utility methods for building code blocks. extension BlockBuilderExtensions on BlockBuilder { /// Adds an empty line to the code block for better readability. diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart index da78418..6c29f44 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart @@ -1,11 +1,11 @@ import 'package:code_builder/code_builder.dart'; -import '../../common/symbols/lerp_info.dart'; import '../../config/config.dart'; import '../../extensions/string.dart'; import '../common.dart'; -/// Generates code for `ThemeExtension` mixins and related helpers. +/// Generates the mixin for a `@ThemeExtensions` class, and the `BuildContext` +/// extension that reaches it. class ThemeExtensionsCodeBuilder { const ThemeExtensionsCodeBuilder(); @@ -21,25 +21,19 @@ class ThemeExtensionsCodeBuilder { final mix = Mixin((m) { m ..name = config.themeExtensionMixinName - ..on = TypeReference( - (t) => t - ..symbol = 'ThemeExtension' - ..types.add(config.className.ref), - ) + ..on = _themeExtensionRef(config) ..methods.addAll([ - copyWith(config), + copyWithMethod( + config, + returns: _themeExtensionRef(config), + isOverride: true, + ), lerpMethod(config), equalOperator(config), hashMethod(config), ]); }); - final emitter = DartEmitter( - allocator: Allocator.simplePrefixing(), - useNullSafetySyntax: true, - orderDirectives: true, - ); - final library = Library( (b) => b.body.addAll([ mix, @@ -47,63 +41,10 @@ class ThemeExtensionsCodeBuilder { ]), ); - return library.accept(emitter).toString(); + return library.accept(partEmitter()).toString(); } } -/// Generates the `copyWith` method for the theme extension. -/// -/// Allows creating a copy of the theme extension with some fields replaced. -Method copyWith(ThemeExtensionsConfig config) => Method((m) { - final fields = config.filteredFields; - - m - ..name = 'copyWith' - ..annotations.add('override'.ref) - ..returns = _buildThemeExtensionRef(config) - ..optionalParameters.addAll( - fields.map( - (field) => Parameter( - (p) => p - ..name = field.name - ..named = true - ..type = field.typeName.typeRef(isNullable: true), - ), - ), - ) - ..body = Block((b) { - if (fields.isNotEmpty) { - b - ..addExpression( - declareFinal( - '_this'.ref.symbol, - ).assign('this'.ref.asA(config.className.ref)), - ) - ..addEmptyLine(); - } - - final args = {}; - for (final field in fields) { - args[field.name] = field.name.ref.ifNullThen( - '_this'.ref.prop(field.name), - ); - } - - b.addExpression( - (fields.isEmpty && config.constConstructor - ? InvokeExpression.constOf - : InvokeExpression.newOf)( - config.className.ref, - [], - args, - [], - config.constructor, - ) - .returned, - ); - }); -}); - /// Generates the `lerp` (linear interpolation) method for the theme extension. /// /// Supports: @@ -114,13 +55,13 @@ Method copyWith(ThemeExtensionsConfig config) => Method((m) { Method lerpMethod(ThemeExtensionsConfig config) => Method((m) { m ..name = 'lerp' - ..annotations.add('override'.ref) - ..returns = _buildThemeExtensionRef(config) + ..annotations.add(overrideAnnotation) + ..returns = _themeExtensionRef(config) ..requiredParameters.addAll([ Parameter( (p) => p ..name = 'other' - ..type = _buildThemeExtensionRef(config, isNullable: true), + ..type = _themeExtensionRef(config, isNullable: true), ), Parameter( (p) => p @@ -129,7 +70,7 @@ Method lerpMethod(ThemeExtensionsConfig config) => Method((m) { ), ]) ..body = Block((b) { - final fields = config.filteredFields; + final fields = config.fields; b ..statements.add( @@ -142,164 +83,25 @@ Method lerpMethod(ThemeExtensionsConfig config) => Method((m) { if (fields.isNotEmpty) { b - ..addExpression( - declareFinal( - '_this'.ref.symbol, - ).assign('this'.ref.asA(config.className.ref)), - ) + ..addExpression(declareThis(config)) ..addEmptyLine(); } - final args = {}; - - for (final field in fields) { - final tProp = '_this'.ref.prop(field.name); - final oProp = 'other'.ref.prop(field.name); - final sLerp = field.baseTypeName.ref.prop('lerp'); - - switch (field.lerp) { - // Handle NoLerp with double field - case NoLerp() when field.isDouble: - // lerpDouble$(_this.field, other.field, t) or - // lerpDouble$(_this.field, other.field, t)! - final expression = r'lerpDouble$'.ref([tProp, oProp, 't'.ref]); - - args[field.name] = field.isNullable - ? expression - : expression.nullChecked; - - // Handle NoLerp with duration field - case NoLerp() when field.isDuration: - // lerpDuration$(_this.field, other.field, t) or - // lerpDuration$(_this.field, other.field, t)! - final expression = r'lerpDuration$'.ref([tProp, oProp, 't'.ref]); - - args[field.name] = field.isNullable - ? expression - : expression.nullChecked; - - // Default conditional expression - case NoLerp(): - // t < 0.5 ? _this.field : other.field - args[field.name] = 't'.ref - .lessThan(literalNum(0.5)) - .conditional(tProp, oProp); - - // Handle StaticLerp on a non-optional field, returning an - // optional result - case StaticLerp(optionalResult: true) when !field.isNullable: - // FieldType.lerp(_this.field, other.field, t)! - args[field.name] = sLerp([tProp, oProp, 't'.ref]).nullChecked; - - // Handle StaticLerp on a non-optional field, returning a - // non-optional result - case StaticLerp() when !field.isNullable: - // FieldType.lerp(_this.field, other.field, t) - args[field.name] = sLerp([tProp, oProp, 't'.ref]); - - // Handle StaticLerp taking nullable arguments, optional field - case StaticLerp(isNullableParameter: true): - // FieldType.lerp(_this.field, other.field, t) - args[field.name] = sLerp([tProp, oProp, 't'.ref]); - - // Handle StaticLerp taking non-nullable arguments, optional field - case StaticLerp(): - // _this.side == null || other.side == null - // ? (t < 0.5 ? _this.side : other.side) - // : Side.lerp(_this.side!, other.side!, t) - args[field.name] = nullGuardedLerp( - tProp, - oProp, - sLerp([tProp.nullChecked, oProp.nullChecked, 't'.ref]), - ); - - // Handle InstanceLerp with an optional field, returning a supertype - case InstanceLerp(needsCast: true) when field.isNullable: - // _this.field == null || other.field == null - // ? (t < 0.5 ? _this.field : other.field) - // : _this.field!.lerp(other.field!, t) as FieldType? - args[field.name] = nullGuardedLerp( - tProp, - oProp, - tProp.nullChecked - .property('lerp')([oProp.nullChecked, 't'.ref]) - .asA(field.typeName.typeRef(isNullable: true)), - ); - - // Handle InstanceLerp with an optional field - case InstanceLerp() when field.isNullable: - // _this.field == null || other.field == null - // ? (t < 0.5 ? _this.field : other.field) - // : _this.field!.lerp(other.field!, t) - args[field.name] = nullGuardedLerp( - tProp, - oProp, - tProp.nullChecked.property('lerp')([oProp.nullChecked, 't'.ref]), - ); - - // Handle InstanceLerp returning a supertype, non-optional field - case InstanceLerp(needsCast: true): - // _this.field.lerp(other.field, t) as FieldType - args[field.name] = tProp - .prop('lerp')([oProp, 't'.ref]) - .asA(field.typeName.typeRef()); - - // Handle InstanceLerp with non-optional field - case InstanceLerp(): - // _this.field.lerp(other.field, t) - args[field.name] = tProp.prop('lerp')([oProp, 't'.ref]); - - // Handle WidgetStateProperty lerp with inner lerp function - case WidgetStatePropertyLerp( - :final baseTypeName, - :final genericType, - :final genericBaseTypeName, - :final isNullableGeneric, - :final genericIsDouble, - :final genericIsDuration, - ): - // Get the inner lerp function reference - final innerLerpFn = genericIsDouble - ? r'lerpDouble$'.ref - : genericIsDuration - ? r'lerpDuration$'.ref - : genericBaseTypeName.ref.prop('lerp'); - - // WidgetStateProperty.lerp( - // _this.field, - // other.field, - // t, - // Color.lerp - // ) - final expression = baseTypeName.ref.prop('lerp')( - [tProp, oProp, 't'.ref, innerLerpFn], - {}, - [genericType.typeRef(isNullable: isNullableGeneric)], - ); - - args[field.name] = field.isNullable - ? expression - : expression.nullChecked; - } - } + final args = { + for (final field in fields) + field.name: lerpFieldExpression( + field, + thisRef.property(field.name), + 'other'.ref.property(field.name), + ), + }; - b.addExpression( - (args.isEmpty && config.constConstructor - ? InvokeExpression.constOf - : InvokeExpression.newOf)( - config.className.ref, - [], - args, - [], - config.constructor, - ) - .returned, - ); + b.addExpression(construct(config, args).returned); }); }); -// Returns a type reference for `ThemeExtension` based on [config]. -TypeReference _buildThemeExtensionRef( +/// A reference to `ThemeExtension`. +TypeReference _themeExtensionRef( ThemeExtensionsConfig config, { bool isNullable = false, }) => TypeReference( @@ -315,28 +117,24 @@ TypeReference _buildThemeExtensionRef( /// ```dart /// context.myThemeExtension /// ``` -Extension contextExtension(ThemeExtensionsConfig config) { - final result = Extension((b) { - b - ..name = '${config.className}BuildContext' - ..on = 'BuildContext'.ref - ..methods.add( - Method((mb) { - mb - ..type = MethodType.getter - ..lambda = true - ..name = - config.contextAccessorName ?? - config.className.camelCase(suffixToRemove: 'Extension') - ..returns = config.className.ref - ..body = 'Theme'.ref - .prop('of')(['this'.ref]) - .prop('extension')([], {}, [config.className.ref]) - .nullChecked - .code; - }), - ); - }); - - return result; -} +Extension contextExtension(ThemeExtensionsConfig config) => Extension((b) { + b + ..name = '${config.className}BuildContext' + ..on = 'BuildContext'.ref + ..methods.add( + Method((mb) { + mb + ..type = MethodType.getter + ..lambda = true + ..name = + config.contextAccessorName ?? + config.className.camelCase(suffixToRemove: 'Extension') + ..returns = config.className.ref + ..body = 'Theme'.ref + .property('of')(['this'.ref]) + .property('extension')([], {}, [config.className.ref]) + .nullChecked + .code; + }), + ); +}); diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart index afdb425..e07defa 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart @@ -4,8 +4,9 @@ import 'package:source_gen/source_gen.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; import '../../common/fields_visitor.dart'; -import '../../common/fields_visitor_config.dart'; +import '../../common/validation.dart'; import '../../config/config.dart'; +import '../annotation_reader.dart'; import 'code_builder.dart'; /// Code generator for classes annotated with `@ThemeExtensions`. @@ -43,39 +44,43 @@ class ThemeExtensionsGenerator extends GeneratorForAnnotation { ); } + checkExtendsThemeExtension(element); + final buildContextExtension = annotation .read('buildContextExtension') .boolValue; - final constructor = annotation.read('constructor').literalValue as String?; - final constConstructor = element.constructors.any((c) => c.isConst); + final contextAccessorName = annotation.optionalString( + 'contextAccessorName', + ); + + if (contextAccessorName != null) { + checkIdentifier( + contextAccessorName, + option: 'contextAccessorName', + element: element, + ); + } - final contextAccessorName = - annotation.read('contextAccessorName').literalValue as String?; + final constructorName = annotation.optionalString('constructor'); + final constructor = resolveConstructor(element, constructorName); // ThemeExtensions needs lerp but doesn't generate merge methods - final fields = collectFields( - element, - config: const FieldsVisitorConfig(includeMergeLookup: false), - ); + final fields = collectFields(element, includeMergeLookup: false); - // Use naming convention instead of expensive AST parsing - // Assume the mixin follows the standard pattern: _$ClassName - final mixinName = '_\$${element.displayName}'; + checkConstructorParameters(element, constructor, fields); - final generatorConfig = ThemeExtensionsConfig( + final config = ThemeExtensionsConfig( fields: fields, className: element.displayName, contextAccessorName: contextAccessorName, buildContextExtension: buildContextExtension, - constructor: constructor, - themeExtensionMixinName: mixinName, - constConstructor: constConstructor, + constructor: constructorName, + // The mixin is applied by name, so the name is a convention. + themeExtensionMixinName: '_\$${element.displayName}', + constConstructor: constructor.isConst, ); - const generator = ThemeExtensionsCodeBuilder(); - final code = generator.generate(generatorConfig); - - return code; + return const ThemeExtensionsCodeBuilder().generate(config); } } diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart index b14de14..3422058 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart @@ -6,7 +6,7 @@ import '../../common/symbols/merge_info.dart'; import '../../config/config.dart'; import '../common.dart'; -/// Generates code for theme extensions based on a given configuration. +/// Generates the mixin for a `@ThemeGen` class. class ThemeGenCodeBuilder { const ThemeGenCodeBuilder(); @@ -21,22 +21,16 @@ class ThemeGenCodeBuilder { ..methods.addAll([ canMerge(config), staticLerp(config), - copyWith(config), + copyWithMethod(config, returns: config.className.ref), merge(config), equalOperator(config), hashMethod(config), ]); }); - // Set up the Dart code emitter - final emitter = DartEmitter( - allocator: Allocator.simplePrefixing(), - useNullSafetySyntax: true, - orderDirectives: true, - ); + final library = Library((lib) => lib.body.add(mix)); - final mixinLibrary = Library((lib) => lib.body.addAll([mix])); - return mixinLibrary.accept(emitter).toString(); + return library.accept(partEmitter()).toString(); } } @@ -50,55 +44,6 @@ Method canMerge(ThemeGenConfig config) => Method((m) { ..body = literalTrue.code; }); -/// Generates a `copyWith` method for the theme class. -Method copyWith(ThemeGenConfig config) => Method((m) { - final fields = config.filteredFields; - - m - ..name = 'copyWith' - ..returns = config.className.ref - ..optionalParameters.addAll( - fields.map( - (field) => Parameter( - (p) => p - ..name = field.name - ..named = true - ..type = field.typeName.typeRef(isNullable: true), - ), - ), - ) - ..body = Block((b) { - // If there are fields, create a _this variable for easier access - if (fields.isNotEmpty) { - b - ..addExpression( - declareFinal('_this').assign('this'.ref.asA(config.className.ref)), - ) - ..addEmptyLine(); - } - - final args = {}; - for (final field in fields) { - args[field.name] = field.name.ref.ifNullThen( - '_this'.ref.prop(field.name), - ); - } - - b.addExpression( - (fields.isEmpty && config.constConstructor - ? InvokeExpression.constOf - : InvokeExpression.newOf)( - config.className.ref, - [], - args, - [], - config.constructor, - ) - .returned, - ); - }); -}); - /// Generates a `merge` method for the theme class. Method merge(ThemeGenConfig config) => Method((m) { m @@ -112,139 +57,103 @@ Method merge(ThemeGenConfig config) => Method((m) { ), ) ..body = Block((b) { - final fields = config.filteredFields; - b - // Create a _this variable for easier access to the current instance - ..addExpression( - declareFinal( - '_this'.ref.symbol, - ).assign('this'.ref.asA(config.className.ref)), - ) + ..addExpression(declareThis(config)) ..addEmptyLine() // Return `_this` if other is null or identical to `_this` ..statements.add( ifStatement( 'other'.ref .equalTo(literalNull) - .or('identical'.ref(['_this'.ref, 'other'.ref])), - Block((b) => b.addExpression('_this'.ref.returned)), + .or('identical'.ref([thisRef, 'other'.ref])), + Block((b) => b.addExpression(thisRef.returned)), ), ) ..addEmptyLine() // Return `other` if it cannot be merged ..statements.add( ifStatement( - 'other'.ref.negate().prop('canMerge'), + 'other'.ref.negate().property('canMerge'), Block((b) => b.addExpression('other'.ref.returned)), ), ) ..addEmptyLine(); - final args = {}; - for (final field in fields) { - final thisProp = '_this'.ref.prop(field.name); - final otherProp = 'other'.ref.prop(field.name); - - final staticMerge = field.baseTypeName.ref.prop('merge'); - final instanceMerge = thisProp.prop('merge'); - - // Handle different merge strategies based on field configuration - switch (field.merge) { - // No merge method, just take the other property - // `property: other.property` - case NoMerge(): - args[field.name] = otherProp; - - // Static merge method with optional field - case StaticMerge() when field.isNullable: - // _this.field == null - // ? other.field - // : other.field == null - // ? _this.field - // : Class.merge(_this.field!, other.field!) - args[field.name] = thisProp - .equalTo(literalNull) - .conditional( - otherProp, - otherProp - .equalTo(literalNull) - .conditional( - thisProp, - staticMerge([ - thisProp.nullChecked, - otherProp.nullChecked, - ]), - ), - ); - - // Static merge method with non-optional field - case StaticMerge(): - args[field.name] = staticMerge([thisProp, otherProp]); - - // Instance merge method taking a nullable argument, optional field - case InstanceMerge(isNullableParameter: true, :final needsCast) - when field.isNullable: - // _this.field?.merge(other.field) ?? other.field - args[field.name] = _castIfNeeded( - thisProp.nullSafeProperty('merge')([otherProp]), - field, - needsCast: needsCast, - isNullable: true, - ).ifNullThen(otherProp); - - // Instance merge method taking a non-nullable argument, optional - // field - case InstanceMerge(:final needsCast) when field.isNullable: - // _this.field == null - // ? other.field - // : other.field == null - // ? _this.field - // : _this.field!.merge(other.field!) - args[field.name] = thisProp - .equalTo(literalNull) - .conditional( - otherProp, - otherProp - .equalTo(literalNull) - .conditional( - thisProp, - _castIfNeeded( - thisProp.nullChecked.property('merge')([ - otherProp.nullChecked, - ]), - field, - needsCast: needsCast, - isNullable: true, - ), - ), - ); - - // Instance merge method with non-optional field - case InstanceMerge(:final needsCast): - args[field.name] = _castIfNeeded( - instanceMerge([otherProp]), - field, - needsCast: needsCast, - isNullable: false, - ); - } - } + final args = { + for (final field in config.fields) + field.name: _mergeFieldExpression( + field, + thisRef.property(field.name), + 'other'.ref.property(field.name), + ), + }; b.addExpression('copyWith'.ref([], args).returned); }); }); -/// Casts [expression] back to the type of [field] when the method that -/// produced it is declared on a supertype. -Expression _castIfNeeded( - Expression expression, - FieldInfo field, { - required bool needsCast, - required bool isNullable, -}) => needsCast - ? expression.asA(field.typeName.typeRef(isNullable: isNullable)) - : expression; +/// The expression that merges [other] into [current] for [field]. +Expression _mergeFieldExpression( + FieldInfo field, + Expression current, + Expression other, +) { + final staticMerge = field.baseTypeName.ref.property('merge'); + + Expression castIfNeeded(Expression expression, {required bool needsCast}) => + needsCast + ? expression.asA(field.typeName.typeRef(isNullable: field.isNullable)) + : expression; + + // A merge that cannot take a null on either side keeps whichever value is + // present: + // _this.field == null + // ? other.field + // : other.field == null + // ? _this.field + // : + Expression whenBothPresent(Expression merge) => current + .equalTo(literalNull) + .conditional( + other, + other.equalTo(literalNull).conditional(current, merge), + ); + + return switch (field.merge) { + // No merge method, just take the other property + NoMerge() => other, + + // Class.merge(_this.field!, other.field!), guarded + StaticMerge() when field.isNullable => whenBothPresent( + staticMerge([current.nullChecked, other.nullChecked]), + ), + + // Class.merge(_this.field, other.field) + StaticMerge() => staticMerge([current, other]), + + // _this.field?.merge(other.field) ?? other.field + InstanceMerge(isNullableParameter: true, :final needsCast) + when field.isNullable => + castIfNeeded( + current.nullSafeProperty('merge')([other]), + needsCast: needsCast, + ).ifNullThen(other), + + // _this.field!.merge(other.field!), guarded + InstanceMerge(:final needsCast) when field.isNullable => whenBothPresent( + castIfNeeded( + current.nullChecked.property('merge')([other.nullChecked]), + needsCast: needsCast, + ), + ), + + // _this.field.merge(other.field) + InstanceMerge(:final needsCast) => castIfNeeded( + current.property('merge')([other]), + needsCast: needsCast, + ), + }; +} /// Generates a static `lerp` method for interpolating between two theme /// instances. @@ -259,23 +168,21 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { ..requiredParameters.addAll([ Parameter( (p) => p - ..name = 'a'.ref.symbol + ..name = 'a' ..type = config.className.typeRef(isNullable: true), ), Parameter( (p) => p - ..name = 'b'.ref.symbol + ..name = 'b' ..type = config.className.typeRef(isNullable: true), ), Parameter( (p) => p - ..name = 't'.ref.symbol + ..name = 't' ..type = 'double'.ref, ), ]) ..body = Block((b) { - final fields = config.filteredFields; - b // If a and b are identical, return a ..statements.add( @@ -291,7 +198,7 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { 'a'.ref.equalTo(literalNull), Block( (b) => b.addExpression( - 't'.ref + tRef .equalTo(literalNum(1.0)) .conditional('b'.ref, literalNull) .returned, @@ -306,7 +213,7 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { 'b'.ref.equalTo(literalNull), Block( (b) => b.addExpression( - 't'.ref + tRef .equalTo(literalNum(0.0)) .conditional('a'.ref, literalNull) .returned, @@ -316,149 +223,19 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { ) ..addEmptyLine(); - final argsResult = {}; - - for (final field in fields) { - final aProp = 'a'.ref.prop(field.name); - final bProp = 'b'.ref.prop(field.name); - final lerp = field.baseTypeName.ref.prop('lerp'); - - // Handle different lerp strategies based on field configuration - switch (field.lerp) { - // Non-nullable field, lerp returning an optional result - case StaticLerp(optionalResult: true) when !field.isNullable: - // value: Class.lerp(a.field, b.field, t)! - argsResult[field.name] = lerp([aProp, bProp, 't'.ref]).nullChecked; - - // Non-nullable field, lerp returning a non-optional result - case StaticLerp() when !field.isNullable: - // value: Class.lerp(a.field, b.field, t) - argsResult[field.name] = lerp([aProp, bProp, 't'.ref]); - - // Nullable field, lerp taking nullable arguments - case StaticLerp(isNullableParameter: true): - // value: Class.lerp(a.field, b.field, t) - argsResult[field.name] = lerp([aProp, bProp, 't'.ref]); - - // Nullable field, lerp taking non-nullable arguments - case StaticLerp(): - // value: a.field == null || b.field == null - // ? (t < 0.5 ? a.field : b.field) - // : Class.lerp(a.field!, b.field!, t) - argsResult[field.name] = nullGuardedLerp( - aProp, - bProp, - lerp([aProp.nullChecked, bProp.nullChecked, 't'.ref]), - ); - - // Instance lerp with a nullable field, returning a supertype - case InstanceLerp(needsCast: true) when field.isNullable: - // value: a.field == null || b.field == null - // ? (t < 0.5 ? a.field : b.field) - // : a.field!.lerp(b.field!, t) as Class? - argsResult[field.name] = nullGuardedLerp( - aProp, - bProp, - aProp.nullChecked - .property('lerp')([bProp.nullChecked, 't'.ref]) - .asA(field.typeName.typeRef(isNullable: true)), - ); - - // Instance lerp with a nullable field - case InstanceLerp() when field.isNullable: - // value: a.field == null || b.field == null - // ? (t < 0.5 ? a.field : b.field) - // : a.field!.lerp(b.field!, t) - argsResult[field.name] = nullGuardedLerp( - aProp, - bProp, - aProp.nullChecked.property('lerp')([bProp.nullChecked, 't'.ref]), - ); - - // Instance lerp returning a supertype, non-nullable field - case InstanceLerp(needsCast: true): - // value: a.field.lerp(b.field, t) as Class - argsResult[field.name] = aProp - .prop('lerp')([bProp, 't'.ref]) - .asA(field.typeName.typeRef()); - - // Instance lerp method with non-nullable field - case InstanceLerp(): - // value: a.field.lerp(b.field, t) - argsResult[field.name] = aProp.prop('lerp')([bProp, 't'.ref]); - - // WidgetStateProperty lerp with inner lerp function - case WidgetStatePropertyLerp( - :final baseTypeName, - :final genericType, - :final genericBaseTypeName, - :final isNullableGeneric, - :final genericIsDouble, - :final genericIsDuration, - ): - // Get the inner lerp function reference - final innerLerpFn = genericIsDouble - ? r'lerpDouble$'.ref - : genericIsDuration - ? r'lerpDuration$'.ref - : genericBaseTypeName.ref.prop('lerp'); - - // WidgetStateProperty.lerp( - // a.field, - // b.field, - // t, - // Color.lerp - // ) - final expression = baseTypeName.ref.prop('lerp')( - [aProp, bProp, 't'.ref, innerLerpFn], - {}, - [genericType.typeRef(isNullable: isNullableGeneric)], - ); - - argsResult[field.name] = field.isNullable - ? expression - : expression.nullChecked; - - // When the field is of type double - case NoLerp() when field.isDouble: - final expression = r'lerpDouble$'.ref([aProp, bProp, 't'.ref]); - - argsResult[field.name] = field.isNullable - ? expression - : expression.nullChecked; - - // When the field is of type Duration - case NoLerp() when field.isDuration: - final expression = r'lerpDuration$'.ref([aProp, bProp, 't'.ref]); - - argsResult[field.name] = field.isNullable - ? expression - : expression.nullChecked; + final args = {}; - // Special case for canMerge field - case NoLerp() when field.name == 'canMerge': - argsResult[field.name] = bProp; + for (final field in config.fields) { + final aProp = 'a'.ref.property(field.name); + final bProp = 'b'.ref.property(field.name); - // Fallback to a simple conditional expression: - // t < 0.5 ? a.field : b.field - case NoLerp(): - argsResult[field.name] = 't'.ref - .lessThan(literalNum(0.5)) - .conditional(aProp, bProp); - } + // A `canMerge` declared as a field rather than a getter is not + // interpolated: the result takes the value of `b`. + args[field.name] = field.name == 'canMerge' && field.lerp is NoLerp + ? bProp + : lerpFieldExpression(field, aProp, bProp); } - b.addExpression( - (argsResult.isEmpty && config.constConstructor - ? InvokeExpression.constOf - : InvokeExpression.newOf)( - config.className.ref, - [], - argsResult, - [], - config.constructor, - ) - .returned, - ); + b.addExpression(construct(config, args).returned); }); }); diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart b/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart index 3000dca..fee0ad2 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart @@ -4,7 +4,9 @@ import 'package:source_gen/source_gen.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; import '../../common/fields_visitor.dart'; +import '../../common/validation.dart'; import '../../config/config.dart'; +import '../annotation_reader.dart'; import 'code_builder.dart'; /// Code generator for classes annotated with `@ThemeGen`. @@ -43,21 +45,20 @@ class ThemeGenGenerator extends GeneratorForAnnotation { ); } - final constructor = annotation.read('constructor').literalValue as String?; - final constConstructor = element.constructors.any((c) => c.isConst); + final constructorName = annotation.optionalString('constructor'); + final constructor = resolveConstructor(element, constructorName); final fields = collectFields(element); - final generatorConfig = ThemeGenConfig( + checkConstructorParameters(element, constructor, fields); + + final config = ThemeGenConfig( fields: fields, className: element.displayName, - constructor: constructor, - constConstructor: constConstructor, + constructor: constructorName, + constConstructor: constructor.isConst, ); - const generator = ThemeGenCodeBuilder(); - final code = generator.generate(generatorConfig); - - return code; + return const ThemeGenCodeBuilder().generate(config); } } diff --git a/packages/theme_extensions_builder/pubspec.yaml b/packages/theme_extensions_builder/pubspec.yaml index 4cd14fb..aeb7e97 100644 --- a/packages/theme_extensions_builder/pubspec.yaml +++ b/packages/theme_extensions_builder/pubspec.yaml @@ -27,17 +27,19 @@ topics: environment: sdk: ">=3.13.0 <4.0.0" +resolution: workspace + dependencies: analyzer: ">=13.0.0 <15.0.0" build: ">=3.0.0 <5.0.0" code_builder: ^4.11.1 - collection: ^1.15.0 - meta: ^1.16.0 source_gen: ">=4.2.3 <5.0.0" - theme_extensions_builder_annotation: ^7.4.0 + theme_extensions_builder_annotation: ^7.5.0 dev_dependencies: build_test: "^3.5.15" + flutter_stubs: + path: ../flutter_stubs path: ^1.9.1 pro_lints: ^6.2.0 source_gen_test: ^1.3.6 diff --git a/packages/theme_extensions_builder/pubspec_overrides.yaml b/packages/theme_extensions_builder/pubspec_overrides.yaml deleted file mode 100644 index dd7a01e..0000000 --- a/packages/theme_extensions_builder/pubspec_overrides.yaml +++ /dev/null @@ -1,3 +0,0 @@ -# dependency_overrides: -# theme_extensions_builder_annotation: -# path: ../theme_extensions_builder_annotation diff --git a/packages/theme_extensions_builder/test/generator/code_builder_test.dart b/packages/theme_extensions_builder/test/generator/code_builder_test.dart index 95690e6..0923862 100644 --- a/packages/theme_extensions_builder/test/generator/code_builder_test.dart +++ b/packages/theme_extensions_builder/test/generator/code_builder_test.dart @@ -2,14 +2,13 @@ import 'package:test/test.dart'; import 'package:theme_extensions_builder/src/common/symbols/field_info.dart'; import 'package:theme_extensions_builder/src/common/symbols/lerp_info.dart'; import 'package:theme_extensions_builder/src/common/symbols/merge_info.dart'; -import 'package:theme_extensions_builder/src/common/symbols/parameter_info.dart'; import 'package:theme_extensions_builder/src/config/config.dart'; import 'package:theme_extensions_builder/src/generator/theme_extensions/code_builder.dart'; import 'package:theme_extensions_builder/src/generator/theme_gen/code_builder.dart'; /// Code paths that cannot be reached through the golden fixtures, either /// because they need more fields than a readable fixture can hold, or because -/// they need a shape the mock classes don't provide. +/// they need a shape the stub classes don't provide. void main() { group('hashCode strategy', () { test('no fields use runtimeType.hashCode', () { @@ -33,6 +32,37 @@ void main() { }); }); + group('constructor', () { + test('a const constructor is invoked with const without fields', () { + final code = _generate(const []); + + expect(code, contains('return const Theme();')); + }); + + test('a non-const constructor is invoked without const', () { + final code = _generate(const [], constConstructor: false); + + expect(code, contains('return Theme();')); + expect(code, isNot(contains('const Theme()'))); + }); + + test('a const constructor is invoked without const with fields', () { + final code = _generate([_field('value')]); + + expect(code, isNot(contains('const Theme('))); + }); + + test('a named constructor is used for every instantiation', () { + final code = _generate([_field('value')], constructor: '_internal'); + + expect(code, contains('Theme._internal(value: value ?? _this.value)')); + expect( + code, + contains('Theme._internal(value: t < 0.5 ? a.value : b.value)'), + ); + }); + }); + group('lerp', () { test('canMerge field takes the value of b', () { final code = _generate([ @@ -47,11 +77,7 @@ void main() { _field( 'value', typeName: 'Lerpable', - lerp: const InstanceLerp( - optionalResult: true, - args: [_nullableArg], - needsCast: true, - ), + lerp: const InstanceLerp(optionalResult: true, needsCast: true), ), ]); @@ -64,7 +90,7 @@ void main() { 'value', typeName: 'Lerpable', isNullable: true, - lerp: const InstanceLerp(optionalResult: true, args: [_nullableArg]), + lerp: const InstanceLerp(optionalResult: true), ), ]); @@ -78,11 +104,7 @@ void main() { 'value', typeName: 'Lerpable', isNullable: true, - lerp: const InstanceLerp( - optionalResult: false, - args: [_nullableArg], - needsCast: true, - ), + lerp: const InstanceLerp(optionalResult: false, needsCast: true), ), ]); @@ -103,7 +125,7 @@ void main() { typeName: 'Lerpable', lerp: const StaticLerp( optionalResult: true, - args: [_nonNullableArg, _nonNullableArg], + isNullableParameter: false, ), ), ]); @@ -118,7 +140,7 @@ void main() { typeName: 'Lerpable', lerp: const StaticLerp( optionalResult: false, - args: [_nullableArg, _nullableArg], + isNullableParameter: true, ), ), ]); @@ -127,20 +149,39 @@ void main() { expect(code, isNot(contains('Lerpable.lerp(a.value, b.value, t)!'))); }); - test('the same guard is emitted for a theme extension', () { - final code = _generateExtension([ + test('static lerp taking non-nullable arguments is guarded', () { + final code = _generate([ _field( 'value', typeName: 'Lerpable', isNullable: true, - lerp: const InstanceLerp( + lerp: const StaticLerp( optionalResult: false, - args: [_nonNullableArg], - needsCast: true, + isNullableParameter: false, ), ), ]); + expect( + code, + contains( + 'a.value == null || b.value == null ? ' + 't < 0.5 ? a.value : b.value : ' + 'Lerpable.lerp(a.value!, b.value!, t)', + ), + ); + }); + + test('the same guard is emitted for a theme extension', () { + final code = _generateExtension([ + _field( + 'value', + typeName: 'Lerpable', + isNullable: true, + lerp: const InstanceLerp(optionalResult: false, needsCast: true), + ), + ]); + expect( code, contains( @@ -156,7 +197,7 @@ void main() { _field( 'value', typeName: 'Lerpable', - lerp: const InstanceLerp(optionalResult: false, args: [_nullableArg]), + lerp: const InstanceLerp(optionalResult: false), ), ]); @@ -171,7 +212,7 @@ void main() { typeName: 'Box', lerp: const StaticLerp( optionalResult: true, - args: [_nullableArg, _nullableArg], + isNullableParameter: true, ), ), ]); @@ -179,33 +220,73 @@ void main() { expect(code, contains('Box.lerp(a.value, b.value, t)!')); expect(code, contains('Box? value')); }); + + test('a canMerge field of a theme extension is not special', () { + final code = _generateExtension([ + _field('canMerge', typeName: 'bool'), + ]); + + expect( + code, + contains('canMerge: t < 0.5 ? _this.canMerge : other.canMerge'), + ); + }); }); -} -const _nullableArg = ParameterInfo( - name: 'other', - type: 'Lerpable', - isNullable: true, -); + group('merge', () { + test('a static merge on a nullable field is guarded', () { + final code = _generate([ + _field( + 'value', + typeName: 'Mergeable', + isNullable: true, + merge: const StaticMerge(), + ), + ]); -const _nonNullableArg = ParameterInfo( - name: 'other', - type: 'Lerpable', - isNullable: false, -); + expect( + code, + contains( + 'value: _this.value == null ? other.value : ' + 'other.value == null ? _this.value : ' + 'Mergeable.merge(_this.value!, other.value!)', + ), + ); + }); + + test('an instance merge returning a supertype is cast back', () { + final code = _generate([ + _field( + 'value', + typeName: 'Mergeable', + merge: const InstanceMerge(needsCast: true), + ), + ]); + + expect( + code, + contains('value: (_this.value.merge(other.value) as Mergeable)'), + ); + }); + }); +} /// Generates the mixin for [fields] and normalizes the emitter output. /// /// The code builder emits unformatted code, so whitespace and the trailing /// commas code_builder adds before a closing paren are collapsed to keep the /// expectations readable. -String _generate(List fields) { +String _generate( + List fields, { + String? constructor, + bool constConstructor = true, +}) { final code = const ThemeGenCodeBuilder().generate( ThemeGenConfig( fields: fields, className: 'Theme', - constructor: null, - constConstructor: true, + constructor: constructor, + constConstructor: constConstructor, ), ); @@ -242,13 +323,13 @@ FieldInfo _field( String typeName = 'int', bool isNullable = false, LerpInfo lerp = const NoLerp(), + MergeInfo merge = const NoMerge(), }) => FieldInfo( name: name, typeName: typeName, isNullable: isNullable, isDouble: false, isDuration: false, - merge: const NoMerge(), + merge: merge, lerp: lerp, - isStatic: false, ); diff --git a/packages/theme_extensions_builder/test/generator/invalid_test.dart b/packages/theme_extensions_builder/test/generator/invalid_test.dart index 192594d..6ebb9f8 100644 --- a/packages/theme_extensions_builder/test/generator/invalid_test.dart +++ b/packages/theme_extensions_builder/test/generator/invalid_test.dart @@ -1,17 +1,30 @@ import 'package:source_gen_test/source_gen_test.dart'; import 'package:test/test.dart'; +import 'package:theme_extensions_builder/src/generator/theme_extensions/generator.dart'; import 'package:theme_extensions_builder/src/generator/theme_gen/generator.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; Future main() async { initializeBuildLogTracking(); - final reader = await initializeLibraryReaderForDirectory( + final themeGenReader = await initializeLibraryReaderForDirectory( 'test/invalid', 'invalid_theme.dart', ); - group('Invalid', () { - testAnnotatedElements(reader, const ThemeGenGenerator()); + group('Invalid ThemeGen', () { + testAnnotatedElements(themeGenReader, const ThemeGenGenerator()); + }); + + final themeExtensionsReader = await initializeLibraryReaderForDirectory( + 'test/invalid', + 'invalid_theme_extension.dart', + ); + + group('Invalid ThemeExtensions', () { + testAnnotatedElements( + themeExtensionsReader, + const ThemeExtensionsGenerator(), + ); }); } diff --git a/packages/theme_extensions_builder/test/invalid/invalid_theme.dart b/packages/theme_extensions_builder/test/invalid/invalid_theme.dart index 20aba87..27e84f8 100644 --- a/packages/theme_extensions_builder/test/invalid/invalid_theme.dart +++ b/packages/theme_extensions_builder/test/invalid/invalid_theme.dart @@ -1,13 +1,16 @@ +/// Classes the generator refuses, with the error it reports for each. +/// +/// This directory is not part of `generate_for` in `build.yaml`, so the +/// failing generation is only exercised by the test. None of the classes mix +/// in the generated mixin for the same reason. +library; + +import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:source_gen_test/source_gen_test.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import 'mock.dart'; - /// `WidgetStateProperty.lerp` needs a lerp function with nullable parameters, /// so the generic of a `WidgetStateProperty` field has to be nullable. -/// -/// This directory is not part of `generate_for` in `build.yaml`, so the -/// failing generation is only exercised by the test. @ShouldThrow( 'WidgetStateProperty must have a nullable generic type, because ' 'WidgetStateProperty.lerp requires a lerp function with nullable ' @@ -20,3 +23,66 @@ final class NonNullableWidgetStatePropertyTheme { final WidgetStateProperty color; } + +/// The `constructor` option names a constructor that does not exist. +@ShouldThrow( + '`MissingNamedConstructorTheme` has no constructor named `_internal`.', + todo: + 'Declare `MissingNamedConstructorTheme._internal({...})`, or point ' + '`constructor:` at an existing constructor.', +) +@ThemeGen(constructor: '_internal') +final class MissingNamedConstructorTheme { + const MissingNamedConstructorTheme({required this.color}); + + final Color color; +} + +/// Without a `constructor` option the unnamed constructor is called, and this +/// class only has a named one. +@ShouldThrow( + '`MissingUnnamedConstructorTheme` has no unnamed constructor, which the ' + 'generated code calls.', + todo: + 'Declare `MissingUnnamedConstructorTheme({...})`, or point ' + '`constructor:` at the constructor to use.', +) +@themeGen +final class MissingUnnamedConstructorTheme { + const MissingUnnamedConstructorTheme.named({required this.color}); + + final Color color; +} + +/// Every field is passed to the constructor by name, so a field the +/// constructor does not take cannot be generated for. +@ShouldThrow( + 'The constructor `MissingParameterTheme` has no named parameters for the ' + 'fields `width`, `height`, which the generated code passes to it.', + todo: + 'Add `this.width` and the others to `MissingParameterTheme`, or mark ' + 'the fields with `@ignore`.', +) +@themeGen +final class MissingParameterTheme { + MissingParameterTheme({required this.color}) : width = 0, height = 0; + + final Color color; + final double width; + final double height; +} + +/// A positional parameter is not a named one. +@ShouldThrow( + 'The constructor `PositionalParameterTheme` has no named parameter for the ' + 'field `color`, which the generated code passes to it.', + todo: + 'Add `this.color` to `PositionalParameterTheme`, or mark the field ' + 'with `@ignore`.', +) +@themeGen +final class PositionalParameterTheme { + const PositionalParameterTheme(this.color); + + final Color color; +} diff --git a/packages/theme_extensions_builder/test/invalid/invalid_theme_extension.dart b/packages/theme_extensions_builder/test/invalid/invalid_theme_extension.dart new file mode 100644 index 0000000..d592f77 --- /dev/null +++ b/packages/theme_extensions_builder/test/invalid/invalid_theme_extension.dart @@ -0,0 +1,82 @@ +/// Theme extensions the generator refuses, with the error it reports for +/// each. +/// +/// This directory is not part of `generate_for` in `build.yaml`, so the +/// failing generation is only exercised by the test. None of the classes mix +/// in the generated mixin for the same reason. +library; + +import 'package:flutter_stubs/flutter_stubs.dart'; +import 'package:source_gen_test/source_gen_test.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +/// The generated mixin is declared `on ThemeExtension`. +@ShouldThrow( + '`NotAThemeExtension` must extend `ThemeExtension` to ' + 'be annotated with `@ThemeExtensions`.', + todo: + 'Declare it as `class NotAThemeExtension extends ' + r'ThemeExtension with _$NotAThemeExtension`, or ' + 'use `@ThemeGen` for a plain class.', +) +@themeExtensions +final class NotAThemeExtension { + const NotAThemeExtension({required this.color}); + + final Color color; +} + +/// A valid theme extension that [WrongTypeArgument] borrows its type +/// argument from. +final class OtherExtension extends ThemeExtension { + const OtherExtension(); +} + +/// Extending `ThemeExtension` of another class is as wrong as not extending +/// it at all. +@ShouldThrow( + '`WrongTypeArgument` must extend `ThemeExtension` to be ' + 'annotated with `@ThemeExtensions`.', + todo: + 'Declare it as `class WrongTypeArgument extends ' + r'ThemeExtension with _$WrongTypeArgument`, or use ' + '`@ThemeGen` for a plain class.', +) +@themeExtensions +final class WrongTypeArgument extends ThemeExtension { + const WrongTypeArgument({required this.color}); + + final Color color; +} + +/// The accessor name is written into the generated extension as is. +@ShouldThrow( + '`my theme` is not a valid Dart identifier, so it cannot be used as ' + '`contextAccessorName`.', + todo: + r'Use letters, digits, `_` and `$` only, and do not start with a ' + 'digit.', +) +@ThemeExtensions(contextAccessorName: 'my theme') +final class BadAccessorName extends ThemeExtension { + const BadAccessorName({required this.color}); + + final Color color; +} + +/// The named constructor is the one checked against the fields. +@ShouldThrow( + 'The constructor `MissingParameterExtension._internal` has no named ' + 'parameter for the field `width`, which the generated code passes to it.', + todo: + 'Add `this.width` to `MissingParameterExtension._internal`, or mark ' + 'the field with `@ignore`.', +) +@ThemeExtensions(constructor: '_internal') +final class MissingParameterExtension + extends ThemeExtension { + MissingParameterExtension._internal({required this.color}) : width = 0; + + final Color color; + final double width; +} diff --git a/packages/theme_extensions_builder/test/invalid/mock.dart b/packages/theme_extensions_builder/test/invalid/mock.dart deleted file mode 120000 index 15c95ff..0000000 --- a/packages/theme_extensions_builder/test/invalid/mock.dart +++ /dev/null @@ -1 +0,0 @@ -../mock/mock.dart \ No newline at end of file diff --git a/packages/theme_extensions_builder/test/runtime/fields_visitor_config_test.dart b/packages/theme_extensions_builder/test/runtime/fields_visitor_config_test.dart deleted file mode 100644 index c5b184d..0000000 --- a/packages/theme_extensions_builder/test/runtime/fields_visitor_config_test.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:test/test.dart'; -import 'package:theme_extensions_builder/src/common/fields_visitor_config.dart'; - -void main() { - group('FieldsVisitorConfig', () { - test('default config looks up merge methods', () { - const config = FieldsVisitorConfig(); - - expect(config.includeMergeLookup, isTrue); - }); - - test('merge lookup can be disabled', () { - const config = FieldsVisitorConfig(includeMergeLookup: false); - - expect(config.includeMergeLookup, isFalse); - }); - - test('equality works correctly', () { - const config1 = FieldsVisitorConfig(); - const config2 = FieldsVisitorConfig(); - const config3 = FieldsVisitorConfig(includeMergeLookup: false); - - expect(config1, equals(config2)); - expect(config1, isNot(equals(config3))); - }); - - test('equal configs have equal hashCodes', () { - const config1 = FieldsVisitorConfig(); - const config2 = FieldsVisitorConfig(); - const config3 = FieldsVisitorConfig(includeMergeLookup: false); - - expect(config1.hashCode, equals(config2.hashCode)); - expect(config1.hashCode, isNot(equals(config3.hashCode))); - }); - - test('toString provides readable output', () { - const config = FieldsVisitorConfig(includeMergeLookup: false); - - expect( - config.toString(), - 'FieldsVisitorConfig(includeMergeLookup: false)', - ); - }); - }); -} diff --git a/packages/theme_extensions_builder/test/runtime/symbols_test.dart b/packages/theme_extensions_builder/test/runtime/symbols_test.dart index 215fc07..490a285 100644 --- a/packages/theme_extensions_builder/test/runtime/symbols_test.dart +++ b/packages/theme_extensions_builder/test/runtime/symbols_test.dart @@ -7,123 +7,26 @@ import 'package:test/test.dart'; import 'package:theme_extensions_builder/src/common/symbols/field_info.dart'; import 'package:theme_extensions_builder/src/common/symbols/lerp_info.dart'; import 'package:theme_extensions_builder/src/common/symbols/merge_info.dart'; -import 'package:theme_extensions_builder/src/common/symbols/parameter_info.dart' - show ParameterInfo; void main() { - group('ParameterInfo', () { - test('creates ParameterInfo with required properties', () { - const arg = ParameterInfo( - name: 'value', - type: 'String', - isNullable: true, - ); - - expect(arg.name, 'value'); - expect(arg.type, 'String'); - expect(arg.isNullable, true); - }); - - test('equality works correctly', () { - const arg1 = ParameterInfo(name: 'a', type: 'int', isNullable: false); - const arg2 = ParameterInfo(name: 'a', type: 'int', isNullable: false); - const arg3 = ParameterInfo(name: 'b', type: 'int', isNullable: false); - - expect(arg1, equals(arg2)); - expect(arg1, isNot(equals(arg3))); - }); - - test('hashCode works correctly', () { - const arg1 = ParameterInfo(name: 'a', type: 'int', isNullable: false); - const arg2 = ParameterInfo(name: 'a', type: 'int', isNullable: false); - - expect(arg1.hashCode, equals(arg2.hashCode)); - }); - - test('toString returns readable format', () { - const arg = ParameterInfo(name: 'test', type: 'double', isNullable: true); - expect( - arg.toString(), - 'ParameterInfo(name: test, type: double, isNullable: true)', - ); - }); - }); - group('StaticLerp', () { test('creates StaticLerp with properties', () { - const lerp = StaticLerp( - optionalResult: true, - args: [ - ParameterInfo(name: 'a', type: 'Color', isNullable: true), - ParameterInfo(name: 'b', type: 'Color', isNullable: true), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ], - ); + const lerp = StaticLerp(optionalResult: true, isNullableParameter: true); expect(lerp.optionalResult, true); - expect(lerp.args.length, 3); - }); - - test('isNullableParameter is independent of the result nullability', () { - const nullableArgs = [ - ParameterInfo(name: 'a', type: 'Color', isNullable: true), - ParameterInfo(name: 'b', type: 'Color', isNullable: true), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ]; - - const optional = StaticLerp(optionalResult: true, args: nullableArgs); - const nonOptional = StaticLerp(optionalResult: false, args: nullableArgs); - - expect(optional.isNullableParameter, true); - expect(nonOptional.isNullableParameter, true); - }); - - test('isNullableParameter returns false when first arg not nullable', () { - const lerp = StaticLerp( - optionalResult: true, - args: [ - ParameterInfo(name: 'a', type: 'Color', isNullable: false), - ParameterInfo(name: 'b', type: 'Color', isNullable: true), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ], - ); - - expect(lerp.isNullableParameter, false); - }); - - test('isNullableParameter handles empty args safely', () { - const lerp = StaticLerp(optionalResult: true, args: []); - - expect(lerp.isNullableParameter, false); - }); - - test('isNullableParameter handles single arg safely', () { - const lerp = StaticLerp( - optionalResult: true, - args: [ParameterInfo(name: 'a', type: 'Color', isNullable: true)], - ); - - expect(lerp.isNullableParameter, false); + expect(lerp.isNullableParameter, true); }); test('equality works correctly', () { - // Built without `const` so the instances are not canonicalized and - // operator== is actually exercised. - final lerp1 = StaticLerp( - optionalResult: true, - args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], - ); - final lerp2 = StaticLerp( - optionalResult: true, - args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], - ); + final lerp1 = StaticLerp(optionalResult: true, isNullableParameter: true); + final lerp2 = StaticLerp(optionalResult: true, isNullableParameter: true); final lerp3 = StaticLerp( optionalResult: true, - args: [ParameterInfo(name: 'b', type: 'int', isNullable: false)], + isNullableParameter: false, ); final lerp4 = StaticLerp( optionalResult: false, - args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], + isNullableParameter: true, ); expect(lerp1, equals(lerp2)); @@ -133,65 +36,49 @@ void main() { }); test('toString returns correct format', () { - const lerp = StaticLerp(optionalResult: true, args: []); + const lerp = StaticLerp(optionalResult: true, isNullableParameter: false); - expect(lerp.toString(), 'StaticLerp(optionalResult: true, args: [])'); + expect( + lerp.toString(), + 'StaticLerp(optionalResult: true, isNullableParameter: false)', + ); }); }); group('InstanceLerp', () { test('creates InstanceLerp with properties', () { - const lerp = InstanceLerp( - optionalResult: true, - args: [ - ParameterInfo(name: 'other', type: 'Color', isNullable: false), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ], - ); + const lerp = InstanceLerp(optionalResult: true); expect(lerp.optionalResult, true); - expect(lerp.args.length, 2); + expect(lerp.needsCast, false); }); test('equality works correctly', () { - final lerp1 = InstanceLerp( - optionalResult: true, - args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], - ); - final lerp2 = InstanceLerp( - optionalResult: true, - args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], - ); - final lerp3 = InstanceLerp( - optionalResult: false, - args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], - ); + final lerp1 = InstanceLerp(optionalResult: true); + final lerp2 = InstanceLerp(optionalResult: true); + final lerp3 = InstanceLerp(optionalResult: false); expect(lerp1, equals(lerp2)); expect(lerp1.hashCode, equals(lerp2.hashCode)); expect(lerp1, isNot(equals(lerp3))); }); + test('needsCast takes part in equality', () { + final plain = InstanceLerp(optionalResult: true); + final cast = InstanceLerp(optionalResult: true, needsCast: true); + + expect(plain, isNot(equals(cast))); + expect(plain.hashCode, isNot(equals(cast.hashCode))); + }); + test('toString returns correct format', () { - const lerp = InstanceLerp(optionalResult: false, args: []); + const lerp = InstanceLerp(optionalResult: false); expect( lerp.toString(), - 'InstanceLerp(optionalResult: false, needsCast: false, args: [])', + 'InstanceLerp(optionalResult: false, needsCast: false)', ); }); - - test('needsCast takes part in equality', () { - final plain = InstanceLerp(optionalResult: true, args: []); - final cast = InstanceLerp( - optionalResult: true, - args: [], - needsCast: true, - ); - - expect(plain, isNot(equals(cast))); - expect(plain.hashCode, isNot(equals(cast.hashCode))); - }); }); group('WidgetStatePropertyLerp', () { @@ -312,17 +199,26 @@ void main() { }); group('FieldInfo', () { + FieldInfo build({ + String name = 'value', + String typeName = 'int', + bool isNullable = false, + bool isDouble = false, + bool isDuration = false, + MergeInfo merge = const NoMerge(), + LerpInfo lerp = const NoLerp(), + }) => FieldInfo( + name: name, + typeName: typeName, + isNullable: isNullable, + isDouble: isDouble, + isDuration: isDuration, + merge: merge, + lerp: lerp, + ); + test('creates FieldInfo with all properties', () { - const field = FieldInfo( - name: 'color', - typeName: 'Color', - isNullable: true, - isDouble: false, - isDuration: false, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: false, - ); + final field = build(name: 'color', typeName: 'Color', isNullable: true); expect(field.name, 'color'); expect(field.typeName, 'Color'); @@ -331,204 +227,51 @@ void main() { expect(field.isDuration, false); expect(field.merge, isA()); expect(field.lerp, isA()); - expect(field.isStatic, false); }); - test('equality works correctly with same properties', () { - const field1 = FieldInfo( - name: 'value', - typeName: 'int', - isNullable: false, - isDouble: false, - isDuration: false, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: false, - ); - - const field2 = FieldInfo( - name: 'value', - typeName: 'int', - isNullable: false, - isDouble: false, - isDuration: false, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: false, - ); - - expect(field1, equals(field2)); + test('the static call receiver drops the type arguments', () { + expect(build(typeName: 'Box').baseTypeName, 'Box'); + expect(build(typeName: 'Color').baseTypeName, 'Color'); }); - test('equality returns false with different properties', () { - const field1 = FieldInfo( - name: 'value', - typeName: 'int', - isNullable: false, - isDouble: false, - isDuration: false, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: false, - ); - - const field2 = FieldInfo( - name: 'other', - typeName: 'int', - isNullable: false, - isDouble: false, - isDuration: false, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: false, - ); - - expect(field1, isNot(equals(field2))); + test('equality works correctly with same properties', () { + expect(build(), equals(build())); + expect(build().hashCode, equals(build().hashCode)); }); - test('hashCode is consistent', () { - const field1 = FieldInfo( - name: 'test', - typeName: 'String', - isNullable: true, - isDouble: false, - isDuration: false, - merge: StaticMerge(), - lerp: NoLerp(), - isStatic: false, - ); - - const field2 = FieldInfo( - name: 'test', - typeName: 'String', - isNullable: true, - isDouble: false, - isDuration: false, - merge: StaticMerge(), - lerp: NoLerp(), - isStatic: false, + test('equality returns false with different properties', () { + expect(build(), isNot(equals(build(name: 'other')))); + expect(build(), isNot(equals(build(typeName: 'double')))); + expect(build(), isNot(equals(build(isNullable: true)))); + expect(build(), isNot(equals(build(isDouble: true)))); + expect(build(), isNot(equals(build(isDuration: true)))); + expect(build(), isNot(equals(build(merge: const StaticMerge())))); + expect( + build(), + isNot( + equals( + build( + lerp: const StaticLerp( + optionalResult: true, + isNullableParameter: true, + ), + ), + ), + ), ); - - expect(field1.hashCode, equals(field2.hashCode)); }); test('toString returns readable format', () { - const field = FieldInfo( - name: 'duration', - typeName: 'Duration', - isNullable: false, - isDouble: false, - isDuration: true, - merge: InstanceMerge(), - lerp: StaticLerp(optionalResult: false, args: []), - isStatic: false, - ); - - final string = field.toString(); - expect(string, contains('duration')); - expect(string, contains('Duration')); - expect(string, contains('isDuration: true')); - }); - - test('works with different lerp methods', () { - const field1 = FieldInfo( - name: 'x', - typeName: 'double', - isNullable: false, - isDouble: true, - isDuration: false, - merge: NoMerge(), - lerp: StaticLerp(optionalResult: false, args: []), - isStatic: false, - ); - - const field2 = FieldInfo( - name: 'x', - typeName: 'double', - isNullable: false, - isDouble: true, - isDuration: false, - merge: NoMerge(), - lerp: InstanceLerp(optionalResult: false, args: []), - isStatic: false, - ); - - expect(field1, isNot(equals(field2))); - }); - }); - - group('Edge cases and boundaries', () { - test('StaticLerp with exactly 2 args works', () { - const lerp = StaticLerp( - optionalResult: true, - args: [ - ParameterInfo(name: 'a', type: 'int', isNullable: true), - ParameterInfo(name: 'b', type: 'int', isNullable: true), - ], - ); - - expect(lerp.isNullableParameter, true); - }); - - test('StaticLerp with 3+ args checks first two', () { - const lerp = StaticLerp( - optionalResult: true, - args: [ - ParameterInfo(name: 'a', type: 'int', isNullable: true), - ParameterInfo(name: 'b', type: 'int', isNullable: true), - ParameterInfo(name: 'c', type: 'double', isNullable: false), - ParameterInfo(name: 'd', type: 'String', isNullable: true), - ], - ); - - expect(lerp.isNullableParameter, true); - }); - - test('FieldInfo with isDouble true', () { - const field = FieldInfo( - name: 'opacity', - typeName: 'double', - isNullable: false, - isDouble: true, - isDuration: false, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: false, - ); - - expect(field.isDouble, true); - expect(field.isDuration, false); - }); - - test('FieldInfo with isDuration true', () { - const field = FieldInfo( - name: 'timeout', - typeName: 'Duration', - isNullable: false, - isDouble: false, - isDuration: true, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: false, - ); - - expect(field.isDouble, false); - expect(field.isDuration, true); - }); - - test('FieldInfo with isStatic true', () { - const field = FieldInfo( - name: 'constant', - typeName: 'int', - isNullable: false, - isDouble: false, - isDuration: false, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: true, + expect( + build().toString(), + 'FieldInfo(name: value, ' + 'typeName: int, ' + 'isNullable: false, ' + 'isDouble: false, ' + 'isDuration: false, ' + 'merge: NoMerge(), ' + 'lerp: NoLerp())', ); - - expect(field.isStatic, true); }); }); } diff --git a/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart b/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart index e1588de..ba6f126 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart @@ -1,9 +1,9 @@ +import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:test/test.dart'; import '../theme_extensions/complex_theme_extension.dart'; import '../theme_extensions/empty_theme.dart'; import '../theme_extensions/empty_theme_extension.dart'; -import '../theme_extensions/mock.dart'; void main() { group('ComplexThemeExtensionNoContext', () { diff --git a/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart b/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart index 87b7f43..b9b0ba9 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart @@ -1,9 +1,9 @@ +import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:test/test.dart'; import '../theme_gen/complex_theme.dart'; import '../theme_gen/empty_theme.dart'; import '../theme_gen/empty_theme_extension.dart'; -import '../theme_gen/mock.dart'; void main() { group('ComplexTheme', () { diff --git a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.dart b/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.dart index c7cfcce..7b139ed 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.dart +++ b/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.dart @@ -1,9 +1,9 @@ +import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:source_gen_test/source_gen_test.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; import 'empty_theme.dart'; import 'empty_theme_extension.dart'; -import 'mock.dart'; part 'complex_theme_extension.g.theme.dart'; diff --git a/packages/theme_extensions_builder/test/theme_extensions/empty_theme_extension.dart b/packages/theme_extensions_builder/test/theme_extensions/empty_theme_extension.dart index e63f01f..891bd16 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/empty_theme_extension.dart +++ b/packages/theme_extensions_builder/test/theme_extensions/empty_theme_extension.dart @@ -1,8 +1,7 @@ +import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:source_gen_test/source_gen_test.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import 'mock.dart'; - part 'empty_theme_extension.g.theme.dart'; /// Empty ThemeExtension - testing edge case with no fields diff --git a/packages/theme_extensions_builder/test/theme_extensions/mock.dart b/packages/theme_extensions_builder/test/theme_extensions/mock.dart deleted file mode 120000 index 15c95ff..0000000 --- a/packages/theme_extensions_builder/test/theme_extensions/mock.dart +++ /dev/null @@ -1 +0,0 @@ -../mock/mock.dart \ No newline at end of file diff --git a/packages/theme_extensions_builder/test/theme_extensions/widget_state_property_theme_extension.dart b/packages/theme_extensions_builder/test/theme_extensions/widget_state_property_theme_extension.dart index 8974475..b153cb8 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/widget_state_property_theme_extension.dart +++ b/packages/theme_extensions_builder/test/theme_extensions/widget_state_property_theme_extension.dart @@ -1,8 +1,7 @@ +import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:source_gen_test/source_gen_test.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import 'mock.dart'; - part 'widget_state_property_theme_extension.g.theme.dart'; /// Empty ThemeExtension - testing edge case with no fields diff --git a/packages/theme_extensions_builder/test/theme_gen/complex_theme.dart b/packages/theme_extensions_builder/test/theme_gen/complex_theme.dart index 751425b..2f50ec3 100644 --- a/packages/theme_extensions_builder/test/theme_gen/complex_theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/complex_theme.dart @@ -1,9 +1,9 @@ +import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:source_gen_test/source_gen_test.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; import 'empty_theme.dart'; import 'empty_theme_extension.dart'; -import 'mock.dart'; part 'complex_theme.g.theme.dart'; diff --git a/packages/theme_extensions_builder/test/theme_gen/empty_theme.dart b/packages/theme_extensions_builder/test/theme_gen/empty_theme.dart index 43b0ba1..f711830 100644 --- a/packages/theme_extensions_builder/test/theme_gen/empty_theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/empty_theme.dart @@ -24,7 +24,7 @@ final class EmptyTheme with _$EmptyTheme { ) @themeGen final class EmptyThemeNonConst with _$EmptyThemeNonConst { - const EmptyThemeNonConst(); + EmptyThemeNonConst(); @override bool get canMerge => true; diff --git a/packages/theme_extensions_builder/test/theme_gen/empty_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/empty_theme.g.theme.dart index 7a41cea..3d7754a 100644 --- a/packages/theme_extensions_builder/test/theme_gen/empty_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/empty_theme.g.theme.dart @@ -85,11 +85,11 @@ mixin _$EmptyThemeNonConst { return t == 0.0 ? a : null; } - return const EmptyThemeNonConst(); + return EmptyThemeNonConst(); } EmptyThemeNonConst copyWith() { - return const EmptyThemeNonConst(); + return EmptyThemeNonConst(); } EmptyThemeNonConst merge(EmptyThemeNonConst? other) { diff --git a/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.dart b/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.dart index b987459..9e53aac 100644 --- a/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.dart +++ b/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.dart @@ -1,7 +1,6 @@ +import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import 'mock.dart'; - part 'empty_theme_extension.g.theme.dart'; @themeExtensions diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/empty_theme_non_const.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/goldens/empty_theme_non_const.g.theme.dart index bdbe524..ef33ada 100644 --- a/packages/theme_extensions_builder/test/theme_gen/goldens/empty_theme_non_const.g.theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/goldens/empty_theme_non_const.g.theme.dart @@ -20,11 +20,11 @@ mixin _$EmptyThemeNonConst { return t == 0.0 ? a : null; } - return const EmptyThemeNonConst(); + return EmptyThemeNonConst(); } EmptyThemeNonConst copyWith() { - return const EmptyThemeNonConst(); + return EmptyThemeNonConst(); } EmptyThemeNonConst merge(EmptyThemeNonConst? other) { diff --git a/packages/theme_extensions_builder/test/theme_gen/mock.dart b/packages/theme_extensions_builder/test/theme_gen/mock.dart deleted file mode 120000 index 15c95ff..0000000 --- a/packages/theme_extensions_builder/test/theme_gen/mock.dart +++ /dev/null @@ -1 +0,0 @@ -../mock/mock.dart \ No newline at end of file diff --git a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart b/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart index f98eed6..991f565 100644 --- a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart +++ b/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart @@ -1,8 +1,7 @@ +import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:source_gen_test/source_gen_test.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import 'mock.dart'; - part 'widget_state_property_theme.g.theme.dart'; const _labelWarning = diff --git a/packages/theme_extensions_builder_annotation/pubspec.yaml b/packages/theme_extensions_builder_annotation/pubspec.yaml index b4b67c1..741a08c 100644 --- a/packages/theme_extensions_builder_annotation/pubspec.yaml +++ b/packages/theme_extensions_builder_annotation/pubspec.yaml @@ -27,6 +27,8 @@ platforms: environment: sdk: ">=3.13.0 <4.0.0" +resolution: workspace + dependencies: meta: ^1.16.0 diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..b79cae8 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,14 @@ +name: theme_extensions_builder_workspace +description: > + Development workspace for theme_extensions_builder. Not published; run + `flutter pub get` here once to resolve every package. +publish_to: none + +environment: + sdk: ">=3.13.0 <4.0.0" + +workspace: + - packages/theme_extensions_builder + - packages/theme_extensions_builder_annotation + - packages/theme_extensions_builder/example + - packages/flutter_stubs diff --git a/scripts/prepare_push.sh b/scripts/prepare_push.sh index 9331666..1ec7607 100755 --- a/scripts/prepare_push.sh +++ b/scripts/prepare_push.sh @@ -49,14 +49,13 @@ function log_warning() { echo -e "${COLOR_YELLOW}[!]${COLOR_RESET} $1" } -function pub_update() { - local package_path=$1 - log_begin "Running 'dart pub update' in $package_path" - if dart pub update --directory "$package_path"; then - log_success "pub update completed for $package_path" +function pub_get() { + log_begin "Running 'flutter pub get' for the workspace" + if flutter pub get; then + log_success "Workspace resolved" return 0 else - log_error "pub update failed for $package_path" + log_error "pub get failed" return 1 fi } @@ -142,7 +141,6 @@ function process_package() { log_info "Processing" "$package_name" local steps=( - "pub_update" "dart_format" "dart_fix" "dart_analyze" @@ -166,9 +164,13 @@ function main() { cd "$PROJECT_ROOT" + # One resolution for every package: they form a pub workspace. + pub_get || return 1 + local dirs=( - "packages/theme_extensions_builder" "packages/theme_extensions_builder_annotation" + "packages/flutter_stubs" + "packages/theme_extensions_builder" "packages/theme_extensions_builder/example" ) From 8de6af6972ea725afd09709367d5f5a87acad985 Mon Sep 17 00:00:00 2001 From: Andrii Ivanov Date: Sun, 6 Sep 2026 14:12:44 +0300 Subject: [PATCH 09/10] Validate the annotated class, share lerp generation, move to a pub workspace Generator - Check the annotated class before generating: a missing constructor, a field without a named constructor parameter, a @ThemeExtensions class that does not extend ThemeExtension, and a contextAccessorName that is not an identifier now stop the build with an InvalidGenerationSourceError naming the class and the fix. - Decide `const` by the constructor named in `constructor:` rather than by any constructor of the class; treat `constructor: ''` as unnamed. - Match @ThemeGen and @ignore by package as well as by name. Code - Generate `lerp` and `copyWith` once, in generator/common.dart, for both generators. Output is unchanged. - Trim the symbol model to what the code builders read: drop ParameterInfo, lerp args, isStatic, filteredFields, FieldsVisitorConfig and the Ref extension type. Skip static fields in the visitor. - Split analysis.dart into per-method lookups, a DartType extension and field_symbol.dart. Drop the meta and collection dependencies. Tests - Fixtures for every new error, for ThemeGen and ThemeExtensions. - EmptyThemeNonConst was declared const; make it non-const and update its golden. - Merge test/theme_gen, test/theme_extensions and test/invalid into test/fixtures, next to a single flutter_stubs.dart, replacing the mock.dart symlinks and the duplicated helper fixtures. Packaging - Drop the `platforms` key: a build_runner generator runs in the build process, and claiming web cost the package its WASM sco - Add example/example.md to the annotation package. Repository - Turn the repository into a pub workspace with the examp remove both pubspec_overrides.yaml. - CI: latest and lowest dependency matrix, analyze with --fatal-infos, regenerate fixtures and example and fail on stale .g.th analyze the example against the real framework. - prepare_push.sh called the non-existent `dart pub updat workspace once instead. Fix the cwd in .vscode/tasks.js Docs - README: accurate build.yaml section, limitations, gener troubleshooting.md lists each new message. CHANGELOG 7.5.0 extended. --- .github/README.md | 2 +- .github/workflows/dart.yml | 5 +- packages/flutter_stubs/.gitignore | 2 - packages/flutter_stubs/analysis_options.yaml | 4 - packages/flutter_stubs/pubspec.yaml | 16 --- .../theme_extensions_builder/CHANGELOG.md | 1 + .../analysis_options.yaml | 3 +- packages/theme_extensions_builder/build.yaml | 7 +- .../theme_extensions_builder/pubspec.yaml | 10 -- .../complex_theme.dart | 2 +- .../complex_theme.g.theme.dart | 0 .../complex_theme_extension.dart | 2 +- .../complex_theme_extension.g.theme.dart | 0 .../{theme_gen => fixtures}/empty_theme.dart | 0 .../empty_theme.g.theme.dart | 0 .../empty_theme_extension.dart | 3 +- .../empty_theme_extension.g.theme.dart | 0 .../test/fixtures}/flutter_stubs.dart | 8 +- .../goldens/complex_theme.g.theme.dart | 0 .../complex_theme_extension.g.theme.dart | 0 ...eme_extension_custom_accessor.g.theme.dart | 0 ...ex_theme_extension_no_context.g.theme.dart | 0 .../complex_theme_internal.g.theme.dart | 0 .../goldens/empty_theme.g.theme.dart | 0 .../empty_theme_extension.g.theme.dart | 0 ...pty_theme_extension_non_const.g.theme.dart | 0 .../empty_theme_non_const.g.theme.dart | 0 .../goldens/inherited_theme.g.theme.dart | 0 .../goldens/lookup_theme.g.theme.dart | 0 .../widget_state_property_theme.g.theme.dart | 0 ...tate_property_theme_extension.g.theme.dart | 0 .../inherited_theme.dart | 0 .../inherited_theme.g.theme.dart | 0 .../{invalid => fixtures}/invalid_theme.dart | 7 +- .../invalid_theme_extension.dart | 7 +- .../{theme_gen => fixtures}/lookup_theme.dart | 0 .../lookup_theme.g.theme.dart | 0 .../widget_state_property_theme.dart | 3 +- .../widget_state_property_theme.g.theme.dart | 0 ...widget_state_property_theme_extension.dart | 3 +- ...tate_property_theme_extension.g.theme.dart | 0 .../test/generator/invalid_test.dart | 4 +- .../test/generator/theme_extensions_test.dart | 6 +- .../test/generator/theme_gen_test.dart | 10 +- .../theme_extensions_complex_test.dart | 8 +- .../runtime/theme_extensions_empty_test.dart | 2 +- .../test/runtime/theme_gen_complex_test.dart | 8 +- .../test/runtime/theme_gen_empty_test.dart | 84 ++++++------ .../test/runtime/theme_gen_lookup_test.dart | 2 +- .../test/theme_extensions/empty_theme.dart | 29 ---- .../theme_extensions/empty_theme.g.theme.dart | 128 ------------------ .../test/theme_gen/empty_theme_extension.dart | 10 -- .../empty_theme_extension.g.theme.dart | 52 ------- .../example/example.md | 64 +++++++++ pubspec.yaml | 1 - scripts/prepare_push.sh | 1 - 56 files changed, 157 insertions(+), 337 deletions(-) delete mode 100644 packages/flutter_stubs/.gitignore delete mode 100644 packages/flutter_stubs/analysis_options.yaml delete mode 100644 packages/flutter_stubs/pubspec.yaml rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/complex_theme.dart (99%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/complex_theme.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_extensions => fixtures}/complex_theme_extension.dart (98%) rename packages/theme_extensions_builder/test/{theme_extensions => fixtures}/complex_theme_extension.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/empty_theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/empty_theme.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_extensions => fixtures}/empty_theme_extension.dart (94%) rename packages/theme_extensions_builder/test/{theme_extensions => fixtures}/empty_theme_extension.g.theme.dart (100%) rename packages/{flutter_stubs/lib => theme_extensions_builder/test/fixtures}/flutter_stubs.dart (97%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/goldens/complex_theme.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_extensions => fixtures}/goldens/complex_theme_extension.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_extensions => fixtures}/goldens/complex_theme_extension_custom_accessor.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_extensions => fixtures}/goldens/complex_theme_extension_no_context.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/goldens/complex_theme_internal.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/goldens/empty_theme.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_extensions => fixtures}/goldens/empty_theme_extension.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_extensions => fixtures}/goldens/empty_theme_extension_non_const.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/goldens/empty_theme_non_const.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/goldens/inherited_theme.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/goldens/lookup_theme.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/goldens/widget_state_property_theme.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_extensions => fixtures}/goldens/widget_state_property_theme_extension.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/inherited_theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/inherited_theme.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{invalid => fixtures}/invalid_theme.dart (93%) rename packages/theme_extensions_builder/test/{invalid => fixtures}/invalid_theme_extension.dart (92%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/lookup_theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/lookup_theme.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/widget_state_property_theme.dart (96%) rename packages/theme_extensions_builder/test/{theme_gen => fixtures}/widget_state_property_theme.g.theme.dart (100%) rename packages/theme_extensions_builder/test/{theme_extensions => fixtures}/widget_state_property_theme_extension.dart (95%) rename packages/theme_extensions_builder/test/{theme_extensions => fixtures}/widget_state_property_theme_extension.g.theme.dart (100%) delete mode 100644 packages/theme_extensions_builder/test/theme_extensions/empty_theme.dart delete mode 100644 packages/theme_extensions_builder/test/theme_extensions/empty_theme.g.theme.dart delete mode 100644 packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.dart delete mode 100644 packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.g.theme.dart create mode 100644 packages/theme_extensions_builder_annotation/example/example.md diff --git a/.github/README.md b/.github/README.md index 6bc1f52..68e0c7c 100644 --- a/.github/README.md +++ b/.github/README.md @@ -153,7 +153,7 @@ scripts/prepare_push.sh # format, analyze, build and test every package scripts/update_goldens.sh # regenerate the golden files after a generator change ``` -The generator tests run on the Dart SDK alone, against `packages/flutter_stubs`: small stand-ins for the Flutter classes the fixtures use, with the same `lerp` and `merge` signatures. The example app is where the generated code meets the real framework, so CI regenerates and analyzes it on every push. +The generator tests run on the Dart SDK alone. The Flutter classes the fixtures use are small stand-ins in `packages/theme_extensions_builder/test/fixtures/flutter_stubs.dart`, with the same `lerp` and `merge` signatures as the real ones. The example app is where the generated code meets the real framework, so CI regenerates and analyzes it on every push. ## 📄 License diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 1107e72..d9f674c 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -9,7 +9,6 @@ on: env: BUILDER: packages/theme_extensions_builder ANNOTATION: packages/theme_extensions_builder_annotation - STUBS: packages/flutter_stubs EXAMPLE: packages/theme_extensions_builder/example jobs: @@ -42,10 +41,10 @@ jobs: - name: Check formatting if: matrix.deps == 'latest' - run: dart format --set-exit-if-changed $BUILDER $ANNOTATION $STUBS + run: dart format --set-exit-if-changed $BUILDER $ANNOTATION - name: Analyze - run: dart analyze --fatal-infos $BUILDER $ANNOTATION $STUBS + run: dart analyze --fatal-infos $BUILDER $ANNOTATION - name: Test annotation working-directory: ${{ env.ANNOTATION }} diff --git a/packages/flutter_stubs/.gitignore b/packages/flutter_stubs/.gitignore deleted file mode 100644 index a4f98c8..0000000 --- a/packages/flutter_stubs/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.dart_tool/ -/pubspec.lock diff --git a/packages/flutter_stubs/analysis_options.yaml b/packages/flutter_stubs/analysis_options.yaml deleted file mode 100644 index 9f84222..0000000 --- a/packages/flutter_stubs/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:pro_lints/recommended.yaml - -formatter: - trailing_commas: preserve diff --git a/packages/flutter_stubs/pubspec.yaml b/packages/flutter_stubs/pubspec.yaml deleted file mode 100644 index 5baffb7..0000000 --- a/packages/flutter_stubs/pubspec.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: flutter_stubs -description: > - Stand-ins for the Flutter classes the theme_extensions_builder tests need, - so the generator is tested on the Dart SDK alone. Not published. -publish_to: none - -environment: - sdk: ">=3.13.0 <4.0.0" - -resolution: workspace - -dependencies: - theme_extensions_builder_annotation: ^7.5.0 - -dev_dependencies: - pro_lints: ^6.2.0 diff --git a/packages/theme_extensions_builder/CHANGELOG.md b/packages/theme_extensions_builder/CHANGELOG.md index 761519a..edf853b 100644 --- a/packages/theme_extensions_builder/CHANGELOG.md +++ b/packages/theme_extensions_builder/CHANGELOG.md @@ -15,6 +15,7 @@ - **Fixed**: `constructor: ''` selects the unnamed constructor instead of emitting `X.()`. - **Fixed**: The `@ThemeGen` and `@ignore` annotations are matched by package as well as by name, so a user class called `ThemeGen` is no longer taken for the annotation. - **Code Quality**: The `lerp` and `copyWith` generation is shared between the two generators instead of being maintained twice. The symbol model keeps only what the code builders read, the method lookups live in one file each, and the `collection` and `meta` dependencies are gone. +- **Changed**: The `platforms` key is gone from the pubspec. A `build_runner` generator runs in the build process rather than on a platform, and declaring web support only cost the package its WASM score; pub.dev now lists the platforms it detects, as it does for `json_serializable` and `freezed`. ## 7.4.0 diff --git a/packages/theme_extensions_builder/analysis_options.yaml b/packages/theme_extensions_builder/analysis_options.yaml index 380e10d..9e8ec77 100644 --- a/packages/theme_extensions_builder/analysis_options.yaml +++ b/packages/theme_extensions_builder/analysis_options.yaml @@ -5,5 +5,4 @@ formatter: analyzer: exclude: - - test/theme_gen/goldens/**.dart - - test/theme_extensions/goldens/**.dart + - test/fixtures/goldens/**.dart diff --git a/packages/theme_extensions_builder/build.yaml b/packages/theme_extensions_builder/build.yaml index 1a5d396..9eb1800 100644 --- a/packages/theme_extensions_builder/build.yaml +++ b/packages/theme_extensions_builder/build.yaml @@ -3,8 +3,11 @@ targets: builders: theme_extensions_builder: generate_for: - - test/theme_gen/**.dart - - test/theme_extensions/**.dart + include: + - test/fixtures/*.dart + exclude: + # These classes are meant to fail generation; see invalid_test. + - test/fixtures/invalid_*.dart builders: theme_extensions_builder: diff --git a/packages/theme_extensions_builder/pubspec.yaml b/packages/theme_extensions_builder/pubspec.yaml index aeb7e97..675ec6c 100644 --- a/packages/theme_extensions_builder/pubspec.yaml +++ b/packages/theme_extensions_builder/pubspec.yaml @@ -10,14 +10,6 @@ documentation: https://github.com/pro100andrey/theme_extensions_builder/blob/mai version: 7.5.0 -platforms: - android: - ios: - linux: - macos: - web: - windows: - topics: - theme - theme-extension @@ -38,8 +30,6 @@ dependencies: dev_dependencies: build_test: "^3.5.15" - flutter_stubs: - path: ../flutter_stubs path: ^1.9.1 pro_lints: ^6.2.0 source_gen_test: ^1.3.6 diff --git a/packages/theme_extensions_builder/test/theme_gen/complex_theme.dart b/packages/theme_extensions_builder/test/fixtures/complex_theme.dart similarity index 99% rename from packages/theme_extensions_builder/test/theme_gen/complex_theme.dart rename to packages/theme_extensions_builder/test/fixtures/complex_theme.dart index 2f50ec3..f2eef6a 100644 --- a/packages/theme_extensions_builder/test/theme_gen/complex_theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/complex_theme.dart @@ -1,9 +1,9 @@ -import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:source_gen_test/source_gen_test.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; import 'empty_theme.dart'; import 'empty_theme_extension.dart'; +import 'flutter_stubs.dart'; part 'complex_theme.g.theme.dart'; diff --git a/packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/complex_theme.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/complex_theme.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.dart b/packages/theme_extensions_builder/test/fixtures/complex_theme_extension.dart similarity index 98% rename from packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.dart rename to packages/theme_extensions_builder/test/fixtures/complex_theme_extension.dart index 7b139ed..17633ed 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.dart +++ b/packages/theme_extensions_builder/test/fixtures/complex_theme_extension.dart @@ -1,9 +1,9 @@ -import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:source_gen_test/source_gen_test.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; import 'empty_theme.dart'; import 'empty_theme_extension.dart'; +import 'flutter_stubs.dart'; part 'complex_theme_extension.g.theme.dart'; diff --git a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/complex_theme_extension.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/complex_theme_extension.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_gen/empty_theme.dart b/packages/theme_extensions_builder/test/fixtures/empty_theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/empty_theme.dart rename to packages/theme_extensions_builder/test/fixtures/empty_theme.dart diff --git a/packages/theme_extensions_builder/test/theme_gen/empty_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/empty_theme.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/empty_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/empty_theme.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_extensions/empty_theme_extension.dart b/packages/theme_extensions_builder/test/fixtures/empty_theme_extension.dart similarity index 94% rename from packages/theme_extensions_builder/test/theme_extensions/empty_theme_extension.dart rename to packages/theme_extensions_builder/test/fixtures/empty_theme_extension.dart index 891bd16..9b964a6 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/empty_theme_extension.dart +++ b/packages/theme_extensions_builder/test/fixtures/empty_theme_extension.dart @@ -1,7 +1,8 @@ -import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:source_gen_test/source_gen_test.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; +import 'flutter_stubs.dart'; + part 'empty_theme_extension.g.theme.dart'; /// Empty ThemeExtension - testing edge case with no fields diff --git a/packages/theme_extensions_builder/test/theme_extensions/empty_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/empty_theme_extension.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/empty_theme_extension.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/empty_theme_extension.g.theme.dart diff --git a/packages/flutter_stubs/lib/flutter_stubs.dart b/packages/theme_extensions_builder/test/fixtures/flutter_stubs.dart similarity index 97% rename from packages/flutter_stubs/lib/flutter_stubs.dart rename to packages/theme_extensions_builder/test/fixtures/flutter_stubs.dart index 676a237..ccbe283 100644 --- a/packages/flutter_stubs/lib/flutter_stubs.dart +++ b/packages/theme_extensions_builder/test/fixtures/flutter_stubs.dart @@ -6,9 +6,11 @@ /// the same `lerp` and `merge` signatures as the real ones. That is the only /// thing the generator looks at. /// -/// Keep the signatures in step with Flutter: the example app in the -/// `theme_extensions_builder` package is what checks them against the real -/// framework. +/// Keep the signatures in step with Flutter: the example app is what checks +/// the generated code against the real framework. +/// +/// This file sits next to the fixtures because `source_gen_test` reads a +/// fixture directory flat, so a fixture can only import its siblings. library; import 'dart:math' as math; diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/complex_theme.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/complex_theme.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_extension.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_extension.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension_custom_accessor.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_extension_custom_accessor.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension_custom_accessor.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_extension_custom_accessor.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension_no_context.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_extension_no_context.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension_no_context.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_extension_no_context.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme_internal.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_internal.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme_internal.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_internal.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/empty_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/empty_theme.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/goldens/empty_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/empty_theme.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_extensions/goldens/empty_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/empty_theme_extension.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/goldens/empty_theme_extension.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/empty_theme_extension.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_extensions/goldens/empty_theme_extension_non_const.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/empty_theme_extension_non_const.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/goldens/empty_theme_extension_non_const.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/empty_theme_extension_non_const.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/empty_theme_non_const.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/empty_theme_non_const.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/goldens/empty_theme_non_const.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/empty_theme_non_const.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/inherited_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/inherited_theme.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/goldens/inherited_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/inherited_theme.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/lookup_theme.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/goldens/lookup_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/lookup_theme.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/widget_state_property_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/widget_state_property_theme.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/goldens/widget_state_property_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/widget_state_property_theme.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_extensions/goldens/widget_state_property_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/widget_state_property_theme_extension.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/goldens/widget_state_property_theme_extension.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/widget_state_property_theme_extension.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_gen/inherited_theme.dart b/packages/theme_extensions_builder/test/fixtures/inherited_theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/inherited_theme.dart rename to packages/theme_extensions_builder/test/fixtures/inherited_theme.dart diff --git a/packages/theme_extensions_builder/test/theme_gen/inherited_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/inherited_theme.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/inherited_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/inherited_theme.g.theme.dart diff --git a/packages/theme_extensions_builder/test/invalid/invalid_theme.dart b/packages/theme_extensions_builder/test/fixtures/invalid_theme.dart similarity index 93% rename from packages/theme_extensions_builder/test/invalid/invalid_theme.dart rename to packages/theme_extensions_builder/test/fixtures/invalid_theme.dart index 27e84f8..672f9d5 100644 --- a/packages/theme_extensions_builder/test/invalid/invalid_theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/invalid_theme.dart @@ -1,14 +1,15 @@ /// Classes the generator refuses, with the error it reports for each. /// -/// This directory is not part of `generate_for` in `build.yaml`, so the -/// failing generation is only exercised by the test. None of the classes mix +/// This file is excluded from `generate_for` in `build.yaml`, so the failing +/// generation is only exercised by the test. None of the classes mix /// in the generated mixin for the same reason. library; -import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:source_gen_test/source_gen_test.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; +import 'flutter_stubs.dart'; + /// `WidgetStateProperty.lerp` needs a lerp function with nullable parameters, /// so the generic of a `WidgetStateProperty` field has to be nullable. @ShouldThrow( diff --git a/packages/theme_extensions_builder/test/invalid/invalid_theme_extension.dart b/packages/theme_extensions_builder/test/fixtures/invalid_theme_extension.dart similarity index 92% rename from packages/theme_extensions_builder/test/invalid/invalid_theme_extension.dart rename to packages/theme_extensions_builder/test/fixtures/invalid_theme_extension.dart index d592f77..b609e99 100644 --- a/packages/theme_extensions_builder/test/invalid/invalid_theme_extension.dart +++ b/packages/theme_extensions_builder/test/fixtures/invalid_theme_extension.dart @@ -1,15 +1,16 @@ /// Theme extensions the generator refuses, with the error it reports for /// each. /// -/// This directory is not part of `generate_for` in `build.yaml`, so the -/// failing generation is only exercised by the test. None of the classes mix +/// This file is excluded from `generate_for` in `build.yaml`, so the failing +/// generation is only exercised by the test. None of the classes mix /// in the generated mixin for the same reason. library; -import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:source_gen_test/source_gen_test.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; +import 'flutter_stubs.dart'; + /// The generated mixin is declared `on ThemeExtension`. @ShouldThrow( '`NotAThemeExtension` must extend `ThemeExtension` to ' diff --git a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart b/packages/theme_extensions_builder/test/fixtures/lookup_theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/lookup_theme.dart rename to packages/theme_extensions_builder/test/fixtures/lookup_theme.dart diff --git a/packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/lookup_theme.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/lookup_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/lookup_theme.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.dart similarity index 96% rename from packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart rename to packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.dart index 991f565..a669a1c 100644 --- a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.dart @@ -1,7 +1,8 @@ -import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:source_gen_test/source_gen_test.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; +import 'flutter_stubs.dart'; + part 'widget_state_property_theme.g.theme.dart'; const _labelWarning = diff --git a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_extensions/widget_state_property_theme_extension.dart b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme_extension.dart similarity index 95% rename from packages/theme_extensions_builder/test/theme_extensions/widget_state_property_theme_extension.dart rename to packages/theme_extensions_builder/test/fixtures/widget_state_property_theme_extension.dart index b153cb8..d6d5188 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/widget_state_property_theme_extension.dart +++ b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme_extension.dart @@ -1,7 +1,8 @@ -import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:source_gen_test/source_gen_test.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; +import 'flutter_stubs.dart'; + part 'widget_state_property_theme_extension.g.theme.dart'; /// Empty ThemeExtension - testing edge case with no fields diff --git a/packages/theme_extensions_builder/test/theme_extensions/widget_state_property_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme_extension.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/widget_state_property_theme_extension.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/widget_state_property_theme_extension.g.theme.dart diff --git a/packages/theme_extensions_builder/test/generator/invalid_test.dart b/packages/theme_extensions_builder/test/generator/invalid_test.dart index 6ebb9f8..c93fa11 100644 --- a/packages/theme_extensions_builder/test/generator/invalid_test.dart +++ b/packages/theme_extensions_builder/test/generator/invalid_test.dart @@ -8,7 +8,7 @@ Future main() async { initializeBuildLogTracking(); final themeGenReader = await initializeLibraryReaderForDirectory( - 'test/invalid', + 'test/fixtures', 'invalid_theme.dart', ); @@ -17,7 +17,7 @@ Future main() async { }); final themeExtensionsReader = await initializeLibraryReaderForDirectory( - 'test/invalid', + 'test/fixtures', 'invalid_theme_extension.dart', ); diff --git a/packages/theme_extensions_builder/test/generator/theme_extensions_test.dart b/packages/theme_extensions_builder/test/generator/theme_extensions_test.dart index f50db95..9047440 100644 --- a/packages/theme_extensions_builder/test/generator/theme_extensions_test.dart +++ b/packages/theme_extensions_builder/test/generator/theme_extensions_test.dart @@ -9,7 +9,7 @@ Future main() async { const generator = ThemeExtensionsGenerator(); final emptyReader = await initializeLibraryReaderForDirectory( - 'test/theme_extensions', + 'test/fixtures', 'empty_theme_extension.dart', ); @@ -18,7 +18,7 @@ Future main() async { }); final complexReader = await initializeLibraryReaderForDirectory( - 'test/theme_extensions', + 'test/fixtures', 'complex_theme_extension.dart', ); group('Complex', () { @@ -26,7 +26,7 @@ Future main() async { }); final wspReader = await initializeLibraryReaderForDirectory( - 'test/theme_extensions', + 'test/fixtures', 'widget_state_property_theme_extension.dart', ); group('WidgetStateProperty', () { diff --git a/packages/theme_extensions_builder/test/generator/theme_gen_test.dart b/packages/theme_extensions_builder/test/generator/theme_gen_test.dart index 229c30b..7724b3e 100644 --- a/packages/theme_extensions_builder/test/generator/theme_gen_test.dart +++ b/packages/theme_extensions_builder/test/generator/theme_gen_test.dart @@ -9,7 +9,7 @@ Future main() async { const generator = ThemeGenGenerator(); final emptyReader = await initializeLibraryReaderForDirectory( - 'test/theme_gen', + 'test/fixtures', 'empty_theme.dart', ); @@ -18,7 +18,7 @@ Future main() async { }); final complexReader = await initializeLibraryReaderForDirectory( - 'test/theme_gen', + 'test/fixtures', 'complex_theme.dart', ); @@ -27,7 +27,7 @@ Future main() async { }); final wspReader = await initializeLibraryReaderForDirectory( - 'test/theme_gen', + 'test/fixtures', 'widget_state_property_theme.dart', ); @@ -36,7 +36,7 @@ Future main() async { }); final lookupReader = await initializeLibraryReaderForDirectory( - 'test/theme_gen', + 'test/fixtures', 'lookup_theme.dart', ); @@ -45,7 +45,7 @@ Future main() async { }); final inheritedReader = await initializeLibraryReaderForDirectory( - 'test/theme_gen', + 'test/fixtures', 'inherited_theme.dart', ); diff --git a/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart b/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart index ba6f126..41206d4 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart @@ -1,9 +1,9 @@ -import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:test/test.dart'; -import '../theme_extensions/complex_theme_extension.dart'; -import '../theme_extensions/empty_theme.dart'; -import '../theme_extensions/empty_theme_extension.dart'; +import '../fixtures/complex_theme_extension.dart'; +import '../fixtures/empty_theme.dart'; +import '../fixtures/empty_theme_extension.dart'; +import '../fixtures/flutter_stubs.dart'; void main() { group('ComplexThemeExtensionNoContext', () { diff --git a/packages/theme_extensions_builder/test/runtime/theme_extensions_empty_test.dart b/packages/theme_extensions_builder/test/runtime/theme_extensions_empty_test.dart index f2aa177..42ab1d4 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_extensions_empty_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_extensions_empty_test.dart @@ -1,6 +1,6 @@ import 'package:test/test.dart'; -import '../theme_extensions/empty_theme_extension.dart'; +import '../fixtures/empty_theme_extension.dart'; void main() { group('EmptyThemeExtension', () { diff --git a/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart b/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart index b9b0ba9..f8de78e 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart @@ -1,9 +1,9 @@ -import 'package:flutter_stubs/flutter_stubs.dart'; import 'package:test/test.dart'; -import '../theme_gen/complex_theme.dart'; -import '../theme_gen/empty_theme.dart'; -import '../theme_gen/empty_theme_extension.dart'; +import '../fixtures/complex_theme.dart'; +import '../fixtures/empty_theme.dart'; +import '../fixtures/empty_theme_extension.dart'; +import '../fixtures/flutter_stubs.dart'; void main() { group('ComplexTheme', () { diff --git a/packages/theme_extensions_builder/test/runtime/theme_gen_empty_test.dart b/packages/theme_extensions_builder/test/runtime/theme_gen_empty_test.dart index 2aadbeb..1353f48 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_gen_empty_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_gen_empty_test.dart @@ -1,6 +1,6 @@ import 'package:test/test.dart'; -import '../theme_extensions/empty_theme.dart'; +import '../fixtures/empty_theme.dart'; void main() { group('EmptyTheme - with const constructor', () { @@ -121,136 +121,136 @@ void main() { }); }); - group('EmptyThemeWithoutConstConstructor - without const constructor', () { + group('EmptyThemeNonConst - without const constructor', () { test('can be instantiated', () { - final theme = EmptyThemeWithoutConstConstructor(); - expect(theme, isA()); + final theme = EmptyThemeNonConst(); + expect(theme, isA()); }); test('canMerge returns true', () { - final theme = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); expect(theme.canMerge, isTrue); }); group('lerp', () { test('returns equal instance when lerping', () { - final a = EmptyThemeWithoutConstConstructor(); - final b = EmptyThemeWithoutConstConstructor(); - final result = EmptyThemeWithoutConstConstructor.lerp(a, b, 0.5); + final a = EmptyThemeNonConst(); + final b = EmptyThemeNonConst(); + final result = EmptyThemeNonConst.lerp(a, b, 0.5); expect(result, equals(a)); expect(identical(a, b), isFalse); expect(identical(result, a), isFalse); expect(identical(result, b), isFalse); - expect(result, isA()); + expect(result, isA()); }); test('returns null when both are null', () { - final result = EmptyThemeWithoutConstConstructor.lerp(null, null, 0.5); + final result = EmptyThemeNonConst.lerp(null, null, 0.5); expect(result, isNull); }); test('returns null when a is null and t != 1.0', () { - final b = EmptyThemeWithoutConstConstructor(); - final result = EmptyThemeWithoutConstConstructor.lerp(null, b, 0.5); + final b = EmptyThemeNonConst(); + final result = EmptyThemeNonConst.lerp(null, b, 0.5); expect(result, isNull); }); test('returns b when a is null and t == 1.0', () { - final b = EmptyThemeWithoutConstConstructor(); - final result = EmptyThemeWithoutConstConstructor.lerp(null, b, 1); + final b = EmptyThemeNonConst(); + final result = EmptyThemeNonConst.lerp(null, b, 1); expect(result, equals(b)); expect(identical(result, b), isTrue); }); test('returns null when b is null and t != 0.0', () { - final a = EmptyThemeWithoutConstConstructor(); - final result = EmptyThemeWithoutConstConstructor.lerp(a, null, 0.5); + final a = EmptyThemeNonConst(); + final result = EmptyThemeNonConst.lerp(a, null, 0.5); expect(result, isNull); }); test('returns a when b is null and t == 0.0', () { - final a = EmptyThemeWithoutConstConstructor(); - final result = EmptyThemeWithoutConstConstructor.lerp(a, null, 0); + final a = EmptyThemeNonConst(); + final result = EmptyThemeNonConst.lerp(a, null, 0); expect(result, equals(a)); expect(identical(result, a), isTrue); }); test('returns new instance when both are not null', () { - final a = EmptyThemeWithoutConstConstructor(); - final b = EmptyThemeWithoutConstConstructor(); - final result = EmptyThemeWithoutConstConstructor.lerp(a, b, 0.5); + final a = EmptyThemeNonConst(); + final b = EmptyThemeNonConst(); + final result = EmptyThemeNonConst.lerp(a, b, 0.5); expect(result, isNotNull); expect(identical(a, b), isFalse); expect(identical(result, a), isFalse); expect(identical(result, b), isFalse); - expect(result, isA()); + expect(result, isA()); }); test('lerp with various t values', () { - final a = EmptyThemeWithoutConstConstructor(); - final b = EmptyThemeWithoutConstConstructor(); - - expect(EmptyThemeWithoutConstConstructor.lerp(a, b, 0), isNotNull); - expect(EmptyThemeWithoutConstConstructor.lerp(a, b, 0.25), isNotNull); - expect(EmptyThemeWithoutConstConstructor.lerp(a, b, 0.5), isNotNull); - expect(EmptyThemeWithoutConstConstructor.lerp(a, b, 0.75), isNotNull); - expect(EmptyThemeWithoutConstConstructor.lerp(a, b, 1), isNotNull); + final a = EmptyThemeNonConst(); + final b = EmptyThemeNonConst(); + + expect(EmptyThemeNonConst.lerp(a, b, 0), isNotNull); + expect(EmptyThemeNonConst.lerp(a, b, 0.25), isNotNull); + expect(EmptyThemeNonConst.lerp(a, b, 0.5), isNotNull); + expect(EmptyThemeNonConst.lerp(a, b, 0.75), isNotNull); + expect(EmptyThemeNonConst.lerp(a, b, 1), isNotNull); }); }); group('copyWith', () { test('returns new instance', () { - final theme = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); final copied = theme.copyWith(); expect(copied, equals(theme)); expect(identical(copied, theme), isFalse); }); test('creates independent copy', () { - final theme = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); final copied = theme.copyWith(); - expect(copied, isA()); + expect(copied, isA()); expect(copied, isNot(same(theme))); }); }); group('merge', () { test('returns this when other is null', () { - final theme = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); final merged = theme.merge(null); expect(identical(merged, theme), isTrue); }); test('returns this when identical', () { - final theme = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); final merged = theme.merge(theme); expect(identical(merged, theme), isTrue); }); test('returns merged instance when other can merge', () { - final theme = EmptyThemeWithoutConstConstructor(); - final other = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); + final other = EmptyThemeNonConst(); final merged = theme.merge(other); - expect(merged, isA()); + expect(merged, isA()); }); }); group('equality', () { test('two instances are equal', () { - final theme1 = EmptyThemeWithoutConstConstructor(); - final theme2 = EmptyThemeWithoutConstConstructor(); + final theme1 = EmptyThemeNonConst(); + final theme2 = EmptyThemeNonConst(); expect(theme1, equals(theme2)); expect(theme1.hashCode, equals(theme2.hashCode)); expect(identical(theme1, theme2), isFalse); }); test('not equal to different type', () { - final theme = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); expect(theme == Object(), isFalse); }); test('not equal to EmptyTheme', () { - final theme = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); const otherTheme = EmptyTheme(); // == operator between different types // ignore: unrelated_type_equality_checks diff --git a/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart index a2c3ae6..2eb3134 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart @@ -1,6 +1,6 @@ import 'package:test/test.dart'; -import '../theme_gen/lookup_theme.dart'; +import '../fixtures/lookup_theme.dart'; void main() { const a = LookupTheme( diff --git a/packages/theme_extensions_builder/test/theme_extensions/empty_theme.dart b/packages/theme_extensions_builder/test/theme_extensions/empty_theme.dart deleted file mode 100644 index 67c80d3..0000000 --- a/packages/theme_extensions_builder/test/theme_extensions/empty_theme.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; - -part 'empty_theme.g.theme.dart'; - -@themeGen -final class EmptyTheme with _$EmptyTheme { - const EmptyTheme(); - - @override - bool get canMerge => true; - - static EmptyTheme? lerp(EmptyTheme? a, EmptyTheme? b, double t) => - _$EmptyTheme.lerp(a, b, t); -} - -@themeGen -final class EmptyThemeWithoutConstConstructor - with _$EmptyThemeWithoutConstConstructor { - EmptyThemeWithoutConstConstructor(); - - @override - bool get canMerge => true; - - static EmptyThemeWithoutConstConstructor? lerp( - EmptyThemeWithoutConstConstructor? a, - EmptyThemeWithoutConstConstructor? b, - double t, - ) => _$EmptyThemeWithoutConstConstructor.lerp(a, b, t); -} diff --git a/packages/theme_extensions_builder/test/theme_extensions/empty_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_extensions/empty_theme.g.theme.dart deleted file mode 100644 index d0d704a..0000000 --- a/packages/theme_extensions_builder/test/theme_extensions/empty_theme.g.theme.dart +++ /dev/null @@ -1,128 +0,0 @@ -// dart format width=80 -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, unused_element - -part of 'empty_theme.dart'; - -// ************************************************************************** -// ThemeGenGenerator -// ************************************************************************** - -mixin _$EmptyTheme { - bool get canMerge => true; - - static EmptyTheme? lerp(EmptyTheme? a, EmptyTheme? b, double t) { - if (identical(a, b)) { - return a; - } - - if (a == null) { - return t == 1.0 ? b : null; - } - - if (b == null) { - return t == 0.0 ? a : null; - } - - return const EmptyTheme(); - } - - EmptyTheme copyWith() { - return const EmptyTheme(); - } - - EmptyTheme merge(EmptyTheme? other) { - final _this = (this as EmptyTheme); - - if (other == null || identical(_this, other)) { - return _this; - } - - if (!other.canMerge) { - return other; - } - - return copyWith(); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - if (other.runtimeType != runtimeType) { - return false; - } - - return true; - } - - @override - int get hashCode { - return runtimeType.hashCode; - } -} - -mixin _$EmptyThemeWithoutConstConstructor { - bool get canMerge => true; - - static EmptyThemeWithoutConstConstructor? lerp( - EmptyThemeWithoutConstConstructor? a, - EmptyThemeWithoutConstConstructor? b, - double t, - ) { - if (identical(a, b)) { - return a; - } - - if (a == null) { - return t == 1.0 ? b : null; - } - - if (b == null) { - return t == 0.0 ? a : null; - } - - return EmptyThemeWithoutConstConstructor(); - } - - EmptyThemeWithoutConstConstructor copyWith() { - return EmptyThemeWithoutConstConstructor(); - } - - EmptyThemeWithoutConstConstructor merge( - EmptyThemeWithoutConstConstructor? other, - ) { - final _this = (this as EmptyThemeWithoutConstConstructor); - - if (other == null || identical(_this, other)) { - return _this; - } - - if (!other.canMerge) { - return other; - } - - return copyWith(); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - if (other.runtimeType != runtimeType) { - return false; - } - - return true; - } - - @override - int get hashCode { - return runtimeType.hashCode; - } -} diff --git a/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.dart b/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.dart deleted file mode 100644 index 9e53aac..0000000 --- a/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter_stubs/flutter_stubs.dart'; -import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; - -part 'empty_theme_extension.g.theme.dart'; - -@themeExtensions -final class EmptyThemeExtension extends ThemeExtension - with _$EmptyThemeExtension { - const EmptyThemeExtension(); -} diff --git a/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.g.theme.dart deleted file mode 100644 index fb9aca5..0000000 --- a/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.g.theme.dart +++ /dev/null @@ -1,52 +0,0 @@ -// dart format width=80 -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, unused_element - -part of 'empty_theme_extension.dart'; - -// ************************************************************************** -// ThemeExtensionsGenerator -// ************************************************************************** - -mixin _$EmptyThemeExtension on ThemeExtension { - @override - ThemeExtension copyWith() { - return const EmptyThemeExtension(); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, - double t, - ) { - if (other is! EmptyThemeExtension) { - return this; - } - - return const EmptyThemeExtension(); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - if (other.runtimeType != runtimeType) { - return false; - } - - return true; - } - - @override - int get hashCode { - return runtimeType.hashCode; - } -} - -extension EmptyThemeExtensionBuildContext on BuildContext { - EmptyThemeExtension get emptyTheme => - Theme.of(this).extension()!; -} diff --git a/packages/theme_extensions_builder_annotation/example/example.md b/packages/theme_extensions_builder_annotation/example/example.md new file mode 100644 index 0000000..e17a12a --- /dev/null +++ b/packages/theme_extensions_builder_annotation/example/example.md @@ -0,0 +1,64 @@ +# Example + +The annotations do nothing on their own: `theme_extensions_builder` reads them +and writes the `copyWith`, `lerp`, `merge`, `==` and `hashCode` members into a +`.g.theme.dart` part file. + +## A theme extension + +```dart +import 'package:flutter/material.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'app_theme.g.theme.dart'; + +@themeExtensions +class AppTheme extends ThemeExtension with _$AppTheme { + const AppTheme({ + required this.primaryColor, + required this.spacing, + this.borderRadius, + }); + + final Color primaryColor; + final double spacing; + final BorderRadius? borderRadius; +} + +// Generated alongside the mixin: +// final theme = context.appTheme; +``` + +## A plain theme data class + +```dart +import 'package:flutter/material.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'button_theme_data.g.theme.dart'; + +@themeGen +class ButtonThemeData with _$ButtonThemeData { + const ButtonThemeData({ + required this.backgroundColor, + this.elevation = 2.0, + this.debugLabel = '', + }); + + final Color backgroundColor; + final double elevation; + + /// Left out of every generated member. + @ignore + final String debugLabel; + + static ButtonThemeData? lerp( + ButtonThemeData? a, + ButtonThemeData? b, + double t, + ) => _$ButtonThemeData.lerp(a, b, t); +} +``` + +See the [package README](https://github.com/pro100andrey/theme_extensions_builder/blob/main/packages/theme_extensions_builder/README.md) +for the generator setup and the full list of options. diff --git a/pubspec.yaml b/pubspec.yaml index b79cae8..336375f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,4 +11,3 @@ workspace: - packages/theme_extensions_builder - packages/theme_extensions_builder_annotation - packages/theme_extensions_builder/example - - packages/flutter_stubs diff --git a/scripts/prepare_push.sh b/scripts/prepare_push.sh index 1ec7607..b59bd05 100755 --- a/scripts/prepare_push.sh +++ b/scripts/prepare_push.sh @@ -169,7 +169,6 @@ function main() { local dirs=( "packages/theme_extensions_builder_annotation" - "packages/flutter_stubs" "packages/theme_extensions_builder" "packages/theme_extensions_builder/example" ) From bbf18b28346850ba28d09ff2c64c6377c3c1ccb4 Mon Sep 17 00:00:00 2001 From: Andrii Ivanov Date: Sun, 6 Sep 2026 15:34:17 +0300 Subject: [PATCH 10/10] Fix the shapes the review found, verify the class before generating, make CI exercise what it claims Generator: - null check an instance lerp with an optional result on a non-nullable field - substitute the type arguments into fields inherited from a generic superclass - resolve merge for @ThemeGen fields by lookup first, so a subclass of an annotated class merges the same way on clean and incremental builds and a hand-written merge is used as declared - fall back instead of throwing for a nested WidgetStateProperty, and name the missing static lerp in the warning instead of an "unsupported signature" - match the annotations by package (inPackage) as the changelog already said Validation: required parameters the generated code cannot pass, generic classes, a missing `with _$X` (read from the parsed declaration, as the mixin may not exist yet), fields named after mixin members, and reserved words in contextAccessorName. CI and packaging: the lowest leg pins analyzer 13.1.0 and source_gen 4.2.3 through dependency_overrides (targeted `pub downgrade` is a no-op at the workspace root); the generated-files gate sees untracked files; the example is published without its workspace-only files. Analyzer floor raised to 13.1.0, the lowest the test tooling compiles against. --- .github/workflows/dart.yml | 26 +- docs/troubleshooting.md | 8 +- packages/theme_extensions_builder/.pubignore | 7 + .../theme_extensions_builder/CHANGELOG.md | 28 +-- packages/theme_extensions_builder/README.md | 5 +- .../lib/src/common/field_symbol.dart | 33 +-- .../lib/src/common/fields_visitor.dart | 100 ++++---- .../lib/src/common/lookup/lerp_lookup.dart | 94 +++++-- .../lib/src/common/lookup/merge_lookup.dart | 37 ++- .../lib/src/common/symbols/lerp_info.dart | 28 ++- .../lib/src/common/validation.dart | 231 ++++++++++++++++-- .../lib/src/generator/common.dart | 10 +- .../theme_extensions/code_builder.dart | 4 + .../generator/theme_extensions/generator.dart | 16 +- .../src/generator/theme_gen/code_builder.dart | 13 +- .../src/generator/theme_gen/generator.dart | 10 +- .../theme_extensions_builder/pubspec.yaml | 2 +- .../generic_inherited_theme.g.theme.dart | 68 ++++++ .../goldens/lookup_theme.g.theme.dart | 6 + .../widget_state_property_theme.g.theme.dart | 14 +- .../test/fixtures/inherited_theme.dart | 29 +++ .../fixtures/inherited_theme.g.theme.dart | 67 +++++ .../test/fixtures/invalid_theme.dart | 91 ++++++- .../fixtures/invalid_theme_extension.dart | 67 ++++- .../test/fixtures/lookup_theme.dart | 12 + .../test/fixtures/lookup_theme.g.theme.dart | 6 + .../fixtures/widget_state_property_theme.dart | 35 ++- .../widget_state_property_theme.g.theme.dart | 14 +- .../test/generator/code_builder_test.dart | 12 + .../test/runtime/symbols_test.dart | 16 +- .../test/runtime/theme_gen_lookup_test.dart | 7 + 31 files changed, 943 insertions(+), 153 deletions(-) create mode 100644 packages/theme_extensions_builder/.pubignore create mode 100644 packages/theme_extensions_builder/test/fixtures/goldens/generic_inherited_theme.g.theme.dart diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index d9f674c..3a860e0 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: # The builder advertises a range of analyzer versions; the lower - # bound is exercised by downgrading the analyzer stack. + # bound is exercised by pinning the analyzer stack to it. deps: [latest, lowest] steps: @@ -35,9 +35,23 @@ jobs: - name: Resolve the workspace run: flutter pub get - - name: Downgrade the analyzer stack to the lower bounds + # `dart pub downgrade analyzer ...` changes nothing at the workspace + # root, and a plain `dart pub downgrade` also takes the test runner to + # a version that no longer runs on the current SDK. The lower bounds + # are pinned outright instead; everything else resolves around them. + # + # The analyzer floor is the lowest version the test tooling compiles + # against: every `dart_style` the goldens are formatted with needs + # 13.1.0. `build` floats, as the tooling needs 4.x. + - name: Pin the lower bounds if: matrix.deps == 'lowest' - run: dart pub downgrade analyzer source_gen build + run: | + cat >> pubspec.yaml <<'EOF' + dependency_overrides: + analyzer: 13.1.0 + source_gen: 4.2.3 + EOF + flutter pub get - name: Check formatting if: matrix.deps == 'latest' @@ -85,5 +99,9 @@ jobs: working-directory: ${{ env.EXAMPLE }} run: flutter analyze --fatal-infos + # A generated file that is new is untracked, which `git diff` cannot + # see; adding it to the index without its content makes it show up. - name: Fail on stale generated files - run: git diff --exit-code -- '*.g.theme.dart' + run: | + git add --intent-to-add -- '*.g.theme.dart' + git diff --exit-code -- '*.g.theme.dart' diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 83deea1..1d4fdd2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -58,12 +58,18 @@ The generator checks the annotated class before writing anything. Each message n - **`` `X` has no constructor named `_internal`. ``** The `constructor:` option names a constructor that does not exist. Declare it, or point the option at an existing one. - **`` `X` has no unnamed constructor, which the generated code calls. ``** The class only has named constructors. Add `constructor: 'name'` to the annotation. - **`` The constructor `X` has no named parameter for the field `y` ``** Every field is passed to the constructor by name. Add `this.y` to the constructor, or mark the field with `@ignore`. +- **`` The constructor `X` requires `y`, which is not among the fields the generated code passes to it ``** The generated code only passes fields, so a required parameter has to be one: a positional parameter, a parameter that is not a field, or a field marked `@ignore` leaves the generated call short of an argument. Make the parameter optional, or declare it as `this.y` without `@ignore`. +- **`` `X` is generic, and the generated mixin cannot be ``** The mixin names the class without type arguments, so a type parameter would be undefined inside it. Remove the type parameters, or write the theme methods by hand. +- **`` The generated mixin `_$X` declares `merge`, so `X` cannot have a field of that name ``** A field is a getter, which cannot override the mixin's method. `copyWith`, `merge` and `lerp` are taken for `@ThemeGen`, `copyWith` and `lerp` for `@ThemeExtensions`. Rename the field. +- **`` `X` does not apply the generated mixin `_$X` ``** The generated methods live in the mixin, so a class without `with _$X` has none of them. Add the clause. - **`` `X` must extend `ThemeExtension` ``** `@ThemeExtensions` needs a class that extends `ThemeExtension` of itself. Extend it, or use `@ThemeGen` for a plain class. -- **`` `...` is not a valid Dart identifier ``** `contextAccessorName` is written into the generated code as a getter name. Use an identifier. +- **`` `...` is not a valid Dart identifier ``** / **`` `...` is a reserved word ``** `contextAccessorName` is written into the generated code as a getter name. Use an identifier that is not a keyword. - **`` WidgetStateProperty must have a nullable generic type ``** `WidgetStateProperty.lerp` takes a lerp function with nullable parameters, so the generic has to be nullable: `WidgetStateProperty`. A warning such as `` The `lerp` method of X has an unsupported signature `` does not stop the build. The field type declares a `lerp` or `merge` the generator cannot call, so the field switches over at `t = 0.5` or is overwritten instead. Rename the method or give it a supported signature if it was meant to be used. +A warning such as `` WidgetStateProperty cannot be interpolated: `X` has no static `X? lerp(X?, X?, double)` `` means the generic of a `WidgetStateProperty` field offers no lerp function for `WidgetStateProperty.lerp` to call, whether it declares no `lerp` at all or one of another shape. The field switches over at `t = 0.5`. Give `X` a static `lerp` that accepts and returns nulls if it was meant to be interpolated. + ## Analyzer Errors In Generated Files Checklist: diff --git a/packages/theme_extensions_builder/.pubignore b/packages/theme_extensions_builder/.pubignore new file mode 100644 index 0000000..8c550ca --- /dev/null +++ b/packages/theme_extensions_builder/.pubignore @@ -0,0 +1,7 @@ +# The example app is a member of the development workspace: its pubspec.yaml +# says `resolution: workspace`, which does not resolve outside the repository, +# and its platform folders are noise on pub.dev. The sources and the README +# stay, so the Example tab still has something to show. +example/* +!example/lib +!example/README.md diff --git a/packages/theme_extensions_builder/CHANGELOG.md b/packages/theme_extensions_builder/CHANGELOG.md index edf853b..8fcaf30 100644 --- a/packages/theme_extensions_builder/CHANGELOG.md +++ b/packages/theme_extensions_builder/CHANGELOG.md @@ -1,21 +1,17 @@ ## 7.5.0 -- **Fixed**: `@ThemeGen` now interpolates a nullable field whose type has an instance `lerp` method, instead of falling back to `t < 0.5 ? a : b`. -- **Fixed**: An interpolation that cannot run on a null now keeps the endpoints: `lerp(a, b, 0)` returns `a`'s value and `lerp(a, b, 1)` returns `b`'s value even when the other side is null. Previously a null on the `a` side either discarded `b` entirely or was taken at any `t`. -- **Fixed**: `merge` now keeps the current value when the incoming one is null. The static and instance merge branches disagreed on this; the instance behaviour was the correct one. -- **Fixed**: Several shapes that used to generate code that does not compile: a nullable field whose `lerp` or `merge` takes a non-nullable parameter is now guarded; a static `lerp` or `merge` is called on the class rather than on an instantiation of it, so a generic field type no longer generates `Box.lerp(...)`; a `lerp` or `merge` result is cast back to the field type when the method is declared on a supertype and returns it, so an inherited `merge` is called instead of being reported unusable; and a generic field type resolves its methods through the instantiated type, so `Box` matches a `lerp` declared as `Box lerp(Box other, double t)`. -- **Fixed**: A field type declaring an unrelated `lerp` or `merge` method no longer fails the build, and is no longer mistaken for a supported one: parameter and return types are checked against the field type, a required named parameter disqualifies the method, and a non-generic method is matched with its type arguments. An unusable method is reported as a build warning naming the field and what happens to it instead. -- **Fixed**: `WidgetStateProperty` fields are validated further: the generic has to offer a static `lerp` that accepts nulls, the inner call drops the generic's own type arguments, `double` and `Duration` generics are recognised by element rather than by name, an unrelated generic type with a four parameter `lerp` is no longer treated as a `WidgetStateProperty`, and a non-nullable generic reports an `InvalidGenerationSourceError` pointing at the field instead of a bare `StateError`. -- **Fixed**: Field collection follows Dart's own resolution: a field narrowed by a subclass keeps the narrowed type, a mixin wins over the superclass chain, an interface reached through `implements` contributes nothing, private and static fields are left out, and `@ignore` on a redeclaration also suppresses the inherited one. Inherited fields now come after the class' own, which changes `hashCode` values for classes that inherit fields. -- **Fixed**: `Duration` is detected by element rather than by name, so a user type of the same name is no longer interpolated as a duration. -- **Updated**: Analyzer dependency to ">=13.0.0 <15.0.0" and Dart SDK constraint to ">=3.13.0 <4.0.0". -- **Code Quality**: Exhaustive switches over `LerpInfo`/`MergeInfo`, `==`/`hashCode` contract fixed for the `LerpInfo` classes, dead code removed (`getMixinsNames`, `BaseClassVisitor`, unused builder options and lerp-lookup flag). -- **New**: The annotated class is checked before anything is generated. A missing constructor, a field without a named constructor parameter, a `@ThemeExtensions` class that does not extend `ThemeExtension`, and a `contextAccessorName` that is not an identifier now stop the build with an `InvalidGenerationSourceError` naming the class and the fix, instead of a compile error in the generated file. -- **Fixed**: `const` is decided by the constructor named in `constructor:` rather than by any constructor of the class, so an empty class with a const default constructor and a non-const named one no longer generates `const X._internal()`. -- **Fixed**: `constructor: ''` selects the unnamed constructor instead of emitting `X.()`. -- **Fixed**: The `@ThemeGen` and `@ignore` annotations are matched by package as well as by name, so a user class called `ThemeGen` is no longer taken for the annotation. -- **Code Quality**: The `lerp` and `copyWith` generation is shared between the two generators instead of being maintained twice. The symbol model keeps only what the code builders read, the method lookups live in one file each, and the `collection` and `meta` dependencies are gone. -- **Changed**: The `platforms` key is gone from the pubspec. A `build_runner` generator runs in the build process rather than on a platform, and declaring web support only cost the package its WASM score; pub.dev now lists the platforms it detects, as it does for `json_serializable` and `freezed`. +- **Breaking**: `hashCode` changes for classes that inherit fields, which now come after the class' own. +- **Changed**: `lerp` keeps the endpoints when one side is null (`t = 0` gives `a`, `t = 1` gives `b`), and `merge` keeps the current value when the incoming one is null. +- **New**: The annotated class is validated before generation. A missing constructor, a field without a named parameter, a required parameter the generated code cannot pass, a generic class, a missing `with _$X`, a field named `copyWith`, `merge` or `lerp`, a `@ThemeExtensions` class not extending `ThemeExtension`, or an invalid `contextAccessorName` now stop the build with a message naming the class and the fix. +- **Fixed**: Generated code that did not compile for an instance `lerp` on a nullable field or with a nullable result, a non-nullable `lerp`/`merge` parameter on a nullable field, a static call on `Box` instead of `Box`, a method declared on a supertype (the result is cast back), a generic field type (methods resolve through the instantiated type), and a field inherited from a generic superclass (`T` is substituted). +- **Fixed**: An unrelated `lerp` or `merge` no longer fails the build or is mistaken for a supported one; an unusable signature is a build warning naming the field and its fallback. +- **Fixed**: A `WidgetStateProperty` generic has to offer a static `lerp` that accepts nulls, and is reported once when it does not; a non-nullable generic is an error at the field, a nested one falls back; `double` and `Duration` generics are detected by element. +- **Fixed**: Field collection follows Dart's resolution: a narrowed field keeps the narrowed type, mixins win over the superclass chain, `implements` contributes nothing, private and static fields are skipped, and `@ignore` on a redeclaration drops the inherited one. `Duration` is detected by element. +- **Fixed**: A field typed as a `@ThemeGen` class, or a subclass of one, merges the same way on clean and incremental builds; a hand-written `merge` is used as declared. +- **Fixed**: `const` follows the constructor named in `constructor:`; `constructor: ''` selects the unnamed one. +- **Fixed**: Annotations are matched by package as well as by name, so a user class called `ThemeGen` is not taken for one. +- **Fixed**: The example is published without its workspace-only files, so it resolves when copied from pub.dev. +- **Updated**: Analyzer `>=13.1.0 <15.0.0` (the lower bound CI tests against) and Dart SDK `>=3.13.0 <4.0.0`; `collection` and `meta` are no longer dependencies; the `platforms` key is gone from the pubspec. ## 7.4.0 diff --git a/packages/theme_extensions_builder/README.md b/packages/theme_extensions_builder/README.md index 2390a16..b90252b 100644 --- a/packages/theme_extensions_builder/README.md +++ b/packages/theme_extensions_builder/README.md @@ -495,8 +495,11 @@ The generator checks the annotated class before it writes anything, and stops th - the constructor named in `constructor:` does not exist, or there is no unnamed constructor to fall back to; - a field has no named parameter of the same name in that constructor (mark it `@ignore` if it is not part of the theme); +- that constructor requires a parameter the generated code does not pass: a positional one, one that is not a field, or the parameter of an `@ignore`d field; +- the class is generic, or does not apply the generated `_$ClassName` mixin; +- a field is named after a member the mixin declares (`copyWith`, `merge`, `lerp`); - a `@ThemeExtensions` class does not extend `ThemeExtension`; -- `contextAccessorName` is not a valid identifier; +- `contextAccessorName` is not a valid identifier, or is a reserved word; - a `WidgetStateProperty` field has a non-nullable generic. A field type whose `lerp` or `merge` has a signature the generator cannot call is not an error. The build logs a warning naming the field, which then switches over at `t = 0.5` instead of being interpolated, or is overwritten instead of being merged. diff --git a/packages/theme_extensions_builder/lib/src/common/field_symbol.dart b/packages/theme_extensions_builder/lib/src/common/field_symbol.dart index 5e57fd6..9fed582 100644 --- a/packages/theme_extensions_builder/lib/src/common/field_symbol.dart +++ b/packages/theme_extensions_builder/lib/src/common/field_symbol.dart @@ -1,4 +1,5 @@ import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; import 'dart_type_extension.dart'; import 'lookup/lerp_lookup.dart'; @@ -6,21 +7,25 @@ import 'lookup/merge_lookup.dart'; import 'symbols/field_info.dart'; import 'symbols/merge_info.dart'; -/// Creates a [FieldInfo] from the given [element]. +/// Creates a [FieldInfo] for [element], a field of [type]. +/// +/// [type] is passed separately because it is not always `element.type`: a +/// field inherited from a generic superclass has the type arguments of the +/// inheriting class substituted in. /// /// When [includeMergeLookup] is `false`, the merge method lookup is skipped /// and the field is reported as [NoMerge]. Use it for generators that don't /// emit a `merge` method. -FieldInfo fieldSymbol(FieldElement element, {bool includeMergeLookup = true}) { - final type = element.type; - - return FieldInfo( - name: element.displayName, - typeName: type.baseType, - isNullable: type.hasNullableSuffix, - isDouble: type.isDartCoreDouble, - isDuration: type.isDuration, - merge: includeMergeLookup ? mergeInfo(type, element) : const NoMerge(), - lerp: lerpInfo(type, element), - ); -} +FieldInfo fieldSymbol( + FieldElement element, + DartType type, { + bool includeMergeLookup = true, +}) => FieldInfo( + name: element.displayName, + typeName: type.baseType, + isNullable: type.hasNullableSuffix, + isDouble: type.isDartCoreDouble, + isDuration: type.isDuration, + merge: includeMergeLookup ? mergeInfo(type, element) : const NoMerge(), + lerp: lerpInfo(type, element), +); diff --git a/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart b/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart index 1d8adcd..eed5c0a 100644 --- a/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart +++ b/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart @@ -1,6 +1,5 @@ import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/type.dart'; -import 'package:analyzer/dart/element/visitor2.dart'; import 'field_symbol.dart'; import 'symbols/field_info.dart'; @@ -23,15 +22,14 @@ List collectFields( ClassElement element, { bool includeMergeLookup = true, }) { - final visitor = FieldsVisitor(includeMergeLookup: includeMergeLookup); + final collector = _FieldCollector(includeMergeLookup: includeMergeLookup); + final thisType = element.thisType; - element.visitChildren(visitor); - - for (final inherited in _inheritedTypes(element.thisType)) { - inherited.element.visitChildren(visitor); + for (final type in [thisType, ..._inheritedTypes(thisType)]) { + collector.addDeclaredOn(type); } - return visitor.fields; + return collector.fields; } /// Yields the types [type] inherits members from, nearest first. @@ -49,16 +47,13 @@ Iterable _inheritedTypes(InterfaceType type) sync* { } } -/// A visitor that collects field information from a class element. +/// Collects field information from the types of a class hierarchy. /// /// Only fields the generated code can pass to a constructor are collected: /// explicitly declared instance fields that are public and not annotated with /// `@ignore`. -class FieldsVisitor extends SimpleElementVisitor2 { - /// Creates a [FieldsVisitor]. - /// - /// When [includeMergeLookup] is `false`, no merge method is looked up. - FieldsVisitor({this.includeMergeLookup = true}); +class _FieldCollector { + _FieldCollector({required this.includeMergeLookup}); /// Whether to look up merge methods on field types. final bool includeMergeLookup; @@ -79,40 +74,51 @@ class FieldsVisitor extends SimpleElementVisitor2 { /// The collected fields, in the order they were visited. List get fields => _fields.values.toList(growable: false); - @override - void visitFieldElement(FieldElement element) { - // Only explicitly declared fields: a synthetic field backs a getter or - // setter, and cannot be passed to a constructor. - if (!element.isOriginDeclaration) { - return; - } - - // A static field is not part of an instance. Dart forbids a static and an - // instance member of the same name in one hierarchy, so it cannot shadow - // an inherited field either. - if (element.isStatic) { - return; - } - - // A private field cannot be passed to a generated constructor call, and a - // private name is not a valid named parameter either. - if (element.isPrivate) { - return; - } - - final name = element.displayName; - - if (!_claimed.add(name)) { - return; - } - - if (ignoreChecker.hasAnnotationOf(element)) { - return; + /// Adds the fields declared on the class of [type]. + /// + /// The declarations are read off the class, but their types through + /// [type]: a field declared as `T value` on `Base` is a `num value` on + /// `Base`, and `num` is the type the generated code has to write. + void addDeclaredOn(InterfaceType type) { + for (final element in type.element.fields) { + // Only explicitly declared fields: a synthetic field backs a getter or + // setter, and cannot be passed to a constructor. + if (!element.isOriginDeclaration) { + continue; + } + + // A static field is not part of an instance. Dart forbids a static and + // an instance member of the same name in one hierarchy, so it cannot + // shadow an inherited field either. + if (element.isStatic) { + continue; + } + + // A private field cannot be passed to a generated constructor call, and + // a private name is not a valid named parameter either. + if (element.isPrivate) { + continue; + } + + final name = element.displayName; + + if (!_claimed.add(name)) { + continue; + } + + if (ignoreChecker.hasAnnotationOf(element)) { + continue; + } + + // The getter looked up on the instantiated type carries the substituted + // field type. + final fieldType = type.getGetter(name)?.returnType ?? element.type; + + _fields[name] = fieldSymbol( + element, + fieldType, + includeMergeLookup: includeMergeLookup, + ); } - - _fields[name] = fieldSymbol( - element, - includeMergeLookup: includeMergeLookup, - ); } } diff --git a/packages/theme_extensions_builder/lib/src/common/lookup/lerp_lookup.dart b/packages/theme_extensions_builder/lib/src/common/lookup/lerp_lookup.dart index ac8ba00..060926e 100644 --- a/packages/theme_extensions_builder/lib/src/common/lookup/lerp_lookup.dart +++ b/packages/theme_extensions_builder/lib/src/common/lookup/lerp_lookup.dart @@ -1,5 +1,6 @@ import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/type.dart'; +import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart'; import '../dart_type_extension.dart'; @@ -16,7 +17,21 @@ import 'method_lookup.dart'; /// /// Throws [InvalidGenerationSourceError] for a `WidgetStateProperty` field /// with a non-nullable generic, which is a mistake we can point at. -LerpInfo lerpInfo(DartType type, FieldElement fieldElement) { +LerpInfo lerpInfo(DartType type, FieldElement fieldElement) => + _lerpInfo(type, fieldElement, nested: false); + +/// [lerpInfo], for the field type itself or, when [nested], for the generic +/// of a `WidgetStateProperty` field. +/// +/// A nested type is reported by the caller as part of the outer field, so +/// nothing is warned about here, and a mistake in it is a fallback rather +/// than an error: an error would name the outer field while pointing at a +/// type that is not its own. +LerpInfo _lerpInfo( + DartType type, + FieldElement fieldElement, { + required bool nested, +}) { if (type is! InterfaceType) { return const NoLerp(); } @@ -27,10 +42,16 @@ LerpInfo lerpInfo(DartType type, FieldElement fieldElement) { return const NoLerp(); } + void warn() { + if (!nested) { + warnUnsupported('lerp', type, fieldElement); + } + } + final params = callableParameters(method); if (params == null) { - warnUnsupported('lerp', type, fieldElement); + warn(); return const NoLerp(); } @@ -49,7 +70,7 @@ LerpInfo lerpInfo(DartType type, FieldElement fieldElement) { p3.type.isDartCoreDouble && checkSubtype(p1, type, strict: strict) && checkSubtype(p2, type, strict: strict)) { - return _widgetStatePropertyLerp(type, p1, p4, fieldElement); + return _widgetStatePropertyLerp(type, p1, p4, fieldElement, nested: nested); } if (params case [final p1, final p2, final p3] @@ -62,7 +83,7 @@ LerpInfo lerpInfo(DartType type, FieldElement fieldElement) { checkSubtype(p1, type, strict: strict) && checkSubtype(p2, type, strict: strict)) { if (!isUsableAs(method.returnType, type, type)) { - warnUnsupported('lerp', type, fieldElement); + warn(); return const NoLerp(); } @@ -88,7 +109,7 @@ LerpInfo lerpInfo(DartType type, FieldElement fieldElement) { final needsCast = !isUsableAs(method.returnType, type, type); if (needsCast && !isUsableAs(type, method.returnType, type)) { - warnUnsupported('lerp', type, fieldElement); + warn(); return const NoLerp(); } @@ -100,7 +121,7 @@ LerpInfo lerpInfo(DartType type, FieldElement fieldElement) { } // The type declares a `lerp` we don't know how to call. - warnUnsupported('lerp', type, fieldElement); + warn(); return const NoLerp(); } @@ -108,13 +129,21 @@ LerpInfo lerpInfo(DartType type, FieldElement fieldElement) { /// Decides how a `WidgetStateProperty` shaped [type] is interpolated. /// /// [p1] is the first parameter of the four parameter `lerp`, which names the -/// declaring type, and [lerpFunction] is its last parameter. +/// declaring type, and [lerpFunction] is its last parameter. [nested] is +/// passed through from [_lerpInfo]. LerpInfo _widgetStatePropertyLerp( InterfaceType type, FormalParameterElement p1, FormalParameterElement lerpFunction, - FieldElement fieldElement, -) { + FieldElement fieldElement, { + required bool nested, +}) { + void warn() { + if (!nested) { + warnUnsupported('lerp', type, fieldElement); + } + } + // The fourth parameter has to be a lerp function itself, with the // signature `R Function(T? a, T? b, double t)`. // @@ -135,7 +164,7 @@ LerpInfo _widgetStatePropertyLerp( : null; if (declaringType == null || declaringType.typeArguments.length != 1) { - warnUnsupported('lerp', type, fieldElement); + warn(); return const NoLerp(); } @@ -143,8 +172,14 @@ LerpInfo _widgetStatePropertyLerp( final baseTypeName = declaringType.element.displayName; final innerType = declaringType.typeArguments.single; - // Check that the generic type is nullable + // Check that the generic type is nullable. Inside another generic this is + // one more shape the outer lerp function cannot take, which the caller + // reports; on the field itself it is a mistake worth stopping for. if (!innerType.hasNullableSuffix) { + if (nested) { + return const NoLerp(); + } + final typeName = type.getDisplayString(); final innerTypeName = innerType.getDisplayString(); @@ -165,12 +200,19 @@ LerpInfo _widgetStatePropertyLerp( // Anything else is interpolated by a static lerp on the generic itself, // which `WidgetStateProperty.lerp` calls with nullable arguments. if (!genericIsDouble && !genericIsDuration) { - final innerLerp = lerpInfo(innerType, fieldElement); + final innerLerp = _lerpInfo(innerType, fieldElement, nested: true); if (innerLerp is! StaticLerp || !innerLerp.optionalResult || !innerLerp.isNullableParameter) { - warnUnsupported('lerp', innerType, fieldElement); + if (!nested) { + _warnUninterpolatedGeneric( + type, + baseTypeName, + innerType, + fieldElement, + ); + } return const NoLerp(); } @@ -179,7 +221,6 @@ LerpInfo _widgetStatePropertyLerp( return WidgetStatePropertyLerp( baseTypeName: baseTypeName, genericType: innerType.baseType, - isNullableGeneric: innerType.hasNullableSuffix, genericIsDouble: genericIsDouble, genericIsDuration: genericIsDuration, ); @@ -187,7 +228,30 @@ LerpInfo _widgetStatePropertyLerp( // A four parameter lerp whose last parameter isn't a lerp function is not // something we know how to call. - warnUnsupported('lerp', type, fieldElement); + warn(); return const NoLerp(); } + +/// Reports a `WidgetStateProperty` shaped [type] whose generic [innerType] +/// cannot be interpolated. +/// +/// The lerp function `WidgetStateProperty.lerp` takes is a static `lerp` on +/// the generic that accepts and returns a null. Whether the generic has no +/// `lerp` at all or one of another shape, the outcome is the same, so the +/// message names the signature that is missing rather than the one found. +void _warnUninterpolatedGeneric( + InterfaceType type, + String baseTypeName, + DartType innerType, + FieldElement fieldElement, +) { + final generic = innerType.baseType; + + log.warning( + '${type.baseType} cannot be interpolated: `$generic` has no static ' + '`$generic? lerp($generic?, $generic?, double)` for `$baseTypeName.lerp` ' + 'to call, so the field `${fieldElement.displayName}` switches over at ' + 't = 0.5 instead of being interpolated.', + ); +} diff --git a/packages/theme_extensions_builder/lib/src/common/lookup/merge_lookup.dart b/packages/theme_extensions_builder/lib/src/common/lookup/merge_lookup.dart index 3e37f45..cae0c9a 100644 --- a/packages/theme_extensions_builder/lib/src/common/lookup/merge_lookup.dart +++ b/packages/theme_extensions_builder/lib/src/common/lookup/merge_lookup.dart @@ -15,16 +15,10 @@ MergeInfo mergeInfo(DartType type, FieldElement fieldElement) { return const NoMerge(); } - // A `@ThemeGen` class gets its `merge` from the generated mixin, which may - // not exist yet when this runs. The annotation is taken as the promise that - // it will: `T merge(T? other)`. - if (themeGenChecker.hasAnnotationOfExact(type.element)) { - return const InstanceMerge(); - } - final method = lookupMethod(type, 'merge'); + if (method == null) { - return const NoMerge(); + return _promisedMerge(type); } final params = callableParameters(method); @@ -75,3 +69,30 @@ MergeInfo mergeInfo(DartType type, FieldElement fieldElement) { return const NoMerge(); } + +/// The `merge` a type without one is going to have once its part file is +/// generated. +/// +/// A `@ThemeGen` class gets `T merge(T? other)` from its generated mixin, +/// which may not exist yet when this runs. The annotation is taken as the +/// promise that it will. A subclass of the annotated class inherits that +/// method, whose result is the base type and has to be cast back — the same +/// shape the lookup resolves once the mixin exists, so the generated code +/// does not depend on whether the build is clean or incremental. +/// +/// A `merge` the class writes itself is found by the lookup before this is +/// reached, so a hand-written signature is never mistaken for the generated +/// one. +MergeInfo _promisedMerge(InterfaceType type) { + if (themeGenChecker.hasAnnotationOfExact(type.element)) { + return const InstanceMerge(); + } + + final inheritsThemeGen = type.allSupertypes.any( + (supertype) => themeGenChecker.hasAnnotationOfExact(supertype.element), + ); + + return inheritsThemeGen + ? const InstanceMerge(needsCast: true) + : const NoMerge(); +} diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart index 50b7265..e358d2f 100644 --- a/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart +++ b/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart @@ -83,7 +83,6 @@ final class WidgetStatePropertyLerp extends LerpInfo { const WidgetStatePropertyLerp({ required this.baseTypeName, required this.genericType, - required this.isNullableGeneric, required this.genericIsDouble, required this.genericIsDuration, }); @@ -94,11 +93,12 @@ final class WidgetStatePropertyLerp extends LerpInfo { /// The generic type without its nullability suffix. /// For `WidgetStateProperty` this is 'Color'. + /// + /// The generic is always nullable: a non-nullable one is refused before a + /// [WidgetStatePropertyLerp] is made, because the lerp function + /// `WidgetStateProperty.lerp` takes has to accept a null. final String genericType; - /// Whether the generic type is nullable. - final bool isNullableGeneric; - /// Whether the generic is `double` from `dart:core`. final bool genericIsDouble; @@ -116,8 +116,9 @@ final class WidgetStatePropertyLerp extends LerpInfo { return index == -1 ? genericType : genericType.substring(0, index); } - // genericIsDouble and genericIsDuration follow from genericType, so they - // take no part in equality. + // genericIsDouble and genericIsDuration are decided by element, not by + // name, so a user type called `Duration` shares genericType with the real + // one while being interpolated differently. @override bool operator ==(Object other) => identical(this, other) || @@ -125,18 +126,25 @@ final class WidgetStatePropertyLerp extends LerpInfo { runtimeType == other.runtimeType && baseTypeName == other.baseTypeName && genericType == other.genericType && - isNullableGeneric == other.isNullableGeneric; + genericIsDouble == other.genericIsDouble && + genericIsDuration == other.genericIsDuration; @override - int get hashCode => - Object.hash(runtimeType, baseTypeName, genericType, isNullableGeneric); + int get hashCode => Object.hash( + runtimeType, + baseTypeName, + genericType, + genericIsDouble, + genericIsDuration, + ); @override String toString() => 'WidgetStatePropertyLerp(' 'baseTypeName: $baseTypeName, ' 'genericType: $genericType, ' - 'isNullableGeneric: $isNullableGeneric)'; + 'genericIsDouble: $genericIsDouble, ' + 'genericIsDuration: $genericIsDuration)'; } /// No usable lerp method on the field type. diff --git a/packages/theme_extensions_builder/lib/src/common/validation.dart b/packages/theme_extensions_builder/lib/src/common/validation.dart index cfd794a..1501f5d 100644 --- a/packages/theme_extensions_builder/lib/src/common/validation.dart +++ b/packages/theme_extensions_builder/lib/src/common/validation.dart @@ -2,11 +2,19 @@ /// file into an [InvalidGenerationSourceError] pointing at the cause. library; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:source_gen/source_gen.dart'; import 'symbols/field_info.dart'; +/// The name of the mixin generated for [element]: `_$ClassName`. +/// +/// The mixin is applied by name, so the name is a convention shared with the +/// annotated class. +String generatedMixinName(ClassElement element) => '_\$${element.displayName}'; + /// Resolves the constructor the generated code instantiates [element] with. /// /// [name] is the `constructor` option of the annotation; `null` selects the @@ -41,16 +49,50 @@ ConstructorElement resolveConstructor(ClassElement element, String? name) { ); } -/// Checks that every one of [fields] can be passed to [constructor] by name. +/// Checks that [element] declares no type parameters. +/// +/// The generated mixin has none: it names the class without type arguments +/// in its `on` clause, its constructor calls and its `lerp` signature, so a +/// type parameter of the class would be undefined inside it. +void checkNotGeneric(ClassElement element) { + final typeParameters = element.typeParameters; + + if (typeParameters.isEmpty) { + return; + } + + final className = element.displayName; + final parameters = typeParameters + .map((parameter) => parameter.displayName) + .join(', '); + + throw InvalidGenerationSourceError( + '`$className<$parameters>` is generic, and the generated mixin cannot ' + 'be: it instantiates `$className` without type arguments.', + element: element, + todo: + 'Remove the type parameters from `$className`, or write its theme ' + 'methods by hand.', + ); +} + +/// Checks that [fields] and [constructor] agree on what the generated code +/// passes. /// /// The generated `copyWith`, `lerp` and `merge` build a new instance with one /// named argument per field, so each field needs a named parameter of the -/// same name. +/// same name, and every required parameter has to be one of those: a +/// parameter the generated code cannot fill in is a missing argument in the +/// generated file. void checkConstructorParameters( ClassElement element, ConstructorElement constructor, List fields, ) { + final constructorName = constructor.name == 'new' + ? element.displayName + : '${element.displayName}.${constructor.name}'; + final named = { for (final parameter in constructor.formalParameters) if (parameter.isNamed) parameter.displayName, @@ -61,29 +103,142 @@ void checkConstructorParameters( if (!named.contains(field.name)) field.name, ]; - if (missing.isEmpty) { + if (missing.isNotEmpty) { + final fieldList = missing.map((name) => '`$name`').join(', '); + final plural = missing.length > 1; + + throw InvalidGenerationSourceError( + 'The constructor `$constructorName` has no named ' + '${plural ? 'parameters' : 'parameter'} for the ' + '${plural ? 'fields' : 'field'} $fieldList, which the generated code ' + 'passes to it.', + element: element, + todo: + 'Add `this.${missing.first}`${plural ? ' and the others' : ''} to ' + '`$constructorName`, or mark the ' + '${plural ? 'fields' : 'field'} with `@ignore`.', + ); + } + + final passed = {for (final field in fields) field.name}; + + // A required positional parameter is never passed: the generated code only + // names its arguments. + final unfilled = [ + for (final parameter in constructor.formalParameters) + if (parameter.isRequired && + !(parameter.isNamed && passed.contains(parameter.displayName))) + parameter.displayName, + ]; + + if (unfilled.isEmpty) { return; } - final constructorName = constructor.name == 'new' - ? element.displayName - : '${element.displayName}.${constructor.name}'; - final fieldList = missing.map((name) => '`$name`').join(', '); - final plural = missing.length > 1; + final parameterList = unfilled.map((name) => '`$name`').join(', '); + final plural = unfilled.length > 1; throw InvalidGenerationSourceError( - 'The constructor `$constructorName` has no named ' - '${plural ? 'parameters' : 'parameter'} for the ' - '${plural ? 'fields' : 'field'} $fieldList, which the generated code ' - 'passes to it.', + 'The constructor `$constructorName` requires $parameterList, which ' + '${plural ? 'are' : 'is'} not among the fields the generated code passes ' + 'to it.', element: element, todo: - 'Add `this.${missing.first}`${plural ? ' and the others' : ''} to ' - '`$constructorName`, or mark the ' - '${plural ? 'fields' : 'field'} with `@ignore`.', + 'Make `${unfilled.first}`${plural ? ' and the others' : ''} optional, ' + 'or make ${plural ? 'them fields' : 'it a field'} the generated code ' + 'passes: declare ${plural ? 'them' : 'it'} as ' + '`this.${unfilled.first}`, without `@ignore` on the field.', ); } +/// Checks that no field in [fields] takes a name in [reserved], the members +/// the generated mixin declares. +/// +/// A field is a getter, which cannot override a method the mixin declares, +/// and a static method cannot share a name with an instance member: the class +/// applying the mixin fails to compile with an error that never mentions the +/// generator. +void checkReservedFieldNames( + ClassElement element, + List fields, { + required Set reserved, +}) { + final clashing = fields + .map((field) => field.name) + .where(reserved.contains) + .firstOrNull; + + if (clashing == null) { + return; + } + + throw InvalidGenerationSourceError( + 'The generated mixin `${generatedMixinName(element)}` declares ' + '`$clashing`, so `${element.displayName}` cannot have a field of that ' + 'name.', + element: element, + todo: 'Rename the field `$clashing`.', + ); +} + +/// Checks that [element] applies the generated mixin. +/// +/// Without it the generated methods exist but are reachable from nowhere: +/// another theme holding a field of this type calls a `merge` the class does +/// not have. +/// +/// The mixin lives in the file about to be generated, so on a clean build it +/// does not resolve and leaves no trace in the element model. The clause is +/// read from the parsed declaration instead. A declaration the session cannot +/// produce is not held against the class. +void checkMixinApplied(ClassElement element) { + final mixinName = generatedMixinName(element); + final declaration = _declarationOf(element); + + if (declaration == null) { + return; + } + + final mixins = declaration.withClause?.mixinTypes ?? const []; + + if (mixins.any((type) => type.name.lexeme == mixinName)) { + return; + } + + final className = element.displayName; + + throw InvalidGenerationSourceError( + '`$className` does not apply the generated mixin `$mixinName`, which ' + 'holds the generated methods.', + element: element, + todo: 'Add `with $mixinName` to the declaration of `$className`.', + ); +} + +/// The parsed declaration of [element], or `null` when the session cannot +/// parse its library. +ClassDeclaration? _declarationOf(ClassElement element) { + final library = element.library; + final parsed = library.session.getParsedLibraryByElement(library); + + if (parsed is! ParsedLibraryResult) { + return null; + } + + final name = element.displayName; + + for (final unit in parsed.units) { + for (final declaration in unit.unit.declarations) { + if (declaration is ClassDeclaration && + declaration.namePart.typeName.lexeme == name) { + return declaration; + } + } + } + + return null; +} + /// Checks that [element] extends `ThemeExtension`. /// /// The generated mixin is declared `on ThemeExtension`, so it cannot be @@ -116,6 +271,44 @@ void checkExtendsThemeExtension(ClassElement element) { final _identifier = RegExp(r'^[A-Za-z_$][A-Za-z0-9_$]*$'); +/// Words the language keeps for itself, which the identifier pattern cannot +/// tell from a name. +const _reservedWords = { + 'assert', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'default', + 'do', + 'else', + 'enum', + 'extends', + 'false', + 'final', + 'finally', + 'for', + 'if', + 'in', + 'is', + 'new', + 'null', + 'rethrow', + 'return', + 'super', + 'switch', + 'this', + 'throw', + 'true', + 'try', + 'var', + 'void', + 'while', + 'with', +}; + /// Checks that [value], given as the annotation option [option], can be /// written into the generated code as a name. void checkIdentifier( @@ -123,6 +316,14 @@ void checkIdentifier( required String option, required ClassElement element, }) { + if (_reservedWords.contains(value)) { + throw InvalidGenerationSourceError( + '`$value` is a reserved word, so it cannot be used as `$option`.', + element: element, + todo: 'Use a name that is not a Dart keyword.', + ); + } + if (_identifier.hasMatch(value)) { return; } diff --git a/packages/theme_extensions_builder/lib/src/generator/common.dart b/packages/theme_extensions_builder/lib/src/generator/common.dart index 5184734..8fc4286 100644 --- a/packages/theme_extensions_builder/lib/src/generator/common.dart +++ b/packages/theme_extensions_builder/lib/src/generator/common.dart @@ -156,6 +156,11 @@ Expression lerpFieldExpression(FieldInfo field, Expression a, Expression b) { InstanceLerp(needsCast: true) => a.property('lerp')([b, tRef]).asA(fieldType), + // Non-nullable field, instance lerp returning an optional result: + // a.field.lerp(b.field, t)! + InstanceLerp(optionalResult: true) => + a.property('lerp')([b, tRef]).nullChecked, + // Non-nullable field, instance lerp: // a.field.lerp(b.field, t) InstanceLerp() => a.property('lerp')([b, tRef]), @@ -165,7 +170,6 @@ Expression lerpFieldExpression(FieldInfo field, Expression a, Expression b) { :final baseTypeName, :final genericType, :final genericBaseTypeName, - :final isNullableGeneric, :final genericIsDouble, :final genericIsDuration, ) => @@ -183,7 +187,7 @@ Expression lerpFieldExpression(FieldInfo field, Expression a, Expression b) { genericBaseTypeName.ref.property('lerp'), ], {}, - [genericType.typeRef(isNullable: isNullableGeneric)], + [genericType.typeRef(isNullable: true)], ), ), @@ -320,7 +324,7 @@ Method hashMethod(BaseConfig config) => Method((m) { /// This is a utility function for generating conditional code when using /// code_builder, as it doesn't provide a built-in if construct. Code ifStatement(Expression condition, Block ifBlock) { - final visitor = DartEmitter(); + final visitor = partEmitter(); final conditionV = condition.accept(visitor); final ifBlockV = ifBlock.accept(visitor); diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart index 6c29f44..00e9012 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart @@ -4,6 +4,10 @@ import '../../config/config.dart'; import '../../extensions/string.dart'; import '../common.dart'; +/// The members the generated mixin declares that a field cannot share a name +/// with. +const themeExtensionsReservedNames = {'copyWith', 'lerp'}; + /// Generates the mixin for a `@ThemeExtensions` class, and the `BuildContext` /// extension that reaches it. class ThemeExtensionsCodeBuilder { diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart index e07defa..5654d10 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart @@ -4,6 +4,7 @@ import 'package:source_gen/source_gen.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; import '../../common/fields_visitor.dart'; +import '../../common/type_checkers.dart'; import '../../common/validation.dart'; import '../../config/config.dart'; import '../annotation_reader.dart'; @@ -28,7 +29,10 @@ import 'code_builder.dart'; /// ``` class ThemeExtensionsGenerator extends GeneratorForAnnotation { /// Creates a [ThemeExtensionsGenerator]. - const ThemeExtensionsGenerator(); + /// + /// The annotation is matched by package as well as by name, so a user class + /// called `ThemeExtensions` does not trigger the generator. + const ThemeExtensionsGenerator() : super(inPackage: annotationPackage); @override Future generateForAnnotatedElement( @@ -44,6 +48,7 @@ class ThemeExtensionsGenerator extends GeneratorForAnnotation { ); } + checkNotGeneric(element); checkExtendsThemeExtension(element); final buildContextExtension = annotation @@ -69,6 +74,12 @@ class ThemeExtensionsGenerator extends GeneratorForAnnotation { final fields = collectFields(element, includeMergeLookup: false); checkConstructorParameters(element, constructor, fields); + checkReservedFieldNames( + element, + fields, + reserved: themeExtensionsReservedNames, + ); + checkMixinApplied(element); final config = ThemeExtensionsConfig( fields: fields, @@ -76,8 +87,7 @@ class ThemeExtensionsGenerator extends GeneratorForAnnotation { contextAccessorName: contextAccessorName, buildContextExtension: buildContextExtension, constructor: constructorName, - // The mixin is applied by name, so the name is a convention. - themeExtensionMixinName: '_\$${element.displayName}', + themeExtensionMixinName: generatedMixinName(element), constConstructor: constructor.isConst, ); diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart index 3422058..7c796c2 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart @@ -1,11 +1,17 @@ import 'package:code_builder/code_builder.dart'; import '../../common/symbols/field_info.dart'; -import '../../common/symbols/lerp_info.dart'; import '../../common/symbols/merge_info.dart'; import '../../config/config.dart'; import '../common.dart'; +/// The members the generated mixin declares that a field cannot share a name +/// with. +/// +/// `canMerge` is left out on purpose: the mixin declares it as a getter, so a +/// `bool` field is a valid override and is handled by [staticLerp]. +const themeGenReservedNames = {'copyWith', 'merge', 'lerp'}; + /// Generates the mixin for a `@ThemeGen` class. class ThemeGenCodeBuilder { const ThemeGenCodeBuilder(); @@ -119,6 +125,9 @@ Expression _mergeFieldExpression( other.equalTo(literalNull).conditional(current, merge), ); + // The result goes through `copyWith`, which reads a null as "keep the + // current value": a null `other.field`, or a null returned by the field's + // own `merge`, leaves `_this.field` in place. return switch (field.merge) { // No merge method, just take the other property NoMerge() => other, @@ -231,7 +240,7 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { // A `canMerge` declared as a field rather than a getter is not // interpolated: the result takes the value of `b`. - args[field.name] = field.name == 'canMerge' && field.lerp is NoLerp + args[field.name] = field.name == 'canMerge' ? bProp : lerpFieldExpression(field, aProp, bProp); } diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart b/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart index fee0ad2..408f2af 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart @@ -4,6 +4,7 @@ import 'package:source_gen/source_gen.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; import '../../common/fields_visitor.dart'; +import '../../common/type_checkers.dart'; import '../../common/validation.dart'; import '../../config/config.dart'; import '../annotation_reader.dart'; @@ -29,7 +30,10 @@ import 'code_builder.dart'; /// ``` class ThemeGenGenerator extends GeneratorForAnnotation { /// Creates a [ThemeGenGenerator]. - const ThemeGenGenerator(); + /// + /// The annotation is matched by package as well as by name, so a user class + /// called `ThemeGen` does not trigger the generator. + const ThemeGenGenerator() : super(inPackage: annotationPackage); @override Future generateForAnnotatedElement( @@ -45,12 +49,16 @@ class ThemeGenGenerator extends GeneratorForAnnotation { ); } + checkNotGeneric(element); + final constructorName = annotation.optionalString('constructor'); final constructor = resolveConstructor(element, constructorName); final fields = collectFields(element); checkConstructorParameters(element, constructor, fields); + checkReservedFieldNames(element, fields, reserved: themeGenReservedNames); + checkMixinApplied(element); final config = ThemeGenConfig( fields: fields, diff --git a/packages/theme_extensions_builder/pubspec.yaml b/packages/theme_extensions_builder/pubspec.yaml index 675ec6c..4d3a99d 100644 --- a/packages/theme_extensions_builder/pubspec.yaml +++ b/packages/theme_extensions_builder/pubspec.yaml @@ -22,7 +22,7 @@ environment: resolution: workspace dependencies: - analyzer: ">=13.0.0 <15.0.0" + analyzer: ">=13.1.0 <15.0.0" build: ">=3.0.0 <5.0.0" code_builder: ^4.11.1 source_gen: ">=4.2.3 <5.0.0" diff --git a/packages/theme_extensions_builder/test/fixtures/goldens/generic_inherited_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/generic_inherited_theme.g.theme.dart new file mode 100644 index 0000000..dc70fdd --- /dev/null +++ b/packages/theme_extensions_builder/test/fixtures/goldens/generic_inherited_theme.g.theme.dart @@ -0,0 +1,68 @@ +part of '../inherited_theme.dart'; + +mixin _$GenericInheritedTheme { + bool get canMerge => true; + + static GenericInheritedTheme? lerp( + GenericInheritedTheme? a, + GenericInheritedTheme? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return GenericInheritedTheme(value: t < 0.5 ? a.value : b.value); + } + + GenericInheritedTheme copyWith({num? value}) { + final _this = (this as GenericInheritedTheme); + + return GenericInheritedTheme(value: value ?? _this.value); + } + + GenericInheritedTheme merge(GenericInheritedTheme? other) { + final _this = (this as GenericInheritedTheme); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(value: other.value); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as GenericInheritedTheme); + final _other = (other as GenericInheritedTheme); + + return _other.value == _this.value; + } + + @override + int get hashCode { + final _this = (this as GenericInheritedTheme); + + return Object.hash(runtimeType, _this.value); + } +} diff --git a/packages/theme_extensions_builder/test/fixtures/goldens/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/lookup_theme.g.theme.dart index 2c4c6ed..19d12c4 100644 --- a/packages/theme_extensions_builder/test/fixtures/goldens/lookup_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/goldens/lookup_theme.g.theme.dart @@ -32,6 +32,7 @@ mixin _$LookupTheme { ? a.special : b.special : (a.special!.lerp(b.special!, t) as Special?), + soft: a.soft.lerp(b.soft, t)!, fade: t < 0.5 ? a.fade : b.fade, ratio: t < 0.5 ? a.ratio : b.ratio, counter: t < 0.5 ? a.counter : b.counter, @@ -51,6 +52,7 @@ mixin _$LookupTheme { Strict? strict, Box? box, Special? special, + Soft? soft, Fade? fade, Ratio? ratio, Counter? counter, @@ -70,6 +72,7 @@ mixin _$LookupTheme { strict: strict ?? _this.strict, box: box ?? _this.box, special: special ?? _this.special, + soft: soft ?? _this.soft, fade: fade ?? _this.fade, ratio: ratio ?? _this.ratio, counter: counter ?? _this.counter, @@ -108,6 +111,7 @@ mixin _$LookupTheme { : other.special == null ? _this.special : (_this.special!.merge(other.special!) as Special?), + soft: other.soft, fade: other.fade, ratio: other.ratio, counter: other.counter, @@ -139,6 +143,7 @@ mixin _$LookupTheme { _other.strict == _this.strict && _other.box == _this.box && _other.special == _this.special && + _other.soft == _this.soft && _other.fade == _this.fade && _other.ratio == _this.ratio && _other.counter == _this.counter && @@ -162,6 +167,7 @@ mixin _$LookupTheme { _this.strict, _this.box, _this.special, + _this.soft, _this.fade, _this.ratio, _this.counter, diff --git a/packages/theme_extensions_builder/test/fixtures/goldens/widget_state_property_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/widget_state_property_theme.g.theme.dart index 4b3c99c..c7dad4c 100644 --- a/packages/theme_extensions_builder/test/fixtures/goldens/widget_state_property_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/goldens/widget_state_property_theme.g.theme.dart @@ -53,6 +53,8 @@ mixin _$WidgetStatePropertyTheme { lerpDuration$, ), label: t < 0.5 ? a.label : b.label, + side: t < 0.5 ? a.side : b.side, + nested: t < 0.5 ? a.nested : b.nested, ); } @@ -64,6 +66,8 @@ mixin _$WidgetStatePropertyTheme { WidgetStateProperty? optionalWidth, WidgetStateProperty? optionalDuration, WidgetStateProperty? label, + WidgetStateProperty? side, + WidgetStateProperty?>? nested, }) { final _this = (this as WidgetStatePropertyTheme); @@ -75,6 +79,8 @@ mixin _$WidgetStatePropertyTheme { optionalWidth: optionalWidth ?? _this.optionalWidth, optionalDuration: optionalDuration ?? _this.optionalDuration, label: label ?? _this.label, + side: side ?? _this.side, + nested: nested ?? _this.nested, ); } @@ -97,6 +103,8 @@ mixin _$WidgetStatePropertyTheme { optionalWidth: other.optionalWidth, optionalDuration: other.optionalDuration, label: other.label, + side: other.side, + nested: other.nested, ); } @@ -119,7 +127,9 @@ mixin _$WidgetStatePropertyTheme { _other.optionalColor == _this.optionalColor && _other.optionalWidth == _this.optionalWidth && _other.optionalDuration == _this.optionalDuration && - _other.label == _this.label; + _other.label == _this.label && + _other.side == _this.side && + _other.nested == _this.nested; } @override @@ -135,6 +145,8 @@ mixin _$WidgetStatePropertyTheme { _this.optionalWidth, _this.optionalDuration, _this.label, + _this.side, + _this.nested, ); } } diff --git a/packages/theme_extensions_builder/test/fixtures/inherited_theme.dart b/packages/theme_extensions_builder/test/fixtures/inherited_theme.dart index 938549e..978aeab 100644 --- a/packages/theme_extensions_builder/test/fixtures/inherited_theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/inherited_theme.dart @@ -52,3 +52,32 @@ final class InheritedTheme extends BaseTheme double t, ) => _$InheritedTheme.lerp(a, b, t); } + +/// Declares a field whose type is the class' own type parameter. +class Slot { + const Slot({required this.value}); + + final T value; +} + +/// Theme inheriting a field from a generic superclass: the field is declared +/// as `T value`, and the generated code has to write it as the `num` the +/// `extends` clause fixes `T` to. +@ShouldGenerateFile( + 'goldens/generic_inherited_theme.g.theme.dart', + partOfCurrent: true, +) +@themeGen +final class GenericInheritedTheme extends Slot + with _$GenericInheritedTheme { + const GenericInheritedTheme({required super.value}); + + @override + bool get canMerge => true; + + static GenericInheritedTheme? lerp( + GenericInheritedTheme? a, + GenericInheritedTheme? b, + double t, + ) => _$GenericInheritedTheme.lerp(a, b, t); +} diff --git a/packages/theme_extensions_builder/test/fixtures/inherited_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/inherited_theme.g.theme.dart index cd37b73..a3e2509 100644 --- a/packages/theme_extensions_builder/test/fixtures/inherited_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/inherited_theme.g.theme.dart @@ -71,3 +71,70 @@ mixin _$InheritedTheme { return Object.hash(runtimeType, _this.inherited); } } + +mixin _$GenericInheritedTheme { + bool get canMerge => true; + + static GenericInheritedTheme? lerp( + GenericInheritedTheme? a, + GenericInheritedTheme? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return GenericInheritedTheme(value: t < 0.5 ? a.value : b.value); + } + + GenericInheritedTheme copyWith({num? value}) { + final _this = (this as GenericInheritedTheme); + + return GenericInheritedTheme(value: value ?? _this.value); + } + + GenericInheritedTheme merge(GenericInheritedTheme? other) { + final _this = (this as GenericInheritedTheme); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(value: other.value); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as GenericInheritedTheme); + final _other = (other as GenericInheritedTheme); + + return _other.value == _this.value; + } + + @override + int get hashCode { + final _this = (this as GenericInheritedTheme); + + return Object.hash(runtimeType, _this.value); + } +} diff --git a/packages/theme_extensions_builder/test/fixtures/invalid_theme.dart b/packages/theme_extensions_builder/test/fixtures/invalid_theme.dart index 672f9d5..3d2a7a7 100644 --- a/packages/theme_extensions_builder/test/fixtures/invalid_theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/invalid_theme.dart @@ -2,7 +2,8 @@ /// /// This file is excluded from `generate_for` in `build.yaml`, so the failing /// generation is only exercised by the test. None of the classes mix -/// in the generated mixin for the same reason. +/// in the generated mixin for the same reason; the mixin check runs last, so +/// every other class is stopped by its own error first. library; import 'package:source_gen_test/source_gen_test.dart'; @@ -87,3 +88,91 @@ final class PositionalParameterTheme { final Color color; } + +/// `@ignore` takes the field out of the generated constructor call, so its +/// parameter has to be optional. +@ShouldThrow( + 'The constructor `IgnoredRequiredParameterTheme` requires `label`, which ' + 'is not among the fields the generated code passes to it.', + todo: + 'Make `label` optional, or make it a field the generated code passes: ' + 'declare it as `this.label`, without `@ignore` on the field.', +) +@themeGen +final class IgnoredRequiredParameterTheme { + const IgnoredRequiredParameterTheme({ + required this.color, + required this.label, + }); + + final Color color; + + @ignore + final String label; +} + +/// A required parameter that is not a field cannot be filled in either; +/// positional ones never are. +@ShouldThrow( + 'The constructor `ExtraParameterTheme` requires `scale`, `label`, which ' + 'are not among the fields the generated code passes to it.', + todo: + 'Make `scale` and the others optional, or make them fields the ' + 'generated code passes: declare them as `this.scale`, without ' + '`@ignore` on the field.', +) +@themeGen +final class ExtraParameterTheme { + ExtraParameterTheme( + double scale, { + required this.color, + required String label, + }) : assert(scale >= 0, 'scale must not be negative'), + assert(label.isNotEmpty, 'label must not be empty'); + + final Color color; +} + +/// The generated mixin names the class without type arguments. +@ShouldThrow( + '`GenericTheme` is generic, and the generated mixin cannot be: it ' + 'instantiates `GenericTheme` without type arguments.', + todo: + 'Remove the type parameters from `GenericTheme`, or write its theme ' + 'methods by hand.', +) +@themeGen +final class GenericTheme { + const GenericTheme({required this.value}); + + final T value; +} + +/// A field is a getter, and the mixin's `merge` is a method of the same name. +@ShouldThrow( + r'The generated mixin `_$ReservedFieldTheme` declares `merge`, so ' + '`ReservedFieldTheme` cannot have a field of that name.', + todo: 'Rename the field `merge`.', +) +@themeGen +final class ReservedFieldTheme { + const ReservedFieldTheme({required this.merge}); + + final int merge; +} + +/// Everything else is in order, so the missing `with` clause is what stops +/// this one. +@ShouldThrow( + '`MissingMixinTheme` does not apply the generated mixin ' + r'`_$MissingMixinTheme`, which holds the generated methods.', + todo: + r'Add `with _$MissingMixinTheme` to the declaration of ' + '`MissingMixinTheme`.', +) +@themeGen +final class MissingMixinTheme { + const MissingMixinTheme({required this.color}); + + final Color color; +} diff --git a/packages/theme_extensions_builder/test/fixtures/invalid_theme_extension.dart b/packages/theme_extensions_builder/test/fixtures/invalid_theme_extension.dart index b609e99..6b056f5 100644 --- a/packages/theme_extensions_builder/test/fixtures/invalid_theme_extension.dart +++ b/packages/theme_extensions_builder/test/fixtures/invalid_theme_extension.dart @@ -3,7 +3,8 @@ /// /// This file is excluded from `generate_for` in `build.yaml`, so the failing /// generation is only exercised by the test. None of the classes mix -/// in the generated mixin for the same reason. +/// in the generated mixin for the same reason; the mixin check runs last, so +/// every other class is stopped by its own error first. library; import 'package:source_gen_test/source_gen_test.dart'; @@ -81,3 +82,67 @@ final class MissingParameterExtension final Color color; final double width; } + +/// A reserved word matches the identifier pattern but cannot name a getter. +@ShouldThrow( + '`class` is a reserved word, so it cannot be used as `contextAccessorName`.', + todo: 'Use a name that is not a Dart keyword.', +) +@ThemeExtensions(contextAccessorName: 'class') +final class ReservedWordAccessor extends ThemeExtension { + const ReservedWordAccessor({required this.color}); + + final Color color; +} + +/// The mixin is declared `on ThemeExtension` with no type arguments to +/// pass on. +@ShouldThrow( + '`GenericExtension` is generic, and the generated mixin cannot be: it ' + 'instantiates `GenericExtension` without type arguments.', + todo: + 'Remove the type parameters from `GenericExtension`, or write its theme ' + 'methods by hand.', +) +@themeExtensions +final class GenericExtension extends ThemeExtension> { + const GenericExtension({required this.value}); + + final T value; +} + +/// The mixin's `lerp` is an instance method here, which a field cannot +/// override. `ThemeExtension` declares the same method, so the analyzer +/// objects to the field on its own; the generator still names the mixin so +/// that a `@ThemeGen` field of the same name reads the same. +@ShouldThrow( + r'The generated mixin `_$ReservedFieldExtension` declares `lerp`, so ' + '`ReservedFieldExtension` cannot have a field of that name.', + todo: 'Rename the field `lerp`.', +) +@themeExtensions +final class ReservedFieldExtension + extends ThemeExtension { + const ReservedFieldExtension({required this.lerp}); + + // The conflict is the point of this fixture. + // ignore: conflicting_field_and_method, annotate_overrides + final double lerp; +} + +/// Everything else is in order, so the missing `with` clause is what stops +/// this one. +@ShouldThrow( + '`MissingMixinExtension` does not apply the generated mixin ' + r'`_$MissingMixinExtension`, which holds the generated methods.', + todo: + r'Add `with _$MissingMixinExtension` to the declaration of ' + '`MissingMixinExtension`.', +) +@themeExtensions +final class MissingMixinExtension + extends ThemeExtension { + const MissingMixinExtension({required this.color}); + + final Color color; +} diff --git a/packages/theme_extensions_builder/test/fixtures/lookup_theme.dart b/packages/theme_extensions_builder/test/fixtures/lookup_theme.dart index d8d15a2..e22bfda 100644 --- a/packages/theme_extensions_builder/test/fixtures/lookup_theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/lookup_theme.dart @@ -119,6 +119,16 @@ class Special extends Animatable { const Special(super.value); } +/// An instance `lerp` with an optional result on a type used non-nullably, +/// whose null the generated code has to check away. +class Soft { + const Soft(this.value); + + final double value; + + Soft? lerp(Soft other, double t) => Soft(value + (other.value - value) * t); +} + /// A `lerp` returning something unrelated to the class it is declared on. class Fade { const Fade(this.value); @@ -240,6 +250,7 @@ final class LookupTheme extends Middle with _$LookupTheme { required this.strict, required this.box, required this.special, + required this.soft, required this.fade, required this.ratio, required this.counter, @@ -261,6 +272,7 @@ final class LookupTheme extends Middle with _$LookupTheme { final Strict strict; final Box box; final Special? special; + final Soft soft; final Fade? fade; final Ratio ratio; final Counter counter; diff --git a/packages/theme_extensions_builder/test/fixtures/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/lookup_theme.g.theme.dart index ed1f257..e32e5ba 100644 --- a/packages/theme_extensions_builder/test/fixtures/lookup_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/lookup_theme.g.theme.dart @@ -41,6 +41,7 @@ mixin _$LookupTheme { ? a.special : b.special : (a.special!.lerp(b.special!, t) as Special?), + soft: a.soft.lerp(b.soft, t)!, fade: t < 0.5 ? a.fade : b.fade, ratio: t < 0.5 ? a.ratio : b.ratio, counter: t < 0.5 ? a.counter : b.counter, @@ -60,6 +61,7 @@ mixin _$LookupTheme { Strict? strict, Box? box, Special? special, + Soft? soft, Fade? fade, Ratio? ratio, Counter? counter, @@ -79,6 +81,7 @@ mixin _$LookupTheme { strict: strict ?? _this.strict, box: box ?? _this.box, special: special ?? _this.special, + soft: soft ?? _this.soft, fade: fade ?? _this.fade, ratio: ratio ?? _this.ratio, counter: counter ?? _this.counter, @@ -117,6 +120,7 @@ mixin _$LookupTheme { : other.special == null ? _this.special : (_this.special!.merge(other.special!) as Special?), + soft: other.soft, fade: other.fade, ratio: other.ratio, counter: other.counter, @@ -148,6 +152,7 @@ mixin _$LookupTheme { _other.strict == _this.strict && _other.box == _this.box && _other.special == _this.special && + _other.soft == _this.soft && _other.fade == _this.fade && _other.ratio == _this.ratio && _other.counter == _this.counter && @@ -171,6 +176,7 @@ mixin _$LookupTheme { _this.strict, _this.box, _this.special, + _this.soft, _this.fade, _this.ratio, _this.counter, diff --git a/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.dart b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.dart index a669a1c..914e0ac 100644 --- a/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.dart @@ -5,15 +5,33 @@ import 'flutter_stubs.dart'; part 'widget_state_property_theme.g.theme.dart'; +const _fallback = 'switches over at t = 0.5 instead of being interpolated.'; + const _labelWarning = - 'The `lerp` method of String has an unsupported signature, so the field ' - '`label` switches over at t = 0.5 instead of being interpolated.'; + 'WidgetStateProperty cannot be interpolated: `String` has no ' + 'static `String? lerp(String?, String?, double)` for ' + '`WidgetStateProperty.lerp` to call, so the field `label` $_fallback'; + +const _sideWarning = + 'WidgetStateProperty cannot be interpolated: `BorderSide` ' + 'has no static `BorderSide? lerp(BorderSide?, BorderSide?, double)` for ' + '`WidgetStateProperty.lerp` to call, so the field `side` $_fallback'; + +const _nestedWarning = + 'WidgetStateProperty?> cannot be ' + 'interpolated: `WidgetStateProperty` has no static ' + '`WidgetStateProperty? lerp(WidgetStateProperty?, ' + 'WidgetStateProperty?, double)` for `WidgetStateProperty.lerp` to ' + 'call, so the field `nested` $_fallback'; -/// Empty Theme - testing edge case with no fields +/// Theme covering the `WidgetStateProperty` shapes: `double` and `Duration` +/// generics have their own lerp functions, anything else needs a static +/// `lerp` on the generic that accepts nulls, and a generic without one is +/// reported once, whatever `lerp` it does declare. @ShouldGenerateFile( 'goldens/widget_state_property_theme.g.theme.dart', partOfCurrent: true, - expectedLogItems: [_labelWarning], + expectedLogItems: [_labelWarning, _sideWarning, _nestedWarning], ) @themeGen final class WidgetStatePropertyTheme with _$WidgetStatePropertyTheme { @@ -25,6 +43,8 @@ final class WidgetStatePropertyTheme with _$WidgetStatePropertyTheme { required this.optionalWidth, required this.optionalDuration, required this.label, + required this.side, + required this.nested, }); final WidgetStateProperty color; @@ -38,6 +58,13 @@ final class WidgetStatePropertyTheme with _$WidgetStatePropertyTheme { /// `String` has no static lerp, so this one cannot be interpolated. final WidgetStateProperty label; + /// `BorderSide.lerp` takes no nulls, so it cannot be passed on either. + final WidgetStateProperty side; + + /// The inner generic is not nullable, which is a fallback here rather than + /// the error it is on a field: the message would point at the wrong type. + final WidgetStateProperty?> nested; + @override bool get canMerge => true; diff --git a/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.g.theme.dart index f6cb2c0..cf14760 100644 --- a/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.g.theme.dart @@ -62,6 +62,8 @@ mixin _$WidgetStatePropertyTheme { lerpDuration$, ), label: t < 0.5 ? a.label : b.label, + side: t < 0.5 ? a.side : b.side, + nested: t < 0.5 ? a.nested : b.nested, ); } @@ -73,6 +75,8 @@ mixin _$WidgetStatePropertyTheme { WidgetStateProperty? optionalWidth, WidgetStateProperty? optionalDuration, WidgetStateProperty? label, + WidgetStateProperty? side, + WidgetStateProperty?>? nested, }) { final _this = (this as WidgetStatePropertyTheme); @@ -84,6 +88,8 @@ mixin _$WidgetStatePropertyTheme { optionalWidth: optionalWidth ?? _this.optionalWidth, optionalDuration: optionalDuration ?? _this.optionalDuration, label: label ?? _this.label, + side: side ?? _this.side, + nested: nested ?? _this.nested, ); } @@ -106,6 +112,8 @@ mixin _$WidgetStatePropertyTheme { optionalWidth: other.optionalWidth, optionalDuration: other.optionalDuration, label: other.label, + side: other.side, + nested: other.nested, ); } @@ -128,7 +136,9 @@ mixin _$WidgetStatePropertyTheme { _other.optionalColor == _this.optionalColor && _other.optionalWidth == _this.optionalWidth && _other.optionalDuration == _this.optionalDuration && - _other.label == _this.label; + _other.label == _this.label && + _other.side == _this.side && + _other.nested == _this.nested; } @override @@ -144,6 +154,8 @@ mixin _$WidgetStatePropertyTheme { _this.optionalWidth, _this.optionalDuration, _this.label, + _this.side, + _this.nested, ); } } diff --git a/packages/theme_extensions_builder/test/generator/code_builder_test.dart b/packages/theme_extensions_builder/test/generator/code_builder_test.dart index 0923862..c41312d 100644 --- a/packages/theme_extensions_builder/test/generator/code_builder_test.dart +++ b/packages/theme_extensions_builder/test/generator/code_builder_test.dart @@ -84,6 +84,18 @@ void main() { expect(code, contains('(a.value.lerp(b.value, t) as Lerpable)')); }); + test('instance lerp with optional result is null checked', () { + final code = _generate([ + _field( + 'value', + typeName: 'Lerpable', + lerp: const InstanceLerp(optionalResult: true), + ), + ]); + + expect(code, contains('value: a.value.lerp(b.value, t)!')); + }); + test('a result that already has the field type is not cast', () { final code = _generate([ _field( diff --git a/packages/theme_extensions_builder/test/runtime/symbols_test.dart b/packages/theme_extensions_builder/test/runtime/symbols_test.dart index 490a285..6455d72 100644 --- a/packages/theme_extensions_builder/test/runtime/symbols_test.dart +++ b/packages/theme_extensions_builder/test/runtime/symbols_test.dart @@ -84,13 +84,11 @@ void main() { group('WidgetStatePropertyLerp', () { WidgetStatePropertyLerp build({ String genericType = 'Color', - bool isNullableGeneric = true, bool genericIsDouble = false, bool genericIsDuration = false, }) => WidgetStatePropertyLerp( baseTypeName: 'WidgetStateProperty', genericType: genericType, - isNullableGeneric: isNullableGeneric, genericIsDouble: genericIsDouble, genericIsDuration: genericIsDuration, ); @@ -111,7 +109,16 @@ void main() { expect(build(), equals(build())); expect(build().hashCode, equals(build().hashCode)); expect(build(), isNot(equals(build(genericType: 'double')))); - expect(build(), isNot(equals(build(isNullableGeneric: false)))); + }); + + test('the element checks take part in equality', () { + // A user type called `Duration` shares genericType with the real one. + final byName = build(genericType: 'Duration'); + final byElement = build(genericType: 'Duration', genericIsDuration: true); + + expect(byName, isNot(equals(byElement))); + expect(byName.hashCode, isNot(equals(byElement.hashCode))); + expect(build(), isNot(equals(build(genericIsDouble: true)))); }); test('toString returns correct format', () { @@ -120,7 +127,8 @@ void main() { 'WidgetStatePropertyLerp(' 'baseTypeName: WidgetStateProperty, ' 'genericType: Color, ' - 'isNullableGeneric: true)', + 'genericIsDouble: false, ' + 'genericIsDuration: false)', ); }); }); diff --git a/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart index 2eb3134..842c4ce 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart @@ -15,6 +15,7 @@ void main() { strict: Strict(1), box: Box(1), special: Special(1), + soft: Soft(1), fade: Fade(1), ratio: Ratio(1), counter: Counter(1), @@ -33,6 +34,7 @@ void main() { strict: Strict(2), box: Box(2), special: Special(2), + soft: Soft(3), fade: Fade(2), ratio: Ratio(2), counter: Counter(2), @@ -81,6 +83,10 @@ void main() { expect(a.merge(b).special, isA()); }); + test('an instance lerp with an optional result is null checked', () { + expect(LookupTheme.lerp(a, b, 0.5)!.soft.value, 2); + }); + test('a lerp returning an unrelated type is ignored', () { expect(LookupTheme.lerp(a, b, 0.4)!.fade, same(a.fade)); }); @@ -102,6 +108,7 @@ void main() { strict: Strict(1), box: Box(1), special: Special(1), + soft: Soft(1), fade: Fade(1), ratio: Ratio(1), counter: Counter(1),