diff --git a/CHANGELOG.md b/CHANGELOG.md index cbcaf77b44..e2d3139837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unresolved + +* Add static code diagnostic `prefer-single-widget-per-file`. + ## 4.1.0 * Add better monorepos support for CLI diff --git a/README.md b/README.md index 22d7aa6463..7d12da2860 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,7 @@ Rules configuration is [described here](#configuring-a-rules-entry). - [avoid-unnecessary-setstate](https://github.com/dart-code-checker/dart-code-metrics/blob/master/doc/rules/avoid-unnecessary-setstate.md) - [avoid-wrapping-in-padding](https://github.com/dart-code-checker/dart-code-metrics/blob/master/doc/rules/avoid-wrapping-in-padding.md) - [prefer-extracting-callbacks](https://github.com/dart-code-checker/dart-code-metrics/blob/master/doc/rules/prefer-extracting-callbacks.md)   [![Configurable](https://img.shields.io/badge/-configurable-informational)](https://github.com/dart-code-checker/dart-code-metrics/blob/master/doc/rules/prefer-extracting-callbacks.md#config-example) +- [prefer-single-widget-per-file](https://github.com/dart-code-checker/dart-code-metrics/blob/master/doc/rules/prefer-single-widget-per-file.md)   [![Configurable](https://img.shields.io/badge/-configurable-informational)](https://github.com/dart-code-checker/dart-code-metrics/blob/master/doc/rules/prefer-single-widget-per-file.md#config-example) ### Intl specific diff --git a/doc/rules/prefer-single-widget-per-file.md b/doc/rules/prefer-single-widget-per-file.md new file mode 100644 index 0000000000..bf9d1421d5 --- /dev/null +++ b/doc/rules/prefer-single-widget-per-file.md @@ -0,0 +1,92 @@ +# Prefer single widget per file + +![Configurable](https://img.shields.io/badge/-configurable-informational) + +## Rule id + +prefer-single-widget-per-file + +## Description + +Warns when a file contains more than a single widget. + +Ensures that files have a single responsibility so that each widget exists in its own file. + +### Config example + +```yaml +dart_code_metrics: + ... + rules: + ... + - prefer-single-widget-per-file: + ignore-private-widgets: true +``` + +### Example + +Bad: + +some_widgets.dart + +```dart +class SomeWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { + ... + } +} + +// LINT +class SomeOtherWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { + ... + } +} + +// LINT +class _SomeOtherWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { + ... + } +} + +// LINT +class SomeStatefulWidget extends StatefulWidget { + @override + _SomeStatefulWidgetState createState() => _someStatefulWidgetState(); +} + +class _SomeStatefulWidgetState extends State { + @override + Widget build(BuildContext context) { + ... + } +} +``` + +Good: + +some_widget.dart + +```dart +class SomeWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { + ... + } +} +``` + +some_other_widget.dart + +```dart +class SomeOtherWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { + ... + } +} +``` diff --git a/lib/src/analyzers/lint_analyzer/rules/rules_factory.dart b/lib/src/analyzers/lint_analyzer/rules/rules_factory.dart index 2cc042fb91..3036f970a6 100644 --- a/lib/src/analyzers/lint_analyzer/rules/rules_factory.dart +++ b/lib/src/analyzers/lint_analyzer/rules/rules_factory.dart @@ -23,6 +23,7 @@ import 'rules_list/prefer_conditional_expressions/prefer_conditional_expressions import 'rules_list/prefer_extracting_callbacks/prefer_extracting_callbacks.dart'; import 'rules_list/prefer_intl_name/prefer_intl_name.dart'; import 'rules_list/prefer_on_push_cd_strategy/prefer_on_push_cd_strategy.dart'; +import 'rules_list/prefer_single_widget_per_file/prefer_single_widget_per_file.dart'; import 'rules_list/prefer_trailing_comma/prefer_trailing_comma.dart'; import 'rules_list/provide_correct_intl_args/provide_correct_intl_args.dart'; @@ -64,6 +65,8 @@ final _implementedRules = )>{ PreferIntlNameRule.ruleId: (config) => PreferIntlNameRule(config), PreferOnPushCdStrategyRule.ruleId: (config) => PreferOnPushCdStrategyRule(config), + PreferSingleWidgetPerFileRule.ruleId: (config) => + PreferSingleWidgetPerFileRule(config), PreferTrailingCommaRule.ruleId: (config) => PreferTrailingCommaRule(config), ProvideCorrectIntlArgsRule.ruleId: (config) => ProvideCorrectIntlArgsRule(config), diff --git a/lib/src/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/config_parser.dart b/lib/src/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/config_parser.dart new file mode 100644 index 0000000000..ea1ed12d41 --- /dev/null +++ b/lib/src/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/config_parser.dart @@ -0,0 +1,8 @@ +part of 'prefer_single_widget_per_file.dart'; + +class _ConfigParser { + static const _ignorePrivateWidgetsName = 'ignore-private-widgets'; + + static bool parseIgnorePrivateWidgets(Map config) => + config[_ignorePrivateWidgetsName] == true; +} diff --git a/lib/src/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/prefer_single_widget_per_file.dart b/lib/src/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/prefer_single_widget_per_file.dart new file mode 100644 index 0000000000..ebc09b378a --- /dev/null +++ b/lib/src/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/prefer_single_widget_per_file.dart @@ -0,0 +1,54 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../../../../../utils/node_utils.dart'; +import '../../../models/internal_resolved_unit_result.dart'; +import '../../../models/issue.dart'; +import '../../../models/severity.dart'; +import '../../flutter_rule_utils.dart'; +import '../../models/rule.dart'; +import '../../models/rule_documentation.dart'; +import '../../rule_utils.dart'; + +part 'config_parser.dart'; +part 'visitor.dart'; + +class PreferSingleWidgetPerFileRule extends Rule { + static const String ruleId = 'prefer-single-widget-per-file'; + + static const _warningMessage = 'Only a single widget per file is allowed.'; + + final bool _ignorePrivateWidgets; + + PreferSingleWidgetPerFileRule([Map config = const {}]) + : _ignorePrivateWidgets = _ConfigParser.parseIgnorePrivateWidgets(config), + super( + id: ruleId, + documentation: const RuleDocumentation( + name: 'Prefer a single widget per file', + brief: 'Warns when a file contains more than a single widget.', + ), + severity: readSeverity(config, Severity.style), + excludes: readExcludes(config), + ); + + @override + Iterable check(InternalResolvedUnitResult source) { + final visitor = _Visitor(ignorePrivateWidgets: _ignorePrivateWidgets); + + source.unit.visitChildren(visitor); + + return visitor.nodes + .map( + (node) => createIssue( + rule: this, + location: nodeLocation( + node: node, + source: source, + ), + message: _warningMessage, + ), + ) + .toList(growable: false); + } +} diff --git a/lib/src/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/visitor.dart b/lib/src/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/visitor.dart new file mode 100644 index 0000000000..0edf5d0f64 --- /dev/null +++ b/lib/src/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/visitor.dart @@ -0,0 +1,24 @@ +part of 'prefer_single_widget_per_file.dart'; + +class _Visitor extends SimpleAstVisitor { + final bool _ignorePrivateWidgets; + + final _nodes = []; + + _Visitor({required bool ignorePrivateWidgets}) + : _ignorePrivateWidgets = ignorePrivateWidgets; + + Iterable get nodes => + _nodes.length > 1 ? _nodes.skip(1) : []; + + @override + void visitClassDeclaration(ClassDeclaration node) { + super.visitClassDeclaration(node); + + final classType = node.extendsClause?.superclass.type; + if (isWidgetOrSubclass(classType) && + (!_ignorePrivateWidgets || !Identifier.isPrivateName(node.name.name))) { + _nodes.add(node); + } + } +} diff --git a/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/examples/correct_statefull_widget_example.dart b/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/examples/correct_statefull_widget_example.dart new file mode 100644 index 0000000000..7f27b8ccd3 --- /dev/null +++ b/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/examples/correct_statefull_widget_example.dart @@ -0,0 +1,13 @@ +import 'flutter_defines.dart'; + +class SomeStatefulWidget extends StatefulWidget { + @override + _someStatefulWidgetState createState() => _someStatefulWidgetState(); +} + +class _SomeStatefulWidgetState extends State { + @override + Widget build(BuildContext context) { +// ... + } +} diff --git a/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/examples/correct_stateless_widget_example.dart b/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/examples/correct_stateless_widget_example.dart new file mode 100644 index 0000000000..d6250109ab --- /dev/null +++ b/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/examples/correct_stateless_widget_example.dart @@ -0,0 +1,8 @@ +import 'flutter_defines.dart'; + +class SomeWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { +// ... + } +} diff --git a/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/examples/flutter_defines.dart b/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/examples/flutter_defines.dart new file mode 100644 index 0000000000..5d6ff7d8f2 --- /dev/null +++ b/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/examples/flutter_defines.dart @@ -0,0 +1,5 @@ +class Widget {} + +class StatefulWidget extends Widget {} + +class StatelessWidget extends Widget {} diff --git a/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/examples/incorrect_example.dart b/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/examples/incorrect_example.dart new file mode 100644 index 0000000000..5a41bb3e61 --- /dev/null +++ b/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/examples/incorrect_example.dart @@ -0,0 +1,37 @@ +import 'flutter_defines.dart'; + +class SomeWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { +// ... + } +} + +// LINT +class SomeOtherWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { +// ... + } +} + +// LINT +class _SomeOtherWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { +// ... + } +} + +// LINT +class SomeStatefulWidget extends StatefulWidget { + @override + _someStatefulWidgetState createState() => _someStatefulWidgetState(); +} + +class _SomeStatefulWidgetState extends State { + @override + Widget build(BuildContext context) { +// ... + } +} diff --git a/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/examples/multi_widgets_example.dart b/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/examples/multi_widgets_example.dart new file mode 100644 index 0000000000..cfb47fd9f1 --- /dev/null +++ b/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/examples/multi_widgets_example.dart @@ -0,0 +1,27 @@ +import 'flutter_defines.dart'; + +class ExampleWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { +// ... + } +} + +class _PrivateWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { +// ... + } +} + +class _AnotherPrivateWidget extends StatefulWidget { + @override + _SomeStatefulWidgetState createState() => _SomeStatefulWidgetState(); +} + +class _SomeStatefulWidgetState extends State { + @override + Widget build(BuildContext context) { +// ... + } +} diff --git a/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/prefer_single_widget_per_file_test.dart b/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/prefer_single_widget_per_file_test.dart new file mode 100644 index 0000000000..7954e3288a --- /dev/null +++ b/test/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/prefer_single_widget_per_file_test.dart @@ -0,0 +1,103 @@ +@TestOn('vm') +import 'package:dart_code_metrics/src/analyzers/lint_analyzer/models/severity.dart'; +import 'package:dart_code_metrics/src/analyzers/lint_analyzer/rules/rules_list/prefer_single_widget_per_file/prefer_single_widget_per_file.dart'; +import 'package:test/test.dart'; + +import '../../../../../helpers/rule_test_helper.dart'; + +const _correctStatefullWidgetExamplePath = + 'prefer_single_widget_per_file/examples/correct_statefull_widget_example.dart'; +const _correctStatelessWidgetExamplePath = + 'prefer_single_widget_per_file/examples/correct_stateless_widget_example.dart'; +const _incorrectExamplePath = + 'prefer_single_widget_per_file/examples/incorrect_example.dart'; +const _multiWidgetsExamplePath = + 'prefer_single_widget_per_file/examples/multi_widgets_example.dart'; + +void main() { + group('PreferSingleWidgetPerFileRule', () { + test('initialization', () async { + final unit = await RuleTestHelper.resolveFromFile( + _correctStatefullWidgetExamplePath, + ); + final issues = PreferSingleWidgetPerFileRule().check(unit); + + RuleTestHelper.verifyInitialization( + issues: issues, + ruleId: 'prefer-single-widget-per-file', + severity: Severity.style, + ); + }); + + test('with default config reports about found issues', () async { + final unit = await RuleTestHelper.resolveFromFile(_incorrectExamplePath); + final issues = PreferSingleWidgetPerFileRule().check(unit); + + RuleTestHelper.verifyIssues( + issues: issues, + startOffsets: [151, 275, 400], + startLines: [11, 19, 27], + startColumns: [1, 1, 1], + endOffsets: [265, 390, 535], + messages: [ + 'Only a single widget per file is allowed.', + 'Only a single widget per file is allowed.', + 'Only a single widget per file is allowed.', + ], + ); + }); + + group('with default config reports no issues for', () { + test('single statefull widget', () async { + final statefullWidgetUnit = await RuleTestHelper.resolveFromFile( + _correctStatefullWidgetExamplePath, + ); + final issues = + PreferSingleWidgetPerFileRule().check(statefullWidgetUnit); + + RuleTestHelper.verifyNoIssues(issues); + }); + test('single stateless widget', () async { + final statelessWidgetUnit = await RuleTestHelper.resolveFromFile( + _correctStatelessWidgetExamplePath, + ); + final issues = + PreferSingleWidgetPerFileRule().check(statelessWidgetUnit); + + RuleTestHelper.verifyNoIssues(issues); + }); + }); + + group('analyze multi widget file', () { + test('with default config', () async { + final multiWidgetsUnit = await RuleTestHelper.resolveFromFile( + _multiWidgetsExamplePath, + ); + final issues = PreferSingleWidgetPerFileRule().check(multiWidgetsUnit); + + RuleTestHelper.verifyIssues( + issues: issues, + startOffsets: [146, 261], + startLines: [10, 17], + startColumns: [1, 1], + endOffsets: [259, 399], + messages: [ + 'Only a single widget per file is allowed.', + 'Only a single widget per file is allowed.', + ], + ); + }); + + test('with custom config', () async { + final multiWidgetsUnit = await RuleTestHelper.resolveFromFile( + _multiWidgetsExamplePath, + ); + final issues = + PreferSingleWidgetPerFileRule({'ignore-private-widgets': true}) + .check(multiWidgetsUnit); + + RuleTestHelper.verifyNoIssues(issues); + }); + }); + }); +}