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
1 change: 0 additions & 1 deletion lib/analysis_options.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,6 @@ solid_lints:
avoid_global_state: true
avoid_duplicate_code:
min_tokens: 30
check_blocks: true
exclude:
- method_name: initState
- method_name: dispose
Expand Down
52 changes: 35 additions & 17 deletions lib/src/lints/avoid_duplicate_code/avoid_duplicate_code_rule.dart
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@ import 'package:analyzer/analysis_rule/rule_visitor_registry.dart';
import 'package:analyzer/error/error.dart';
import 'package:solid_lints/src/lints/avoid_duplicate_code/models/avoid_duplicate_code_parameters.dart';
import 'package:solid_lints/src/lints/avoid_duplicate_code/visitors/avoid_duplicate_code_visitor.dart';
import 'package:solid_lints/src/models/solid_lint_rule.dart';
import 'package:solid_lints/src/models/solid_multi_lint_rule.dart';
import 'package:solid_lints/src/utils/ignore_matcher.dart';

/// A lint rule that detects duplicated code blocks (clones) across the project.
Expand All@@ -18,13 +18,13 @@ import 'package:solid_lints/src/utils/ignore_matcher.dart';
/// The rule is built upon the fundamental code clone classification by
/// **Roy & Cordy (2007)** (*"A Survey on Software Clone Detection Research"*):
///
/// :::note Type 2 Clones
/// The rule focuses on **Type 2 clones** (syntactic clones): structurally
/// identical AST subtrees where names of local variables, formal parameters,
/// or literal values may differ (if configured via `ignore_identifiers` or
/// `ignore_literals`). While plain text diff tools only catch exact
/// copies (Type 1), this rule operates on the AST level to detect copy-pasted
/// logic even after variable renaming or code formatting changes.
/// :::note Type 2 & Type 3 Clones
/// The rule focuses on **Type 2 clones** (syntactic clones with renamed
/// variables) and **Type 3 clones with differing literals** (structurally
/// identical AST subtrees where literal values differ). While plain text diff
/// tools only catch exact copies (Type 1), this rule operates on the AST level
/// to detect copy-pasted logic even after variable renaming, code formatting
/// changes, or literal constant tweaks.
/// :::
/// :::info Sequential Variable Indexing
/// Local variable and parameter names in the AST subtree are replaced with
Expand All@@ -34,9 +34,13 @@ import 'package:solid_lints/src/utils/ignore_matcher.dart';
/// renamed (e.g., `x` to `item`).
/// :::
///
/// :::info Structural Hashing
/// Builds an AST subtree fingerprint using **Bob Jenkins' One-at-a-time**
/// **hash** algorithm (structural hashing).
/// :::info Dual Structural & Exact Hashing
/// Computes both a **structural hash** (ignoring literal values) and an
/// **exact hash** (including literal values) in a single pass using **Bob
/// Jenkins' One-at-a-time hash** algorithm. When duplicate candidates have
/// identical structural hashes but differing exact hashes, the rule provides
/// detailed context messages showing which literal slots differ (e.g., `[1, 2]`
/// or `['hello', 'world']`).
/// :::
///
/// :::info Nested Clone Suppression
Expand DownExpand Up@@ -104,15 +108,12 @@ import 'package:solid_lints/src/utils/ignore_matcher.dart';
/// diagnostics:
/// avoid_duplicate_code:
/// min_tokens: 30
/// ignore_literals: false
/// ignore_identifiers: true
/// check_blocks: true
/// exclude:
/// - method_name: initState
/// - method_name: dispose
/// ```
class AvoidDuplicateCodeRule
extends SolidLintRule<AvoidDuplicateCodeParameters> {
extends SolidMultiLintRule<AvoidDuplicateCodeParameters> {
/// Name of the lint.
static const lintName = 'avoid_duplicate_code';

Expand All@@ -122,13 +123,30 @@ class AvoidDuplicateCodeRule
'Consider extracting the shared logic into a common function.',
);

static const _differentLiteralsCode = LintCode(
lintName,
'This code has identical structure but differs in literal values{0}.\n'
'Extracting it directly will alter behavior — consider extracting a '
'shared function with parameters for the differing values.',
uniqueName: 'avoid_duplicate_code_different_literals',
);

/// Diagnostic code for exact duplicates.
DiagnosticCode get exactCode => _code;

/// Diagnostic code for structural duplicates with differing literal values.
DiagnosticCode get differentLiteralsCode => _differentLiteralsCode;

@override
DiagnosticCode get diagnosticCode => _code;
List<DiagnosticCode> get diagnosticCodes => [
_code,
_differentLiteralsCode,
];

/// Creates a new instance of [AvoidDuplicateCodeRule].
AvoidDuplicateCodeRule({
required super.analysisOptionsLoader,
}) : super.withParameters(
}) : super(
name: lintName,
description:
'Detects structurally identical function/method bodies '
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
import 'package:solid_lints/src/lints/avoid_duplicate_code/models/body_candidate.dart';
import 'package:solid_lints/src/lints/avoid_duplicate_code/models/hash_entry.dart';

/// A record pairing a [BodyCandidate] with its computed [HashEntry].
typedef AnalyzedCandidate = ({
BodyCandidate candidate,
HashEntry entry,
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,90 +39,6 @@ class AvoidDuplicateCodeParameters {
/// ```
final int minTokens;

/// When `true`, literal values (strings, numbers, booleans) are excluded
/// from the structural hash, ignoring literal differences during duplicate
/// search.
///
/// ##### Example:
/// ```dart
/// // Function A
/// double calculateTax(double amount) {
/// final tax = amount * 0.20;
/// return amount + tax;
/// }
///
/// // Function B (differs only by literal 0.15 vs 0.20)
/// double calculateDiscount(double amount) {
/// final tax = amount * 0.15;
/// return amount + tax;
/// }
/// ```
/// * **When `ignore_literals: false` (default):** **NOT reported**
/// because numbers `0.20` and `0.15` differ.
/// * **When `ignore_literals: true`:** **Reported as duplicate**
/// because literal values are ignored.
final bool ignoreLiterals;

/// When `true`, local variable and parameter names are excluded from the
/// structural hash (using Sequential Variable Indexing). This enables
/// detection of renamed variable clones (Type 2). Note that method, class,
/// and field names are NOT ignored to prevent excessive false positives.
///
/// ##### Example:
/// ```dart
/// // Function A
/// double calcTotal(double price, int count) {
/// final subtotal = price * count;
/// return subtotal > 100 ? subtotal * 0.9 : subtotal;
/// }
///
/// // Function B (renamed: price->amount, count->qty, subtotal->total)
/// double calcTotal(double amount, int qty) {
/// final total = amount * qty;
/// return total > 100 ? total * 0.9 : total;
/// }
/// ```
/// * **When `ignore_identifiers: true` (default):** **Reported as**
/// **duplicate** (Type 2 Clone).
/// * **When `ignore_identifiers: false`:** **NOT reported as duplicate**
/// because local names differ.
final bool ignoreIdentifiers;

/// When `true`, statement blocks (such as `if` blocks or loops) inside
/// functions are also checked for duplication.
///
/// ##### Example:
/// ```dart
/// // Function A
/// void processUser(User user) {
/// print('Starting user process...');
/// if (user.isActive) {
/// logger.log('Processing user');
/// user.lastActive = DateTime.now();
/// user.status = UserStatus.active;
/// repository.save(user);
/// analytics.track('user_processed', user.id);
/// }
/// }
///
/// // Function B (different function, same inner if block)
/// void processAdmin(User user) {
/// validateAdmin(user);
/// if (user.isActive) {
/// logger.log('Processing user');
/// user.lastActive = DateTime.now();
/// user.status = UserStatus.active;
/// repository.save(user);
/// analytics.track('user_processed', user.id);
/// }
/// }
/// ```
/// * **When `check_blocks: true` (default):** **Reported as duplicate**
/// for the inner `if` block.
/// * **When `check_blocks: false`:** **NOT reported as duplicate**
/// because nested `{ ... }` block nodes are skipped.
final bool checkBlocks;

/// A list of methods/functions that should be excluded from clone detection.
final ExcludedIdentifiersListParameter exclude;

Expand All@@ -135,37 +51,25 @@ class AvoidDuplicateCodeParameters {
/// Constructor for [AvoidDuplicateCodeParameters] model.
const AvoidDuplicateCodeParameters({
required this.minTokens,
required this.ignoreLiterals,
required this.ignoreIdentifiers,
required this.checkBlocks,
required this.exclude,
});

/// Empty [AvoidDuplicateCodeParameters] model with default values.
factory AvoidDuplicateCodeParameters.empty() => AvoidDuplicateCodeParameters(
minTokens: _defaultMinTokens,
ignoreLiterals: false,
ignoreIdentifiers: true,
checkBlocks: true,
exclude: _defaultExclude,
);

/// Creates parameters from JSON configuration.
factory AvoidDuplicateCodeParameters.fromJson(Map<String, Object?> json) =>
AvoidDuplicateCodeParameters(
minTokens: json['min_tokens'] as int? ?? _defaultMinTokens,
ignoreLiterals: json['ignore_literals'] as bool? ?? false,
ignoreIdentifiers: json['ignore_identifiers'] as bool? ?? true,
checkBlocks: json['check_blocks'] as bool? ?? true,
exclude: ExcludedIdentifiersListParameter.defaultFromJson(json),
);

/// Converts the parameters to a JSON-compatible Map.
Map<String, Object?> toJson() => {
'min_tokens': minTokens,
'ignore_literals': ignoreLiterals,
'ignore_identifiers': ignoreIdentifiers,
'check_blocks': checkBlocks,
'exclude': exclude.exclude.map((e) => e.toJson()).toList(),
};

Expand All@@ -174,17 +78,11 @@ class AvoidDuplicateCodeParameters {
identical(this, other) ||
other is AvoidDuplicateCodeParameters &&
other.minTokens == minTokens &&
other.ignoreLiterals == ignoreLiterals &&
other.ignoreIdentifiers == ignoreIdentifiers &&
other.checkBlocks == checkBlocks &&
other.exclude == exclude;

@override
int get hashCode => Object.hash(
minTokens,
ignoreLiterals,
ignoreIdentifiers,
checkBlocks,
exclude,
);
}
11 changes: 8 additions & 3 deletions lib/src/lints/avoid_duplicate_code/models/cross_file_match.dart
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:solid_lints/src/lints/avoid_duplicate_code/models/duplicate_location.dart';
import 'package:solid_lints/src/lints/avoid_duplicate_code/models/hash_entry.dart';

Expand All@@ -21,7 +22,11 @@ class CrossFileMatch {
extension CrossFileMatchIterableExtension on Iterable<CrossFileMatch> {
/// Converts this iterable of cross-file matches to a map of duplicates
/// grouped by hash.
Map<int, List<DuplicateLocation>> toDuplicatesByHash() => {
for (final match in this) match.currentEntry.hash: match.duplicates,
};
Map<int, List<DuplicateLocation>> toDuplicatesByHash() =>
groupBy(this, (m) => m.currentEntry.hash).map(
(hash, matches) => MapEntry(
hash,
matches.expand((m) => m.duplicates).toSet().toList(),
),
);
}
12 changes: 9 additions & 3 deletions lib/src/lints/avoid_duplicate_code/models/hash_entry.dart
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,9 @@ class HashEntry {
/// The structural hash of the AST subtree.
final int hash;

/// The exact hash of the AST subtree (including literal values).
final int exactHash;

/// The line number where this candidate starts.
final int lineNumber;

Expand All@@ -22,15 +25,17 @@ class HashEntry {
/// Creates a new [HashEntry].
const HashEntry({
required this.hash,
required this.exactHash,
required this.lineNumber,
required this.offset,
required this.length,
required this.tokenCount,
this.offset = 0,
this.length = 0,
});

/// Converts this [HashEntry] to a JSON-compatible map using shortened keys.
Map<String, Object?> toJson() => {
'h': hash,
'e': exactHash,
'n': lineNumber,
'o': offset,
'l': length,
Expand All@@ -40,8 +45,9 @@ class HashEntry {
/// Creates a [HashEntry] from a JSON map.
HashEntry.fromJson(Map<String, Object?> json)
: hash = json['h']! as int,
exactHash = json['e']! as int,
lineNumber = json['n']! as int,
offset = (json['o'] ?? 0) as int,
length = (json['l'] ?? 0) as int,
tokenCount = (json['t'] ?? json['s'] ?? 0) as int;
tokenCount = (json['t'] ?? 0) as int;
Comment thread
solid-illiaaihistov marked this conversation as resolved.
}
18 changes: 18 additions & 0 deletions lib/src/lints/avoid_duplicate_code/models/literal_info.dart
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
/// Represents information about a literal found within a code block.
class LiteralInfo {
/// The string representation of the literal value.
final String text;

/// The character offset where the literal begins.
final int offset;

/// The character length of the literal.
final int length;

/// Creates a new [LiteralInfo].
const LiteralInfo({
required this.text,
required this.offset,
required this.length,
});
}
Loading