From 9c1410e09e81058caf6b851c2116d432b2e45909 Mon Sep 17 00:00:00 2001 From: Piotr Rogulski Date: Sat, 7 Feb 2026 15:33:50 +0100 Subject: [PATCH 1/3] Add the `prefer_equatable_mixin` lint --- packages/leancode_lint/README.md | 44 ++++++- packages/leancode_lint/lib/plugin.dart | 8 +- .../lib/src/lints/prefer_equatable_mixin.dart | 116 ++++++++++++++++++ .../leancode_lint/test/mock_libraries.dart | 1 + .../test/mock_libraries/equatable.dart | 18 +++ .../prefer_equatable_mixin_test.dart | 49 ++++++++ 6 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 packages/leancode_lint/lib/src/lints/prefer_equatable_mixin.dart create mode 100644 packages/leancode_lint/test/mock_libraries/equatable.dart create mode 100644 packages/leancode_lint/test/test_cases/prefer_equatable_mixin_test.dart diff --git a/packages/leancode_lint/README.md b/packages/leancode_lint/README.md index 3b15b1d6..2141c285 100644 --- a/packages/leancode_lint/README.md +++ b/packages/leancode_lint/README.md @@ -575,15 +575,53 @@ class MyWidget extends StatelessWidget { None +### `prefer_equatable_mixin` + +**DO** mix in `EquatableMixin` instead of extending `Equatable`. + +**BAD:** + +```dart +import 'package:equatable/equatable.dart'; + +class Foobar extends Equatable { + const Foobar(this.value); + + final int value; + + @override + List get props => [value]; +} +``` + +**GOOD:** + +```dart +import 'package:equatable/equatable.dart'; + +class Foobar with EquatableMixin { + const Foobar(this.value); + + final int value; + + @override + List get props => [value]; +} +``` + +#### Configuration + +None. + ## Assists Assists are IDE refactorings not related to a particular issue. They can be triggered by placing your cursor over a relevant piece of code and opening the code actions dialog. For instance, in VSCode this is done with ctrl+. or +.. See linked source code containing explanation in dart doc. -- [Convert positional to named formal](./lib/assists/convert_positional_to_named_formal.dart) -- [Convert record into nominal type](./lib/assists/convert_record_into_nominal_type.dart) -- [Convert iterable map to collection-for](./lib/assists/convert_iterable_map_to_collection_for.dart) +- [Convert positional to named formal](./lib/src/assists/convert_positional_to_named_formal.dart) +- [Convert record into nominal type](./lib/src/assists/convert_record_into_nominal_type.dart) +- [Convert iterable map to collection-for](./lib/src/assists/convert_iterable_map_to_collection_for.dart) [pub-badge]: https://img.shields.io/pub/v/leancode_lint [pub-badge-link]: https://pub.dev/packages/leancode_lint diff --git a/packages/leancode_lint/lib/plugin.dart b/packages/leancode_lint/lib/plugin.dart index 725f0a62..7f153c5c 100644 --- a/packages/leancode_lint/lib/plugin.dart +++ b/packages/leancode_lint/lib/plugin.dart @@ -10,6 +10,7 @@ import 'package:leancode_lint/src/lints/avoid_single_child_in_multi_child_widget import 'package:leancode_lint/src/lints/catch_parameter_names.dart'; import 'package:leancode_lint/src/lints/constructor_parameters_and_fields_should_have_the_same_order.dart'; import 'package:leancode_lint/src/lints/hook_widget_does_not_use_hooks.dart'; +import 'package:leancode_lint/src/lints/prefer_equatable_mixin.dart'; import 'package:leancode_lint/src/lints/prefix_widgets_returning_slivers.dart'; import 'package:leancode_lint/src/lints/start_comments_with_space.dart'; import 'package:leancode_lint/src/lints/use_align.dart'; @@ -64,8 +65,13 @@ final class LeanCodeLintPlugin extends Plugin { UseDedicatedMediaQueryMethods.code, ReplaceMediaQueryOfWithDedicatedMethodFix.new, ) + ..registerWarningRule(PreferEquatableMixin()) + ..registerFixForRule( + PreferEquatableMixin.code, + ConvertToEquatableMixin.new, + ) // TODO: uncomment when `prefer_center_over_align` is migrated - // ..registerAssist(PreferCenterOverAlign()) + // ..registerWarningRule(PreferCenterOverAlign()) ..registerAssist(ConvertRecordIntoNominalType.new) ..registerAssist(ConvertPositionalToNamedFormal.new) ..registerAssist(ConvertIterableMapToCollectionFor.new); diff --git a/packages/leancode_lint/lib/src/lints/prefer_equatable_mixin.dart b/packages/leancode_lint/lib/src/lints/prefer_equatable_mixin.dart new file mode 100644 index 00000000..0bb22736 --- /dev/null +++ b/packages/leancode_lint/lib/src/lints/prefer_equatable_mixin.dart @@ -0,0 +1,116 @@ +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; +import 'package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart'; +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/error/error.dart'; +import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; +import 'package:analyzer_plugin/utilities/fixes/fixes.dart'; +import 'package:analyzer_plugin/utilities/range_factory.dart'; +import 'package:leancode_lint/src/type_checker.dart'; + +class PreferEquatableMixin extends AnalysisRule { + PreferEquatableMixin() + : super(name: code.lowerCaseName, description: code.problemMessage); + + static const code = LintCode( + 'prefer_equatable_mixin', + 'The class {0} should mix in EquatableMixin instead of extending Equatable.', + correctionMessage: 'Replace with a mixin application.', + severity: .WARNING, + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + registry.addClassDeclaration(this, _Visitor(this, context)); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule, this.context); + + final AnalysisRule rule; + final RuleContext context; + + static const equatable = TypeChecker.fromName( + 'Equatable', + packageName: 'equatable', + ); + static const equatableMixin = TypeChecker.fromName( + 'EquatableMixin', + packageName: 'equatable', + ); + + @override + void visitClassDeclaration(ClassDeclaration node) { + final extendsClause = node.extendsClause; + if (extendsClause == null) { + return; + } + + final superType = extendsClause.superclass.type; + final isEquatable = superType != null && equatable.isExactlyType(superType); + + final isEquatableMixin = + node.withClause?.mixinTypes + .map((mixin) => mixin.type) + .nonNulls + .any(equatableMixin.isExactlyType) ?? + false; + + if (isEquatable && !isEquatableMixin) { + rule.reportAtNode( + extendsClause.superclass, + arguments: [node.namePart.typeName.lexeme], + ); + } + } +} + +class ConvertToEquatableMixin extends ResolvedCorrectionProducer { + ConvertToEquatableMixin({required super.context}); + + @override + FixKind? get fixKind => const FixKind( + 'leancode_lint.fix.convertToEquatableMixin', + DartFixKindPriority.standard, + 'Convert to EquatableMixin', + ); + + @override + CorrectionApplicability get applicability => .automatically; + + @override + Future compute(ChangeBuilder builder) async { + final classDeclaration = node.thisOrAncestorOfType()!; + final extendsClause = classDeclaration.extendsClause!; + final withClause = classDeclaration.withClause; + + await builder.addDartFileEdit(file, (builder) { + if (withClause != null) { + builder + ..addSimpleReplacement( + range.startStart(extendsClause, withClause), + '', + ) + ..addSimpleInsertion( + withClause.mixinTypes.first.offset, + 'EquatableMixin, ', + ); + } else { + builder.addSimpleReplacement( + extendsClause.sourceRange, + 'with EquatableMixin', + ); + } + }); + } +} diff --git a/packages/leancode_lint/test/mock_libraries.dart b/packages/leancode_lint/test/mock_libraries.dart index 329ee859..a5142e63 100644 --- a/packages/leancode_lint/test/mock_libraries.dart +++ b/packages/leancode_lint/test/mock_libraries.dart @@ -1,6 +1,7 @@ import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; part 'mock_libraries/bloc.dart'; +part 'mock_libraries/equatable.dart'; part 'mock_libraries/flutter.dart'; part 'mock_libraries/flutter_bloc.dart'; part 'mock_libraries/flutter_hooks.dart'; diff --git a/packages/leancode_lint/test/mock_libraries/equatable.dart b/packages/leancode_lint/test/mock_libraries/equatable.dart new file mode 100644 index 00000000..d9d47d92 --- /dev/null +++ b/packages/leancode_lint/test/mock_libraries/equatable.dart @@ -0,0 +1,18 @@ +part of '../mock_libraries.dart'; + +mixin MockEquatable on AnalysisRuleTest { + @override + void setUp() { + newPackage('equatable').addFile('lib/equatable.dart', ''' +class Equatable { + const Equatable(); + List get props; +} + +mixin EquatableMixin { + List get props; +} +'''); + super.setUp(); + } +} diff --git a/packages/leancode_lint/test/test_cases/prefer_equatable_mixin_test.dart b/packages/leancode_lint/test/test_cases/prefer_equatable_mixin_test.dart new file mode 100644 index 00000000..e134bc71 --- /dev/null +++ b/packages/leancode_lint/test/test_cases/prefer_equatable_mixin_test.dart @@ -0,0 +1,49 @@ +import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; +import 'package:leancode_lint/src/lints/prefer_equatable_mixin.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../assert_ranges.dart'; +import '../mock_libraries.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(PreferEquatableMixinTest); + }); +} + +@reflectiveTest +class PreferEquatableMixinTest extends AnalysisRuleTest with MockEquatable { + @override + void setUp() { + rule = PreferEquatableMixin(); + + super.setUp(); + } + + Future test_only_directly_extending_equatable() async { + await assertDiagnosticsInRanges(''' +import 'package:equatable/equatable.dart'; + +class MyState extends [!Equatable!] { + @override + List get props => []; +} + +class MyState2 extends MyState { + @override + List get props => []; +} +'''); + } + + Future test_mixin_not_flagged() async { + await assertNoDiagnostics(''' +import 'package:equatable/equatable.dart'; + +class MyState3 with EquatableMixin { + @override + List get props => []; +} +'''); + } +} From 510845b97f4a6e6ff4cc0e18de70e201eaf34507 Mon Sep 17 00:00:00 2001 From: Piotr Rogulski Date: Sat, 7 Feb 2026 16:17:02 +0100 Subject: [PATCH 2/3] More tests --- .../prefer_equatable_mixin_test.dart | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/leancode_lint/test/test_cases/prefer_equatable_mixin_test.dart b/packages/leancode_lint/test/test_cases/prefer_equatable_mixin_test.dart index e134bc71..a839bbfe 100644 --- a/packages/leancode_lint/test/test_cases/prefer_equatable_mixin_test.dart +++ b/packages/leancode_lint/test/test_cases/prefer_equatable_mixin_test.dart @@ -44,6 +44,29 @@ class MyState3 with EquatableMixin { @override List get props => []; } +'''); + } + + Future test_with_other_mixins() async { + await assertDiagnosticsInRanges(''' +import 'package:equatable/equatable.dart'; + +mixin SomethingElse {} + +class MyState extends [!Equatable!] with SomethingElse { + @override + List get props => []; +} + +class MyState2 extends MyState with SomethingElse { + @override + List get props => []; +} + +class MyState3 with SomethingElse, EquatableMixin { + @override + List get props => []; +} '''); } } From fdb02acb30d202e99366f103f6ea127bf050a2a3 Mon Sep 17 00:00:00 2001 From: Piotr Rogulski Date: Thu, 26 Feb 2026 13:11:49 +0100 Subject: [PATCH 3/3] Update correction message --- .../leancode_lint/lib/src/lints/prefer_equatable_mixin.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/leancode_lint/lib/src/lints/prefer_equatable_mixin.dart b/packages/leancode_lint/lib/src/lints/prefer_equatable_mixin.dart index 0bb22736..69d50157 100644 --- a/packages/leancode_lint/lib/src/lints/prefer_equatable_mixin.dart +++ b/packages/leancode_lint/lib/src/lints/prefer_equatable_mixin.dart @@ -18,7 +18,7 @@ class PreferEquatableMixin extends AnalysisRule { static const code = LintCode( 'prefer_equatable_mixin', 'The class {0} should mix in EquatableMixin instead of extending Equatable.', - correctionMessage: 'Replace with a mixin application.', + correctionMessage: 'Replace with a mixin.', severity: .WARNING, );