Skip to content
Merged
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
195 changes: 174 additions & 21 deletions src/DotNetApiDiff/ApiExtraction/ApiComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,14 +187,27 @@ public IEnumerable<ApiDifference> CompareTypes(IEnumerable<Type> oldTypes, IEnum
}
else
{
// Check if this type matches any mapped old types
var mappedOldNames = _nameMapper.MapFullTypeName(newTypeName).ToList();

foreach (var mappedName in mappedOldNames)
// Check if any old type maps to this new type
foreach (var oldType in oldTypesList)
{
if (oldTypesByFullName.ContainsKey(mappedName))
var oldTypeName = oldType.FullName ?? oldType.Name;
var mappedNames = _nameMapper.MapFullTypeName(oldTypeName).ToList();

foreach (var mappedName in mappedNames)
{
if (string.Equals(mappedName, newTypeName, StringComparison.Ordinal))
{
foundMatch = true;
_logger.LogDebug(
"Found mapped type: {OldTypeName} -> {NewTypeName}",
oldTypeName,
newTypeName);
break;
}
}

if (foundMatch)
{
foundMatch = true;
break;
}
}
Expand Down Expand Up @@ -298,8 +311,12 @@ public IEnumerable<ApiDifference> CompareTypes(IEnumerable<Type> oldTypes, IEnum
var memberDifferences = CompareMembers(oldType, mappedNewType).ToList();
differences.AddRange(memberDifferences);

// Check for type-level changes
var typeDifference = _differenceCalculator.CalculateTypeChanges(oldType, mappedNewType);
// Check for type-level changes with signature equivalence
// For mapped types, check if the old type name maps to the new type name
var mappedOldTypeName = _nameMapper.MapTypeName(oldType.Name);
var areTypeNamesEquivalent = string.Equals(mappedOldTypeName, mappedNewType.Name, StringComparison.Ordinal);

var typeDifference = _differenceCalculator.CalculateTypeChanges(oldType, mappedNewType, areTypeNamesEquivalent);
if (typeDifference != null)
{
differences.Add(typeDifference);
Expand Down Expand Up @@ -387,40 +404,49 @@ public IEnumerable<ApiDifference> CompareMembers(Type oldType, Type newType)
_logger.LogDebug(
"Found {OldMemberCount} members in old type and {NewMemberCount} members in new type",
oldMembers.Count,
newMembers.Count);

// Create dictionaries for faster lookup
var oldMembersBySignature = oldMembers.ToDictionary(m => m.Signature);
var newMembersBySignature = newMembers.ToDictionary(m => m.Signature);

// Find added members
newMembers.Count); // Find added members (exist in new but not in old)
foreach (var newMember in newMembers)
{
if (!oldMembersBySignature.ContainsKey(newMember.Signature))
var equivalentOldMember = FindEquivalentMember(newMember, oldMembers);
if (equivalentOldMember == null)
{
_logger.LogDebug("Found added member: {MemberName}", newMember.FullName);
var addedDifference = _differenceCalculator.CalculateAddedMember(newMember);
differences.Add(addedDifference);
}
}

// Find removed members
// Find removed members (exist in old but not in new)
foreach (var oldMember in oldMembers)
{
if (!newMembersBySignature.ContainsKey(oldMember.Signature))
var equivalentNewMember = FindEquivalentMember(oldMember, newMembers);
if (equivalentNewMember == null)
{
_logger.LogDebug("Found removed member: {MemberName}", oldMember.FullName);
var removedDifference = _differenceCalculator.CalculateRemovedMember(oldMember);
differences.Add(removedDifference);
}
}

// Find modified members
// Find modified members (exist in both but with differences)
foreach (var oldMember in oldMembers)
{
if (newMembersBySignature.TryGetValue(oldMember.Signature, out var newMember))
var equivalentNewMember = FindEquivalentMember(oldMember, newMembers);
if (equivalentNewMember != null)
{
var memberDifference = _differenceCalculator.CalculateMemberChanges(oldMember, newMember);
// Check if the members are truly different or just equivalent via type mappings
if (AreSignaturesEquivalent(oldMember.Signature, equivalentNewMember.Signature))
{
// Members are equivalent via type mappings - no difference to report
_logger.LogDebug(
"Members are equivalent via type mappings: {OldSignature} <-> {NewSignature}",
oldMember.Signature,
equivalentNewMember.Signature);
continue;
}

// Members match but have other differences beyond type mappings
var memberDifference = _differenceCalculator.CalculateMemberChanges(oldMember, equivalentNewMember);
if (memberDifference != null)
{
_logger.LogDebug("Found modified member: {MemberName}", oldMember.FullName);
Expand All @@ -441,4 +467,131 @@ public IEnumerable<ApiDifference> CompareMembers(Type oldType, Type newType)
return Enumerable.Empty<ApiDifference>();
}
}

/// <summary>
/// Applies type mappings to a signature to enable equivalence checking
/// </summary>
/// <param name="signature">The original signature</param>
/// <returns>The signature with type mappings applied</returns>
private string ApplyTypeMappingsToSignature(string signature)
{
if (string.IsNullOrEmpty(signature))
{
return signature;
}

var mappedSignature = signature;

// Check if we have type mappings configured
if (_nameMapper.Configuration?.TypeMappings == null)
{
return mappedSignature;
}

// Apply all type mappings to the signature
foreach (var mapping in _nameMapper.Configuration.TypeMappings)
{
// Replace the type name in the signature
// We need to be careful to only replace whole type names, not partial matches
mappedSignature = ReplaceTypeNameInSignature(mappedSignature, mapping.Key, mapping.Value);

// Also try with just the type name (without namespace) since signatures might not include full namespaces
var oldTypeNameOnly = mapping.Key.Split('.').Last();
var newTypeNameOnly = mapping.Value.Split('.').Last();

// Only if we had a namespace
if (oldTypeNameOnly != mapping.Key)
{
mappedSignature = ReplaceTypeNameInSignature(mappedSignature, oldTypeNameOnly, newTypeNameOnly);
}
}

return mappedSignature;
}

/// <summary>
/// Replaces a type name in a signature, ensuring we only replace complete type names
/// </summary>
/// <param name="signature">The signature to modify</param>
/// <param name="oldTypeName">The type name to replace</param>
/// <param name="newTypeName">The replacement type name</param>
/// <returns>The modified signature</returns>
private string ReplaceTypeNameInSignature(string signature, string oldTypeName, string newTypeName)
{
// We need to replace type names carefully to avoid partial matches
// For example, when replacing "RedisValue" with "ValkeyValue", we don't want to
// replace "RedisValueWithExpiry" incorrectly
var result = signature;

// Pattern 1: Type name followed by non-word character (space, <, >, ,, etc.)
// This handles most cases including generic parameters and return types
result = System.Text.RegularExpressions.Regex.Replace(
result,
$@"\b{System.Text.RegularExpressions.Regex.Escape(oldTypeName)}\b",
newTypeName);

// Pattern 2: Special handling for constructor names
// Constructor signatures typically look like: "public RedisValue(parameters)"
// We need to replace the constructor name (which matches the type name) as well
// This pattern matches: word boundary + type name + opening parenthesis
result = System.Text.RegularExpressions.Regex.Replace(
result,
$@"\b{System.Text.RegularExpressions.Regex.Escape(oldTypeName)}(?=\s*\()",
newTypeName);

return result;
}

/// <summary>
/// Checks if two signatures are equivalent considering type mappings
/// </summary>
/// <param name="sourceSignature">Signature from the source assembly</param>
/// <param name="targetSignature">Signature from the target assembly</param>
/// <returns>True if the signatures are equivalent after applying type mappings</returns>
private bool AreSignaturesEquivalent(string sourceSignature, string targetSignature)
{
// Apply type mappings to the source signature to see if it matches the target
var mappedSourceSignature = ApplyTypeMappingsToSignature(sourceSignature);

return string.Equals(mappedSourceSignature, targetSignature, StringComparison.Ordinal);
}

/// <summary>
/// Finds an equivalent member in the target collection based on signature equivalence with type mappings
/// </summary>
/// <param name="sourceMember">The member from the source assembly (could be old or new)</param>
/// <param name="targetMembers">The collection of members from the target assembly (could be new or old)</param>
/// <returns>The equivalent member if found, null otherwise</returns>
private ApiMember? FindEquivalentMember(ApiMember sourceMember, IEnumerable<ApiMember> targetMembers)
{
// First, try to find a member with the same name - this handles "modified" members
// where the signature might have changed but it's still the same conceptual member
var sameNameMember = targetMembers.FirstOrDefault(m =>
m.Name == sourceMember.Name &&
m.FullName == sourceMember.FullName);

if (sameNameMember != null)
{
return sameNameMember;
}

// If no exact name match, check for signature equivalence due to type mappings
// This handles cases where type mappings make signatures equivalent even with different names
foreach (var targetMember in targetMembers)
{
// Check if source maps to target (source signature with mappings applied == target signature)
if (AreSignaturesEquivalent(sourceMember.Signature, targetMember.Signature))
{
return targetMember;
}

// Also check the reverse: if target maps to source (target signature with mappings applied == source signature)
if (AreSignaturesEquivalent(targetMember.Signature, sourceMember.Signature))
{
return targetMember;
}
}

return null;
}
}
9 changes: 8 additions & 1 deletion src/DotNetApiDiff/ApiExtraction/DifferenceCalculator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,9 @@ public ApiDifference CalculateRemovedType(Type oldType)
/// </summary>
/// <param name="oldType">The original type</param>
/// <param name="newType">The new type</param>
/// <param name="signaturesEquivalent">Whether the signatures are equivalent after applying type mappings</param>
/// <returns>ApiDifference representing the changes, or null if no changes</returns>
public ApiDifference? CalculateTypeChanges(Type oldType, Type newType)
public ApiDifference? CalculateTypeChanges(Type oldType, Type newType, bool signaturesEquivalent = false)
{
if (oldType == null)
{
Expand Down Expand Up @@ -134,6 +135,12 @@ public ApiDifference CalculateRemovedType(Type oldType)
};
}

// If signatures are different but equivalent after type mappings, no change
if (signaturesEquivalent)
{
return null;
}

// If signatures are different, we have changes
if (oldTypeMember.Signature != newTypeMember.Signature)
{
Expand Down
12 changes: 10 additions & 2 deletions src/DotNetApiDiff/ApiExtraction/MemberSignatureBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,7 @@ private string GetTypeName(Type type)
}

// Handle primitive types with C# keywords using a direct switch on type
return type switch
var primitiveTypeName = type switch
{
Type t when t == typeof(bool) => "bool",
Type t when t == typeof(byte) => "byte",
Expand All @@ -693,8 +693,16 @@ private string GetTypeName(Type type)
Type t when t == typeof(ushort) => "ushort",
Type t when t == typeof(string) => "string",
Type t when t == typeof(object) => "object",
_ => type.Name // For regular types, just return the name
_ => null // Not a primitive type
};

if (primitiveTypeName != null)
{
return primitiveTypeName;
}

// For regular types, return the type name directly
return type.Name;
}

/// <summary>
Expand Down
3 changes: 0 additions & 3 deletions src/DotNetApiDiff/Commands/CompareCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,6 @@ public override int Execute([NotNull] CommandContext context, [NotNull] CompareC
// Add configuration-specific services
commandServices.AddScoped<INameMapper>(provider =>
{
_logger.LogInformation(
"Creating NameMapper with {MappingCount} namespace mappings",
config.Mappings.NamespaceMappings.Count);
return new ApiExtraction.NameMapper(
config.Mappings,
loggerFactory.CreateLogger<ApiExtraction.NameMapper>());
Expand Down
3 changes: 2 additions & 1 deletion src/DotNetApiDiff/Interfaces/IDifferenceCalculator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ public interface IDifferenceCalculator
/// </summary>
/// <param name="oldType">The original type</param>
/// <param name="newType">The new type</param>
/// <param name="signaturesEquivalent">Whether the signatures are equivalent after applying type mappings</param>
/// <returns>ApiDifference representing the changes, or null if no changes</returns>
ApiDifference? CalculateTypeChanges(Type oldType, Type newType);
ApiDifference? CalculateTypeChanges(Type oldType, Type newType, bool signaturesEquivalent = false);

/// <summary>
/// Calculates an ApiDifference for an added member
Expand Down
1 change: 1 addition & 0 deletions src/DotNetApiDiff/Interfaces/IMemberSignatureBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public interface IMemberSignatureBuilder
/// <returns>Normalized event signature</returns>
string BuildEventSignature(EventInfo eventInfo);

/// <summary>
/// <summary>
/// Builds a normalized signature for a constructor
/// </summary>
Expand Down
13 changes: 8 additions & 5 deletions tests/DotNetApiDiff.Tests/ApiExtraction/ApiComparerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ public ApiComparerTests()
_mockChangeClassifier = new Mock<IChangeClassifier>();
_mockLogger = new Mock<ILogger<ApiComparer>>();

// Setup the name mapper to return an empty configuration
_mockNameMapper.Setup(x => x.Configuration)
.Returns(new MappingConfiguration { TypeMappings = new Dictionary<string, string>() });

// Setup the change classifier to return the same difference that is passed to it
_mockChangeClassifier.Setup(x => x.ClassifyChange(It.IsAny<ApiDifference>()))
.Returns<ApiDifference>(diff => diff);
Expand Down Expand Up @@ -204,7 +208,7 @@ public void CompareTypes_DetectsModifiedTypes()
IsBreakingChange = true
};

_mockDifferenceCalculator.Setup(x => x.CalculateTypeChanges(typeof(string), typeof(string)))
_mockDifferenceCalculator.Setup(x => x.CalculateTypeChanges(typeof(string), typeof(string), false))
.Returns(modifiedTypeDifference);

// Setup empty member differences
Expand Down Expand Up @@ -331,7 +335,7 @@ public void CompareMembers_DetectsModifiedMembers()
{
// Arrange
var oldType = typeof(string);
var newType = typeof(string);
var newType = typeof(int); // Use a different type to avoid mock collision

var oldMember = new ApiMember
{
Expand All @@ -345,7 +349,7 @@ public void CompareMembers_DetectsModifiedMembers()
{
Name = "Method",
FullName = "System.String.Method",
Signature = "public void Method()",
Signature = "public string Method()",
Type = MemberType.Method
};

Expand All @@ -364,8 +368,7 @@ public void CompareMembers_DetectsModifiedMembers()
IsBreakingChange = true
};

_mockDifferenceCalculator.Setup(x => x.CalculateMemberChanges(It.Is<ApiMember>(m => m.Signature == oldMember.Signature),
It.Is<ApiMember>(m => m.Signature == newMember.Signature)))
_mockDifferenceCalculator.Setup(x => x.CalculateMemberChanges(It.IsAny<ApiMember>(), It.IsAny<ApiMember>()))
.Returns(modifiedMemberDifference);

// Act
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public ApiComparerWithMappingTests()

// Setup default behavior for all tests
_apiExtractorMock.Setup(x => x.ExtractTypeMembers(It.IsAny<Type>())).Returns(new List<ApiMember>());
_differenceCalculatorMock.Setup(x => x.CalculateTypeChanges(It.IsAny<Type>(), It.IsAny<Type>())).Returns((ApiDifference?)null);
_differenceCalculatorMock.Setup(x => x.CalculateTypeChanges(It.IsAny<Type>(), It.IsAny<Type>(), It.IsAny<bool>())).Returns((ApiDifference?)null);

// Setup the change classifier to return the same difference that is passed to it
_changeClassifierMock.Setup(x => x.ClassifyChange(It.IsAny<ApiDifference>()))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright DotNet API Diff Project Contributors - SPDX Identifier: MIT
using System.Reflection;
using DotNetApiDiff.ApiExtraction;
using DotNetApiDiff.Interfaces;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
Expand Down
Loading
Loading