diff --git a/README.md b/README.md index c355f5a..a6d071e 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,17 @@ To get more metadata from a `PropertyInfo`, you can use extensions methods like: * `IsIndexer` * `HasAttribute` and `HasAttributeInHierarchy` * `IsPublic`, `IsInternal` or `IsAbstract` to check either the getter or setters matches the criteria +* `GetNullability` and `IsNullableReference` to determine the nullable reference type annotation of the property Similarly, you can find indexers using `FindIndexers`, conversion operators through `FindImplicitConversionOperators` and `FindExplicitConversionOperators`, and methods via `FindMethod`, `FindParameterlessMethod` and `HasMethod`. +For a `FieldInfo`, you can use `GetNullability` and `IsNullableReference` in the same way as for `PropertyInfo`. + For `ParameterInfo`, you can use: * `HasAttribute` and `HasAttributeInHierarchy` to check whether a parameter is decorated with a specific attribute, with an optional predicate to filter on attribute properties. +* `GetNullability` and `IsNullableReference` to determine the nullable reference type annotation of the parameter. Other extension methods act on `Type` directly and include: @@ -102,6 +106,12 @@ Additionally, Reflectify offers some helpers such as * `NullableOrActualType` to get the actual type of a nullable type or the type itself if it's not nullable. +`GetNullability` returns a `Nullability` enum (`Unknown`, `NotNull` or `Nullable`) that reflects the compiler-emitted +nullable reference type metadata for reference types, and treats value types consistently with `NullableOrActualType` +(e.g. `int` is `NotNull`, `int?` is `Nullable`). `IsNullableReference` is a convenience shortcut for +`GetNullability() == Nullability.Nullable`. On .NET 6 and later this is backed by `NullabilityInfoContext`; on older +targets it's determined by reading the compiler's `NullableAttribute`/`NullableContextAttribute` metadata directly. + ## Download This library is available as [a NuGet package](https://www.nuget.org/packages/Reflectify) on https://nuget.org. To install it, use the following command-line: diff --git a/src/Reflectify/FieldInfoExtensions.cs b/src/Reflectify/FieldInfoExtensions.cs new file mode 100644 index 0000000..1dd9362 --- /dev/null +++ b/src/Reflectify/FieldInfoExtensions.cs @@ -0,0 +1,63 @@ +#if !REFLECTIFY_COMPILE +// +#pragma warning disable +#endif + +#nullable disable + +using System; +using System.Reflection; + +namespace Reflectify; + +#if REFLECTIFY_COMPILE +public static class FieldInfoExtensions +#else +[global::Microsoft.CodeAnalysis.Embedded] +[global::System.Diagnostics.DebuggerNonUserCode] +internal static class FieldInfoExtensions +#endif +{ + /// + /// Determines the nullability of the field, taking into account nullable reference type metadata as well as + /// nullable value types (see ). + /// + public static Nullability GetNullability(this FieldInfo field) + { + Type fieldType = field.FieldType; + + Type actualType = fieldType.NullableOrActualType(); + + if (actualType != fieldType) + { + return Nullability.Nullable; + } + + if (fieldType.IsValueType) + { + return Nullability.NotNull; + } + +#if NET6_0_OR_GREATER + NullabilityInfo info = new NullabilityInfoContext().Create(field); + + return info.ReadState switch + { + NullabilityState.NotNull => Nullability.NotNull, + NullabilityState.Nullable => Nullability.Nullable, + _ => Nullability.Unknown + }; +#else + return NullabilityMetadataReader.GetNullability(fieldType, field.GetCustomAttributes(inherit: false), field.DeclaringType); +#endif + } + + /// + /// Returns if the field is annotated as accepting , either + /// because it is a nullable reference type or a nullable value type, or otherwise. + /// + public static bool IsNullableReference(this FieldInfo field) + { + return field.GetNullability() == Nullability.Nullable; + } +} diff --git a/src/Reflectify/Nullability.cs b/src/Reflectify/Nullability.cs new file mode 100644 index 0000000..9c14892 --- /dev/null +++ b/src/Reflectify/Nullability.cs @@ -0,0 +1,36 @@ +#if !REFLECTIFY_COMPILE +// +#pragma warning disable +#endif + +#nullable disable + +namespace Reflectify; + +/// +/// Represents the nullability of a member, as determined from the compiler-emitted nullable reference type +/// metadata, or from the fact that the member is a value type (or ). +/// +#if REFLECTIFY_COMPILE +public enum Nullability +#else +[global::Microsoft.CodeAnalysis.Embedded] +internal enum Nullability +#endif +{ + /// + /// The nullability of the member could not be determined, typically because it was compiled without + /// nullable reference types enabled (the "oblivious" context). + /// + Unknown, + + /// + /// The member is annotated as not allowing . + /// + NotNull, + + /// + /// The member is annotated as allowing . + /// + Nullable +} diff --git a/src/Reflectify/NullabilityMetadataReader.cs b/src/Reflectify/NullabilityMetadataReader.cs new file mode 100644 index 0000000..5c0f62e --- /dev/null +++ b/src/Reflectify/NullabilityMetadataReader.cs @@ -0,0 +1,149 @@ +#if !REFLECTIFY_COMPILE +// +#pragma warning disable +#endif + +#nullable disable + +using System; +using System.Reflection; + +namespace Reflectify; + +// This fallback is only needed on frameworks that lack System.Reflection.NullabilityInfoContext (pre-.NET 6). On +// net6.0-or-greater the extension methods use that API directly, so this whole type is excluded there to avoid it +// being reported as uninstrumented/uncovered dead code. +#if !NET6_0_OR_GREATER +/// +/// Provides the manual, attribute-based fallback used to determine nullable reference type metadata on target +/// frameworks that don't have access to System.Reflection.NullabilityInfoContext (i.e. everything +/// before .NET 6). +/// +/// +/// The C# compiler encodes nullable reference type information using two compiler-internal attributes that are not +/// part of the public BCL surface: System.Runtime.CompilerServices.NullableAttribute and +/// System.Runtime.CompilerServices.NullableContextAttribute. Because these attributes are embedded +/// per-assembly (rather than shared through a common reference), they must be matched by their full type name +/// instead of via typeof(...), since a NullableAttribute emitted into one assembly is a different CLR +/// type than one emitted into another. See +/// https://github.com/dotnet/roslyn/blob/main/docs/features/nullable-metadata.md for the specification this logic +/// is based on. +/// +#if !REFLECTIFY_COMPILE +[global::Microsoft.CodeAnalysis.Embedded] +#endif +[global::System.Diagnostics.DebuggerNonUserCode] +internal static class NullabilityMetadataReader +{ + private const string NullableAttributeFullName = "System.Runtime.CompilerServices.NullableAttribute"; + private const string NullableContextAttributeFullName = "System.Runtime.CompilerServices.NullableContextAttribute"; + + /// + /// Determines the of a member of type , given the + /// attributes declared directly on that member and the type that declares it. + /// + /// The type of the property, field or parameter. + /// The custom attributes declared directly on the member. + /// The type that declares the member, used to walk outward for the ambient nullable context. + public static Nullability GetNullability(Type memberType, object[] memberAttributes, Type declaringType) + { + if (memberType is null) + { + return Nullability.Unknown; + } + + Type actualType = memberType.NullableOrActualType(); + + if (actualType != memberType) + { + // Nullable, e.g. int? + return Nullability.Nullable; + } + + if (memberType.IsValueType) + { + // Plain value types, e.g. int, can never be null. + return Nullability.NotNull; + } + + byte? flag = GetNullableAttributeFlag(memberAttributes) ?? GetAmbientNullableContextFlag(declaringType); + + return MapFlag(flag); + } + + private static byte? GetNullableAttributeFlag(object[] attributes) + { + foreach (object attribute in attributes) + { + if (attribute.GetType().FullName == NullableAttributeFullName) + { + FieldInfo field = attribute.GetType().GetField("NullableFlags", BindingFlags.Public | BindingFlags.Instance); + + // The NullableAttribute constructor always populates NullableFlags as a byte[], regardless of + // whether it was invoked with a single byte or a byte[] (nested generic arguments). The first + // element always applies to the member itself. + if (field?.GetValue(attribute) is byte[] { Length: > 0 } flags) + { + return flags[0]; + } + } + } + + return null; + } + + private static byte? GetAmbientNullableContextFlag(Type declaringType) + { + for (Type type = declaringType; type is not null; type = type.DeclaringType) + { + byte? flag = GetNullableContextFlag(type.GetCustomAttributes(inherit: false)); + + if (flag is not null) + { + return flag; + } + } + + if (declaringType is not null) + { + byte? flag = GetNullableContextFlag(declaringType.Module.GetCustomAttributes(inherit: false)); + + if (flag is not null) + { + return flag; + } + } + + return null; + } + + private static byte? GetNullableContextFlag(object[] attributes) + { + foreach (object attribute in attributes) + { + if (attribute.GetType().FullName == NullableContextAttributeFullName) + { + FieldInfo field = attribute.GetType().GetField("Flag", BindingFlags.Public | BindingFlags.Instance); + + if (field?.GetValue(attribute) is byte value) + { + return value; + } + } + } + + return null; + } + + private static Nullability MapFlag(byte? flag) + { + // Per the Roslyn nullable metadata spec: 0 = oblivious, 1 = not annotated (not null), 2 = annotated (nullable). + return flag switch + { + 1 => Nullability.NotNull, + 2 => Nullability.Nullable, + _ => Nullability.Unknown + }; + } +} +#endif diff --git a/src/Reflectify/ParameterInfoExtensions.cs b/src/Reflectify/ParameterInfoExtensions.cs index 3dd82aa..3aaf49e 100644 --- a/src/Reflectify/ParameterInfoExtensions.cs +++ b/src/Reflectify/ParameterInfoExtensions.cs @@ -53,4 +53,48 @@ public static bool HasAttributeInHierarchy(this ParameterInfo parame { return parameter.IsDefined(typeof(TAttribute), inherit: true); } + + /// + /// Determines the nullability of the parameter, taking into account nullable reference type metadata as well as + /// nullable value types (see ). + /// + public static Nullability GetNullability(this ParameterInfo parameter) + { + Type parameterType = parameter.ParameterType; + + Type actualType = parameterType.NullableOrActualType(); + + if (actualType != parameterType) + { + return Nullability.Nullable; + } + + if (parameterType.IsValueType) + { + return Nullability.NotNull; + } + +#if NET6_0_OR_GREATER + NullabilityInfo info = new NullabilityInfoContext().Create(parameter); + + return info.ReadState switch + { + NullabilityState.NotNull => Nullability.NotNull, + NullabilityState.Nullable => Nullability.Nullable, + _ => Nullability.Unknown + }; +#else + return NullabilityMetadataReader.GetNullability(parameterType, parameter.GetCustomAttributes(inherit: false), + parameter.Member.DeclaringType); +#endif + } + + /// + /// Returns if the parameter is annotated as accepting , either + /// because it is a nullable reference type or a nullable value type, or otherwise. + /// + public static bool IsNullableReference(this ParameterInfo parameter) + { + return parameter.GetNullability() == Nullability.Nullable; + } } diff --git a/src/Reflectify/PropertyInfoExtensions.cs b/src/Reflectify/PropertyInfoExtensions.cs index e049f95..6328578 100644 --- a/src/Reflectify/PropertyInfoExtensions.cs +++ b/src/Reflectify/PropertyInfoExtensions.cs @@ -5,6 +5,7 @@ #nullable disable +using System; using System.Reflection; namespace Reflectify; @@ -64,4 +65,48 @@ public static bool IsAbstract(this PropertyInfo prop) { return prop.GetMethod is { IsAbstract: true } || prop.SetMethod is { IsAbstract: true }; } + + /// + /// Determines the nullability of the property, taking into account nullable reference type metadata as well as + /// nullable value types (see ). + /// + public static Nullability GetNullability(this PropertyInfo prop) + { + Type propertyType = prop.PropertyType; + + Type actualType = propertyType.NullableOrActualType(); + + if (actualType != propertyType) + { + return Nullability.Nullable; + } + + if (propertyType.IsValueType) + { + return Nullability.NotNull; + } + +#if NET6_0_OR_GREATER + NullabilityInfo info = new NullabilityInfoContext().Create(prop); + NullabilityState state = prop.GetMethod is not null ? info.ReadState : info.WriteState; + + return state switch + { + NullabilityState.NotNull => Nullability.NotNull, + NullabilityState.Nullable => Nullability.Nullable, + _ => Nullability.Unknown + }; +#else + return NullabilityMetadataReader.GetNullability(propertyType, prop.GetCustomAttributes(inherit: false), prop.DeclaringType); +#endif + } + + /// + /// Returns if the property is annotated as accepting , either + /// because it is a nullable reference type or a nullable value type, or otherwise. + /// + public static bool IsNullableReference(this PropertyInfo prop) + { + return prop.GetNullability() == Nullability.Nullable; + } } diff --git a/tests/Reflectify.Specs/FieldInfoExtensionsSpecs.cs b/tests/Reflectify.Specs/FieldInfoExtensionsSpecs.cs new file mode 100644 index 0000000..395222e --- /dev/null +++ b/tests/Reflectify.Specs/FieldInfoExtensionsSpecs.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; +using System.Reflection; +using FluentAssertions; +using JetBrains.Annotations; +using Xunit; + +namespace Reflectify.Specs; + +public class FieldInfoExtensionsSpecs +{ + public class GetNullability + { + [Fact] + public void A_non_nullable_reference_field_is_not_null() + { + // Act + FieldInfo field = typeof(ClassWithNullableFields).GetField("NonNullableString"); + + // Assert + field.GetNullability().Should().Be(Nullability.NotNull); + } + + [Fact] + public void A_nullable_reference_field_is_nullable() + { + // Act + FieldInfo field = typeof(ClassWithNullableFields).GetField("NullableString"); + + // Assert + field.GetNullability().Should().Be(Nullability.Nullable); + } + + [Fact] + public void A_value_type_field_is_not_null() + { + // Act + FieldInfo field = typeof(ClassWithNullableFields).GetField("NonNullableInt"); + + // Assert + field.GetNullability().Should().Be(Nullability.NotNull); + } + + [Fact] + public void A_nullable_value_type_field_is_nullable() + { + // Act + FieldInfo field = typeof(ClassWithNullableFields).GetField("NullableInt"); + + // Assert + field.GetNullability().Should().Be(Nullability.Nullable); + } + + [Fact] + public void A_non_nullable_generic_field_is_not_null() + { + // Act + FieldInfo field = typeof(ClassWithNullableFields).GetField("NonNullableList"); + + // Assert + field.GetNullability().Should().Be(Nullability.NotNull); + } + + [Fact] + public void A_nullable_generic_field_is_nullable() + { + // Act + FieldInfo field = typeof(ClassWithNullableFields).GetField("NullableList"); + + // Assert + field.GetNullability().Should().Be(Nullability.Nullable); + } + + [Fact] + public void A_field_compiled_without_a_nullable_context_is_unknown() + { + // Act + FieldInfo field = typeof(ClassWithoutNullableContext).GetField("SomeString"); + + // Assert + field.GetNullability().Should().Be(Nullability.Unknown); + } + } + + public class IsNullableReference + { + [Fact] + public void A_nullable_reference_field_is_a_nullable_reference() + { + // Act + FieldInfo field = typeof(ClassWithNullableFields).GetField("NullableString"); + + // Assert + field.IsNullableReference().Should().BeTrue(); + } + + [Fact] + public void A_non_nullable_reference_field_is_not_a_nullable_reference() + { + // Act + FieldInfo field = typeof(ClassWithNullableFields).GetField("NonNullableString"); + + // Assert + field.IsNullableReference().Should().BeFalse(); + } + } + +#nullable enable +#pragma warning disable CS0649 // Field is never assigned to - these fields exist only for reflection metadata purposes. + private class ClassWithNullableFields + { + [UsedImplicitly] + public string NonNullableString = ""; + + [UsedImplicitly] + public string? NullableString; + + [UsedImplicitly] + public int NonNullableInt; + + [UsedImplicitly] + public int? NullableInt; + + [UsedImplicitly] + public List NonNullableList = new(); + + [UsedImplicitly] + public List? NullableList; + } +#pragma warning restore CS0649 +#nullable disable + + private class ClassWithoutNullableContext + { + [UsedImplicitly] +#pragma warning disable CS0649 // Field is never assigned to - this field exists only for reflection metadata purposes. + public string SomeString; +#pragma warning restore CS0649 + } +} diff --git a/tests/Reflectify.Specs/ParameterInfoExtensionsSpecs.cs b/tests/Reflectify.Specs/ParameterInfoExtensionsSpecs.cs index 7008213..757b8ab 100644 --- a/tests/Reflectify.Specs/ParameterInfoExtensionsSpecs.cs +++ b/tests/Reflectify.Specs/ParameterInfoExtensionsSpecs.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Reflection; using FluentAssertions; +using JetBrains.Annotations; using Xunit; namespace Reflectify.Specs; @@ -99,4 +101,123 @@ public void Method([CustomParameter("Specific reason")] string value) { } } + + public class GetNullability + { + [Fact] + public void A_non_nullable_reference_parameter_is_not_null() + { + // Arrange + ParameterInfo parameter = typeof(ClassWithNullableParameters).GetMethod("Method")!.GetParameters()[0]; + + // Act / Assert + parameter.GetNullability().Should().Be(Nullability.NotNull); + } + + [Fact] + public void A_nullable_reference_parameter_is_nullable() + { + // Arrange + ParameterInfo parameter = typeof(ClassWithNullableParameters).GetMethod("Method")!.GetParameters()[1]; + + // Act / Assert + parameter.GetNullability().Should().Be(Nullability.Nullable); + } + + [Fact] + public void A_value_type_parameter_is_not_null() + { + // Arrange + ParameterInfo parameter = typeof(ClassWithNullableParameters).GetMethod("Method")!.GetParameters()[2]; + + // Act / Assert + parameter.GetNullability().Should().Be(Nullability.NotNull); + } + + [Fact] + public void A_nullable_value_type_parameter_is_nullable() + { + // Arrange + ParameterInfo parameter = typeof(ClassWithNullableParameters).GetMethod("OtherMethod")!.GetParameters()[0]; + + // Act / Assert + parameter.GetNullability().Should().Be(Nullability.Nullable); + } + + [Fact] + public void A_non_nullable_generic_parameter_is_not_null() + { + // Arrange + ParameterInfo parameter = typeof(ClassWithNullableParameters).GetMethod("OtherMethod")!.GetParameters()[1]; + + // Act / Assert + parameter.GetNullability().Should().Be(Nullability.NotNull); + } + + [Fact] + public void A_nullable_generic_parameter_is_nullable() + { + // Arrange + ParameterInfo parameter = typeof(ClassWithNullableParameters).GetMethod("OtherMethod")!.GetParameters()[2]; + + // Act / Assert + parameter.GetNullability().Should().Be(Nullability.Nullable); + } + + [Fact] + public void A_parameter_compiled_without_a_nullable_context_is_unknown() + { + // Arrange + ParameterInfo parameter = typeof(ClassWithoutNullableContext).GetMethod("Method")!.GetParameters()[0]; + + // Act / Assert + parameter.GetNullability().Should().Be(Nullability.Unknown); + } + } + + public class IsNullableReference + { + [Fact] + public void A_nullable_reference_parameter_is_a_nullable_reference() + { + // Arrange + ParameterInfo parameter = typeof(ClassWithNullableParameters).GetMethod("Method")!.GetParameters()[1]; + + // Act / Assert + parameter.IsNullableReference().Should().BeTrue(); + } + + [Fact] + public void A_non_nullable_reference_parameter_is_not_a_nullable_reference() + { + // Arrange + ParameterInfo parameter = typeof(ClassWithNullableParameters).GetMethod("Method")!.GetParameters()[0]; + + // Act / Assert + parameter.IsNullableReference().Should().BeFalse(); + } + } + +#nullable enable + private class ClassWithNullableParameters + { + [UsedImplicitly] + public void Method(string nonNullableString, string? nullableString, int nonNullableInt) + { + } + + [UsedImplicitly] + public void OtherMethod(int? nullableInt, List nonNullableList, List? nullableList) + { + } + } +#nullable disable + + private class ClassWithoutNullableContext + { + [UsedImplicitly] + public void Method(string someString) + { + } + } } diff --git a/tests/Reflectify.Specs/PropertyInfoExtensionsSpecs.cs b/tests/Reflectify.Specs/PropertyInfoExtensionsSpecs.cs index 67a9307..2eb5059 100644 --- a/tests/Reflectify.Specs/PropertyInfoExtensionsSpecs.cs +++ b/tests/Reflectify.Specs/PropertyInfoExtensionsSpecs.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Globalization; using System.Reflection; using FluentAssertions; @@ -195,6 +196,102 @@ private sealed class ConcreteClassWithProperty : AbstractClassWithProperty } } + public class GetNullability + { + [Fact] + public void A_non_nullable_reference_property_is_not_null() + { + // Act + PropertyInfo property = typeof(ClassWithNullableProperties).GetProperty("NonNullableString"); + + // Assert + property.GetNullability().Should().Be(Nullability.NotNull); + } + + [Fact] + public void A_nullable_reference_property_is_nullable() + { + // Act + PropertyInfo property = typeof(ClassWithNullableProperties).GetProperty("NullableString"); + + // Assert + property.GetNullability().Should().Be(Nullability.Nullable); + } + + [Fact] + public void A_value_type_property_is_not_null() + { + // Act + PropertyInfo property = typeof(ClassWithNullableProperties).GetProperty("NonNullableInt"); + + // Assert + property.GetNullability().Should().Be(Nullability.NotNull); + } + + [Fact] + public void A_nullable_value_type_property_is_nullable() + { + // Act + PropertyInfo property = typeof(ClassWithNullableProperties).GetProperty("NullableInt"); + + // Assert + property.GetNullability().Should().Be(Nullability.Nullable); + } + + [Fact] + public void A_non_nullable_generic_property_is_not_null() + { + // Act + PropertyInfo property = typeof(ClassWithNullableProperties).GetProperty("NonNullableList"); + + // Assert + property.GetNullability().Should().Be(Nullability.NotNull); + } + + [Fact] + public void A_nullable_generic_property_is_nullable() + { + // Act + PropertyInfo property = typeof(ClassWithNullableProperties).GetProperty("NullableList"); + + // Assert + property.GetNullability().Should().Be(Nullability.Nullable); + } + + [Fact] + public void A_property_compiled_without_a_nullable_context_is_unknown() + { + // Act + PropertyInfo property = typeof(ClassWithoutNullableContext).GetProperty("SomeString"); + + // Assert + property.GetNullability().Should().Be(Nullability.Unknown); + } + } + + public class IsNullableReference + { + [Fact] + public void A_nullable_reference_property_is_a_nullable_reference() + { + // Act + PropertyInfo property = typeof(ClassWithNullableProperties).GetProperty("NullableString"); + + // Assert + property.IsNullableReference().Should().BeTrue(); + } + + [Fact] + public void A_non_nullable_reference_property_is_not_a_nullable_reference() + { + // Act + PropertyInfo property = typeof(ClassWithNullableProperties).GetProperty("NonNullableString"); + + // Assert + property.IsNullableReference().Should().BeFalse(); + } + } + private class ClassWithVariousProperties { [UsedImplicitly] @@ -209,4 +306,33 @@ private class ClassWithVariousProperties [UsedImplicitly] protected internal string ProtectedInternalProperty { get; set; } } + +#nullable enable + private class ClassWithNullableProperties + { + [UsedImplicitly] + public string NonNullableString { get; set; } = ""; + + [UsedImplicitly] + public string? NullableString { get; set; } + + [UsedImplicitly] + public int NonNullableInt { get; set; } + + [UsedImplicitly] + public int? NullableInt { get; set; } + + [UsedImplicitly] + public List NonNullableList { get; set; } = new(); + + [UsedImplicitly] + public List? NullableList { get; set; } + } +#nullable disable + + private class ClassWithoutNullableContext + { + [UsedImplicitly] + public string SomeString { get; set; } + } }