Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
refactor: update CrossFileMatch to use Sets for duplicates and optimi…
…ze hash grouping logic
  • Loading branch information
Illia Aihistov
Illia Aihistov committed Aug 27, 2026
commit b3e29586caa7ddd20281ead5bf0bf54a3460d030
19 changes: 7 additions & 12 deletions lib/src/lints/avoid_duplicate_code/models/cross_file_match.dart
Original file line number Diff line number Diff 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 @@ -6,9 +7,9 @@ class CrossFileMatch {
/// The hash entry in the current file.
final HashEntry currentEntry;

/// The list of locations in other files where a duplicate of this entry
/// The set of locations in other files where a duplicate of this entry
/// exists.
final List<DuplicateLocation> duplicates;
final Set<DuplicateLocation> duplicates;

/// Creates a new [CrossFileMatch].
const CrossFileMatch({
Expand All @@ -21,14 +22,8 @@ class CrossFileMatch {
extension CrossFileMatchIterableExtension on Iterable<CrossFileMatch> {
/// Converts this iterable of cross-file matches to a map of unique duplicates
/// grouped by entry hash.
Map<int, List<DuplicateLocation>> toDuplicatesByHash() {
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;
}
Map<int, Set<DuplicateLocation>> toDuplicatesByHash() => groupFoldBy(
(match) => match.currentEntry.hash,
(previous, match) => {...?previous, ...match.duplicates},
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class AvoidDuplicateCodeReporter {
void report({
required String filePath,
required List<DuplicateReportContext> contexts,
required Map<int, List<DuplicateLocation>> crossFileDuplicatesByHash,
required Map<int, Set<DuplicateLocation>> crossFileDuplicatesByHash,
}) {
final hashGroups = groupBy(contexts, (c) => c.entry.hash);
final suppressedRanges = <(int, int)>[];
Expand All @@ -51,7 +51,7 @@ class AvoidDuplicateCodeReporter {
!suppressedRanges.anyContainsStrictly(c.entry.range),
)
.toList();
final externalPartners = crossFileDuplicatesByHash[hash] ?? const [];
final externalPartners = crossFileDuplicatesByHash[hash] ?? const {};

if (internalPartners.isEmpty && externalPartners.isEmpty) continue;

Expand All @@ -71,7 +71,7 @@ class AvoidDuplicateCodeReporter {
required String filePath,
required DuplicateReportContext target,
required List<DuplicateReportContext> internalPartners,
required List<DuplicateLocation> externalPartners,
required Set<DuplicateLocation> externalPartners,
}) {
final currentExactHash = target.entry.exactHash;
final internalEntries = internalPartners.map((p) => p.entry).toList();
Expand Down Expand Up @@ -114,7 +114,7 @@ class AvoidDuplicateCodeReporter {
}

Iterable<List<LiteralInfo>> _loadExternalLiterals(
List<DuplicateLocation> locations,
Set<DuplicateLocation> locations,
) => locations
.map(_literalsAnalyzer.loadExternalLiterals)
.nonNulls
Expand All @@ -135,7 +135,7 @@ class AvoidDuplicateCodeReporter {
List<DiagnosticMessage> _buildContextMessages({
required String currentFilePath,
required int currentExactHash,
required List<DuplicateLocation> externalPartners,
required Set<DuplicateLocation> externalPartners,
required Iterable<HashEntry> internalPartners,
}) {
String messageFor(int exactHash) => exactHash == currentExactHash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ class GlobalHashRegistry {
final fileStatusCache = <String, bool>{};

for (final entry in currentEntries) {
final duplicates = <DuplicateLocation>[];
final duplicates = <DuplicateLocation>{};
final locations = _hashToLocations[entry.hash];

if (locations == null) continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ class AvoidDuplicateCodeVisitor extends RecursiveAstVisitor<void> {
bool _isFileExcluded(String path) =>
_analysisOptionsLoader?.isFileExcludedForFile(path) ?? false;

Map<int, List<DuplicateLocation>> _findAndSaveCrossFileMatches(
Map<int, Set<DuplicateLocation>> _findAndSaveCrossFileMatches(
String filePath,
List<HashEntry> hashEntries,
String packageRoot,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,8 +346,10 @@ void main() {
final expectedPathB = p.normalize(
p.join(io.Directory.current.path, 'file_b.dart'),
);
expect(matches.first.duplicates[0].filePath, equals(expectedPathA));
expect(matches.first.duplicates[1].filePath, equals(expectedPathB));
expect(
matches.first.duplicates.map((d) => d.filePath),
containsAll([expectedPathA, expectedPathB]),
);
});

test('findCrossFileMatches processes multiple candidates correctly', () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,63 +6,61 @@ import 'package:test/test.dart';
void main() {
group('CrossFileMatchIterableExtension', () {
test('toDuplicatesByHash groups matches by entry hash', () {
final locA = DuplicateLocation(
filePath: 'lib/a.dart',
entry: _TestFactory.entry(hash: 100, offset: 10),
);
final locB = DuplicateLocation(
filePath: 'lib/b.dart',
entry: _TestFactory.entry(hash: 200, offset: 20),
);
final match1 = CrossFileMatch(
currentEntry: _TestFactory.entry(hash: 100, offset: 0),
duplicates: [
DuplicateLocation(
filePath: 'lib/a.dart',
entry: _TestFactory.entry(hash: 100, offset: 10),
),
],
duplicates: {locA},
);
final match2 = CrossFileMatch(
currentEntry: _TestFactory.entry(hash: 200, offset: 50),
duplicates: [
DuplicateLocation(
filePath: 'lib/b.dart',
entry: _TestFactory.entry(hash: 200, offset: 20),
),
],
duplicates: {locB},
);

final result = [match1, match2].toDuplicatesByHash();

expect(result.keys, containsAll([100, 200]));
expect(result[100], hasLength(1));
expect(result[100]!.first.filePath, 'lib/a.dart');
expect(result[200], hasLength(1));
expect(result[200]!.first.filePath, 'lib/b.dart');
expect(
result,
equals({
100: {locA},
200: {locB},
}),
);
});

test(
'toDuplicatesByHash aggregates duplicates when matches share same hash',
() {
final locA = DuplicateLocation(
filePath: 'lib/a.dart',
entry: _TestFactory.entry(hash: 100, offset: 10),
);
final locB = DuplicateLocation(
filePath: 'lib/b.dart',
entry: _TestFactory.entry(hash: 100, offset: 20),
);
final match1 = CrossFileMatch(
currentEntry: _TestFactory.entry(hash: 100, offset: 0),
duplicates: [
DuplicateLocation(
filePath: 'lib/a.dart',
entry: _TestFactory.entry(hash: 100, offset: 10),
),
],
duplicates: {locA},
);
final match2 = CrossFileMatch(
currentEntry: _TestFactory.entry(hash: 100, offset: 100),
duplicates: [
DuplicateLocation(
filePath: 'lib/b.dart',
entry: _TestFactory.entry(hash: 100, offset: 20),
),
],
duplicates: {locB},
);

final result = [match1, match2].toDuplicatesByHash();

expect(result.keys, equals([100]));
expect(result[100], hasLength(2));
expect(
result[100]!.map((d) => d.filePath),
containsAll(['lib/a.dart', 'lib/b.dart']),
result,
equals({
100: {locA, locB},
}),
);
},
);
Expand All @@ -74,18 +72,21 @@ void main() {
);
final match1 = CrossFileMatch(
currentEntry: _TestFactory.entry(hash: 100, offset: 0),
duplicates: [loc],
duplicates: {loc},
);
final match2 = CrossFileMatch(
currentEntry: _TestFactory.entry(hash: 100, offset: 50),
duplicates: [loc],
duplicates: {loc},
);

final result = [match1, match2].toDuplicatesByHash();

expect(result.keys, equals([100]));
expect(result[100], hasLength(1));
expect(result[100]!.first.filePath, 'lib/a.dart');
expect(
result,
equals({
100: {loc},
}),
);
});
});
}
Expand Down