Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 number Diff line number Diff 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,
);
}
17 changes: 12 additions & 5 deletions lib/src/lints/avoid_duplicate_code/models/cross_file_match.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,16 @@ class CrossFileMatch {

/// Extension on an iterable of [CrossFileMatch] to group duplicates by hash.
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,
};
/// Converts this iterable of cross-file matches to a map of unique duplicates
/// grouped by entry hash.
Map<int, List<DuplicateLocation>> toDuplicatesByHash() {

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.

I think it might be the perfect use case for Set?
Also we can use groupFoldBy from collection for that

  Map<int, Set<DuplicateLocation>> toDuplicatesByHash() => groupFoldBy(
    (match) => match.currentEntry.hash,
    (previous, match) => {...?previous, ...match.duplicates},
  );

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! Thank you!

final result = <int, List<DuplicateLocation>>{};

for (final CrossFileMatch(:currentEntry, :duplicates) in this) {
final locations = result[currentEntry.hash] ??= [];
locations.addAll(duplicates.where((loc) => !locations.contains(loc)));
}

return result;
}
}
12 changes: 9 additions & 3 deletions lib/src/lints/avoid_duplicate_code/models/hash_entry.dart
Original file line number Diff line number Diff 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.
Resolved
}
18 changes: 18 additions & 0 deletions lib/src/lints/avoid_duplicate_code/models/literal_info.dart
Original file line number Diff line number Diff 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