diff --git a/src/CsSig/Analyzer/AnalyzerRules.globalconfig b/src/CsSig/Analyzer/AnalyzerRules.globalconfig new file mode 100644 index 0000000..fa6ff4e --- /dev/null +++ b/src/CsSig/Analyzer/AnalyzerRules.globalconfig @@ -0,0 +1,13 @@ +is_global = true + +# RS1035: 'Environment' is banned for use by analyzers. The only use is Environment.NewLine inside +# the third-party StaticCS.IndentingBuilder source package, which is compiled into this assembly and +# cannot be modified here. The .cssig writer normalises line endings through the host, so this does +# not affect determinism of the analyzer's diagnostics. +dotnet_diagnostic.RS1035.severity = none + +# RS1037: suggests adding the "CompilationEnd" custom tag to descriptors reported from the +# compilation action. These diagnostics are intentionally whole-compilation (the public surface can +# only be compared once every symbol is known); the tag is an optimisation hint that is orthogonal +# to this change. +dotnet_diagnostic.RS1037.severity = none diff --git a/src/CsSig/Analyzer/ApiSurface.cs b/src/CsSig/Analyzer/ApiSurface.cs index 5ad5d97..4fec9a6 100644 --- a/src/CsSig/Analyzer/ApiSurface.cs +++ b/src/CsSig/Analyzer/ApiSurface.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Microsoft.CodeAnalysis; @@ -21,26 +22,23 @@ internal static class ApiSurface /// A readable signature format, used only for diagnostic messages. Equivalence is decided by /// the structural , not by this string. /// - private static readonly SymbolDisplayFormat s_displayFormat = - new( - globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.OmittedAsContaining, - typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, - propertyStyle: SymbolDisplayPropertyStyle.ShowReadWriteDescriptor, - genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, - memberOptions: - SymbolDisplayMemberOptions.IncludeParameters | - SymbolDisplayMemberOptions.IncludeContainingType | - SymbolDisplayMemberOptions.IncludeExplicitInterface | - SymbolDisplayMemberOptions.IncludeModifiers | - SymbolDisplayMemberOptions.IncludeConstantValue, - parameterOptions: - SymbolDisplayParameterOptions.IncludeExtensionThis | - SymbolDisplayParameterOptions.IncludeParamsRefOut | - SymbolDisplayParameterOptions.IncludeType | - SymbolDisplayParameterOptions.IncludeName | - SymbolDisplayParameterOptions.IncludeDefaultValue, - miscellaneousOptions: - SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + private static readonly SymbolDisplayFormat s_displayFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.OmittedAsContaining, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + propertyStyle: SymbolDisplayPropertyStyle.ShowReadWriteDescriptor, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + memberOptions: SymbolDisplayMemberOptions.IncludeParameters + | SymbolDisplayMemberOptions.IncludeContainingType + | SymbolDisplayMemberOptions.IncludeExplicitInterface + | SymbolDisplayMemberOptions.IncludeModifiers + | SymbolDisplayMemberOptions.IncludeConstantValue, + parameterOptions: SymbolDisplayParameterOptions.IncludeExtensionThis + | SymbolDisplayParameterOptions.IncludeParamsRefOut + | SymbolDisplayParameterOptions.IncludeType + | SymbolDisplayParameterOptions.IncludeName + | SymbolDisplayParameterOptions.IncludeDefaultValue, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes + ); /// /// Builds a map from the structural signature of every externally visible member declared in @@ -57,26 +55,23 @@ public static Dictionary Collect(IAssemblySymbol assem continue; } - Add(map, type); + Add(type); - foreach (var member in GetApiMembers(type)) - { - Add(map, member); - } + AddApiMembers(type, Add); } return map; - } - private static void Add(Dictionary map, ISymbol symbol) - { - var member = ApiMember.From(symbol); - if (map.ContainsKey(member.Identity)) + void Add(ISymbol symbol) { - return; - } + var member = ApiMember.From(symbol); + if (map.ContainsKey(member.Identity)) + { + return; + } - map.Add(member.Identity, new ApiEntry(member, GetLocation(symbol), GetDisplay(symbol))); + map.Add(member.Identity, new ApiEntry(member, GetLocation(symbol), GetDisplay(symbol))); + } } private static Location GetLocation(ISymbol symbol) @@ -92,7 +87,7 @@ private static Location GetLocation(ISymbol symbol) /// Yields the tracked members of , including implicit /// constructors and implicit record members, mirroring the Public API analyzer. - private static IEnumerable GetApiMembers(INamedTypeSymbol type) + private static void AddApiMembers(INamedTypeSymbol type, Action add) { foreach (var member in type.GetMembers()) { @@ -109,46 +104,69 @@ private static IEnumerable GetApiMembers(INamedTypeSymbol type) if (IsTrackedApi(member)) { - yield return member; + add(member); } } // Implicitly declared (parameterless) constructor. IMethodSymbol? implicitConstructor = null; - if (type is { TypeKind: TypeKind.Class, InstanceConstructors.Length: 1 } or { TypeKind: TypeKind.Struct }) + if ( + type + is { TypeKind: TypeKind.Class, InstanceConstructors.Length: 1 } + or { TypeKind: TypeKind.Struct } + ) { - implicitConstructor = type.InstanceConstructors.FirstOrDefault(static c => c.IsImplicitlyDeclared); + implicitConstructor = type.InstanceConstructors.FirstOrDefault(static c => + c.IsImplicitlyDeclared + ); if (implicitConstructor is not null && IsTrackedApi(implicitConstructor)) { - yield return implicitConstructor; + add(implicitConstructor); } } // Implicitly declared members of a record (Equals, GetHashCode, Deconstruct, copy ctor, // positional property accessors, ...). + // + // A static class hosting extension blocks also carries implicit *implementation* methods + // for each extension member (e.g. `get_Empty`, `TryFirst`). Those are implementation + // details: the members themselves are tracked through the extension marker types, so skip + // the implicit-method pass for such classes to avoid double-counting. + bool hostsExtensions = ExtensionMembers.ContainsExtension(type); foreach (var member in type.GetMembers()) { + if (hostsExtensions) + { + break; + } + if (SymbolEqualityComparer.Default.Equals(member, implicitConstructor)) { continue; } - if (member is IMethodSymbol { IsImplicitlyDeclared: true } method && IsTrackedApi(method)) + if ( + member is IMethodSymbol { IsImplicitlyDeclared: true } method + && IsTrackedApi(method) + ) { // Skip accessors of explicit (non-implicit) properties: those properties are // tracked through their own accessor callbacks already. Keep accessors that // belong to implicit properties (e.g. record `EqualityContract`). - if (method.MethodKind is not (MethodKind.PropertyGet or MethodKind.PropertySet) || - method is { AssociatedSymbol.IsImplicitlyDeclared: true }) + if ( + method.MethodKind is not (MethodKind.PropertyGet or MethodKind.PropertySet) + || method is { AssociatedSymbol.IsImplicitlyDeclared: true } + ) { - yield return method; + add(method); } } } } - private static IEnumerable AllNamedTypes(INamespaceSymbol root) + private static List AllNamedTypes(INamespaceSymbol root) { + var result = new List(); var stack = new Stack(); stack.Push(root); @@ -163,12 +181,14 @@ private static IEnumerable AllNamedTypes(INamespaceSymbol root stack.Push(ns); break; case INamedTypeSymbol type: - yield return type; + result.Add(type); stack.Push(type); break; } } } + + return result; } /// @@ -186,13 +206,22 @@ public static bool IsTrackedApi(ISymbol symbol) } // Enum constructors are not user-visible API. - if (methodSymbol is { MethodKind: MethodKind.Constructor, ContainingType.TypeKind: TypeKind.Enum }) + if ( + methodSymbol is + { MethodKind: MethodKind.Constructor, ContainingType.TypeKind: TypeKind.Enum } + ) { return false; } // For delegates, only the 'Invoke' method carries the signature. - if (methodSymbol is { ContainingType.TypeKind: TypeKind.Delegate, MethodKind: not MethodKind.DelegateInvoke }) + if ( + methodSymbol is + { + ContainingType.TypeKind: TypeKind.Delegate, + MethodKind: not MethodKind.DelegateInvoke + } + ) { return false; } @@ -217,7 +246,10 @@ public static bool IsTrackedApi(ISymbol symbol) case Accessibility.ProtectedOrInternal: // Protected members are only externally visible if the containing type can // actually be extended outside the assembly. - if (current.ContainingType is not { } container || !CanTypeBeExtended(container)) + if ( + current.ContainingType is not { } container + || !CanTypeBeExtended(container) + ) { return false; } @@ -233,13 +265,16 @@ private static bool CanTypeBeExtended(ITypeSymbol type) { // A type can be extended publicly if it isn't sealed and has a constructor that is not // internal, private, or protected-and-internal. - return !type.IsSealed && - type.GetMembers(InstanceConstructorName).Any(static m => m.DeclaredAccessibility switch - { - Accessibility.Internal or Accessibility.ProtectedAndInternal => false, - Accessibility.Private => false, - _ => true, - }); + return !type.IsSealed + && type.GetMembers(InstanceConstructorName) + .Any(static m => + m.DeclaredAccessibility switch + { + Accessibility.Internal or Accessibility.ProtectedAndInternal => false, + Accessibility.Private => false, + _ => true, + } + ); } /// diff --git a/src/CsSig/Analyzer/CsSigAnalyzer.cs b/src/CsSig/Analyzer/CsSigAnalyzer.cs index cfc6d60..b1c8c20 100644 --- a/src/CsSig/Analyzer/CsSigAnalyzer.cs +++ b/src/CsSig/Analyzer/CsSigAnalyzer.cs @@ -26,7 +26,8 @@ public sealed class CsSigAnalyzer : DiagnosticAnalyzer messageFormat: "The signature '{0}' is declared in a .cssig file but is not part of the project's public API (breaks {1} equivalence)", category: "CsSig", defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true); + isEnabledByDefault: true + ); private static readonly DiagnosticDescriptor s_missingFromSignature = new( id: DiagId.MissingFromSignature.ToIdString(), @@ -34,7 +35,8 @@ public sealed class CsSigAnalyzer : DiagnosticAnalyzer messageFormat: "The signature '{0}' is part of the project's public API but is not declared in any .cssig file (breaks {1} equivalence)", category: "CsSig", defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true); + isEnabledByDefault: true + ); private static readonly DiagnosticDescriptor s_signatureFileError = new( id: DiagId.SignatureFileError.ToIdString(), @@ -42,7 +44,8 @@ public sealed class CsSigAnalyzer : DiagnosticAnalyzer messageFormat: "The .cssig file could not be parsed: {0}", category: "CsSig", defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true); + isEnabledByDefault: true + ); private static readonly DiagnosticDescriptor s_signatureMismatch = new( id: DiagId.SignatureMismatch.ToIdString(), @@ -50,11 +53,17 @@ public sealed class CsSigAnalyzer : DiagnosticAnalyzer messageFormat: "The signature '{0}' is declared in a .cssig file but does not match the project's public API (breaks {1} equivalence)", category: "CsSig", defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true); + isEnabledByDefault: true + ); public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create( - s_missingFromProject, s_missingFromSignature, s_signatureFileError, s_signatureMismatch, CsSigRecognizer.Rule); + s_missingFromProject, + s_missingFromSignature, + s_signatureFileError, + s_signatureMismatch, + CsSigRecognizer.Rule + ); public override void Initialize(AnalysisContext context) { @@ -67,8 +76,10 @@ private static void AnalyzeCompilation(CompilationAnalysisContext context) { var compilation = context.Compilation; - var sigFiles = context.Options.AdditionalFiles - .Where(static f => f.Path.EndsWith(Extension, System.StringComparison.OrdinalIgnoreCase)) + var sigFiles = context + .Options.AdditionalFiles.Where(static f => + f.Path.EndsWith(Extension, System.StringComparison.OrdinalIgnoreCase) + ) .ToImmutableArray(); // Nothing to enforce unless the project declares signatures. When one or more .cssig @@ -78,7 +89,8 @@ private static void AnalyzeCompilation(CompilationAnalysisContext context) return; } - var parseOptions = compilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions + var parseOptions = + compilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions ?? CSharpParseOptions.Default; var sigTrees = new List(sigFiles.Length); @@ -91,17 +103,25 @@ private static void AnalyzeCompilation(CompilationAnalysisContext context) continue; } - var tree = CSharpSyntaxTree.ParseText(text, parseOptions, path: file.Path, cancellationToken: context.CancellationToken); + var tree = CSharpSyntaxTree.ParseText( + text, + parseOptions, + path: file.Path, + cancellationToken: context.CancellationToken + ); foreach (var diagnostic in tree.GetDiagnostics(context.CancellationToken)) { if (diagnostic.Severity == DiagnosticSeverity.Error) { hadParseError = true; - context.ReportDiagnostic(Diagnostic.Create( - s_signatureFileError, - CsSigLocation.ToExternal(diagnostic.Location, file.Path), - diagnostic.GetMessage())); + context.ReportDiagnostic( + Diagnostic.Create( + s_signatureFileError, + CsSigLocation.ToExternal(diagnostic.Location, file.Path), + diagnostic.GetMessage() + ) + ); } } @@ -126,7 +146,8 @@ private static void AnalyzeCompilation(CompilationAnalysisContext context) "__cssig__", sigTrees, compilation.References, - compilation.Options as CSharpCompilationOptions); + compilation.Options as CSharpCompilationOptions + ); var declared = ApiSurface.Collect(sigCompilation.Assembly); var actual = ApiSurface.Collect(compilation.Assembly); @@ -141,30 +162,40 @@ private static void AnalyzeCompilation(CompilationAnalysisContext context) { // Declared in a .cssig file but missing from the project. An add/remove breaks // whichever equivalence is being enforced. - context.ReportDiagnostic(Diagnostic.Create( - s_missingFromProject, - CsSigLocation.ToExternal(pair.Value.Location, sigFiles[0].Path), - pair.Value.Display, - Describe(mode))); + context.ReportDiagnostic( + Diagnostic.Create( + s_missingFromProject, + CsSigLocation.ToExternal(pair.Value.Location, sigFiles[0].Path), + pair.Value.Display, + Describe(mode) + ) + ); continue; } // Present on both sides: the identities match, so compare the equivalence projections // that are active. A common-aspect change differs in both views, yielding a single // diagnostic labelled with both equivalences. - var sourceDiffers = (mode & Equivalence.Source) != 0 + var sourceDiffers = + (mode & Equivalence.Source) != 0 && !Equals(pair.Value.Member.Source, actualEntry.Member.Source); - var binaryDiffers = (mode & Equivalence.Binary) != 0 + var binaryDiffers = + (mode & Equivalence.Binary) != 0 && !Equals(pair.Value.Member.Binary, actualEntry.Member.Binary); if (sourceDiffers || binaryDiffers) { - context.ReportDiagnostic(Diagnostic.Create( - s_signatureMismatch, - CsSigLocation.ToExternal(pair.Value.Location, sigFiles[0].Path), - pair.Value.Display, - Describe( - (sourceDiffers ? Equivalence.Source : 0) | (binaryDiffers ? Equivalence.Binary : 0)))); + context.ReportDiagnostic( + Diagnostic.Create( + s_signatureMismatch, + CsSigLocation.ToExternal(pair.Value.Location, sigFiles[0].Path), + pair.Value.Display, + Describe( + (sourceDiffers ? Equivalence.Source : 0) + | (binaryDiffers ? Equivalence.Binary : 0) + ) + ) + ); } } @@ -174,11 +205,14 @@ private static void AnalyzeCompilation(CompilationAnalysisContext context) context.CancellationToken.ThrowIfCancellationRequested(); if (!declared.ContainsKey(pair.Key)) { - context.ReportDiagnostic(Diagnostic.Create( - s_missingFromSignature, - pair.Value.Location, - pair.Value.Display, - Describe(mode))); + context.ReportDiagnostic( + Diagnostic.Create( + s_missingFromSignature, + pair.Value.Location, + pair.Value.Display, + Describe(mode) + ) + ); } } } @@ -189,9 +223,12 @@ private static void AnalyzeCompilation(CompilationAnalysisContext context) /// private static Equivalence ReadEquivalence(AnalyzerOptions options) { - if (options.AnalyzerConfigOptionsProvider.GlobalOptions - .TryGetValue("build_property.CsSigEquivalence", out var raw) - && !string.IsNullOrWhiteSpace(raw)) + if ( + options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue( + "build_property.CsSigEquivalence", + out var raw + ) && !string.IsNullOrWhiteSpace(raw) + ) { switch (raw.Trim().ToLowerInvariant()) { @@ -208,13 +245,14 @@ private static Equivalence ReadEquivalence(AnalyzerOptions options) return Equivalence.Both; } - private static string Describe(Equivalence equivalence) => equivalence switch - { - Equivalence.Source => "source", - Equivalence.Binary => "binary", - Equivalence.Both => "source and binary", - _ => "no", - }; + private static string Describe(Equivalence equivalence) => + equivalence switch + { + Equivalence.Source => "source", + Equivalence.Binary => "binary", + Equivalence.Both => "source and binary", + _ => "no", + }; } /// The equivalence relation(s) the analyzer enforces between project and signatures. diff --git a/src/CsSig/Analyzer/CsSigRecognizer.cs b/src/CsSig/Analyzer/CsSigRecognizer.cs index 0aff8bf..2d26eb5 100644 --- a/src/CsSig/Analyzer/CsSigRecognizer.cs +++ b/src/CsSig/Analyzer/CsSigRecognizer.cs @@ -39,13 +39,17 @@ internal static class CsSigRecognizer messageFormat: "{0}", category: "CsSig", defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true); + isEnabledByDefault: true + ); /// /// Recognizes against the .cssig grammar, yielding a diagnostic /// for every construct that the grammar does not allow. /// - public static IEnumerable Recognize(SyntaxTree tree, CancellationToken cancellationToken = default) + public static IEnumerable Recognize( + SyntaxTree tree, + CancellationToken cancellationToken = default + ) { var walker = new Walker(tree.FilePath); walker.Visit(tree.GetRoot(cancellationToken)); @@ -60,12 +64,17 @@ private sealed class Walker : CSharpSyntaxWalker public List Diagnostics { get; } = new(); - private void Report(Location location, string message) - => Diagnostics.Add(Diagnostic.Create(Rule, CsSigLocation.ToExternal(location, _path), message)); + private void Report(Location location, string message) => + Diagnostics.Add( + Diagnostic.Create(Rule, CsSigLocation.ToExternal(location, _path), message) + ); - private static bool IsAccessibility(SyntaxKind kind) - => kind is SyntaxKind.PublicKeyword or SyntaxKind.PrivateKeyword - or SyntaxKind.ProtectedKeyword or SyntaxKind.InternalKeyword; + private static bool IsAccessibility(SyntaxKind kind) => + kind + is SyntaxKind.PublicKeyword + or SyntaxKind.PrivateKeyword + or SyntaxKind.ProtectedKeyword + or SyntaxKind.InternalKeyword; /// /// Modifiers allowed on a virtualizable member (method, property, indexer, event): the @@ -109,7 +118,8 @@ private void CheckModifiers(SyntaxTokenList modifiers, params SyntaxKind[] allow Report( modifier.GetLocation(), - $"The '{modifier.ValueText}' modifier does not affect the signature and is not allowed in a .cssig file"); + $"The '{modifier.ValueText}' modifier does not affect the signature and is not allowed in a .cssig file" + ); } } @@ -119,14 +129,16 @@ private void RejectBody(BlockSyntax? body, ArrowExpressionClauseSyntax? expressi { Report( body.GetLocation(), - "Member bodies are not allowed in a .cssig file; signatures declare members without an implementation"); + "Member bodies are not allowed in a .cssig file; signatures declare members without an implementation" + ); } if (expressionBody is not null) { Report( expressionBody.GetLocation(), - "Expression bodies are not allowed in a .cssig file; signatures declare members without an implementation"); + "Expression bodies are not allowed in a .cssig file; signatures declare members without an implementation" + ); } } @@ -134,7 +146,12 @@ public override void VisitClassDeclaration(ClassDeclarationSyntax node) { // 'static'/'abstract'/'sealed' all affect the type's signature (instantiation, // extensibility of protected members, virtual dispatch). - CheckModifiers(node.Modifiers, SyntaxKind.StaticKeyword, SyntaxKind.AbstractKeyword, SyntaxKind.SealedKeyword); + CheckModifiers( + node.Modifiers, + SyntaxKind.StaticKeyword, + SyntaxKind.AbstractKeyword, + SyntaxKind.SealedKeyword + ); base.VisitClassDeclaration(node); } @@ -159,7 +176,12 @@ public override void VisitRecordDeclaration(RecordDeclarationSyntax node) } else { - CheckModifiers(node.Modifiers, SyntaxKind.StaticKeyword, SyntaxKind.AbstractKeyword, SyntaxKind.SealedKeyword); + CheckModifiers( + node.Modifiers, + SyntaxKind.StaticKeyword, + SyntaxKind.AbstractKeyword, + SyntaxKind.SealedKeyword + ); } base.VisitRecordDeclaration(node); @@ -193,7 +215,9 @@ public override void VisitOperatorDeclaration(OperatorDeclarationSyntax node) base.VisitOperatorDeclaration(node); } - public override void VisitConversionOperatorDeclaration(ConversionOperatorDeclarationSyntax node) + public override void VisitConversionOperatorDeclaration( + ConversionOperatorDeclarationSyntax node + ) { CheckModifiers(node.Modifiers, SyntaxKind.StaticKeyword); RejectBody(node.Body, node.ExpressionBody); @@ -244,7 +268,12 @@ public override void VisitFieldDeclaration(FieldDeclarationSyntax node) { // 'static' -> ApiMember.IsStatic; 'const' -> the captured constant value; // 'readonly' -> the field's read-only-ness (observable to external writers). - CheckModifiers(node.Modifiers, SyntaxKind.StaticKeyword, SyntaxKind.ConstKeyword, SyntaxKind.ReadOnlyKeyword); + CheckModifiers( + node.Modifiers, + SyntaxKind.StaticKeyword, + SyntaxKind.ConstKeyword, + SyntaxKind.ReadOnlyKeyword + ); // A field's value is only part of the signature when it is 'const'; any other // initializer is invisible to the comparison. @@ -256,7 +285,8 @@ public override void VisitFieldDeclaration(FieldDeclarationSyntax node) { Report( initializer.GetLocation(), - "A field initializer does not affect the signature and is not allowed in a .cssig file; only 'const' values are part of the signature"); + "A field initializer does not affect the signature and is not allowed in a .cssig file; only 'const' values are part of the signature" + ); } } } diff --git a/src/CsSig/Analyzer/CsSigWriter.cs b/src/CsSig/Analyzer/CsSigWriter.cs index 1df6e48..d30cb8a 100644 --- a/src/CsSig/Analyzer/CsSigWriter.cs +++ b/src/CsSig/Analyzer/CsSigWriter.cs @@ -27,36 +27,34 @@ public static class CsSigWriter globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.OmittedAsContaining, typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, - memberOptions: - SymbolDisplayMemberOptions.IncludeParameters | - SymbolDisplayMemberOptions.IncludeType | - SymbolDisplayMemberOptions.IncludeModifiers | - SymbolDisplayMemberOptions.IncludeAccessibility | - SymbolDisplayMemberOptions.IncludeConstantValue | - SymbolDisplayMemberOptions.IncludeRef | - SymbolDisplayMemberOptions.IncludeExplicitInterface, + memberOptions: SymbolDisplayMemberOptions.IncludeParameters + | SymbolDisplayMemberOptions.IncludeType + | SymbolDisplayMemberOptions.IncludeModifiers + | SymbolDisplayMemberOptions.IncludeAccessibility + | SymbolDisplayMemberOptions.IncludeConstantValue + | SymbolDisplayMemberOptions.IncludeRef + | SymbolDisplayMemberOptions.IncludeExplicitInterface, kindOptions: SymbolDisplayKindOptions.IncludeMemberKeyword, propertyStyle: SymbolDisplayPropertyStyle.ShowReadWriteDescriptor, - parameterOptions: - SymbolDisplayParameterOptions.IncludeType | - SymbolDisplayParameterOptions.IncludeName | - SymbolDisplayParameterOptions.IncludeParamsRefOut | - SymbolDisplayParameterOptions.IncludeExtensionThis | - SymbolDisplayParameterOptions.IncludeDefaultValue, - miscellaneousOptions: - SymbolDisplayMiscellaneousOptions.UseSpecialTypes | - SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | - SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers); + parameterOptions: SymbolDisplayParameterOptions.IncludeType + | SymbolDisplayParameterOptions.IncludeName + | SymbolDisplayParameterOptions.IncludeParamsRefOut + | SymbolDisplayParameterOptions.IncludeExtensionThis + | SymbolDisplayParameterOptions.IncludeDefaultValue, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes + | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier + | SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers + ); // A bare type reference, fully qualified, for return/parameter/underlying types. private static readonly SymbolDisplayFormat s_typeFormat = new( globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.OmittedAsContaining, typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, - miscellaneousOptions: - SymbolDisplayMiscellaneousOptions.UseSpecialTypes | - SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | - SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers); + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes + | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier + | SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers + ); /// Generates the .cssig text for 's public API. public static string Write(Compilation compilation) => Write(compilation.Assembly); @@ -73,7 +71,9 @@ public static class CsSigWriter /// public static string Write(IAssemblySymbol assembly, ISet? topLevelKeys) { - var byNamespace = new SortedDictionary>(StringComparer.Ordinal); + var byNamespace = new SortedDictionary>( + StringComparer.Ordinal + ); foreach (var type in TopLevelTypes(assembly.GlobalNamespace)) { if (!ApiSurface.IsTrackedApi(type)) @@ -86,7 +86,9 @@ public static string Write(IAssemblySymbol assembly, ISet? topLevelKeys) continue; } - var ns = type.ContainingNamespace is { IsGlobalNamespace: false } n ? n.ToDisplayString() : string.Empty; + var ns = type.ContainingNamespace is { IsGlobalNamespace: false } n + ? n.ToDisplayString() + : string.Empty; if (!byNamespace.TryGetValue(ns, out var list)) { byNamespace[ns] = list = new List(); @@ -145,7 +147,9 @@ private static void WriteType(IndentingBuilder builder, INamedTypeSymbol type) if (type.TypeKind == TypeKind.Enum) { - foreach (var field in type.GetMembers().OfType().Where(f => f.HasConstantValue)) + foreach ( + var field in type.GetMembers().OfType().Where(f => f.HasConstantValue) + ) { builder.AppendLine(FormatMember(field)); } @@ -157,7 +161,27 @@ private static void WriteType(IndentingBuilder builder, INamedTypeSymbol type) builder.AppendLine(FormatMember(member)); } - foreach (var nested in Sorted(type.GetMembers().OfType().Where(ApiSurface.IsTrackedApi))) + var nestedTypes = type.GetMembers() + .OfType() + .Where(ApiSurface.IsTrackedApi) + .ToList(); + + // Extension blocks are nested types with an unspeakable name; emit them as + // `extension(Receiver) { ... }` ordered by their header so the output is deterministic. + foreach ( + var extension in nestedTypes + .Where(ExtensionMembers.IsExtension) + .OrderBy(ExtensionHeader, StringComparer.Ordinal) + ) + { + WriteExtension(builder, extension); + } + + foreach ( + var nested in Sorted( + nestedTypes.Where(static t => !ExtensionMembers.IsExtension(t)) + ) + ) { WriteType(builder, nested); } @@ -167,6 +191,45 @@ private static void WriteType(IndentingBuilder builder, INamedTypeSymbol type) builder.AppendLine("}"); } + private static void WriteExtension(IndentingBuilder builder, INamedTypeSymbol extension) + { + builder.AppendLine(ExtensionHeader(extension)); + builder.AppendLine("{"); + builder.Indent(); + + foreach (var member in Sorted(VisibleMembers(extension))) + { + builder.AppendLine(FormatMember(member)); + } + + builder.Dedent(); + builder.AppendLine("}"); + } + + /// The header of an extension block, e.g. extension(int) or + /// extension<T>(T[] source). The receiver is the block's marker type's receiver + /// parameter; its name is emitted only when the source declared one. + private static string ExtensionHeader(INamedTypeSymbol extension) + { + var receiver = ExtensionMembers.Receiver(extension); + var receiverText = receiver is null ? string.Empty : FormatReceiver(receiver); + return $"extension{TypeParameterList(extension)}({receiverText})"; + } + + private static string FormatReceiver(IParameterSymbol receiver) + { + var prefix = receiver.RefKind switch + { + RefKind.Ref => "ref ", + RefKind.Out => "out ", + RefKind.In => "in ", + _ => string.Empty, + }; + + var type = prefix + receiver.Type.ToDisplayString(s_typeFormat); + return receiver.Name.Length == 0 ? type : type + " " + receiver.Name; + } + /// Generates the body-less declaration text of a single member, exactly as it should /// appear inside a type in a .cssig file (no leading indentation, no trailing newline). /// Enum members are rendered as Name = value,. @@ -177,7 +240,8 @@ private static string FormatMember(ISymbol member) return $"{enumField.Name} = {FormatConstant(enumField.ConstantValue)},"; } - var text = member.ToDisplayString(s_memberFormat) + var text = member + .ToDisplayString(s_memberFormat) .Replace("volatile ", string.Empty) .Replace("required ", string.Empty); @@ -207,14 +271,16 @@ private static string TypeHeader(INamedTypeSymbol type) } } - parts.Add(type.TypeKind switch - { - TypeKind.Class => type.IsRecord ? "record" : "class", - TypeKind.Struct => type.IsRecord ? "record struct" : "struct", - TypeKind.Interface => "interface", - TypeKind.Enum => "enum", - _ => "class", - }); + parts.Add( + type.TypeKind switch + { + TypeKind.Class => type.IsRecord ? "record" : "class", + TypeKind.Struct => type.IsRecord ? "record struct" : "struct", + TypeKind.Interface => "interface", + TypeKind.Enum => "enum", + _ => "class", + } + ); parts.Add(type.Name + TypeParameterList(type)); return string.Join(" ", parts); @@ -223,29 +289,36 @@ private static string TypeHeader(INamedTypeSymbol type) private static string DelegateDeclaration(INamedTypeSymbol type) { var invoke = type.DelegateInvokeMethod!; - var @return = (invoke.ReturnsByRef ? "ref " : invoke.ReturnsByRefReadonly ? "ref readonly " : string.Empty) - + invoke.ReturnType.ToDisplayString(s_typeFormat); + var @return = + ( + invoke.ReturnsByRef ? "ref " + : invoke.ReturnsByRefReadonly ? "ref readonly " + : string.Empty + ) + invoke.ReturnType.ToDisplayString(s_typeFormat); return $"{Accessibility(type.DeclaredAccessibility)} delegate {@return} " + $"{type.Name}{TypeParameterList(type)}({FormatParameters(invoke.Parameters)})"; } - private static string TypeParameterList(INamedTypeSymbol type) - => type.TypeParameters.IsEmpty + private static string TypeParameterList(INamedTypeSymbol type) => + type.TypeParameters.IsEmpty ? string.Empty : "<" + string.Join(", ", type.TypeParameters.Select(p => p.Name)) + ">"; - private static string FormatParameters(IEnumerable parameters) - => string.Join(", ", parameters.Select(p => - { - var prefix = p.RefKind switch + private static string FormatParameters(IEnumerable parameters) => + string.Join( + ", ", + parameters.Select(p => { - RefKind.Ref => "ref ", - RefKind.Out => "out ", - RefKind.In => "in ", - _ => string.Empty, - }; - return prefix + p.Type.ToDisplayString(s_typeFormat) + " " + p.Name; - })); + var prefix = p.RefKind switch + { + RefKind.Ref => "ref ", + RefKind.Out => "out ", + RefKind.In => "in ", + _ => string.Empty, + }; + return prefix + p.Type.ToDisplayString(s_typeFormat) + " " + p.Name; + }) + ); /// The members of that should appear in the signature file: /// explicitly declared, externally visible non-type members, excluding accessors (emitted via @@ -259,11 +332,15 @@ private static IEnumerable VisibleMembers(INamedTypeSymbol type) continue; } - if (member is IMethodSymbol + if ( + member is IMethodSymbol { - MethodKind: MethodKind.PropertyGet or MethodKind.PropertySet - or MethodKind.EventAdd or MethodKind.EventRemove, - }) + MethodKind: MethodKind.PropertyGet + or MethodKind.PropertySet + or MethodKind.EventAdd + or MethodKind.EventRemove, + } + ) { continue; } @@ -275,14 +352,15 @@ private static IEnumerable VisibleMembers(INamedTypeSymbol type) } } - private static bool IsVisible(ISymbol member) => member switch - { - // Properties are tracked through their accessors; emit the property if either surfaces. - IPropertySymbol property => - (property.GetMethod is { } getter && ApiSurface.IsTrackedApi(getter)) - || (property.SetMethod is { } setter && ApiSurface.IsTrackedApi(setter)), - _ => ApiSurface.IsTrackedApi(member), - }; + private static bool IsVisible(ISymbol member) => + member switch + { + // Properties are tracked through their accessors; emit the property if either surfaces. + IPropertySymbol property => ( + property.GetMethod is { } getter && ApiSurface.IsTrackedApi(getter) + ) || (property.SetMethod is { } setter && ApiSurface.IsTrackedApi(setter)), + _ => ApiSurface.IsTrackedApi(member), + }; private static IEnumerable TopLevelTypes(INamespaceSymbol root) { @@ -305,8 +383,9 @@ private static IEnumerable TopLevelTypes(INamespaceSymbol root } } - private static IEnumerable Sorted(IEnumerable symbols) where T : ISymbol - => symbols.OrderBy(s => s.ToDisplayString(s_memberFormat), StringComparer.Ordinal); + private static IEnumerable Sorted(IEnumerable symbols) + where T : ISymbol => + symbols.OrderBy(s => s.ToDisplayString(s_memberFormat), StringComparer.Ordinal); /// /// A stable identity for a top-level type — its namespace-qualified name plus generic @@ -316,22 +395,25 @@ private static IEnumerable Sorted(IEnumerable symbols) where T : ISymbo /// public static string TopLevelKey(INamedTypeSymbol type) { - var ns = type.ContainingNamespace is { IsGlobalNamespace: false } n ? n.ToDisplayString() + "." : string.Empty; + var ns = type.ContainingNamespace is { IsGlobalNamespace: false } n + ? n.ToDisplayString() + "." + : string.Empty; var name = type.Arity > 0 ? type.Name + "`" + type.Arity : type.Name; return ns + name; } - private static string Accessibility(Accessibility accessibility) => accessibility switch - { - Microsoft.CodeAnalysis.Accessibility.Public => "public", - Microsoft.CodeAnalysis.Accessibility.Protected => "protected", - Microsoft.CodeAnalysis.Accessibility.ProtectedOrInternal => "protected internal", - Microsoft.CodeAnalysis.Accessibility.ProtectedAndInternal => "private protected", - Microsoft.CodeAnalysis.Accessibility.Internal => "internal", - Microsoft.CodeAnalysis.Accessibility.Private => "private", - _ => "internal", - }; - - private static string FormatConstant(object? value) - => value is null ? "null" : Convert.ToString(value, CultureInfo.InvariantCulture) ?? "null"; + private static string Accessibility(Accessibility accessibility) => + accessibility switch + { + Microsoft.CodeAnalysis.Accessibility.Public => "public", + Microsoft.CodeAnalysis.Accessibility.Protected => "protected", + Microsoft.CodeAnalysis.Accessibility.ProtectedOrInternal => "protected internal", + Microsoft.CodeAnalysis.Accessibility.ProtectedAndInternal => "private protected", + Microsoft.CodeAnalysis.Accessibility.Internal => "internal", + Microsoft.CodeAnalysis.Accessibility.Private => "private", + _ => "internal", + }; + + private static string FormatConstant(object? value) => + value is null ? "null" : Convert.ToString(value, CultureInfo.InvariantCulture) ?? "null"; } diff --git a/src/CsSig/Analyzer/ExtensionMembers.cs b/src/CsSig/Analyzer/ExtensionMembers.cs new file mode 100644 index 0000000..4e75086 --- /dev/null +++ b/src/CsSig/Analyzer/ExtensionMembers.cs @@ -0,0 +1,47 @@ +using Microsoft.CodeAnalysis; + +namespace CsSig; + +/// +/// Helpers for C# "extension" members (extension(Receiver) { ... }). Roslyn models an +/// extension block as a nested type whose is +/// and whose name is an unspeakable, compiler-generated marker +/// derived from the block's contents. The members declared inside the block live on that marker +/// type; the enclosing static class carries only their (implicit) implementations. +/// +internal static class ExtensionMembers +{ + /// + /// The structural name used for an extension marker type in a / + /// . The real metadata name is an unspeakable content hash, so the + /// block is instead identified by this fixed discriminator plus its receiver type. + /// + public const string Name = ""; + + /// Whether is an extension marker type. + public static bool IsExtension(INamedTypeSymbol type) => type.IsExtension; + + /// + /// Whether directly contains any extension block (i.e. it is a static + /// class hosting extension(...) { ... } members). Such a class also carries the implicit + /// implementation methods of those members, which are not part of the signature surface. + /// + public static bool ContainsExtension(INamedTypeSymbol type) + { + foreach (var member in type.GetTypeMembers()) + { + if (member.IsExtension) + { + return true; + } + } + + return false; + } + + /// + /// The receiver parameter of an extension block (e.g. the int in extension(int)), + /// or when it cannot be determined. + /// + public static IParameterSymbol? Receiver(INamedTypeSymbol type) => type.ExtensionParameter; +} diff --git a/src/CsSig/Analyzer/IsExternalInit.cs b/src/CsSig/Analyzer/IsExternalInit.cs index 476a949..2cdc3b1 100644 --- a/src/CsSig/Analyzer/IsExternalInit.cs +++ b/src/CsSig/Analyzer/IsExternalInit.cs @@ -2,7 +2,5 @@ namespace System.Runtime.CompilerServices { // Required so that `record` types and `init` accessors can be used when targeting // netstandard2.0 (which does not ship the IsExternalInit type). - internal static class IsExternalInit - { - } + internal static class IsExternalInit { } } diff --git a/src/CsSig/Analyzer/SignatureModel.cs b/src/CsSig/Analyzer/SignatureModel.cs index 5a8cbc3..176e626 100644 --- a/src/CsSig/Analyzer/SignatureModel.cs +++ b/src/CsSig/Analyzer/SignatureModel.cs @@ -20,15 +20,22 @@ public sealed record Named( string Namespace, TypeRef? ContainingType, string Name, - EqArray TypeArguments) : TypeRef; + EqArray TypeArguments + ) : TypeRef; + public sealed record Array(TypeRef ElementType, int Rank) : TypeRef; + public sealed record Pointer(TypeRef ElementType) : TypeRef; + public sealed record TypeParameter(int Ordinal, bool IsMethodTypeParameter) : TypeRef; + public sealed record FunctionPointer( SignatureCallingConvention CallingConvention, EqArray CallingConventionTypes, ParamKey Return, - EqArray Parameters) : TypeRef; + EqArray Parameters + ) : TypeRef; + public sealed record Dynamic : TypeRef { public static readonly Dynamic Instance = new(); @@ -50,7 +57,8 @@ public static TypeRef From(ITypeSymbol type) case ITypeParameterSymbol typeParameter: return new TypeParameter( typeParameter.Ordinal, - typeParameter.TypeParameterKind == TypeParameterKind.Method); + typeParameter.TypeParameterKind == TypeParameterKind.Method + ); case IDynamicTypeSymbol: return Dynamic.Instance; @@ -59,34 +67,61 @@ public static TypeRef From(ITypeSymbol type) { var signature = functionPointer.Signature; var callingConventionTypes = EqArray.From( - signature.UnmanagedCallingConventionTypes.Select(From)); + signature.UnmanagedCallingConventionTypes.Select(From) + ); var @return = new ParamKey(From(signature.ReturnType), signature.RefKind); var parameters = EqArray.From( - signature.Parameters.Select(p => new ParamKey(From(p.Type), p.RefKind))); + signature.Parameters.Select(p => new ParamKey(From(p.Type), p.RefKind)) + ); return new FunctionPointer( - signature.CallingConvention, callingConventionTypes, @return, parameters); + signature.CallingConvention, + callingConventionTypes, + @return, + parameters + ); } case INamedTypeSymbol named: - var container = named.ContainingType is { } containingType ? From(containingType) : null; - var @namespace = container is null ? NamespaceName(named.ContainingNamespace) : string.Empty; + var container = named.ContainingType is { } containingType + ? From(containingType) + : null; + var @namespace = container is null + ? NamespaceName(named.ContainingNamespace) + : string.Empty; + + if (ExtensionMembers.IsExtension(named)) + { + // An extension block's metadata name is an unspeakable content hash that depends + // on its members, so two compilations agree only when every member agrees. + // Identify it structurally by its receiver type instead, so members are paired + // by the receiver they extend, independent of their sibling members. + var receiver = ExtensionMembers.Receiver(named); + var receiverArgs = receiver is null + ? default + : EqArray.From(new[] { From(receiver.Type) }); + return new Named(@namespace, container, ExtensionMembers.Name, receiverArgs); + } + return new Named( @namespace, container, named.Name, - EqArray.From(named.TypeArguments.Select(From))); + EqArray.From(named.TypeArguments.Select(From)) + ); default: // The ITypeSymbol hierarchy above is exhaustive (array, pointer, type parameter, // dynamic, function pointer, named/error). Anything else cannot be modeled // structurally, so fail loudly rather than invent a comparison. throw new ArgumentException( - $"Cannot build a type reference from type kind '{type.TypeKind}'.", nameof(type)); + $"Cannot build a type reference from type kind '{type.TypeKind}'.", + nameof(type) + ); } } - private static string NamespaceName(INamespaceSymbol? @namespace) - => @namespace is null || @namespace.IsGlobalNamespace + private static string NamespaceName(INamespaceSymbol? @namespace) => + @namespace is null || @namespace.IsGlobalNamespace ? string.Empty : @namespace.ToDisplayString(); } @@ -228,7 +263,11 @@ private static void Walk(ITypeSymbol type, ImmutableArray.Builder builder) /// ref readonly vs in), and the nullable annotations of its type. None of these change /// the binary calling convention. /// -internal readonly record struct SourceParam(string Name, ParamModifiers Modifiers, Nullability Nullability); +internal readonly record struct SourceParam( + string Name, + ParamModifiers Modifiers, + Nullability Nullability +); /// /// The identity of an API member: the tuple by which two members from different compilations are @@ -240,7 +279,8 @@ internal sealed record MemberIdentity( TypeRef? ContainingType, string Name, int Arity, - EqArray Parameters); + EqArray Parameters +); /// /// The aspects of a type declaration observable to every consumer (source or binary): @@ -270,7 +310,10 @@ private SourceMember() { } public sealed record Type(CommonTypeAspects Common) : SourceMember; public sealed record Method( - CommonMethodAspects Common, Nullability ReturnNullability, EqArray Parameters) : SourceMember; + CommonMethodAspects Common, + Nullability ReturnNullability, + EqArray Parameters + ) : SourceMember; public sealed record Field(CommonFieldAspects Common, Nullability Nullability) : SourceMember; @@ -307,9 +350,10 @@ internal sealed record ApiMember(MemberIdentity Identity, SourceMember Source, B public static ApiMember From(ISymbol symbol) { var containingType = symbol.ContainingType is { } type ? TypeRef.From(type) : null; - var @namespace = containingType is null && symbol.ContainingNamespace is { IsGlobalNamespace: false } ns - ? ns.ToDisplayString() - : string.Empty; + var @namespace = + containingType is null && symbol.ContainingNamespace is { IsGlobalNamespace: false } ns + ? ns.ToDisplayString() + : string.Empty; // Every flag bit except a field's ReadOnly/HasConstantValue is reported generically by the // symbol: types expose IsAbstract/IsSealed, members expose virtuality, and the rest are @@ -320,52 +364,99 @@ public static ApiMember From(ISymbol symbol) { case INamedTypeSymbol named: { + // Extension blocks share an unspeakable empty name; key them by their receiver so + // two blocks that extend different receivers are distinct and members never collide. + var isExtension = ExtensionMembers.IsExtension(named); + var name = isExtension ? ExtensionMembers.Name : named.Name; + var typeParameters = isExtension ? ExtensionReceiverKey(named) : default; var identity = new MemberIdentity( - ApiMemberKind.Type, @namespace, containingType, named.Name, named.Arity, default); + ApiMemberKind.Type, + @namespace, + containingType, + name, + named.Arity, + typeParameters + ); var common = new CommonTypeAspects(flags); - return new ApiMember(identity, new SourceMember.Type(common), new BinaryMember.Type(common)); + return new ApiMember( + identity, + new SourceMember.Type(common), + new BinaryMember.Type(common) + ); } case IMethodSymbol method: { var keys = EqArray.From( - method.Parameters.Select(p => new ParamKey(TypeRef.From(p.Type), BinaryRefKind(p.RefKind)))); + method.Parameters.Select(p => new ParamKey( + TypeRef.From(p.Type), + BinaryRefKind(p.RefKind) + )) + ); var parameters = EqArray.From( - method.Parameters.Select(p => new SourceParam(p.Name, ParamModifiersFrom(p), Nullability.Of(p.Type)))); + method.Parameters.Select(p => new SourceParam( + p.Name, + ParamModifiersFrom(p), + Nullability.Of(p.Type) + )) + ); var identity = new MemberIdentity( - ApiMemberKind.Method, @namespace, containingType, method.Name, method.Arity, keys); + ApiMemberKind.Method, + @namespace, + containingType, + method.Name, + method.Arity, + keys + ); var common = new CommonMethodAspects( TypeRef.From(method.ReturnType), - flags | (method.IsReadOnly ? MemberFlags.ReadOnly : MemberFlags.None)); + flags | (method.IsReadOnly ? MemberFlags.ReadOnly : MemberFlags.None) + ); return new ApiMember( identity, new SourceMember.Method(common, Nullability.Of(method.ReturnType), parameters), - new BinaryMember.Method(common)); + new BinaryMember.Method(common) + ); } case IFieldSymbol field: { var identity = new MemberIdentity( - ApiMemberKind.Field, @namespace, containingType, field.Name, Arity: 0, default); - flags |= (field.IsReadOnly ? MemberFlags.ReadOnly : MemberFlags.None) + ApiMemberKind.Field, + @namespace, + containingType, + field.Name, + Arity: 0, + default + ); + flags |= + (field.IsReadOnly ? MemberFlags.ReadOnly : MemberFlags.None) | (field.HasConstantValue ? MemberFlags.HasConstantValue : MemberFlags.None); var common = new CommonFieldAspects(TypeRef.From(field.Type), flags); var constant = field.HasConstantValue ? FormatConstant(field.ConstantValue) : null; return new ApiMember( identity, new SourceMember.Field(common, Nullability.Of(field.Type)), - new BinaryMember.Field(common, constant)); + new BinaryMember.Field(common, constant) + ); } case IEventSymbol @event: { var identity = new MemberIdentity( - ApiMemberKind.Event, @namespace, containingType, @event.Name, Arity: 0, default); + ApiMemberKind.Event, + @namespace, + containingType, + @event.Name, + Arity: 0, + default + ); var common = new CommonEventAspects(TypeRef.From(@event.Type), flags); return new ApiMember( identity, new SourceMember.Event(common, Nullability.Of(@event.Type)), - new BinaryMember.Event(common)); + new BinaryMember.Event(common) + ); } default: @@ -373,35 +464,51 @@ public static ApiMember From(ISymbol symbol) // IsTrackedApi/GetApiMembers). Any other kind cannot be credibly modeled or // compared, so fail loudly rather than synthesize a meaningless member. throw new ArgumentException( - $"Cannot build an API member from symbol kind '{symbol.Kind}'.", nameof(symbol)); + $"Cannot build an API member from symbol kind '{symbol.Kind}'.", + nameof(symbol) + ); } } - private static MemberFlags FlagsFrom(ISymbol symbol) - => (symbol.IsStatic ? MemberFlags.Static : MemberFlags.None) - | (symbol.IsVirtual ? MemberFlags.Virtual : MemberFlags.None) - | (symbol.IsAbstract ? MemberFlags.Abstract : MemberFlags.None) - | (symbol.IsOverride ? MemberFlags.Override : MemberFlags.None) - | (symbol.IsSealed ? MemberFlags.Sealed : MemberFlags.None); - - private static ParamModifiers ParamModifiersFrom(IParameterSymbol parameter) - => (parameter.IsParams ? ParamModifiers.Params : ParamModifiers.None) - | (parameter.IsThis ? ParamModifiers.This : ParamModifiers.None) - | (parameter.HasExplicitDefaultValue ? ParamModifiers.Optional : ParamModifiers.None) - | (parameter.RefKind == RefReadOnlyParameter ? ParamModifiers.RefReadOnly : ParamModifiers.None); - - // RefKind.RefReadOnlyParameter (C# 12) is not defined in the Roslyn baseline this analyzer - // compiles against, but the host compiler reports it at runtime. Reference it by its numeric - // value so the analyzer keeps targeting the older Roslyn version. - private const RefKind RefReadOnlyParameter = (RefKind)4; + private static MemberFlags FlagsFrom(ISymbol symbol) => + (symbol.IsStatic ? MemberFlags.Static : MemberFlags.None) + | (symbol.IsVirtual ? MemberFlags.Virtual : MemberFlags.None) + | (symbol.IsAbstract ? MemberFlags.Abstract : MemberFlags.None) + | (symbol.IsOverride ? MemberFlags.Override : MemberFlags.None) + | (symbol.IsSealed ? MemberFlags.Sealed : MemberFlags.None); + + private static ParamModifiers ParamModifiersFrom(IParameterSymbol parameter) => + (parameter.IsParams ? ParamModifiers.Params : ParamModifiers.None) + | (parameter.IsThis ? ParamModifiers.This : ParamModifiers.None) + | (parameter.HasExplicitDefaultValue ? ParamModifiers.Optional : ParamModifiers.None) + | ( + parameter.RefKind == RefKind.RefReadOnlyParameter + ? ParamModifiers.RefReadOnly + : ParamModifiers.None + ); // `in` and `ref readonly` parameters share the same binary calling convention (both an // `in`-flagged byref with a `modreq(InAttribute)`); `ref readonly` only adds a source-level // `RequiresLocationAttribute`. Identity therefore pairs them, and the source difference is // carried by ParamModifiers.RefReadOnly so it surfaces as a source-only modification. - private static RefKind BinaryRefKind(RefKind refKind) - => refKind == RefReadOnlyParameter ? RefKind.In : refKind; + private static RefKind BinaryRefKind(RefKind refKind) => + refKind == RefKind.RefReadOnlyParameter ? RefKind.In : refKind; + + /// + /// The identity contribution of an extension block: its receiver parameter, encoded as a single + /// , so that distinguishes blocks by the + /// receiver they extend (the receiver may reference the block's own type parameters). + /// + private static EqArray ExtensionReceiverKey(INamedTypeSymbol extension) + { + var receiver = ExtensionMembers.Receiver(extension); + return receiver is null + ? default + : EqArray.From( + new[] { new ParamKey(TypeRef.From(receiver.Type), BinaryRefKind(receiver.RefKind)) } + ); + } - private static string FormatConstant(object? value) - => value is null ? "null" : Convert.ToString(value, CultureInfo.InvariantCulture) ?? "null"; + private static string FormatConstant(object? value) => + value is null ? "null" : Convert.ToString(value, CultureInfo.InvariantCulture) ?? "null"; } diff --git a/src/CsSig/Analyzer/StaticCs.CsSig.Analyzers.csproj b/src/CsSig/Analyzer/StaticCs.CsSig.Analyzers.csproj index 25818b7..7f2c49e 100644 --- a/src/CsSig/Analyzer/StaticCs.CsSig.Analyzers.csproj +++ b/src/CsSig/Analyzer/StaticCs.CsSig.Analyzers.csproj @@ -1,5 +1,4 @@ - netstandard2.0 enable @@ -12,9 +11,9 @@ - + + - diff --git a/src/CsSig/Analyzer/SymbolVisibility.cs b/src/CsSig/Analyzer/SymbolVisibility.cs index 7b2f82e..8dcb036 100644 --- a/src/CsSig/Analyzer/SymbolVisibility.cs +++ b/src/CsSig/Analyzer/SymbolVisibility.cs @@ -52,8 +52,8 @@ public static SymbolVisibility GetResultantVisibility(this ISymbol symbol) visibility = SymbolVisibility.Internal; break; - // For anything else (Public, Protected, ProtectedOrInternal), the - // symbol stays at the level we've gotten so far. + // For anything else (Public, Protected, ProtectedOrInternal), the + // symbol stays at the level we've gotten so far. } current = current.ContainingSymbol; diff --git a/src/CsSig/Analyzer/build/StaticCS.CsSig.props b/src/CsSig/Analyzer/build/StaticCS.CsSig.props index 2604cf4..41404c2 100644 --- a/src/CsSig/Analyzer/build/StaticCS.CsSig.props +++ b/src/CsSig/Analyzer/build/StaticCS.CsSig.props @@ -1,12 +1,13 @@ - - + StaticCS.CsSig - 0.1.0 + 0.2.0 agocke true BSD-3-Clause @@ -29,7 +28,11 @@ - + @@ -39,10 +42,23 @@ separate assembly. The host (the IDE / compiler) supplies the Microsoft.CodeAnalysis.* assemblies. --> - - - + + + - diff --git a/test/test/ClosedTests.cs b/test/test/ClosedTests.cs index 529c5aa..1fe1a75 100644 --- a/test/test/ClosedTests.cs +++ b/test/test/ClosedTests.cs @@ -4,10 +4,9 @@ using System.IO; using System.Runtime.CompilerServices; using System.Threading.Tasks; -using Microsoft.CodeAnalysis.CSharp.Testing; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Testing; -using Microsoft.CodeAnalysis.Testing.Verifiers; +using Microsoft.CodeAnalysis.CSharp.Testing; using Xunit; namespace StaticCs.Tests; @@ -343,11 +342,11 @@ await VerifyDiagnostics( private static readonly DiagnosticResult ClosedEnumConversion = CSharpAnalyzerVerifier< EnumClosedConversionAnalyzer, - XUnitVerifier + DefaultVerifier >.Diagnostic(DiagId.ClosedEnumConversion.ToIdString()); private static readonly DiagnosticResult ClassOrRecordMustBeClosed = CSharpAnalyzerVerifier< ClosedDeclarationChecker, - XUnitVerifier + DefaultVerifier >.Diagnostic(DiagId.ClassOrRecordMustBeClosed.ToIdString()); private Task VerifyDiagnostics(string src, params DiagnosticResult[] expected) diff --git a/test/test/CsSigTests.cs b/test/test/CsSigTests.cs index ba17761..41b1b59 100644 --- a/test/test/CsSigTests.cs +++ b/test/test/CsSigTests.cs @@ -576,6 +576,95 @@ public sealed class Nested """); } + [Fact] + public async Task RoundTripExtensionMembers() + { + // C# 14 extension blocks: each `extension(Receiver) { ... }` is modelled by Roslyn as a + // nested type with an unspeakable name. The writer must emit it as an `extension(...)` block + // (not a nameless `class`), and the round-trip must produce no diagnostics. + await AssertRoundTripsAsync(""" + namespace N + { + public static class Ext + { + extension(int[]) + { + public static string Describe => "ints"; + } + extension(T[] source) + { + public int Count2 => source.Length; + public bool TryFirst(out T value) { value = default!; return false; } + public static T[] Empty => System.Array.Empty(); + } + } + } + """, nullable: true, languageVersion: LanguageVersion.Preview); + } + + [Fact] + public async Task ExtensionMemberMissingFromProjectReported() + { + var source = """ + namespace N; + public static class Ext + { + extension(T[] source) + { + public int Count2 => source.Length; + } + } + """; + var sig = """ + namespace N + { + public static class Ext + { + extension(T[] source) + { + public int Count2 { get; } + public static int Bogus { get; } + } + } + } + """; + // `Bogus` is declared in the signature but absent from the project: exactly one report. + var diagnostic = Assert.Single(await RunPreviewAsync(source, sig)); + Assert.Equal("CSSIG001", diagnostic.Id); + } + + [Fact] + public async Task ExtensionMemberMissingFromSignatureReported() + { + var source = """ + namespace N; + public static class Ext + { + extension(T[] source) + { + public int Count2 => source.Length; + public static T[] Empty => System.Array.Empty(); + } + } + """; + var sig = """ + namespace N + { + public static class Ext + { + extension(T[] source) + { + public int Count2 { get; } + } + } + } + """; + // `Empty` exists in the project but is not declared: exactly one report (no double-count + // from the implicit implementation method the compiler synthesises on the static class). + var diagnostic = Assert.Single(await RunPreviewAsync(source, sig)); + Assert.Equal("CSSIG002", diagnostic.Id); + } + [Fact] public async Task RoundTripFunctionPointerAndVolatile() { @@ -593,7 +682,8 @@ public unsafe class C /// Generates a .cssig from and asserts that feeding it /// back through the analyzer reports no diagnostics. - private static async Task AssertRoundTripsAsync(string source, bool nullable = false) + private static async Task AssertRoundTripsAsync( + string source, bool nullable = false, LanguageVersion languageVersion = LanguageVersion.Default) { var references = await ReferenceAssemblies.Net.Net60.ResolveAsync(LanguageNames.CSharp, CancellationToken.None); var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true); @@ -604,14 +694,12 @@ private static async Task AssertRoundTripsAsync(string source, bool nullable = f var compilation = CSharpCompilation.Create( "TestProject", - new[] { CSharpSyntaxTree.ParseText(source, path: "Test.cs") }, + new[] { CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(languageVersion), path: "Test.cs") }, references, compilationOptions); var generated = CsSigWriter.Write(compilation); - var diagnostics = nullable - ? await RunNullableAsync(source, generated) - : await RunAsync(source, generated); + var diagnostics = await RunCoreAsync(source, equivalence: null, nullable, languageVersion, generated); Assert.Empty(diagnostics); } @@ -1082,24 +1170,28 @@ private sealed class CollectedDiagnosticProvider : FixAllContext.DiagnosticProvi } private static Task> RunAsync(string source, params string[] signatureFiles) - => RunCoreAsync(source, equivalence: null, nullable: false, signatureFiles); + => RunCoreAsync(source, equivalence: null, nullable: false, LanguageVersion.Default, signatureFiles); + + private static Task> RunPreviewAsync(string source, params string[] signatureFiles) + => RunCoreAsync(source, equivalence: null, nullable: true, LanguageVersion.Preview, signatureFiles); private static Task> RunNullableAsync(string source, params string[] signatureFiles) - => RunCoreAsync(source, equivalence: null, nullable: true, signatureFiles); + => RunCoreAsync(source, equivalence: null, nullable: true, LanguageVersion.Default, signatureFiles); private static Task> RunWithEquivalenceAsync( string source, string? equivalence, params string[] signatureFiles) - => RunCoreAsync(source, equivalence, nullable: false, signatureFiles); + => RunCoreAsync(source, equivalence, nullable: false, LanguageVersion.Default, signatureFiles); private static Task> RunNullableWithEquivalenceAsync( string source, string? equivalence, params string[] signatureFiles) - => RunCoreAsync(source, equivalence, nullable: true, signatureFiles); + => RunCoreAsync(source, equivalence, nullable: true, LanguageVersion.Default, signatureFiles); private static async Task> RunCoreAsync( - string source, string? equivalence, bool nullable, params string[] signatureFiles) + string source, string? equivalence, bool nullable, LanguageVersion languageVersion, params string[] signatureFiles) { var references = await ReferenceAssemblies.Net.Net60.ResolveAsync(LanguageNames.CSharp, CancellationToken.None); + var parseOptions = new CSharpParseOptions(languageVersion); var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true); if (nullable) { @@ -1108,7 +1200,7 @@ private static async Task> RunCoreAsync( var compilation = CSharpCompilation.Create( "TestProject", - new[] { CSharpSyntaxTree.ParseText(source, path: "Test.cs") }, + new[] { CSharpSyntaxTree.ParseText(source, parseOptions, path: "Test.cs") }, references, compilationOptions); diff --git a/test/test/SuppressorTest.cs b/test/test/SuppressorTest.cs index 28576e3..6da5602 100644 --- a/test/test/SuppressorTest.cs +++ b/test/test/SuppressorTest.cs @@ -5,11 +5,10 @@ using Microsoft.CodeAnalysis.CSharp.Testing; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Testing; -using Microsoft.CodeAnalysis.Testing.Verifiers; namespace StaticCs.Tests; -internal class SuppressorTest : CSharpAnalyzerTest +internal class SuppressorTest : CSharpAnalyzerTest where TAnalyzer : DiagnosticAnalyzer, new() { public CSharpCompilationOptions CompilationOptions { get; private init; } = diff --git a/test/test/test.csproj b/test/test/test.csproj index 4b9fe94..51f4c22 100644 --- a/test/test/test.csproj +++ b/test/test/test.csproj @@ -11,8 +11,9 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - - + + +