Skip to content
Merged
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 @@ -9,6 +9,7 @@ import 'package:solid_lints/src/lints/avoid_global_state/avoid_global_state_rule
import 'package:solid_lints/src/lints/avoid_late_keyword/avoid_late_keyword_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';
import 'package:solid_lints/src/lints/avoid_unnecessary_return_variable/avoid_unnecessary_return_variable_rule.dart';
import 'package:solid_lints/src/lints/avoid_unnecessary_setstate/avoid_unnecessary_set_state_rule.dart';
import 'package:solid_lints/src/lints/avoid_unnecessary_type_assertions/avoid_unnecessary_type_assertions_rule.dart';
Expand Down Expand Up @@ -62,6 +63,7 @@ class SolidLintsPlugin extends Plugin {
AvoidLateKeywordRule(analysisOptionsLoader: analysisLoader),
AvoidNonNullAssertionRule(analysisOptionsLoader: analysisLoader),
AvoidReturningWidgetsRule(analysisOptionsLoader: analysisLoader),
AvoidSimilarNamesRule(),
AvoidUnnecessaryReturnVariableRule(),
AvoidUnnecessarySetStateRule(),
AvoidUnnecessaryTypeAssertionsRule(),
Expand Down
67 changes: 67 additions & 0 deletions lib/src/lints/avoid_similar_names/avoid_similar_names_rule.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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_similar_names/visitors/avoid_similar_names_visitor.dart';
import 'package:solid_lints/src/models/solid_lint_rule.dart';

/// Avoid similar names
///
/// Warns about variables or parameters that have confusingly similar names
/// within the same function scope (e.g., using numeric suffixes or
/// single-letter modifiers like `someClass1` and `someClass2`).
///
/// This encourages using descriptive, distinct names to improve code
/// readability and prevent logical errors caused by mixing up variables.
///
/// ### Example
///
/// #### BAD:
///
/// ```dart
/// void test(SomeClass someClass1, SomeClass someClass2) { // LINT
/// final tempA = 'a'; // LINT
/// final tempB = 'b'; // LINT
/// }
/// ```
///
/// #### GOOD:
///
/// ```dart
/// void test(SomeClass first, SomeClass second) {
/// final that = 'a';
/// final other = 'b';
/// }
/// ```
class AvoidSimilarNamesRule extends SolidLintRule<void> {
/// The name of this lint rule.
static const lintName = 'avoid_similar_names';

static const _code = LintCode(
lintName,
'Avoid using similar names.',
correctionMessage: 'Use more descriptive names.',
);

/// Creates an instance of [AvoidSimilarNamesRule].
AvoidSimilarNamesRule()
: super(
name: lintName,
description: 'Warns about variables or parameters with similar names.',
);

@override
LintCode get diagnosticCode => _code;

@override
void registerNodeProcessors(
RuleVisitorRegistry registry,
RuleContext context,
) {
final visitor = AvoidSimilarNamesVisitor(this);

registry
..addMethodDeclaration(this, visitor)
..addConstructorDeclaration(this, visitor)
..addFunctionDeclaration(this, visitor);
}
Comment thread
solid-illiaaihistov marked this conversation as resolved.
}
49 changes: 49 additions & 0 deletions lib/src/lints/avoid_similar_names/models/scope_variable.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/token.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:solid_lints/src/lints/avoid_similar_names/utils/name_tokenizer.dart';

/// Represents a variable or parameter collected from a scope.
class ScopeVariable {
/// The resolved type of the variable, if available.
final DartType? type;

/// The AST node representing this variable declaration.
final AstNode node;

/// The token representing the name.
final Token nameToken;

/// The individual word/digit tokens of the name.
final List<String> tokens;

/// The minimum length for a variable name to be considered descriptive enough
/// to be analyzed for similarity.
static const minDescriptiveNameLength = 3;

/// Creates a new [ScopeVariable] if the [nameToken] is descriptive enough
/// to be analyzed for similarity. Returns `null` if the cleaned name
/// is too short.
static ScopeVariable? createOrNull({
required Token nameToken,
required DartType? type,
required AstNode node,
}) {
final cleaned = NameTokenizer.cleanName(nameToken.lexeme);
if (cleaned.length < minDescriptiveNameLength) return null;

return ScopeVariable._(
nameToken: nameToken,
type: type,
node: node,
tokens: NameTokenizer.tokenize(cleaned),
);
}

ScopeVariable._({
required this.nameToken,
required this.type,
required this.node,
required this.tokens,
});
}
70 changes: 70 additions & 0 deletions lib/src/lints/avoid_similar_names/utils/name_tokenizer.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/// Utility class for tokenizing identifiers and
/// comparing name similarity.
abstract final class NameTokenizer {
/// Regex Fragment | Meaning
/// =================================================================
/// [A-Z]?[a-z]+ | Match words (e.g., Class, user):
/// [A-Z]? | ... optional leading uppercase,
/// [a-z]+ | ... followed by lowercase letters.
/// | | OR
/// [A-Z]+(?=[A-Z][a-z]|\d|\b)| Match acronyms (uppercase letters).
/// | | OR
/// \d+ | Match digits (e.g., 1, 10).
static final _tokenPattern = RegExp(
r'[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\d|\b)|\d+',
);

/// Regex Fragment | Meaning
/// =================================================================
/// ^ | Match start of string.
/// _+ | Match one or more leading underscores.
static final _leadingUnderscoresPattern = RegExp('^_+');

static const _allowedTokens = {'x', 'y', 'z', 'w', 'i', 'j', 'k'};

/// Splits a camelCase or snake_case identifier
/// into lowercase tokens.
///
/// E.g., `someClass1` returns `['some', 'class', '1']`.
static List<String> tokenize(String name) => [
for (final match in _tokenPattern.allMatches(name))
if (match.group(0) case final group?) group.toLowerCase(),
];

/// Strips leading underscores from a name.
///
/// E.g., `_someName` returns `someName`.
static String cleanName(String name) =>
name.replaceFirst(_leadingUnderscoresPattern, '');

/// Returns `true` if the string consists only
/// of digit characters.
static bool isDigit(String s) => int.tryParse(s) != null;

/// Returns `true` if the token is a common
/// loop variable or coordinate name.
static bool isAllowedToken(String s) => _allowedTokens.contains(s);

/// Returns `true` if the token is considered non-descriptive
/// (either a digit or a disallowed single letter).
static bool isNonDescriptiveToken(String s) =>
isDigit(s) || (s.length == 1 && !isAllowedToken(s));

/// Returns `true` if [longer] is a superset of
/// [shorter] with exactly one extra non-descriptive token.
static bool isSubsetWithNonDescriptiveToken(
List<String> longer,
List<String> shorter,
) {
if (longer.length != shorter.length + 1) return false;
var i = 0;
while (i < shorter.length && longer[i] == shorter[i]) {
i++;
}
if (!isNonDescriptiveToken(longer[i])) return false;
while (i < shorter.length && longer[i + 1] == shorter[i]) {
i++;
}
return i == shorter.length;
}
Comment thread
solid-illiaaihistov marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:collection/collection.dart';
import 'package:solid_lints/src/lints/avoid_similar_names/avoid_similar_names_rule.dart';
import 'package:solid_lints/src/lints/avoid_similar_names/models/scope_variable.dart';
import 'package:solid_lints/src/lints/avoid_similar_names/utils/name_tokenizer.dart';
import 'package:solid_lints/src/lints/avoid_similar_names/visitors/local_variables_visitor.dart';
import 'package:solid_lints/src/utils/iterable_utils.dart';
import 'package:solid_lints/src/utils/types_utils.dart';

/// A visitor that checks for variables with
/// confusingly similar names.
class AvoidSimilarNamesVisitor extends SimpleAstVisitor<void> {
final AvoidSimilarNamesRule _rule;
final _reportedNodes = <AstNode>{};
final _collector = LocalVariablesVisitor();

/// Creates a new instance of
/// [AvoidSimilarNamesVisitor].
AvoidSimilarNamesVisitor(this._rule);

@override
void visitMethodDeclaration(MethodDeclaration node) =>
_checkScope(node.parameters, node.body);

@override
void visitConstructorDeclaration(ConstructorDeclaration node) =>
_checkScope(node.parameters, node.body);

@override
void visitFunctionDeclaration(FunctionDeclaration node) => _checkScope(
node.functionExpression.parameters,
node.functionExpression.body,
);

void _checkScope(
FormalParameterList? parameters,
FunctionBody body,
) {
_reportedNodes.clear();
_collector.variables.clear();
Comment thread
solid-illiaaihistov marked this conversation as resolved.
body.accept(_collector);

_compareVariables([
..._extractParameters(parameters),
..._collector.variables,
]);
_reportedNodes.clear();
}

Iterable<ScopeVariable> _extractParameters(
FormalParameterList? parameters,
) => [
for (final parameter in parameters?.parameters ?? const <FormalParameter>[])
if (parameter.name case final nameToken?)
if (ScopeVariable.createOrNull(
nameToken: nameToken,
type: parameter.declaredFragment?.element.type,
node: parameter,
)
case final variable?)
variable,
];

void _compareVariables(List<ScopeVariable> variables) {
for (var i = 0; i < variables.length; i++) {
for (var j = i + 1; j < variables.length; j++) {
_comparePair(variables[i], variables[j]);
}
}
}

void _comparePair(ScopeVariable a, ScopeVariable b) {
final lengthDiff = (a.tokens.length - b.tokens.length).abs();
final shouldReport =
a.type?.isDifferentIgnoringNullability(b.type) != true &&
((lengthDiff == 0 && _checkSameLengthTokens(a, b)) ||
(lengthDiff == 1 && _checkSingleTokenLengthDifference(a, b)));

if (shouldReport) {
_report(a, b);
}
}

bool _checkSameLengthTokens(
ScopeVariable a,
ScopeVariable b,
) {
final diff = a.tokens
.zipWithIndexed(b.tokens)
.where((e) => e.$2 != e.$3)
.singleOrNull
?.$1;

return diff != null &&
NameTokenizer.isNonDescriptiveToken(a.tokens[diff]) &&
NameTokenizer.isNonDescriptiveToken(b.tokens[diff]);
}

bool _checkSingleTokenLengthDifference(ScopeVariable a, ScopeVariable b) {
final [shorter, longer] = [a.tokens, b.tokens].sortedBy((e) => e.length);

return NameTokenizer.isSubsetWithNonDescriptiveToken(longer, shorter);
}

void _report(ScopeVariable a, ScopeVariable b) => [a, b]
.where((e) => _reportedNodes.add(e.node))
.forEach((e) => _rule.reportAtToken(e.nameToken));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/token.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:solid_lints/src/lints/avoid_similar_names/models/scope_variable.dart';

/// Collects local variable declarations within a function body,
/// stopping at nested function boundaries.
class LocalVariablesVisitor extends RecursiveAstVisitor<void> {
/// The collected variables.
final List<ScopeVariable> variables = [];

@override
void visitVariableDeclaration(VariableDeclaration node) {
_collect(node.name, node.declaredFragment?.element.type, node);
super.visitVariableDeclaration(node);
}
Comment thread
solid-illiaaihistov marked this conversation as resolved.

@override
void visitDeclaredIdentifier(DeclaredIdentifier node) {
_collect(node.name, node.declaredFragment?.element.type, node);
super.visitDeclaredIdentifier(node);
}
Comment thread
solid-illiaaihistov marked this conversation as resolved.

@override
void visitDeclaredVariablePattern(DeclaredVariablePattern node) {
_collect(node.name, node.declaredFragment?.element.type, node);
super.visitDeclaredVariablePattern(node);
}

@override
void visitFunctionDeclaration(FunctionDeclaration node) {
// Stop traversing nested function scopes.
}

@override
void visitFunctionExpression(FunctionExpression node) {
// Stop traversing nested closures.
}

void _collect(Token nameToken, DartType? type, AstNode node) {
final variable = ScopeVariable.createOrNull(
nameToken: nameToken,
type: type,
node: node,
);
if (variable != null) {
variables.add(variable);
}
}
}
17 changes: 17 additions & 0 deletions lib/src/utils/iterable_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,20 @@ extension IterablePairwise<T> on Iterable<T> {
}
}
}

/// Extension on [Iterable] to zip elements with another iterable.
extension IterableZip<T> on Iterable<T> {
/// Zips this iterable with [other].
Iterable<(T, U)> zipWith<U>(Iterable<U> other) sync* {
for (var i = 0; i < length && i < other.length; i++) {
yield (elementAt(i), other.elementAt(i));
}
}

/// Zips this iterable with [other] and includes the index.
Iterable<(int, T, U)> zipWithIndexed<U>(Iterable<U> other) sync* {
for (var i = 0; i < length && i < other.length; i++) {
yield (i, elementAt(i), other.elementAt(i));
}
}
}
4 changes: 4 additions & 0 deletions lib/src/utils/types_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ extension Subtypes on DartType {

return false;
}

/// Compares this type with [other] ignoring nullability where applicable.
bool isDifferentIgnoringNullability(DartType? other) =>
other != null && (element ?? this) != (other.element ?? other);
}

extension TypeString on String {
Expand Down
Loading