diff --git a/api/Nullean.PublicApi.net10.0.api b/api/Nullean.PublicApi.net10.0.api index 165479e..2838c2c 100644 --- a/api/Nullean.PublicApi.net10.0.api +++ b/api/Nullean.PublicApi.net10.0.api @@ -115,6 +115,21 @@ namespace Nullean.PublicApi static bool operator ==(ApiEvent? left, ApiEvent? right) static bool operator !=(ApiEvent? left, ApiEvent? right) + sealed record ApiExtension : ApiMember, IEquatable + ApiExtension(string Name, ApiAccessibility Accessibility, IReadOnlyList Attributes, IReadOnlyList TypeParameters, ApiParameter Receiver, IReadOnlyList Members) + IReadOnlyList Members { get; init; } + ApiParameter Receiver { get; init; } + IReadOnlyList TypeParameters { get; init; } + override ApiExtension $() + void Deconstruct(out string Name, out ApiAccessibility Accessibility, out IReadOnlyList Attributes, out IReadOnlyList TypeParameters, out ApiParameter Receiver, out IReadOnlyList Members) + bool Equals(ApiExtension? other) + sealed override bool Equals(ApiMember? other) + override bool Equals(object? obj) + override int GetHashCode() + override string ToString() + static bool operator ==(ApiExtension? left, ApiExtension? right) + static bool operator !=(ApiExtension? left, ApiExtension? right) + sealed record ApiField : ApiMember, IEquatable ApiField(string Name, ApiAccessibility Accessibility, IReadOnlyList Attributes, ApiTypeReference Type, bool IsStatic, bool IsReadOnly, bool IsVolatile, bool IsRequired, string? Constant) string? Constant { get; init; } diff --git a/api/Nullean.PublicApi.netstandard2.0.api b/api/Nullean.PublicApi.netstandard2.0.api index 5acc54d..8745def 100644 --- a/api/Nullean.PublicApi.netstandard2.0.api +++ b/api/Nullean.PublicApi.netstandard2.0.api @@ -115,6 +115,21 @@ namespace Nullean.PublicApi static bool operator ==(ApiEvent? left, ApiEvent? right) static bool operator !=(ApiEvent? left, ApiEvent? right) + sealed record ApiExtension : ApiMember, IEquatable + ApiExtension(string Name, ApiAccessibility Accessibility, IReadOnlyList Attributes, IReadOnlyList TypeParameters, ApiParameter Receiver, IReadOnlyList Members) + IReadOnlyList Members { get; init; } + ApiParameter Receiver { get; init; } + IReadOnlyList TypeParameters { get; init; } + override ApiMember $() + void Deconstruct(out string Name, out ApiAccessibility Accessibility, out IReadOnlyList Attributes, out IReadOnlyList TypeParameters, out ApiParameter Receiver, out IReadOnlyList Members) + bool Equals(ApiExtension? other) + sealed override bool Equals(ApiMember? other) + override bool Equals(object? obj) + override int GetHashCode() + override string ToString() + static bool operator ==(ApiExtension? left, ApiExtension? right) + static bool operator !=(ApiExtension? left, ApiExtension? right) + sealed record ApiField : ApiMember, IEquatable ApiField(string Name, ApiAccessibility Accessibility, IReadOnlyList Attributes, ApiTypeReference Type, bool IsStatic, bool IsReadOnly, bool IsVolatile, bool IsRequired, string? Constant) string? Constant { get; init; } diff --git a/src/Nullean.PublicApi.Diff/ApiComparison.cs b/src/Nullean.PublicApi.Diff/ApiComparison.cs index bd395a8..da0cf79 100644 --- a/src/Nullean.PublicApi.Diff/ApiComparison.cs +++ b/src/Nullean.PublicApi.Diff/ApiComparison.cs @@ -78,6 +78,12 @@ public bool Equals(ApiMember? x, ApiMember? y) { case (ApiProperty a, ApiProperty b): return SameParameters(a.Parameters, b.Parameters); + // a block of one member (see Flattened): the receiver as the runtime sees it, its type parameters, and the member + case (ApiExtension a, ApiExtension b): + return a.TypeParameters.Count == b.TypeParameters.Count && SameParameters( + [a.Receiver], + [b.Receiver] + ) && a.Members.Count == b.Members.Count && a.Members.Zip(b.Members, Equals).All(same => same); case (ApiMethod a, ApiMethod b): return a.TypeParameters.Count == b.TypeParameters.Count && SameParameters(a.Parameters, b.Parameters) && (a.Kind != ApiMethodKind.Conversion || Context.SameRuntime(a.ReturnType, b.ReturnType)); @@ -102,6 +108,7 @@ public int GetHashCode(ApiMember obj) { ApiMethod m => m.Parameters.Count * 31 + m.TypeParameters.Count, ApiProperty p => p.Parameters.Count, + ApiExtension e => e.TypeParameters.Count * 31 + e.Members.Sum(m => GetHashCode(m)), _ => 0, }; return (StringComparer.Ordinal.GetHashCode(obj.Name) * 397) ^ (count * 17) ^ obj.GetType().GetHashCode(); @@ -223,7 +230,11 @@ void Changed(string part, string? old, string? @new, ApiAudience breaks, ApiComp ); } else - Pair(oldType.Members, newType.Members, MemberIdentity.Instance, (o, n) => Member(o, n, oldType, newType, here)); + { + var oldMembers = Flattened(oldType.Members); + var newMembers = Flattened(newType.Members); + Pair(Lowered(oldMembers, newMembers), Lowered(newMembers, oldMembers), MemberIdentity.Instance, (o, n) => Member(o, n, oldType, newType, here)); + } Pair(oldType.NestedTypes, newType.NestedTypes, t => t.Name, (o, n) => Type(o, n, here)); } @@ -379,6 +390,12 @@ private void Member(ApiMember? oldMember, ApiMember? newMember, ApiType oldType, List Here() => here ??= delegateInvoke ? path : path.Append(MemberSegment(member, declaring)).ToList(); + // a block added or removed is its member added or removed, under the block's receiver + if (member is ApiExtension { Members: [var inner] } && (oldMember is null || newMember is null)) + { + Member(oldMember is null ? null : inner, newMember is null ? null : inner, Block(oldType), Block(newType), Here()); + return; + } if (oldMember is null) { var (breaks, compatibility, note) = Obligation(newMember!, newType); @@ -471,6 +488,30 @@ void Changed(string part, string? old, string? @new, ApiAudience breaks, ApiComp Modifiers(o.Modifiers, n.Modifiers, oldType, Changed); TypeOf("type", o.Type, n.Type, oldType, newType, Changed); break; + case (ApiExtension o, ApiExtension n): + { + // the block's receiver, reported on the member it is the receiver of + var at = Here().Append(MemberSegment(n.Members[0], Block(newType))).ToList(); + + void Receiver(string part, string? old, string? @new, ApiAudience breaks, ApiCompatibility compatibility, string? note = null) => + Add(at, ApiChangeKind.Changed, part, old, @new, breaks, compatibility, oldType, oldMember, newMember, note); + // the receiver's name is what a caller passes it by to the static method the compiler wrote + if (o.Receiver.Name != n.Receiver.Name) + Receiver( + "receiver name", + o.Receiver.Name, + n.Receiver.Name, + ApiAudience.Callers, + ApiCompatibility.Source, + "callers that pass it by name" + ); + if (o.Receiver.RefKind != n.Receiver.RefKind) + Receiver("receiver ref", RefKind(o.Receiver.RefKind), RefKind(n.Receiver.RefKind), ApiAudience.Callers, Both); + TypeOf("receiver", o.Receiver.Type, n.Receiver.Type, oldType, newType, Receiver); + TypeParameters(o.TypeParameters, n.TypeParameters, oldType, newType, Receiver); + Member(o.Members[0], n.Members[0], Block(oldType), Block(newType), Here()); + break; + } case (ApiMethod o, ApiMethod n): Modifiers(o.Modifiers, n.Modifiers, oldType, Changed); TypeOf("return type", o.ReturnType, n.ReturnType, oldType, newType, Changed); @@ -1099,8 +1140,71 @@ private void Add( // keys and segments /// A member's identity: its kind, name, number of type parameters and parameter types, without what only the compiler reads. + /// + /// A type's members with each extension block split into one block per member. Blocks are how the source groups + /// them; a caller sees each member with its receiver, and a member can move to another block over the same type. + /// + private static IReadOnlyList Flattened(IReadOnlyList members) + { + if (!members.Any(m => m is ApiExtension)) + return members; + var result = new List(members.Count); + foreach (var member in members) + if (member is ApiExtension extension) + result.AddRange(extension.Members.Select(m => extension with { Members = [m] })); + else + result.Add(member); + return result; + } + + /// + /// The members, with a block's instance method written as the static method the compiler makes of it where the + /// other side declares that method the old way, static int A(this string s). Moving a method into a block, + /// or out of one, keeps what callers compiled against and what they write. + /// + private static IReadOnlyList Lowered(IReadOnlyList members, IReadOnlyList other) + { + List? result = null; + HashSet? classic = null; + for (var i = 0; i < members.Count; i++) + { + if ( + members[i] is not ApiExtension + { + Members: [ApiMethod { Kind: ApiMethodKind.Method } method] + } block || (method.Modifiers & ApiMemberModifiers.Static) != 0 + ) + continue; + classic ??= new HashSet(other.Where(m => m is ApiMethod { IsExtension: true }), MemberIdentity.Instance); + var lowered = new ApiMethod( + method.Name, + method.Accessibility, + method.Attributes, + ApiMethodKind.Method, + method.Modifiers | ApiMemberModifiers.Static, + method.ReturnType, + method.ReturnRefKind, + method.ReturnAttributes, + [.. block.TypeParameters, .. method.TypeParameters], + [block.Receiver, .. method.Parameters], + true + ); + if (!classic.Contains(lowered)) + continue; + result ??= [.. members]; + result[i] = lowered; + } + return result ?? members; + } + + /// An extension block as the declaring type of its members: sealed, so nothing about them is for inheritors. + private static ApiType Block(ApiType container) => + container with { Name = "extension", Kind = ApiTypeKind.Class, Modifiers = ApiTypeModifiers.Sealed, CanBeExtendedOutside = false }; + private static string Key(ApiMember member, ApiType declaring) => member switch { + ApiExtension e => + "extension`" + e.TypeParameters.Count + "(" + Parameter(e.Receiver) + ")" + string.Concat(e.Members.Select(m => "::" + Key(m, declaring))), ApiField f => "field " + f.Name, ApiProperty p => "property " + p.Name + "[" + string.Join(",", p.Parameters.Select(Parameter)) + "]", ApiEvent e => "event " + e.Name, @@ -1173,6 +1277,10 @@ string Types(IEnumerable parameters) => m.Parameters ) + ")", ApiProperty { Parameters.Count: > 0 } p => "this[" + Types(p.Parameters) + "]", + ApiExtension e => + "extension" + (e.TypeParameters.Count == 0 ? "" : "<" + string.Join(", ", e.TypeParameters.Select(p => p.Name)) + ">") + "(" + Types([ + e.Receiver + ]) + ")", _ => member.Name, }; } diff --git a/src/Nullean.PublicApi/ApiParser.cs b/src/Nullean.PublicApi/ApiParser.cs index 59cc6a2..9d97646 100644 --- a/src/Nullean.PublicApi/ApiParser.cs +++ b/src/Nullean.PublicApi/ApiParser.cs @@ -613,11 +613,41 @@ private ApiMember Member(ref int i, int depth, ApiType type, Declared declared, var cursor = new Cursor(line.Text, 0, line.Number, depth * 2); var member = type.Kind == ApiTypeKind.Enum ? EnumValue(cursor, attributes, enumType!) - : Declaration(cursor, type, scope, attributes, returnAttributes, indexerName); + : cursor.PeekWord() == "extension" && cursor.PeekAfterWord() is '(' or '<' + ? Extension(ref i, depth, cursor, line, type, declared, attributes) + : Declaration(cursor, type, scope, attributes, returnAttributes, indexerName); _locations.Add((member, new ApiLocation(_file, line.Number))); return member; } + /// + /// A C# 14 extension block, extension<T>(List<T> list) where T : struct, and the members indented + /// under it. Its type parameters are in scope for the receiver and the members, as a type's are. + /// + private ApiExtension Extension(ref int i, int depth, Cursor cursor, Line line, ApiType type, Declared declared, List attributes) + { + cursor.Word(); + var typeParameters = TypeParameters(cursor, false); + var block = new Declared(declared.Namespace, "extension", declared, ApiTypeKind.Class, typeParameters.Select(p => p.Name).ToList()); + block.StructParameters.UnionWith(StructParameters(line.Text)); + var scope = new Scope(this, block, []); + var receiver = Parameters(cursor, scope, '(', ')', out _); + if (receiver.Count != 1) + throw cursor.Error("an extension block has one receiver"); + Constraints(cursor, scope, typeParameters); + cursor.End(); + + var shell = type with { Name = "extension", Kind = ApiTypeKind.Class, Modifiers = ApiTypeModifiers.Sealed, CanBeExtendedOutside = false }; + var members = new List(); + while (i < _lines.Count && _lines[i].Depth > depth) + { + if (_lines[i].Depth != depth + 1) + throw new ApiParseException("indented too far", _lines[i].Number, _lines[i].Depth * 2 + 1); + members.Add(Member(ref i, depth + 1, shell, block, null)); + } + return new ApiExtension("extension", ApiAccessibility.Public, attributes, typeParameters, receiver[0], members); + } + private static ApiField EnumValue(Cursor cursor, List attributes, ApiTypeReference type) { var name = cursor.Identifier(); @@ -1020,7 +1050,8 @@ private static List Parameters(Cursor cursor, Scope scope, char op } } var type = scope.Type(cursor); - var name = cursor.MemberName(); + // a receiver that only static members use has no name: extension(string) + var name = cursor.Peek() == close ? "" : cursor.MemberName(); string? defaultValue = null; if (cursor.TryChar('=')) defaultValue = cursor.RawUntil(',', close); diff --git a/src/Nullean.PublicApi/ApiReader.cs b/src/Nullean.PublicApi/ApiReader.cs index da0f385..b8a85ac 100644 --- a/src/Nullean.PublicApi/ApiReader.cs +++ b/src/Nullean.PublicApi/ApiReader.cs @@ -305,7 +305,7 @@ private ApiAssembly ReadAssembly() .Select(m => ReadMethod(m, shell, allTypeParameters, typeContext, forceVisible: true)) .OfType() .ToList(), - _ => Members(type, shell, allTypeParameters, typeContext), + _ => WithExtensions(type, shell, Members(type, shell, allTypeParameters, typeContext), typeContext), }; var nested = type.GetNestedTypes().Select(h => ReadType(h, shell)).OfType().OrderBy(t => t.Name, StringComparer.Ordinal).ToList(); return shell with { Members = members, NestedTypes = nested }; @@ -510,12 +510,185 @@ private List Members(TypeDefinition type, ApiType declaring, Immutabl ApiEvent => 3, ApiMethod { Kind: ApiMethodKind.Method } => 4, ApiMethod { Kind: ApiMethodKind.Operator } => 5, + ApiExtension => 7, _ => 6, }; + private static List Ordered(IEnumerable members) => + members.OrderBy(Order).ThenBy(m => m.Name, StringComparer.Ordinal).ThenBy(ParameterKey, StringComparer.Ordinal).ToList(); + + /// + /// A static class's C# 14 extension blocks, which the compiler writes as nested grouping types (<G>$…): + /// their members as declared, under a marker type (<M>$…) that holds the receiver, and as static methods + /// in the class that implement them. The blocks take the place of those methods. + /// + private List WithExtensions(TypeDefinition container, ApiType shell, List members, byte? typeContext) + { + var blocks = new List(); + foreach (var groupingHandle in container.GetNestedTypes()) + { + var grouping = _reader.GetTypeDefinition(groupingHandle); + if ( + !_reader.GetString(grouping.Name).StartsWith( + "$", + StringComparison.Ordinal + ) || (grouping.Attributes & TypeAttributes.VisibilityMask) != TypeAttributes.NestedPublic + ) + continue; + blocks.AddRange(Extensions(grouping, shell, typeContext)); + } + if (blocks.Count == 0) + return members; + + var implementations = new HashSet(blocks.SelectMany(Implementations), StringComparer.Ordinal); + var kept = members.Where( + m => m is not ApiMethod { Modifiers: var modifiers } method || (modifiers & ApiMemberModifiers.Static) == 0 || + !implementations.Contains(Signature(method.Name, method.Parameters.Select(p => p.Type))) + ); + return Ordered(kept.Concat(blocks)); + } + + private IEnumerable Extensions(TypeDefinition grouping, ApiType container, byte? typeContext) + { + var groupingContext = NullableContext(grouping.GetCustomAttributes()) ?? typeContext; + var shell = container with + { + Name = _reader.GetString(grouping.Name), + Kind = ApiTypeKind.Class, + Modifiers = ApiTypeModifiers.Sealed, + CanBeExtendedOutside = false, + }; + + // each marker is one block: its $ method holds the receiver, and its type parameters have the names + // the source gave them, where the grouping type's are the compiler's own + var blocks = new Dictionary Names, List TypeParameters, IReadOnlyList Attributes)>( + StringComparer.Ordinal + ); + foreach (var markerHandle in grouping.GetNestedTypes()) + { + var marker = _reader.GetTypeDefinition(markerHandle); + var names = TypeParameterNames(marker.GetGenericParameters()); + var context = new TypeContext(names, []); + foreach (var methodHandle in marker.GetMethods()) + { + var method = _reader.GetMethodDefinition(methodHandle); + if (!_reader.StringComparer.Equals(method.Name, "$")) + continue; + var signature = method.DecodeSignature(_types, context); + var parameters = ReadParameters(method, signature.ParameterTypes, NullableContext(method.GetCustomAttributes()) ?? groupingContext); + // a receiver without a name has no parameter row + var named = false; + foreach (var parameterHandle in method.GetParameters()) + named |= _reader.GetParameter(parameterHandle).SequenceNumber == 1; + if (parameters.Count == 1) + blocks[_reader.GetString(marker.Name)] = + (named ? parameters[0] : parameters[0] with { Name = "" }, names, ReadTypeParameters( + marker.GetGenericParameters(), + context, + groupingContext, + false + ), Attributes(marker.GetCustomAttributes())); + } + } + + var byMarker = blocks.Keys.ToDictionary(k => k, _ => new List(), StringComparer.Ordinal); + var accessors = new HashSet(); + foreach (var propertyHandle in grouping.GetProperties()) + { + var property = _reader.GetPropertyDefinition(propertyHandle); + var propertyAccessors = property.GetAccessors(); + if (!propertyAccessors.Getter.IsNil) + accessors.Add(propertyAccessors.Getter); + if (!propertyAccessors.Setter.IsNil) + accessors.Add(propertyAccessors.Setter); + var accessor = propertyAccessors.Getter.IsNil ? propertyAccessors.Setter : propertyAccessors.Getter; + if ( + accessor.IsNil || Marker(_reader.GetMethodDefinition(accessor).GetCustomAttributes()) is not { } markerName || + !byMarker.TryGetValue(markerName, out var list) + ) + continue; + var context = new TypeContext(blocks[markerName].Names, []); + if (ReadProperty(property, propertyAccessors, shell, context, groupingContext, false) is { } read) + list.Add(read); + } + foreach (var methodHandle in grouping.GetMethods()) + { + if (accessors.Contains(methodHandle)) + continue; + var method = _reader.GetMethodDefinition(methodHandle); + if (Marker(method.GetCustomAttributes()) is not { } markerName || !byMarker.TryGetValue(markerName, out var list)) + continue; + if (ReadMethod(method, shell, blocks[markerName].Names, groupingContext, forceVisible: false) is { } read) + list.Add(read); + } + + // netstandard2.0's KeyValuePair has no Deconstruct + foreach (var pair in blocks) + if (byMarker[pair.Key].Count > 0) + yield return new ApiExtension( + "extension", + ApiAccessibility.Public, + pair.Value.Attributes, + pair.Value.TypeParameters, + pair.Value.Receiver, + Ordered(byMarker[pair.Key]) + ); + } + + /// The marker an extension member belongs to, named by its ExtensionMarkerAttribute. + private string? Marker(IEnumerable attributes) + { + foreach (var handle in attributes) + { + var attribute = _reader.GetCustomAttribute(handle); + if ( + AttributeTypeName(attribute) == CompilerServices + "ExtensionMarkerAttribute" && + TryDecode(attribute) is { FixedArguments: [{ Value: string name }] } + ) + return name; + } + return null; + } + + /// + /// The static methods the compiler implements a block's members with: a method by its name, an accessor by + /// get_ or set_ and the property's name; an instance member takes the receiver first. + /// + private static IEnumerable Implementations(ApiExtension block) + { + foreach (var member in block.Members) + { + var isStatic = member switch + { + ApiMethod m => (m.Modifiers & ApiMemberModifiers.Static) != 0, + ApiProperty p => (p.Modifiers & ApiMemberModifiers.Static) != 0, + _ => true, + }; + IEnumerable receiver = isStatic ? [] : [block.Receiver.Type]; + switch (member) + { + case ApiMethod method: + yield return Signature(method.Name, receiver.Concat(method.Parameters.Select(p => p.Type))); + break; + case ApiProperty property: + var index = property.Parameters.Select(p => p.Type).ToList(); + if (property.Getter is not null) + yield return Signature("get_" + property.Name, receiver.Concat(index)); + if (property.Setter is not null) + yield return Signature("set_" + property.Name, receiver.Concat(index).Append(property.Type)); + break; + } + } + } + + /// A method's name and parameter types, with type parameters by name whether a type or a method declares them. + private static string Signature(string name, IEnumerable parameters) => + name + "(" + string.Join(",", parameters.Select(p => TypeKey(p).Replace("!!", "!"))) + ")"; + /// Tells overloads apart in the order: their parameter types. private static string ParameterKey(ApiMember member) => member switch { + ApiExtension extension => TypeKey(extension.Receiver.Type) + ";" + extension.TypeParameters.Count, ApiMethod method => string.Join(",", method.Parameters.Select(p => p.RefKind + " " + TypeKey(p.Type))) + ";" + method.TypeParameters.Count, ApiProperty property => string.Join(",", property.Parameters.Select(p => TypeKey(p.Type))), _ => string.Empty, @@ -757,7 +930,10 @@ private IReadOnlyList ReadParameters(MethodDefinition method, Immu for (var i = 0; i < types.Length; i++) { var row = rows[i]; - IEnumerable attributes = row is { } withAttributes ? withAttributes.GetCustomAttributes() : []; + // typed as the interface: a bare [] would take the struct collection's type, and a default one has no reader + IEnumerable attributes = row is { } withAttributes + ? withAttributes.GetCustomAttributes() + : Array.Empty(); var rawType = types[i]; var refKind = ApiRefKind.None; var offset = 0; diff --git a/src/Nullean.PublicApi/ApiWriter.cs b/src/Nullean.PublicApi/ApiWriter.cs index 43e715e..00cb988 100644 --- a/src/Nullean.PublicApi/ApiWriter.cs +++ b/src/Nullean.PublicApi/ApiWriter.cs @@ -187,37 +187,47 @@ private void Collect(ApiType type, HashSet<(string, string)> used) foreach (var parameter in type.TypeParameters) Collect(parameter, used); foreach (var member in type.Members) - { - foreach (var attribute in member.Attributes) - Collect(attribute.Type, used); - switch (member) - { - case ApiField field: - Collect(field.Type, used); - break; - case ApiProperty property: - Collect(property.Type, used); - foreach (var parameter in property.Parameters) - Collect(parameter, used); - break; - case ApiEvent @event: - Collect(@event.Type, used); - break; - case ApiMethod method: - Collect(method.ReturnType, used); - foreach (var attribute in method.ReturnAttributes) - Collect(attribute.Type, used); - foreach (var parameter in method.TypeParameters) - Collect(parameter, used); - foreach (var parameter in method.Parameters) - Collect(parameter, used); - break; - } - } + Collect(member, used); foreach (var nested in type.NestedTypes) Collect(nested, used); } + private void Collect(ApiMember member, HashSet<(string, string)> used) + { + foreach (var attribute in member.Attributes) + Collect(attribute.Type, used); + switch (member) + { + case ApiExtension extension: + Collect(extension.Receiver, used); + foreach (var parameter in extension.TypeParameters) + Collect(parameter, used); + foreach (var inner in extension.Members) + Collect(inner, used); + break; + case ApiField field: + Collect(field.Type, used); + break; + case ApiProperty property: + Collect(property.Type, used); + foreach (var parameter in property.Parameters) + Collect(parameter, used); + break; + case ApiEvent @event: + Collect(@event.Type, used); + break; + case ApiMethod method: + Collect(method.ReturnType, used); + foreach (var attribute in method.ReturnAttributes) + Collect(attribute.Type, used); + foreach (var parameter in method.TypeParameters) + Collect(parameter, used); + foreach (var parameter in method.Parameters) + Collect(parameter, used); + break; + } + } + private void Collect(ApiTypeParameter parameter, HashSet<(string, string)> used) { foreach (var constraint in parameter.ConstraintTypes) @@ -354,6 +364,11 @@ ApiTypeKind.Class when (modifiers & ApiTypeModifiers.Record) != 0 => "record", public void Member(ApiMember member, ApiType type, int depth) { + if (member is ApiExtension extension) + { + Extension(extension, type, depth); + return; + } if (member is ApiMethod { ReturnAttributes: var returnAttributes }) Attributes(returnAttributes, depth, "return"); // metadata names an indexer by the attribute that renamed it in source @@ -468,6 +483,24 @@ private void MemberStart(ApiAccessibility accessibility, ApiMemberModifiers modi builder.Append("readonly "); } + /// + /// A C# 14 extension block: extension<T>(List<T> list) where T : struct, its members indented + /// under it as a type's are. + /// + private void Extension(ApiExtension extension, ApiType type, int depth) + { + Attributes(extension.Attributes, depth, null); + Line(depth); + builder.Append("extension"); + TypeParameters(extension.TypeParameters); + Parameters([extension.Receiver], false); + Constraints(extension.TypeParameters); + builder.Append('\n'); + var block = type with { Name = "extension", Kind = ApiTypeKind.Class, Modifiers = ApiTypeModifiers.Sealed, CanBeExtendedOutside = false }; + foreach (var member in extension.Members) + Member(member, block, depth + 1); + } + /// An interface member with a default implementation: virtual, or sealed, which is neither virtual nor abstract. private static bool HasBody(ApiMemberModifiers modifiers) => (modifiers & ApiMemberModifiers.Virtual) != 0 || (modifiers & (ApiMemberModifiers.Static | ApiMemberModifiers.Abstract)) == 0; @@ -514,7 +547,9 @@ private void ParameterList(IReadOnlyList parameters, bool isExtens ApiRefKind.RefReadOnly => "ref readonly ", _ => string.Empty, }); - builder.Append(Reference(parameter.Type)).Append(' ').Append(parameter.Name); + builder.Append(Reference(parameter.Type)); + if (parameter.Name.Length > 0) + builder.Append(' ').Append(parameter.Name); if (parameter.DefaultValue is not null) builder.Append(" = ").Append(parameter.DefaultValue); } @@ -760,11 +795,12 @@ public string Declaration() return ApiWriter.LastLine(builder); } - /// A member's declaration line: void Draw(Canvas canvas). + /// A member's declaration line: void Draw(Canvas canvas); an extension block's, extension(string self). public string Declaration(ApiMember member) { var builder = new StringBuilder(); - new ApiWriter.Writer(builder, _names).Member(member, _type, 0); + // a block's own line, not the last of its members + new ApiWriter.Writer(builder, _names).Member(member is ApiExtension extension ? extension with { Members = [] } : member, _type, 0); return ApiWriter.LastLine(builder); } diff --git a/src/Nullean.PublicApi/Model.cs b/src/Nullean.PublicApi/Model.cs index b9e38f1..c3dc85e 100644 --- a/src/Nullean.PublicApi/Model.cs +++ b/src/Nullean.PublicApi/Model.cs @@ -353,3 +353,23 @@ public sealed record ApiEvent( ApiMemberModifiers Modifiers, ApiTypeReference Type ) : ApiMember(Name, Accessibility, Attributes); + +/// +/// A C# 14 extension block in a static class: members that extend a receiver, as extension(string self) declares +/// them. Its is always extension; the receiver tells blocks apart. The static methods the +/// compiler implements the members with are part of it, not of the class. +/// +/// Always extension. +/// Public: an extension block has no accessibility of its own. +/// The attributes on the block. +/// The block's type parameters, which its receiver and members use: extension<T>(List<T> list). +/// What the block extends: the receiver parameter, its type, name and ref kind. +/// The members: methods and properties, instance ones on the receiver and static ones on its type. +public sealed record ApiExtension( + string Name, + ApiAccessibility Accessibility, + IReadOnlyList Attributes, + IReadOnlyList TypeParameters, + ApiParameter Receiver, + IReadOnlyList Members +) : ApiMember(Name, Accessibility, Attributes); diff --git a/src/Nullean.PublicApi/README.md b/src/Nullean.PublicApi/README.md index c519ef1..1afb7db 100644 --- a/src/Nullean.PublicApi/README.md +++ b/src/Nullean.PublicApi/README.md @@ -40,4 +40,17 @@ as a nullable value type and `string?` as an annotated reference, and `using int names, so a reader can tell a base class from an interface without the other assembly. `ApiParser` reads the file back into the same model the reader produced from the assembly. +C# 14 extension blocks are written as the source declares them, with their members under the receiver; the static +methods the compiler writes for those members are left out, since they are the block's and not the class's: + +``` + static class TextExtensions + static int Old(this string s) + extension(List list) where T : struct + T? FirstOrNone() + extension(string self) + bool IsBlank { get; } + static string Make(int n) +``` + Targets netstandard2.0 and net10.0. It is the reader behind [assembly-differ](https://github.com/nullean/assembly-differ). diff --git a/src/assembly-differ/ApiFiles/ApiJson.cs b/src/assembly-differ/ApiFiles/ApiJson.cs index eb853ea..0cd5509 100644 --- a/src/assembly-differ/ApiFiles/ApiJson.cs +++ b/src/assembly-differ/ApiFiles/ApiJson.cs @@ -109,6 +109,15 @@ private static void Member(Utf8JsonWriter json, ApiMember member, ApiType type) Parameters(json, method.Parameters, type); json.WriteBoolean("extension", method.IsExtension); break; + case ApiExtension extension: + json.WriteString("kind", "extension"); + TypeParameters(json, extension.TypeParameters, type); + Parameters(json, [extension.Receiver], type); + json.WriteStartArray("members"); + foreach (var inner in extension.Members) + Member(json, inner, type); + json.WriteEndArray(); + break; } Attributes(json, member.Attributes, type); json.WriteEndObject(); diff --git a/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-block-constraint-added.e845a167.verified.txt b/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-block-constraint-added.e845a167.verified.txt new file mode 100644 index 0000000..1c8ad5f --- /dev/null +++ b/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-block-constraint-added.e845a167.verified.txt @@ -0,0 +1,2 @@ +E › extension(T[]) › First() › type parameter T constraint changed: - → struct [Major, breaks Callers Binary, Source] +suggested: Major diff --git a/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-member-added.dbed5446.verified.txt b/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-member-added.dbed5446.verified.txt new file mode 100644 index 0000000..1a8bd14 --- /dev/null +++ b/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-member-added.dbed5446.verified.txt @@ -0,0 +1,2 @@ +E › extension(string) › B added: - → bool B { get; } [Minor] +suggested: Minor diff --git a/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-member-moved-to-another-block.6e6a29c5.verified.txt b/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-member-moved-to-another-block.6e6a29c5.verified.txt new file mode 100644 index 0000000..27e3dc2 --- /dev/null +++ b/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-member-moved-to-another-block.6e6a29c5.verified.txt @@ -0,0 +1,3 @@ +E › extension(string) › A() › receiver name changed: s → t [Major, breaks Callers Source] (callers that pass it by name) +E › extension(string) › A() › receiver changed: string → string? [Minor] (nullability) +suggested: Major diff --git a/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-member-removed.236a2486.verified.txt b/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-member-removed.236a2486.verified.txt new file mode 100644 index 0000000..e4c43c3 --- /dev/null +++ b/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-member-removed.236a2486.verified.txt @@ -0,0 +1,2 @@ +E › extension(string) › B() removed: static int B() → - [Major, breaks Callers Binary, Source] +suggested: Major diff --git a/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-method-became-an-extension-block-member.4f4d33b4.verified.txt b/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-method-became-an-extension-block-member.4f4d33b4.verified.txt new file mode 100644 index 0000000..b0501f2 --- /dev/null +++ b/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-method-became-an-extension-block-member.4f4d33b4.verified.txt @@ -0,0 +1 @@ +suggested: None diff --git a/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-receiver-renamed.8f134156.verified.txt b/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-receiver-renamed.8f134156.verified.txt new file mode 100644 index 0000000..e5b5e4c --- /dev/null +++ b/tests/Nullean.PublicApi.Tests/.snapshots/ComparisonTests/Classifies.extension-receiver-renamed.8f134156.verified.txt @@ -0,0 +1,2 @@ +E › extension(string) › A() › receiver name changed: s → text [Major, breaks Callers Source] (callers that pass it by name) +suggested: Major diff --git a/tests/Nullean.PublicApi.Tests/.snapshots/WriterTests/Writes.extensions.dae7f828.verified.txt b/tests/Nullean.PublicApi.Tests/.snapshots/WriterTests/Writes.extensions.dae7f828.verified.txt new file mode 100644 index 0000000..02b2aec --- /dev/null +++ b/tests/Nullean.PublicApi.Tests/.snapshots/WriterTests/Writes.extensions.dae7f828.verified.txt @@ -0,0 +1,20 @@ +assembly Lib +using System.Collections.Generic: List<> + +namespace Lib + static class TextExtensions + static int Old(this string s) + extension(List list) where T : struct + int Size { get; set; } + T? FirstOrNone() + extension(ref int value) + void Bump() + extension(string self) + static string Empty2 { get; } + bool IsBlank { get; } + static string Make(int n) + int WordCount() + extension(string? maybe) + bool IsMissing { get; } + extension(string) + static string Twice(string value) diff --git a/tests/Nullean.PublicApi.Tests/ComparisonTests.cs b/tests/Nullean.PublicApi.Tests/ComparisonTests.cs index ec54b6d..1b35088 100644 --- a/tests/Nullean.PublicApi.Tests/ComparisonTests.cs +++ b/tests/Nullean.PublicApi.Tests/ComparisonTests.cs @@ -44,6 +44,12 @@ public class ComparisonTests yield return ("constraint added", "public class A { public void M() { } }", "public class A { public void M() where T : class { } }"); yield return ("made static", "public class A { public void M() { } }", "public class A { public static void M() { } }"); yield return ("accessibility reduced", "public class A { public void M() { } }", "public class A { protected void M() { } }"); + yield return ("extension member added", "public static class E { extension(string s) { public int A() => 0; } }", "public static class E { extension(string s) { public int A() => 0; public bool B => true; } }"); + yield return ("extension member removed", "public static class E { extension(string s) { public int A() => 0; public static int B() => 0; } }", "public static class E { extension(string s) { public int A() => 0; } }"); + yield return ("extension member moved to another block", "public static class E { extension(string s) { public int A() => 0; } extension(string? t) { public int B() => 0; } }", "public static class E { extension(string? t) { public int A() => 0; public int B() => 0; } }"); + yield return ("extension receiver renamed", "public static class E { extension(string s) { public int A() => 0; } }", "public static class E { extension(string text) { public int A() => 0; } }"); + yield return ("extension block constraint added", "public static class E { extension(T[] items) { public T First() => items[0]; } }", "public static class E { extension(T[] items) where T : struct { public T First() => items[0]; } }"); + yield return ("extension method became an extension block member", "public static class E { public static int A(this string s) => 0; }", "public static class E { extension(string s) { public int A() => 0; } }"); yield return ("nothing changed", "public class A { public void M() { } }", "public class A { public void M() { } }"); } diff --git a/tests/Nullean.PublicApi.Tests/GeneratedLibrary.cs b/tests/Nullean.PublicApi.Tests/GeneratedLibrary.cs index d4279de..7ad7881 100644 --- a/tests/Nullean.PublicApi.Tests/GeneratedLibrary.cs +++ b/tests/Nullean.PublicApi.Tests/GeneratedLibrary.cs @@ -483,6 +483,48 @@ private void Extensions() target = "string"; _source.AppendLine($" public static {ReturnType(new Context([], [], Kind.StaticClass))} {Name("X")}(this {target} self) => throw null!;"); } + // C# 14 extension blocks: instance and static members over a receiver, generic blocks, and a receiver without a name + var blocks = Next(3); + for (var i = 0; i < blocks; i++) + { + var context = new Context([], [], Kind.StaticClass); + var generic = Chance(3); + var named = !Chance(4); + var receiver = Next(5) switch + { + _ when generic => Chance(2) ? "System.Collections.Generic.List" : "T[]", + 1 when named => "ref int", + 2 => "string?", + _ => Type(context, valueOnly: false), + }; + if (receiver.StartsWith("dynamic", StringComparison.Ordinal)) + receiver = "string"; + var constraint = generic + ? Next(3) switch + { + 1 => " where T : struct", + 2 => " where T : class", + _ => "" + } + : ""; + _source.AppendLine($" extension{(generic ? "" : "")}({receiver}{(named ? " self" : "")}){constraint}").AppendLine(" {"); + var members = 1 + Next(3); + for (var m = 0; m < members; m++) + { + var isStatic = !named || Chance(3); + var modifier = isStatic ? "static " : ""; + var type = generic && Chance(2) ? "T" : ReturnType(context); + if (type == "void" && Chance(2)) + _source.AppendLine($" public {modifier}void {Name("Do")}() {{ }}"); + else if (type == "void") + _source.AppendLine($" public {modifier}int {Name("Count")}(int p0) => 0;"); + else if (Chance(2)) + _source.AppendLine($" public {modifier}{type} {Name("Value")} {{ get => throw null!;{(Chance(3) ? " set { }" : "")} }}"); + else + _source.AppendLine($" public {modifier}{type} {Name("Make")}({Parameters(context, allowDefaults: true)}) => throw null!;"); + } + _source.AppendLine(" }"); + } _source.AppendLine(" }"); } diff --git a/tests/Nullean.PublicApi.Tests/ModelDump.cs b/tests/Nullean.PublicApi.Tests/ModelDump.cs index c0bfde0..597e48a 100644 --- a/tests/Nullean.PublicApi.Tests/ModelDump.cs +++ b/tests/Nullean.PublicApi.Tests/ModelDump.cs @@ -38,6 +38,8 @@ private static void Type(ApiType type, string path, List lines) ApiEvent e => $"event {e.Name} {e.Accessibility} {e.Modifiers} {Ref(e.Type)} {Attributes(e.Attributes)}", ApiMethod m => $"method {m.Name} {m.Accessibility} {m.Kind} {m.Modifiers} {m.ReturnRefKind} {Ref(m.ReturnType)} return={Attributes(m.ReturnAttributes)} tps={TypeParameters(m.TypeParameters)} {Parameters(m.Parameters)} extension={m.IsExtension} {Attributes(m.Attributes)}", + ApiExtension x => + $"extension tps={TypeParameters(x.TypeParameters)} receiver={Parameters([x.Receiver])} {Attributes(x.Attributes)} {{ {string.Join(" ; ", x.Members.Select(Member))} }}", _ => member.ToString()!, }; diff --git a/tests/Nullean.PublicApi.Tests/RoslynOracleTests.cs b/tests/Nullean.PublicApi.Tests/RoslynOracleTests.cs index 1e44d4f..18616d8 100644 --- a/tests/Nullean.PublicApi.Tests/RoslynOracleTests.cs +++ b/tests/Nullean.PublicApi.Tests/RoslynOracleTests.cs @@ -86,58 +86,72 @@ private static void Ours(ApiType type, string? declaring, Dictionary result) + { + switch (member) { - switch (member) - { - case ApiField field: - { - var facts = new List { Access(field.Accessibility) }; - if (field.Constant is not null) - facts.Add("const"); - else - { - if (field.IsStatic) - facts.Add("static"); - if (field.IsReadOnly) - facts.Add("readonly"); - if (field.IsVolatile) - facts.Add("volatile"); - } - if (field.IsRequired) - facts.Add("required"); - result[name + "::" + field.Name] = string.Join(" ", facts) + " " + Render(field.Type); - break; - } - case ApiProperty property: + // a block is its receiver; its members sit under it + case ApiExtension extension: + { + var block = name + "::extension`" + extension.TypeParameters.Count + Key([extension.Receiver.Type], [extension.Receiver.RefKind]); + result[block] = RefKind(extension.Receiver.RefKind) + Render(extension.Receiver.Type) + " " + extension.Receiver.Name + TypeParameters( + extension.TypeParameters + ); + foreach (var inner in extension.Members) + Ours(inner, block, result); + break; + } + case ApiField field: + { + var facts = new List { Access(field.Accessibility) }; + if (field.Constant is not null) + facts.Add("const"); + else { - var facts = new List { Access(property.Accessibility), Modifiers(property.Modifiers) }; - if (property.IsRequired) - facts.Add("required"); - if (property.Getter is { } get) - facts.Add("get:" + Access(get.Accessibility)); - if (property.Setter is { } set) - facts.Add((set.IsInit ? "init:" : "set:") + Access(set.Accessibility)); - result[name + "::" + property.Name + Key(property.Parameters.Select(p => p.Type))] = string.Join(" ", facts) + " " + RefKind( - property.RefKind - ) + Render(property.Type) + Parameters(property.Parameters); - break; + if (field.IsStatic) + facts.Add("static"); + if (field.IsReadOnly) + facts.Add("readonly"); + if (field.IsVolatile) + facts.Add("volatile"); } - case ApiEvent @event: - result[name + "::" + @event.Name] = Access(@event.Accessibility) + " " + Modifiers(@event.Modifiers) + " " + Render(@event.Type); + if (field.IsRequired) + facts.Add("required"); + result[name + "::" + field.Name] = string.Join(" ", facts) + " " + Render(field.Type); break; - case ApiMethod method: - result[name + "::" + method.Name + "`" + method.TypeParameters.Count + Key( - method.Parameters.Select(p => 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); + } + case ApiProperty property: + { + var facts = new List { Access(property.Accessibility), Modifiers(property.Modifiers) }; + if (property.IsRequired) + facts.Add("required"); + if (property.Getter is { } get) + facts.Add("get:" + Access(get.Accessibility)); + if (property.Setter is { } set) + facts.Add((set.IsInit ? "init:" : "set:") + Access(set.Accessibility)); + result[name + "::" + property.Name + Key(property.Parameters.Select(p => p.Type))] = string.Join(" ", facts) + " " + RefKind( + property.RefKind + ) + Render(property.Type) + Parameters(property.Parameters); break; - } + } + case ApiEvent @event: + result[name + "::" + @event.Name] = Access(@event.Accessibility) + " " + Modifiers(@event.Modifiers) + " " + Render(@event.Type); + break; + case ApiMethod method: + result[name + "::" + method.Name + "`" + method.TypeParameters.Count + Key( + method.Parameters.Select(p => 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); + break; } - foreach (var nested in type.NestedTypes) - Ours(nested, name, result); } private static string Parameters(IEnumerable parameters) => @@ -325,74 +339,99 @@ private static void Theirs(INamedTypeSymbol type, INamedTypeSymbol? declaring, D if (type.TypeKind is TypeKind.Enum or TypeKind.Delegate) return; var extendable = Extendable(type); + var blocks = type.GetTypeMembers().Where(t => t.IsExtension && t.DeclaredAccessibility == Accessibility.Public).ToList(); + // the static methods the compiler writes for a block's members are the block's, not the container's + var implementations = new HashSet( + blocks + .SelectMany(b => b.GetMembers()) + .SelectMany(m => m is IPropertySymbol p ? new ISymbol?[] { p.GetMethod, p.SetMethod } : [m]) + .OfType() + .Select(m => m.AssociatedExtensionImplementation?.OriginalDefinition) + .Where(m => m is not null)!, + SymbolEqualityComparer.Default + ); foreach (var member in type.GetMembers()) + if (!implementations.Contains(member)) + Theirs(member, name, extendable, result); + foreach (var block in blocks) { - if (!Visible(member, extendable)) - continue; - switch (member) - { - case IFieldSymbol field: - { - var facts = new List { Access(field.DeclaredAccessibility) }; - if (field.IsConst) - facts.Add("const"); - else - { - if (field.IsStatic) - facts.Add("static"); - if (field.IsReadOnly) - facts.Add("readonly"); - if (field.IsVolatile) - facts.Add("volatile"); - } - if (field.IsRequired) - facts.Add("required"); - result[name + "::" + field.MetadataName] = string.Join(" ", facts) + " " + Render(field.Type, field.NullableAnnotation); - break; - } - case IPropertySymbol property when (property.GetMethod is { } g && Visible(g, extendable)) || - (property.SetMethod is { } s && Visible(s, extendable)): + var receiver = block.ExtensionParameter!; + var blockName = name + "::extension`" + block.TypeParameters.Length + Key([receiver]); + result[blockName] = RefKind(receiver.RefKind) + Render(receiver.Type, receiver.NullableAnnotation) + " " + receiver.Name + TypeParameters( + block.TypeParameters + ); + foreach (var member in block.GetMembers()) + Theirs(member, blockName, false, result); + } + foreach (var nested in type.GetTypeMembers()) + Theirs(nested, type, result); + } + + private static void Theirs(ISymbol member, string name, bool extendable, Dictionary result) + { + if (!Visible(member, extendable)) + return; + switch (member) + { + case IFieldSymbol field: + { + var facts = new List { Access(field.DeclaredAccessibility) }; + if (field.IsConst) + facts.Add("const"); + else { - var get = property.GetMethod is { } getter && Visible(getter, extendable) ? getter : null; - var set = property.SetMethod is { } setter && Visible(setter, extendable) ? setter : null; - var most = new[] { get, set }.Where(m => m is not null).Select(m => m!.DeclaredAccessibility).Max(); - var facts = new List { Access(most), Modifiers(property) }; - if (property.IsRequired) - facts.Add("required"); - if (get is not null) - facts.Add("get:" + Access(get.DeclaredAccessibility)); - if (set is not null) - facts.Add((set.IsInitOnly ? "init:" : "set:") + Access(set.DeclaredAccessibility)); - result[name + "::" + property.MetadataName + Key(property.Parameters)] = string.Join(" ", facts) + " " + - (property.RefKind == Microsoft.CodeAnalysis.RefKind.RefReadOnly ? "ref readonly " : RefKind(property.RefKind)) + Render( - property.Type, - property.NullableAnnotation - ) + Parameters(property.Parameters); - break; + if (field.IsStatic) + facts.Add("static"); + if (field.IsReadOnly) + facts.Add("readonly"); + if (field.IsVolatile) + facts.Add("volatile"); } - case IEventSymbol @event: - result[name + "::" + @event.MetadataName] = Access(@event.DeclaredAccessibility) + " " + Modifiers(@event) + " " + Render( - @event.Type, - @event.NullableAnnotation - ); + if (field.IsRequired) + facts.Add("required"); + result[name + "::" + field.MetadataName] = string.Join(" ", facts) + " " + Render(field.Type, field.NullableAnnotation); break; - // a struct's parameterless constructor is not in metadata unless the struct declares one - case IMethodSymbol { IsImplicitlyDeclared: true, MethodKind: MethodKind.Constructor, ContainingType.IsValueType: true }: - break; - case IMethodSymbol method when method.MethodKind is not (MethodKind.PropertyGet or MethodKind.PropertySet or MethodKind.EventAdd or MethodKind.EventRemove or MethodKind.StaticConstructor): - 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) + " " + - (method.RefKind == Microsoft.CodeAnalysis.RefKind.RefReadOnly ? "ref readonly " : RefKind(method.RefKind)) + Render( - method.ReturnType, - method.ReturnNullableAnnotation - ) + Parameters(method.Parameters); + } + case IPropertySymbol property when (property.GetMethod is { } g && Visible(g, extendable)) || + (property.SetMethod is { } s && Visible(s, extendable)): + { + var get = property.GetMethod is { } getter && Visible(getter, extendable) ? getter : null; + var set = property.SetMethod is { } setter && Visible(setter, extendable) ? setter : null; + var most = new[] { get, set }.Where(m => m is not null).Select(m => m!.DeclaredAccessibility).Max(); + var facts = new List { Access(most), Modifiers(property) }; + if (property.IsRequired) + facts.Add("required"); + if (get is not null) + facts.Add("get:" + Access(get.DeclaredAccessibility)); + if (set is not null) + facts.Add((set.IsInitOnly ? "init:" : "set:") + Access(set.DeclaredAccessibility)); + result[name + "::" + property.MetadataName + Key(property.Parameters)] = string.Join(" ", facts) + " " + + (property.RefKind == Microsoft.CodeAnalysis.RefKind.RefReadOnly ? "ref readonly " : RefKind(property.RefKind)) + Render( + property.Type, + property.NullableAnnotation + ) + Parameters(property.Parameters); break; - } + } + case IEventSymbol @event: + result[name + "::" + @event.MetadataName] = Access(@event.DeclaredAccessibility) + " " + Modifiers(@event) + " " + Render( + @event.Type, + @event.NullableAnnotation + ); + break; + // a struct's parameterless constructor is not in metadata unless the struct declares one + case IMethodSymbol { IsImplicitlyDeclared: true, MethodKind: MethodKind.Constructor, ContainingType.IsValueType: true }: + break; + case IMethodSymbol method when method.MethodKind is not (MethodKind.PropertyGet or MethodKind.PropertySet or MethodKind.EventAdd or MethodKind.EventRemove or MethodKind.StaticConstructor): + 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) + " " + + (method.RefKind == Microsoft.CodeAnalysis.RefKind.RefReadOnly ? "ref readonly " : RefKind(method.RefKind)) + Render( + method.ReturnType, + method.ReturnNullableAnnotation + ) + Parameters(method.Parameters); + break; } - foreach (var nested in type.GetTypeMembers()) - Theirs(nested, type, result); } private static bool PublicAllTheWay(INamedTypeSymbol type) => diff --git a/tests/Nullean.PublicApi.Tests/WriterTests.cs b/tests/Nullean.PublicApi.Tests/WriterTests.cs index 1be8ebb..92cce8b 100644 --- a/tests/Nullean.PublicApi.Tests/WriterTests.cs +++ b/tests/Nullean.PublicApi.Tests/WriterTests.cs @@ -117,6 +117,38 @@ public void Open(FileShare share = FileShare.Read | FileShare.Delete, StringComp [EditorBrowsable(EditorBrowsableState.Never)] [Sized(Size.Large)] public void Hidden() { } } """); + yield return ("extensions", """ + using System.Collections.Generic; + namespace Lib; + public static class TextExtensions + { + public static int Old(this string s) => 0; + extension(string self) + { + public bool IsBlank => self.Length == 0; + public int WordCount() => 0; + public static string Make(int n) => ""; + public static string Empty2 => ""; + } + extension(string? maybe) + { + public bool IsMissing => maybe is null; + } + extension(string) + { + public static string Twice(string value) => value + value; + } + extension(List list) where T : struct + { + public T? FirstOrNone() => null; + public int Size { get => 0; set { } } + } + extension(ref int value) + { + public void Bump() => value++; + } + } + """); yield return ("names", """ namespace Lib.A { public class Range { } public class Task { } } namespace Lib.B