@@ -14,14 +14,14 @@ public static class CoverageMatcher
1414 /// <summary>
1515 /// <![CDATA[
1616 /// Matches coverage data to metrics collection.
17- /// Primary match: method name + line number + file path
18- /// Fallback match: class name + file path (for class-level coverage)
19- /// Tertiary match: file path only (file-level aggregate, e.g. Visual Studio coverage XML)
17+ /// Primary match: method name + line number + file path (O(1) lookup)
18+ /// Fallback match: class name + file path (O(1) lookup)
19+ /// Tertiary match: file path only — file-level aggregate (O(1) lookup)
20+ ///
21+ /// All three lookup levels are pre-indexed so the overall algorithm is O(N+M)
22+ /// rather than the original O(N×M) with repeated linear scans.
2023 /// ]]>
2124 /// </summary>
22- /// <param name="metricsCollection">The cognitive metrics collection</param>
23- /// <param name="coverageData">The coverage data from Cobertura report</param>
24- /// <returns>Dictionary mapping metrics to their matched coverage data</returns>
2525 public static Dictionary < CognitiveMetrics , Coverage > MatchCoverageToMetrics (
2626 CognitiveMetricsCollection metricsCollection ,
2727 IEnumerable < Coverage > coverageData
@@ -32,7 +32,6 @@ public static Dictionary<CognitiveMetrics, Coverage> MatchCoverageToMetrics(
3232 IEnumerable < Coverage > coverageData ,
3333 IProgress < AnalysisProgress > ? progress
3434 ) {
35- var matches = new Dictionary < CognitiveMetrics , Coverage > ( ) ;
3635 var coverageList = coverageData . ToList ( ) ;
3736 int totalMethods = metricsCollection . Count ;
3837
@@ -42,16 +41,18 @@ public static Dictionary<CognitiveMetrics, Coverage> MatchCoverageToMetrics(
4241 ProcessedFiles : 0
4342 ) ) ;
4443
44+ CoverageIndex index = CoverageIndex . Build ( coverageList ) ;
45+
46+ var matches = new Dictionary < CognitiveMetrics , Coverage > ( totalMethods ) ;
4547 const int progressBatchSize = 100 ;
4648 int processedMethods = 0 ;
4749
4850 foreach ( CognitiveMetrics metrics in metricsCollection )
4951 {
50- Coverage ? matchedCoverage = FindMatchingCoverage ( metrics , coverageList ) ;
51-
52- if ( matchedCoverage != null )
52+ Coverage ? matched = index . Find ( metrics ) ;
53+ if ( matched != null )
5354 {
54- matches [ metrics ] = matchedCoverage ;
55+ matches [ metrics ] = matched ;
5556 }
5657
5758 processedMethods ++ ;
@@ -74,68 +75,138 @@ public static Dictionary<CognitiveMetrics, Coverage> MatchCoverageToMetrics(
7475 return matches ;
7576 }
7677
77- private static Coverage ? FindMatchingCoverage ( CognitiveMetrics metrics , List < Coverage > coverageList )
78+ internal static string NormalizePath ( string path )
7879 {
79- // Primary match: method name + line number + file path
80- Coverage ? methodMatch = coverageList . FirstOrDefault ( c =>
81- c . IsMethodLevel &&
82- NormalizePath ( c . FilePath ) == NormalizePath ( metrics . FilePath ) &&
83- c . MethodName == metrics . MethodName &&
84- c . MethodLineNumber == metrics . methodLineNumber
85- ) ;
86-
87- if ( methodMatch != null )
80+ if ( string . IsNullOrEmpty ( path ) )
8881 {
89- return methodMatch ;
82+ return string . Empty ;
9083 }
9184
92- // Fallback match: class name + file path (for class-level coverage)
93- Coverage ? classMatch = coverageList . FirstOrDefault ( c =>
94- ! c . IsMethodLevel &&
95- NormalizePath ( c . FilePath ) == NormalizePath ( metrics . FilePath ) &&
96- ( c . FullyQualifiedClassName == metrics . ClassName ||
97- c . FullyQualifiedClassName . EndsWith ( "." + metrics . ClassName ) ||
98- metrics . ClassName . EndsWith ( "." + c . FullyQualifiedClassName ) )
99- ) ;
100-
101- if ( classMatch != null )
85+ try
10286 {
103- return classMatch ;
87+ string absolutePath = Path . IsPathRooted ( path ) ? path : Path . GetFullPath ( path ) ;
88+ return absolutePath . Replace ( '\\ ' , '/' ) . TrimEnd ( '/' ) ;
89+ }
90+ catch
91+ {
92+ return path . Replace ( '\\ ' , '/' ) . TrimEnd ( '/' ) ;
10493 }
105-
106- // File-level aggregate (empty FQCN): same line stats for every method in the file
107- return coverageList . FirstOrDefault ( c =>
108- ! c . IsMethodLevel &&
109- string . IsNullOrEmpty ( c . FullyQualifiedClassName ) &&
110- NormalizePath ( c . FilePath ) == NormalizePath ( metrics . FilePath ) ) ;
11194 }
11295
11396 /// <summary>
114- /// <![CDATA[
115- /// Normalizes file paths for comparison by converting to absolute paths and standardizing separators.
116- /// ]]>
97+ /// Pre-built three-level lookup so each metrics lookup is O(1) instead of O(M).
11798 /// </summary>
118- private static string NormalizePath ( string path )
99+ private sealed class CoverageIndex
119100 {
120- if ( string . IsNullOrEmpty ( path ) )
101+ // key: "normalizedPath|methodName|lineNumber"
102+ private readonly Dictionary < string , Coverage > _methodLevel ;
103+
104+ // key: "normalizedPath|fullyQualifiedClassName" (class-level entries only)
105+ private readonly Dictionary < string , Coverage > _classLevel ;
106+
107+ // key: normalizedPath (file-aggregate entries: empty FQCN, no method name)
108+ private readonly Dictionary < string , Coverage > _fileLevel ;
109+
110+ private CoverageIndex (
111+ Dictionary < string , Coverage > methodLevel ,
112+ Dictionary < string , Coverage > classLevel ,
113+ Dictionary < string , Coverage > fileLevel
114+ ) {
115+ _methodLevel = methodLevel ;
116+ _classLevel = classLevel ;
117+ _fileLevel = fileLevel ;
118+ }
119+
120+ internal static CoverageIndex Build ( List < Coverage > coverageList )
121121 {
122- return string . Empty ;
122+ var methodLevel = new Dictionary < string , Coverage > ( StringComparer . OrdinalIgnoreCase ) ;
123+ var classLevel = new Dictionary < string , Coverage > ( StringComparer . OrdinalIgnoreCase ) ;
124+ var fileLevel = new Dictionary < string , Coverage > ( StringComparer . OrdinalIgnoreCase ) ;
125+
126+ foreach ( Coverage c in coverageList )
127+ {
128+ string normalizedPath = NormalizePath ( c . FilePath ) ;
129+
130+ if ( c . IsMethodLevel )
131+ {
132+ // Primary: method name + line + file
133+ string key = MethodKey ( normalizedPath , c . MethodName , c . MethodLineNumber ) ;
134+ methodLevel . TryAdd ( key , c ) ;
135+ }
136+ else if ( string . IsNullOrEmpty ( c . FullyQualifiedClassName ) )
137+ {
138+ // Tertiary: file-level aggregate
139+ fileLevel . TryAdd ( normalizedPath , c ) ;
140+ }
141+ else
142+ {
143+ // Secondary: class-level (add all FQCN variations so either direction matches)
144+ string key = ClassKey ( normalizedPath , c . FullyQualifiedClassName ) ;
145+ classLevel . TryAdd ( key , c ) ;
146+ }
147+ }
148+
149+ return new CoverageIndex ( methodLevel , classLevel , fileLevel ) ;
123150 }
124151
125- try
152+ internal Coverage ? Find ( CognitiveMetrics metrics )
126153 {
127- // Convert to absolute path if relative, then normalize separators
128- string absolutePath = Path . IsPathRooted ( path )
129- ? path
130- : Path . GetFullPath ( path ) ;
154+ string normalizedPath = NormalizePath ( metrics . FilePath ) ;
131155
132- // Normalize directory separators (handle both / and \)
133- return absolutePath . Replace ( '\\ ' , '/' ) . TrimEnd ( '/' ) ;
156+ // 1. Method-level exact match
157+ string methodKey = MethodKey ( normalizedPath , metrics . MethodName , metrics . methodLineNumber ) ;
158+ if ( _methodLevel . TryGetValue ( methodKey , out Coverage ? methodMatch ) )
159+ {
160+ return methodMatch ;
161+ }
162+
163+ // 2. Class-level match — try both the full FQCN stored in the index and
164+ // partial suffix-based lookups to replicate the original fallback logic.
165+ Coverage ? classMatch = FindClassLevelMatch ( normalizedPath , metrics . ClassName ) ;
166+ if ( classMatch != null )
167+ {
168+ return classMatch ;
169+ }
170+
171+ // 3. File-level aggregate
172+ return _fileLevel . TryGetValue ( normalizedPath , out Coverage ? fileMatch ) ? fileMatch : null ;
134173 }
135- catch
174+
175+ private Coverage ? FindClassLevelMatch ( string normalizedPath , string metricsClassName )
136176 {
137- // If path is invalid, just normalize separators
138- return path . Replace ( '\\ ' , '/' ) . TrimEnd ( '/' ) ;
177+ // Direct key for coverage FQCN == metrics ClassName
178+ string directKey = ClassKey ( normalizedPath , metricsClassName ) ;
179+ if ( _classLevel . TryGetValue ( directKey , out Coverage ? direct ) )
180+ {
181+ return direct ;
182+ }
183+
184+ // Coverage FQCN ends with ".metricsClassName" (e.g. "MyNs.Engine" vs "Engine")
185+ // or metrics class name ends with ".coverageFQCN" — scan only the entries for
186+ // this path to keep worst case O(classes_per_file) instead of O(M).
187+ string prefix = normalizedPath + "|" ;
188+ foreach ( KeyValuePair < string , Coverage > kv in _classLevel )
189+ {
190+ if ( ! kv . Key . StartsWith ( prefix , StringComparison . OrdinalIgnoreCase ) )
191+ {
192+ continue ;
193+ }
194+
195+ string fqcn = kv . Value . FullyQualifiedClassName ;
196+ if ( fqcn . EndsWith ( "." + metricsClassName , StringComparison . Ordinal )
197+ || metricsClassName . EndsWith ( "." + fqcn , StringComparison . Ordinal ) )
198+ {
199+ return kv . Value ;
200+ }
201+ }
202+
203+ return null ;
139204 }
205+
206+ private static string MethodKey ( string normalizedPath , string methodName , int lineNumber )
207+ => $ "{ normalizedPath } |{ methodName } |{ lineNumber } ";
208+
209+ private static string ClassKey ( string normalizedPath , string fqcn )
210+ => $ "{ normalizedPath } |{ fqcn } ";
140211 }
141212}
0 commit comments