From cf0ad7e0f973d940949377f014ab5a03d5268fc9 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Sun, 15 Nov 2026 07:45:07 +0100 Subject: [PATCH] Write enum values by name, from the assembly that declares the enum A default, a constant or an attribute argument of an enum type was written as a cast of its number, (ApiSplit)0, and an attribute's enum from another assembly was decoded as if it were an int. The reader now finds the enum, in the assembly being read, its folder, the folders ApiReaderOptions adds, or the installed .NET runtime, following type forwarders such as System.Runtime's: ApiSplit.None, StringComparison.Ordinal, FileShare.Read | FileShare.Delete for flags, and the real underlying type for decoding, so [Sized(Size.Large)] on a byte enum reads right. An enum that cannot be found, or a value with no member, stays a cast. The comparison counts a cast and a name of the same enum as the same value, since one machine can find an enum that another cannot. Co-Authored-By: Claude Opus 5.5 --- api/Nullean.PublicApi.net10.0.api | 7 + api/Nullean.PublicApi.netstandard2.0.api | 7 + src/Nullean.PublicApi.Diff/ApiComparison.cs | 48 ++- src/Nullean.PublicApi/ApiReader.cs | 119 ++++++-- src/Nullean.PublicApi/EnumCatalog.cs | 284 ++++++++++++++++++ src/Nullean.PublicApi/Signatures.cs | 62 ++-- .../Writes.enums.bd44bae9.verified.txt | 31 ++ .../Writes.signatures.34d2702d.verified.txt | 2 +- .../ComparisonTests.cs | 12 + tests/Nullean.PublicApi.Tests/WriterTests.cs | 23 ++ 10 files changed, 533 insertions(+), 62 deletions(-) create mode 100644 src/Nullean.PublicApi/EnumCatalog.cs create mode 100644 tests/Nullean.PublicApi.Tests/.snapshots/WriterTests/Writes.enums.bd44bae9.verified.txt diff --git a/api/Nullean.PublicApi.net10.0.api b/api/Nullean.PublicApi.net10.0.api index cc5eadc..165479e 100644 --- a/api/Nullean.PublicApi.net10.0.api +++ b/api/Nullean.PublicApi.net10.0.api @@ -332,9 +332,16 @@ namespace Nullean.PublicApi static bool operator !=(ApiProperty? left, ApiProperty? right) sealed class ApiReader + static ApiAssembly Read(Stream stream, ApiReaderOptions options) static ApiAssembly Read(Stream stream) + static ApiAssembly Read(string path, ApiReaderOptions options) static ApiAssembly Read(string path) + sealed class ApiReaderOptions + ApiReaderOptions() + IReadOnlyList ReferenceDirectories { get; init; } + bool SearchRuntime { get; init; } + enum ApiRefKind None = 0 Ref = 1 diff --git a/api/Nullean.PublicApi.netstandard2.0.api b/api/Nullean.PublicApi.netstandard2.0.api index 40fdca0..5acc54d 100644 --- a/api/Nullean.PublicApi.netstandard2.0.api +++ b/api/Nullean.PublicApi.netstandard2.0.api @@ -332,9 +332,16 @@ namespace Nullean.PublicApi static bool operator !=(ApiProperty? left, ApiProperty? right) sealed class ApiReader + static ApiAssembly Read(Stream stream, ApiReaderOptions options) static ApiAssembly Read(Stream stream) + static ApiAssembly Read(string path, ApiReaderOptions options) static ApiAssembly Read(string path) + sealed class ApiReaderOptions + ApiReaderOptions() + IReadOnlyList ReferenceDirectories { get; init; } + bool SearchRuntime { get; init; } + enum ApiRefKind None = 0 Ref = 1 diff --git a/src/Nullean.PublicApi.Diff/ApiComparison.cs b/src/Nullean.PublicApi.Diff/ApiComparison.cs index d964851..bd395a8 100644 --- a/src/Nullean.PublicApi.Diff/ApiComparison.cs +++ b/src/Nullean.PublicApi.Diff/ApiComparison.cs @@ -572,7 +572,7 @@ private static void Field( TypeOf("type", o.Type, n.Type, oldType, newType, changed); if (o.IsStatic != n.IsStatic) changed("static", o.IsStatic ? "static" : "instance", n.IsStatic ? "static" : "instance", ApiAudience.Callers, Both, null); - if (o.Constant != n.Constant) + if (!SameLiteral(o.Constant, n.Constant)) { // compiled code holds the old value if (o.Constant is not null && n.Constant is not null) @@ -746,7 +746,7 @@ private static void Parameters( ApiCompatibility.Source, null ); - if (o.DefaultValue != n.DefaultValue) + if (!SameLiteral(o.DefaultValue, n.DefaultValue)) { if (o.DefaultValue is null) changed(label + " default", null, n.DefaultValue, ApiAudience.None, ApiCompatibility.None, null); @@ -957,7 +957,10 @@ private static void Attributes( { var o = oldByName.TryGetValue(name, out var oldTexts) ? string.Join(", ", oldTexts) : null; var n = newByName.TryGetValue(name, out var newTexts) ? string.Join(", ", newTexts) : null; - if (o == n) + if ( + o == n || + (o is not null && n is not null && oldTexts!.Count == newTexts!.Count && oldTexts.Zip(newTexts, SameAttributeText).All(same => same)) + ) continue; var isError = name == Obsolete && IsError(@new) && !IsError(old); changed("attribute", o, n, isError ? ApiAudience.Callers : ApiAudience.None, isError ? ApiCompatibility.Source : ApiCompatibility.None); @@ -972,12 +975,49 @@ private static bool SameAttributes(IReadOnlyList old, IReadOnlyLis if (!Same(a.Type, b.Type, Sameness.Exact) || a.Arguments.Count != b.Arguments.Count) return false; for (var j = 0; j < a.Arguments.Count; j++) - if (a.Arguments[j] != b.Arguments[j]) + if (!SameLiteral(a.Arguments[j], b.Arguments[j])) return false; } return true; } + /// + /// Whether two literals say the same value. An enum value is written by name where the reader found the enum, and + /// as a cast of its number where it did not, so (StringComparison)4 and StringComparison.Ordinal + /// cannot be told apart and count as the same. + /// + private static bool SameLiteral(string? a, string? b) => a == b || (a is not null && b is not null && (CastOfNamed(a, b) || CastOfNamed(b, a))); + + /// Whether is (Type)number and names members of that type. + private static bool CastOfNamed(string cast, string named) + { + var close = cast.IndexOf(')'); + if (cast.Length < 4 || cast[0] != '(' || close < 2 || close == cast.Length - 1) + return false; + var number = cast.Substring(close + 1); + if (!number.TrimStart('-').All(char.IsDigit) || number.TrimStart('-').Length == 0) + return false; +#pragma warning disable CA1845 // netstandard2.0 has no span Concat + var prefix = cast.Substring(1, close - 1) + "."; +#pragma warning restore CA1845 + return named.Split('|').All(part => part.Trim().StartsWith(prefix, StringComparison.Ordinal)); + } + + private static readonly string[] ArgumentSeparator = [", "]; + + /// Two attributes as written, their arguments compared as literals. + private static bool SameAttributeText(string a, string b) + { + if (a == b) + return true; + var open = a.IndexOf('('); + if (open < 0 || open != b.IndexOf('(') || string.CompareOrdinal(a, 0, b, 0, open) != 0) + return false; + var left = a.Substring(open + 1, a.Length - open - 3).Split(ArgumentSeparator, StringSplitOptions.None); + var right = b.Substring(open + 1, b.Length - open - 3).Split(ArgumentSeparator, StringSplitOptions.None); + return left.Length == right.Length && left.Zip(right, SameLiteral).All(same => same); + } + private static bool IsError(IReadOnlyList attributes) => attributes.Any(a => a.Type.FullName == Obsolete && a.Arguments.Count >= 2 && a.Arguments[1] == "true"); diff --git a/src/Nullean.PublicApi/ApiReader.cs b/src/Nullean.PublicApi/ApiReader.cs index 0821577..da0f385 100644 --- a/src/Nullean.PublicApi/ApiReader.cs +++ b/src/Nullean.PublicApi/ApiReader.cs @@ -62,10 +62,14 @@ public sealed class ApiReader private readonly MetadataReader _reader; private readonly TypeReferenceProvider _types; - private ApiReader(MetadataReader reader) + // owned by Read, which disposes it once the assembly is read + private readonly EnumCatalog _catalog; + + private ApiReader(MetadataReader reader, TypeReferenceProvider types, EnumCatalog catalog) { _reader = reader; - _types = new TypeReferenceProvider(reader, NumericIntPtr(reader)); + _types = types; + _catalog = catalog; } /// @@ -108,18 +112,78 @@ out var version #pragma warning restore CA1846 } - /// Reads the assembly at . - public static ApiAssembly Read(string path) + /// + /// Reads the assembly at , looking in its folder and the installed .NET runtime for the enums + /// it refers to. + /// + public static ApiAssembly Read(string path) => Read(path, new ApiReaderOptions()); + + /// Reads the assembly at , looking for the assemblies it references as say. + public static ApiAssembly Read(string path, ApiReaderOptions options) { using var stream = File.OpenRead(path); - return Read(stream); + var directory = System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(path)); + return Read(stream, options, directory is null ? [] : [directory]); } - /// Reads an assembly from , which must be seekable. - public static ApiAssembly Read(Stream stream) + /// Reads an assembly from , which must be seekable, looking in the installed .NET runtime for the enums it refers to. + public static ApiAssembly Read(Stream stream) => Read(stream, new ApiReaderOptions()); + + /// Reads an assembly from , which must be seekable, looking for the assemblies it references as say. + public static ApiAssembly Read(Stream stream, ApiReaderOptions options) => Read(stream, options, []); + + private static ApiAssembly Read(Stream stream, ApiReaderOptions options, IReadOnlyList own) { +#pragma warning disable CA1510 // netstandard2.0 has no ThrowIfNull + if (options is null) + throw new ArgumentNullException(nameof(options)); +#pragma warning restore CA1510 using var pe = new PEReader(stream, PEStreamOptions.LeaveOpen); - return new ApiReader(pe.GetMetadataReader()).ReadAssembly(); + var metadata = pe.GetMetadataReader(); + var directories = own.Concat(options.ReferenceDirectories).Concat(options.SearchRuntime ? RuntimeDirectories() : []).ToList(); + var types = new TypeReferenceProvider(metadata, NumericIntPtr(metadata)); + using var catalog = new EnumCatalog(metadata, types, directories); + types.Catalog = catalog; + return new ApiReader(metadata, types, catalog).ReadAssembly(); + } + + /// + /// Where the .NET runtime's assemblies are: the running one's, or, in a native build that has none to point at, the + /// newest shared runtime of the installed SDK. + /// + private static IEnumerable RuntimeDirectories() + { +#pragma warning disable IL3000 // empty in a native or single-file build, which the fallback below covers + var running = typeof(object).Assembly.Location; +#pragma warning restore IL3000 + if (!string.IsNullOrEmpty(running)) + { + yield return System.IO.Path.GetDirectoryName(running)!; + yield break; + } + var roots = new List { Environment.GetEnvironmentVariable("DOTNET_ROOT") }; + foreach (var directory in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(System.IO.Path.PathSeparator)) + if ( + directory.Length > 0 && + (File.Exists(System.IO.Path.Combine(directory, "dotnet")) || File.Exists(System.IO.Path.Combine(directory, "dotnet.exe"))) + ) + roots.Add(directory); + foreach (var root in roots.Where(r => !string.IsNullOrEmpty(r))) + { + var shared = System.IO.Path.Combine(root!, "shared", "Microsoft.NETCore.App"); + if (!Directory.Exists(shared)) + continue; + var newest = Directory + .GetDirectories(shared) + .Select(d => (Path: d, Version: Version.TryParse(System.IO.Path.GetFileName(d).Split('-')[0], out var v) ? v : new Version())) + .OrderByDescending(d => d.Version) + .FirstOrDefault(); + if (newest.Path is not null) + { + yield return newest.Path; + yield break; + } + } } private ApiAssembly ReadAssembly() @@ -372,7 +436,9 @@ private List Members(TypeDefinition type, ApiType declaring, Immutabl rawType = modified.Type; } var fieldType = Annotate(rawType, field.GetCustomAttributes(), typeContext); - var constant = (field.Attributes & FieldAttributes.Literal) != 0 ? Constant(field.GetDefaultValue()) : DecimalConstant(field.GetCustomAttributes()); + var constant = (field.Attributes & FieldAttributes.Literal) != 0 + ? EnumConstant(rawType, field.GetDefaultValue()) + : DecimalConstant(field.GetCustomAttributes()); members.Add( new ApiField( _reader.GetString(field.Name), @@ -735,10 +801,15 @@ private IReadOnlyList ReadParameters(MethodDefinition method, Immu { var constant = Constant(parameter.GetDefaultValue()); if (type is ApiNamedType { IsValueType: true } valueType && !IsBuiltIn(valueType) && valueType.FullName != "System.Decimal") - // a struct's default is written as null; an enum's value is its number - return constant is null or "null" - ? "default" - : valueType.IsNullableValueType ? constant : "(" + ApiWriter.NonGeneric(valueType.Name) + ")" + constant; + { + // a struct's default is written as null; an enum's value by its name + if (constant is null or "null") + return "default"; + if (valueType.IsNullableValueType) + return valueType.TypeArguments[0] is ApiNamedType { IsValueType: true } inner && !IsBuiltIn(inner) && + RawConstant(parameter.GetDefaultValue()) is { } wrapped ? _catalog.Render(inner, wrapped) : constant; + return RawConstant(parameter.GetDefaultValue()) is { } raw ? _catalog.Render(valueType, raw) : constant; + } return constant ?? "default"; } if ((parameter.Attributes & ParameterAttributes.Optional) == 0) @@ -970,13 +1041,13 @@ private IReadOnlyList Attributes(IEnumerable argument) => argument.Value switch + private string Literal(CustomAttributeTypedArgument argument) => argument.Value switch { null => "null", ImmutableArray> items => "new[] { " + string.Join(", ", items.Select(Literal)) + " }", ApiTypeReference type => "typeof(" + TypeKey(type) + ")", - // an enum's value is written as a cast of its number - var value when argument.Type is ApiNamedType named && !IsBuiltIn(named) => "(" + named.Name + ")" + Constant(value), + // an enum's value by its name, where the enum can be found + var value when argument.Type is ApiNamedType named && !IsBuiltIn(named) => _catalog.Render(named, value), var value => Constant(value) ?? "null", }; @@ -990,10 +1061,10 @@ type is // constants - private string? Constant(ConstantHandle handle) + private string? Constant(ConstantHandle handle) => handle.IsNil ? null : Constant(RawConstant(handle)); + + private object? RawConstant(ConstantHandle handle) { - if (handle.IsNil) - return null; var constant = _reader.GetConstant(handle); var blob = _reader.GetBlobReader(constant.Value); object? value = constant.TypeCode switch @@ -1013,9 +1084,17 @@ type is ConstantTypeCode.String => blob.ReadUTF16(blob.Length), _ => null, }; - return Constant(value); + return value; } + /// A constant of an enum type by name, where the enum can be found; the number otherwise. + private string? EnumConstant(ApiTypeReference type, ConstantHandle handle) => + handle.IsNil + ? null + : type is ApiNamedType { IsValueType: true } named && !IsBuiltIn(named) && named.FullName != "System.Decimal" && RawConstant(handle) is { } value + ? _catalog.Render(named, value) + : Constant(handle); + private string? DecimalConstant(IEnumerable attributes) { foreach (var handle in attributes) diff --git a/src/Nullean.PublicApi/EnumCatalog.cs b/src/Nullean.PublicApi/EnumCatalog.cs new file mode 100644 index 0000000..6ed02ab --- /dev/null +++ b/src/Nullean.PublicApi/EnumCatalog.cs @@ -0,0 +1,284 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +namespace Nullean.PublicApi; + +/// How finds what an assembly only refers to. +public sealed class ApiReaderOptions +{ + /// + /// Folders to look in for the assemblies an assembly references, after its own folder, such as a build's + /// dependencies. The reader uses them to write an enum's value by name: StringComparison.Ordinal rather than + /// (StringComparison)4. + /// + public IReadOnlyList ReferenceDirectories { get; init; } = []; + + /// Whether to look in the installed .NET runtime too, for the framework's own enums. On by default. + public bool SearchRuntime { get; init; } = true; +} + +/// An enum's underlying type, its values, and whether it is a set of flags. +internal sealed record EnumInfo(PrimitiveTypeCode Underlying, IReadOnlyList<(string Name, ulong Value)> Values, bool IsFlags); + +/// +/// Finds enums, in the assembly being read or in the ones it references, by looking for those in the folders it is +/// given and following type forwarders, as System.Runtime's to System.Private.CoreLib. +/// +internal sealed class EnumCatalog(MetadataReader own, TypeReferenceProvider types, IReadOnlyList directories) : IDisposable +{ + private readonly Dictionary _assemblies = new(StringComparer.OrdinalIgnoreCase); + private readonly List _open = []; + private readonly Dictionary _enums = new(StringComparer.Ordinal); + + /// The enum names, or null when it is not one, or cannot be found. + public EnumInfo? Find(ApiNamedType type) + { + var key = (types.AssemblyOf(type) ?? "") + "|" + type.FullName; + if (_enums.TryGetValue(key, out var known)) + return known; + var found = Find(own, type, types.AssemblyOf(type), 0); + _enums[key] = found; + return found; + } + + private EnumInfo? Find(MetadataReader reader, ApiNamedType type, string? assembly, int depth) + { + if (depth > 4) + return null; + // a reference to another assembly is looked up there; the assembly's own types here + if (ReferenceEquals(reader, own) && assembly is not null) + return Open(assembly) is { } referenced ? Find(referenced, type, null, depth + 1) : null; + + var path = Path(type); + // the assembly being read is asked about many enums, and indexed once; another is asked about few, and searched + if (ReferenceEquals(reader, own) ? Index(reader).TryGetValue(path, out var definition) : TryFind(reader, type, out definition)) + return Read(reader, reader.GetTypeDefinition(definition)); + // a forwarder sends the type on to the assembly that has it now + foreach (var handle in reader.ExportedTypes) + { + var exported = reader.GetExportedType(handle); + if (!exported.IsForwarder || exported.Implementation.Kind != HandleKind.AssemblyReference) + continue; + var name = reader.GetString(exported.Namespace).Length == 0 + ? reader.GetString(exported.Name) + : reader.GetString(exported.Namespace) + "." + reader.GetString(exported.Name); + if (name != path.Split('/')[0]) + continue; + var target = reader.GetString(reader.GetAssemblyReference((AssemblyReferenceHandle)exported.Implementation).Name); + return Open(target) is { } forwarded ? Find(forwarded, type, null, depth + 1) : null; + } + return null; + } + + private readonly Dictionary> _indexes = []; + + /// Each assembly's types by full name, built once: an assembly such as CoreLib is asked about many enums. + private Dictionary Index(MetadataReader reader) + { + if (_indexes.TryGetValue(reader, out var index)) + return index; + index = new Dictionary(StringComparer.Ordinal); + foreach (var handle in reader.TypeDefinitions) + index[FullName(reader, handle)] = handle; + return _indexes[reader] = index; + } + + /// A type by namespace and name, nested ones through their declaring types, compared in place. + private static bool TryFind(MetadataReader reader, ApiNamedType type, out TypeDefinitionHandle found) + { + found = default; + var chain = new List(); + for (var current = type; current is not null; current = current.DeclaringType) + chain.Insert(0, current); + foreach (var handle in reader.TypeDefinitions) + { + var definition = reader.GetTypeDefinition(handle); + if ( + !definition.GetDeclaringType().IsNil || !reader.StringComparer.Equals(definition.Name, chain[0].Name) || + !reader.StringComparer.Equals(definition.Namespace, chain[0].Namespace) + ) + continue; + found = handle; + for (var i = 1; i < chain.Count && !found.IsNil; i++) + { + var nested = default(TypeDefinitionHandle); + foreach (var candidate in reader.GetTypeDefinition(found).GetNestedTypes()) + if (reader.StringComparer.Equals(reader.GetTypeDefinition(candidate).Name, chain[i].Name)) + nested = candidate; + found = nested; + } + return !found.IsNil; + } + return false; + } + + private static string Path(ApiNamedType type) => + type.DeclaringType is null ? (type.Namespace.Length == 0 ? type.Name : type.Namespace + "." + type.Name) : Path(type.DeclaringType) + "/" + type.Name; + + private static string FullName(MetadataReader reader, TypeDefinitionHandle handle) + { + var definition = reader.GetTypeDefinition(handle); + var declaring = definition.GetDeclaringType(); + if (!declaring.IsNil) + return FullName(reader, declaring) + "/" + reader.GetString(definition.Name); + var ns = reader.GetString(definition.Namespace); + return ns.Length == 0 ? reader.GetString(definition.Name) : ns + "." + reader.GetString(definition.Name); + } + + private static EnumInfo? Read(MetadataReader reader, TypeDefinition definition) + { + var provider = new TypeReferenceProvider(reader); + if (definition.BaseType.IsNil || provider.FullName(definition.BaseType) != "System.Enum") + return null; + var underlying = PrimitiveTypeCode.Int32; + var values = new List<(string, ulong)>(); + foreach (var handle in definition.GetFields()) + { + var field = reader.GetFieldDefinition(handle); + if ((field.Attributes & FieldAttributes.Static) == 0) + { + if (field.DecodeSignature(provider, TypeContext.None) is ApiNamedType { Namespace: "System" } type) + underlying = Underlying(type.Name); + continue; + } + if ((field.Attributes & FieldAttributes.Literal) != 0 && Value(reader, field.GetDefaultValue()) is { } value) + values.Add((reader.GetString(field.Name), value)); + } + var isFlags = definition.GetCustomAttributes().Any(h => + { + var attribute = reader.GetCustomAttribute(h); + return attribute.Constructor.Kind == HandleKind.MemberReference && provider.FullName( + reader.GetMemberReference((MemberReferenceHandle)attribute.Constructor).Parent + ) == "System.FlagsAttribute" || attribute.Constructor.Kind == HandleKind.MethodDefinition && provider.FullName( + reader.GetMethodDefinition((MethodDefinitionHandle)attribute.Constructor).GetDeclaringType() + ) == "System.FlagsAttribute"; + }); + return new EnumInfo(underlying, values, isFlags); + } + + internal static PrimitiveTypeCode Underlying(string name) => name switch + { + "Byte" => PrimitiveTypeCode.Byte, + "SByte" => PrimitiveTypeCode.SByte, + "Int16" => PrimitiveTypeCode.Int16, + "UInt16" => PrimitiveTypeCode.UInt16, + "UInt32" => PrimitiveTypeCode.UInt32, + "Int64" => PrimitiveTypeCode.Int64, + "UInt64" => PrimitiveTypeCode.UInt64, + "Char" => PrimitiveTypeCode.Char, + "Boolean" => PrimitiveTypeCode.Boolean, + _ => PrimitiveTypeCode.Int32, + }; + + private static ulong? Value(MetadataReader reader, ConstantHandle handle) + { + if (handle.IsNil) + return null; + var constant = reader.GetConstant(handle); + var blob = reader.GetBlobReader(constant.Value); + return constant.TypeCode switch + { + ConstantTypeCode.SByte => unchecked((ulong)blob.ReadSByte()), + ConstantTypeCode.Byte => blob.ReadByte(), + ConstantTypeCode.Int16 => unchecked((ulong)blob.ReadInt16()), + ConstantTypeCode.UInt16 => blob.ReadUInt16(), + ConstantTypeCode.Int32 => unchecked((ulong)blob.ReadInt32()), + ConstantTypeCode.UInt32 => blob.ReadUInt32(), + ConstantTypeCode.Int64 => unchecked((ulong)blob.ReadInt64()), + ConstantTypeCode.UInt64 => blob.ReadUInt64(), + ConstantTypeCode.Char => blob.ReadChar(), + ConstantTypeCode.Boolean => blob.ReadBoolean() ? 1UL : 0UL, + _ => null, + }; + } + + /// An assembly from the folders, by name; null when none of them has it. + private MetadataReader? Open(string name) + { + if (_assemblies.TryGetValue(name, out var known)) + return known; + MetadataReader? reader = null; + foreach (var directory in directories) + { + var file = System.IO.Path.Combine(directory, name + ".dll"); + if (!File.Exists(file)) + continue; + try + { + // only the metadata is read, not a whole CoreLib + var pe = new PEReader(File.OpenRead(file), PEStreamOptions.PrefetchMetadata); + if (!pe.HasMetadata) + continue; + _open.Add(pe); + reader = pe.GetMetadataReader(); + break; + } + catch (Exception e) when (e is BadImageFormatException or IOException or UnauthorizedAccessException) + { + // not an assembly, or not readable: look further + } + } + _assemblies[name] = reader; + return reader; + } + + /// + /// A value of as C#: its member, StringComparison.Ordinal; flags joined, + /// FileShare.Read | FileShare.Write; or a cast of the number when the enum cannot be found or has no such + /// member, (StringComparison)42. + /// + public string Render(ApiNamedType type, object value) + { + var display = Display(type); + var number = Number(value); + var cast = "(" + display + ")" + Convert.ToString(value, CultureInfo.InvariantCulture); + if (number is not { } bits || Find(type) is not { } info) + return cast; + foreach (var (name, member) in info.Values) + if (member == bits) + return display + "." + name; + if (!info.IsFlags || bits == 0) + return cast; + var names = new List(); + var left = bits; + foreach (var (name, member) in info.Values.Where(v => v.Value != 0).OrderByDescending(v => v.Value)) + { + if ((left & member) == member) + { + names.Add(display + "." + name); + left &= ~member; + } + } + return left == 0 ? string.Join(" | ", Enumerable.Reverse(names)) : cast; + } + + private static string Display(ApiNamedType type) => (type.DeclaringType is null ? "" : Display(type.DeclaringType) + ".") + ApiWriter.NonGeneric(type.Name); + + private static ulong? Number(object value) => value switch + { + sbyte v => unchecked((ulong)v), + byte v => v, + short v => unchecked((ulong)v), + ushort v => v, + int v => unchecked((ulong)v), + uint v => v, + long v => unchecked((ulong)v), + ulong v => v, + char v => v, + bool v => v ? 1UL : 0UL, + _ => null, + }; + + public void Dispose() + { + foreach (var pe in _open) + pe.Dispose(); + } +} diff --git a/src/Nullean.PublicApi/Signatures.cs b/src/Nullean.PublicApi/Signatures.cs index 757eceb..5022e31 100644 --- a/src/Nullean.PublicApi/Signatures.cs +++ b/src/Nullean.PublicApi/Signatures.cs @@ -29,6 +29,16 @@ internal sealed class TypeReferenceProvider( ) : ISignatureTypeProvider, ICustomAttributeTypeProvider { private readonly Dictionary _named = []; + // the assembly each referenced type comes from, by full name + private readonly Dictionary _assemblies = new(StringComparer.Ordinal); + + /// The assembly a referenced type comes from; null for one this assembly defines. + internal string? AssemblyOf(ApiNamedType type) + { + while (type.DeclaringType is not null) + type = type.DeclaringType; + return _assemblies.TryGetValue(type.FullName, out var assembly) ? assembly : null; + } private static readonly ApiNativeIntegerType Nint = new(false); private static readonly ApiNativeIntegerType Nuint = new(true); @@ -142,7 +152,16 @@ internal ApiNamedType Reference(TypeReferenceHandle handle, bool isValueType) var reference = reader.GetTypeReference(handle); var scope = reference.ResolutionScope; var declaring = scope.Kind == HandleKind.TypeReference ? Reference((TypeReferenceHandle)scope, false) : null; - return new ApiNamedType(declaring?.Namespace ?? reader.GetString(reference.Namespace), reader.GetString(reference.Name), declaring, [], isValueType); + var named = new ApiNamedType( + declaring?.Namespace ?? reader.GetString(reference.Namespace), + reader.GetString(reference.Name), + declaring, + [], + isValueType + ); + if (scope.Kind == HandleKind.AssemblyReference) + _assemblies[named.FullName] = reader.GetString(reader.GetAssemblyReference((AssemblyReferenceHandle)scope).Name); + return named; } public ApiTypeReference GetTypeFromSpecification(MetadataReader metadata, TypeContext genericContext, TypeSpecificationHandle handle, byte rawTypeKind) => @@ -224,41 +243,10 @@ public ApiTypeReference GetTypeFromSerializedName(string name) return named; } - private Dictionary? _enumTypes; + /// Finds enums for attribute arguments; set by the reader. + internal EnumCatalog? Catalog { get; set; } - public PrimitiveTypeCode GetUnderlyingEnumType(ApiTypeReference type) - { - // an enum this assembly defines says; one from elsewhere is assumed to be an int, which nearly all are - if (_enumTypes is null) - { - _enumTypes = new Dictionary(StringComparer.Ordinal); - foreach (var handle in reader.TypeDefinitions) - if (Definition(handle) is { IsValueType: true } definition) - foreach (var fieldHandle in reader.GetTypeDefinition(handle).GetFields()) - { - var field = reader.GetFieldDefinition(fieldHandle); - if ( - (field.Attributes & System.Reflection.FieldAttributes.Static) == 0 && - field.DecodeSignature(this, TypeContext.None) is ApiNamedType { Namespace: "System" } underlying - ) - { - _enumTypes[definition.FullName] = underlying.Name switch - { - "Byte" => PrimitiveTypeCode.Byte, - "SByte" => PrimitiveTypeCode.SByte, - "Int16" => PrimitiveTypeCode.Int16, - "UInt16" => PrimitiveTypeCode.UInt16, - "UInt32" => PrimitiveTypeCode.UInt32, - "Int64" => PrimitiveTypeCode.Int64, - "UInt64" => PrimitiveTypeCode.UInt64, - "Char" => PrimitiveTypeCode.Char, - "Boolean" => PrimitiveTypeCode.Boolean, - _ => PrimitiveTypeCode.Int32, - }; - break; - } - } - } - return type is ApiNamedType named && _enumTypes.TryGetValue(named.FullName, out var code) ? code : PrimitiveTypeCode.Int32; - } + public PrimitiveTypeCode GetUnderlyingEnumType(ApiTypeReference type) => + // the enum says, where it can be found; one that cannot is assumed to be an int, as nearly all are + type is ApiNamedType named && Catalog?.Find(named) is { } info ? info.Underlying : PrimitiveTypeCode.Int32; } diff --git a/tests/Nullean.PublicApi.Tests/.snapshots/WriterTests/Writes.enums.bd44bae9.verified.txt b/tests/Nullean.PublicApi.Tests/.snapshots/WriterTests/Writes.enums.bd44bae9.verified.txt new file mode 100644 index 0000000..c2b554e --- /dev/null +++ b/tests/Nullean.PublicApi.Tests/.snapshots/WriterTests/Writes.enums.bd44bae9.verified.txt @@ -0,0 +1,31 @@ +assembly Lib +using System: Attribute, AttributeUsageAttribute, FlagsAttribute +using struct System: StringComparison +using System.ComponentModel: EditorBrowsableAttribute +using struct System.IO: FileShare + +namespace Lib + [Flags] + enum Access + None = 0 + Read = 1 + Write = 2 + Execute = 4 + ReadWrite = 3 + + class Files + Files() + const Access Default = Access.Read + [Sized(Size.Large)] + [EditorBrowsable(EditorBrowsableState.Never)] + void Hidden() + void Open(FileShare share = FileShare.Read | FileShare.Delete, StringComparison comparison = StringComparison.OrdinalIgnoreCase, Size size = Size.Large, Access access = Access.ReadWrite | Access.Execute, Access none = Access.None, Access odd = (Access)64, Size? maybe = Size.Small) + + enum Size : byte + Small = 1 + Large = 200 + + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property)] + sealed class SizedAttribute : Attribute + SizedAttribute(Size size) + Size Size { get; } diff --git a/tests/Nullean.PublicApi.Tests/.snapshots/WriterTests/Writes.signatures.34d2702d.verified.txt b/tests/Nullean.PublicApi.Tests/.snapshots/WriterTests/Writes.signatures.34d2702d.verified.txt index 185c632..3a1067b 100644 --- a/tests/Nullean.PublicApi.Tests/.snapshots/WriterTests/Writes.signatures.34d2702d.verified.txt +++ b/tests/Nullean.PublicApi.Tests/.snapshots/WriterTests/Writes.signatures.34d2702d.verified.txt @@ -9,7 +9,7 @@ namespace Lib delegate* Callback volatile int Flag int this[int index, string key] { get; set; } - void Defaults(string s = "a\"b", char c = 'x', double d = 1.5d, decimal m = 2.5m, DayOfWeek day = (DayOfWeek)1, object? o = null, DateTime when = default) + void Defaults(string s = "a\"b", char c = 'x', double d = 1.5d, decimal m = 2.5m, DayOfWeek day = DayOfWeek.Monday, object? o = null, DateTime when = default) dynamic Dynamic(List list) override bool Equals(object? obj) T Generic(U value) where T : struct, IComparable where U : class?, new() diff --git a/tests/Nullean.PublicApi.Tests/ComparisonTests.cs b/tests/Nullean.PublicApi.Tests/ComparisonTests.cs index 71031ca..ec54b6d 100644 --- a/tests/Nullean.PublicApi.Tests/ComparisonTests.cs +++ b/tests/Nullean.PublicApi.Tests/ComparisonTests.cs @@ -63,6 +63,18 @@ public void Configured_not_an_extension_point_does_not_count() Report(result).Should().MatchSnapshot(); } + [Test] + public void An_enum_by_name_and_by_number_is_no_change() + { + const string source = "namespace Lib; public class A { public void M(System.StringComparison c = System.StringComparison.Ordinal) { } }"; + using var stream = Compiler.Compile(source); + var image = stream.ToArray(); + var named = ApiReader.Read(new MemoryStream(image)); + var cast = ApiReader.Read(new MemoryStream(image), new ApiReaderOptions { SearchRuntime = false }); + ApiComparison.Compare(cast, named).Changes.Should().BeEmpty(); + ApiComparison.Compare(named, cast).Changes.Should().BeEmpty(); + } + [Test] public void IntPtr_read_as_nint_is_no_change() { diff --git a/tests/Nullean.PublicApi.Tests/WriterTests.cs b/tests/Nullean.PublicApi.Tests/WriterTests.cs index b037abf..1be8ebb 100644 --- a/tests/Nullean.PublicApi.Tests/WriterTests.cs +++ b/tests/Nullean.PublicApi.Tests/WriterTests.cs @@ -101,6 +101,22 @@ public unsafe void Pointers(int* p, delegate* f) { } public override int GetHashCode() => 0; } """); + yield return ("enums", """ + using System; + using System.ComponentModel; + using System.IO; + namespace Lib; + public enum Size : byte { Small = 1, Large = 200 } + [Flags] public enum Access { None = 0, Read = 1, Write = 2, Execute = 4, ReadWrite = Read | Write } + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property)] + public sealed class SizedAttribute(Size size) : Attribute { public Size Size { get; } = size; } + public class Files + { + public void Open(FileShare share = FileShare.Read | FileShare.Delete, StringComparison comparison = StringComparison.OrdinalIgnoreCase, Size size = Size.Large, Access access = Access.ReadWrite | Access.Execute, Access none = Access.None, Access odd = (Access)64, Size? maybe = Size.Small) { } + public const Access Default = Access.Read; + [EditorBrowsable(EditorBrowsableState.Never)] [Sized(Size.Large)] public void Hidden() { } + } + """); yield return ("names", """ namespace Lib.A { public class Range { } public class Task { } } namespace Lib.B @@ -122,6 +138,13 @@ public class InGlobal { } [MethodDataSource(nameof(Cases))] public void Writes(string name, string source) => Compiler.Api(source).Should().MatchSnapshot(name: name); + [Test] + public void An_enum_that_cannot_be_found_is_a_cast() + { + using var stream = Compiler.Compile("namespace Lib; public class A { public void M(System.StringComparison c = System.StringComparison.Ordinal) { } }"); + ApiWriter.Write(ApiReader.Read(stream, new ApiReaderOptions { SearchRuntime = false })).Should().Contain("StringComparison c = (StringComparison)4"); + } + [Test] public void Without_nullable_annotations() => Compiler.Api("namespace Lib; public class Oblivious { public string Name(string? value) => value!; }", nullable: false).Should().MatchSnapshot();