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();