Skip to content
Open
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
2 changes: 2 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import 'package:solid_lints/src/lints/prefer_first/fixes/prefer_first_fix.dart';
import 'package:solid_lints/src/lints/prefer_first/prefer_first_rule.dart';
import 'package:solid_lints/src/lints/prefer_last/fixes/prefer_last_fix.dart';
import 'package:solid_lints/src/lints/prefer_last/prefer_last_rule.dart';
import 'package:solid_lints/src/lints/prefer_match_file_name/prefer_match_file_name_rule.dart';
import 'package:solid_lints/src/lints/proper_super_calls/proper_super_calls_rule.dart';
import 'package:solid_lints/src/lints/use_nearest_context/fixes/rename_nearest_context_parameter_fix.dart';
import 'package:solid_lints/src/lints/use_nearest_context/use_nearest_context_rule.dart';
Expand Down Expand Up @@ -65,6 +66,7 @@ class SolidLintsPlugin extends Plugin {
NoMagicNumberRule(analysisOptionsLoader: analysisLoader),
preferFirstRule,
preferLastRule,
PreferMatchFileNameRule(analysisOptionsLoader: analysisLoader),
// TODO: Add more lint rules and use analysisLoader
// for rules that need parameters
// For example: `CyclomaticComplexityRule(analysisLoader)`
Expand Down
18 changes: 10 additions & 8 deletions lib/src/common/parameters/excluded_entities_list_parameter.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import 'package:analyzer/dart/ast/ast.dart';

/// A model representing "exclude_entity" parameters for linting, defining
/// identifiers (classes, mixins, enums, extensions) to be ignored during
/// identifiers (classes, mixins, enums, extensions, extension_types) to be ignored during
/// analysis.
/// Supported entities:
/// - mixin
/// - extension
/// - extension_type
/// - enum
class ExcludedEntitiesListParameter {
/// The parameter model
Expand All @@ -20,17 +21,15 @@ class ExcludedEntitiesListParameter {
});

/// Method for creating from json data
factory ExcludedEntitiesListParameter.fromJson(Map<String, dynamic> json) {
final raw = json['exclude_entity'];
if (raw is List) {
factory ExcludedEntitiesListParameter.fromJson(Map<String, Object?> json) {
final excludedEntities = json['exclude_entity'];
if (excludedEntities is Iterable) {
return ExcludedEntitiesListParameter(
excludedEntityNames: Set<String>.from(raw),
excludedEntityNames: excludedEntities.cast<String>().toSet(),
);
}

return ExcludedEntitiesListParameter(
excludedEntityNames: {},
);
return ExcludedEntitiesListParameter(excludedEntityNames: {});
}

/// Returns whether the target node should be ignored during analysis.
Expand All @@ -45,6 +44,9 @@ class ExcludedEntitiesListParameter {
} else if (node is ExtensionDeclaration &&
excludedEntityNames.contains('extension')) {
return true;
} else if (node is ExtensionTypeDeclaration &&
excludedEntityNames.contains('extension_type')) {
return true;
}

return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ class PreferMatchFileNameParameters {
required this.excludeEntity,
});

/// Empty [PreferMatchFileNameParameters] model.
factory PreferMatchFileNameParameters.empty() {
return PreferMatchFileNameParameters(
excludeEntity: ExcludedEntitiesListParameter(excludedEntityNames: {}),
);
}

/// Method for creating from json data
factory PreferMatchFileNameParameters.fromJson(Map<String, Object?> json) =>
PreferMatchFileNameParameters(
Expand Down
118 changes: 48 additions & 70 deletions lib/src/lints/prefer_match_file_name/prefer_match_file_name_rule.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import 'package:analyzer/error/listener.dart';
import 'package:custom_lint_builder/custom_lint_builder.dart';
import 'package:path/path.dart' as p;
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/prefer_match_file_name/models/prefer_match_file_name_parameters.dart';
import 'package:solid_lints/src/lints/prefer_match_file_name/visitors/prefer_match_file_name_visitor.dart';
import 'package:solid_lints/src/models/rule_config.dart';
import 'package:solid_lints/src/models/solid_lint_rule.dart';
import 'package:solid_lints/src/utils/node_utils.dart';

/// Warns about a mismatch between file name and first declared element inside.
///
/// This improves navigation by matching file content and file name.
///
/// ### Example config:
///
/// ```yaml
/// plugins:
/// solid_lints:
/// diagnostics:
/// prefer_match_file_name:
/// exclude_entity:
/// - mixin
/// - extension
/// - extension_type
/// - enum
/// ```
///
/// ## Tests
///
/// State: **Disabled**.
Expand Down Expand Up @@ -51,80 +63,46 @@ import 'package:solid_lints/src/utils/node_utils.dart';
///
class PreferMatchFileNameRule
extends SolidLintRule<PreferMatchFileNameParameters> {
/// This lint rule represents the error if iterable
/// access can be simplified.
static const String lintName = 'prefer_match_file_name';
static final _onlySymbolsRegex = RegExp('[^a-zA-Z0-9]');
/// Name of the lint.
static const lintName = 'prefer_match_file_name';

PreferMatchFileNameRule._(super.config);
static const _code = LintCode(
lintName,
'File name does not match with first {0} name.',
);

@override
DiagnosticCode get diagnosticCode => _code;

/// Creates a new instance of [PreferMatchFileNameRule]
/// based on the lint configuration.
factory PreferMatchFileNameRule.createRule(CustomLintConfigs configs) {
final config = RuleConfig(
configs: configs,
name: lintName,
paramsParser: PreferMatchFileNameParameters.fromJson,
problemMessage: (value) =>
'File name does not match with first declared element name.',
);

return PreferMatchFileNameRule._(config);
}
PreferMatchFileNameRule({
required super.analysisOptionsLoader,
}) : super.withParameters(
name: lintName,
description:
'Warns about a mismatch between file name and first declared '
'element inside.',
parametersParser: PreferMatchFileNameParameters.fromJson,
);

@override
void run(
CustomLintResolver resolver,
DiagnosticReporter reporter,
CustomLintContext context,
void registerNodeProcessors(
RuleVisitorRegistry registry,
RuleContext context,
) {
context.registry.addCompilationUnit((node) {
final excludedEntities = config.parameters.excludeEntity;

final visitor = PreferMatchFileNameVisitor(
excludedEntities: excludedEntities,
);

node.accept(visitor);
super.registerNodeProcessors(registry, context);

if (visitor.declarations.isEmpty) return;
final parameters =
getParametersForContext(context) ??
PreferMatchFileNameParameters.empty();

final firstDeclaration = visitor.declarations.first;

if (_doNormalizedNamesMatch(
resolver.source.fullName,
firstDeclaration.token.lexeme,
)) {
return;
}

final nodeType =
humanReadableNodeType(firstDeclaration.parent).toLowerCase();

reporter.atToken(
firstDeclaration.token,
LintCode(
name: lintName,
problemMessage: 'File name does not match with first $nodeType name.',
),
);
});
}

bool _doNormalizedNamesMatch(String path, String identifierName) {
final fileName = _normalizePath(path);
final dartIdentifier = _normalizeDartIdentifierName(identifierName);
final visitor = PreferMatchFileNameVisitor(
diagnosticCode: diagnosticCode,
context: context,
excludedEntities: parameters.excludeEntity,
);

return fileName == dartIdentifier;
registry.addCompilationUnit(this, visitor);
}

String _normalizePath(String s) => p
.basename(s)
.split('.')
.first
.replaceAll(_onlySymbolsRegex, '')
.toLowerCase();

String _normalizeDartIdentifierName(String s) =>
s.replaceAll(_onlySymbolsRegex, '').toLowerCase();
}
Original file line number Diff line number Diff line change
@@ -1,83 +1,97 @@
import 'package:analyzer/analysis_rule/rule_context.dart';
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/iterable_utils.dart';
import 'package:solid_lints/src/utils/node_utils.dart';

/// The AST visitor that will collect all Class, Enum, Extension and Mixin
/// declarations
class PreferMatchFileNameVisitor extends RecursiveAstVisitor<void> {
final _declarations = <DeclarationTokenInfo>[];
/// The AST visitor that will collect all Class, Enum, Extension, Mixin and
/// Extension Type declarations
class PreferMatchFileNameVisitor extends SimpleAstVisitor<void> {
static final _onlySymbolsRegex = RegExp('[^a-zA-Z0-9]');

/// The diagnostic code to report
final DiagnosticCode diagnosticCode;

/// The rule context
final RuleContext context;

/// Iterable that contains the name of entity (or entities) that should
/// be ignored
final ExcludedEntitiesListParameter excludedEntities;

/// Constructor of [PreferMatchFileNameVisitor] class
PreferMatchFileNameVisitor({
required this.diagnosticCode,
required this.context,
required this.excludedEntities,
});

/// List of all declarations
Iterable<DeclarationTokenInfo> get declarations => _declarations.where(
(declaration) {
if (declaration.parent is Declaration) {
return !excludedEntities
.shouldIgnoreEntity(declaration.parent as Declaration);
}
return true;
},
).toList()
..sort(
(a, b) => _publicDeclarationsFirst(a, b) ?? _byDeclarationOrder(a, b),
);

@override
void visitClassDeclaration(ClassDeclaration node) {
super.visitClassDeclaration(node);
void visitCompilationUnit(CompilationUnit node) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

void visitCompilationUnit(CompilationUnit node) {
    final declarations = node.declarations
        .whereNot(excludedEntities.shouldIgnoreEntity)
        .map(
          (d) {
            final token = switch (d) {
              ClassDeclaration() => d.namePart.typeName,
              ExtensionDeclaration() => d.name,
              MixinDeclaration() => d.name,
              EnumDeclaration() => d.namePart.typeName,
              ExtensionTypeDeclaration() => d.primaryConstructor.typeName,
              _ => null,
            };

            return token == null ? null : (token: token, parent: d);
          },
        )
        .nonNulls
        .multiSortedBy(
          [
            (t) => Identifier.isPrivateName(t.token.lexeme) ? 1 : 0,
            (t) => t.token.offset,
          ],
        );

    if (declarations.isEmpty) return;

    final firstDeclaration = declarations.first;
    final fullName = context.currentUnit?.file.path;

    if (fullName != null &&
        rule.doNormalizedNamesMatch(
          fullName,
          firstDeclaration.token.lexeme,
        )) {
      return;
    }

    final nodeType = humanReadableNodeType(
      firstDeclaration.parent,
    ).toLowerCase();

    final reporter = context.currentUnit?.diagnosticReporter;
    reporter?.atToken(
      firstDeclaration.token,
      rule.diagnosticCode,
      arguments: [nodeType],
    );
  }

To add to utils:

extension<T> on Iterable<T> {
  List<T> multiSortedBy(List<int Function(T)> keys) => sorted(
    (a, b) =>
        keys
            .map((key) => key(a).compareTo(key(b)))
            .firstWhereOrNull((comparison) => comparison != 0) ??
        0,
  );
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done!

final declarations = node.declarations
.whereNot(excludedEntities.shouldIgnoreEntity)
.map<DeclarationTokenInfo?>(
(d) {
final token = switch (d) {
ClassDeclaration() => d.namePart.typeName,
ExtensionDeclaration() => d.name,
MixinDeclaration() => d.name,
EnumDeclaration() => d.namePart.typeName,
ExtensionTypeDeclaration() => d.primaryConstructor.typeName,
_ => null,
};

_declarations.add((token: node.name, parent: node));
}
return token == null ? null : (token: token, parent: d);
},
)
.nonNulls
.multiSortedBy(
[
(t) => Identifier.isPrivateName(t.token.lexeme) ? 1 : 0,
(t) => t.token.offset,
],
);

@override
void visitExtensionDeclaration(ExtensionDeclaration node) {
super.visitExtensionDeclaration(node);
if (declarations.isEmpty) return;

final name = node.name;
if (name != null) {
_declarations.add((token: name, parent: node));
final firstDeclaration = declarations.first;
final fullName = context.currentUnit?.file.path;

if (fullName != null &&
_doNormalizedNamesMatch(
fullName,
firstDeclaration.token.lexeme,
)) {
return;
}
}

@override
void visitMixinDeclaration(MixinDeclaration node) {
super.visitMixinDeclaration(node);
final nodeType = humanReadableNodeType(
firstDeclaration.parent,
).toLowerCase();

_declarations.add((token: node.name, parent: node));
final reporter = context.currentUnit?.diagnosticReporter;
reporter?.atToken(
firstDeclaration.token,
diagnosticCode,
arguments: [nodeType],
);
}

@override
void visitEnumDeclaration(EnumDeclaration node) {
super.visitEnumDeclaration(node);
bool _doNormalizedNamesMatch(String path, String identifierName) {
final fileName = _normalizePath(path);
final dartIdentifier = _normalizeDartIdentifierName(identifierName);

_declarations.add((token: node.name, parent: node));
return fileName == dartIdentifier;
}

int? _publicDeclarationsFirst(
DeclarationTokenInfo a,
DeclarationTokenInfo b,
) {
final isAPrivate = Identifier.isPrivateName(a.token.lexeme);
final isBPrivate = Identifier.isPrivateName(b.token.lexeme);
if (!isAPrivate && isBPrivate) {
return -1;
} else if (isAPrivate && !isBPrivate) {
return 1;
}
// no reorder needed;
return null;
}
String _normalizePath(String s) =>
_normalizeDartIdentifierName(p.basename(s).split('.').first);

int _byDeclarationOrder(DeclarationTokenInfo a, DeclarationTokenInfo b) {
return a.token.offset.compareTo(b.token.offset);
}
String _normalizeDartIdentifierName(String s) =>
s.replaceAll(_onlySymbolsRegex, '').toLowerCase();
}
15 changes: 15 additions & 0 deletions lib/src/utils/iterable_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import 'package:collection/collection.dart';

/// Extension on [Iterable] to sort by multiple keys.
extension MultiSortedByIterable<T> on Iterable<T> {
/// Sorts this iterable by multiple keys sequentially.
///
/// The first key selector that produces a non-zero comparison is used.
List<T> multiSortedBy(List<Comparable<dynamic> Function(T)> keys) => sorted(
(a, b) =>
keys
.map((key) => key(a).compareTo(key(b)))
.firstWhereOrNull((comparison) => comparison != 0) ??
0,
);
}
5 changes: 0 additions & 5 deletions lint_test/prefer_match_file_name_enum_test.dart

This file was deleted.

Loading