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
3 changes: 2 additions & 1 deletion src/Crap4DotNet.Core/Matching/CoberturaMethodParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ public static string ToCanonicalKey(CoberturaMethodCoverage coverage)
var className = NormalizeClassName(coverage.ClassName);
var methodName = NormalizeMethodName(coverage.MethodName, className);
var signature = NormalizeSignature(coverage.Signature);
return $"{className}.{methodName}{signature}";
return $"{className}.{methodName}"
+ MethodKeyHelper.NormalizeSignatureForMatching(signature);
}

private static string NormalizeClassName(string className)
Expand Down
43 changes: 41 additions & 2 deletions src/Crap4DotNet.Core/Matching/MethodCoverageMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace Crap4DotNet.Core.Matching;
/// <summary>
/// Performs a left-outer-join from complexity results to coverage entries per spec 6.4.
/// Every source method produces a result; unmatched methods default to coverage 0.0.
/// Uses two-pass matching: exact canonical key first, then name-only fallback.
/// Matching passes, in order: exact canonical key, relaxed key, name-only key.
/// </summary>
public static class MethodCoverageMatcher
{
Expand All @@ -16,22 +16,29 @@ public static MatchResult Match(
{
// Build coverage lookups by normalized key
var fullKeyLookup = new Dictionary<string, List<CoberturaMethodCoverage>>(StringComparer.Ordinal);
var relaxedKeyLookup = new Dictionary<string, List<CoberturaMethodCoverage>>(StringComparer.Ordinal);
var nameKeyLookup = new Dictionary<string, List<CoberturaMethodCoverage>>(StringComparer.Ordinal);

foreach (var entry in coverageEntries)
{
var fullKey = CoberturaMethodParser.ToCanonicalKey(entry);
AddToLookup(fullKeyLookup, fullKey, entry);

AddToLookup(relaxedKeyLookup, RelaxKey(fullKey), entry);

var nameKey = MethodKeyHelper.GetNameOnlyKey(fullKey);
AddToLookup(nameKeyLookup, nameKey, entry);
}

var matchedFullKeys = new HashSet<string>(StringComparer.Ordinal);
var matchedRelaxedKeys = new HashSet<string>(StringComparer.Ordinal);
var matchedNameKeys = new HashSet<string>(StringComparer.Ordinal);
var methods = new List<MatchedMethod>();
var unmatchedNames = new List<string>();
var warnings = new List<DiagnosticWarning>();
var sourceCountByRelaxedKey = complexityResults
.GroupBy(c => RelaxKey(RoslynMethodParser.ToCanonicalKey(c.Identity)), StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);

foreach (var complexity in complexityResults)
{
Expand All @@ -49,6 +56,22 @@ public static MatchResult Match(
continue;
}

// Pass 1b: Relaxed key. Match only when exactly one coverage entry and exactly
// one source method have this key.
var relaxedKey = RelaxKey(fullKey);
if (relaxedKeyLookup.TryGetValue(relaxedKey, out var relaxedMatches)
&& relaxedMatches.Count == 1
&& sourceCountByRelaxedKey[relaxedKey] == 1)
{
matchedRelaxedKeys.Add(relaxedKey);
methods.Add(new MatchedMethod
{
Complexity = complexity,
Coverage = relaxedMatches[0].Coverage
});
continue;
}

// Pass 2: Fallback to name-only key (without signature)
var nameKey = MethodKeyHelper.GetNameOnlyKey(fullKey);
if (nameKeyLookup.TryGetValue(nameKey, out var nameMatches) && nameMatches.Count == 1)
Expand Down Expand Up @@ -80,7 +103,10 @@ public static MatchResult Match(
if (matchedFullKeys.Contains(kvp.Key))
continue;

// Check if matched by name-only fallback
// Check if matched by the relaxed or name-only fallback
if (matchedRelaxedKeys.Contains(RelaxKey(kvp.Key)))
continue;

var nameKey = MethodKeyHelper.GetNameOnlyKey(kvp.Key);
if (matchedNameKeys.Contains(nameKey))
continue;
Expand Down Expand Up @@ -138,6 +164,19 @@ public static MatchResult Match(
};
}

/// <summary>
/// Canonical key without the method's generic arity and without <c>?</c> annotations.
/// Cobertura method names often have no arity, and CLR signatures have no nullable reference types.
/// </summary>
private static string RelaxKey(string canonicalKey)
{
var sigStart = MethodKeyHelper.FindSignatureStart(canonicalKey);
return sigStart < 0
? MethodKeyHelper.StripMethodGenericArity(canonicalKey)
: MethodKeyHelper.StripMethodGenericArity(canonicalKey[..sigStart])
+ canonicalKey[sigStart..].Replace("?", "", StringComparison.Ordinal);
}

private static void AddToLookup(
Dictionary<string, List<CoberturaMethodCoverage>> lookup,
string key,
Expand Down
60 changes: 60 additions & 0 deletions src/Crap4DotNet.Core/Matching/MethodIdentityNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,66 @@ public static List<string> SplitTypeList(string typeList)
return result;
}

/// <summary>
/// Strip the generic arity marker from the method-name segment only.
/// </summary>
/// <remarks>
/// Class-level arity is kept. Cobertura encodes it as a backtick (<c>Cache`1</c>).
/// </remarks>
public static string StripMethodGenericArity(string namePart)
{
var depth = 0;
var lastDot = -1;
for (var i = 0; i < namePart.Length; i++)
{
switch (namePart[i])
{
case '<': depth++; break;
case '>': depth--; break;
case '.' when depth == 0: lastDot = i; break;
}
}

var segStart = lastDot + 1;
var segment = namePart[segStart..];
var open = segment.IndexOf('<', StringComparison.Ordinal);
if (open < 0 || !segment.EndsWith('>'))
return namePart;

return namePart[..segStart] + segment[..open];
}

/// <summary>
/// Fold the <c>out</c> and <c>in</c> parameter modifiers to <c>ref</c>.
/// </summary>
/// <remarks>
/// A CLR signature has one by-ref marker for all three modifiers.
/// <c>?</c> annotations are kept, so <c>Foo(int)</c> and <c>Foo(int?)</c> stay distinct.
/// </remarks>
public static string NormalizeSignatureForMatching(string signature)
{
if (string.IsNullOrEmpty(signature) || signature == "()")
return "()";
if (!signature.StartsWith('(') || !signature.EndsWith(')'))
return signature;

var inner = signature[1..^1];
if (string.IsNullOrWhiteSpace(inner))
return "()";

var reduced = SplitTypeList(inner).Select(t =>
{
var x = t.Trim();
if (x.StartsWith("out ", StringComparison.Ordinal))
x = "ref " + x[4..];
else if (x.StartsWith("in ", StringComparison.Ordinal))
x = "ref " + x[3..];
return x;
});

return "(" + string.Join(", ", reduced) + ")";
}

/// <summary>
/// Convert CLR backtick generic arity notation to angle bracket notation.
/// Cache`1 → Cache&lt;&gt;, Dictionary`2 → Dictionary&lt;,&gt;
Expand Down
2 changes: 1 addition & 1 deletion src/Crap4DotNet.Core/Matching/RoslynMethodParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,6 @@ private static string NormalizeGenericTypeParams(string fullName)
return "<" + new string(',', paramCount - 1) + ">";
});

return normalized + sigPart;
return normalized + MethodKeyHelper.NormalizeSignatureForMatching(sigPart);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using Crap4DotNet.Core.Complexity;
using Crap4DotNet.Core.Coverage;
using Crap4DotNet.Core.Matching;
using Crap4DotNet.Core.Models;
using FluentAssertions;
using Xunit;

namespace Crap4DotNet.Core.Tests.Matching;

/// <summary>
/// Coverage matching for generic methods, parameter modifiers, nullable annotations, and overload sets.
/// </summary>
public sealed class GenericAndOverloadMatchingTests
{
private static MethodComplexityResult Source(string methodName, string signature, string className = "Helper") =>
new()
{
Identity = new MethodIdentity
{
Namespace = "MyApp",
ClassName = className,
MethodName = methodName,
Signature = signature,
FullName = $"MyApp.{className}.{methodName}{signature}",
FilePath = "Test.cs",
LineNumber = 1
},
Complexity = 10
};

private static CoberturaMethodCoverage Cover(string methodName, string signature, double coverage,
string className = "MyApp.Helper") =>
new() { ClassName = className, MethodName = methodName, Signature = signature, Coverage = coverage };

[Fact]
public void GenericMethod_WithByRefOutParameter_TakesItsCoverage()
{
var result = MethodCoverageMatcher.Match(
[Source("TryConvertToNumeric<T>", "(string, out T)")],
[Cover("TryConvertToNumeric", "(System.String,T&)", 1.0)]);

result.Methods.Should().HaveCount(1);
result.Methods[0].Coverage.Should().Be(1.0);
}

[Fact]
public void GenericMethod_NoParameters_TakesItsCoverage()
{
var result = MethodCoverageMatcher.Match(
[Source("GetObjectTypeName<T>", "()")],
[Cover("GetObjectTypeName", "()", 0.75)]);

result.Methods[0].Coverage.Should().Be(0.75);
}

[Fact]
public void NullableReferenceAnnotation_DoesNotBlockTheMatch()
{
var result = MethodCoverageMatcher.Match(
[Source("BuildLink", "(string, string, int, string?)")],
[Cover("BuildLink", "(System.String,System.String,System.Int32,System.String)", 0.5)]);

result.Methods[0].Coverage.Should().Be(0.5);
}

[Fact]
public void OverloadSet_PairsEachOverloadWithItsOwnCoverage()
{
var result = MethodCoverageMatcher.Match(
[
Source("BuildLink", "(string, string, int, string?)"),
Source("BuildLink", "(EcsDtoBase, string, string?)")
],
[
Cover("BuildLink", "(System.String,System.String,System.Int32,System.String)", 0.25),
Cover("BuildLink", "(MyApp.Dto.EcsDtoBase,System.String,System.String)", 0.75)
]);

result.Methods.Should().HaveCount(2);
result.Methods[0].Coverage.Should().Be(0.25);
result.Methods[1].Coverage.Should().Be(0.75);
}

[Fact]
public void NullableValueTypeOverloads_KeepTheirOwnCoverage()
{
var result = MethodCoverageMatcher.Match(
[Source("Foo", "(int)"), Source("Foo", "(int?)")],
[
Cover("Foo", "(System.Int32)", 0.2),
Cover("Foo", "(System.Nullable`1<System.Int32>)", 0.8)
]);

result.Methods[0].Coverage.Should().Be(0.2);
result.Methods[1].Coverage.Should().Be(0.8);
}

[Fact]
public void RelaxedMatch_SharedBySeveralSourceMethods_Refuses()
{
// Foo<T>(int) and Foo(int) share the relaxed key Foo(int).
var result = MethodCoverageMatcher.Match(
[Source("Foo", "(int)"), Source("Foo<T>", "(int)")],
[Cover("Foo", "(System.Int32)", 0.6)]);

result.Methods[0].Coverage.Should().Be(0.6);
result.Methods[1].Coverage.Should().Be(0.0);
}

[Fact]
public void GenuinelyUncoveredMethod_StillReportsZero()
{
var result = MethodCoverageMatcher.Match(
[Source("Untested<T>", "(int)")],
[Cover("SomethingElse", "()", 1.0)]);

result.Methods[0].Coverage.Should().Be(0.0);
result.Warnings.Should().Contain(w => w.Code == "UNMATCHED_METHODS");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,9 @@ public void FallbackMatch_SignatureMismatch_SingleCandidate()
}

[Fact]
public void FallbackMatch_OverloadedMethods_NoFallback()
public void OverloadedMethods_ResolvedByTheExactSignaturePass()
{
// Two overloads with incompatible signatures → fallback finds multiple candidates → no match
// `out int` and `System.Int32&` produce the same key.
var complexity = new[]
{
MakeComplexity("Process", signature: "(out int)")
Expand All @@ -360,9 +360,29 @@ public void FallbackMatch_OverloadedMethods_NoFallback()

var result = MethodCoverageMatcher.Match(complexity, coverage);

// Fallback finds 2 candidates for "Process" → ambiguous → defaults to 0.0
result.Methods.Should().ContainSingle()
.Which.Coverage.Should().Be(0.7);
}

[Fact]
public void ArityRelaxedMatch_AmbiguousCandidates_Refuses()
{
var complexity = new[]
{
MakeComplexity("Find<T>", signature: "(int)")
};
var coverage = new[]
{
// Neither entry matches Find<T> exactly; both share its relaxed key.
MakeCoverage("Find`2", signature: "(System.Int32)", coverage: 0.9),
MakeCoverage("Find", signature: "(System.Int32)", coverage: 0.1)
};

var result = MethodCoverageMatcher.Match(complexity, coverage);

result.Methods.Should().ContainSingle()
.Which.Coverage.Should().Be(0.0);
result.Warnings.Should().Contain(w => w.Code == "UNMATCHED_METHODS");
}

// === Preserves order ===
Expand Down