Skip to content
2 changes: 2 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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 @@ -57,6 +58,7 @@ class SolidLintsPlugin extends Plugin {
AvoidLateKeywordRule(analysisOptionsLoader: analysisLoader),
AvoidNonNullAssertionRule(analysisOptionsLoader: analysisLoader),
AvoidReturningWidgetsRule(analysisOptionsLoader: analysisLoader),
AvoidSimilarNamesRule(),
AvoidUnnecessaryReturnVariableRule(),
AvoidUnnecessarySetStateRule(),
AvoidUnnecessaryTypeAssertionsRule(),
Expand Down
66 changes: 66 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,66 @@
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);
registry.addConstructorDeclaration(this, visitor);
registry.addFunctionDeclaration(this, visitor);
Comment thread
solid-illiaaihistov marked this conversation as resolved.
}
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,147 @@
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.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/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,
);
}
Comment thread
solid-illiaaihistov marked this conversation as resolved.
Comment thread
solid-illiaaihistov marked this conversation as resolved.

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

final variables = [
..._extractParameters(parameters),
..._collector.variables,
];

_compareVariables(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,
) {
if (a.type?.isDifferentIgnoringNullability(b.type) ?? false) {
return;
}

switch ((a.tokens.length - b.tokens.length).abs()) {
case 0:
_checkSameLengthTokens(a, b);
case 1:
_checkDifferentLengthTokens(a, b);
}
}

void _checkSameLengthTokens(
ScopeVariable a,
ScopeVariable b,
) {
var diffCount = 0;
var diffIndex = -1;
for (var k = 0; k < a.tokens.length; k++) {
if (a.tokens[k] != b.tokens[k]) {
diffCount++;
if (diffCount > 1) return;
diffIndex = k;
}
}

if (diffCount != 1) return;

if (NameTokenizer.isNonDescriptiveToken(a.tokens[diffIndex]) &&
NameTokenizer.isNonDescriptiveToken(b.tokens[diffIndex])) {
_report(a, b);
}
}

void _checkDifferentLengthTokens(
ScopeVariable a,
ScopeVariable b,
) {
final (longer, shorter) = a.tokens.length > b.tokens.length
? (a.tokens, b.tokens)
: (b.tokens, a.tokens);

if (NameTokenizer.isSubsetWithNonDescriptiveToken(
longer,
shorter,
)) {
_report(a, b);
}
}

void _report(ScopeVariable a, ScopeVariable b) {
if (_reportedNodes.add(a.node)) {
_rule.reportAtToken(a.nameToken);
}
if (_reportedNodes.add(b.node)) {
_rule.reportAtToken(b.nameToken);
}
}
}
Loading
Loading