diff --git a/doc/docusaurus/docs/1_rulesets/test.md b/doc/docusaurus/docs/1_rulesets/test.md index 4a7073d0..cb5665aa 100644 --- a/doc/docusaurus/docs/1_rulesets/test.md +++ b/doc/docusaurus/docs/1_rulesets/test.md @@ -83,4 +83,15 @@ Since we're not using the `function_lines_of_code` rule, the `main()` function i State: **Disabled**. -It's acceptable to include stubs or other helper classes into the test file. +Allows declaring stubs and helper classes whose names do not match the test +file name. + +## avoid_multiple_declarations_per_file + +State: **Disabled**. + +Allows keeping test-specific declarations (such as mocks, fakes, and fixtures) +together within the test file so tests remain self-contained without creating +extra files. + + diff --git a/lib/analysis_options.yaml b/lib/analysis_options.yaml index c87b4ca8..d2faab6e 100644 --- a/lib/analysis_options.yaml +++ b/lib/analysis_options.yaml @@ -46,6 +46,9 @@ solid_lints: allow_initialized: true ignored_types: - AnimationController + avoid_multiple_declarations_per_file: + ignored_types: + - State avoid_non_null_assertion: true avoid_returning_widgets: true avoid_similar_names: true diff --git a/lib/analysis_options_test.yaml b/lib/analysis_options_test.yaml index 10d57108..4a9c6a8c 100644 --- a/lib/analysis_options_test.yaml +++ b/lib/analysis_options_test.yaml @@ -51,6 +51,7 @@ solid_lints: avoid_late_keyword: false # It's acceptable to include stubs or other helper classes into the test file. prefer_match_file_name: false + avoid_multiple_declarations_per_file: false # Test files inherently contain a lot of duplicated setups, teardowns, and mock structures. # Running duplicate code detection on them usually produces a lot of noise. avoid_duplicate_code: false diff --git a/lib/main.dart b/lib/main.dart index 9fad74cc..3adb7b30 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:solid_lints/src/lints/avoid_duplicate_code/avoid_duplicate_code_ import 'package:solid_lints/src/lints/avoid_final_with_getter/avoid_final_with_getter_rule.dart'; import 'package:solid_lints/src/lints/avoid_global_state/avoid_global_state_rule.dart'; import 'package:solid_lints/src/lints/avoid_late_keyword/avoid_late_keyword_rule.dart'; +import 'package:solid_lints/src/lints/avoid_multiple_declarations_per_file/avoid_multiple_declarations_per_file_rule.dart'; import 'package:solid_lints/src/lints/avoid_non_null_assertion/avoid_non_null_assertion_rule.dart'; import 'package:solid_lints/src/lints/avoid_returning_widgets/avoid_returning_widgets_rule.dart'; import 'package:solid_lints/src/lints/avoid_similar_names/avoid_similar_names_rule.dart'; @@ -65,6 +66,9 @@ class SolidLintsPlugin extends Plugin { AvoidFinalWithGetterRule(), AvoidGlobalStateRule(), AvoidLateKeywordRule(analysisOptionsLoader: analysisLoader), + AvoidMultipleDeclarationsPerFileRule( + analysisOptionsLoader: analysisLoader, + ), AvoidNonNullAssertionRule(analysisOptionsLoader: analysisLoader), AvoidReturningWidgetsRule(analysisOptionsLoader: analysisLoader), AvoidSimilarNamesRule(), diff --git a/lib/src/lints/avoid_multiple_declarations_per_file/avoid_multiple_declarations_per_file_rule.dart b/lib/src/lints/avoid_multiple_declarations_per_file/avoid_multiple_declarations_per_file_rule.dart new file mode 100644 index 00000000..5890371c --- /dev/null +++ b/lib/src/lints/avoid_multiple_declarations_per_file/avoid_multiple_declarations_per_file_rule.dart @@ -0,0 +1,99 @@ +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; +import 'package:analyzer/error/error.dart'; +import 'package:solid_lints/src/lints/avoid_multiple_declarations_per_file/models/avoid_multiple_declarations_per_file_parameters.dart'; +import 'package:solid_lints/src/lints/avoid_multiple_declarations_per_file/visitors/avoid_multiple_declarations_per_file_visitor.dart'; +import 'package:solid_lints/src/models/solid_lint_rule.dart'; + +/// Warns about multiple nominal declarations (classes, enums, mixins, +/// extensions, extension types) in a single file. +/// +/// Each declaration should ideally live in its own dedicated file matching its +/// name, improving navigation, test structure, and Single Responsibility +/// Principle. +/// +/// ### Example config: +/// +/// ```yaml +/// solid_lints: +/// diagnostics: +/// avoid_multiple_declarations_per_file: +/// ignored_types: +/// - State +/// exclude_entity: +/// - extension_type +/// - enum +/// allow_private: true +/// maximum_loc: 20 +/// ``` +/// +/// ### Example +/// +/// #### BAD: +/// +/// ```dart +/// class Parent extends StatelessWidget {} +/// +/// class _Child extends StatelessWidget {} // LINT +/// ``` +/// +/// ```dart +/// class User {} +/// +/// enum UserRole { admin, regular } // LINT +/// ``` +/// +/// #### GOOD: +/// +/// ```dart +/// // parent.dart +/// import 'child.dart'; +/// +/// class Parent extends StatelessWidget {} +/// +/// // child.dart +/// class Child extends StatelessWidget {} +/// ``` +class AvoidMultipleDeclarationsPerFileRule + extends SolidLintRule { + /// The lint rule name. Must be public to generate docs. + static const lintName = 'avoid_multiple_declarations_per_file'; + + static const _code = LintCode( + lintName, + "Avoid declaring multiple declarations in a single file. Extract '{0}' " + 'into its own file.', + ); + + @override + DiagnosticCode get diagnosticCode => _code; + + /// Creates a new instance of [AvoidMultipleDeclarationsPerFileRule]. + AvoidMultipleDeclarationsPerFileRule({ + required super.analysisOptionsLoader, + }) : super.withParameters( + name: lintName, + description: + 'Warns about declaring multiple declarations in a single file.', + parametersParser: AvoidMultipleDeclarationsPerFileParameters.fromJson, + ); + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + super.registerNodeProcessors(registry, context); + + final parameters = + getParametersForContext(context) ?? + AvoidMultipleDeclarationsPerFileParameters.empty(); + + final visitor = AvoidMultipleDeclarationsPerFileVisitor( + rule: this, + parameters: parameters, + ); + + registry.addCompilationUnit(this, visitor); + } +} diff --git a/lib/src/lints/avoid_multiple_declarations_per_file/models/avoid_multiple_declarations_per_file_parameters.dart b/lib/src/lints/avoid_multiple_declarations_per_file/models/avoid_multiple_declarations_per_file_parameters.dart new file mode 100644 index 00000000..89d16ac7 --- /dev/null +++ b/lib/src/lints/avoid_multiple_declarations_per_file/models/avoid_multiple_declarations_per_file_parameters.dart @@ -0,0 +1,122 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:solid_lints/src/common/parameters/excluded_entities_list_parameter.dart'; +import 'package:solid_lints/src/common/parameters/ignored_types_list_parameter.dart'; +import 'package:solid_lints/src/utils/node_utils.dart'; + +/// A data model class that represents the "avoid multiple declarations per +/// file" input parameters. +class AvoidMultipleDeclarationsPerFileParameters { + /// Types and their subtypes that should be ignored as violations. + /// + /// Example: + /// + /// ```yaml + /// solid_lints: + /// diagnostics: + /// avoid_multiple_declarations_per_file: + /// ignored_types: + /// - State + /// ``` + /// + /// ```dart + /// class MyWidget extends StatefulWidget {} + /// + /// class _MyWidgetState extends State {} // OK + /// ``` + final IgnoredTypesListParameter ignoredTypes; + + /// AST entities (e.g. enum, mixin, extension, extension_type) to exclude. + /// + /// Example: + /// + /// ```yaml + /// solid_lints: + /// diagnostics: + /// avoid_multiple_declarations_per_file: + /// exclude_entity: + /// - extension_type + /// - enum + /// ``` + /// + /// ```dart + /// class User {} + /// + /// extension type UserId(int id) {} // OK + /// + /// enum UserRole { admin, regular } // OK + /// ``` + final ExcludedEntitiesListParameter excludeEntity; + + /// Whether private declarations (e.g. prefixed with `_`) are allowed. + /// + /// Example: + /// + /// ```yaml + /// solid_lints: + /// diagnostics: + /// avoid_multiple_declarations_per_file: + /// allow_private: true + /// ``` + /// + /// ```dart + /// class PublicClass {} + /// + /// class _PrivateHelper {} // OK + /// ``` + final bool allowPrivate; + + /// Maximum lines of code allowed for secondary declarations, excluding + /// blank lines and comments. + /// + /// Example: + /// + /// ```yaml + /// solid_lints: + /// diagnostics: + /// avoid_multiple_declarations_per_file: + /// maximum_loc: 10 + /// ``` + /// + /// ```dart + /// class MainClass {} + /// + /// class SmallHelper { + /// // Comments and blank lines are excluded from LOC calculation. + /// void run() {} + /// } // OK if LOC <= 10 + /// ``` + final int? maximumLoc; + + /// Constructor for [AvoidMultipleDeclarationsPerFileParameters]. + const AvoidMultipleDeclarationsPerFileParameters({ + required this.ignoredTypes, + required this.excludeEntity, + this.allowPrivate = false, + this.maximumLoc, + }); + + /// Empty parameters model with default values. + factory AvoidMultipleDeclarationsPerFileParameters.empty() => + AvoidMultipleDeclarationsPerFileParameters( + ignoredTypes: IgnoredTypesListParameter.empty(), + excludeEntity: ExcludedEntitiesListParameter( + excludedEntityNames: {}, + ), + ); + + /// Method for creating parameters from JSON configuration. + factory AvoidMultipleDeclarationsPerFileParameters.fromJson( + Map json, + ) => AvoidMultipleDeclarationsPerFileParameters( + ignoredTypes: IgnoredTypesListParameter.fromJson(json), + excludeEntity: ExcludedEntitiesListParameter.fromJson(json), + allowPrivate: json['allow_private'] as bool? ?? false, + maximumLoc: json['maximum_loc'] as int?, + ); + + /// Returns `true` if the given [node] should be ignored based on + /// [excludeEntity] or [ignoredTypes]. + bool shouldIgnore(CompilationUnitMember node) => + excludeEntity.shouldIgnoreEntity(node) || + ignoredTypes.shouldIgnore(node.declaredType); +} diff --git a/lib/src/lints/avoid_multiple_declarations_per_file/visitors/avoid_multiple_declarations_per_file_visitor.dart b/lib/src/lints/avoid_multiple_declarations_per_file/visitors/avoid_multiple_declarations_per_file_visitor.dart new file mode 100644 index 00000000..5de512da --- /dev/null +++ b/lib/src/lints/avoid_multiple_declarations_per_file/visitors/avoid_multiple_declarations_per_file_visitor.dart @@ -0,0 +1,78 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/source/line_info.dart'; +import 'package:collection/collection.dart'; +import 'package:solid_lints/src/lints/avoid_multiple_declarations_per_file/avoid_multiple_declarations_per_file_rule.dart'; +import 'package:solid_lints/src/lints/avoid_multiple_declarations_per_file/models/avoid_multiple_declarations_per_file_parameters.dart'; +import 'package:solid_lints/src/utils/file_name_matcher.dart'; +import 'package:solid_lints/src/utils/node_utils.dart'; + +/// The AST visitor that reports multiple top-level nominal declarations in a +/// single file. +class AvoidMultipleDeclarationsPerFileVisitor extends SimpleAstVisitor { + /// The rule that instantiated this visitor. + final AvoidMultipleDeclarationsPerFileRule rule; + + /// Configuration parameters for this rule. + final AvoidMultipleDeclarationsPerFileParameters parameters; + + /// Creates a new instance of [AvoidMultipleDeclarationsPerFileVisitor]. + AvoidMultipleDeclarationsPerFileVisitor({ + required this.rule, + required this.parameters, + }); + + @override + void visitCompilationUnit(CompilationUnit node) { + final declarations = node.declarations + .where((d) => d.isNominalDeclaration) + .whereNot(parameters.shouldIgnore) + .toList(); + + if (declarations.length <= 1) return; + + final primary = _findPrimaryDeclaration( + declarations, + node.declaredFragment?.source.fullName, + ); + + final violations = declarations + .where((c) => c != primary) + .whereNot((c) => parameters.allowPrivate && c.isPrivate) + .whereNot((c) => _isSubclassOfSealed(c, node.sealedClassElements)) + .whereNot((c) => _isUnderMaxLoc(c, node.lineInfo)); + + for (final violation in violations) { + rule.reportAtToken( + violation.declarationToken ?? violation.beginToken, + arguments: [violation.displayName], + ); + } + } + + CompilationUnitMember _findPrimaryDeclaration( + List declarations, + String? filePath, + ) { + final targetName = FileNameMatcher.normalizePath(filePath); + + return declarations.firstWhereOrNull( + (d) => + targetName.isNotEmpty && + targetName == FileNameMatcher.normalizeIdentifier(d.displayName), + ) ?? + declarations.firstWhereOrNull((d) => !d.isPrivate) ?? + declarations.first; + } + + bool _isSubclassOfSealed( + CompilationUnitMember candidate, + Set sealedElements, + ) => candidate.allSupertypeElements.any(sealedElements.contains); + + bool _isUnderMaxLoc(CompilationUnitMember node, LineInfo lineInfo) { + final maxLoc = parameters.maximumLoc ?? 0; + return maxLoc > 0 && node.calculateLoc(lineInfo) <= maxLoc; + } +} diff --git a/lib/src/lints/prefer_match_file_name/visitors/prefer_match_file_name_visitor.dart b/lib/src/lints/prefer_match_file_name/visitors/prefer_match_file_name_visitor.dart index 2fa9b428..c6998faa 100644 --- a/lib/src/lints/prefer_match_file_name/visitors/prefer_match_file_name_visitor.dart +++ b/lib/src/lints/prefer_match_file_name/visitors/prefer_match_file_name_visitor.dart @@ -3,17 +3,15 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/error/error.dart'; import 'package:collection/collection.dart'; -import 'package:path/path.dart' as p; import 'package:solid_lints/src/common/parameters/excluded_entities_list_parameter.dart'; import 'package:solid_lints/src/lints/prefer_match_file_name/models/declaration_token_info.dart'; +import 'package:solid_lints/src/utils/file_name_matcher.dart'; import 'package:solid_lints/src/utils/iterable_utils.dart'; import 'package:solid_lints/src/utils/node_utils.dart'; /// The AST visitor that will collect all Class, Enum, Extension, Mixin and /// Extension Type declarations class PreferMatchFileNameVisitor extends SimpleAstVisitor { - static final _onlySymbolsRegex = RegExp('[^a-zA-Z0-9]'); - /// The diagnostic code to report final DiagnosticCode diagnosticCode; @@ -62,11 +60,10 @@ class PreferMatchFileNameVisitor extends SimpleAstVisitor { final firstDeclaration = declarations.first; final fullName = context.currentUnit?.file.path; - if (fullName != null && - _doNormalizedNamesMatch( - fullName, - firstDeclaration.token.lexeme, - )) { + if (FileNameMatcher.matches( + filePath: fullName, + identifierName: firstDeclaration.token.lexeme, + )) { return; } @@ -81,17 +78,4 @@ class PreferMatchFileNameVisitor extends SimpleAstVisitor { arguments: [nodeType], ); } - - bool _doNormalizedNamesMatch(String path, String identifierName) { - final fileName = _normalizePath(path); - final dartIdentifier = _normalizeDartIdentifierName(identifierName); - - return fileName == dartIdentifier; - } - - String _normalizePath(String s) => - _normalizeDartIdentifierName(p.basename(s).split('.').first); - - String _normalizeDartIdentifierName(String s) => - s.replaceAll(_onlySymbolsRegex, '').toLowerCase(); } diff --git a/lib/src/utils/file_name_matcher.dart b/lib/src/utils/file_name_matcher.dart new file mode 100644 index 00000000..a46f0f4b --- /dev/null +++ b/lib/src/utils/file_name_matcher.dart @@ -0,0 +1,31 @@ +/// Utility class for matching file paths with Dart identifier names. +abstract final class FileNameMatcher { + static final _alphanumericRegex = RegExp('[^a-zA-Z0-9]'); + + static final _pathSeparatorRegex = RegExp(r'[/\\]'); + + /// Checks whether the file name of [filePath] matches a Dart + /// [identifierName] (e.g. `user_profile.dart` matches `UserProfile`). + static bool matches({ + required String? filePath, + required String identifierName, + }) { + if (filePath == null) return false; + final fileName = normalizePath(filePath); + return fileName.isNotEmpty && + fileName == normalizeIdentifier(identifierName); + } + + /// Normalizes a file path to an alphanumeric, lowercase string without + /// extensions or symbols. + static String normalizePath(String? path) { + if (path == null) return ''; + final basename = path.split(_pathSeparatorRegex).last; + return normalizeIdentifier(basename.split('.').first); + } + + /// Normalizes a Dart identifier by stripping all non-alphanumeric + /// characters and converting to lowercase. + static String normalizeIdentifier(String identifier) => + identifier.replaceAll(_alphanumericRegex, '').toLowerCase(); +} diff --git a/lib/src/utils/node_utils.dart b/lib/src/utils/node_utils.dart index 95b58b58..e257a153 100644 --- a/lib/src/utils/node_utils.dart +++ b/lib/src/utils/node_utils.dart @@ -2,7 +2,10 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/token.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/type.dart'; +import 'package:analyzer/source/line_info.dart'; +import 'package:collection/collection.dart'; import 'package:solid_lints/src/utils/path_utils.dart'; +import 'package:solid_lints/src/utils/token_utils.dart'; /// Check node is override method from its metadata bool isOverride(List metadata) => metadata.any( @@ -160,6 +163,23 @@ extension AstNodeExtension on AstNode { } return null; } + + /// Returns the first token of actual code or metadata, skipping + /// documentation comments. + Token get firstCodeToken => switch (this) { + final AnnotatedNode n => + n.metadata.firstOrNull?.beginToken ?? n.firstTokenAfterCommentAndMetadata, + _ => beginToken, + }; + + /// Returns the number of lines of code in this node, ignoring synthetic + /// tokens and blank/comment-only lines. + int calculateLoc(LineInfo lineInfo) => firstCodeToken + .upTo(endToken) + .whereNot((t) => t.isSynthetic) + .map((t) => lineInfo.getLocation(t.offset).lineNumber) + .toSet() + .length; } /// Extension on [NamedType] to provide source URL utility. @@ -226,6 +246,13 @@ extension ElementExtension on Element { InterfaceElement? get enclosingInterface => enclosingElements.whereType().firstOrNull; + /// Returns the [InterfaceType] if this element is an [InterfaceElement], + /// or null otherwise. + InterfaceType? get interfaceType => switch (this) { + InterfaceElement(:final thisType) => thisType, + _ => null, + }; + /// Returns an iterable of this element and all its enclosing elements. Iterable get enclosingElements sync* { for (Element? e = this; e != null; e = e.enclosingElement) { @@ -363,3 +390,56 @@ extension DeclarationExtension on Declaration { _ => null, }; } + +/// Extension on [CompilationUnitMember] to provide declaration metadata. +extension CompilationUnitMemberExtension on CompilationUnitMember { + /// Returns `true` if this member is a nominal type declaration (class, enum, + /// mixin, extension, extension type, or class type alias). + bool get isNominalDeclaration => declarationToken != null; + + /// Returns the primary identifier token of this declaration, or `null` if + /// not a nominal declaration. + Token? get declarationToken => switch (this) { + ClassDeclaration(:final namePart) || + EnumDeclaration(:final namePart) || + ExtensionTypeDeclaration(:final namePart) => namePart.typeName, + ClassTypeAlias(:final name) || MixinDeclaration(:final name) => name, + ExtensionDeclaration(:final name, :final extensionKeyword) => + name ?? extensionKeyword, + _ => null, + }; + + /// Returns the human-readable display name of this declaration. + String get displayName => + declaredFragment?.element.name ?? 'unnamed extension'; + + /// Returns `true` if this declaration has a private name or is an unnamed + /// extension (which is library-private). + bool get isPrivate => declaredFragment?.element.isPrivate ?? false; + + /// Returns the declared [DartType] of this member if it declares an + /// interface type (class, enum, mixin, or extension type), or `null` + /// otherwise. + DartType? get declaredType => declaredFragment?.element.interfaceType; + + /// Returns the elements of all supertypes of this declaration. + Iterable get allSupertypeElements => + switch (declaredFragment?.element) { + InterfaceElement(:final allSupertypes) => allSupertypes.map( + (s) => s.element, + ), + _ => const [], + }; +} + +/// Extension on [CompilationUnit] to provide declaration queries. +extension CompilationUnitExtension on CompilationUnit { + /// Returns the elements of all sealed classes declared in this compilation + /// unit. + Set get sealedClassElements => declarations + .whereType() + .where((c) => c.sealedKeyword != null) + .map((c) => c.declaredFragment?.element) + .nonNulls + .toSet(); +} diff --git a/test/src/common/utils/file_name_matcher_test.dart b/test/src/common/utils/file_name_matcher_test.dart new file mode 100644 index 00000000..a4816975 --- /dev/null +++ b/test/src/common/utils/file_name_matcher_test.dart @@ -0,0 +1,140 @@ +import 'package:solid_lints/src/utils/file_name_matcher.dart'; +import 'package:test/test.dart'; + +void main() { + group('FileNameMatcher', () { + group('normalizeIdentifier', () { + test('converts camelCase to lowercase', () { + expect( + FileNameMatcher.normalizeIdentifier('UserProfile'), + 'userprofile', + ); + }); + + test('removes underscores and symbols', () { + expect( + FileNameMatcher.normalizeIdentifier(r'_User_Profile$123'), + 'userprofile123', + ); + }); + + test('handles empty string', () { + expect(FileNameMatcher.normalizeIdentifier(''), isEmpty); + }); + + test('handles string with only non-alphanumeric characters', () { + expect(FileNameMatcher.normalizeIdentifier(r'___---$$$'), isEmpty); + }); + }); + + group('normalizePath', () { + test('returns empty string for null path', () { + expect(FileNameMatcher.normalizePath(null), isEmpty); + }); + + test('extracts and normalizes simple filename without path', () { + expect( + FileNameMatcher.normalizePath('user_profile.dart'), + 'userprofile', + ); + }); + + test('extracts and normalizes basename from posix path', () { + expect( + FileNameMatcher.normalizePath('/path/to/user_profile.dart'), + 'userprofile', + ); + }); + + test('handles compound extensions by taking first component', () { + expect( + FileNameMatcher.normalizePath('user_profile.freezed.dart'), + 'userprofile', + ); + }); + + test('handles Windows style paths', () { + expect( + FileNameMatcher.normalizePath(r'C:\project\lib\user_profile.dart'), + 'userprofile', + ); + }); + + test('handles package URI paths', () { + expect( + FileNameMatcher.normalizePath('package:my_app/src/user_profile.dart'), + 'userprofile', + ); + }); + }); + + group('matches', () { + test('returns false when filePath is null', () { + expect( + FileNameMatcher.matches( + filePath: null, + identifierName: 'UserProfile', + ), + isFalse, + ); + }); + + test('returns false when filePath is empty', () { + expect( + FileNameMatcher.matches(filePath: '', identifierName: 'UserProfile'), + isFalse, + ); + }); + + test('returns false when identifierName is empty', () { + expect( + FileNameMatcher.matches( + filePath: '/lib/src/user_profile.dart', + identifierName: '', + ), + isFalse, + ); + }); + + test('returns false when both normalize to empty string', () { + expect( + FileNameMatcher.matches( + filePath: '/lib/src/---.dart', + identifierName: '---', + ), + isFalse, + ); + }); + + test('returns true when normalized names match', () { + expect( + FileNameMatcher.matches( + filePath: '/lib/src/user_profile.dart', + identifierName: 'UserProfile', + ), + isTrue, + ); + }); + + test('returns true for private identifier matching file name', () { + expect( + FileNameMatcher.matches( + filePath: '/lib/src/user_profile.dart', + identifierName: '_UserProfile', + ), + isTrue, + ); + }); + + test('returns false when names differ', () { + expect( + FileNameMatcher.matches( + filePath: '/lib/src/account_details.dart', + identifierName: 'UserProfile', + ), + isFalse, + ); + }); + }); + }); +} diff --git a/test/src/lints/avoid_multiple_declarations_per_file/avoid_multiple_declarations_per_file_rule_test.dart b/test/src/lints/avoid_multiple_declarations_per_file/avoid_multiple_declarations_per_file_rule_test.dart new file mode 100644 index 00000000..5467c38b --- /dev/null +++ b/test/src/lints/avoid_multiple_declarations_per_file/avoid_multiple_declarations_per_file_rule_test.dart @@ -0,0 +1,460 @@ +import 'dart:convert'; + +import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; +import 'package:analyzer_testing/utilities/utilities.dart'; +import 'package:solid_lints/src/common/parameter_parser/analysis_options_loader.dart'; +import 'package:solid_lints/src/lints/avoid_multiple_declarations_per_file/avoid_multiple_declarations_per_file_rule.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../../utils/auto_test_lint_offsets.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(AvoidMultipleDeclarationsPerFileRuleTest); + }); +} + +@reflectiveTest +class AvoidMultipleDeclarationsPerFileRuleTest extends AnalysisRuleTest + with AutoTestLintOffsets { + @override + void setUp() { + rule = AvoidMultipleDeclarationsPerFileRule( + analysisOptionsLoader: AnalysisOptionsLoader( + resourceProvider: resourceProvider, + ), + ); + super.setUp(); + + newFile('$testPackageLibPath/base.dart', r''' +abstract class Base {} +mixin Mix {} +'''); + + newFile('$testPackageLibPath/flutter.dart', r''' +abstract class StatefulWidget {} +abstract class State {} +'''); + } + + void _configureRule({ + List? ignoredTypes, + List? excludeEntity, + bool? allowPrivate, + int? maximumLoc, + }) { + final options = jsonEncode({ + 'ignored_types': ?ignoredTypes, + 'exclude_entity': ?excludeEntity, + 'allow_private': ?allowPrivate, + 'maximum_loc': ?maximumLoc, + }); + + newAnalysisOptionsYamlFile(testPackageRootPath, ''' +${analysisOptionsContent(rules: [rule.name])} +plugins: + solid_lints: + diagnostics: + ${rule.name}: $options'''); + } + + // --------------------------------------------------------------------------- + // Single & Non-Nominal Declarations + // --------------------------------------------------------------------------- + + Future test_does_not_report_on_single_declaration() async { + await assertNoDiagnostics(r''' +class Test {} +'''); + } + + Future test_does_not_report_on_non_nominal_declarations() async { + await assertNoDiagnostics(r''' +typedef JsonMap = Map; + +class Test {} + +void topLevelHelper() {} + +const timeoutSeconds = 30; +'''); + } + + // --------------------------------------------------------------------------- + // File Name Matching & Priority + // --------------------------------------------------------------------------- + + Future test_reports_class_when_matching_class_is_not_first() async { + await assertAutoDiagnostics(''' +class ${expectLint('Helper')} {} + +class Test {} +'''); + } + + Future test_reports_secondary_when_no_class_matches_file_name() async { + await assertAutoDiagnostics(''' +class FirstHelper {} + +class ${expectLint('SecondHelper')} {} +'''); + } + + // --------------------------------------------------------------------------- + // Violations Reported by Default + // --------------------------------------------------------------------------- + + Future test_reports_secondary_class() async { + await assertAutoDiagnostics(''' +class Test {} + +class ${expectLint('SecondClass')} {} +'''); + } + + Future test_reports_all_secondary_classes() async { + await assertAutoDiagnostics(''' +class Test {} + +class ${expectLint('SecondClass')} {} + +class ${expectLint('ThirdClass')} {} +'''); + } + + Future test_reports_class_type_alias() async { + await assertAutoDiagnostics(''' +import 'base.dart'; + +class Test {} + +class ${expectLint('TestAlias')} = Base with Mix; +'''); + } + + Future test_reports_enum() async { + await assertAutoDiagnostics(''' +class Test {} + +enum ${expectLint('TestRole')} { admin, user } +'''); + } + + Future test_reports_mixin() async { + await assertAutoDiagnostics(''' +class Test {} + +mixin ${expectLint('LoggingMixin')} {} +'''); + } + + Future test_reports_extension() async { + await assertAutoDiagnostics(''' +class Test {} + +extension ${expectLint('TestFormatting')} on Test {} +'''); + } + + Future test_reports_extension_type() async { + await assertAutoDiagnostics(''' +class Test {} + +extension type ${expectLint('TestId')}(int id) {} +'''); + } + + Future test_reports_unnamed_extension() async { + await assertAutoDiagnostics(''' +class Test {} + +${expectLint('extension')} on Test {} +'''); + } + + Future test_reports_private_declaration_by_default() async { + await assertAutoDiagnostics(''' +class Test {} + +class ${expectLint('_PrivateHelper')} {} +'''); + } + + Future test_reports_state_class_by_default() async { + await assertAutoDiagnostics(''' +import 'flutter.dart'; + +class Test extends StatefulWidget {} + +class ${expectLint('_TestState')} extends State {} +'''); + } + + // --------------------------------------------------------------------------- + // allow_private Configuration + // --------------------------------------------------------------------------- + + Future test_does_not_report_private_declarations_when_allowed() async { + _configureRule(allowPrivate: true); + + await assertNoDiagnostics(r''' +class Test {} + +class _PrivateHelper {} + +enum _PrivateEnum { a, b } + +mixin _PrivateMixin {} + +extension _PrivateExtension on String {} + +// Unnamed extensions are library-private by Dart specification. +extension on String {} +'''); + } + + Future test_reports_public_class_when_private_allowed() async { + _configureRule(allowPrivate: true); + + await assertAutoDiagnostics(''' +class Test {} + +class _PrivateHelper {} + +class ${expectLint('OtherPublic')} {} +'''); + } + + // --------------------------------------------------------------------------- + // ignored_types Configuration + // --------------------------------------------------------------------------- + + Future test_does_not_report_on_state_when_type_ignored() async { + _configureRule(ignoredTypes: ['State']); + + await assertNoDiagnostics(r''' +import 'flutter.dart'; + +class Test extends StatefulWidget {} + +class _TestState extends State {} +'''); + } + + Future test_reports_unrelated_class_when_type_ignored() async { + _configureRule(ignoredTypes: ['State']); + + await assertAutoDiagnostics(''' +import 'flutter.dart'; + +class Test extends StatefulWidget {} + +class _TestState extends State {} + +class ${expectLint('UnrelatedClass')} {} +'''); + } + + // --------------------------------------------------------------------------- + // Sealed Class Hierarchy + // --------------------------------------------------------------------------- + + Future test_does_not_report_on_sealed_class_hierarchy() async { + await assertNoDiagnostics(r''' +sealed class Result {} + +class Success extends Result { + final int value; + Success(this.value); +} + +class Failure extends Result { + final String error; + Failure(this.error); +} +'''); + } + + Future test_does_not_report_on_sealed_class_implements() async { + await assertNoDiagnostics(r''' +sealed class Result {} + +class Success implements Result {} + +class Failure implements Result {} +'''); + } + + Future test_does_not_report_on_sealed_class_enum_implements() async { + await assertNoDiagnostics(r''' +sealed class Status {} + +enum ItemStatus implements Status { + active, + inactive, +} +'''); + } + + Future test_reports_unrelated_class_in_sealed_hierarchy() async { + await assertAutoDiagnostics(''' +sealed class Result {} + +class Success extends Result {} + +class Failure extends Result {} + +class ${expectLint('OtherClass')} {} +'''); + } + + Future + test_reports_class_extending_external_with_same_name_as_sealed() async { + newFile('$testPackageLibPath/other.dart', r''' +class Result {} +'''); + + await assertAutoDiagnostics(''' +import 'other.dart' as other; + +sealed class Result {} + +class ${expectLint('Helper')} extends other.Result {} +'''); + } + + Future test_does_not_report_on_transitive_sealed_subclass() async { + await assertNoDiagnostics(r''' +sealed class Result {} + +class Success extends Result {} + +class SpecialSuccess extends Success {} +'''); + } + + // --------------------------------------------------------------------------- + // maximum_loc Configuration + // --------------------------------------------------------------------------- + + Future test_does_not_report_when_loc_equals_maximum() async { + _configureRule(maximumLoc: 3); + + await assertNoDiagnostics(r''' +class Test {} + +class SmallHelper { + void run() {} +} +'''); + } + + Future test_does_not_report_on_loc_with_comments_and_blanks() async { + _configureRule(maximumLoc: 3); + + await assertNoDiagnostics(r''' +class Test {} + +// Comment before helper class +class SmallHelper { + // Method comment + + void run() {} +} +'''); + } + + Future test_reports_when_loc_exceeds_maximum() async { + _configureRule(maximumLoc: 3); + + await assertAutoDiagnostics(''' +class Test {} + +class ${expectLint('LargeHelper')} { + void lineOne() {} + void lineTwo() {} + void lineThree() {} +} +'''); + } + + Future test_reports_when_loc_exceeds_maximum_with_doc_comments() async { + _configureRule(maximumLoc: 3); + + await assertAutoDiagnostics(''' +class Test {} + +/// Documentation comment +/// across multiple lines +class ${expectLint('LargeHelper')} { + void lineOne() {} + void lineTwo() {} + void lineThree() {} +} +'''); + } + + Future test_does_not_report_on_loc_with_doc_comments() async { + _configureRule(maximumLoc: 3); + + await assertNoDiagnostics(r''' +class Test {} + +/// Documentation comment +/// across multiple lines +class SmallHelper { + void run() {} +} +'''); + } + + Future test_reports_when_loc_exceeds_maximum_with_metadata() async { + _configureRule(maximumLoc: 3); + + await assertAutoDiagnostics(''' +class Test {} + +/// Documentation comment +@deprecated +class ${expectLint('LargeHelper')} { + void lineOne() {} + void lineTwo() {} + void lineThree() {} +} +'''); + } + + // --------------------------------------------------------------------------- + // exclude_entity Configuration + // --------------------------------------------------------------------------- + + Future test_does_not_report_on_excluded_entities() async { + _configureRule( + excludeEntity: ['enum', 'mixin', 'extension', 'extension_type'], + ); + + await assertNoDiagnostics(r''' +class Test {} + +enum TestRole { admin, user } + +mixin LoggingMixin {} + +extension TestFormatting on Test {} + +extension type TestId(int id) {} +'''); + } + + Future test_reports_unrelated_class_when_entity_excluded() async { + _configureRule(excludeEntity: ['enum']); + + await assertAutoDiagnostics(''' +class Test {} + +enum TestStatus { active, inactive } + +class ${expectLint('OtherClass')} {} +'''); + } +}