diff --git a/src/Nullean.PublicApi/ApiParser.cs b/src/Nullean.PublicApi/ApiParser.cs index 9d97646..4855780 100644 --- a/src/Nullean.PublicApi/ApiParser.cs +++ b/src/Nullean.PublicApi/ApiParser.cs @@ -728,6 +728,11 @@ private static ApiMember Declaration( { var conversion = cursor.Word() == "implicit" ? "op_Implicit" : "op_Explicit"; cursor.ExpectWord("operator"); + if (conversion == "op_Explicit" && cursor.PeekWord() == "checked") + { + cursor.Word(); + conversion = "op_CheckedExplicit"; + } var conversionType = scope.Type(cursor); var conversionParameters = Parameters(cursor, scope, '(', ')', out _); var conversionBody = Body(cursor); @@ -977,6 +982,12 @@ bool isSealedInInterface } var hasBody = Body(cursor); cursor.End(); + // a readonly property's accessors are all readonly + if ((modifiers & ApiMemberModifiers.ReadOnly) != 0) + { + getter = getter is null ? null : getter with { IsReadOnly = true }; + setter = setter is null ? null : setter with { IsReadOnly = true }; + } return new ApiProperty( name, access, @@ -1021,18 +1032,19 @@ private static List Parameters(Cursor cursor, Scope scope, char op cursor.Word(); isExtension = true; } - var isScoped = false; - if (cursor.PeekWord() == "scoped") - { - cursor.Word(); - isScoped = true; - } + // params scoped, as C# writes it, and scoped alone var isParams = false; if (cursor.PeekWord() == "params") { cursor.Word(); isParams = true; } + var isScoped = false; + if (cursor.PeekWord() == "scoped") + { + cursor.Word(); + isScoped = true; + } var refKind = cursor.PeekWord() switch { "out" => ApiRefKind.Out, @@ -1103,20 +1115,32 @@ private static string OperatorName(string symbol, int parameters, Cursor cursor) ">=" => "op_GreaterThanOrEqual", "!" => "op_LogicalNot", "~" => "op_OnesComplement", - "++" => "op_Increment", - "--" => "op_Decrement", + // without a parameter, the instance operator that changes the value it is called on + "++" => parameters == 0 ? "op_IncrementAssignment" : "op_Increment", + "--" => parameters == 0 ? "op_DecrementAssignment" : "op_Decrement", "true" => "op_True", "false" => "op_False", "checked +" => "op_CheckedAddition", "checked -" => unary ? "op_CheckedUnaryNegation" : "op_CheckedSubtraction", "checked *" => "op_CheckedMultiply", "checked /" => "op_CheckedDivision", - "checked ++" => "op_CheckedIncrement", - "checked --" => "op_CheckedDecrement", + "checked ++" => parameters == 0 ? "op_CheckedIncrementAssignment" : "op_CheckedIncrement", + "checked --" => parameters == 0 ? "op_CheckedDecrementAssignment" : "op_CheckedDecrement", "+=" => "op_AdditionAssignment", "-=" => "op_SubtractionAssignment", "*=" => "op_MultiplicationAssignment", "/=" => "op_DivisionAssignment", + "%=" => "op_ModulusAssignment", + "&=" => "op_BitwiseAndAssignment", + "|=" => "op_BitwiseOrAssignment", + "^=" => "op_ExclusiveOrAssignment", + "<<=" => "op_LeftShiftAssignment", + ">>=" => "op_RightShiftAssignment", + ">>>=" => "op_UnsignedRightShiftAssignment", + "checked +=" => "op_CheckedAdditionAssignment", + "checked -=" => "op_CheckedSubtractionAssignment", + "checked *=" => "op_CheckedMultiplicationAssignment", + "checked /=" => "op_CheckedDivisionAssignment", _ when symbol.StartsWith("op_", StringComparison.Ordinal) => symbol, _ => null, }; diff --git a/src/Nullean.PublicApi/ApiReader.cs b/src/Nullean.PublicApi/ApiReader.cs index b8a85ac..5c87da5 100644 --- a/src/Nullean.PublicApi/ApiReader.cs +++ b/src/Nullean.PublicApi/ApiReader.cs @@ -776,7 +776,7 @@ bool isStruct return null; var isInit = isSetter && m.DecodeSignature(_types, context).ReturnType is ApiModifiedType { Modifier.FullName: CompilerServices + "IsExternalInit" }; - var isReadOnly = isStruct && !isSetter && HasAttribute(m.GetCustomAttributes(), CompilerServices + "IsReadOnlyAttribute"); + var isReadOnly = isStruct && HasAttribute(m.GetCustomAttributes(), CompilerServices + "IsReadOnlyAttribute"); return new ApiAccessor(access.Value, isInit, isReadOnly); } @@ -784,7 +784,7 @@ bool isStruct _reader.GetString(property.Name), accessibility, Attributes(property.GetCustomAttributes()), - Modifiers(primary, declaring), + PropertyModifiers(Modifiers(primary, declaring), getter, setter, isStruct), propertyType, refKind, parameters, @@ -794,6 +794,17 @@ bool isStruct ); } + /// A property is readonly when every accessor is: the getter alone being readonly is the getter's. + private ApiMemberModifiers PropertyModifiers(ApiMemberModifiers modifiers, MethodDefinition? getter, MethodDefinition? setter, bool isStruct) + { + bool ReadOnly(MethodDefinition? accessor) => accessor is not { } m || HasAttribute(m.GetCustomAttributes(), CompilerServices + "IsReadOnlyAttribute"); + + return isStruct && ReadOnly(getter) && ReadOnly(setter) ? modifiers | ApiMemberModifiers.ReadOnly : modifiers & ~ApiMemberModifiers.ReadOnly; + } + + private bool IsParams(IEnumerable attributes) => + HasAttribute(attributes, "System.ParamArrayAttribute") || HasAttribute(attributes, CompilerServices + "ParamCollectionAttribute"); + private static ApiAccessibility Most(ApiAccessibility? first, ApiAccessibility? second) { if (first == ApiAccessibility.Public || second == ApiAccessibility.Public) @@ -822,7 +833,7 @@ private static ApiAccessibility Most(ApiAccessibility? first, ApiAccessibility? var special = (method.Attributes & MethodAttributes.SpecialName) != 0; var kind = name == ".ctor" ? ApiMethodKind.Constructor - : special && name is "op_Implicit" or "op_Explicit" + : special && name is "op_Implicit" or "op_Explicit" or "op_CheckedExplicit" ? ApiMethodKind.Conversion : special && name.StartsWith("op_", StringComparison.Ordinal) ? ApiMethodKind.Operator : ApiMethodKind.Method; @@ -961,8 +972,9 @@ private IReadOnlyList ReadParameters(MethodDefinition method, Immu row is { } named ? _reader.GetString(named.Name) : "arg" + i, Annotate(rawType, attributes, methodContext, offset), refKind, - HasAttribute(attributes, "System.ParamArrayAttribute") || HasAttribute(attributes, CompilerServices + "ParamCollectionAttribute"), - HasAttribute(attributes, CompilerServices + "ScopedRefAttribute"), + IsParams(attributes), + // a params span is scoped without saying so; only a scoped the source wrote is the API's + HasAttribute(attributes, CompilerServices + "ScopedRefAttribute") && !IsParams(attributes), row is { } optional ? DefaultValue(optional, rawType) : null, Attributes(attributes) ) diff --git a/src/Nullean.PublicApi/ApiWriter.cs b/src/Nullean.PublicApi/ApiWriter.cs index 00cb988..89acd09 100644 --- a/src/Nullean.PublicApi/ApiWriter.cs +++ b/src/Nullean.PublicApi/ApiWriter.cs @@ -415,8 +415,9 @@ public void Member(ApiMember member, ApiType type, int depth) else builder.Append(property.Name); builder.Append(" {"); - Accessor(property.Getter, property.Accessibility, "get"); - Accessor(property.Setter, property.Accessibility, property.Setter is { IsInit: true } ? "init" : "set"); + var allReadOnly = (property.Modifiers & ApiMemberModifiers.ReadOnly) != 0; + Accessor(property.Getter, property.Accessibility, "get", allReadOnly); + Accessor(property.Setter, property.Accessibility, property.Setter is { IsInit: true } ? "init" : "set", allReadOnly); builder.Append(" }"); if (inInterface && HasBody(property.Modifiers)) builder.Append(" { … }"); @@ -437,7 +438,12 @@ public void Member(ApiMember member, ApiType type, int depth) switch (method.Kind) { case ApiMethodKind.Conversion: - builder.Append(method.Name == "op_Implicit" ? "implicit" : "explicit").Append(" operator ").Append(returnType); + builder.Append(method.Name switch + { + "op_Implicit" => "implicit operator ", + "op_CheckedExplicit" => "explicit operator checked ", + _ => "explicit operator ", + }).Append(returnType); break; case ApiMethodKind.Operator: builder.Append(returnType).Append(" operator ").Append(Operator(method.Name)); @@ -505,14 +511,14 @@ private void Extension(ApiExtension extension, ApiType type, int depth) private static bool HasBody(ApiMemberModifiers modifiers) => (modifiers & ApiMemberModifiers.Virtual) != 0 || (modifiers & (ApiMemberModifiers.Static | ApiMemberModifiers.Abstract)) == 0; - private void Accessor(ApiAccessor? accessor, ApiAccessibility propertyAccessibility, string keyword) + private void Accessor(ApiAccessor? accessor, ApiAccessibility propertyAccessibility, string keyword, bool propertyReadOnly) { if (accessor is null) return; builder.Append(' '); if (accessor.Accessibility != propertyAccessibility) builder.Append(Access(accessor.Accessibility)).Append(' '); - if (accessor.IsReadOnly) + if (accessor.IsReadOnly && !propertyReadOnly) builder.Append("readonly "); builder.Append(keyword).Append(';'); } @@ -535,10 +541,11 @@ private void ParameterList(IReadOnlyList parameters, bool isExtens builder.Append(Attribute(attribute, null)).Append(' '); if (i == 0 && isExtension) builder.Append("this "); - if (parameter.IsScoped) - builder.Append("scoped "); + // params comes first: C# rejects scoped params if (parameter.IsParams) builder.Append("params "); + if (parameter.IsScoped) + builder.Append("scoped "); builder.Append(parameter.RefKind switch { ApiRefKind.Ref => "ref ", @@ -748,6 +755,22 @@ public string Reference(ApiTypeReference type) "op_SubtractionAssignment" => "-=", "op_MultiplicationAssignment" => "*=", "op_DivisionAssignment" => "/=", + "op_ModulusAssignment" => "%=", + "op_BitwiseAndAssignment" => "&=", + "op_BitwiseOrAssignment" => "|=", + "op_ExclusiveOrAssignment" => "^=", + "op_LeftShiftAssignment" => "<<=", + "op_RightShiftAssignment" => ">>=", + "op_UnsignedRightShiftAssignment" => ">>>=", + // an instance ++ changes the value it is called on: void operator ++() + "op_IncrementAssignment" => "++", + "op_DecrementAssignment" => "--", + "op_CheckedAdditionAssignment" => "checked +=", + "op_CheckedSubtractionAssignment" => "checked -=", + "op_CheckedMultiplicationAssignment" => "checked *=", + "op_CheckedDivisionAssignment" => "checked /=", + "op_CheckedIncrementAssignment" => "checked ++", + "op_CheckedDecrementAssignment" => "checked --", _ => name, }; } diff --git a/tests/Nullean.PublicApi.Tests/.snapshots/WriterTests/Writes.modern.65b459a3.verified.txt b/tests/Nullean.PublicApi.Tests/.snapshots/WriterTests/Writes.modern.65b459a3.verified.txt new file mode 100644 index 0000000..f39c773 --- /dev/null +++ b/tests/Nullean.PublicApi.Tests/.snapshots/WriterTests/Writes.modern.65b459a3.verified.txt @@ -0,0 +1,75 @@ +assembly Lib +using System: Action +using struct System: ReadOnlySpan<> +using interface System: IDisposable, IEquatable<> +using System.Collections.Generic: IEnumerable<> +using System.Diagnostics.CodeAnalysis: ExperimentalAttribute +using System.Runtime.CompilerServices: InlineArrayAttribute, OverloadResolutionPriorityAttribute + +namespace Lib + class Api + Api() + int Field { get; set; } + void Add(params IEnumerable values) + void Keep(scoped ReadOnlySpan parts) + void Log(params ReadOnlySpan parts) + ref readonly int Peek(ref readonly int value) + [OverloadResolutionPriority(1)] + void Prefer(ReadOnlySpan s) + void Prefer(string s) + void Take(T value) where T : allows ref struct + [Experimental("LIB001")] + void Trial() + + [InlineArray(4)] + struct Buffer4 + + ref struct Cursor : IDisposable + void Dispose() + + interface ICounter + static int One { get; } { … } + static abstract int Zero { get; } + void Tick() { … } + static int Two() + + struct Money + decimal Amount + int Auto { readonly get; set; } + int Mutating { get; set; } + int Setter { get; readonly set; } + readonly int Whole { get; set; } + readonly int Sum() + static Money operator +(Money a, Money b) + void operator +=(Money other) + static Money operator checked +(Money a, Money b) + void operator checked +=(Money other) + void operator checked --() + void operator --() + static Money operator ++(Money m) + void operator ++() + void operator %=(Money other) + void operator >>>=(int count) + static explicit operator checked int(Money m) + static explicit operator int(Money m) + + class Parts + Parts() + int Size { get; } + event Action? Changed + + struct Point : IEquatable + Point(int X, int Y) + int X { readonly get; set; } + int Y { readonly get; set; } + readonly void Deconstruct(out int X, out int Y) + readonly bool Equals(Point other) + override readonly bool Equals(object obj) + override readonly int GetHashCode() + override readonly string ToString() + static bool operator ==(Point left, Point right) + static bool operator !=(Point left, Point right) + + class Primary + Primary(int seed) + int Seed { get; } diff --git a/tests/Nullean.PublicApi.Tests/RoslynOracleTests.cs b/tests/Nullean.PublicApi.Tests/RoslynOracleTests.cs index 18616d8..e82b9f2 100644 --- a/tests/Nullean.PublicApi.Tests/RoslynOracleTests.cs +++ b/tests/Nullean.PublicApi.Tests/RoslynOracleTests.cs @@ -131,9 +131,9 @@ private static void Ours(ApiMember member, string name, Dictionary p.Type))] = string.Join(" ", facts) + " " + RefKind( property.RefKind ) + Render(property.Type) + Parameters(property.Parameters); @@ -147,9 +147,9 @@ private static void Ours(ApiMember member, string name, Dictionary p.Type), method.Parameters.Select(p => p.RefKind) )] = Access(method.Accessibility) + " " + (IsFinalizer(method) ? "[finalizer]" : Modifiers(method.Modifiers)) + - (method.IsExtension ? " extension" : "") + TypeParameters(method.TypeParameters) + " " + RefKind(method.ReturnRefKind) + Render( - method.ReturnType - ) + Parameters(method.Parameters); + ((method.Modifiers & ApiMemberModifiers.ReadOnly) != 0 ? " readonly" : "") + (method.IsExtension ? " extension" : "") + TypeParameters( + method.TypeParameters + ) + " " + RefKind(method.ReturnRefKind) + Render(method.ReturnType) + Parameters(method.Parameters); break; } } @@ -402,9 +402,9 @@ private static void Theirs(ISymbol member, string name, bool extendable, Diction if (property.IsRequired) facts.Add("required"); if (get is not null) - facts.Add("get:" + Access(get.DeclaredAccessibility)); + facts.Add("get:" + Access(get.DeclaredAccessibility) + (Readonly(get) ? ":readonly" : "")); if (set is not null) - facts.Add((set.IsInitOnly ? "init:" : "set:") + Access(set.DeclaredAccessibility)); + facts.Add((set.IsInitOnly ? "init:" : "set:") + Access(set.DeclaredAccessibility) + (Readonly(set) ? ":readonly" : "")); result[name + "::" + property.MetadataName + Key(property.Parameters)] = string.Join(" ", facts) + " " + (property.RefKind == Microsoft.CodeAnalysis.RefKind.RefReadOnly ? "ref readonly " : RefKind(property.RefKind)) + Render( property.Type, @@ -425,7 +425,7 @@ private static void Theirs(ISymbol member, string name, bool extendable, Diction result[name + "::" + method.MetadataName + "`" + method.TypeParameters.Length + Key(method.Parameters)] = // Roslyn shows a finalizer as a destructor, which is never virtual; metadata says it is Access(method.DeclaredAccessibility) + " " + (method.MethodKind == MethodKind.Destructor ? "[finalizer]" : Modifiers(method)) + - (method.IsExtensionMethod ? " extension" : "") + TypeParameters(method.TypeParameters) + " " + + (Readonly(method) ? " readonly" : "") + (method.IsExtensionMethod ? " extension" : "") + TypeParameters(method.TypeParameters) + " " + (method.RefKind == Microsoft.CodeAnalysis.RefKind.RefReadOnly ? "ref readonly " : RefKind(method.RefKind)) + Render( method.ReturnType, method.ReturnNullableAnnotation @@ -434,6 +434,10 @@ private static void Theirs(ISymbol member, string name, bool extendable, Diction } } + /// A struct member declared or made readonly: in a readonly struct every member is, so none says so. + private static bool Readonly(IMethodSymbol method) => + method.IsReadOnly && method.ContainingType is { TypeKind: TypeKind.Struct, IsReadOnly: false } && !method.IsStatic; + private static bool PublicAllTheWay(INamedTypeSymbol type) => type.DeclaredAccessibility == Accessibility.Public && (type.ContainingType is not { } containing || PublicAllTheWay(containing)); @@ -447,7 +451,7 @@ private static string Parameters(ImmutableArray parameters) => ", ", parameters.Select( p => (p.IsParams ? "params " : "") + - (p.ScopedKind != ScopedKind.None && p.RefKind != Microsoft.CodeAnalysis.RefKind.Out && !p.IsThis ? "scoped " : "") + RefKind( + (p.ScopedKind != ScopedKind.None && p.RefKind != Microsoft.CodeAnalysis.RefKind.Out && !p.IsThis && !p.IsParams ? "scoped " : "") + RefKind( p.RefKind ) + Render(p.Type, p.NullableAnnotation) + " " + p.MetadataName + (p.IsOptional ? " = ?" : "") ) diff --git a/tests/Nullean.PublicApi.Tests/WriterTests.cs b/tests/Nullean.PublicApi.Tests/WriterTests.cs index 92cce8b..8e7b2b3 100644 --- a/tests/Nullean.PublicApi.Tests/WriterTests.cs +++ b/tests/Nullean.PublicApi.Tests/WriterTests.cs @@ -149,6 +149,59 @@ public static class TextExtensions } } """); + yield return ("modern", """ + using System; + using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; + using System.Runtime.CompilerServices; + namespace Lib; + public struct Money + { + public decimal Amount; + public static Money operator +(Money a, Money b) => a; + public static Money operator checked +(Money a, Money b) => a; + public void operator +=(Money other) { } + public void operator checked +=(Money other) { } + public void operator %=(Money other) { } + public void operator >>>=(int count) { } + public void operator ++() { } + public void operator --() { } + public void operator checked --() { } + public static Money operator ++(Money m) => m; + public static explicit operator int(Money m) => 0; + public static explicit operator checked int(Money m) => 0; + public int Auto { get; set; } + public int Setter { get => 0; readonly set { } } + public readonly int Whole { get => 0; set { } } + public int Mutating { get => 0; set { } } + public readonly int Sum() => 0; + } + [InlineArray(4)] + public struct Buffer4 { private int _element; } + public ref struct Cursor : IDisposable { public void Dispose() { } } + public class Api + { + public void Log(params ReadOnlySpan parts) { } + public void Keep(scoped ReadOnlySpan parts) { } + public void Add(params IEnumerable values) { } + public void Take(T value) where T : allows ref struct { } + [OverloadResolutionPriority(1)] public void Prefer(ReadOnlySpan s) { } + public void Prefer(string s) { } + [Experimental("LIB001")] public void Trial() { } + public ref readonly int Peek(ref readonly int value) => ref value; + public int Field { get => field; set => field = value; } + } + public partial class Parts + { + public partial int Size { get; } + public partial int Size { get => 0; } + public partial event Action? Changed; + public partial event Action? Changed { add { } remove { } } + } + public interface ICounter { static abstract int Zero { get; } static virtual int One => 1; static int Two() => 2; void Tick() { } } + public record struct Point(int X, int Y); + public class Primary(int seed) { public int Seed => seed; } + """); yield return ("names", """ namespace Lib.A { public class Range { } public class Task { } } namespace Lib.B