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
-
-
+
+
+