diff --git a/Extensions.Test/ReflectionExtensionsTests.cs b/Extensions.Test/ReflectionExtensionsTests.cs index 1697e88..77d7dd0 100644 --- a/Extensions.Test/ReflectionExtensionsTests.cs +++ b/Extensions.Test/ReflectionExtensionsTests.cs @@ -141,13 +141,90 @@ public void TryFindMethodFindsMethodWithParameters() } [TestMethod] - public void TryFindMethodThrowsOnAmbiguousMatchForOverloadedMethod() + public void TryFindMethodPicksFewestParametersForOverloadedMethod() { Type type = typeof(DerivedClassWithAdditionalMethods); string methodName = "OverloadedMethod"; BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Public; - Assert.ThrowsExactly(() => type.TryFindMethod(methodName, bindingFlags, out MethodInfo? methodInfo)); + bool result = type.TryFindMethod(methodName, bindingFlags, out MethodInfo? methodInfo); + + Assert.IsTrue(result, "TryFindMethod should return true for an overloaded method name."); + Assert.IsNotNull(methodInfo); + Assert.IsEmpty(methodInfo.GetParameters()); + } + + [TestMethod] + public void TryFindMethodDoesNotThrowForOverloadedFrameworkMethod() + { + bool result = typeof(string).TryFindMethod(nameof(string.Split), BindingFlags.Instance | BindingFlags.Public, out MethodInfo? methodInfo); + + Assert.IsTrue(result, "TryFindMethod should return true for string.Split."); + Assert.IsNotNull(methodInfo); + Assert.AreEqual(nameof(string.Split), methodInfo.Name); + } + + [TestMethod] + public void TryFindMethodFindsOverloadedPrivateMethodInBaseClass() + { + Type type = typeof(DerivedFromPrivateOverloads); + BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.NonPublic; + + bool result = type.TryFindMethod("Foo", bindingFlags, out MethodInfo? methodInfo); + + Assert.IsTrue(result, "TryFindMethod should return true for an overloaded private method on a base class."); + Assert.IsNotNull(methodInfo); + Assert.AreEqual(typeof(BaseWithPrivateOverloads), methodInfo.DeclaringType); + } + + [TestMethod] + public void TryFindMethodResolvesOverloadsTheSameWayEveryTime() + { + BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Public; + + typeof(string).TryFindMethod(nameof(string.Split), bindingFlags, out MethodInfo? first); + typeof(string).TryFindMethod(nameof(string.Split), bindingFlags, out MethodInfo? second); + + Assert.AreEqual(first, second); + } + + [TestMethod] + public void TryFindMethodFindsMethodInheritedFromBaseInterface() + { + BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Public; + + bool result = typeof(IDerivedInterface).TryFindMethod(nameof(IBaseInterface.BaseOp), bindingFlags, out MethodInfo? methodInfo); + + Assert.IsTrue(result, "TryFindMethod should find a method declared on a base interface."); + Assert.IsNotNull(methodInfo); + Assert.AreEqual(typeof(IBaseInterface), methodInfo.DeclaringType); + } + + [TestMethod] + public void TryFindMethodFindsMethodTwoInterfaceLevelsUp() + { + BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Public; + + bool result = typeof(IGrandchildInterface).TryFindMethod(nameof(IBaseInterface.BaseOp), bindingFlags, out MethodInfo? methodInfo); + + Assert.IsTrue(result, "TryFindMethod should find a method declared two interface levels up."); + Assert.IsNotNull(methodInfo); + Assert.AreEqual(typeof(IBaseInterface), methodInfo.DeclaringType); + } + + [TestMethod] + public void TryFindMethodFindsMethodsOnGenericCollectionInterfaces() + { + BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Public; + + Assert.IsTrue(typeof(IList).TryFindMethod(nameof(IList<>.Add), bindingFlags, out MethodInfo? add)); + Assert.AreEqual(typeof(ICollection), add?.DeclaringType); + + Assert.IsTrue(typeof(IList).TryFindMethod(nameof(IList<>.GetEnumerator), bindingFlags, out MethodInfo? getEnumerator)); + Assert.AreEqual(typeof(IEnumerable), getEnumerator?.DeclaringType); + + Assert.IsTrue(typeof(ICollection).TryFindMethod(nameof(ICollection<>.Add), bindingFlags, out MethodInfo? collectionAdd)); + Assert.AreEqual(typeof(ICollection), collectionAdd?.DeclaringType); } [TestMethod] @@ -194,4 +271,35 @@ public void GenericMethod(T param) { } } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Test class")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0051:Remove unused private members", Justification = "Test class")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Test class")] + public class BaseWithPrivateOverloads + { + private void Foo(int value) + { + } + + private void Foo(string value) + { + } + } + + public class DerivedFromPrivateOverloads : BaseWithPrivateOverloads + { + } + + public interface IBaseInterface + { + public void BaseOp(); + } + + public interface IDerivedInterface : IBaseInterface + { + } + + public interface IGrandchildInterface : IDerivedInterface + { + } } diff --git a/Extensions/ReflectionExtensions.cs b/Extensions/ReflectionExtensions.cs index ff1b6c4..2d60135 100644 --- a/Extensions/ReflectionExtensions.cs +++ b/Extensions/ReflectionExtensions.cs @@ -12,6 +12,18 @@ public static class ReflectionExtensions /// /// Walks up the inheritance tree to find a method with the given name and binding flags. /// + /// + /// + /// The search starts at and moves to each base class in turn, stopping at the first + /// level that declares a method with the given name. When is an interface, the + /// interfaces it inherits are searched after it, more-derived interfaces first. + /// + /// + /// When the name is overloaded at that level, the overload with the fewest parameters is returned. A + /// non-generic method is preferred over a generic one with the same parameter count, and any remaining tie is + /// broken by ordinal comparison of the method signatures, so the result is always the same for the same type. + /// + /// /// The type to search. /// The name of the method to find. /// The binding flags to use when searching. @@ -28,16 +40,52 @@ public static bool TryFindMethod(this Type type, string methodName, BindingFlags } methodInfo = null; - Type? methodOwner = type; - while (methodInfo is null && methodOwner is not null) + foreach (Type[] level in SearchLevels(type)) { - methodInfo = methodOwner.GetMethod(methodName, bindingFlags); - if (methodInfo is null) + methodInfo = level + .SelectMany(t => t.GetMethods(bindingFlags | BindingFlags.DeclaredOnly)) + .Where(m => m.Name.Equals(methodName, StringComparison.Ordinal)) + .OrderBy(m => m.GetParameters().Length) + .ThenBy(m => m.IsGenericMethodDefinition) + .ThenBy(m => m.ToString(), StringComparer.Ordinal) + .FirstOrDefault(); + + if (methodInfo is not null) + { + return true; + } + } + + return false; + } + + /// + /// Yields the groups of types to search, nearest first: the type and its base classes one at a time, or for an + /// interface, the interface itself followed by its inherited interfaces grouped by how many interfaces they in + /// turn inherit, so that a derived interface is always searched before the interfaces it extends. + /// + private static IEnumerable SearchLevels(Type type) + { + if (type.IsInterface) + { + yield return [type]; + + IEnumerable inheritedLevels = type.GetInterfaces() + .GroupBy(i => i.GetInterfaces().Length) + .OrderByDescending(g => g.Key) + .Select(g => g.ToArray()); + + foreach (Type[] level in inheritedLevels) { - methodOwner = methodOwner.BaseType; + yield return level; } + + yield break; } - return methodInfo is not null; + for (Type? methodOwner = type; methodOwner is not null; methodOwner = methodOwner.BaseType) + { + yield return [methodOwner]; + } } }