diff --git a/packages/leancode_lint/README.md b/packages/leancode_lint/README.md index e0d0de14..98f4cbc3 100644 --- a/packages/leancode_lint/README.md +++ b/packages/leancode_lint/README.md @@ -585,6 +585,44 @@ 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 +.. @@ -631,4 +669,4 @@ We are **top-tier experts** focused on Flutter Enterprise solutions. [leancode-landing]: https://leancode.co/?utm_source=github.com&utm_medium=referral&utm_campaign=leancode-lint [leancode-estimate]: https://leancode.co/get-estimate?utm_source=github.com&utm_medium=referral&utm_campaign=leancode-lint [leancode-packages]: https://pub.dev/packages?q=publisher%3Aleancode.co&sort=downloads -[patrol-landing]: https://patrol.leancode.co/?utm_source=github.com&utm_medium=referral&utm_campaign=leancode-lint \ No newline at end of file +[patrol-landing]: https://patrol.leancode.co/?utm_source=github.com&utm_medium=referral&utm_campaign=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..69d50157 --- /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.', + 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..a839bbfe --- /dev/null +++ b/packages/leancode_lint/test/test_cases/prefer_equatable_mixin_test.dart @@ -0,0 +1,72 @@ +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 => []; +} +'''); + } + + 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 => []; +} +'''); + } +}