Skip to content
This repository was archived by the owner on Jul 16, 2023. It is now read-only.

feat: add list-all-equatable-fields rule #1103

Merged
merged 11 commits into from
Dec 19, 2022
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

* feat: add static code diagnostic [`list-all-equatable-fields`](https://dartcodemetrics.dev/docs/rules/common/list-all-equatable-fields).
* feat: add `strict` config option to [`avoid-collection-methods-with-unrelated-types`](https://dartcodemetrics.dev/docs/rules/common/avoid-collection-methods-with-unrelated-types).
* fix: support function expression invocations for [`prefer-moving-to-variable`](https://dartcodemetrics.dev/docs/rules/common/prefer-moving-to-variable).
* feat: support ignoring regular comments for [`format-comment`](https://dartcodemetrics.dev/docs/rules/common/format-comment).
Expand Down
2 changes: 2 additions & 0 deletions lib/src/analyzers/lint_analyzer/rules/rules_factory.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import 'rules_list/component_annotation_arguments_ordering/component_annotation_
import 'rules_list/consistent_update_render_object/consistent_update_render_object_rule.dart';
import 'rules_list/double_literal_format/double_literal_format_rule.dart';
import 'rules_list/format_comment/format_comment_rule.dart';
import 'rules_list/list_all_equatable_fields/list_all_equatable_fields_rule.dart';
import 'rules_list/member_ordering/member_ordering_rule.dart';
import 'rules_list/missing_test_assertion/missing_test_assertion_rule.dart';
import 'rules_list/newline_before_return/newline_before_return_rule.dart';
Expand Down Expand Up @@ -117,6 +118,7 @@ final _implementedRules = <String, Rule Function(Map<String, Object>)>{
ConsistentUpdateRenderObjectRule.ruleId: ConsistentUpdateRenderObjectRule.new,
DoubleLiteralFormatRule.ruleId: DoubleLiteralFormatRule.new,
FormatCommentRule.ruleId: FormatCommentRule.new,
ListAllEquatableFieldsRule.ruleId: ListAllEquatableFieldsRule.new,
MemberOrderingRule.ruleId: MemberOrderingRule.new,
MissingTestAssertionRule.ruleId: MissingTestAssertionRule.new,
NewlineBeforeReturnRule.ruleId: NewlineBeforeReturnRule.new,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// ignore_for_file: public_member_api_docs

import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:collection/collection.dart';

import '../../../../../utils/node_utils.dart';
import '../../../lint_utils.dart';
import '../../../models/internal_resolved_unit_result.dart';
import '../../../models/issue.dart';
import '../../../models/severity.dart';
import '../../models/flutter_rule.dart';
import '../../rule_utils.dart';

part 'visitor.dart';

class ListAllEquatableFieldsRule extends FlutterRule {
static const ruleId = 'list-all-equatable-fields';

ListAllEquatableFieldsRule([
Map<String, Object> config = const {},
]) : super(
id: ruleId,
severity: readSeverity(config, Severity.warning),
excludes: readExcludes(config),
includes: readIncludes(config),
);

@override
Iterable<Issue> check(InternalResolvedUnitResult source) {
final visitor = _Visitor();

source.unit.visitChildren(visitor);

return visitor.declarations
.map((declaration) => createIssue(
rule: this,
location: nodeLocation(node: declaration.node, source: source),
message: declaration.errorMessage,
))
.toList(growable: false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
part of 'list_all_equatable_fields_rule.dart';

class _Visitor extends GeneralizingAstVisitor<void> {
final _declarations = <_DeclarationInfo>[];

Iterable<_DeclarationInfo> get declarations => _declarations;

@override
void visitClassDeclaration(ClassDeclaration node) {
final classType = node.extendsClause?.superclass.type;

final isEquatable = _isEquatableOrSubclass(classType);
final isMixin = node.withClause?.mixinTypes
.any((mixinType) => _isEquatableMixin(mixinType.type)) ??
false;
final isSubclassOfMixin = _isSubclassOfEquatableMixin(classType);
if (!isEquatable && !isMixin && !isSubclassOfMixin) {
return;
}

final fieldNames = node.members
.whereType<FieldDeclaration>()
.whereNot((field) => field.isStatic)
.map((declaration) =>
declaration.fields.variables.firstOrNull?.name.lexeme)
.whereNotNull()
.toSet();

if (isMixin) {
fieldNames.addAll(_getParentFields(classType));
}

final props = node.members.firstWhereOrNull((declaration) =>
declaration is MethodDeclaration &&
declaration.isGetter &&
declaration.name.lexeme == 'props') as MethodDeclaration?;

if (props == null) {
return;
}

final literalVisitor = _ListLiteralVisitor();
props.body.visitChildren(literalVisitor);

final expression = literalVisitor.literals.firstOrNull;
if (expression != null) {
final usedFields = expression.elements
.whereType<SimpleIdentifier>()
.map((identifier) => identifier.name)
.toSet();

if (!usedFields.containsAll(fieldNames)) {
final missingFields = fieldNames.difference(usedFields).join(', ');
_declarations.add(_DeclarationInfo(
props,
'Missing declared class fields: $missingFields',
));
}
}
}

Set<String> _getParentFields(DartType? classType) {
// ignore: deprecated_member_use
final element = classType?.element2;
if (element is! ClassElement) {
return {};
}

return element.fields
.where(
(field) =>
!field.isStatic &&
!field.isConst &&
!field.isPrivate &&
field.name != 'hashCode',
)
.map((field) => field.name)
.toSet();
}

bool _isEquatableOrSubclass(DartType? type) =>
_isEquatable(type) || _isSubclassOfEquatable(type);

bool _isSubclassOfEquatable(DartType? type) =>
type is InterfaceType && type.allSupertypes.any(_isEquatable);

bool _isEquatable(DartType? type) =>
type?.getDisplayString(withNullability: false) == 'Equatable';

bool _isEquatableMixin(DartType? type) =>
// ignore: deprecated_member_use
type?.element2 is MixinElement &&
type?.getDisplayString(withNullability: false) == 'EquatableMixin';

bool _isSubclassOfEquatableMixin(DartType? type) {
// ignore: deprecated_member_use
final element = type?.element2;

return element is ClassElement && element.mixins.any(_isEquatableMixin);
}
}

class _ListLiteralVisitor extends RecursiveAstVisitor<void> {
final literals = <ListLiteral>{};

@override
void visitListLiteral(ListLiteral node) {
super.visitListLiteral(node);

literals.add(node);
}
}

class _DeclarationInfo {
final Declaration node;
final String errorMessage;

const _DeclarationInfo(this.node, this.errorMessage);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
class SomePerson {
const SomePerson(this.name);

final String name;

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SomePerson &&
runtimeType == other.runtimeType &&
name == other.name;

@override
int get hashCode => name.hashCode;
}

class Person extends Equatable {
const Person(this.name);

final String name;

@override
List<Object> get props => [name];
}

class AnotherPerson extends Equatable {
const AnotherPerson(this.name, this.age);

final String name;

final int age;

@override
List<Object> get props => [name]; // LINT
}

class AndAnotherPerson extends Equatable {
const AndAnotherPerson(this.name, this.age, this.address);

final String name;

final int age;

final String address;

@override
List<Object> get props {
return [name]; // LINT
}
}

class AndAnotherPerson extends Equatable {
static final someProp = 'hello';

const AndAnotherPerson(this.name);

final String name;

@override
List<Object> get props => [name];
}

class SubPerson extends AndAnotherPerson {
const SubPerson(this.value, String name) : super();

final int value;

@override
List<Object> get props {
return super.props..addAll([]); // LINT
}
}

class EquatableDateTimeSubclass extends EquatableDateTime {
final int century;

EquatableDateTimeSubclass(
this.century,
int year, [
int month = 1,
int day = 1,
int hour = 0,
int minute = 0,
int second = 0,
int millisecond = 0,
int microsecond = 0,
]) : super(year, month, day, hour, minute, second, millisecond, microsecond);

@override
List<Object> get props => super.props..addAll([]); // LINT
}

class EquatableDateTime extends DateTime with EquatableMixin {
EquatableDateTime(
int year, [
int month = 1,
int day = 1,
int hour = 0,
int minute = 0,
int second = 0,
int millisecond = 0,
int microsecond = 0,
]) : super(year, month, day, hour, minute, second, millisecond, microsecond);

@override
List<Object> get props {
return [
year,
month,
day,
hour,
minute,
second,
millisecond,
microsecond,
// LINT
];
}
}

class Equatable {
List<Object> get props;
}

mixin EquatableMixin {
List<Object?> get props;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import 'package:dart_code_metrics/src/analyzers/lint_analyzer/models/severity.dart';
import 'package:dart_code_metrics/src/analyzers/lint_analyzer/rules/rules_list/list_all_equatable_fields/list_all_equatable_fields_rule.dart';
import 'package:test/test.dart';

import '../../../../../helpers/rule_test_helper.dart';

const _examplePath = 'list_all_equatable_fields/examples/example.dart';

void main() {
group('ListAllEquatableFieldsRule', () {
test('initialization', () async {
final unit = await RuleTestHelper.resolveFromFile(_examplePath);
final issues = ListAllEquatableFieldsRule().check(unit);

RuleTestHelper.verifyInitialization(
issues: issues,
ruleId: 'list-all-equatable-fields',
severity: Severity.warning,
);
});

test('reports about found issues', () async {
final unit = await RuleTestHelper.resolveFromFile(_examplePath);
final issues = ListAllEquatableFieldsRule().check(unit);

RuleTestHelper.verifyIssues(
issues: issues,
startLines: [34, 47, 69, 90, 106],
startColumns: [3, 3, 3, 3, 3],
locationTexts: [
'List<Object> get props => [name];',
'List<Object> get props {\n'
' return [name]; // LINT\n'
' }',
'List<Object> get props {\n'
' return super.props..addAll([]); // LINT\n'
' }',
'List<Object> get props => super.props..addAll([]);',
'List<Object> get props {\n'
' return [\n'
' year,\n'
' month,\n'
' day,\n'
' hour,\n'
' minute,\n'
' second,\n'
' millisecond,\n'
' microsecond,\n'
' // LINT\n'
' ];\n'
' }',
],
messages: [
'Missing declared class fields: age',
'Missing declared class fields: age, address',
'Missing declared class fields: value',
'Missing declared class fields: century',
'Missing declared class fields: isUtc, millisecondsSinceEpoch, microsecondsSinceEpoch, timeZoneName, timeZoneOffset, weekday',
],
);
});
});
}
Loading