Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion packages/leancode_lint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,44 @@ class MyWidget extends StatelessWidget {

None

### `prefer_equatable_mixin`
Copy link
Copy Markdown
Member

@Albert221 Albert221 Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explaining all lints in README takes much space. Maybe we could wrap them into <details>? Out of scope though

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do next 💯


**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<Object?> get props => [value];
}
```

**GOOD:**

```dart
import 'package:equatable/equatable.dart';

class Foobar with EquatableMixin {
const Foobar(this.value);

final int value;

@override
List<Object?> 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 <kbd>ctrl</kbd>+<kbd>.</kbd> or <kbd>⌘</kbd>+<kbd>.</kbd>.
Expand Down Expand Up @@ -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
[patrol-landing]: https://patrol.leancode.co/?utm_source=github.com&utm_medium=referral&utm_campaign=leancode-lint
8 changes: 7 additions & 1 deletion packages/leancode_lint/lib/plugin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
116 changes: 116 additions & 0 deletions packages/leancode_lint/lib/src/lints/prefer_equatable_mixin.dart
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
Albert221 marked this conversation as resolved.
);

@override
LintCode get diagnosticCode => code;

@override
void registerNodeProcessors(
RuleVisitorRegistry registry,
RuleContext context,
) {
registry.addClassDeclaration(this, _Visitor(this, context));
}
}

class _Visitor extends SimpleAstVisitor<void> {
_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<void> compute(ChangeBuilder builder) async {
final classDeclaration = node.thisOrAncestorOfType<ClassDeclaration>()!;
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',
);
}
});
}
}
1 change: 1 addition & 0 deletions packages/leancode_lint/test/mock_libraries.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
18 changes: 18 additions & 0 deletions packages/leancode_lint/test/mock_libraries/equatable.dart
Original file line number Diff line number Diff line change
@@ -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<Object?> get props;
}

mixin EquatableMixin {
List<Object?> get props;
}
''');
super.setUp();
}
}
Original file line number Diff line number Diff line change
@@ -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<void> test_only_directly_extending_equatable() async {
await assertDiagnosticsInRanges('''
import 'package:equatable/equatable.dart';

class MyState extends [!Equatable!] {
@override
List<Object?> get props => [];
}

class MyState2 extends MyState {
@override
List<Object?> get props => [];
}
''');
}

Future<void> test_mixin_not_flagged() async {
await assertNoDiagnostics('''
import 'package:equatable/equatable.dart';

class MyState3 with EquatableMixin {
@override
List<Object?> get props => [];
}
''');
}
Comment thread
PiotrRogulski marked this conversation as resolved.

Future<void> test_with_other_mixins() async {
await assertDiagnosticsInRanges('''
import 'package:equatable/equatable.dart';

mixin SomethingElse {}

class MyState extends [!Equatable!] with SomethingElse {
@override
List<Object?> get props => [];
}

class MyState2 extends MyState with SomethingElse {
@override
List<Object?> get props => [];
}

class MyState3 with SomethingElse, EquatableMixin {
@override
List<Object?> get props => [];
}
''');
}
}