Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/Nullean.PublicApi.net10.0.api
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> ReferenceDirectories { get; init; }
bool SearchRuntime { get; init; }

enum ApiRefKind
None = 0
Ref = 1
Expand Down
7 changes: 7 additions & 0 deletions api/Nullean.PublicApi.netstandard2.0.api
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> ReferenceDirectories { get; init; }
bool SearchRuntime { get; init; }

enum ApiRefKind
None = 0
Ref = 1
Expand Down
48 changes: 44 additions & 4 deletions src/Nullean.PublicApi.Diff/ApiComparison.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -972,12 +975,49 @@ private static bool SameAttributes(IReadOnlyList<ApiAttribute> 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;
}

/// <summary>
/// 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 <c>(StringComparison)4</c> and <c>StringComparison.Ordinal</c>
/// cannot be told apart and count as the same.
/// </summary>
private static bool SameLiteral(string? a, string? b) => a == b || (a is not null && b is not null && (CastOfNamed(a, b) || CastOfNamed(b, a)));

/// <summary>Whether <paramref name="cast"/> is <c>(Type)number</c> and <paramref name="named"/> names members of that type.</summary>
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 = [", "];

/// <summary>Two attributes as written, their arguments compared as literals.</summary>
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<ApiAttribute> attributes) =>
attributes.Any(a => a.Type.FullName == Obsolete && a.Arguments.Count >= 2 && a.Arguments[1] == "true");

Expand Down
119 changes: 99 additions & 20 deletions src/Nullean.PublicApi/ApiReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>
Expand Down Expand Up @@ -108,18 +112,78 @@ out var version
#pragma warning restore CA1846
}

/// <summary>Reads the assembly at <paramref name="path"/>.</summary>
public static ApiAssembly Read(string path)
/// <summary>
/// Reads the assembly at <paramref name="path"/>, looking in its folder and the installed .NET runtime for the enums
/// it refers to.
/// </summary>
public static ApiAssembly Read(string path) => Read(path, new ApiReaderOptions());

/// <summary>Reads the assembly at <paramref name="path"/>, looking for the assemblies it references as <paramref name="options"/> say.</summary>
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]);
}

/// <summary>Reads an assembly from <paramref name="stream"/>, which must be seekable.</summary>
public static ApiAssembly Read(Stream stream)
/// <summary>Reads an assembly from <paramref name="stream"/>, which must be seekable, looking in the installed .NET runtime for the enums it refers to.</summary>
public static ApiAssembly Read(Stream stream) => Read(stream, new ApiReaderOptions());

/// <summary>Reads an assembly from <paramref name="stream"/>, which must be seekable, looking for the assemblies it references as <paramref name="options"/> say.</summary>
public static ApiAssembly Read(Stream stream, ApiReaderOptions options) => Read(stream, options, []);

private static ApiAssembly Read(Stream stream, ApiReaderOptions options, IReadOnlyList<string> 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();
}

/// <summary>
/// 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.
/// </summary>
private static IEnumerable<string> 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<string?> { 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()
Expand Down Expand Up @@ -372,7 +436,9 @@ private List<ApiMember> 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),
Expand Down Expand Up @@ -735,10 +801,15 @@ private IReadOnlyList<ApiParameter> 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)
Expand Down Expand Up @@ -970,13 +1041,13 @@ private IReadOnlyList<ApiAttribute> Attributes(IEnumerable<CustomAttributeHandle
};
}

private static string Literal(CustomAttributeTypedArgument<ApiTypeReference> argument) => argument.Value switch
private string Literal(CustomAttributeTypedArgument<ApiTypeReference> argument) => argument.Value switch
{
null => "null",
ImmutableArray<CustomAttributeTypedArgument<ApiTypeReference>> 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",
};

Expand All @@ -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
Expand All @@ -1013,9 +1084,17 @@ type is
ConstantTypeCode.String => blob.ReadUTF16(blob.Length),
_ => null,
};
return Constant(value);
return value;
}

/// <summary>A constant of an enum type by name, where the enum can be found; the number otherwise.</summary>
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<CustomAttributeHandle> attributes)
{
foreach (var handle in attributes)
Expand Down
Loading
Loading