diff --git a/lib/analysis_options.yaml b/lib/analysis_options.yaml index 21857abc..bfc02b97 100644 --- a/lib/analysis_options.yaml +++ b/lib/analysis_options.yaml @@ -50,7 +50,10 @@ custom_lint: - avoid_unnecessary_setstate - avoid_unnecessary_type_assertions - avoid_unrelated_type_assertions - - avoid_unused_parameters + - avoid_unused_parameters: + exclude_annotation: + - freezed + - unfreezed - avoid_debug_print_in_release - avoid_final_with_getter diff --git a/lib/src/common/parameters/excluded_annotations_list_parameter.dart b/lib/src/common/parameters/excluded_annotations_list_parameter.dart new file mode 100644 index 00000000..2f740d92 --- /dev/null +++ b/lib/src/common/parameters/excluded_annotations_list_parameter.dart @@ -0,0 +1,58 @@ +import 'package:analyzer/dart/ast/ast.dart'; + +/// A parameter model representing excluded annotations for linting. +/// It defines class-level annotations that indicate when class members +/// should be ignored during analysis. +class ExcludedAnnotationsListParameter { + /// The set of excluded annotation names. + final Set excludedAnnotations; + + /// A common parameter key for analysis_options.yaml + static const String excludeAnnotationKey = 'exclude_annotation'; + + /// Constructor for [ExcludedAnnotationsListParameter] class + ExcludedAnnotationsListParameter({ + required this.excludedAnnotations, + }); + + /// Method for creating from json data + factory ExcludedAnnotationsListParameter.fromJson(Map json) { + final raw = json[excludeAnnotationKey]; + if (raw is Iterable) { + return ExcludedAnnotationsListParameter( + excludedAnnotations: Set.from(raw.whereType()), + ); + } else if (raw is String) { + return ExcludedAnnotationsListParameter( + excludedAnnotations: {raw}, + ); + } + + return ExcludedAnnotationsListParameter( + excludedAnnotations: {}, + ); + } + + /// Returns whether the target node should be ignored during analysis because + /// its enclosing declaration is annotated with one of the excluded + /// annotations. + bool shouldIgnore(Declaration node) { + if (excludedAnnotations.isEmpty) return false; + + AstNode? current = node; + while (current != null) { + if (current is Declaration) { + final hasAnnotation = current.metadata.any((annotation) { + final name = annotation.name.name; + final simpleName = name.split('.').last; + return excludedAnnotations.contains(name) || + excludedAnnotations.contains(simpleName); + }); + if (hasAnnotation) return true; + } + current = current.parent; + } + + return false; + } +} diff --git a/lib/src/common/parameters/excluded_entities_list_parameter.dart b/lib/src/common/parameters/excluded_entities_list_parameter.dart index 34d4277c..c10b6793 100644 --- a/lib/src/common/parameters/excluded_entities_list_parameter.dart +++ b/lib/src/common/parameters/excluded_entities_list_parameter.dart @@ -22,9 +22,13 @@ class ExcludedEntitiesListParameter { /// Method for creating from json data factory ExcludedEntitiesListParameter.fromJson(Map json) { final raw = json['exclude_entity']; - if (raw is List) { + if (raw is Iterable) { return ExcludedEntitiesListParameter( - excludedEntityNames: Set.from(raw), + excludedEntityNames: Set.from(raw.whereType()), + ); + } else if (raw is String) { + return ExcludedEntitiesListParameter( + excludedEntityNames: {raw}, ); } diff --git a/lib/src/common/parameters/excluded_identifiers_list_parameter.dart b/lib/src/common/parameters/excluded_identifiers_list_parameter.dart index 526201a4..484c49cc 100644 --- a/lib/src/common/parameters/excluded_identifiers_list_parameter.dart +++ b/lib/src/common/parameters/excluded_identifiers_list_parameter.dart @@ -43,10 +43,13 @@ class ExcludedIdentifiersListParameter { factory ExcludedIdentifiersListParameter.defaultFromJson( Map json, ) { - final excludeList = - json[ExcludedIdentifiersListParameter.excludeParameterName] - as Iterable? ?? - []; + final raw = json[ExcludedIdentifiersListParameter.excludeParameterName]; + + final excludeList = switch (raw) { + Iterable() => raw, + Map() || String() => [raw], + _ => const [], + }; return ExcludedIdentifiersListParameter.fromJson( excludeList: excludeList, diff --git a/lib/src/lints/avoid_unused_parameters/avoid_unused_parameters_rule.dart b/lib/src/lints/avoid_unused_parameters/avoid_unused_parameters_rule.dart index ab9d7e9c..a1858429 100644 --- a/lib/src/lints/avoid_unused_parameters/avoid_unused_parameters_rule.dart +++ b/lib/src/lints/avoid_unused_parameters/avoid_unused_parameters_rule.dart @@ -11,6 +11,13 @@ import 'package:solid_lints/src/models/solid_lint_rule.dart'; /// Parameters whose names consist only of underscores are also ignored. /// Overridden methods and methods used as tear-offs are skipped. /// +/// ### Parameters +/// +/// #### exclude_annotation +/// A list of class annotations (such as `freezed` or `unfreezed`) whose +/// constructor parameters should be ignored by this rule (useful for code +/// generation). +/// /// {@template solid_lints.avoid_unused_parameters.example} /// ### Example /// diff --git a/lib/src/lints/avoid_unused_parameters/models/avoid_unused_parameters_parameters.dart b/lib/src/lints/avoid_unused_parameters/models/avoid_unused_parameters_parameters.dart index a6a20cbd..67e5d326 100644 --- a/lib/src/lints/avoid_unused_parameters/models/avoid_unused_parameters_parameters.dart +++ b/lib/src/lints/avoid_unused_parameters/models/avoid_unused_parameters_parameters.dart @@ -1,3 +1,4 @@ +import 'package:solid_lints/src/common/parameters/excluded_annotations_list_parameter.dart'; import 'package:solid_lints/src/common/parameters/excluded_identifiers_list_parameter.dart'; /// A data model class that represents the `avoid_unused_parameters` input @@ -6,15 +7,22 @@ class AvoidUnusedParametersParameters { /// A list of methods that should be excluded from the lint. final ExcludedIdentifiersListParameter exclude; + /// A list of annotations that should be ignored during class check. + final ExcludedAnnotationsListParameter excludeAnnotation; + /// Constructor for [AvoidUnusedParametersParameters] model. AvoidUnusedParametersParameters({ required this.exclude, + required this.excludeAnnotation, }); /// Empty [AvoidUnusedParametersParameters] model, excludes nothing. factory AvoidUnusedParametersParameters.empty() { return AvoidUnusedParametersParameters( exclude: ExcludedIdentifiersListParameter(exclude: []), + excludeAnnotation: ExcludedAnnotationsListParameter( + excludedAnnotations: {}, + ), ); } @@ -22,6 +30,7 @@ class AvoidUnusedParametersParameters { factory AvoidUnusedParametersParameters.fromJson(Map json) { return AvoidUnusedParametersParameters( exclude: ExcludedIdentifiersListParameter.defaultFromJson(json), + excludeAnnotation: ExcludedAnnotationsListParameter.fromJson(json), ); } } diff --git a/lib/src/lints/avoid_unused_parameters/visitors/avoid_unused_parameters_visitor.dart b/lib/src/lints/avoid_unused_parameters/visitors/avoid_unused_parameters_visitor.dart index e10fb310..31cb12ad 100644 --- a/lib/src/lints/avoid_unused_parameters/visitors/avoid_unused_parameters_visitor.dart +++ b/lib/src/lints/avoid_unused_parameters/visitors/avoid_unused_parameters_visitor.dart @@ -45,8 +45,10 @@ class AvoidUnusedParametersVisitor extends RecursiveAstVisitor { final parent = node.parent; final parameters = node.parameters; - if (parent is ClassDeclaration && parent.abstractKeyword != null || + if ((parent is ClassDeclaration && parent.abstractKeyword != null) || node.externalKeyword != null || + node.redirectedConstructor != null || + _hasExcludedAnnotationClass(node) || parameters.parameters.isEmpty) { return; } @@ -71,7 +73,7 @@ class AvoidUnusedParametersVisitor extends RecursiveAstVisitor { final parent = node.parent; final parameters = node.parameters; - if (parent is ClassDeclaration && parent.abstractKeyword != null || + if ((parent is ClassDeclaration && parent.abstractKeyword != null) || node.isAbstract || node.externalKeyword != null || (parameters == null || parameters.parameters.isEmpty)) { @@ -113,6 +115,9 @@ class AvoidUnusedParametersVisitor extends RecursiveAstVisitor { bool _isExcluded(Declaration node) => _parameters.exclude.shouldIgnore(node); + bool _hasExcludedAnnotationClass(ConstructorDeclaration node) => + _parameters.excludeAnnotation.shouldIgnore(node); + Iterable _filterOutUnderscoresAndNamed( AstNode body, Iterable parameters, diff --git a/test/lints/avoid_unused_parameters/avoid_unused_parameters_rule_test.dart b/test/lints/avoid_unused_parameters/avoid_unused_parameters_rule_test.dart index 440ee2e4..ab627276 100644 --- a/test/lints/avoid_unused_parameters/avoid_unused_parameters_rule_test.dart +++ b/test/lints/avoid_unused_parameters/avoid_unused_parameters_rule_test.dart @@ -50,6 +50,7 @@ class Placeholder extends StatelessWidget { 'SimpleClassName', 'exclude', ], + 'exclude_annotation': ['freezed'], }, ); @@ -381,4 +382,67 @@ class SimpleClassName { [lint(38, 8), lint(118, 8)], ); } + + Future + test_does_not_report_on_redirecting_factory_constructors() async { + await assertNoDiagnostics(r''' +class RedirectingClass { + const factory RedirectingClass({required int parameter}) = _RedirectingClass; +} + +class _RedirectingClass implements RedirectingClass { + final int parameter; + const _RedirectingClass({required this.parameter}); +} +'''); + } + + Future test_does_not_report_on_freezed_classes() async { + await assertNoDiagnostics(r''' +const freezed = Object(); + +@freezed +class Test { + const Test(int unusedParameter); +} +'''); + } + + Future + test_does_not_report_on_excluded_declaration_and_annotation_single_string() async { + final FakeAnalysisOptionsLoader fakeAnalysisOptionsLoader = + FakeAnalysisOptionsLoader( + ruleOptions: { + 'exclude': 'excludeMethod', + 'exclude_annotation': 'freezed', + }, + ); + + rule = AvoidUnusedParametersRule( + analysisOptionsLoader: fakeAnalysisOptionsLoader, + ); + + await assertNoDiagnostics(r''' +const freezed = Object(); + +@freezed +class Test { + const Test(int unusedParameter); +} + +class Meta { + const Meta(); +} +const meta = Meta(); + +@meta.freezed +class TestWithPrefix { + const TestWithPrefix(int unusedParameter); +} + +void excludeMethod(String s) { + return; +} +'''); + } } diff --git a/test/src/common/parameters/parameters_parsing_test.dart b/test/src/common/parameters/parameters_parsing_test.dart new file mode 100644 index 00000000..f64d303a --- /dev/null +++ b/test/src/common/parameters/parameters_parsing_test.dart @@ -0,0 +1,87 @@ +import 'package:solid_lints/src/common/parameters/excluded_annotations_list_parameter.dart'; +import 'package:solid_lints/src/common/parameters/excluded_entities_list_parameter.dart'; +import 'package:solid_lints/src/common/parameters/excluded_identifiers_list_parameter.dart'; +import 'package:test/test.dart'; + +void main() { + group('ExcludedAnnotationsListParameter', () { + test('parses list of strings', () { + final param = ExcludedAnnotationsListParameter.fromJson({ + 'exclude_annotation': ['MyAnnotation1', 'MyAnnotation2'], + }); + expect(param.excludedAnnotations, containsAll(['MyAnnotation1', 'MyAnnotation2'])); + }); + + test('parses single string', () { + final param = ExcludedAnnotationsListParameter.fromJson({ + 'exclude_annotation': 'MyAnnotation', + }); + expect(param.excludedAnnotations, contains('MyAnnotation')); + expect(param.excludedAnnotations.length, 1); + }); + + test('parses empty or invalid input', () { + final param = ExcludedAnnotationsListParameter.fromJson({}); + expect(param.excludedAnnotations, isEmpty); + }); + }); + + group('ExcludedEntitiesListParameter', () { + test('parses list of strings', () { + final param = ExcludedEntitiesListParameter.fromJson({ + 'exclude_entity': ['mixin', 'enum'], + }); + expect(param.excludedEntityNames, containsAll(['mixin', 'enum'])); + }); + + test('parses single string', () { + final param = ExcludedEntitiesListParameter.fromJson({ + 'exclude_entity': 'mixin', + }); + expect(param.excludedEntityNames, contains('mixin')); + expect(param.excludedEntityNames.length, 1); + }); + + test('parses empty or invalid input', () { + final param = ExcludedEntitiesListParameter.fromJson({}); + expect(param.excludedEntityNames, isEmpty); + }); + }); + + group('ExcludedIdentifiersListParameter', () { + test('parses list of strings and maps', () { + final param = ExcludedIdentifiersListParameter.defaultFromJson({ + 'exclude': [ + 'my_function', + {'class_name': 'MyClass', 'method_name': 'my_method'}, + ], + }); + expect(param.exclude.length, 2); + expect(param.exclude[0].declarationName, 'my_function'); + expect(param.exclude[1].className, 'MyClass'); + expect(param.exclude[1].methodName, 'my_method'); + }); + + test('parses single string', () { + final param = ExcludedIdentifiersListParameter.defaultFromJson({ + 'exclude': 'my_function', + }); + expect(param.exclude.length, 1); + expect(param.exclude[0].declarationName, 'my_function'); + }); + + test('parses single map', () { + final param = ExcludedIdentifiersListParameter.defaultFromJson({ + 'exclude': {'class_name': 'MyClass', 'method_name': 'my_method'}, + }); + expect(param.exclude.length, 1); + expect(param.exclude[0].className, 'MyClass'); + expect(param.exclude[0].methodName, 'my_method'); + }); + + test('parses empty or invalid input', () { + final param = ExcludedIdentifiersListParameter.defaultFromJson({}); + expect(param.exclude, isEmpty); + }); + }); +}